API Documentation

Data access

Gas

Bases: object

Source code in src/aitana/whakaari.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
class Gas(object):

    def __init__(self, start_date: datetime, end_date: datetime):
        self.start_date = pd.to_datetime(start_date, utc=True)
        self.end_date = pd.to_datetime(end_date, utc=True)

    @cache_dataframe()
    def so2(self, fuse: bool = False, smooth: bool = True) -> pd.DataFrame:
        """
        Retrieve SO2 gas flux for Whakaari. If fuse is True, the returned values
        will be a fusion of airborne and scandoas SO2 gas flux measurements. Otherwise,
        the returned values will be the airborne SO2 gas flux measurements from cospec
        if available and contouring otherwise.

        Parameters
        ----------
        fuse : bool
            If True, the returned values will be a fusion of airborne and scandoas
            SO2 gas flux measurements using a Kalman filter.
        smooth : bool
            If True, the returned values will be smoothed using a Kalman smoother.

        Returns
        -------
        pd.DataFrame
            Dataframe with SO2 gas flux values.
        """
        dataframes = {}

        tmp_df = []
        for method in ["cospec", "contouring", "flyspec", "mobile-doas"]:
            try:
                df = tilde_request(start_date=datetime(2000, 1, 1),
                                   end_date=self.end_date,
                                   domain="manualcollect",
                                   name="plume-SO2-gasflux",
                                   method=method,
                                   station="WI000", sensor="MC01")
            except ValueError:
                continue
            if len(df) > 0:
                tmp_df.append(df)
        if len(tmp_df) == 0:
            df = pd.DataFrame()
        else:
            df = tmp_df[0]
            if len(tmp_df) > 1:
                for df1 in tmp_df[1:]:
                    df = df.combine_first(df1)

        if not df.empty:
            dataframes['WI000'] = df

        if not fuse:
            return df * 86.4

        # Note: north-east point (WID01) is reporting consistently too low values
        for station in ["WID01", "WID02"]:
            try:
                df_ = tilde_request(start_date=datetime(2000, 1, 1),
                                    end_date=self.end_date,
                                    domain="scandoas",
                                    name="gasflux",
                                    method="reviewed",
                                    station=station,
                                    sensor="01")
            except ValueError:
                continue
            else:
                dataframes[station] = df_

        startdate = np.array([df.index.min()
                             for df in list(dataframes.values())]).min()
        enddate = np.array([df.index.max()
                           for df in list(dataframes.values())]).max()
        dates = pd.date_range(startdate, enddate, freq="1D")
        gasdata = {}
        interval = "1D"
        for key, df in dataframes.items():
            gasdata[key] = (
                df["obs"].groupby(pd.Grouper(freq=interval)
                                  ).median().reindex(dates).values
            )

        gasdata = pd.DataFrame(gasdata, index=dates)

        # define a covariance matrix for the observations that gives cospec values
        # a higher weight than mdoas values
        obs_cov = np.diag(np.array([5., 200., 20.])**2)
        model = SO2FusionModel(measurements=gasdata, initial_state=np.array([0]),
                               initial_cov=np.diag([[1]]),
                               obs_cov=obs_cov, k_states=1, k_posdef=1)
        # Set initial parameters for process noise
        initial_params = [0.5]
        # Fit the model
        result = model.fit(start_params=initial_params, disp=False)

        # Get the smoothed state estimates (filtered values)
        filtered_state = result.filtered_state[0]
        smoothed_state = result.smoothed_state[0]

        mean_trace = filtered_state
        error = np.sqrt(result.filtered_state_cov[0, 0])
        if smooth:
            mean_trace = smoothed_state
            error = np.sqrt(result.smoothed_state_cov[0, 0])
        so2df = pd.DataFrame(
            {"obs": mean_trace, "err": error}, index=gasdata.index)
        so2df.index.name = "dt"
        return so2df * 86.4

    @cache_dataframe()
    def co2(self) -> pd.DataFrame:
        df = tilde_request(start_date=datetime(2000, 1, 1),
                           end_date=self.end_date,
                           domain="manualcollect",
                           name="plume-CO2-gasflux",
                           method="contouring",
                           station="WI000", sensor="MC01")
        return df * 86.4

    @cache_dataframe()
    def h2s(self) -> pd.DataFrame:
        df = tilde_request(start_date=datetime(2000, 1, 1),
                           end_date=self.end_date,
                           domain="manualcollect",
                           name="plume-H2S-gasflux",
                           method="contouring",
                           station="WI000", sensor="MC01")
        return df * 86.4

so2(fuse=False, smooth=True)

Retrieve SO2 gas flux for Whakaari. If fuse is True, the returned values will be a fusion of airborne and scandoas SO2 gas flux measurements. Otherwise, the returned values will be the airborne SO2 gas flux measurements from cospec if available and contouring otherwise.

Parameters:
  • fuse (bool, default: False ) –

    If True, the returned values will be a fusion of airborne and scandoas SO2 gas flux measurements using a Kalman filter.

  • smooth (bool, default: True ) –

    If True, the returned values will be smoothed using a Kalman smoother.

Returns:
  • DataFrame

    Dataframe with SO2 gas flux values.

Source code in src/aitana/whakaari.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@cache_dataframe()
def so2(self, fuse: bool = False, smooth: bool = True) -> pd.DataFrame:
    """
    Retrieve SO2 gas flux for Whakaari. If fuse is True, the returned values
    will be a fusion of airborne and scandoas SO2 gas flux measurements. Otherwise,
    the returned values will be the airborne SO2 gas flux measurements from cospec
    if available and contouring otherwise.

    Parameters
    ----------
    fuse : bool
        If True, the returned values will be a fusion of airborne and scandoas
        SO2 gas flux measurements using a Kalman filter.
    smooth : bool
        If True, the returned values will be smoothed using a Kalman smoother.

    Returns
    -------
    pd.DataFrame
        Dataframe with SO2 gas flux values.
    """
    dataframes = {}

    tmp_df = []
    for method in ["cospec", "contouring", "flyspec", "mobile-doas"]:
        try:
            df = tilde_request(start_date=datetime(2000, 1, 1),
                               end_date=self.end_date,
                               domain="manualcollect",
                               name="plume-SO2-gasflux",
                               method=method,
                               station="WI000", sensor="MC01")
        except ValueError:
            continue
        if len(df) > 0:
            tmp_df.append(df)
    if len(tmp_df) == 0:
        df = pd.DataFrame()
    else:
        df = tmp_df[0]
        if len(tmp_df) > 1:
            for df1 in tmp_df[1:]:
                df = df.combine_first(df1)

    if not df.empty:
        dataframes['WI000'] = df

    if not fuse:
        return df * 86.4

    # Note: north-east point (WID01) is reporting consistently too low values
    for station in ["WID01", "WID02"]:
        try:
            df_ = tilde_request(start_date=datetime(2000, 1, 1),
                                end_date=self.end_date,
                                domain="scandoas",
                                name="gasflux",
                                method="reviewed",
                                station=station,
                                sensor="01")
        except ValueError:
            continue
        else:
            dataframes[station] = df_

    startdate = np.array([df.index.min()
                         for df in list(dataframes.values())]).min()
    enddate = np.array([df.index.max()
                       for df in list(dataframes.values())]).max()
    dates = pd.date_range(startdate, enddate, freq="1D")
    gasdata = {}
    interval = "1D"
    for key, df in dataframes.items():
        gasdata[key] = (
            df["obs"].groupby(pd.Grouper(freq=interval)
                              ).median().reindex(dates).values
        )

    gasdata = pd.DataFrame(gasdata, index=dates)

    # define a covariance matrix for the observations that gives cospec values
    # a higher weight than mdoas values
    obs_cov = np.diag(np.array([5., 200., 20.])**2)
    model = SO2FusionModel(measurements=gasdata, initial_state=np.array([0]),
                           initial_cov=np.diag([[1]]),
                           obs_cov=obs_cov, k_states=1, k_posdef=1)
    # Set initial parameters for process noise
    initial_params = [0.5]
    # Fit the model
    result = model.fit(start_params=initial_params, disp=False)

    # Get the smoothed state estimates (filtered values)
    filtered_state = result.filtered_state[0]
    smoothed_state = result.smoothed_state[0]

    mean_trace = filtered_state
    error = np.sqrt(result.filtered_state_cov[0, 0])
    if smooth:
        mean_trace = smoothed_state
        error = np.sqrt(result.smoothed_state_cov[0, 0])
    so2df = pd.DataFrame(
        {"obs": mean_trace, "err": error}, index=gasdata.index)
    so2df.index.name = "dt"
    return so2df * 86.4

Seismicity

Bases: object

Source code in src/aitana/whakaari.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
class Seismicity(object):

    def __init__(self, start_date: datetime, end_date: datetime):
        self.start_date = pd.to_datetime(start_date, utc=True)
        self.end_date = pd.to_datetime(end_date, utc=True)

    def rsam(self) -> pd.DataFrame:
        """
        Load RSAM values NZ.WIZ.10.HHZ.The first time you call
        this it will download the data from Zenodo and store it
        locally.

        Returns:
        --------
            :param df: Dataframe with RSAM values.
            :type df: :class:`pandas.DataFrame`
        """
        rsam_fn = get_data('data/RSAM_NZ.WIZ.10.HHZ.csv')
        if not os.path.isfile(rsam_fn):
            # download the data from zenodo
            print('Downloading RSAM data from Zenodo')
            url = "https://zenodo.org/records/14759090/files/RSAM_NZ.WIZ.10.HHZ.csv"
            response = requests.get(url)
            if response.status_code == 200:
                with open(rsam_fn, "wb") as f:
                    f.write(response.content)
                print('Successfully downloaded RSAM data from Zenodo')
            else:
                print('Failed to download RSAM data from Zenodo')

        df = pd.read_csv(rsam_fn, skiprows=1, parse_dates=True, names=['dt', 'obs'], index_col=0,
                         date_format='ISO8601')
        if self.end_date.tzinfo is not None:
            df.index = df.index.tz_localize(timezone.utc)
        df = df[df.index <= str(self.end_date)]
        df = df[df.index >= str(self.start_date)]
        return df

    @cache_dataframe()
    def quakes(self):
        center_point = "177.186833+-37.523118"
        return wfs_request(self.start_date, self.end_date, radius=20000,
                           center_point=center_point, maxdepth=30)

rsam()

Load RSAM values NZ.WIZ.10.HHZ.The first time you call this it will download the data from Zenodo and store it locally.

Returns:

:param df: Dataframe with RSAM values.
:type df: :class:`pandas.DataFrame`
Source code in src/aitana/whakaari.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def rsam(self) -> pd.DataFrame:
    """
    Load RSAM values NZ.WIZ.10.HHZ.The first time you call
    this it will download the data from Zenodo and store it
    locally.

    Returns:
    --------
        :param df: Dataframe with RSAM values.
        :type df: :class:`pandas.DataFrame`
    """
    rsam_fn = get_data('data/RSAM_NZ.WIZ.10.HHZ.csv')
    if not os.path.isfile(rsam_fn):
        # download the data from zenodo
        print('Downloading RSAM data from Zenodo')
        url = "https://zenodo.org/records/14759090/files/RSAM_NZ.WIZ.10.HHZ.csv"
        response = requests.get(url)
        if response.status_code == 200:
            with open(rsam_fn, "wb") as f:
                f.write(response.content)
            print('Successfully downloaded RSAM data from Zenodo')
        else:
            print('Failed to download RSAM data from Zenodo')

    df = pd.read_csv(rsam_fn, skiprows=1, parse_dates=True, names=['dt', 'obs'], index_col=0,
                     date_format='ISO8601')
    if self.end_date.tzinfo is not None:
        df.index = df.index.tz_localize(timezone.utc)
    df = df[df.index <= str(self.end_date)]
    df = df[df.index >= str(self.start_date)]
    return df

eruptions(min_size=0, dec_interval=None)

This function loads the eruption catalogue for White Island and declusters it.

Parameters:
  • eruption_scale (int) –

    The minimum eruption scale (between 0 and 4) to use. This is currently ignored as the White Island catalogue does not contain eruption scale.

  • dec_interval (str, default: None ) –

    The declustering interval, which is the minimum distance in time between any two eruptions.

  • datadir (str) –

    Path that contains the catalogue file in csv format.

Returns:
  • :class:`pandas.DataFrame`

    Declustered eruption catalogue

Source code in src/aitana/whakaari.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
@cache_dataframe()
def eruptions(min_size: int = 0, dec_interval: str = None) -> pd.DataFrame:
    """
    This function loads the eruption catalogue for White Island and
    declusters it.

    :param eruption_scale: The minimum eruption scale (between 0 and 4)
                           to use. This is currently ignored as the
                           White Island catalogue does not contain
                           eruption scale.
    :type eruption_scale: int
    :param dec_interval: The declustering interval, which is the minimum
                         distance in time between any two eruptions.
    :type dec_interval: :class:`pandas.DateOffset` or str
    :param datadir: Path that contains the catalogue file in csv format.
    :type datadir: str
    :returns: Declustered eruption catalogue
    :rtype: :class:`pandas.DataFrame`
    """
    eruptions = pd.read_csv(
        get_data("data/WhiteIs_Eruption_Catalogue.csv"),
        index_col="Date",
        parse_dates=True,
        comment="#",
    )

    # Select eruptions of a particular scale or larger
    eruptions = eruptions[eruptions.Activity_Scale >= min_size].copy()

    if dec_interval is not None:
        # duplicate time index as a data column
        eruptions.insert(1, "tvalue", eruptions.index)
        # calculate intereruption event time
        delta = (eruptions["tvalue"] - eruptions["tvalue"].shift()).fillna(
            pd.Timedelta(seconds=0)
        )
        eruptions.insert(1, "delta", delta)
        eruptions.iloc[0, 1] = pd.Timedelta(dec_interval)
        eruptions = eruptions[(eruptions.delta >= dec_interval)]
        eruptions = eruptions.drop(columns=["tvalue", "delta"])

    eruptions.index = pd.DatetimeIndex(eruptions.index)
    eruptions = eruptions.tz_localize('utc')
    return eruptions

CraterLake

Bases: object

Source code in src/aitana/ruapehu.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
class CraterLake(object):

    def __init__(self, start_date: datetime, end_date: datetime):
        self.start_date = pd.to_datetime(start_date, utc=True)
        self.end_date = pd.to_datetime(end_date, utc=True)

    @cache_dataframe()
    def temperature(self, resample: str = "D", interpolate: str = None,
                    exclude1995: bool = True, dropna: bool = True) -> pd.DataFrame:
        """
        Read crater lake temperature from Tilde (https://tilde.geonet.org.nz).

        Parameters:
        -----------
            :param resample: Average over resampling interval and
                            linearly interpolate in between. The
                            interval should be one of 'D', 'W',
                            or 'M'
            :type resample: str
            :param interpolate: Interpolate between resampled points.
                                Only takes effect if resample is not
                                None.
            :type interpolate: str

        Returns:
        --------
            :param df: A dataframe with the temperature time series.
            :type df: :class:`pandas.Dataframe`
        """
        dataframes = []
        # read in datalogger temperature data
        start_date = datetime(1954, 1, 1)
        sensors = [("envirosensor", "lake-temperature", "RU001", "-", "04"),
                   ("envirosensor", "lake-temperature", "RU001", "-", "01"),
                   ("envirosensor", "lake-temperature", "RU001", "snapshot", "06"),
                   ("envirosensor", "lake-temperature", "RU001", "snapshot", "07"),
                   ("manualcollect", "lake-temperature",
                    "RU001", "thermometer", "MC01"),
                   ("manualcollect", "lake-temperature", "RU001", "thermocouple", "MC01")]
        for domain, name, station, method, sensor in sensors:
            try:
                df = tilde_request(start_date=start_date, end_date=self.end_date,
                                   domain=domain, name=name,
                                   station=station, method=method, sensor=sensor)
            except ValueError:
                continue
            if len(df) > 0:
                dataframes.append(df)

        if len(dataframes) == 0:
            raise ValueError(
                f"No data found for lake temperature between {self.start_date} and {self.end_date}")

        df = dataframes[0]
        if len(dataframes) > 1:
            for df1 in dataframes[1:]:
                df = df.combine_first(df1)

        if resample is not None:
            df = df.resample("D").mean()
            if interpolate is not None:
                df = df.interpolate(interpolate)

        # remove observations following the 1995 eruption when
        # the measured temperatures do not represent a lake.
        if exclude1995:
            cond1 = df.index <= "1995-09-20 00:00:00"
            cond2 = df.index >= "2000-01-01 00:00:00"
            df = df[(cond1) | (cond2)]
        # get rid of nans
        if dropna:
            df.dropna(inplace=True)

        return df

    @cache_dataframe()
    def water_level(self, resample: str = "D", interpolate: str = None,
                    dropna: bool = True):
        """
        Read crater lake water level from Tilde (https://tilde.geonet.org.nz).
        """
        sensors = [("envirosensor", "lake-height", "RU001", "-", "02"),
                   ("envirosensor", "lake-height", "RU001", "-", "03")]
        dataframes = []
        for domain, name, station, method, sensor in sensors:
            try:
                df = tilde_request(start_date=datetime(2009, 4, 15, 2, 0, 0), end_date=self.end_date,
                                   domain=domain, name=name, station=station,
                                   method=method, sensor=sensor)
            except ValueError:
                continue
            if len(df) > 0:
                dataframes.append(df)
        try:
            df08 = tilde_request(start_date=datetime(2009, 4, 15, 2, 0, 0), end_date=self.end_date,
                                 domain="envirosensor", name="lake-height", station="RU001",
                                 method="snapshot", sensor="08")
        except ValueError:
            pass
        if len(df08) > 0:
            intercept, slope = lake_height_scaling()
            df08['obs'] = df08['obs'] * slope + intercept
            dataframes.append(df08)

        if len(dataframes) == 0:
            raise ValueError(
                f"No data found for lake height between {self.start_date} and {self.end_date}")
        df = dataframes[0]
        if len(dataframes) > 1:
            for df1 in dataframes[1:]:
                df = df.combine_first(df1)

        if resample is not None:
            df = df.resample("D").mean()
            if interpolate is not None:
                df = df.interpolate(interpolate)

        if dropna:
            df.dropna(inplace=True)

        return df

    @cache_dataframe()
    def water_analyte(self, analyte: str, resample: str = None, interpolate: str = None,
                      exclude1995: bool = True, drop_duplicates: bool = True) -> pd.DataFrame:
        """
        Download water analyte data from Tilde (https://tilde.geonet.org.nz).

        Parameters:
        -----------
            analyte : str
                The analyte to download. This can be one of
                    'Al, 'As', 'B', 'Br', 'Ca', 'Cl', 'Cs', 'F',
                    'Fe', 'H2S', 'K', 'Li', 'Mg', 'NH3', 'NO3-N', 'Na',
                    'PO4-P', 'Rb', 'SO4', 'SiO2', 'd18O', 'd2H', 'ph'
            resample : str
                Resample the data to a given interval. This can be anything
                allowed by :pandas.DataFrame.resample:. 
            interpolate : str
                Interpolate between points. This can be anything allowed by
                :pandas.DataFrame.interpolate:.
            exclude1995 : bool
                Exclude data from 1995 when there wasn't a lake.
        Returns:
        --------
            pandas.DataFrame
                A dataframe with the water analyte time series.
        """
        dataframes = []
        sensors = ["MC03", "MC04", "MC01"]
        for sensor in sensors:
            try:
                df = tilde_request(start_date=datetime(1964, 5, 9),
                                   end_date=self.end_date,
                                   domain="manualcollect",
                                   name=f"lake-{analyte}-conc",
                                   station="RU001",
                                   sensor=sensor)
            except ValueError:
                continue
            if len(df) > 0:
                dataframes.append(df)
        if len(dataframes) == 0:
            raise ValueError(
                f"No data found for {analyte} between {self.start_date} and {self.end_date}")
        df = dataframes[0]
        if len(dataframes) > 1:
            for df1 in dataframes[1:]:
                df = df.combine_first(df1)
        if resample is not None:
            df = df.resample(resample).mean()

        if interpolate is not None:
            df = df.interpolate(interpolate)

        if exclude1995:
            cond1 = df.index <= "1995-09-20 00:00:00"
            cond2 = df.index >= "2000-01-01 00:00:00"
            df = df[(cond1) | (cond2)]

        if drop_duplicates:
            # If there are multiple measurements on the same day, take the mean
            df = df.groupby(df.index.date).mean()
            df.index = pd.to_datetime(df.index, utc=True)
        return df

temperature(resample='D', interpolate=None, exclude1995=True, dropna=True)

Read crater lake temperature from Tilde (https://tilde.geonet.org.nz).

Parameters:

:param resample: Average over resampling interval and
                linearly interpolate in between. The
                interval should be one of 'D', 'W',
                or 'M'
:type resample: str
:param interpolate: Interpolate between resampled points.
                    Only takes effect if resample is not
                    None.
:type interpolate: str

Returns:

:param df: A dataframe with the temperature time series.
:type df: :class:`pandas.Dataframe`
Source code in src/aitana/ruapehu.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@cache_dataframe()
def temperature(self, resample: str = "D", interpolate: str = None,
                exclude1995: bool = True, dropna: bool = True) -> pd.DataFrame:
    """
    Read crater lake temperature from Tilde (https://tilde.geonet.org.nz).

    Parameters:
    -----------
        :param resample: Average over resampling interval and
                        linearly interpolate in between. The
                        interval should be one of 'D', 'W',
                        or 'M'
        :type resample: str
        :param interpolate: Interpolate between resampled points.
                            Only takes effect if resample is not
                            None.
        :type interpolate: str

    Returns:
    --------
        :param df: A dataframe with the temperature time series.
        :type df: :class:`pandas.Dataframe`
    """
    dataframes = []
    # read in datalogger temperature data
    start_date = datetime(1954, 1, 1)
    sensors = [("envirosensor", "lake-temperature", "RU001", "-", "04"),
               ("envirosensor", "lake-temperature", "RU001", "-", "01"),
               ("envirosensor", "lake-temperature", "RU001", "snapshot", "06"),
               ("envirosensor", "lake-temperature", "RU001", "snapshot", "07"),
               ("manualcollect", "lake-temperature",
                "RU001", "thermometer", "MC01"),
               ("manualcollect", "lake-temperature", "RU001", "thermocouple", "MC01")]
    for domain, name, station, method, sensor in sensors:
        try:
            df = tilde_request(start_date=start_date, end_date=self.end_date,
                               domain=domain, name=name,
                               station=station, method=method, sensor=sensor)
        except ValueError:
            continue
        if len(df) > 0:
            dataframes.append(df)

    if len(dataframes) == 0:
        raise ValueError(
            f"No data found for lake temperature between {self.start_date} and {self.end_date}")

    df = dataframes[0]
    if len(dataframes) > 1:
        for df1 in dataframes[1:]:
            df = df.combine_first(df1)

    if resample is not None:
        df = df.resample("D").mean()
        if interpolate is not None:
            df = df.interpolate(interpolate)

    # remove observations following the 1995 eruption when
    # the measured temperatures do not represent a lake.
    if exclude1995:
        cond1 = df.index <= "1995-09-20 00:00:00"
        cond2 = df.index >= "2000-01-01 00:00:00"
        df = df[(cond1) | (cond2)]
    # get rid of nans
    if dropna:
        df.dropna(inplace=True)

    return df

water_analyte(analyte, resample=None, interpolate=None, exclude1995=True, drop_duplicates=True)

Download water analyte data from Tilde (https://tilde.geonet.org.nz).

Parameters:

analyte : str
    The analyte to download. This can be one of
        'Al, 'As', 'B', 'Br', 'Ca', 'Cl', 'Cs', 'F',
        'Fe', 'H2S', 'K', 'Li', 'Mg', 'NH3', 'NO3-N', 'Na',
        'PO4-P', 'Rb', 'SO4', 'SiO2', 'd18O', 'd2H', 'ph'
resample : str
    Resample the data to a given interval. This can be anything
    allowed by :pandas.DataFrame.resample:. 
interpolate : str
    Interpolate between points. This can be anything allowed by
    :pandas.DataFrame.interpolate:.
exclude1995 : bool
    Exclude data from 1995 when there wasn't a lake.

Returns:

pandas.DataFrame
    A dataframe with the water analyte time series.
Source code in src/aitana/ruapehu.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
@cache_dataframe()
def water_analyte(self, analyte: str, resample: str = None, interpolate: str = None,
                  exclude1995: bool = True, drop_duplicates: bool = True) -> pd.DataFrame:
    """
    Download water analyte data from Tilde (https://tilde.geonet.org.nz).

    Parameters:
    -----------
        analyte : str
            The analyte to download. This can be one of
                'Al, 'As', 'B', 'Br', 'Ca', 'Cl', 'Cs', 'F',
                'Fe', 'H2S', 'K', 'Li', 'Mg', 'NH3', 'NO3-N', 'Na',
                'PO4-P', 'Rb', 'SO4', 'SiO2', 'd18O', 'd2H', 'ph'
        resample : str
            Resample the data to a given interval. This can be anything
            allowed by :pandas.DataFrame.resample:. 
        interpolate : str
            Interpolate between points. This can be anything allowed by
            :pandas.DataFrame.interpolate:.
        exclude1995 : bool
            Exclude data from 1995 when there wasn't a lake.
    Returns:
    --------
        pandas.DataFrame
            A dataframe with the water analyte time series.
    """
    dataframes = []
    sensors = ["MC03", "MC04", "MC01"]
    for sensor in sensors:
        try:
            df = tilde_request(start_date=datetime(1964, 5, 9),
                               end_date=self.end_date,
                               domain="manualcollect",
                               name=f"lake-{analyte}-conc",
                               station="RU001",
                               sensor=sensor)
        except ValueError:
            continue
        if len(df) > 0:
            dataframes.append(df)
    if len(dataframes) == 0:
        raise ValueError(
            f"No data found for {analyte} between {self.start_date} and {self.end_date}")
    df = dataframes[0]
    if len(dataframes) > 1:
        for df1 in dataframes[1:]:
            df = df.combine_first(df1)
    if resample is not None:
        df = df.resample(resample).mean()

    if interpolate is not None:
        df = df.interpolate(interpolate)

    if exclude1995:
        cond1 = df.index <= "1995-09-20 00:00:00"
        cond2 = df.index >= "2000-01-01 00:00:00"
        df = df[(cond1) | (cond2)]

    if drop_duplicates:
        # If there are multiple measurements on the same day, take the mean
        df = df.groupby(df.index.date).mean()
        df.index = pd.to_datetime(df.index, utc=True)
    return df

water_level(resample='D', interpolate=None, dropna=True)

Read crater lake water level from Tilde (https://tilde.geonet.org.nz).

Source code in src/aitana/ruapehu.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
@cache_dataframe()
def water_level(self, resample: str = "D", interpolate: str = None,
                dropna: bool = True):
    """
    Read crater lake water level from Tilde (https://tilde.geonet.org.nz).
    """
    sensors = [("envirosensor", "lake-height", "RU001", "-", "02"),
               ("envirosensor", "lake-height", "RU001", "-", "03")]
    dataframes = []
    for domain, name, station, method, sensor in sensors:
        try:
            df = tilde_request(start_date=datetime(2009, 4, 15, 2, 0, 0), end_date=self.end_date,
                               domain=domain, name=name, station=station,
                               method=method, sensor=sensor)
        except ValueError:
            continue
        if len(df) > 0:
            dataframes.append(df)
    try:
        df08 = tilde_request(start_date=datetime(2009, 4, 15, 2, 0, 0), end_date=self.end_date,
                             domain="envirosensor", name="lake-height", station="RU001",
                             method="snapshot", sensor="08")
    except ValueError:
        pass
    if len(df08) > 0:
        intercept, slope = lake_height_scaling()
        df08['obs'] = df08['obs'] * slope + intercept
        dataframes.append(df08)

    if len(dataframes) == 0:
        raise ValueError(
            f"No data found for lake height between {self.start_date} and {self.end_date}")
    df = dataframes[0]
    if len(dataframes) > 1:
        for df1 in dataframes[1:]:
            df = df.combine_first(df1)

    if resample is not None:
        df = df.resample("D").mean()
        if interpolate is not None:
            df = df.interpolate(interpolate)

    if dropna:
        df.dropna(inplace=True)

    return df

Seismicity

Bases: object

Source code in src/aitana/ruapehu.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
class Seismicity(object):

    def __init__(self, start_date: datetime, end_date: datetime):
        self.start_date = pd.to_datetime(start_date, utc=True)
        self.end_date = pd.to_datetime(end_date, utc=True)

    @cache_dataframe()
    def regional(self):
        """
        Load an earthquake catalogue for an area containing Waiouru and National Park.
        """

        polygon = (
            "175.32+-39.50,+175.32+-39.18,+175.77+-39.18,+175.77+-39.50,+175.32+-39.50"
        )
        tstart = datetime(1955, 1, 1, tzinfo=timezone.utc)
        return wfs_request(tstart, self.end_date, polygon=polygon)

    @cache_dataframe()
    def cone(self):
        """
        Load an earthquake catalogue for an area of 7 km around the summit.
        """
        radius = 7000
        center_point = "175.57+-39.28"
        tstart = datetime(1955, 1, 1, tzinfo=timezone.utc)
        return wfs_request(tstart, self.end_date, radius=radius, center_point=center_point)

    def rm_duplicates(self, cat1: pd.DataFrame, cat2: pd.DataFrame) -> pd.DataFrame:
        """
        Remove events from catalogue cat1 that are also in cat2 and return a new catalogue.

        Parameters:
        -----------
            cat1 : pandas.DataFrame
                The first catalogue.
            cat2 : pandas.DataFrame
                The second catalogue.

        Returns:
        --------
            pandas.DataFrame
                A new catalogue with the events from cat1 that are not in cat2.
        """
        idxs = []
        cat_tmp = cat1.copy()
        for i in range(cat_tmp.shape[0]):
            if cat_tmp.iloc[i].publicid in cat2.publicid.values:
                idxs.append(i)
        dates_to_drop = cat_tmp.index[idxs]
        cat_tmp.drop(dates_to_drop, inplace=True)
        return cat_tmp

    def daily_rsam(self, update=False) -> pd.DataFrame:
        """
        Load RSAM values from DRZ, MAVZ and FWVZ combined by scaling
        MAVZ and FWVZ RSAM values with DRZ.

        Returns:
        --------
            :param df: Dataframe with RSAM values.
            :type df: :class:`pandas.DataFrame`
        """
        df = pd.read_csv(get_data("data/ruapehu_rsam.csv"),
                         parse_dates=True, index_col=0)
        try:
            df.index = df.index.tz_localize(timezone.utc)
        except TypeError:
            pass
        if self.end_date > df.index[-1] and update:
            df_drz = pd.read_csv(
                get_data("data/DRZ_scaled_MAVZ.csv"), parse_dates=True, index_col=0
            )
            df_drz.rename(columns={"RSAM": "obs"}, inplace=True)
            url = "https://gessp.gns.cri.nz/vumt-api/feature?group=vumt&name=rsam"
            url += "&starttime=2007-01-01T00:00:00"
            url += f"&endtime={self.end_date.isoformat().split('+')[0]}"
            url += "&subdir=Ruapehu&subdir=FWVZ&subdir=HHZ"
            try:
                df_fwvz = pd.read_csv(
                    url,
                    parse_dates=True,
                    index_col=0,
                    date_format="ISO8601",
                )
            except Exception as e:
                print(url)
                raise e
            df_fwvz.rename(columns={"feature": "rsam"}, inplace=True)
            df_fwvz["rsam"] = np.where(
                df_fwvz["rsam"] > 1e30, np.nan, df_fwvz["rsam"])
            df_fwvz_daily = df_fwvz.resample("1D").mean()
            df_fwvz_daily_scaled = -2.3945 + 3.5062 * df_fwvz_daily
            df_fwvz_daily_scaled.rename(columns={"rsam": "obs"}, inplace=True)
            df_drz.index = df_drz.index.tz_localize(timezone.utc)
            df = df_drz.combine_first(df_fwvz_daily_scaled)
            # remove duplicated dates:
            df = df.loc[~df.index.duplicated(), :]
            df.to_csv(get_data("data/ruapehu_rsam.csv"))
        df = df[df.index <= str(self.end_date)]
        df = df[df.index >= str(self.start_date)]
        return df

cone()

Load an earthquake catalogue for an area of 7 km around the summit.

Source code in src/aitana/ruapehu.py
276
277
278
279
280
281
282
283
284
@cache_dataframe()
def cone(self):
    """
    Load an earthquake catalogue for an area of 7 km around the summit.
    """
    radius = 7000
    center_point = "175.57+-39.28"
    tstart = datetime(1955, 1, 1, tzinfo=timezone.utc)
    return wfs_request(tstart, self.end_date, radius=radius, center_point=center_point)

daily_rsam(update=False)

Load RSAM values from DRZ, MAVZ and FWVZ combined by scaling MAVZ and FWVZ RSAM values with DRZ.

Returns:

:param df: Dataframe with RSAM values.
:type df: :class:`pandas.DataFrame`
Source code in src/aitana/ruapehu.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def daily_rsam(self, update=False) -> pd.DataFrame:
    """
    Load RSAM values from DRZ, MAVZ and FWVZ combined by scaling
    MAVZ and FWVZ RSAM values with DRZ.

    Returns:
    --------
        :param df: Dataframe with RSAM values.
        :type df: :class:`pandas.DataFrame`
    """
    df = pd.read_csv(get_data("data/ruapehu_rsam.csv"),
                     parse_dates=True, index_col=0)
    try:
        df.index = df.index.tz_localize(timezone.utc)
    except TypeError:
        pass
    if self.end_date > df.index[-1] and update:
        df_drz = pd.read_csv(
            get_data("data/DRZ_scaled_MAVZ.csv"), parse_dates=True, index_col=0
        )
        df_drz.rename(columns={"RSAM": "obs"}, inplace=True)
        url = "https://gessp.gns.cri.nz/vumt-api/feature?group=vumt&name=rsam"
        url += "&starttime=2007-01-01T00:00:00"
        url += f"&endtime={self.end_date.isoformat().split('+')[0]}"
        url += "&subdir=Ruapehu&subdir=FWVZ&subdir=HHZ"
        try:
            df_fwvz = pd.read_csv(
                url,
                parse_dates=True,
                index_col=0,
                date_format="ISO8601",
            )
        except Exception as e:
            print(url)
            raise e
        df_fwvz.rename(columns={"feature": "rsam"}, inplace=True)
        df_fwvz["rsam"] = np.where(
            df_fwvz["rsam"] > 1e30, np.nan, df_fwvz["rsam"])
        df_fwvz_daily = df_fwvz.resample("1D").mean()
        df_fwvz_daily_scaled = -2.3945 + 3.5062 * df_fwvz_daily
        df_fwvz_daily_scaled.rename(columns={"rsam": "obs"}, inplace=True)
        df_drz.index = df_drz.index.tz_localize(timezone.utc)
        df = df_drz.combine_first(df_fwvz_daily_scaled)
        # remove duplicated dates:
        df = df.loc[~df.index.duplicated(), :]
        df.to_csv(get_data("data/ruapehu_rsam.csv"))
    df = df[df.index <= str(self.end_date)]
    df = df[df.index >= str(self.start_date)]
    return df

regional()

Load an earthquake catalogue for an area containing Waiouru and National Park.

Source code in src/aitana/ruapehu.py
264
265
266
267
268
269
270
271
272
273
274
@cache_dataframe()
def regional(self):
    """
    Load an earthquake catalogue for an area containing Waiouru and National Park.
    """

    polygon = (
        "175.32+-39.50,+175.32+-39.18,+175.77+-39.18,+175.77+-39.50,+175.32+-39.50"
    )
    tstart = datetime(1955, 1, 1, tzinfo=timezone.utc)
    return wfs_request(tstart, self.end_date, polygon=polygon)

rm_duplicates(cat1, cat2)

Remove events from catalogue cat1 that are also in cat2 and return a new catalogue.

Parameters:

cat1 : pandas.DataFrame
    The first catalogue.
cat2 : pandas.DataFrame
    The second catalogue.

Returns:

pandas.DataFrame
    A new catalogue with the events from cat1 that are not in cat2.
Source code in src/aitana/ruapehu.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def rm_duplicates(self, cat1: pd.DataFrame, cat2: pd.DataFrame) -> pd.DataFrame:
    """
    Remove events from catalogue cat1 that are also in cat2 and return a new catalogue.

    Parameters:
    -----------
        cat1 : pandas.DataFrame
            The first catalogue.
        cat2 : pandas.DataFrame
            The second catalogue.

    Returns:
    --------
        pandas.DataFrame
            A new catalogue with the events from cat1 that are not in cat2.
    """
    idxs = []
    cat_tmp = cat1.copy()
    for i in range(cat_tmp.shape[0]):
        if cat_tmp.iloc[i].publicid in cat2.publicid.values:
            idxs.append(i)
    dates_to_drop = cat_tmp.index[idxs]
    cat_tmp.drop(dates_to_drop, inplace=True)
    return cat_tmp

eruptions(min_size=0, dec_interval=None)

This function loads the eruption catalogue for Mt. Ruapehu and declusters it if required. The catalogue is based on Brad Scott's GNS Science Report. Declustering is turned on if dec_interval is greater than zero. This will also exclude magmatic eruption episodes between 9 January 1944 and 8 January 1946 as well as 6 January 1995 to 12 January 1997 as these aren't independent events.

Parameters:

min_size : int
    The minimum eruption size to include in the catalogue.
dec_interval : str 
    The declustering interval in pandas complient time delta format.

Returns:

pandas.DataFrame
    A dataframe with the (declustered) eruption catalogue.
Source code in src/aitana/ruapehu.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
@cache_dataframe()
def eruptions(min_size: int = 0, dec_interval: str = None) -> pd.DataFrame:
    """
    This function loads the eruption catalogue for Mt. Ruapehu and declusters
    it if required. The catalogue is based on Brad Scott's GNS Science Report.
    Declustering is turned on if dec_interval is greater than zero. This will
    also exclude magmatic eruption episodes between 9 January 1944 and
    8 January 1946 as well as 6 January 1995 to 12 January 1997 as these aren't
    independent events.

    Parameters:
    -----------
        min_size : int
            The minimum eruption size to include in the catalogue.
        dec_interval : str 
            The declustering interval in pandas complient time delta format.

    Returns:
    --------
        pandas.DataFrame
            A dataframe with the (declustered) eruption catalogue.
    """
    filename = "https://raw.githubusercontent.com/GeoNet/data/refs/heads/main/"
    filename += "historic-volcanic-activity/historic_eruptive_activity_ruapehu.csv"
    df = pd.read_csv(filename, index_col=0, parse_dates=True, comment='#')

    # Select eruptions of a particular scale or larger
    scaled_eruptions = df[df['Activity Scale'] >= min_size].copy()

    if dec_interval is not None:
        # duplicate time index as a data column
        scaled_eruptions.insert(1, "tvalue", scaled_eruptions.index)
        # calculate intereruption event time
        delta = (scaled_eruptions["tvalue"] - scaled_eruptions["tvalue"].shift()).fillna(
            pd.Timedelta(seconds=0)
        )
        scaled_eruptions.insert(1, "delta", delta)
        scaled_eruptions.iloc[0, 1] = pd.Timedelta(dec_interval)
        # Exclude certain date ranges from calculations to ensure a more
        # Poisson-like process by removing long term eruption periods.
        period1 = scaled_eruptions.index < "1944-10-01 00:00:00"
        period2 = scaled_eruptions.index > "1946-08-01 00:00:00"
        scaled_eruptions = scaled_eruptions[period1 | period2]
        period3 = scaled_eruptions.index < "1995-07-01 00:00:00"
        period4 = scaled_eruptions.index > "1997-12-01 00:00:00"
        scaled_eruptions = scaled_eruptions[period3 | period4]
        scaled_eruptions = scaled_eruptions[(
            scaled_eruptions.delta >= dec_interval)]
        scaled_eruptions = scaled_eruptions.drop(columns=["delta", "tvalue"])
    scaled_eruptions = scaled_eruptions.tz_localize('utc')
    return scaled_eruptions

lake_height_scaling(recompute=False)

Calculate the scaling factor between the lake height sensors 08 and 03.

Parameters:

recompute : bool
    If True, recompute the scaling factor. If False, load the
    scaling factor from the cache.

Returns:

tuple
    A tuple containing the intercept and slope of the linear regression
    between the two sensors.
Source code in src/aitana/ruapehu.py
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
def lake_height_scaling(recompute: bool = False) -> tuple:
    """
    Calculate the scaling factor between the lake height sensors 08 and 03.

    Parameters:
    -----------
        recompute : bool
            If True, recompute the scaling factor. If False, load the
            scaling factor from the cache.

    Returns:
    --------
        tuple
            A tuple containing the intercept and slope of the linear regression
            between the two sensors.
    """
    fout = get_data("data/lake_height_scaling.csv")
    if not recompute and os.path.exists(fout):
        df = pd.read_csv(fout)
        return df['intercept'].values[0], df['slope'].values[0]
    else:
        df08 = tilde_request(start_date=datetime(2009, 4, 15, 2, 0, 0), end_date=datetime(2025, 7, 6),
                             domain="envirosensor", name="lake-height", station="RU001",
                             method="snapshot", sensor="08")
        df08_daily = df08.resample("1D").mean().drop(columns=['err'])
        df03 = tilde_request(start_date=df08.index[0], end_date=datetime(2025, 7, 6),
                             domain="envirosensor", name="lake-height", station="RU001",
                             method="-", sensor="03")
        df03_daily = df03.resample("1D").mean().drop(columns=['err'])

        df_combined = pd.concat([df08_daily.rename(
            columns={'obs': 'x'}), df03_daily.rename(columns={'obs': 'y'})], axis=1)
        df_combined.dropna(inplace=True)
        df_combined.head()
        X = df_combined['x']
        y = df_combined['y']

        # Add constant term (intercept) manually
        X_with_const = sm.add_constant(X)

        # Fit the model
        model = sm.OLS(y, X_with_const).fit()
        # Save the model parameters
        model_params = pd.DataFrame({
            'intercept': [model.params['const']],
            'slope': [model.params['x']]
        })
        model_params.to_csv(fout, index=False)
        return model.params['const'], model.params['x']

tilde_request(start_date, end_date, domain, name, station, method='-', sensor='-', aspect='-')

Request data from the tilde API (https://tilde.geonet.org.nz/v3/api-docs/). See the tilde discovery tool for more information: https://tilde.geonet.org.nz/ui/data-discovery/

Parameters:
  • domain (str) –

    The domain of the data (e.g. 'manualcollect')

  • name (str) –

    The name of the data (e.g. 'plume-SO2-gasflux')

  • station (str) –

    The station code (e.g. 'WI000')

  • method (str, default: '-' ) –

    The method of the data (e.g. 'contouring')

  • sensor (str, default: '-' ) –

    The sensor of the data (e.g. 'MC01')

  • aspect (str, default: '-' ) –

    The aspect of the data (e.g. 'nil')

  • start_date (date) –

    The start date of the data

  • end_date (date) –

    The end date of the data

Returns:
  • DataFrame

    A pandas dataframe with the requested data

Source code in src/aitana/tilde.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def tilde_request(
    start_date: datetime,
    end_date: datetime,
    domain: str,
    name: str,
    station: str,
    method: str = '-',
    sensor: str = '-',
    aspect: str = '-',
) -> pd.DataFrame:
    """
    Request data from the tilde API (https://tilde.geonet.org.nz/v3/api-docs/).
    See the tilde discovery tool for more information:
    https://tilde.geonet.org.nz/ui/data-discovery/

    Parameters
    ----------
    domain : str
        The domain of the data (e.g. 'manualcollect')
    name : str
        The name of the data (e.g. 'plume-SO2-gasflux')
    station : str
        The station code (e.g. 'WI000')
    method : str
        The method of the data (e.g. 'contouring')
    sensor : str
        The sensor of the data (e.g. 'MC01')
    aspect : str
        The aspect of the data (e.g. 'nil')
    start_date : date
        The start date of the data
    end_date : date
        The end date of the data

    Returns
    -------
    pd.DataFrame
        A pandas dataframe with the requested data
    """
    tilde_url = "https://tilde.geonet.org.nz/v4/data"
    # split the request into historic and latest data
    get_historic = True
    get_latest = False
    start_date = start_date.astimezone(timezone.utc)
    end_date = end_date.astimezone(timezone.utc)
    _tstart = str(start_date.date())
    _tend = str(end_date.date())
    _today = datetime.now(timezone.utc).date()
    if end_date.date() > (_today - timedelta(days=29)):
        _tend = str((end_date.date() - timedelta(days=29)))
        get_latest = True
    if start_date.date() > (_today - timedelta(days=29)):
        get_historic = False

    assert get_historic or get_latest, "Check start and end dates."

    if get_latest:
        latest = f"{tilde_url}/{domain}/{station}/{name}/{sensor}/{method}/{aspect}/latest/30d"
        rval = requests.get(latest, headers={"Accept": "text/csv"})
        if rval.status_code != 200:
            msg = f"Download of {name} for {station} failed with status code {rval.status_code}"
            msg += f" and url {latest}"
            raise ValueError(msg)
        df_latest = pd.read_csv(
            io.StringIO(rval.text),
            index_col="timestamp",
            parse_dates=["timestamp"],
            usecols=["timestamp", "value", "error"],
            date_format="ISO8601",
        )
    if get_historic:
        historic = f"{tilde_url}/{domain}/{station}/{name}/{sensor}/{method}/{aspect}/"
        historic += f"{_tstart}/{_tend}"
        rval = requests.get(historic, headers={"Accept": "text/csv"})
        if rval.status_code != 200:
            msg = f"Download of {name} for {station} failed with status code {rval.status_code}"
            msg += f" and url {historic}"
            raise ValueError(msg)
        data = io.StringIO(rval.text)
        df_historic = pd.read_csv(
            data,
            index_col="timestamp",
            parse_dates=["timestamp"],
            usecols=["timestamp", "value", "error"],
            date_format="ISO8601",
        )
        if get_latest and len(df_latest) > 0:
            df = df_historic.combine_first(df_latest)
        else:
            df = df_historic
    else:
        df = df_latest
    df.rename(columns={"value": "obs", "error": "err"}, inplace=True)
    df.index.name = "dt"
    return df

wfs_request(start_date, end_date, polygon=None, radius=None, center_point=None, maxdepth=30)

Load volcano-tectonic earthquake catalogues from GeoNet's WFS service (http://wfs.geonet.org.nz).

Parameters:

polygon : str
    A string with the polygon coordinates in the format
    "lon1+lat1,lon2+lat2,...,lonN+latN,lon1+lat1"
radius : int
    The radius around the volcano summit in meters.
center_point : str
    The center point of the radius in the format "lon+lat".
maxdepth : int
    The maximum depth of the earthquakes in kilometers.

Returns:

pandas.DataFrame
    A dataframe with the earthquake catalogue.
Source code in src/aitana/wfs.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def wfs_request(
    start_date: datetime,
    end_date: datetime,
    polygon: str = None,
    radius: int = None,
    center_point: str = None,
    maxdepth: int = 30
) -> pd.DataFrame:
    """
    Load volcano-tectonic earthquake catalogues from GeoNet's WFS service (http://wfs.geonet.org.nz).

    Parameters:
    -----------
        polygon : str
            A string with the polygon coordinates in the format
            "lon1+lat1,lon2+lat2,...,lonN+latN,lon1+lat1"
        radius : int
            The radius around the volcano summit in meters.
        center_point : str
            The center point of the radius in the format "lon+lat".
        maxdepth : int
            The maximum depth of the earthquakes in kilometers.

    Returns:
    --------
        pandas.DataFrame
            A dataframe with the earthquake catalogue.
    """
    wfs_url = "http://wfs.geonet.org.nz/geonet/ows?service=WFS&version=1.0.0"
    if polygon is None and radius is None:
        raise ValueError("Either polygon or radius must be provided.")
    start_date = start_date.strftime("%Y-%m-%dT%H:%M:%S.0Z")
    url = wfs_url
    url += (
        "&request=GetFeature&typeName=geonet:quake_search_v1&outputFormat=csv"
    )
    url += f"&cql_filter=origintime>={start_date}"

    if radius is not None:
        url += (
            f"+AND+DWITHIN(origin_geom,Point+({center_point}),{radius},meters)+AND+depth<{maxdepth}"
        )
    if polygon is not None:
        url += f"+AND+WITHIN(origin_geom,POLYGON(({polygon})))+AND+depth<{maxdepth}"
    try:
        cat = pd.read_csv(url, parse_dates=[
                          "origintime"], index_col="origintime")
    except HTTPError as e:
        logger.error(f"HTTP Error: {e.code} - {e.reason}")
        logger.error("URL: " + url)
        raise e
    cat.sort_index(inplace=True)
    if cat.size == 0:
        msg = "There are no earthquakes available for"
        msg += f"the selected date range ({str(start_date)}, {str(end_date)}) in "
        if radius is not None:
            msg += "a radius of {} m".format(radius)
        if polygon is not None:
            msg += "the selected region: {}".format(polygon)
        raise ValueError(msg)

    return cat

Utilities

cache_dataframe(cache_dir='')

Decorator to cache pandas DataFrames, handle date ranges, and persist the cache to disk.

Parameters:
  • cache_key (str) –

    The attribute name for the cached DataFrame.

  • use_class_attrs (bool) –

    If True, fetch start_date and end_date from the class instance if available.

  • cache_file (str) –

    Filepath for persisting the cache.

Source code in src/aitana/util.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def cache_dataframe(cache_dir: str = ""):
    """
    Decorator to cache pandas DataFrames, handle date ranges, and persist the cache to disk.

    Args:
        cache_key (str): The attribute name for the cached DataFrame.
        use_class_attrs (bool): If True, fetch start_date and end_date from the class instance if available.
        cache_file (str): Filepath for persisting the cache.
    """
    if not cache_dir:
        cache_dir = os.path.join(Path.home(), ".aitana_cache")
        os.makedirs(cache_dir, exist_ok=True)

    def decorator(func):
        @wraps(func)
        def wrapper(*args, start_date=None, end_date=None, clear_cache=False, **kwargs):
            cache_key = generate_cache_key(func.__name__, args, kwargs)
            cache_file = os.path.join(cache_dir, f"{cache_key}.csv")
            if clear_cache:
                if os.path.exists(cache_file):
                    os.remove(cache_file)

            instance = args[0] if _is_class_method(func) else None

            if instance is not None:
                # Use instance attributes if not explicitly provided
                end_date = end_date or getattr(instance, "end_date", None)
                start_date = start_date or getattr(instance, "start_date", None)

            if end_date is None:
                default_params = get_defaults_with_names(func)
                if "end_date" in default_params:
                    end_date = default_params["end_date"]
                else:
                    raise ValueError("end_date must be provided.")

            end_date = pd.to_datetime(end_date, utc=True)
            if start_date is not None:
                start_date = pd.to_datetime(start_date, utc=True)

            # Load the cache from disk if it exists
            if not hasattr(wrapper, f"cached_df_{cache_key}"):
                if os.path.exists(cache_file):
                    logger.debug(f"Loading cache from {cache_file}")
                    cached_df = pd.read_csv(
                        cache_file, parse_dates=True, index_col=0, date_format="ISO8601"
                    )
                else:
                    cached_df = pd.DataFrame()
                setattr(wrapper, f"cached_df_{cache_key}", cached_df)
            else:
                cached_df = getattr(wrapper, f"cached_df_{cache_key}")

            # Check the date range of the cached data
            if not cached_df.empty:
                cached_end = cached_df.index.max()
            else:
                cached_end = None

            # Determine missing ranges
            missing_end = (
                end_date if cached_end is None or end_date > cached_end else None
            )

            # If there are missing ranges, fetch the data
            if missing_end:
                logger.debug("Fetching missing data...")
                if instance is not None:
                    setattr(instance, "end_date", missing_end)
                    missing_data = func(*args, **kwargs)
                else:
                    try:
                        missing_data = func(*args, end_date=end_date, **kwargs)
                    except TypeError:
                        missing_data = func(*args, **kwargs)

                if not isinstance(missing_data, pd.DataFrame):
                    raise ValueError(
                        "The decorated function must return a pandas DataFrame."
                    )

                if not missing_data.empty:
                    missing_data.index = pd.to_datetime(
                        missing_data.index
                    )  # Ensure the index is datetime

                # Update the cache
                cached_df = missing_data
                setattr(wrapper, f"cached_df_{cache_key}", cached_df)

                # Persist the updated cache to disk
                logger.debug(f"Saving cache to {cache_file}")
                cached_df.to_csv(cache_file)
            # Return the relevant slice of the cached DataFrame
            return_df = cached_df.loc[
                start_date : end_date.replace(hour=23, minute=59, second=59)
            ]
            if return_df.empty:
                raise ValueError("No data available for the given date range.")
            return return_df

        return wrapper

    return decorator

eqRate(cat, fixed_time=None, fixed_nevents=None, enddate=datetime.utcnow())

Compute earthquake rate.

Parameters:
  • cat (:class:`pandas.DataFrame`) –

    A catalogue of earthquakes as returned by :method:pyvolprob.load_ruapehu_earthquakes

  • fixed_time (int, default: None ) –

    If not 'None', compute the earthquake rate based on a fixed-length time window given in days.

  • fixed_nevents (int, default: None ) –

    If not None, compute the earthquake rate based on a fixed number of events.

  • enddate (:class:`datetime.datetime`, default: utcnow() ) –

    The latest date of the time-series. Mainly needed for testing.

Source code in src/aitana/util.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def eqRate(cat, fixed_time=None, fixed_nevents=None, enddate=datetime.utcnow()):
    """
    Compute earthquake rate.

    :param cat: A catalogue of earthquakes as returned by
                :method:`pyvolprob.load_ruapehu_earthquakes`
    :type cat: :class:`pandas.DataFrame`
    :param fixed_time: If not 'None', compute the earthquake rate
                       based on a fixed-length time window given in
                       days.
    :type fixed_time: int
    :param fixed_nevents: If not None, compute the earthquake rate
                          based on a fixed number of events.
    :type fixed_nevents: int
    :param enddate: The latest date of the time-series.
                    Mainly needed for testing.
    :type enddate: :class:`datetime.datetime`

    """
    if fixed_time is not None and fixed_nevents is not None:
        raise ValueError("Please define either 'fixed_time' or 'fixed_nevents'")

    dates = cat.index.values
    if fixed_time is not None:
        ds = pd.Series(np.ones(len(dates)), index=dates)
        ds = pd.concat([ds, pd.Series([np.nan], index=[enddate])])
        ds.sort_index(inplace=True)
        ds = ds.rolling("{:d}D".format(fixed_time)).count() / fixed_time
        ds.index -= pd.Timedelta("{:d}D".format(int(fixed_time / 2.0)))
        return pd.DataFrame({"obs": ds}).tz_localize("utc")
    elif fixed_nevents is not None:
        nevents = dates.shape[0]
        aBin = np.zeros(nevents - fixed_nevents, dtype="datetime64[ns]")
        aRate = np.zeros(nevents - fixed_nevents)
        iS = 0
        for s in np.arange(fixed_nevents, nevents):
            i1, i2 = s - fixed_nevents, s
            dt = (dates[i2] - dates[i1]).astype("timedelta64[s]")
            dt_days = dt.astype(float) / 86400.0
            aBin[iS] = dates[i1] + 0.5 * dt
            aRate[iS] = fixed_nevents / dt_days
            iS += 1
        return pd.DataFrame({"obs": aRate}, index=aBin).tz_localize("utc")
    else:
        raise ValueError("Please define either 'fixed_time' or 'fixed_nevents'")

generate_cache_key(func_name, args, kwargs)

Generate a unique filename for caching based on function name and arguments.

Source code in src/aitana/util.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def generate_cache_key(func_name, args, kwargs):
    """
    Generate a unique filename for caching based on function name and arguments.
    """
    # Serialize arguments to a string representation
    instance = args[0] if args and hasattr(args[0], "__class__") else None

    if instance:
        args_repr = "_".join(repr(arg) for arg in args[1:])
    else:
        args_repr = "_".join(repr(arg) for arg in args)
    kwargs_repr = "_".join(f"{k}={v}" for k, v in sorted(kwargs.items()))

    # Combine everything into a single string
    combined = f"{func_name}_{args_repr}_{kwargs_repr}"

    # Hash the combined string to ensure the filename is valid and not too long
    hash_object = hashlib.md5(combined.encode("utf-8"))
    hashed_name = hash_object.hexdigest()

    # Construct the filename
    return f"{func_name}_{hashed_name}"  # Use .pkl for cached dataframes

get_color(idx, alpha=1.0, style='seaborn-v0_8-paper')

Return a color from the matplotlib color cycle by index.

Parameters:
  • idx (int) –

    Index of the color to return.

  • alpha (float, default: 1.0 ) –

    Opacity of the color between 0 and 1, by default 1.

  • style (str, default: 'seaborn-v0_8-paper' ) –

    matplotlib style to choose colors from, by default 'bmh'

Returns:
  • _type_

    description

Source code in src/aitana/util.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def get_color(idx, alpha=1.0, style="seaborn-v0_8-paper"):
    """Return a color from the matplotlib color cycle by index.

    Parameters
    ----------
    idx : int
        Index of the color to return.
    alpha : float, optional
        Opacity of the color between 0 and 1, by default 1.
    style : str, optional
        matplotlib style to choose colors from, by default 'bmh'

    Returns
    -------
    _type_
        _description_
    """
    matplotlib.style.use(style)
    prop_cycle = plt.rcParams["axes.prop_cycle"]
    colors = prop_cycle.by_key()["color"]
    colors_rgb = []
    for c in colors:
        colors_rgb.append(hex_to_rgb(c, alpha=alpha))
    return f"rgba{colors_rgb[idx]}"

get_defaults_with_names(func)

Get parameter names and their default values from a function object.

Source code in src/aitana/util.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def get_defaults_with_names(func):
    """
    Get parameter names and their default values
    from a function object.
    """
    sig = inspect.signature(func)
    defaults = func.__defaults__ or ()
    kwdefaults = func.__kwdefaults__ or {}

    # Positional/keyword arguments with defaults
    param_names = list(sig.parameters.keys())
    positional_defaults = param_names[-len(defaults) :] if defaults else []
    positional_defaults_with_names = dict(zip(positional_defaults, defaults))

    # Combine with keyword-only defaults
    all_defaults = {**positional_defaults_with_names, **kwdefaults}
    return all_defaults

gradient(df, period='14D')

Compute gradient for time series by first smoothing the time series and then computing the first-order difference.

Parameters:

:param df: Dataframe
:type df: :class:`~pandas.DataFrame`
:param period: Period over which to compute a rolling mean.
:type period: str
Source code in src/aitana/util.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def gradient(df, period="14D"):
    """
    Compute gradient for time series by first smoothing
    the time series and then computing the first-order difference.

    Parameters:
    -----------
        :param df: Dataframe
        :type df: :class:`~pandas.DataFrame`
        :param period: Period over which to compute a rolling mean.
        :type period: str
    """
    df_smooth = df.rolling(period).mean()
    df_grad = df_smooth.diff()
    df_grad.loc[df_grad.index[0], "obs"] = 0.0
    return df_grad

hex_to_rgb(value, alpha=1.0)

Return (red, green, blue) for the color given as #rrggbb.

Source code in src/aitana/util.py
269
270
271
272
273
274
275
def hex_to_rgb(value, alpha=1.0):
    """Return (red, green, blue) for the color given as #rrggbb."""
    value = value.lstrip("#")
    lv = len(value)
    rgb_list = [int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3)]
    rgb_list.append(alpha)
    return tuple(rgb_list)

reindex(df, dates, fill_method=None, ffill_interval=14)

Reindex and forward fill to generate a timeseries that can be used to set the evidence for a BN.

Parameters:
  • df (:class:`pandas.DataFrame`) –

    Dataframe to reindex

  • dates (:class:`pandas.DataTimeIndex`) –

    new date index

  • ffill_interval (int, default: 14 ) –

    Best-by interval for data

Source code in src/aitana/util.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def reindex(df, dates, fill_method=None, ffill_interval=14):
    """
    Reindex and forward fill to generate a
    timeseries that can be used to set the evidence
    for a BN.

    :param df: Dataframe to reindex
    :type df: :class:`pandas.DataFrame`
    :param dates: new date index
    :type dates: :class:`pandas.DataTimeIndex`
    :param ffill_interval: Best-by interval for data
    :type ffill_interval: int
    """
    if fill_method is None:
        return df["obs"].resample("D").max().reindex(dates)
    elif fill_method == "ffill":
        return df["obs"].reindex(dates, method="ffill", limit=ffill_interval)
    elif fill_method == "interpolate":
        df_tmp = df["obs"].resample("D").max().reindex(dates)
        return df_tmp.interpolate(method="linear")
    else:
        msg = "'fill_method' has to be one of "
        msg += "[None, 'ffill', 'interpolate']"
        raise ValueError(msg)

rgb_to_hex(red, green, blue)

Return color as #rrggbb for the given color values.

Source code in src/aitana/util.py
278
279
280
def rgb_to_hex(red, green, blue):
    """Return color as #rrggbb for the given color values."""
    return "#%02x%02x%02x" % (red, green, blue)

test_signal(nsec=3600, sampling_rate=100.0, frequencies=[0.1, 3.0, 10.0], amplitudes=[0.1, 1.0, 0.7], phases=[0.0, np.pi * 0.25, np.pi], offsets=[0.0, 0.0, 0.0], starttime=UTCDateTime(1970, 1, 1), gaps=False, noise=True, noise_std=0.5, sinusoid=True, addchirp=True, network='NZ', station='BLUB', location='', channel='HHZ')

Produce a test signal for which we know where the peaks are in the spectrogram.

Parameters:
  • nsec (int, default: 3600 ) –

    Length of the trace in seconds.

  • sampling_rate (float, default: 100.0 ) –

    Sampling rate of the signal in Hz.

  • starttime (:class:`~obspy.UTCDateTime`, default: UTCDateTime(1970, 1, 1) ) –

    Starttime of the trace.

  • gaps (boolean, default: False ) –

    If 'True' add gaps to the test signal.

  • noise_std (float, default: 0.5 ) –

    Standard deviation of the noise.

  • sinusoid (bool, default: True ) –

    Add a signal with a sinusoidal frequency change.

  • station (str, default: 'BLUB' ) –

    Station name of the returned trace.

  • location (str, default: '' ) –

    Location of the returned trace.

Returns:
  • :class:`~obspy.Trace`

    Time series of test data

Source code in src/aitana/util.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def test_signal(
    nsec=3600,
    sampling_rate=100.0,
    frequencies=[0.1, 3.0, 10.0],
    amplitudes=[0.1, 1.0, 0.7],
    phases=[0.0, np.pi * 0.25, np.pi],
    offsets=[0.0, 0.0, 0.0],
    starttime=UTCDateTime(1970, 1, 1),
    gaps=False,
    noise=True,
    noise_std=0.5,
    sinusoid=True,
    addchirp=True,
    network="NZ",
    station="BLUB",
    location="",
    channel="HHZ",
):
    """
    Produce a test signal for which we know where the peaks
    are in the spectrogram.

    :param nsec: Length of the trace in seconds.
    :type nsec: int
    :param sampling_rate: Sampling rate of the signal in Hz.
    :type sampling_rate: float
    :param starttime: Starttime of the trace.
    :type starttime: :class:`~obspy.UTCDateTime`
    :param gaps: If 'True' add gaps to the test signal.
    :type gaps: boolean
    :param noise_std: Standard deviation of the noise.
    :type noise_std: float
    :param sinusoid: Add a signal with a sinusoidal frequency change.
    :type sinusoid: bool
    :param station: Station name of the returned trace.
    :type station: str
    :param location: Location of the returned trace.
    :type location: str
    :return: Time series of test data
    :rtype: :class:`~obspy.Trace`
    """
    t = np.arange(0, nsec, 1 / sampling_rate)
    signals = []
    # Some constant signals with different phases
    for f, A, ph, dt in zip(frequencies, amplitudes, phases, offsets):
        _s = np.zeros(t.size)
        dt_idx = int(dt * sampling_rate)
        _s[dt_idx:] = A * np.sin(2.0 * np.pi * f * t[dt_idx:] + ph)
        signals.append(_s)
    if addchirp:
        # Frequency-swept signals
        # Chirp
        s4 = 0.8 * chirp(t, 5, t[-1], 15, method="quadratic")
        signals.append(s4)

    if sinusoid:
        vals = [6]
        for k in range(0, 7):
            vals.append(
                2
                * (-1) ** k
                * np.power(2 * np.pi / nsec, 2 * k + 1)
                / M.factorial(2 * k + 1)
            )
            vals.append(0)
        p = np.poly1d(np.array(vals[::-1]))
        s5 = sweep_poly(t, p)
        signals.append(s5)

    # add some noise
    if noise:
        rs = np.random.default_rng(42)
        noise = rs.normal(loc=0.0, scale=noise_std, size=t.size)
        for s in signals:
            noise += s
        signal = noise
    else:
        signal = signals[0]
        for s in signals[1:]:
            signal += s
    stats = {
        "network": network,
        "station": station,
        "location": location,
        "channel": channel,
        "npts": len(signal),
        "sampling_rate": sampling_rate,
        "mseed": {"dataquality": "D"},
    }
    stats["starttime"] = starttime
    stats["endtime"] = stats["starttime"] + nsec
    if gaps:
        p1 = (0.27, 0.33)
        p2 = (0.69, 0.83)
        p3 = (0.95, 1)
        for pmin, pmax in [p1, p2, p3]:
            idx0 = int(pmin * nsec * sampling_rate)
            idx1 = int(pmax * nsec * sampling_rate)
            signal[idx0:idx1] = np.nan
    return Trace(data=signal, header=stats)

Evaluation and scoring

compute_rates(model, pew, debug=False)

Compute the forecasted rates for positive and negative windows.

Arguments:

model: pandas.DataFrame
    The model probabilities.
pew: int
    The pre-eruption window size.
debug: bool, optional
    Whether to print debug information.

Returns:

dict: The forecasted rates for positive and negative windows as well as the overall rate.
Source code in src/aitana/scoring.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def compute_rates(model: pd.DataFrame, pew: int, debug: bool = False):
    """
    Compute the forecasted rates for positive and negative windows.

    Arguments:
    ----------
        model: pandas.DataFrame
            The model probabilities.
        pew: int
            The pre-eruption window size.
        debug: bool, optional
            Whether to print debug information.
    Returns:
    --------
        dict: The forecasted rates for positive and negative windows as well as the overall rate.
    """
    positive_windows, negative_windows = get_evaluation_windows(
        model.index[0], model.index[-1], pew
    )
    positive_rates = []
    for win_start, win_end in positive_windows:
        tmp_model = model.loc[win_start:win_end]
        rates = -np.log(1 - tmp_model)
        if debug:
            print("--->", win_start, win_end, "Forecasted rate:", rates)
        positive_rates.append(rates)

    negative_rates = []
    for win_start, win_end in negative_windows:
        tmp_model = model.loc[win_start:win_end]
        rates = -np.log(1 - tmp_model)
        if debug:
            print("--->", win_start, win_end, "Forecasted rate:", rates)
        negative_rates.append(rates)
    positive_rates = np.concatenate(positive_rates)
    negative_rates = np.concatenate(negative_rates)

    yearly_scale = 365.25 / pew
    pos_mean = np.mean(positive_rates) * yearly_scale
    pos_sem = np.std(positive_rates) / np.sqrt(len(positive_rates)) * yearly_scale
    neg_mean = np.mean(negative_rates) * yearly_scale
    neg_sem = np.std(negative_rates) / np.sqrt(len(negative_rates)) * yearly_scale
    overall_rate = np.mean(-np.log(1 - model)) * yearly_scale
    overall_sem = np.std(-np.log(1 - model)) / np.sqrt(len(model)) * yearly_scale

    return dict(
        positive_rates=(pos_mean, pos_sem),
        negative_rates=(neg_mean, neg_sem),
        overall_rate=(overall_rate, overall_sem),
    )

evaluate_threshold(thresh, model, debug=False, pew=90, return_windows=False)

Evaluate the number of true positives, false positives, true negatives and false negatives for a given threshold.

Arguments:

thresh: float
    The threshold value.
model: pandas.DataFrame
    The forecasted probabilities.
debug: bool, optional
    Whether to print debug information.
pew: int, optional
    The pre-eruption window size.
return_windows: bool, optional
    Whether to return the positive and negative windows.

Returns:

dict: The evaluation results.
list: A list of dictionaries for the tp, fp, tn, fn windows.
Source code in src/aitana/scoring.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def evaluate_threshold(
    thresh: float,
    model: pd.DataFrame,
    debug: bool = False,
    pew: int = 90,
    return_windows: bool = False,
):
    """
    Evaluate the number of true positives, false positives, true negatives and false negatives for a given threshold.

    Arguments:
    ----------
        thresh: float
            The threshold value.
        model: pandas.DataFrame
            The forecasted probabilities.
        debug: bool, optional
            Whether to print debug information.
        pew: int, optional
            The pre-eruption window size.
        return_windows: bool, optional
            Whether to return the positive and negative windows.

    Returns:
    --------
        dict: The evaluation results.
        list: A list of dictionaries for the tp, fp, tn, fn windows.
    """
    try:
        model.index = pd.to_datetime(model.index).tz_localize("UTC")
    except TypeError:
        pass

    starttime = max(pd.Timestamp(model.index[0]), pd.Timestamp("2013-01-01", tz="UTC"))
    endtime = pd.Timestamp(model.index[-1])
    explosive_eruptions = model["eruptions"].loc[model["eruptions"] > 0]
    model = model["prob"].loc[starttime:endtime]
    # normalise to 0-1
    model = (model - model.min()) / (model.max() - model.min())
    dt = pd.to_datetime(model.index)
    bin_model = np.where(model >= thresh, 1, 0)
    if len(bin_model.shape) > 1:
        bin_model = bin_model[0]
    # assign data on eruption days to the value on the day before
    eidx = np.where(np.isin(model.index, explosive_eruptions.index))[0]
    bin_model[eidx] = bin_model[eidx - 1]
    negative_windows = []
    positive_windows = []
    first_day = 0
    alert = bin_model[0]
    for i, date in enumerate(dt):
        if bin_model[i] != alert or i == len(dt) - 1:
            last_day = i - 1
            if i == len(dt) - 1:
                last_day = i
            if alert > 0:
                positive_windows.append((first_day, last_day))
            else:
                negative_windows.append((first_day, last_day))
            first_day = i
            alert = bin_model[i]

    true_positives = 0
    false_positives = 0
    true_negatives = 0
    false_negatives = 0
    windows = []
    if debug:
        print("Positive windows:")
    for win_start, win_end in positive_windows:
        date_start = dt[win_start]
        date_end = dt[win_end]
        if debug:
            print("--->", date_start, date_end)
        explosion_in_window = False
        for ee in explosive_eruptions.index:
            if date_start < ee <= date_end:
                if pew is not None:
                    fp_ = tp_ = tn_ = fn_ = 0
                    pre_eruption_window = ee - pd.Timedelta(days=pew)
                    tp_ += (date_end - ee).days
                    tp_ += (ee - max(pre_eruption_window, date_start)).days
                    pre_win = (date_start - pre_eruption_window).days
                    if pre_win < 0:
                        fp_ += abs(pre_win)
                    else:
                        fn_ += pre_win
                    false_positives += fp_
                    true_positives += tp_
                    false_negatives += fn_
                    # subtract from previous windows true_negatives
                    true_negatives -= fn_
                else:
                    true_positives += win_end - win_start + 1
                    windows.append(
                        dict(start=date_start, end=date_end, type="true_positive")
                    )
                if debug:
                    print("Eruption in positive window: ", date_start, date_end)
                # stop if there is at least one eruption in the window
                explosion_in_window = True
                break
        if not explosion_in_window:
            false_positives += win_end - win_start + 1
            windows.append(dict(start=date_start, end=date_end, type="false_positive"))

    if debug:
        print("Negative windows:")
    for win_start, win_end in negative_windows:
        date_start = dt[win_start]
        date_end = dt[win_end]
        if debug:
            print("--->", date_start, date_end)
        explosion_in_window = False
        for ee in explosive_eruptions.index:
            if date_start < ee <= date_end:
                if pew is not None:
                    fp_ = tp_ = tn_ = fn_ = 0
                    tn_ = (date_end - ee).days
                    pre_eruption_window = ee - pd.Timedelta(days=pew)
                    fn_ = (ee - max(pre_eruption_window, date_start)).days
                    pre_win = (date_start - pre_eruption_window).days
                    if pre_win < 0:
                        tn_ += abs(pre_win)
                    true_negatives += tn_
                    false_negatives += fn_
                else:
                    false_negatives += win_end - win_start + 1
                    # if window ends later than 09/01/2020 count the
                    # days between that date and the window end date as true negatives
                    # as the last explosive eruption was on 09/12/2019
                    if date_end > pd.Timestamp("2020-01-09", tz="UTC"):
                        diff = (date_end - pd.Timestamp("2020-01-09", tz="UTC")).days
                        true_negatives += diff
                        false_negatives -= diff
                        windows.append(
                            dict(
                                start=date_start,
                                end=date_end - pd.Timedelta(days=diff),
                                type="false_negative",
                            )
                        )
                        windows.append(
                            dict(
                                start=date_end - pd.Timedelta(days=diff - 1),
                                end=date_end,
                                type="true_negative",
                            )
                        )
                    else:
                        windows.append(
                            dict(start=date_start, end=date_end, type="false_negative")
                        )
                if debug:
                    print("Eruption in negative window: ", date_start, date_end)
                # stop if there is at least one eruption in the window
                explosion_in_window = True
                break
        if not explosion_in_window:
            true_negatives += win_end - win_start + 1
            windows.append(dict(start=date_start, end=date_end, type="true_negative"))
    if return_windows:
        return dict(
            tp=true_positives, fp=false_positives, tn=true_negatives, fn=false_negatives
        ), windows
    return dict(
        tp=true_positives, fp=false_positives, tn=true_negatives, fn=false_negatives
    )

get_evaluation_windows(starttime, endtime, pew)

Get the evaluation windows for the forecasted rates.

Arguments:

starttime: pd.Timestamp
    The start time of the forecast.
endtime: pd.Timestamp
    The end time of the forecast.
pew: int
    The pre-eruption window size.

Returns:

positive_windows: list
    The pre-eruption windows.
negative_windows: list
    Non pre-eruption windows.
Source code in src/aitana/scoring.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def get_evaluation_windows(starttime, endtime, pew):
    """
    Get the evaluation windows for the forecasted rates.

    Arguments:
    ----------
        starttime: pd.Timestamp
            The start time of the forecast.
        endtime: pd.Timestamp
            The end time of the forecast.
        pew: int
            The pre-eruption window size.
    Returns:
    --------
        positive_windows: list
            The pre-eruption windows.
        negative_windows: list
            Non pre-eruption windows.
    """
    explosive_eruptions = whakaari.eruptions(2, "0D", end_date=endtime)
    explosive_eruptions = explosive_eruptions.loc[starttime:endtime]
    positive_windows = []
    negative_windows = []
    tstart = starttime
    for e in explosive_eruptions.iterrows():
        positive_windows.append((e[0] - pd.Timedelta(days=pew - 1), e[0]))
        negative_windows.append((tstart, e[0] - pd.Timedelta(days=pew)))
        tstart = e[0] + pd.Timedelta(days=30)
    negative_windows.append((tstart, endtime))
    return positive_windows, negative_windows

get_roc_curve(model, thresholds, func, debug=False)

Compute the ROC curve for a given model and thresholds.

Arguments:

model: pandas.DataFrame
    The model probabilities.
thresholds: list
    The thresholds to evaluate.
func: function
    The evaluation function.
debug: bool, optional
    Whether to print debug information.
Source code in src/aitana/scoring.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def get_roc_curve(
    model: pd.DataFrame, thresholds: list, func: Callable, debug: bool = False
):
    """
    Compute the ROC curve for a given model and thresholds.

    Arguments:
    ----------
        model: pandas.DataFrame
            The model probabilities.
        thresholds: list
            The thresholds to evaluate.
        func: function
            The evaluation function.
        debug: bool, optional
            Whether to print debug information.
    """
    tpr = np.zeros(len(thresholds))
    fpr = np.zeros(len(thresholds))
    precision = np.zeros(len(thresholds))

    for i, thresh in enumerate(thresholds):
        result = func(thresh, model)
        if debug:
            print(thresh, result)
        try:
            tpr_ = result["tp"] / (result["tp"] + result["fn"])
            fpr_ = result["fp"] / (result["fp"] + result["tn"])
            prec_ = result["tp"] / (result["tp"] + result["fp"])
        except ZeroDivisionError:
            if debug:
                print(
                    "Threshold: ",
                    thresh,
                    "True positives: ",
                    result["tp"],
                    "False negatives: ",
                    result["fn"],
                )
                print(
                    "Threshold: ",
                    thresh,
                    "True negatives: ",
                    result["tn"],
                    "False positives: ",
                    result["fp"],
                )
            continue
        # start the evaluation from the first correct alerts
        if result["tp"] == 0:
            continue
        tpr[i] = tpr_
        fpr[i] = fpr_
        precision[i] = prec_
    # make sure that tpr and fpr end in 1
    # so that the AUC value is comparable
    tpr = np.r_[tpr, 1.0]
    fpr = np.r_[fpr, 1.0]
    return tpr, fpr, precision

make_strictly_increasing(sequence)

Make a sequence strictly increasing. Some of the ROC curves computed with our own metric are not strictly increasing due to the way tps, fps, tns and fns are defined. This function makes sure that the sequence is strictly increasing so that we can caluculate the AUC value.

Arguments:

sequence: Sequence
    The sequence to make strictly increasing.

Returns:

Sequence: The strictly increasing sequence.
Source code in src/aitana/scoring.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def make_strictly_increasing(sequence: Sequence) -> Sequence:
    """
    Make a sequence strictly increasing. Some of the ROC curves computed with
    our own metric are not strictly increasing due to the way tps, fps, tns and fns
    are defined. This function makes sure that the sequence is strictly increasing so
    that we can caluculate the AUC value.

    Arguments:
    ----------
        sequence: Sequence
            The sequence to make strictly increasing.

    Returns:
    --------
        Sequence: The strictly increasing sequence.
    """
    # Make a copy to avoid modifying the original list
    result = list(sequence)

    # Iterate through the sequence starting from the second element
    for i in range(1, len(result)):
        # If the current element is not greater than the previous one
        if result[i] <= result[i - 1]:
            # Increment the current element to be greater than the previous one
            result[i] = result[i - 1]

    return result

State-space models

LocalLinearTrend

Bases: MLEModel

Source code in src/aitana/assimilate.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class LocalLinearTrend(MLEModel):
    def __init__(self, endog, obs_cov=None):
        """
        Univariate Local Linear Trend Model
        """
        # Model order
        k_states = k_posdef = 2
        if obs_cov is not None:
            self.obs_cov_median = np.median(obs_cov)
            obs_cov = obs_cov[np.newaxis, np.newaxis, :]


        # Initialize the statespace
        super(LocalLinearTrend, self).__init__(
            endog, k_states=k_states, k_posdef=k_posdef,
            initialization='approximate_diffuse',
            loglikelihood_burn=k_states
        )
        # Initialize the matrices
        self.ssm['design'] = np.array([1, 0])
        self.ssm['transition'] = np.array([[1, 1],
                                       [0, 1]])
        self.ssm['selection'] = np.eye(k_states)

        # Cache some indices
        self._state_cov_idx = ('state_cov',) + np.diag_indices(k_posdef)
        if obs_cov is not None:
            self.ssm['obs_cov'] = obs_cov
        self.obs_cov = obs_cov

    def clone(self, endog, exog, **kwargs):
        # This method must be set to allow forecasting in custom
        # state space models that include time-varying state
        # space matrices, like we have for state_intercept here
        obs_cov = np.ones(endog.shape[0])*self.obs_cov_median
        mod = self.__class__(endog, obs_cov=obs_cov)
        return mod

    @property
    def param_names(self):
        if self.obs_cov is None:
            return ['sigma2.measurement', 'sigma2.level', 'sigma2.trend']
        return ['sigma2.level', 'sigma2.trend']

    @property
    def start_params(self):
        if self.obs_cov is None:
            return [np.nanstd(self.endog)]*3
        return [np.nanstd(self.endog)]*2

    def transform_params(self, unconstrained):
        return unconstrained**2

    def untransform_params(self, constrained):
        return constrained**0.5

    def update(self, params, *args, **kwargs):
        params = super(LocalLinearTrend, self).update(params, *args, **kwargs)

        if self.obs_cov is None:
            # Observation covariance
            self.ssm['obs_cov',0,0] = params[0]

            # State covariance
            self.ssm[self._state_cov_idx] = params[1:3]
        else:
            # State covariance
            self.ssm[self._state_cov_idx] = params[:]

__init__(endog, obs_cov=None)

Univariate Local Linear Trend Model

Source code in src/aitana/assimilate.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(self, endog, obs_cov=None):
    """
    Univariate Local Linear Trend Model
    """
    # Model order
    k_states = k_posdef = 2
    if obs_cov is not None:
        self.obs_cov_median = np.median(obs_cov)
        obs_cov = obs_cov[np.newaxis, np.newaxis, :]


    # Initialize the statespace
    super(LocalLinearTrend, self).__init__(
        endog, k_states=k_states, k_posdef=k_posdef,
        initialization='approximate_diffuse',
        loglikelihood_burn=k_states
    )
    # Initialize the matrices
    self.ssm['design'] = np.array([1, 0])
    self.ssm['transition'] = np.array([[1, 1],
                                   [0, 1]])
    self.ssm['selection'] = np.eye(k_states)

    # Cache some indices
    self._state_cov_idx = ('state_cov',) + np.diag_indices(k_posdef)
    if obs_cov is not None:
        self.ssm['obs_cov'] = obs_cov
    self.obs_cov = obs_cov

SemiLinearTrend

Bases: MLEModel

Source code in src/aitana/assimilate.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class SemiLinearTrend(MLEModel):
    def __init__(self, endog):
        """
        Univariate Local Linear Trend Model
        """
        # Model order
        k_states = k_posdef = 2

        # Initialize the statespace
        super(SemiLinearTrend, self).__init__(
            endog, k_states=k_states, k_posdef=k_posdef,
            initialization='approximate_diffuse',
            loglikelihood_burn=k_states)

        # Initialize the matrices
        self.ssm['design'] = np.array([1, 0])
        self.ssm['transition'] = np.array([[1, 1],
                                       [0, 1]])
        self.ssm['selection'] = np.eye(k_states)

        # Cache some indices
        self._state_cov_idx = ('state_cov',) + np.diag_indices(k_posdef)

    @property
    def param_names(self):
        return ['sigma2.measurement', 'sigma2.level', 'sigma2.trend', 'rho']

    @property
    def start_params(self):
        return np.r_[[np.nanstd(self.endog)]*3, 0.99]

    def transform_params(self, unconstrained):
        return unconstrained**2

    def untransform_params(self, constrained):
        return constrained**0.5

    def update(self, params, *args, **kwargs):
        params = super(SemiLinearTrend, self).update(params, *args, **kwargs)

        # Observation covariance
        self.ssm['obs_cov',0,0] = params[0]

        # State covariance
        self.ssm[self._state_cov_idx] = params[1:3]
        self['transition', 1, 1] = params[3]

__init__(endog)

Univariate Local Linear Trend Model

Source code in src/aitana/assimilate.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def __init__(self, endog):
    """
    Univariate Local Linear Trend Model
    """
    # Model order
    k_states = k_posdef = 2

    # Initialize the statespace
    super(SemiLinearTrend, self).__init__(
        endog, k_states=k_states, k_posdef=k_posdef,
        initialization='approximate_diffuse',
        loglikelihood_burn=k_states)

    # Initialize the matrices
    self.ssm['design'] = np.array([1, 0])
    self.ssm['transition'] = np.array([[1, 1],
                                   [0, 1]])
    self.ssm['selection'] = np.eye(k_states)

    # Cache some indices
    self._state_cov_idx = ('state_cov',) + np.diag_indices(k_posdef)

Visualisation

scoring_plot(forecast, threshold, scoring_function, debug=False, ax=None)

Plot the forecast probabilities and the evaluation windows.

Arguments:

forecast: pandas.Series
    The forecast probabilities.
threshold: float
    The threshold value.
scoring_function: Callable
    Function that returns (stats, time_windows) given (threshold, trace).
debug: bool, optional
    Whether to print debug information.
ax: matplotlib Axes, optional
    The axes to plot on. If None, a new figure and axes are created.
Source code in src/aitana/visualise.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def scoring_plot(
    forecast: pd.Series,
    threshold: float,
    scoring_function: Callable,
    debug: bool = False,
    ax=None,
):
    """
    Plot the forecast probabilities and the evaluation windows.

    Arguments:
    ----------
        forecast: pandas.Series
            The forecast probabilities.
        threshold: float
            The threshold value.
        scoring_function: Callable
            Function that returns (stats, time_windows) given (threshold, trace).
        debug: bool, optional
            Whether to print debug information.
        ax: matplotlib Axes, optional
            The axes to plot on. If None, a new figure and axes are created.
    """
    try:
        forecast.index = pd.to_datetime(forecast.index).tz_localize("UTC")
    except TypeError:
        pass
    time = forecast.index
    eruptions = whakaari.eruptions(2, "0D", end_date=time[-1]).loc[time[0] : time[-1]]
    trace = pd.DataFrame(
        {
            "prob": (forecast - forecast.min()) / (forecast.max() - forecast.min()),
            "eruptions": eruptions["Activity_Scale"].reindex(time, fill_value=0),
        },
        index=time,
    )
    stats_, time_windows = scoring_function(threshold, trace)
    if debug:
        print(stats_)
    if ax is None:
        _, ax = plt.subplots()

    colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
    n = len(colors)
    cl_ = dict(
        true_positive=colors[5 % n],
        true_negative=colors[1 % n],
        false_positive=colors[4 % n],
        false_negative=colors[0 % n],
    )

    _eruption_label_shown = False
    for t in eruptions.index:
        ax.axvline(
            t,
            ymin=0.0,
            ymax=0.7,
            linewidth=0.7,
            color="black",
            label="Observed Eruption" if not _eruption_label_shown else "_nolegend_",
        )
        _eruption_label_shown = True

    for window in time_windows:
        start = window["start"]
        end = window["end"]
        type_ = window["type"]

        mask = (time >= start) & (time <= end)
        x_window = time[mask]
        y_window = trace["prob"][mask]

        color = cl_[type_]
        ax.fill_between(
            x_window,
            y_window,
            threshold,
            color=color,
            alpha=0.7,
            linewidth=0,
        )

    legend_handles = [
        mpatches.Patch(color=color, label=type_.replace("_", " ").capitalize())
        for type_, color in cl_.items()
    ]

    ax.set_ylim(0, 1)

    return ax, legend_handles

trellis_plot(models, data, plot_uncertainty=False, groups=['b', 'c', 'd', 'e'], q_min=0.15, q_max=0.85)

Create a trellis plot for the given models and data.

Parameters:
  • models (dict) –

    A dictionary containing the models to plot. Each key should be a model name, and each value should be a dictionary with keys 'model', 'color', and optionally 'colorscale' for ensemble models. The 'model' key should contain a xarray DataArray.

  • data (DataFrame) –

    A pandas DataFrame containing the data to plot. It should have a datetime index and a 'group' column indicating the group (b, c, d, e) for each time point.

  • plot_uncertainty (bool, default: False ) –

    Whether to plot uncertainty for the models. Default is False.

  • q_min (float, default: 0.15 ) –

    The minimum quantile to use for plotting uncertainty. Default is 0.15.

  • q_max (float, default: 0.85 ) –

    The maximum quantile to use for plotting uncertainty. Default is 0.85.

Returns:
  • fig( Figure ) –

    A Matplotlib figure object containing the trellis plot.

Source code in src/aitana/visualise.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def trellis_plot(
    models: dict,
    data: pd.DataFrame,
    plot_uncertainty: bool = False,
    groups=["b", "c", "d", "e"],
    q_min: float = 0.15,
    q_max: float = 0.85,
):
    """Create a trellis plot for the given models and data.

    Parameters
    ----------
    models : dict
        A dictionary containing the models to plot. Each key should be a model name,
        and each value should be a dictionary with keys 'model', 'color', and optionally
        'colorscale' for ensemble models. The 'model' key should contain a xarray DataArray.
    data : pd.DataFrame
        A pandas DataFrame containing the data to plot.
        It should have a datetime index and a 'group' column indicating the group (b, c, d, e)
        for each time point.
    plot_uncertainty : bool, optional
        Whether to plot uncertainty for the models. Default is False.
    q_min : float, optional
        The minimum quantile to use for plotting uncertainty. Default is 0.15.
    q_max : float, optional
        The maximum quantile to use for plotting uncertainty. Default is 0.85.

    Returns
    -------
    fig : matplotlib.figure.Figure
        A Matplotlib figure object containing the trellis plot.
    """
    # Per-row annotations: (text, x_date, y, ha, arrow_to_x)
    # arrow_to_x is a second x date if an arrow points elsewhere, else None
    row_annotations = {
        "b": [
            ("Dome extrusion", "2012-11-24", 1.05, "center", None),
            ("Geysering", "2013-02-15", 1.05, "center", None),
            (
                "Minor steam and mud eruptions",
                "2013-10-04",
                1.05,
                "right",
                "2013-08-17",
            ),
        ],
        "c": [
            ("Banded tremor", "2015-10-13", 1.05, "center", None),
        ],
        "d": [
            ("Non-explosive ash venting", "2016-09-13", 1.05, "center", None),
            ("Earthquake swarm", "2019-04-15", 1.05, "center", None),
            ("Minor ash emissions", "2019-12-31", 1.05, "center", None),
        ],
        "e": [
            ("Lava extrusion", "2020-01-15", 1.05, "center", None),
            ("Minor ash emissions", "2020-11-13", 1.05, "center", None),
            ("Small steam explosions", "2020-12-29", 1.05, "left", None),
            ("Minor ash emissions", "2022-09-18", 1.05, "center", None),
            ("Small steam explosion", "2024-05-24", 1.05, "center", None),
            ("Minor ash emissions", "2024-07-24", 1.05, "left", None),
        ],
    }

    # Per-row vrect spans: list of (x0, x1)
    row_vrects = {
        "b": [
            ("2012-11-22", "2012-12-10"),
            ("2013-01-15", "2013-04-10"),
            ("2013-08-15", "2013-08-18"),
            ("2013-10-01", "2013-10-08"),
        ],
        "c": [
            ("2015-10-13", "2015-10-20"),
        ],
        "d": [
            ("2016-09-13", "2016-09-18"),
            ("2019-04-23", "2019-07-01"),
            ("2019-12-23", "2019-12-29"),
        ],
        "e": [
            ("2020-01-10", "2020-01-20"),
            ("2020-11-13", "2020-12-01"),
            ("2020-12-29", "2021-01-02"),
            ("2022-09-18", "2022-09-24"),
            ("2024-05-24", "2024-05-31"),
            ("2024-07-24", "2024-09-10"),
        ],
    }

    prop_colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
    highlight_color = prop_colors[6 % len(prop_colors)]

    # Auto-assign colors from prop cycle if not provided
    _color_idx = 0
    for name, model in models.items():
        if name not in ("min", "max", "ensemble") and "color" not in model:
            model["color"] = prop_colors[_color_idx % len(prop_colors)]
            _color_idx += 1

    fig, axes = plt.subplots(
        len(groups), 1, figsize=(14, 12), sharex=False, constrained_layout=True
    )

    legend_handles = {}

    for irow, group_name in enumerate(groups):
        ax = axes[irow]
        ax_twin = ax.twinx()
        ax_twin.set_ylim(0, 1)
        ax_twin.set_yticks([])
        ax_twin.set_yticklabels([])

        # Derive the start/end of this group from data's index directly,
        # then mask each model by its own datetime falling within that range.
        group_times = data.index[data.group == group_name]
        t_start, t_end = group_times[0], group_times[-1]

        def model_mask(da):
            """Return a boolean mask over da's datetime dimension for this group."""
            model_times = pd.to_datetime(da["datetime"].values)
            # Normalise timezone: strip tz from model_times if data index is tz-naive,
            # or localise to UTC if data index is tz-aware.
            if t_start.tzinfo is None:
                model_times = model_times.tz_localize(None)
            else:
                model_times = (
                    model_times.tz_localize("UTC")
                    if model_times.tzinfo is None
                    else model_times
                )
            return (model_times >= t_start) & (model_times <= t_end)

        # Keep a reference time axis for eruption markers (from any plotted model)
        time = None

        for name, model in models.items():
            if name in ["min", "max", "ensemble"]:
                continue
            msk = model_mask(model["model"])
            time = pd.to_datetime(model["model"]["datetime"].values)[msk]
            probs = model["model"].values[msk]
            linestyle = {"solid": "-", "dash": "--", "dot": ":"}.get(
                model.get("dash", "solid"), "-"
            )
            (line,) = ax.plot(
                time, probs, color=model["color"], linestyle=linestyle, label=name
            )
            if name not in legend_handles:
                legend_handles[name] = line

        # Uncertainty
        if plot_uncertainty == "quantile" and "min" in models and "max" in models:
            msk = model_mask(models["min"]["model"])
            time = pd.to_datetime(models["min"]["model"]["datetime"].values)[msk]
            probs_min = models["min"]["model"].values[msk]
            probs_max = models["max"]["model"].values[msk]
            ax.fill_between(
                time,
                probs_min,
                probs_max,
                color=models["min"]["color"],
                alpha=0.3,
            )
        elif plot_uncertainty == "ensemble" and "ensemble" in models:
            ens_model = models["ensemble"]["model"]
            colorscale = models["ensemble"]["colorscale"]
            scores = ens_model.model_score.values
            cmap = plt.get_cmap(
                colorscale if isinstance(colorscale, str) else "viridis"
            )
            norm = mcolors.Normalize(vmin=float(scores.min()), vmax=float(scores.max()))
            msk = model_mask(ens_model)
            time = pd.to_datetime(ens_model["datetime"].values)[msk]
            for j in range(len(scores)):
                model_to_plot = ens_model.isel(model_score=j).values[msk]
                color = cmap(norm(float(scores[j])))
                ax.plot(time, model_to_plot, color=color, linewidth=0.1, alpha=1.0)

        # Eruption markers on twin axis
        t1 = pd.Timestamp(time[0], tz="UTC")
        t2 = pd.Timestamp(time[-1], tz="UTC")
        dfe = whakaari.eruptions(end_date=data.index[-1]).loc[t1:t2]
        for i, erupt_time in enumerate(dfe.index):
            label = (
                "Explosive Eruption"
                if "Explosive Eruption" not in legend_handles
                else None
            )
            vline = ax_twin.axvline(
                erupt_time, color="black", linewidth=0.8, label=label
            )
            if label:
                legend_handles["Explosive Eruption"] = vline

        # Highlighted spans
        for x0_str, x1_str in row_vrects.get(group_name, []):
            ax.axvspan(
                pd.Timestamp(x0_str),
                pd.Timestamp(x1_str),
                color=highlight_color,
                alpha=0.2,
                zorder=0,
            )

        # Annotations
        for annot in row_annotations.get(group_name, []):
            text, x_str, y_frac, ha, arrow_x_str = annot
            x_dt = pd.Timestamp(x_str)
            ax.annotate(
                text,
                xy=(pd.Timestamp(arrow_x_str) if arrow_x_str else x_dt, 1.0),
                xytext=(x_dt, y_frac),
                xycoords=("data", "axes fraction"),
                textcoords=("data", "axes fraction"),
                ha=ha,
                fontsize=16,
                arrowprops=dict(arrowstyle="->", color="black")
                if arrow_x_str
                else None,
            )

        ax.set_ylim(0, 1)
        ax.set_ylabel("Probability", fontsize=20)
        ax.tick_params(axis="both", labelsize=20)
        ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
        ax.xaxis.set_major_locator(mdates.YearLocator())
        plt.setp(ax.get_xticklabels(), rotation=0, ha="center")

    # Shared legend above all subplots
    fig.legend(
        handles=list(legend_handles.values()),
        labels=list(legend_handles.keys()),
        loc="lower center",
        ncol=len(legend_handles),
        fontsize=20,
        bbox_to_anchor=(0.5, -0.08),
    )

    return fig

CLI and workflows

SnakemakeBackend

Source code in src/aitana/volcanobench.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
class SnakemakeBackend:
    def __init__(
        self, workflowdir: Path, outdir: Path, default_args: tuple[str, ...] = ()
    ):
        self.workflowdir = workflowdir
        self.outdir = outdir
        self.default_args = default_args
        self.outdir.mkdir(parents=True, exist_ok=True)
        self.snakefile = self._prepare_workflow_directory()

    def _prepare_workflow_directory(self) -> str:
        """Copy the snakefile, rules/, and notebooks/ into *directory* if it differs
        from the bundled workflow directory.  Returns the path to the snakefile that
        should be passed to snakemake (the copy when applicable, original otherwise).
        """
        if self.outdir.resolve() == self.workflowdir.resolve():
            raise PipelineError(
                "Output directory cannot be the same as the workflow directory."
            )
        shutil.copytree(self.workflowdir, self.outdir, dirs_exist_ok=True)
        return str(self.outdir / "Snakefile")

    def _base_cmd(self) -> list[str]:
        return [
            "snakemake",
            "--snakefile",
            str(self.snakefile),
            "--directory",
            str(self.outdir),
        ]

    def _unlock(self) -> None:
        cmd = self._base_cmd() + ["--unlock"]
        try:
            subprocess.run(cmd, check=False, capture_output=True)
        except Exception as e:
            logger.warning("snakemake --unlock failed: %s", e)

    def execute(self, cores: int, extra: Sequence[str] | None = None) -> None:
        self._unlock()
        cmd = self._base_cmd() + ["--cores", str(cores)]
        cmd.extend(self.default_args)
        if extra:
            cmd.extend(extra)
        result = subprocess.run(cmd, check=False)
        if result.returncode != 0:
            raise PipelineError("Snakemake failed.")

    def clean(self) -> None:
        cmd = self._base_cmd() + ["--cores", "1", "--delete-all-output"]
        cmd.extend(self.default_args)
        subprocess.run(cmd, check=True)

clean(volcano, outdir)

Delete all workflow outputs for a volcano.

Source code in src/aitana/volcanobench.py
116
117
118
119
120
@app.command()
def clean(volcano: str, outdir: str):
    """Delete all workflow outputs for a volcano."""
    for w in _resolve(volcano):
        SnakemakeBackend(w.workflowfile, Path(outdir)).clean()

list_workflows()

List all registered workflows.

Source code in src/aitana/volcanobench.py
123
124
125
126
127
128
129
130
131
@app.command(name="list")
def list_workflows():
    """List all registered workflows."""
    workflows = discover_workflows()
    if not workflows:
        typer.echo("No workflows registered.")
        return
    for w in workflows.values():
        typer.echo(f"{w.name} ({w.volcano}): {w.description}")

run(volcano, outdir, cores=1)

Run all registered benchmark workflows for a volcano.

Source code in src/aitana/volcanobench.py
109
110
111
112
113
@app.command()
def run(volcano: str, outdir: str, cores: int = 1):
    """Run all registered benchmark workflows for a volcano."""
    for w in _resolve(volcano):
        SnakemakeBackend(w.workflowdir, Path(outdir)).execute(cores=cores)

Logging configuration for the aitana package.

This module sets up logging to output to stdout/stderr without file handlers.

get_logger(name)

Get a logger for a specific module within aitana.

Parameters:

name : str
    The name of the module (e.g., 'aitana.ruapehu')

Returns:

logging.Logger
    A configured logger instance
Source code in src/aitana/logging_config.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def get_logger(name):
    """
    Get a logger for a specific module within aitana.

    Parameters:
    -----------
        name : str
            The name of the module (e.g., 'aitana.ruapehu')

    Returns:
    --------
        logging.Logger
            A configured logger instance
    """
    return logging.getLogger(name)

setup_logging(level=logging.INFO)

Configure logging for the aitana package.

By default, INFO and DEBUG messages go to stdout, WARNING, ERROR, and CRITICAL go to stderr.

Parameters:

level : int
    The logging level (e.g., logging.DEBUG, logging.INFO)
Source code in src/aitana/logging_config.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def setup_logging(level=logging.INFO):
    """
    Configure logging for the aitana package.

    By default, INFO and DEBUG messages go to stdout,
    WARNING, ERROR, and CRITICAL go to stderr.

    Parameters:
    -----------
        level : int
            The logging level (e.g., logging.DEBUG, logging.INFO)
    """
    # Get the root logger for the aitana package
    logger = logging.getLogger('aitana')
    logger.setLevel(level)

    # Prevent propagation to avoid duplicate logs
    logger.propagate = False

    # Remove existing handlers to avoid duplicates
    logger.handlers.clear()

    # Handler for INFO and DEBUG -> stdout
    stdout_handler = logging.StreamHandler(sys.stdout)
    stdout_handler.setLevel(logging.DEBUG)
    stdout_handler.addFilter(lambda record: record.levelno <= logging.INFO)

    # Handler for WARNING, ERROR, CRITICAL -> stderr
    stderr_handler = logging.StreamHandler(sys.stderr)
    stderr_handler.setLevel(logging.WARNING)

    # Create a formatter
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )

    stdout_handler.setFormatter(formatter)
    stderr_handler.setFormatter(formatter)

    # Add handlers to logger
    logger.addHandler(stdout_handler)
    logger.addHandler(stderr_handler)

    return logger