diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml new file mode 100644 index 00000000..0b5e69d6 --- /dev/null +++ b/.github/workflows/types.yml @@ -0,0 +1,23 @@ +name: Type Check +on: [pull_request] +jobs: + typecheck: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install project with types + run: uv sync --all-extras --dev + + - name: Run type checking with ty + run: uv run ty check --exclude setup.py --exclude nzgmdb/CCLD/ccldpy.py diff --git a/changelog.md b/changelog.md index 8beb442a..e22d40f0 100644 --- a/changelog.md +++ b/changelog.md @@ -5,7 +5,6 @@ * New Quality Filter to remove Broadband data during certain time periods due to sensitivity issues * New Quality Filter to compare against an empirical GMPE (Atkinson 2022) to remove significant outliers * Add ability to generate a report to compare NZGMDB versions -* Automatic report creation * Add GMC skipped reasons * Update to use the new NZCVM 2.09 basins * TPVZ calculation fix @@ -22,6 +21,7 @@ * Change processing to use remove response instead of remove sensitivity * Updated CMT solutions for domain regions * Increased date range to end of 2025 +* Vs30 and Z1.0 / 2.5 for missing station metadata ## Version 4.3 - July 25 **2000-01-01 to 2024-12-31** * Sensitivity Fix (previously always taking first value not for actual datetime expected) diff --git a/nzgmdb/calculation/aftershocks.py b/nzgmdb/calculation/aftershocks.py index 7b284210..111eda6e 100644 --- a/nzgmdb/calculation/aftershocks.py +++ b/nzgmdb/calculation/aftershocks.py @@ -185,7 +185,7 @@ def resample_polygon_1km(rupture_polygons: list[Polygon]) -> list[MultiPoint]: def calculate_crjb( rupture_poly: Polygon, boundary_points: MultiPoint, centroids: np.ndarray -) -> np.ndarray: +) -> np.ndarray | float: """ Calculates centroid Joyner-Boore (CRJB) distance for given earthquake centroids. @@ -200,8 +200,8 @@ def calculate_crjb( Returns ------- - numpy.ndarray - Array of min CRJB distances. + numpy.ndarray or float + Array of min CRJB distances for each centroid, or a single float if only one centroid is provided. """ # Gather the points of the boundary points = np.array([(p.x, p.y) for p in boundary_points.geoms]) diff --git a/nzgmdb/calculation/distances.py b/nzgmdb/calculation/distances.py index 9adf2f22..13d08051 100644 --- a/nzgmdb/calculation/distances.py +++ b/nzgmdb/calculation/distances.py @@ -107,9 +107,9 @@ def run_ccld_simulation( dip: float, rake: float, method: str, - strike2: float = None, - dip2: float = None, - rake2: float = None, + strike2: float | None = None, + dip2: float | None = None, + rake2: float | None = None, ) -> FocalMechanism: """ Run the CCLD simulation for an event. @@ -361,7 +361,7 @@ def get_nodal_plane_info( The focal type that determined the nodal plane (ff, geonet_rm, cmt, cmt_unc, domain) """ # Create the default return to be filled using defaultdict - nodal_plane_info = defaultdict(lambda: None) + nodal_plane_info: dict[str, object | None] = defaultdict(lambda: None) ccld_info = None # Split the cmt data into reviewed and unreviewed data @@ -496,6 +496,7 @@ def get_nodal_plane_info( hik_strike_rbf, hik_dip_rbf, hik_footprint = hik_objs puy_strike_rbf, puy_dip_rbf, puy_footprint = puy_objs domain_no_backup = event_row["domain_no_backup"] + nodal_plane_info["f_type"] = "domain" if event_row["tect_class"] == "Crustal": # First assume strike-slip to estimate length @@ -564,7 +565,7 @@ def get_nodal_plane_info( ccld_info = run_ccld_simulation( event_id, event_row, strike, dip, rake, "D" ) - nodal_plane_info.update(ccld_info) + nodal_plane_info.update(ccld_info) # type: ignore[no-matching-overload] return nodal_plane_info # Find the closest point in the table @@ -595,7 +596,7 @@ def get_nodal_plane_info( if ccld_info is not None: # Update the nodal plane info with the ccld info - nodal_plane_info.update(ccld_info) + nodal_plane_info.update(ccld_info) # type: ignore[no-matching-overload] return nodal_plane_info @@ -603,7 +604,7 @@ def get_nodal_plane_info( def compute_distances_for_event( event_row: pd.Series, im_df: pd.DataFrame, - station_df: pd.DataFrame, + site_df: pd.DataFrame, cmt_df: pd.DataFrame, domain_focal_df: pd.DataFrame, taupo_polygon: Polygon, @@ -612,7 +613,7 @@ def compute_distances_for_event( puy_objs: np.ndarray, nz_mech: dict, slab_faulting_geo: dict, -) -> tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]: +) -> tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]: """ Compute the distances for a given event @@ -622,8 +623,8 @@ def compute_distances_for_event( The event row from the earthquake source table im_df : pd.DataFrame The full IM data from the catalogue - station_df : pd.DataFrame - The full station data + site_df : pd.DataFrame + The full site data cmt_df : pd.DataFrame The Centroid Moment Tensor data domain_focal_df : pd.DataFrame @@ -660,8 +661,8 @@ def compute_distances_for_event( if im_event_df.empty: return None, None, None - # Get the station data - event_sta_df = station_df[station_df["sta"].isin(im_event_df["sta"])].reset_index() + # Get the site data + event_sta_df = site_df[site_df["sta"].isin(im_event_df["sta"])].reset_index() stations = event_sta_df[["lon", "lat", "depth"]].to_numpy() # Get the nodal plane information @@ -870,6 +871,7 @@ def compute_distances_for_event( [ { "evid": event_id, + "provider": station.provider, "net": station.net, "sta": station.sta, "r_epi": r_epis[station_index], @@ -1010,7 +1012,7 @@ def perpendicular_height( point_vec = point - base_start cross = np.cross(base_vec, point_vec) base_len = np.linalg.norm(base_vec) - return np.linalg.norm(cross) / base_len if base_len else 0.0 + return float(np.linalg.norm(cross) / base_len) if base_len else 0.0 def inverse_square_integral( @@ -1131,11 +1133,12 @@ def distance_in_taupo( # Loop through all the stations for station_index, station in sta_df.iterrows(): + idx = int(station_index) # type: ignore # Create the line between the station and the event sta_transform = wgs2nztm.transform(station.lat, station.lon) line = LineString( [ - [rrups_transform[0][station_index], rrups_transform[1][station_index]], + [rrups_transform[0][idx], rrups_transform[1][idx]], [sta_transform[0], sta_transform[1]], ] ) @@ -1167,7 +1170,7 @@ def distance_in_taupo( ) line_points = line.intersection(taupo_polygon) - tvz_length = min(line_points.length / 1000 / r_epis[station_index], 1) + tvz_length = min(line_points.length / 1000 / r_epis[idx], 1) tvz_lengths.append(tvz_length) boundary_dists_rjb.append(boundary_dist_rjb) @@ -1240,9 +1243,10 @@ def calc_distances(main_dir: Path, n_procs: int = 1): ll_num = config.get_value("ll_num") nztm_num = config.get_value("nztm_num") wgs2nztm = Transformer.from_crs(ll_num, nztm_num) - taupo_transform = np.dstack( - np.array(wgs2nztm.transform(tvz_points.latitude, tvz_points.longitude)) - )[0] + x, y = wgs2nztm.transform( + tvz_points.latitude.to_numpy(), tvz_points.longitude.to_numpy() + ) + taupo_transform = np.column_stack((x, y)) taupo_polygon = Polygon(taupo_transform) # Go through the registry keys and check if they are .srf files to use @@ -1260,38 +1264,24 @@ def calc_distances(main_dir: Path, n_procs: int = 1): usecols=["evid", "sta"], ) - # Get the station information - client_NZ = FDSN_Client("GEONET") - channel_codes = config.get_value("channel_codes") - inventory = client_NZ.get_stations(channel=channel_codes, level="station") - station_info = [] - for network in inventory: - for station in network: - station_info.append( - [ - network.code, - station.code, - station.latitude, - station.longitude, - station.elevation, - ] - ) - station_df = pd.DataFrame( - station_info, columns=["net", "sta", "lat", "lon", "elev"] + # Get the site information + site_df = pd.read_csv( + flatfile_dir / file_structure.PreFlatfileNames.SITE_TABLE, + dtype={"sta": str}, ) - station_df = station_df.drop_duplicates().reset_index(drop=True) + site_df = site_df.loc[:, ["sta", "provider", "net", "lat", "lon", "elev"]] # Select unique stations from IM data and merge im_station_df = im_df[["sta"]].drop_duplicates() - station_df = pd.merge(im_station_df, station_df, on="sta", how="left") - station_df["depth"] = station_df["elev"] / -1000 + site_df = pd.merge(im_station_df, site_df, on="sta", how="left") + site_df["depth"] = site_df["elev"] / -1000 with mp.Pool(n_procs) as p: result_dfs = p.map( functools.partial( compute_distances_for_event, im_df=im_df, - station_df=station_df, + site_df=site_df, cmt_df=cmt_df, domain_focal_df=domain_focal_df, taupo_polygon=taupo_polygon, diff --git a/nzgmdb/calculation/fmax.py b/nzgmdb/calculation/fmax.py index b4e46b3e..c172b942 100644 --- a/nzgmdb/calculation/fmax.py +++ b/nzgmdb/calculation/fmax.py @@ -20,7 +20,7 @@ def run_full_fmax_calc( waveform_dir: Path, snr_fas_output_dir: Path, n_procs: int = 1, - bypass_records_ffp: Path = None, + bypass_records_ffp: Path | None = None, ): """ Run the full procedure for each record to assess SNR produced from mseed files diff --git a/nzgmdb/calculation/snr.py b/nzgmdb/calculation/snr.py index faea1ee2..96dcdd01 100644 --- a/nzgmdb/calculation/snr.py +++ b/nzgmdb/calculation/snr.py @@ -66,7 +66,7 @@ def compute_snr_for_single_mseed( inventory = None if xml_dir: # Load the inventory information - inventory_file = xml_dir / f"NZ.{station}.xml" + inventory_file = xml_dir / f"{station}.xml" if inventory_file.is_file(): inventory = read_inventory(inventory_file) @@ -121,6 +121,13 @@ def compute_snr_for_single_mseed( } skipped_record = pd.DataFrame([skipped_record_dict]) return None, skipped_record + except custom_errors.DetrendError: + skipped_record_dict = { + "record_id": mseed_file.stem, + "reason": "Unable to detrend record", + } + skipped_record = pd.DataFrame([skipped_record_dict]) + return None, skipped_record # Get the TP from the phase arrival table try: @@ -222,9 +229,9 @@ def compute_snr_for_mseed_data( snr_fas_output_dir: Path, ko_directory: Path, n_procs: int = 1, - common_frequency_vector: np.ndarray = None, + common_frequency_vector: np.ndarray | None = None, batch_size: int = 5000, - bypass_records_ffp: Path = None, + bypass_records_ffp: Path | None = None, ): """ Compute the SNR for the data in the data_dir diff --git a/nzgmdb/config/config.yaml b/nzgmdb/config/config.yaml index b66b1e90..8a498346 100644 --- a/nzgmdb/config/config.yaml +++ b/nzgmdb/config/config.yaml @@ -24,9 +24,54 @@ priority_phase_list: - Pn - Pg - Pb -channel_codes: "HN?,BN?,HH?,BH?" +channel_codes: "HN?,BN?,HH?,BH?,EH?,DH?" percentage_gap_allowed: 0.1 is_large_overlap: 0.5 +# Provider / Network Filters +main_providers_networks: + GEONET: + - "IU" + - "NZ" +tmp_array_providers_networks: + AUSPASS: + - "2B" + - "2E" + - "6Y" + IRIS: + - "1U" + - "2B" + - "2C" + - "2L" + - "2P" + - "3C" + - "4A" + - "6F" + - "6K" + - "7D" + - "7S" + - "9F" + - "9G" + - "QC" + - "X2" + - "XA" + - "XB" + - "XH" + - "XO" + - "XQ" + - "Y3" + - "YA" + - "YG" + - "YO" + - "YR" + - "Z1" + - "Z8" + - "ZP" + - "ZT" + - "ZX" + IRISPH5: + - "6B" + RASPISHAKE: + - "AM" # Mseed Variables vs30: 500 pre_event_time_difference: 15 diff --git a/nzgmdb/config/machine_config.yaml b/nzgmdb/config/machine_config.yaml index d33466c8..3c57fd3b 100644 --- a/nzgmdb/config/machine_config.yaml +++ b/nzgmdb/config/machine_config.yaml @@ -35,14 +35,14 @@ hypocentre: distances: 44 upload: 44 rch: - geonet: 128 - extraction: 32 - tec_domain: 128 - phase_table: 128 - snr: 64 - fmax: 128 - gmc: 64 - process: 128 - im: 64 - distances: 128 - upload: 128 \ No newline at end of file + geonet: 64 + extraction: 64 + tec_domain: 64 + phase_table: 16 + snr: 16 + fmax: 64 + gmc: 16 + process: 64 + im: 32 + distances: 64 + upload: 64 \ No newline at end of file diff --git a/nzgmdb/data_processing/filtering.py b/nzgmdb/data_processing/filtering.py index 79929b8b..a7a721ca 100644 --- a/nzgmdb/data_processing/filtering.py +++ b/nzgmdb/data_processing/filtering.py @@ -3,10 +3,11 @@ """ import numpy as np -from gmprocess.waveform_processing.clipping.clipping_ann import clipNet -from gmprocess.waveform_processing.clipping.histogram import Histogram -from gmprocess.waveform_processing.clipping.max_amp import MaxAmp -from gmprocess.waveform_processing.clipping.ping import Ping + +# from gmprocess.waveform_processing.clipping.clipping_ann import clipNet +# from gmprocess.waveform_processing.clipping.histogram import Histogram +# from gmprocess.waveform_processing.clipping.max_amp import MaxAmp +# from gmprocess.waveform_processing.clipping.ping import Ping from obspy import Stream from nzgmdb.management import config as cfg @@ -31,43 +32,44 @@ def get_clip_probability(event_mag: float, dist: float, mseed: Stream) -> float: The clip probability from ClipNet """ # Get the config values - config = cfg.Config() - mag_clip_low = config.get_value("mag_clip_low") - mag_clip_high = config.get_value("mag_clip_high") - dist_clip_low = config.get_value("dist_clip_low") - dist_clip_high = config.get_value("dist_clip_high") - - # Ensure numeric inputs - try: - event_mag = float(event_mag) - except (TypeError, ValueError) as exc: - raise TypeError(f"event_mag must be a number, got {event_mag!r}") from exc - - try: - dist = float(dist) - except (TypeError, ValueError) as exc: - raise TypeError(f"dist must be a number, got {dist!r}") from exc - - # Clip the event_mag and dist values - event_mag = np.clip(event_mag, mag_clip_low, mag_clip_high) - dist = np.clip(dist, dist_clip_low, dist_clip_high) - - # Get different methods for clipping - max_amp_method = MaxAmp(mseed) - hist_method = Histogram(mseed) - ping_method = Ping(mseed) - - # Define the inputs for the clipNet - inputs = [ - event_mag, - dist, - max_amp_method.is_clipped, - hist_method.is_clipped, - ping_method.is_clipped, - ] - # Get the clip probability - clip_nnet = clipNet() - return clip_nnet.evaluate(inputs)[0][0] + # config = cfg.Config() + # mag_clip_low = config.get_value("mag_clip_low") + # mag_clip_high = config.get_value("mag_clip_high") + # dist_clip_low = config.get_value("dist_clip_low") + # dist_clip_high = config.get_value("dist_clip_high") + # + # # Ensure numeric inputs + # try: + # event_mag = float(event_mag) + # except (TypeError, ValueError) as exc: + # raise TypeError(f"event_mag must be a number, got {event_mag!r}") from exc + # + # try: + # dist = float(dist) + # except (TypeError, ValueError) as exc: + # raise TypeError(f"dist must be a number, got {dist!r}") from exc + # + # # Clip the event_mag and dist values + # event_mag = np.clip(event_mag, mag_clip_low, mag_clip_high) + # dist = np.clip(dist, dist_clip_low, dist_clip_high) + # + # # Get different methods for clipping + # max_amp_method = MaxAmp(mseed) + # hist_method = Histogram(mseed) + # ping_method = Ping(mseed) + # + # # Define the inputs for the clipNet + # inputs = [ + # event_mag, + # dist, + # max_amp_method.is_clipped, + # hist_method.is_clipped, + # ping_method.is_clipped, + # ] + # # Get the clip probability + # clip_nnet = clipNet() + # return clip_nnet.evaluate(inputs)[0][0] + return 0.0 def get_jerk(mseed: Stream) -> bool: diff --git a/nzgmdb/data_processing/merge_flatfiles.py b/nzgmdb/data_processing/merge_flatfiles.py index d6609b3b..4bd10e7b 100644 --- a/nzgmdb/data_processing/merge_flatfiles.py +++ b/nzgmdb/data_processing/merge_flatfiles.py @@ -154,13 +154,16 @@ def merge_im_data( def add_ground_level( + station_df: pd.DataFrame, gm_im_df_flat: pd.DataFrame, ): """ - Add in the ground level location elevation information to the gm_im_df_flat dataframe + Add in the is ground level location elevation information to the gm_im_df_flat dataframe Parameters ---------- + station_df : pd.DataFrame + The station dataframe containing the station information such as loc_elev gm_im_df_flat : pd.DataFrame The ground motion IM dataframe to add the ground level information to @@ -169,46 +172,6 @@ def add_ground_level( pd.DataFrame The ground motion IM dataframe with the ground level information added """ - # Find the station location information with the inventory lat, lon and elev - config = cfg.Config() - channel_codes = config.get_value("channel_codes") - client_NZ = FDSN_Client("GEONET") - inventory = client_NZ.get_stations(channel=channel_codes, level="response") - station_info = [ - [ - station.code, - station.latitude, - station.longitude, - station.elevation, - channel.code[:2], - channel.location_code, - channel.depth, - channel.start_date, - channel.end_date, - ] - for network in inventory - for station in network - for channel in station.channels - ] - station_df = pd.DataFrame( - station_info, - columns=[ - "sta", - "sta_lat", - "sta_lon", - "sta_elev", - "chan", - "loc", - "loc_elev", - "start_time", - "end_time", - ], - ) - # Remove duplicates - station_df = station_df.drop_duplicates( - ["sta", "chan", "loc", "loc_elev"] - ).reset_index(drop=True) - # Get the recorders information for location codes config = cfg.Config() locations_url = config.get_value("locations_url") @@ -240,50 +203,56 @@ def add_ground_level( station_df["end_time"] = station_df["end_time"].fillna(pd.Timestamp.max) # Ensure datetime dtypes - def _to_py_datetime(val: object) -> object: - """ - Convert UTCDateTime to python datetime.datetime if needed. - - Parameters - ---------- - val : object - The value to convert. - - Returns - ------- - object - The converted value. - """ - if isinstance(val, UTCDateTime): - return val.datetime - return val - - station_df["start_time"] = station_df["start_time"].apply(_to_py_datetime) - station_df["end_time"] = station_df["end_time"].apply(_to_py_datetime) - - def ensure_utc(series: pd.Series) -> pd.Series: - """ - Ensure a pandas Series of datetimes is timezone-aware in UTC. - - Parameters - ---------- - series : pd.Series - The pandas Series to ensure is timezone-aware in UTC. - - Returns - ------- - pd.Series - The timezone-aware pandas Series in UTC. - """ - # coerce to datetime first, then ensure UTC tz (convert if already tz-aware, localize if naive) - s = pd.to_datetime(series, errors="coerce") - if pd.api.types.is_datetime64tz_dtype(s.dtype): - return s.dt.tz_convert("UTC") - return s.dt.tz_localize("UTC") + # def _to_py_datetime(val: object) -> object: + # """ + # Convert UTCDateTime to python datetime.datetime if needed. + # + # Parameters + # ---------- + # val : object + # The value to convert. + # + # Returns + # ------- + # object + # The converted value. + # """ + # if isinstance(val, UTCDateTime): + # return val.datetime + # return val + # + # station_df["start_time"] = station_df["start_time"].apply(_to_py_datetime) + # station_df["end_time"] = station_df["end_time"].apply(_to_py_datetime) + + # def ensure_utc(series: pd.Series) -> pd.Series: + # """ + # Ensure a pandas Series of datetimes is timezone-aware in UTC. + # + # Parameters + # ---------- + # series : pd.Series + # The pandas Series to ensure is timezone-aware in UTC. + # + # Returns + # ------- + # pd.Series + # The timezone-aware pandas Series in UTC. + # """ + # # coerce to datetime first, then ensure UTC tz (convert if already tz-aware, localize if naive) + # s = pd.to_datetime(series, errors="coerce") + # if pd.api.types.is_datetime64tz_dtype(s.dtype): + # return s.dt.tz_convert("UTC") + # return s.dt.tz_localize("UTC") # Normalize both frames to UTC before sorting / merge_asof - station_df["start_time"] = ensure_utc(station_df["start_time"]) - station_df["end_time"] = ensure_utc(station_df["end_time"]) + # station_df["start_time"] = ensure_utc(station_df["start_time"]) + # station_df["end_time"] = ensure_utc(station_df["end_time"]) + station_df["start_time"] = pd.to_datetime( + station_df["start_time"], errors="coerce", utc=True + ) + station_df["end_time"] = pd.to_datetime( + station_df["end_time"], errors="coerce", utc=True + ) station_df["start_time"] = pd.to_datetime(station_df["start_time"]) station_df["end_time"] = pd.to_datetime(station_df["end_time"]) @@ -354,37 +323,10 @@ def custom_idxmin(group: pd.DataFrame): ["is_ground_level", "loc_elev"], ] = [True, 0.0] - # remove duplicates of sta in the station_df - station_df = station_df.drop_duplicates(subset=["sta"]) - - # Merge missing station lat / lon / elev information into the gm_im_df_flat - gm_im_df_flat = gm_im_df_flat.merge( - station_df[["sta", "sta_lat", "sta_lon", "sta_elev"]], - on="sta", - how="left", - suffixes=("", "_new"), - ) - - # Find where sta_lat is nan and replace with the inventory's lat, lon and elev - gm_im_df_flat["sta_lat"] = gm_im_df_flat["sta_lat"].fillna( - gm_im_df_flat["sta_lat_new"] - ) - gm_im_df_flat["sta_lon"] = gm_im_df_flat["sta_lon"].fillna( - gm_im_df_flat["sta_lon_new"] - ) - gm_im_df_flat["sta_elev"] = gm_im_df_flat["sta_elev"].fillna( - gm_im_df_flat["sta_elev_new"] - ) - - # Drop the new columns - gm_im_df_flat = gm_im_df_flat.drop( - columns=["sta_lat_new", "sta_lon_new", "sta_elev_new"] - ) - return gm_im_df_flat -def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path = None): +def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path | None = None): """ Merge the flatfiles into the final flatfiles, separating the components and ensuring that the data contains only the unique events and sites that made it to the IM calculation @@ -393,7 +335,7 @@ def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path = None): ---------- main_dir : Path The main directory of the NZGMDB results (Highest level directory) - bypass_records_ffp : Path + bypass_records_ffp : Path, optional The full file path to the bypass records file, which includes a custom fmin, fmax, and p_wave_ix """ # Get the flatfile directory @@ -442,6 +384,9 @@ def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path = None): site_basin_df = pd.read_csv( flatfile_dir / file_structure.PreFlatfileNames.SITE_TABLE ) + station_df = pd.read_csv( + flatfile_dir / file_structure.PreFlatfileNames.STATION_TABLE + ) station_extraction_df = pd.read_csv( flatfile_dir / file_structure.PreFlatfileNames.STATION_EXTRACTION_TABLE_GEONET, dtype={"evid": str}, @@ -471,6 +416,7 @@ def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path = None): # Ensure that the site_basin_df only has the unique sites found in the im_df unique_sites = im_df["sta"].unique() site_basin_df = site_basin_df[site_basin_df["sta"].isin(unique_sites)] + station_df = station_df[station_df["sta"].isin(unique_sites)] # Ensure the station magnitude table only has values of events and station pairs available in the im_df unique_pairs_df = im_df[["evid", "sta"]].drop_duplicates() @@ -578,7 +524,7 @@ def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path = None): ) # Add in the ground level location elevation information - gm_im_df_flat = add_ground_level(gm_im_df_flat) + gm_im_df_flat = add_ground_level(station_df, gm_im_df_flat) # Add in multi_event information gm_im_df_flat = gm_im_df_flat.merge( @@ -846,6 +792,9 @@ def merge_flatfiles(main_dir: Path, bypass_records_ffp: Path = None): site_basin_df.to_csv( flatfile_dir / file_structure.FlatfileNames.SITE_TABLE, index=False ) + station_df.to_csv( + flatfile_dir / file_structure.FlatfileNames.STATION_TABLE, index=False + ) prop_df.to_csv( flatfile_dir / file_structure.FlatfileNames.PROPAGATION_TABLE, index=False ) diff --git a/nzgmdb/data_processing/multi_event.py b/nzgmdb/data_processing/multi_event.py index 67a7cf4f..69ee4e39 100644 --- a/nzgmdb/data_processing/multi_event.py +++ b/nzgmdb/data_processing/multi_event.py @@ -153,6 +153,7 @@ def stalta_for_stream(stream: Stream, inventory: Inventory | None = None) -> flo custom_errors.InventoryNotFoundError, custom_errors.SensitivityRemovalError, custom_errors.RotationError, + custom_errors.DetrendError, ): return np.nan diff --git a/nzgmdb/data_processing/process_observed.py b/nzgmdb/data_processing/process_observed.py index f1bdfa8c..1e486c2a 100644 --- a/nzgmdb/data_processing/process_observed.py +++ b/nzgmdb/data_processing/process_observed.py @@ -9,12 +9,9 @@ import numpy as np import pandas as pd from obspy import read_inventory -from obspy.clients.fdsn import Client as FDSN_Client -from obspy.core.inventory import Inventory import qcore.timeseries as ts from nzgmdb.data_processing import waveform_manipulation -from nzgmdb.management import config as cfg from nzgmdb.management import custom_errors, file_structure from nzgmdb.mseed_management import reading @@ -25,7 +22,6 @@ def process_single_mseed( fmax_df: pd.DataFrame | None = None, bypass_df: pd.DataFrame | None = None, xml_dir: Path | None = None, - inventory: Inventory | None = None, ): """ Process a single mseed file and save the processed data to a txt file @@ -46,8 +42,6 @@ def process_single_mseed( The bypass records containing custom fmin, fmax values xml_dir : Path, optional The directory containing the station xml files for inventory information - inventory : Inventory, optional - The inventory information for the mseed file Returns ------- @@ -78,7 +72,7 @@ def process_single_mseed( inventory = None if xml_dir: # Load the inventory information - inventory_file = xml_dir / f"NZ.{station}.xml" + inventory_file = xml_dir / f"{station}.xml" if inventory_file.is_file(): inventory = read_inventory(inventory_file) @@ -113,6 +107,13 @@ def process_single_mseed( } skipped_record = pd.DataFrame([skipped_record_dict]) return skipped_record + except custom_errors.DetrendError: + skipped_record_dict = { + "record_id": mseed_file.stem, + "reason": "Unable to detrend record", + } + skipped_record = pd.DataFrame([skipped_record_dict]) + return skipped_record # Get the GMC fmin values fmin_h = ( @@ -247,11 +248,6 @@ def process_mseeds_to_txt( ) bypass_df = None if bypass_records_ffp is None else pd.read_csv(bypass_records_ffp) - config = cfg.Config() - channel_codes = config.get_value("channel_codes") - client = FDSN_Client("GEONET") - inventory = client.get_stations(channel=channel_codes, level="response") - # Use multiprocessing to process the mseed files with multiprocessing.Pool(processes=n_procs) as pool: skipped_records = pool.map( @@ -261,7 +257,6 @@ def process_mseeds_to_txt( fmax_df=fmax_df, bypass_df=bypass_df, xml_dir=xml_dir, - inventory=inventory, ), mseed_files, ) diff --git a/nzgmdb/data_processing/quality_db.py b/nzgmdb/data_processing/quality_db.py index bbec6288..44e29581 100644 --- a/nzgmdb/data_processing/quality_db.py +++ b/nzgmdb/data_processing/quality_db.py @@ -127,7 +127,9 @@ def filter_mag(catalogue: pd.DataFrame, mag_min: float): return skipped_records -def filter_has_score_mean(catalogue: pd.DataFrame, bypass_records: np.ndarray = None): +def filter_has_score_mean( + catalogue: pd.DataFrame, bypass_records: np.ndarray | None = None +): """ Filter the catalogue based on if there is a score from GMC. @@ -166,7 +168,7 @@ def filter_has_score_mean(catalogue: pd.DataFrame, bypass_records: np.ndarray = def filter_score_mean( catalogue: pd.DataFrame, score_min: float, - bypass_records: np.ndarray = None, + bypass_records: np.ndarray | None = None, include_z: bool = False, ): """ @@ -220,7 +222,7 @@ def filter_score_mean( def filter_multi_event( catalogue: pd.DataFrame, score_min: float, - bypass_records: np.ndarray = None, + bypass_records: np.ndarray | None = None, ): """ Filter the catalogue based on the multi-event STA/LTA and sync event check. @@ -262,7 +264,7 @@ def filter_multi_event( def filter_fmax( - catalogue: pd.DataFrame, fmax_min: float, bypass_records: np.ndarray = None + catalogue: pd.DataFrame, fmax_min: float, bypass_records: np.ndarray | None = None ): """ Filter the catalogue based on the fmax_min value for the fmax_X and fmax_Y. @@ -306,7 +308,7 @@ def filter_fmax( def filter_fmin( - catalogue: pd.DataFrame, fmin_max: float, bypass_records: np.ndarray = None + catalogue: pd.DataFrame, fmin_max: float, bypass_records: np.ndarray | None = None ): """ Filter the catalogue based on the fmin max value for the fmin_X and fmin_Y. @@ -385,7 +387,7 @@ def filter_missing_sta_info( def filter_ground_level_locations( - catalogue: pd.DataFrame, bypass_records: np.ndarray = None + catalogue: pd.DataFrame, bypass_records: np.ndarray | None = None ): """ Filter the catalogue based on the ground level locations @@ -425,7 +427,7 @@ def filter_ground_level_locations( def apply_clipNet_filter( clipped_records_ffp: Path, - bypass_records: np.ndarray = None, + bypass_records: np.ndarray | None = None, ): """ Apply the ClipNet filter to the catalogue @@ -471,7 +473,7 @@ def apply_clipNet_filter( def apply_jerk_filter( clipped_records_ffp: Path, - bypass_records: np.ndarray = None, + bypass_records: np.ndarray | None = None, ): """ Apply the Jerk filter from ClipNet to the catalogue @@ -516,7 +518,7 @@ def apply_jerk_filter( def filter_troublesome_sensitivity( - catalogue: pd.DataFrame, bypass_records: np.ndarray = None + catalogue: pd.DataFrame, bypass_records: np.ndarray | None = None ): """ Filter the catalogue by removing records that are known to be troublesome for sensitivity analysis. @@ -737,7 +739,7 @@ def filter_empirical_predictions( def filter_duplicate_channels( - catalogue: pd.DataFrame, bypass_records: np.ndarray = None + catalogue: pd.DataFrame, bypass_records: np.ndarray | None = None ): """ Filter the catalogue by removing lower-priority duplicate channel records. @@ -770,7 +772,7 @@ def filter_duplicate_channels( # Step 2: Create bypass flag using record_id if bypass_records is None: - bypass_records = [] + bypass_records = np.array([]) catalogue["bypass"] = catalogue["record_id"].isin(bypass_records) # Step 3: Define priority levels @@ -800,12 +802,12 @@ def filter_duplicate_channels( def apply_all_filters( catalogue: pd.DataFrame, clipped_records_ffp: Path, - bypass_records: np.ndarray = None, - score_min: float = None, - multi_score_min: float = None, - fmax_min: float = None, - fmin_max: float = None, - min_mag: float = None, + bypass_records: np.ndarray | None = None, + score_min: float | None = None, + multi_score_min: float | None = None, + fmax_min: float | None = None, + fmin_max: float | None = None, + min_mag: float | None = None, ): """ Apply all the quality filters to the catalogue. @@ -958,7 +960,7 @@ def apply_all_filters( def create_quality_db( main_dir: Path, - bypass_records_ffp: Path = None, + bypass_records_ffp: Path | None = None, ): """ Create the quality database by running the following checks: diff --git a/nzgmdb/data_processing/waveform_manipulation.py b/nzgmdb/data_processing/waveform_manipulation.py index 7c49bf8a..01134a05 100644 --- a/nzgmdb/data_processing/waveform_manipulation.py +++ b/nzgmdb/data_processing/waveform_manipulation.py @@ -17,8 +17,10 @@ def initial_preprocessing( mseed: Stream, apply_taper: bool = True, apply_zero_padding: bool = True, - inventory: Inventory = None, -): + inventory: Inventory | None = None, + provider: str = "GEONET", + network: str = "NZ", +) -> Stream: """ Basic pre-processing of the waveform data This performs the following: @@ -39,6 +41,10 @@ def initial_preprocessing( Whether to apply zero padding, by default True inventory : Inventory, optional The inventory object to use for sensitivity removal, by default None (Will try to extract from FDSN if not provided) + provider : str, optional + The FDSN provider to use if inventory is not provided, by default "GEONET" + network : str, optional + The network code to use if inventory is not provided, by default "NZ" Returns ------- @@ -54,9 +60,15 @@ def initial_preprocessing( RotationError If the rotation fails """ - # Small Processing - mseed.detrend("demean") - mseed.detrend("linear") + try: + # Small Processing + mseed.detrend("demean") + mseed.detrend("linear") + except NotImplementedError: + # This is an issue with extracted waveforms where the trace has masked values. + raise custom_errors.DetrendError( + f"Failed to demean and detrend the data for station {mseed[0].stats.station} with location {mseed[0].stats.location}" + ) # Load config config = cfg.Config() @@ -82,9 +94,13 @@ def initial_preprocessing( inv = inventory if inv is None: try: - client_NZ = FDSN_Client("GEONET") + client_NZ = FDSN_Client(provider) inv = client_NZ.get_stations( - level="response", network="NZ", station=station, location=location, channel=f"{channel}?" + level="response", + network=network, + station=station, + location=location, + channel=f"{channel}?", ) except FDSNNoDataException: raise custom_errors.InventoryNotFoundError( @@ -195,10 +211,10 @@ def butter_bandpass_filter( def high_and_low_cut_processing( mseed: Stream, dt: float, - fmin_h: float = None, - fmin_v: float = None, - fmax_h: float = None, - fmax_v: float = None, + fmin_h: float | None = None, + fmin_v: float | None = None, + fmax_h: float | None = None, + fmax_v: float | None = None, ): """ Process the waveform data by using the highcut and lowcut for the butter bandpass filter diff --git a/nzgmdb/data_retrieval/geonet.py b/nzgmdb/data_retrieval/geonet.py index a3f3f52f..acfcef21 100644 --- a/nzgmdb/data_retrieval/geonet.py +++ b/nzgmdb/data_retrieval/geonet.py @@ -21,6 +21,7 @@ from pandas.errors import EmptyDataError from scipy.interpolate import interp1d +from nzgmdb.data_retrieval import inventory_xml from nzgmdb.management import config as cfg from nzgmdb.management import custom_errors, file_structure from nzgmdb.management.data_registry import NZGMDB_DATA @@ -86,8 +87,8 @@ def fetch_event_line(event_cat: Event, event_id: str): preferred_origin = event_cat.preferred_origin() preferred_magnitude = event_cat.preferred_magnitude() - # If the preferred origin is None, return None - if preferred_origin is None: + # If the preferred origin or magnitude is None, return None + if preferred_origin is None or preferred_magnitude is None: return None # Extract basic info from the catalogue @@ -105,21 +106,17 @@ def fetch_event_line(event_cat: Event, event_id: str): if preferred_origin.earth_model_id is None else str(preferred_origin.earth_model_id).split("/")[1] ) - ev_ndef = ( - None - if preferred_origin.quality.used_phase_count is None - else preferred_origin.quality.used_phase_count - ) - ev_nsta = ( - None - if preferred_origin.quality.used_station_count is None - else preferred_origin.quality.used_station_count - ) - std = ( - None - if preferred_origin.quality.standard_error is None - else preferred_origin.quality.standard_error - ) + quality = preferred_origin.quality + if quality is None: + ev_ndef = None + ev_nsta = None + std = None + else: + ev_ndef = None if quality.used_phase_count is None else quality.used_phase_count + ev_nsta = ( + None if quality.used_station_count is None else quality.used_station_count + ) + std = None if quality.standard_error is None else quality.standard_error pref_mag_type = preferred_magnitude.magnitude_type @@ -289,6 +286,27 @@ def fetch_sta_extraction( """ config = cfg.Config() + # Get the provider and network codes + provider_networks = config.get_value("main_providers_networks") + provider_networks.update(config.get_value("tmp_array_providers_networks")) + + provider = next( + (prov for prov, nets in provider_networks.items() if network.code in nets), + None, + ) + + if provider is None: + print( + f"Warning: No provider found for network {network.code}. Skipping station {station.code}." + ) + skipped_reason = pd.DataFrame( + { + "record_id": [f"{event_id}_{station.code}"], + "skipped_reason": ["No provider found for network"], + } + ) + return pd.DataFrame(), skipped_reason + # Get the preferred_origin preferred_origin = event_cat.preferred_origin() ev_lat = preferred_origin.latitude @@ -359,6 +377,7 @@ def fetch_sta_extraction( # Create the station_extraction_table station_extraction_table = pd.DataFrame( { + "provider": [provider], "net": [network.code], "sta": [station.code], "evid": [event_id], @@ -394,8 +413,8 @@ def fetch_event_data( inventory: Inventory, site_table: pd.DataFrame, mw_rrup_data: np.ndarray, - only_sites: list[str] = None, - only_record_ids: pd.DataFrame = None, + only_sites: list[str] | None = None, + only_record_ids: pd.DataFrame | None = None, n_procs: int = 1, ): """ @@ -505,8 +524,8 @@ def process_batch( site_table: pd.DataFrame, mw_rrup_data: np.ndarray, n_procs: int = 1, - only_sites: list[str] = None, - only_record_ids: pd.DataFrame = None, + only_sites: list[str] | None = None, + only_record_ids: pd.DataFrame | None = None, mp_sites: bool = False, ): """ @@ -637,8 +656,8 @@ def process_batch( def download_earthquake_data( - start_date: datetime, - end_date: datetime, + start_date: datetime.datetime, + end_date: datetime.datetime, ): """ Download the earthquake data files from the geonet website @@ -721,15 +740,16 @@ def download_earthquake_data( def parse_geonet_information( main_dir: Path, - start_date: datetime, - end_date: datetime, + start_date: datetime.datetime, + end_date: datetime.datetime, n_procs: int = 1, batch_size: int = 500, - only_event_ids: list[str] = None, - only_sites: list[str] = None, - only_record_ids_ffp: Path = None, + only_event_ids: list[str] | None = None, + only_sites: list[str] | None = None, + only_record_ids_ffp: Path | None = None, real_time: bool = False, mp_sites: bool = False, + add_tmp_arrays: bool = False, ): """ Read the geonet information and manage the fetching of more data to create the mseed files @@ -756,6 +776,8 @@ def parse_geonet_information( If the function is being used in real time use a different client, default is False mp_sites : bool (optional) Whether to multiprocess over sites (when not using mp over events) + add_tmp_arrays : bool (optional) + Whether to add temporary array stations to the inventory, default is False """ if only_record_ids_ffp: # Read the only record ids file @@ -780,13 +802,16 @@ def parse_geonet_information( only_record_ids = None config = cfg.Config() - channel_codes = config.get_value("channel_codes") if real_time: + inventory = inventory_xml.get_provider_inventory( + real_time=True, level="station" + ) client_NZ = FDSN_Client(base_url=config.get_value("real_time_url")) else: - # Get Station Information from geonet clients + inventory = inventory_xml.get_full_inventory( + add_tmp_arrays=add_tmp_arrays, level="station" + ) client_NZ = FDSN_Client("GEONET") - inventory = client_NZ.get_stations(channel=channel_codes, level="station") # Get the rrup data mw_rrup_data = np.loadtxt(NZGMDB_DATA.fetch("Mw_rrup.txt")) diff --git a/nzgmdb/data_retrieval/inventory_xml.py b/nzgmdb/data_retrieval/inventory_xml.py index 824786cd..10ed3512 100644 --- a/nzgmdb/data_retrieval/inventory_xml.py +++ b/nzgmdb/data_retrieval/inventory_xml.py @@ -5,15 +5,217 @@ import datetime from pathlib import Path +import pandas as pd from obspy.clients.fdsn import Client as FDSN_Client -from obspy.clients.fdsn.header import FDSNNoDataException +from obspy.clients.fdsn.header import FDSNException, FDSNNoDataException +from nzgmdb.management import config as cfg from nzgmdb.management import file_structure +def get_provider_inventory( + provider: str | None = None, + networks: list[str] | None = None, + channel_codes: str | None = None, + stations: str = "*", + level: str = "response", + starttime: str = "2000-01-01", + endtime: str = datetime.datetime.strftime(datetime.datetime.now(), "%Y-%m-%d"), + real_time: bool = False, +): + """ + Fetch inventory from a specified FDSN provider within configured bounding box. + + Parameters + ---------- + provider : str, optional + FDSN provider base URL, required if `real_time` is False. + networks : list[str], optional + List of network codes to fetch. If None, fetches all networks. + channel_codes : str or None, optional + Channel codes filter. If None, uses config default. + stations : str, optional + Station selector passed to FDSN, by default "*". + level : str, optional + StationXML detail level to request, by default "response". + starttime : str, optional + Start date (YYYY-MM-DD), by default "2000-01-01". + endtime : str, optional + End date (YYYY-MM-DD), by default today. + real_time : bool, optional + Whether to use real-time data source from config, by default False. + """ + config = cfg.Config() + channel_codes = ( + config.get_value("channel_codes") if channel_codes is None else channel_codes + ) + bbox = config.get_value("bbox") # [min_lon, min_lat, max_lon, max_lat] + min_lon, min_lat, max_lon, max_lat = bbox + max_lon = 180 # Due to issues with FDSN of passing barrier (no land past this point for sites that are of interest) + if real_time: + client = FDSN_Client(base_url=config.get_value("real_time_url")) + # Adjust the start time to be more recent for real-time data to improve speed + starttime = datetime.datetime.strftime( + datetime.datetime.now() - datetime.timedelta(days=14), "%Y-%m-%d" + ) + else: + if provider is None: + raise ValueError("Provider must be specified if not using real-time data.") + client = FDSN_Client(provider) + networks_str = "*" if networks is None else ",".join(networks) + try: + inv = client.get_stations( + network=networks_str, + station=stations, + channel=channel_codes, + level=level, + maxlatitude=max_lat, + minlatitude=min_lat, + maxlongitude=max_lon, + minlongitude=min_lon, + starttime=starttime, + endtime=endtime, + ) + except FDSNException: + print( + f"No inventory data found for provider {provider} with the specified parameters." + ) + inv = None + return inv + + +def get_full_inventory( + add_tmp_arrays: bool = False, + level: str = "response", + channel_codes: str | None = None, + stations: str = "*", + starttime: str = "2000-01-01", + endtime: str = datetime.datetime.strftime(datetime.datetime.now(), "%Y-%m-%d"), + return_df: bool = False, +): + """ + Fetch inventories from all configured providers and optionally return station/channel metadata. + + Parameters + ---------- + add_tmp_arrays : bool, optional + Whether to include temporary array providers, by default False. + level : str, optional + StationXML detail level to request, by default "response". + channel_codes : str or None, optional + Channel codes filter. If None, uses config default. + stations : str, optional + Station selector passed to FDSN, by default "*". + starttime : str, optional + Start date (YYYY-MM-DD), by default "2000-01-01". + endtime : str, optional + End date (YYYY-MM-DD), by default today. + return_df : bool, optional + If True, return a DataFrame of station/channel info; otherwise return merged Inventory. + + Returns + ------- + pandas.DataFrame or obspy.core.inventory.inventory.Inventory + DataFrame when `return_df=True`, else the merged ObsPy Inventory. + """ + config = cfg.Config() + provider_networks = config.get_value("main_providers_networks") + if add_tmp_arrays: + provider_networks.update(config.get_value("tmp_array_providers_networks")) + return_inv = None + info_dfs = [] + for provider, networks in provider_networks.items(): + inventory = get_provider_inventory( + provider=provider, + networks=networks, + stations=stations, + channel_codes=channel_codes, + level=level, + starttime=starttime, + endtime=endtime, + ) + if inventory is None: + continue + if return_inv is None: + return_inv = inventory + else: + return_inv += inventory + + if return_df: + station_info = [ + [ + provider, + network.code, + station.code, + station.latitude, + station.longitude, + station.elevation, + station.creation_date, + station.end_date, + channel.code[:2], + channel.location_code, + channel.depth, + channel.start_date, + channel.end_date, + ] + for network in inventory + for station in network + for channel in station.channels + ] + + info_dfs.append( + pd.DataFrame( + station_info, + columns=[ + "provider", + "net", + "sta", + "lat", + "lon", + "elev", + "creation_date", + "end_date", + "chan", + "loc", + "loc_elev", + "start_time", + "end_time", + ], + ) + ) + + if return_df: + if not info_dfs: + return pd.DataFrame( + columns=[ + "provider", + "net", + "sta", + "lat", + "lon", + "elev", + "creation_date", + "end_date", + "chan", + "loc", + "loc_elev", + "start_time", + "end_time", + ] + ) + + all_info_df = pd.concat(info_dfs, ignore_index=True) + return all_info_df.drop_duplicates( + ["provider", "net", "sta", "chan", "loc", "loc_elev"] + ).reset_index(drop=True) + + return return_inv + + def fetch_and_save_inventory( main_dir: Path, stations: list[str], + add_tmp_arrays: bool = False, starttime: str = "2000-01-01", endtime: str = datetime.datetime.strftime(datetime.datetime.now(), "%Y-%m-%d"), ): @@ -26,32 +228,31 @@ def fetch_and_save_inventory( The main directory where the StationXML files will be saved. stations : list[str] A list of station codes to fetch the inventory data for. + add_tmp_arrays : bool, optional + Whether to include temporary array providers in the inventory fetch, by default False. starttime : str, optional The start time for the inventory data, by default "2000-01-01". endtime : str, optional The end time for the inventory data, by default the current date. """ - client = FDSN_Client("GEONET") - xml_dir = file_structure.get_stationxml_dir(main_dir) xml_dir.mkdir(parents=True, exist_ok=True) all_stations = ",".join(stations) try: - inv = client.get_stations( - network="NZ", - station=all_stations, + inv = get_full_inventory( + add_tmp_arrays=add_tmp_arrays, + stations=all_stations, starttime=starttime, endtime=endtime, - level="response", ) for sta in stations: sel = inv.select(station=sta) if not sel.networks: print(f"Warning: No inventory data found for station {sta}. Skipping.") continue - fname = xml_dir / f"NZ.{sta}.xml" + fname = xml_dir / f"{sta}.xml" sel.write(fname, format="STATIONXML") except FDSNNoDataException: print("No inventory data found for the specified stations and time range.") diff --git a/nzgmdb/data_retrieval/rupture_models.py b/nzgmdb/data_retrieval/rupture_models.py index 3078815d..6837aaf5 100644 --- a/nzgmdb/data_retrieval/rupture_models.py +++ b/nzgmdb/data_retrieval/rupture_models.py @@ -33,7 +33,7 @@ class RuptureModel(TypedDict): """The average width of the rupture model.""" -def get_seismic_data_from_url(url: str) -> dict: +def get_seismic_data_from_url(url: str) -> RuptureModel: """ Fetch and process the seismic data from a URL. @@ -51,7 +51,7 @@ def get_seismic_data_from_url(url: str) -> dict: df = pd.read_csv(url) # Define calculated data output variables - length = 0 + length = 0.0 widths = [] # Group by segment @@ -100,7 +100,7 @@ def get_seismic_data_from_url(url: str) -> dict: "strike": strike, "dip": dip, "rake": rake, - "length": length, + "length": float(length), "width": width, } diff --git a/nzgmdb/data_retrieval/sites.py b/nzgmdb/data_retrieval/sites.py index 113b966a..0ccda6e6 100644 --- a/nzgmdb/data_retrieval/sites.py +++ b/nzgmdb/data_retrieval/sites.py @@ -6,52 +6,206 @@ from pathlib import Path import fiona +import numpy as np import pandas as pd -from obspy.clients.fdsn import Client as FDSN_Client +import rasterio +from pyproj import Transformer +from scipy.spatial import cKDTree -from nzgmdb.data_retrieval import tect_domain +from nzgmdb.data_retrieval import tect_domain, inventory_xml from nzgmdb.management import config as cfg from nzgmdb.management.data_registry import NZGMDB_DATA from qcore import point_in_polygon -from velocity_modelling import registry +from velocity_modelling import registry, threshold -def create_site_table_response() -> pd.DataFrame: +def fill_gaps_with_nearest( + coords: np.ndarray, + values: np.ndarray, + invalid_mask: np.ndarray | None = None, + k: int = 8, +) -> np.ndarray: + """ + Fill NaN or invalid values using nearest-neighbour averaging. + + Parameters + ---------- + coords : (N, 2) array_like + Coordinates of the points (e.g., [x, y] or [lon, lat]). + values : (N,) array_like + Values at the points, with NaN for invalid/missing values. + invalid_mask : (N,) array_like, optional + Boolean mask indicating invalid points. If None, NaNs in `values` are used. + k : int, default=8 + Number of nearest neighbors to consider for averaging. + + Returns + ------- + ndarray + Values with NaNs filled using nearest-neighbour averaging. + """ + + coords = np.asarray(coords) + values = np.asarray(values).astype(float) + + # ---- Enforce correct shapes ---- + if values.ndim == 2 and values.shape[1] == 1: + values = values.ravel() + + if coords.ndim != 2 or coords.shape[1] != 2: + raise ValueError("coords must be of shape (N, 2)") + + if invalid_mask is None: + invalid_mask = np.isnan(values) + else: + invalid_mask = np.asarray(invalid_mask) + if invalid_mask.ndim == 2 and invalid_mask.shape[1] == 1: + invalid_mask = invalid_mask.ravel() + + valid_mask = ~invalid_mask + + if not valid_mask.any(): + return np.full_like(values, np.nan) + + # ---- Build KDTree ---- + tree = cKDTree(coords[valid_mask]) + valid_values = values[valid_mask] + + # ---- Fill invalid points ---- + for idx in np.where(invalid_mask)[0]: + coord = coords[idx] + kk = min(k, len(valid_values)) + _, nn = tree.query(coord, k=kk) + values[idx] = np.nanmean(valid_values[nn]) + + return values + + +def sample_points_from_geotiff( + file_path: Path, + latlon_points: np.ndarray, + band: int = 1, +) -> np.ndarray: + """ + Sample a GeoTIFF raster at given latitude/longitude points. + + Parameters + ---------- + file_path : Path + Path to the GeoTIFF file. + latlon_points : (N, 2) array_like + Input points as [lat, lon] in EPSG:4326. + band : int, default=1 + Raster band to sample (1-based index). + + Returns + ------- + ndarray + Sampled raster values. NaN where points fall outside the raster + or where raster contains nodata. + """ + + # ---- Normalize inputs ---- + file_path = Path(file_path) + latlon_points = np.asarray(latlon_points, dtype=float) + + lat = latlon_points[:, 0] + lon = latlon_points[:, 1] + + # Prepare output (NaN by default) + samples = np.full(lat.shape, np.nan, dtype=float) + + # ---- Open raster ---- + with rasterio.open(file_path) as ds: + + if ds.crs is None: + raise ValueError("Raster CRS is undefined.") + + # CRS of input coordinates (WGS84 lat/lon) + input_crs = rasterio.crs.CRS.from_epsg(4326) + + # ---- Transform coordinates if needed ---- + if ds.crs == input_crs: + # Raster already in lat/lon + x = lon + y = lat + else: + # Transform lat/lon → raster CRS + transformer = Transformer.from_crs( + input_crs, + ds.crs, + always_xy=True, + ) + x, y = transformer.transform(lon, lat) + + # ---- Determine which points lie inside raster bounds ---- + bounds = ds.bounds + inside = ( + (x >= bounds.left) + & (x <= bounds.right) + & (y >= bounds.bottom) + & (y <= bounds.top) + ) + + if not np.any(inside): + return samples.reshape(-1, 1) + + # ---- Sample raster at valid points ---- + coords = list(zip(x[inside], y[inside])) + + raw_values = np.array( + [v[0] for v in ds.sample(coords, indexes=band)], + dtype=float, + ) + + # ---- Handle nodata ---- + if ds.nodata is not None: + raw_values[raw_values == ds.nodata] = np.nan + + # ---- Apply scale and offset if defined ---- + scale = 1.0 + offset = 0.0 + + if ds.scales is not None: + scale = ds.scales[band - 1] + + if ds.offsets is not None: + offset = ds.offsets[band - 1] + + values = raw_values * scale + offset + + # ---- Insert values back into output ---- + samples[inside] = values + + return samples.reshape(-1, 1) + + +def create_site_table_response( + add_tmp_arrays: bool = False, +) -> tuple[pd.DataFrame, pd.DataFrame]: """ Create the site table for the NZGMDB. This function fetches the station information from the FDSN clients, and the Geonet metadata summary information. It then merges the two dataframes and determines the tectonic domain for each station. The final dataframe is saved as a csv file in the flatfile directory. + Parameters + ---------- + add_tmp_arrays : bool, optional + Whether to add temporary arrays to the station information, by default False + Returns ------- pd.DataFrame The site table dataframe with all Z, vs30, domain and location values for each site used in the NZGMDB + pd.DataFrame + The station table dataframe with all channel and location values for each site """ # Fetch the client station information - client_NZ = FDSN_Client("GEONET") config = cfg.Config() - channel_codes = config.get_value("channel_codes") - inventory = client_NZ.get_stations(channel=channel_codes, level="station") - station_info = [] - for network in inventory: - for station in network: - station_info.append( - [ - network.code, - station.code, - station.latitude, - station.longitude, - station.elevation, - station.creation_date, - station.end_date, - ] - ) - sta_df = pd.DataFrame( - station_info, - columns=["net", "sta", "lat", "lon", "elev", "creation_date", "end_date"], + all_info_df = inventory_xml.get_full_inventory( + add_tmp_arrays=add_tmp_arrays, return_df=True ) - sta_df = sta_df.drop_duplicates(["net", "sta"]).reset_index(drop=True) # Get the Geonet metadata summary information geo_meta_summary_df = pd.read_csv( @@ -62,8 +216,6 @@ def create_site_table_response() -> pd.DataFrame: geo_meta_summary_df = geo_meta_summary_df.rename( columns={ "Name": "sta", - "Lat": "lat", - "Long": "lon", "NZS1170SiteClass": "site_class", "Vs30_median": "Vs30", "Sigmaln_Vs30": "Vs30_std", @@ -81,13 +233,35 @@ def create_site_table_response() -> pd.DataFrame: } ) - merged_df = geo_meta_summary_df.merge( - sta_df[["net", "elev", "sta", "creation_date", "end_date"]], + for col in ("start_time", "end_time"): + all_info_df[col] = pd.to_datetime(all_info_df[col], format="ISO8601") + + # Remove the duplicated stations between different networks + all_info_df = all_info_df.drop_duplicates( + subset=[ + "sta", + "lat", + "lon", + "elev", + "chan", + "loc", + "loc_elev", + "start_time", + "end_time", + ] + ) + + # separate into site and sta here to avoid merging issues exploding + site_df = all_info_df[ + ["provider", "net", "sta", "lat", "lon", "elev", "creation_date", "end_date"] + ] + + merged_df = site_df.merge( + geo_meta_summary_df, on="sta", how="left", ) - # Fill Elevation NaN values from sta_df - merged_df["elev"] = merged_df["Elevation"].combine_first(merged_df["elev"]) + # Specify the required files for fiona NZGMDB_DATA.fetch("nt_domains_kiran.shp") NZGMDB_DATA.fetch("nt_domains_kiran.dbf") @@ -101,9 +275,98 @@ def create_site_table_response() -> pd.DataFrame: # Rename the domain column tect_merged_df = tect_merged_df.rename(columns={"domain_no": "site_domain_no"}) - # Select specific columns - site_df = tect_merged_df[ + # Only compute thresholds for stations where Z1.0 is missing + mask_missing_z1 = tect_merged_df["Z1.0"].isna() + mask_q3 = tect_merged_df["Q_Z1.0"] == "Q3" + mask_to_compute = mask_missing_z1 | mask_q3 + if mask_to_compute.any(): + # Prepare stations DataFrame for only missing rows, indexed by station code + stations = tect_merged_df.loc[mask_to_compute, ["sta", "lon", "lat"]].set_index( + "sta" + )[["lon", "lat"]] + try: + nzcvm_version = config.get_value("nzcvm_version") + thresholds = threshold.compute_station_thresholds( + stations, model_version=nzcvm_version + ) + # Merge computed thresholds back (computed columns will be suffixed) + tect_merged_df = tect_merged_df.merge( + thresholds[["Z1.0(km)", "Z2.5(km)", "sigma"]], + left_on="sta", + right_index=True, + how="left", + ) + + # Add in the computed values where missing + tect_merged_df["Z1.0"] = tect_merged_df["Z1.0"].combine_first( + tect_merged_df.get("Z1.0(km)") * 1000.0 + ) + tect_merged_df["Z2.5"] = tect_merged_df["Z2.5"].combine_first( + tect_merged_df.get("Z2.5(km)") * 1000.0 + ) + tect_merged_df["Z1.0_std"] = tect_merged_df["Z1.0_std"].combine_first( + tect_merged_df.get("sigma") + ) + tect_merged_df["Z2.5_std"] = tect_merged_df["Z2.5_std"].combine_first( + tect_merged_df.get("sigma") + ) + + # Set extra ref / quality fields + tect_merged_df.loc[ + mask_to_compute, ["Z1.0_ref", "Z2.5_ref", "Q_Z1.0", "Q_Z2.5"] + ] = ["NZCVM (2026)", "NZCVM (2026)", "Q3", "Q3"] + + # Get the file path to the combined MVN GeoTIFF + NZGMDB_DATA.fetch("nzcvm_v1.tif") + file_path = Path(NZGMDB_DATA.abspath) / "nzcvm_v1.tif" + + # Compute Vs30 for missing values + points = tect_merged_df.loc[mask_to_compute, ["lat", "lon"]].to_numpy() + vs30_values = sample_points_from_geotiff(file_path, points).ravel() + + # Fill missing gaps in Vs30 using nearest-neighbour averaging + coords = np.column_stack([points[:, 1], points[:, 0]]) + vs30_values_filled = fill_gaps_with_nearest(coords, vs30_values) + vs30_values_filled_rounded = np.round(vs30_values_filled) + + # Update Vs30 and related fields + tect_merged_df.loc[mask_to_compute, "Vs30"] = vs30_values_filled_rounded + + # Ensure reference and quality fields are set for Vs30 where filled + vs30_mask = mask_to_compute & ~tect_merged_df["Vs30"].isna() + tect_merged_df.loc[vs30_mask, "Vs30_Ref"] = "Vs30 Map v1.0 (2026)" + tect_merged_df.loc[vs30_mask, "Q_Vs30"] = "Q3" + + except (FileNotFoundError, ValueError, RuntimeError): + raise UserWarning( + "Could not compute thresholds for missing Z1.0 values, check correct setup for NZCVM" + ) + + # Split into station and site dfs + station_df = all_info_df.loc[ + :, [ + "provider", + "net", + "sta", + "lat", + "lon", + "elev", + "chan", + "loc", + "loc_elev", + "start_time", + "end_time", + ], + ] + # Adjust any "" loc codes to be "00" + # based on the FDSN Source Indentifiers documentation (https://docs.fdsn.org/projects/source-identifiers/en/latest/location-codes.html) + station_df = station_df.replace({"loc": {"": "00"}}) + + site_df = tect_merged_df.loc[ + :, + [ + "provider", "net", "sta", "lat", @@ -130,12 +393,12 @@ def create_site_table_response() -> pd.DataFrame: "Q_Z2.5", "Z2.5_ref", "site_domain_no", - ] + ], ] site_df = site_df.astype({"Z2.5": float}) site_df.loc[:, "Z2.5"] /= 1000.0 - return site_df + return site_df, station_df def add_site_basins(site_df: pd.DataFrame, nzcvm_data_ffp: Path) -> pd.DataFrame: diff --git a/nzgmdb/data_retrieval/tect_domain.py b/nzgmdb/data_retrieval/tect_domain.py index 1c217b63..d51c7a7e 100644 --- a/nzgmdb/data_retrieval/tect_domain.py +++ b/nzgmdb/data_retrieval/tect_domain.py @@ -187,9 +187,9 @@ def create_regions( fault_file: Path, d_s: float, d_d: float, - region_a_offshore: dict = None, - region_b_on: dict = None, - region_c_downdip: dict = None, + region_a_offshore: dict | None = None, + region_b_on: dict | None = None, + region_c_downdip: dict | None = None, ): """ Determine an array of points on and offshore of a fault and divide them into regions. @@ -257,10 +257,9 @@ def create_regions( def ngasub2020_tectclass( row: pd.Series, - region_a_offshore: dict = False, - region_b_on: dict = False, - region_c_downdip: dict = False, - fault_label: str = np.nan, + region_a_offshore: dict, + region_b_on: dict, + region_c_downdip: dict, h_thresh: float = 10, v_thresh: float = 10, ): @@ -294,8 +293,6 @@ def ngasub2020_tectclass( Portion of faults in Region B (latitude, longitude, depth) as defined in NGA-SUB (2020). region_c_downdip : dict Portion of faults in Region C (latitude, longitude, depth) as defined in NGA-SUB (2020). - fault_label : str - The fault label associated with the event. h_thresh : float Horizontal distance threshold for classification. v_thresh : float @@ -310,6 +307,7 @@ def ngasub2020_tectclass( """ lat, lon, depth = row["lat"], row["lon"], row["depth"] + fault_label = np.nan # Initially classify as if farfield, correct later if neccessary if depth <= 30: diff --git a/nzgmdb/data_retrieval/waveform_extraction.py b/nzgmdb/data_retrieval/waveform_extraction.py index 295cc85e..665ce1f8 100644 --- a/nzgmdb/data_retrieval/waveform_extraction.py +++ b/nzgmdb/data_retrieval/waveform_extraction.py @@ -15,7 +15,7 @@ import numpy as np import pandas as pd import scipy as sp -from obspy import Stream, Trace, UTCDateTime +from obspy import Stream, Trace, UTCDateTime, read_inventory, read from obspy.clients.fdsn import Client as FDSN_Client from obspy.clients.fdsn.header import ( FDSNNoDataException, @@ -691,33 +691,21 @@ def check_trace_issues(st: Stream, record_id: str, station_extraction_row: pd.Se def get_station_window( station_extraction_row: pd.Series, - client: FDSN_Client, - channel_codes: str, - loc: str, ): """ - Get the initial stream of waveforms for a station based on the extraction parameters. + Get the start and end time for the waveform extraction window for a station based on the parameters in the station extraction table. Parameters ---------- station_extraction_row : pd.Series A row from the station extraction table containing the parameters for waveform extraction. - client : FDSN_Client - The FDSN client to use for retrieving waveforms. - channel_codes : str - The channel codes to retrieve, formatted as a comma-separated string. - e.g. "HN?,BN?,HH?". - loc : str - The location code to retrieve waveforms for, typically "*". Returns ------- - Stream - An ObsPy Stream object containing the waveform data for the specified station. + tuple of UTCDateTime + A tuple containing the start time and end time for the waveform extraction window. """ # Extract the parameters from the row - net = station_extraction_row["net"] - sta = station_extraction_row["sta"] r_hyp = station_extraction_row["r_hyp"] ptime_est = UTCDateTime(station_extraction_row["ptime_est"]) ds_mean = station_extraction_row["ds_mean"] @@ -736,7 +724,73 @@ def get_station_window( # Note: both ds_mean and ds_std are in logspace end_time = ptime_est + np.exp(ds_mean) * np.exp(ds_std_multiplier * ds_std) - return get_inital_stream(start_time, end_time, channel_codes, loc, client, net, sta) + return start_time, end_time + + +def get_tmp_array_stream( + tmp_array_dir: Path, + net: str, + sta: str, + start_time: UTCDateTime, + end_time: UTCDateTime, +): + """ + Get the initial stream of waveforms for a station from the temporary array storage location. + + Parameters + ---------- + tmp_array_dir : Path + The directory where the temporary array waveform files are stored. + net : str + The network code to retrieve waveforms for. + sta : str + The station code to retrieve waveforms for. + start_time : UTCDateTime + The start time of the waveform data to retrieve. + end_time : UTCDateTime + The end time of the waveform data to retrieve. + + Returns + ------- + Stream + An ObsPy Stream object containing the waveform data for the specified station. + """ + net_dir = tmp_array_dir / net + if not net_dir.is_dir(): + return None + + st = Stream() + + pattern = f"{net}_{sta}_*" + selected_files = [] + for sta_dir in sorted(p for p in net_dir.glob(pattern) if p.is_dir()): + + for f in sta_dir.glob("*.mseed"): + # Example filename + # Y3.CASS..HHN__20090312T000000Z__20090411T000000Z.mseed + parts = f.name.split("__") + + file_start = UTCDateTime(parts[1]) + file_end = UTCDateTime(parts[2].replace(".mseed", "")) + + # Check overlap + if file_end >= start_time and file_start <= end_time: + selected_files.append(f) + + if not selected_files: + return None + + # Read files + for f in sorted(selected_files): + st += read(str(f)) + + # Merge overlapping / adjacent segments + # st.merge(method=1, fill_value=None) + + # Trim to exact window + st.trim(start_time, end_time) + + return st def extract_station_info( @@ -744,7 +798,8 @@ def extract_station_info( main_dir: Path, event_catalogues: dict, extraction_table: pd.DataFrame, - only_record_ids: pd.DataFrame = None, + only_record_ids: pd.DataFrame | None = None, + tmp_array_dir: Path | None = None, ) -> StationExtractionResult: """ Extract the waveform data for a single station based on the extraction parameters. @@ -761,6 +816,8 @@ def extract_station_info( The full extraction table containing all extraction parameters. only_record_ids : pd.DataFrame, optional A DataFrame containing a subset of record IDs to use for extraction, if provided. + tmp_array_dir : Path, optional + The directory where the temporary array waveform files are stored, if using temporary array storage for waveforms. Returns ------- @@ -776,6 +833,7 @@ def extract_station_info( multi_event_records, ) = ([], [], [], [], []) # Extract the parameters from the row + provider = station_extraction_row["provider"] event_id = station_extraction_row["evid"] station = station_extraction_row["sta"] network = station_extraction_row["net"] @@ -810,9 +868,28 @@ def extract_station_info( ) location = site_only_record_ids["record_id"].str.split("_").str[-1].values[0] - # Get the Stream - client = FDSN_Client("GEONET") - st = get_station_window(station_extraction_row, client, channel_codes, location) + start_time, end_time = get_station_window(station_extraction_row) + net = station_extraction_row["net"] + sta = station_extraction_row["sta"] + + if provider == "GEONET": + client = FDSN_Client("GEONET") + st = get_inital_stream( + start_time, end_time, channel_codes, location, client, net, sta + ) + else: + if tmp_array_dir is not None: + st = get_tmp_array_stream(tmp_array_dir, net, sta, start_time, end_time) + else: + try: + client = FDSN_Client(provider) + st = get_inital_stream( + start_time, end_time, channel_codes, location, client, net, sta + ) + except Exception as e: + raise ValueError( + f"Failed to retrieve data for provider '{provider}': {e}" + ) # Check that data was found if st is None: @@ -829,6 +906,12 @@ def extract_station_info( multi_event_records=multi_event_records, ) + # Get the inventory xml file + xml_dir = file_structure.get_stationxml_dir(main_dir) + # Load the inventory information + inventory_file = xml_dir / f"{station}.xml" + inventory = read_inventory(inventory_file) if inventory_file.is_file() else None + # Get the unique channels (Using first 2 keys) and locations unique_channels = set([(tr.stats.channel[:2], tr.stats.location) for tr in st]) @@ -837,7 +920,7 @@ def extract_station_info( for chan, loc in unique_channels: # Each unique channel and location pair is a new mseed file st_new = st.select(location=loc, channel=f"{chan}?") - record_id = f"{event_id}_{st_new[0].stats.station}_{st_new[0].stats.channel[:2]}_{st_new[0].stats.location}" + record_id = f"{event_id}_{st_new[0].stats.station}_{st_new[0].stats.channel[:2]}_{'00' if st_new[0].stats.location == '' else st_new[0].stats.location}" # Check trace issues st_revised, skipped, issues = check_trace_issues( @@ -860,40 +943,47 @@ def extract_station_info( ] for mseed in mseeds: + stats = mseed[0].stats + record_id = f"{event_id}_{stats.station}_{stats.channel[:2]}_{'00' if stats.location == '' else stats.location}" try: # Check the data is not all 0's if all([np.allclose(tr.data, 0) for tr in mseed]): - stats = mseed[0].stats + skipped_records.append( pd.DataFrame( { - "record_id": [ - f"{event_id}_{stats.station}_{stats.channel}_{stats.location}" - ], + "record_id": [record_id], "reason": ["All 0's"], } ) ) continue except TypeError: - stats = mseed[0].stats skipped_records.append( pd.DataFrame( { - "record_id": [ - f"{event_id}_{stats.station}_{stats.channel}_{stats.location}" - ], + "record_id": [record_id], "reason": ["TypeError when checking for all 0's"], } ) ) + # Check for 3 component data, if not skip + if len(mseed) < 3: + skipped_records.append( + pd.DataFrame( + { + "record_id": [record_id], + "reason": ["Less than 3 component traces"], + } + ) + ) + continue + # Calculate clip to determine if the record should be dropped clip = filtering.get_clip_probability(event_mag, r_hyp, mseed) threshold = config.get_value("clip_threshold") - stats = mseed[0].stats - record_id = f"{event_id}_{stats.station}_{stats.channel[:2]}_{stats.location}" # Check if the record should be dropped if clip > threshold: @@ -918,7 +1008,7 @@ def extract_station_info( # Check for multi-event flagging start_time, end_time, stalta_score, sync_event = ( multi_event.compute_multi_event_scores( - mseed.copy(), sync_check_extraction_table + mseed.copy(), sync_check_extraction_table, inventory=inventory ) ) # Add to the multi_event_records list @@ -936,8 +1026,19 @@ def extract_station_info( year = event_cat.origins[0].time.year mseed_dir = file_structure.get_mseed_dir(main_dir, year, event_id) - # Write the mseed file - creation.write_mseed(mseed, event_id, station, mseed_dir) + try: + # Write the mseed file + creation.write_mseed(mseed, event_id, station, mseed_dir) + except Exception as e: + skipped_records.append( + pd.DataFrame( + { + "record_id": [record_id], + "reason": [f"Error writing mseed file: {str(e)}"], + } + ) + ) + continue for trace in mseed: chan = trace.stats.channel @@ -1003,8 +1104,9 @@ def extract_waveforms( main_dir: Path, station_extraction_table_ffp: Path, n_procs: int = 1, - only_record_ids_ffp: Path = None, + only_record_ids_ffp: Path | None = None, batch_size: int = 1000, + tmp_array_dir: Path | None = None, ): """ Extract waveforms for each station in the station extraction table. @@ -1024,6 +1126,8 @@ def extract_waveforms( Full file path to the file containing a subset of record IDs to use for extraction, if provided. batch_size : int, optional The number of rows to process in each batch, by default 1000. + tmp_array_dir : Path, optional + The directory where the temporary array waveform files are stored, if using temporary array storage for waveforms. """ station_extraction_table = pd.read_csv( station_extraction_table_ffp, dtype={"evid": str} @@ -1095,17 +1199,62 @@ def extract_waveforms( print("Retrying in 120 seconds...") time.sleep(120) # Wait for 2 minutes before retrying - with mp.Pool(n_procs) as pool: - results = pool.map( - functools.partial( - extract_station_info, - main_dir=main_dir, - event_catalogues=catalog_dict, - extraction_table=station_extraction_table, - only_record_ids=only_record_ids, - ), - (row for _, row in batch_rows.iterrows()), - ) + extract_fn = functools.partial( + extract_station_info, + main_dir=main_dir, + event_catalogues=catalog_dict, + extraction_table=station_extraction_table, + only_record_ids=only_record_ids, + tmp_array_dir=tmp_array_dir, + ) + + results = [] + pool = mp.Pool(processes=n_procs) + timeout_s = 60 * 5 # 5 min + try: + for _, row in batch_rows.iterrows(): + job = pool.apply_async(extract_fn, (row,)) + record_id = f"{row['evid']}_{row['sta']}" + try: + results.append(job.get(timeout=timeout_s)) + except mp.TimeoutError: + results.append( + StationExtractionResult( + sta_mag_line=[], + skipped_records=[ + pd.DataFrame( + { + "record_id": [record_id], + "reason": [ + f"Hung (> {timeout_s // 60} min)" + ], + } + ) + ], + clipped_records=[], + multi_trace_issues=[], + multi_event_records=[], + ) + ) + pool.terminate() + pool.join() + pool = mp.Pool(processes=n_procs) + finally: + pool.close() + pool.join() + + # with mp.Pool(n_procs) as pool: + # results = pool.map( + # functools.partial( + # extract_station_info, + # main_dir=main_dir, + # event_catalogues=catalog_dict, + # extraction_table=station_extraction_table, + # only_record_ids=only_record_ids, + # tmp_array_dir=tmp_array_dir, + # ), + # (row for _, row in batch_rows.iterrows()), + # ) # Extract the results ( @@ -1205,7 +1354,9 @@ def extract_waveforms( # Grab all the station xmls and write them as outputs unique_sites = station_extraction_table["sta"].unique() print(f"Fetching station XML metadata for {len(unique_sites)} unique sites") - inventory_xml.fetch_and_save_inventory(main_dir, unique_sites) + inventory_xml.fetch_and_save_inventory( + main_dir, unique_sites, add_tmp_arrays=tmp_array_dir is not None + ) print("Station XML metadata fetching complete.") # Combine all the event and sta_mag dataframes diff --git a/nzgmdb/management/custom_errors.py b/nzgmdb/management/custom_errors.py index ddf23e94..89aca6ef 100644 --- a/nzgmdb/management/custom_errors.py +++ b/nzgmdb/management/custom_errors.py @@ -15,6 +15,12 @@ class DiffrentiateError(Exception): pass +class DetrendError(Exception): + """Exception raised when detrend fails.""" + + pass + + class SensitivityRemovalError(Exception): """Exception raised when sensitivity removal fails.""" diff --git a/nzgmdb/management/data_registry.py b/nzgmdb/management/data_registry.py index 56703a47..09ee8daf 100644 --- a/nzgmdb/management/data_registry.py +++ b/nzgmdb/management/data_registry.py @@ -5,6 +5,8 @@ REGISTRY = { "hik_kerm_fault_300km_wgs84_poslon.txt": "sha256:1a199978b6c9c608f8473539b639a8825c1091167da3d14b07c7268528320e03", "Geonet_Metadata_Summary_v1.4.csv": "sha256:7884422c3fcae0810c02948ba1a3bd39ba5793ba28189e90d730541be1c207c0", + "combined_mvn_wgs84.tif": "sha256:b0aed1d441a3c441d784a5dd9016314ee74df164dcafee6e233211a43cbfba0f", + "nzcvm_v1.tif": "sha256:34e22f4065c4380b0a048a24b8bcf2282567183391037d76308f8e3dc60285f0", "puy_slab2_dep_02.26.18.xyz": "sha256:9ebe4feab4ee3b80e3fe403f2d873f94e4d7f06d937d721cc9e154ecee83e3c0", "reyners_relocations.csv": "sha256:7795c60dae67af14eb590b0d919fa850e2f2fe2fb3beb077cbac14d27eb8faf5", "focal_mech_tectonic_domain_v1.csv": "sha256:1f1e0c4b7f9ca1b87fb2ca4883e587f330fe82b5bbf9ebb6eb8f4d12aa2e1936", @@ -40,6 +42,8 @@ URLS = { "focal_mech_tectonic_domain_v1.csv": "https://www.dropbox.com/scl/fi/zseg304cbjmti7gg5tdyv/focal_mech_tectonic_domain_v1.csv?rlkey=kfb9ttvnv9yi9zftixw6kmz4v&st=4j9pgpgj&dl=1", "Geonet_Metadata_Summary_v1.4.csv": "https://www.dropbox.com/scl/fi/iev7qmoqqzvc5quhf8mk8/Geonet-Metadata-Summary_v1.4.csv?rlkey=7twwwck5iy5zao7lwao6xodvm&st=6m3elzuu&dl=1", + "combined_mvn_wgs84.tif": "https://www.dropbox.com/scl/fi/lzqijoivcg4wzybj06rsh/combined_mvn_wgs84.tif?rlkey=3g4milzk41c2lcsdggvy2ieql&st=re0izlz4&dl=1", + "nzcvm_v1.tif": "https://www.dropbox.com/scl/fi/b7qpyhd94vhii7z9lhmtg/nzcvm_v1.tif?rlkey=xc7k9f6gcx46ive0z91txn2tp&st=qqnvy1ib&dl=1", "GeoNet_CMT_solutions_20201129_PreferredNodalPlane_v1.csv": "https://www.dropbox.com/scl/fi/fq28jx8jlbozj0d1x5tnq/GeoNet_CMT_solutions_20201129_PreferredNodalPlane_v1.csv?rlkey=30xj6n7ara0vz4t8kg4pz8w5s&st=63x7nr3j&dl=1", "hik_kerm_fault_300km_wgs84_poslon.txt": "https://www.dropbox.com/scl/fi/ig3ajufpv4xg2qjfxxuup/hik_kerm_fault_300km_wgs84_poslon.txt?rlkey=9jajfkq2elrzwzzgh6px17k8e&st=6ham2oox&dl=1", "Mw_rrup.txt": "https://www.dropbox.com/scl/fi/e3o9v9ze9e4955xxtrl14/Mw_rrup.txt?rlkey=c663zntx7gaeyxt04i97r62nu&st=6ri3c620&dl=1", diff --git a/nzgmdb/management/file_structure.py b/nzgmdb/management/file_structure.py index 01d686c4..f4d28b6f 100644 --- a/nzgmdb/management/file_structure.py +++ b/nzgmdb/management/file_structure.py @@ -23,6 +23,7 @@ class PreFlatfileNames(StrEnum): EARTHQUAKE_SOURCE_GEOMETRY = "earthquake_source_geometry_all.csv" PHASE_ARRIVAL_TABLE = "phase_arrival_table_all.csv" SITE_TABLE = "site_table_all.csv" + STATION_TABLE = "station_table_all.csv" PROPAGATION_TABLE = "propagation_path_table_all.csv" GROUND_MOTION_IM_CATALOGUE = "ground_motion_im_catalogue.csv" PROB_SERIES = "prob_series.h5" @@ -39,6 +40,7 @@ class FlatfileNames(StrEnum): STATION_EXTRACTION_TABLE = "station_extraction_table.csv" MULTI_EVENT_TABLE = "multi_event_table.csv" SITE_TABLE = "site_table.csv" + STATION_TABLE = "station_table.csv" PHASE_ARRIVAL_TABLE = "phase_arrival_table.csv" PROPAGATION_TABLE = "propagation_path_table.csv" GMC_PREDICTIONS = "gmc_predictions.csv" diff --git a/nzgmdb/management/shell_commands.py b/nzgmdb/management/shell_commands.py index 679c6497..e7d752f8 100644 --- a/nzgmdb/management/shell_commands.py +++ b/nzgmdb/management/shell_commands.py @@ -31,11 +31,11 @@ def run_command( """ with open(log_file_path, "w") as log_file: # Create the command to source conda.sh, activate the environment, and execute the full command - command = f"source {env_sh} && {env_activate_command} && {command}" + bash_command = f"source {env_sh} && {env_activate_command} && {command}" env = os.environ.copy() try: subprocess.check_call( - command, + bash_command, stdout=log_file, stderr=log_file, shell=True, diff --git a/nzgmdb/mseed_management/creation.py b/nzgmdb/mseed_management/creation.py index e84b94c2..05837085 100644 --- a/nzgmdb/mseed_management/creation.py +++ b/nzgmdb/mseed_management/creation.py @@ -64,6 +64,11 @@ def write_mseed(mseed: Stream, event_id: str, station: str, output_directory: Pa channel = mseed[0].stats.channel[:2] location = mseed[0].stats.location + # If the location is empty, set it to "00" as the default value + # based on the FDSN Source Indentifiers documentation (https://docs.fdsn.org/projects/source-identifiers/en/latest/location-codes.html) + if location is None or location == "": + location = "00" + # Create the filename and add it to the output directory filename = f"{event_id}_{station}_{channel}_{location}.mseed" mseed_ffp = output_directory / filename diff --git a/nzgmdb/mseed_management/reading.py b/nzgmdb/mseed_management/reading.py index 91c16992..7d36341a 100644 --- a/nzgmdb/mseed_management/reading.py +++ b/nzgmdb/mseed_management/reading.py @@ -130,7 +130,7 @@ def create_waveform_from_mseed( data = np.stack([tr.data for tr in mseed], axis=1) data = data.astype(np.float32) # Reshape the waveform to have the correct shape for the IM calculation - reshaped_waveform = data[np.newaxis, :, :] + reshaped_waveform = data.T[:, None, :] except ValueError: raise custom_errors.InvalidTraceLengthError( f"Error reading data from {mseed_file}" diff --git a/nzgmdb/phase_arrival/gen_phase_arrival_table.py b/nzgmdb/phase_arrival/gen_phase_arrival_table.py index 7d50efa8..035cc530 100644 --- a/nzgmdb/phase_arrival/gen_phase_arrival_table.py +++ b/nzgmdb/phase_arrival/gen_phase_arrival_table.py @@ -20,6 +20,7 @@ def process_batch( conda_sh: Path, env_activate_command: str, bypass_records_ffp: Path | None = None, + xml_dir: Path | None = None, ): """ Process a single subfolder: run PhaseNet over mseeds. @@ -36,6 +37,8 @@ def process_batch( The command to activate the environment for running PhaseNet. bypass_records_ffp : Path The full file path to the bypass records file, which includes a custom p_wave_datetime and/or s_wave_datetime + xml_dir: Path + The path to the station xml files. Used for reducing FDSN calls that require station information. Raises ------ @@ -59,7 +62,11 @@ def process_batch( print(f"Skipping run_phasenet for Batch {batch_num} as results already exist") else: # Activate phaseNet environment and run over mseeds for the subfolder - phasenet_command = f"python {run_phasenet_script_ffp} {batch_txt} {output_dir} {bypass_records_ffp if bypass_records_ffp is not None else ''}" + phasenet_command = f"python {run_phasenet_script_ffp} {batch_txt} {output_dir}" + if bypass_records_ffp is not None: + phasenet_command += f" --bypass_ffp {bypass_records_ffp}" + if xml_dir is not None: + phasenet_command += f" --xml_dir {xml_dir}" shell_commands.run_command( phasenet_command, conda_sh, env_activate_command, log_file_path_phasenet ) @@ -77,7 +84,9 @@ def generate_phase_arrival_table( conda_sh: Path, env_activate_command: str, n_procs: int, - bypass_records_ffp: Path = None, + n_batches: int | None = None, + bypass_records_ffp: Path | None = None, + xml_dir: Path | None = None, ): """ Generate the phase arrival table utilizing phaseNet @@ -95,8 +104,12 @@ def generate_phase_arrival_table( The command to activate the environment for running PhaseNet. n_procs : int The number of processes to use + n_batches : int, optional + The number of batches to split the mseed files into. If None, it will be set to the number of processes. (Default is None) bypass_records_ffp : Path The full file path to the bypass records file, which includes a custom p_wave_ix + xml_dir: Path + The path to the station xml files. Used for reducing FDSN calls that require station information. """ # Get the Phase_arrival directory phase_dir = main_dir / "phase_arrival" @@ -109,26 +122,42 @@ def generate_phase_arrival_table( mseed_files = list(main_dir.rglob("*.mseed")) # Split them into even batches based on number of mseeds and n_procs - # Ensure n_procs gets reduced if it is greater than the number of mseed files + # Ensure n_procs and n_batches gets reduced if it is greater than the number of mseed files n_procs = min(n_procs, len(mseed_files)) - mseed_batches = np.array_split(mseed_files, n_procs) + n_batches = n_batches or n_procs + n_batches = min(n_batches, len(mseed_files)) + mseed_batches = np.array_split(mseed_files, n_batches) batches = [ (batch, (phase_dir / f"batch_{idx}")) for idx, batch in enumerate(mseed_batches) ] - # Fetch results - with mp.Pool(n_procs) as p: - p.map( - functools.partial( - process_batch, - run_phasenet_script_ffp=run_phasenet_script_ffp, - conda_sh=conda_sh, - env_activate_command=env_activate_command, - bypass_records_ffp=bypass_records_ffp, - ), - batches, - ) + # Checkpointing: only schedule batches that are missing outputs + pending_batches = [] + for batch, out_dir in batches: + phase_table_ffp = out_dir / file_structure.FlatfileNames.PHASE_ARRIVAL_TABLE + if phase_table_ffp.exists(): + batch_num = out_dir.name.split("_")[-1] + print(f"Skipping Batch {batch_num} (found existing phase arrival table)") + continue + pending_batches.append((batch, out_dir)) + + if not pending_batches: + print("All batches already have a phase arrival table; nothing to run.") + else: + # Fetch results (only for pending batches) + with mp.Pool(n_procs) as p: + p.map( + functools.partial( + process_batch, + run_phasenet_script_ffp=run_phasenet_script_ffp, + conda_sh=conda_sh, + env_activate_command=env_activate_command, + bypass_records_ffp=bypass_records_ffp, + xml_dir=xml_dir, + ), + pending_batches, + ) # For each subfolder combine the phase_arrival_table.csv and skipped_records.csv into a single file phase_results = [] diff --git a/nzgmdb/phase_arrival/run_phasenet.py b/nzgmdb/phase_arrival/run_phasenet.py index 453790b2..50321f0d 100644 --- a/nzgmdb/phase_arrival/run_phasenet.py +++ b/nzgmdb/phase_arrival/run_phasenet.py @@ -9,7 +9,7 @@ import mseedlib import numpy as np import pandas as pd -from obspy import Inventory, Stream, Trace, UTCDateTime +from obspy import Inventory, Stream, Trace, UTCDateTime, read_inventory from obspy.clients.fdsn import Client as FDSN_Client from obspy.clients.fdsn.header import FDSNNoDataException @@ -48,7 +48,7 @@ def create_empty_h5_file(h5_ffp: Path, group_name: str): def run_phase_net( input_data: np.ndarray, dt: float, - t: np.ndarray = None, + t: np.ndarray | None = None, return_prob_series: bool = False, ): """ @@ -113,8 +113,8 @@ def run_phase_net( def process_mseed( mseed_file: Path, h5_ffp: Path, - bypass_row: pd.Series = None, - inventory: Inventory = None, + bypass_row: pd.Series | None = None, + inventory: Inventory | None = None, ): """ Process an mseed file and return the phase arrival data. @@ -214,7 +214,8 @@ def process_mseed( inv = inventory try: - mseed = mseed.remove_response(inventory=inv, output="ACC") + output_type = "ACC" if channel[:2] in ["HN", "BN"] else "VEL" + mseed = mseed.remove_response(inventory=inv, output=output_type) except ValueError: skipped_record = pd.DataFrame( { @@ -317,7 +318,12 @@ def process_mseed( ) -def run_phasenet(mseed_files_ffp: Path, output_dir: Path, bypass_ffp: Path = None): +def run_phasenet( + mseed_files_ffp: Path, + output_dir: Path, + bypass_ffp: Path | None = None, + xml_dir: Path | None = None, +): """ Run PhaseNet on the mseed files. @@ -329,6 +335,8 @@ def run_phasenet(mseed_files_ffp: Path, output_dir: Path, bypass_ffp: Path = Non Output directory for skipped records and phase arrival information. bypass_ffp : Path, optional Optional bypass file path with known p and s wave datetimes, by default None + xml_dir : Path, optional + Optional directory containing station xml files to use for sensitivity removal, by default None (Will try extract from FDSN if not provided) """ # Read the .txt for the mseed files to process mseed_files = mseed_files_ffp.read_text().splitlines() @@ -347,10 +355,19 @@ def run_phasenet(mseed_files_ffp: Path, output_dir: Path, bypass_ffp: Path = Non mseed_file = Path(mseed_file) bypass_row = None if bypass_ffp is not None: - bypass_row = bypass_df.loc[bypass_df["record_id"] == mseed_file.stem].iloc[ - 0 - ] - phase_arrival, skipped_record = process_mseed(mseed_file, h5_ffp, bypass_row) + bypass_rows = bypass_df.loc[bypass_df["record_id"] == mseed_file.stem] + if len(bypass_rows) > 0: + bypass_row = bypass_rows.iloc[0] + + inventory = None + if xml_dir is not None: + station = mseed_file.stem.split("_")[1] + xml_file = xml_dir / f"{station}.xml" + if xml_file.exists(): + inventory = read_inventory(xml_file) + phase_arrival, skipped_record = process_mseed( + mseed_file, h5_ffp, bypass_row, inventory=inventory + ) if phase_arrival is not None: phase_arrival_table.append(phase_arrival) if skipped_record is not None: @@ -395,5 +412,11 @@ def run_phasenet(mseed_files_ffp: Path, output_dir: Path, bypass_ffp: Path = Non help="Optional bypass file path with known p and s wave datetimes.", default=None, ) + parser.add_argument( + "--xml_dir", + type=Path, + help="Optional directory containing station xml files to use for sensitivity removal.", + default=None, + ) args = parser.parse_args() - run_phasenet(args.mseed_files_ffp, args.output_dir, args.bypass_ffp) + run_phasenet(args.mseed_files_ffp, args.output_dir, args.bypass_ffp, args.xml_dir) diff --git a/nzgmdb/scripts/generate_report.py b/nzgmdb/scripts/generate_report.py index 8a38d0c9..66848bff 100644 --- a/nzgmdb/scripts/generate_report.py +++ b/nzgmdb/scripts/generate_report.py @@ -11,6 +11,7 @@ from typing import Annotated, Optional import matplotlib.pyplot as plt +import matplotlib.ticker as mticker import numpy as np import pandas as pd import typer @@ -131,7 +132,7 @@ def apply_fmin_filter_df(df: pd.DataFrame, pre_4p3: bool = False) -> pd.DataFram return df -def format_percentage(pct: float, allvals: list[float]): +def format_percentage(pct: float, allvals: list[int | float]): """ Function to format pie chart labels with both percentage and absolute values. @@ -139,7 +140,7 @@ def format_percentage(pct: float, allvals: list[float]): ---------- pct : float The percentage value. - allvals : list[float] + allvals : list[int | float] The list of all values to compute the absolute value. Returns @@ -189,7 +190,7 @@ def plot_pie_chart(full_labels: list[str], full_sizes: list[int], title: str): colors=colors, autopct=lambda pct: format_percentage(pct, sizes), startangle=270, - ) + ) # type: ignore ax.set_title(f"{title} ({total_records} total records)") ax.axis("equal") @@ -235,7 +236,7 @@ def numpy_str_join(sep: str, *arrays: str | Sequence[str]) -> np.ndarray: numpy.ndarray The joined array. """ - result = arrays[0] + result = np.array(arrays[0]) for cur_array in arrays[1:]: result = np.char.add(result, sep) result = np.char.add(result, cur_array) @@ -457,7 +458,7 @@ def get_residuals( pred_im_keys = numpy_str_join("_", ims, pred_suffix) res_df = pd.DataFrame( data=results.loc[:, ims].values - results.loc[:, pred_im_keys].values, - columns=ims, + columns=list(ims), ) res_df.index = results.index @@ -608,6 +609,139 @@ def important_set_figures( return bar_img_base64, pie_imgs_base64 +def plot_usable_period_records( + df_full: pd.DataFrame, + df_quality: pd.DataFrame, + title: Optional[str] = None, +) -> str: + """ + Plot number of usable records vs period for Full and Quality datasets. + + Parameters + ---------- + df_full : pd.DataFrame + Full dataset DataFrame containing columns for fmin_X, fmin_Y, fmax_X, fmax_Y, and pSA_{period} columns. + df_quality : pd.DataFrame + Quality dataset DataFrame containing columns for fmin_X, fmin_Y, fmax_X, fmax_Y, and pSA_{period} columns. + title : Optional[str], optional + Title for the plot, by default None. + + Returns + ------- + str + Base64 encoded string of the plot image. + """ + + def extract_periods_from_columns(df: pd.DataFrame): + """ + Extracts periods from column names that start with "pSA_". + + Parameters + ---------- + df : pd.DataFrame + DataFrame containing columns with names like "pSA_{period}". + + Returns + ------- + np.ndarray + Sorted array of periods extracted from the column names. + """ + psa_cols = [c for c in df.columns if c.startswith("pSA_")] + periods = [] + for c in psa_cols: + periods.append(float(c.split("_")[1])) + return np.array(sorted(periods)) + + def count_usable_records(df: pd.DataFrame, periods: np.ndarray) -> np.ndarray: + """ + Count usable records per period using BOTH fmin and fmax. + + A record is usable if: + 1/fmax <= T <= 1/fmin + + Parameters + ---------- + df : pandas.DataFrame + Input dataframe containing `fmin_X`, `fmin_Y`, `fmax_X`, and `fmax_Y`. + periods : numpy.ndarray + Periods (s) to evaluate. + + Returns + ------- + numpy.ndarray + Array of counts for each period in `periods`. + """ + + fmin_max = np.maximum(df["fmin_X"].to_numpy(), df["fmin_Y"].to_numpy()) + fmax_min = np.minimum(df["fmax_X"].to_numpy(), df["fmax_Y"].to_numpy()) + + T_upper = 1.0 / fmin_max # long-period limit + T_lower = 1.0 / fmax_min # short-period limit + + # Vectorised evaluation + T_lower = T_lower[:, None] + T_upper = T_upper[:, None] + + usable = (periods >= T_lower) & (periods <= T_upper) + return usable.sum(axis=0) + + required_cols = ["fmin_X", "fmin_Y", "fmax_X", "fmax_Y"] + + df_full = df_full.dropna(subset=required_cols) + df_quality = df_quality.dropna(subset=required_cols) + + periods = extract_periods_from_columns(df_full) + + counts_full = count_usable_records(df_full, periods) + counts_quality = count_usable_records(df_quality, periods) + + fig, ax = plt.subplots(figsize=(8, 6)) + + ax.plot( + periods, + counts_full, + label="Full Database", + linewidth=2, + drawstyle="steps-post", + ) + + ax.plot( + periods, + counts_quality, + label="Quality Database", + linewidth=2, + drawstyle="steps-post", + ) + + ax.set_xscale("log") + ax.set_xlim(0.01, 10) + + ax.xaxis.set_major_locator(mticker.FixedLocator([0.01, 0.1, 1.0, 10.0])) + + ax.xaxis.set_major_formatter( + mticker.FuncFormatter( + lambda x, _: {0.01: "0.01", 0.1: "0.1", 1.0: "1", 10.0: "10"}.get(x, "") + ) + ) + + ax.xaxis.set_minor_formatter(mticker.NullFormatter()) + + ax.set_xlabel("Period (s)") + ax.set_ylabel("Number of Records") + if title: + ax.set_title(title) + ax.legend() + ax.grid(True, which="both", linestyle="--", alpha=0.4) + + fig.tight_layout() + + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", dpi=300) + plt.close(fig) + buf.seek(0) + return base64.b64encode(buf.read()).decode("utf-8") + + def skipped_reason_overlap_barplot(skipped_df: pd.DataFrame, title: str): """ Generate a bar plot showing the overlap of skipped reasons. @@ -1080,6 +1214,29 @@ def plot_site_table_image( ) +def parse_nzgmdb_version(value: str) -> NZGMDB_Versions: + """ + Parse a string input into an NZGMDB_Versions enum member. + + Parameters + ---------- + value : str + The input string representing the NZGMDB version (e.g., "4p3", "4.3", "v4p3"). + + Returns + ------- + NZGMDB_Versions + The corresponding NZGMDB_Versions enum member. + """ + try: + return NZGMDB_Versions(value.strip().lower()) + except ValueError as exc: + allowed = ", ".join(v.value for v in NZGMDB_Versions) + raise typer.BadParameter( + f"Invalid NZGMDB version: {value}. Allowed: {allowed}" + ) from exc + + @cli.from_docstring(app) def generate_report( new_version_directory: Annotated[ @@ -1094,28 +1251,24 @@ def generate_report( typer.Argument(), ], compare_version_directory: Annotated[ - Path, + Optional[Path], typer.Option( exists=True, file_okay=False, ), ] = None, new_version: Annotated[ - Optional[NZGMDB_Versions], + str, typer.Option( - None, - help="The version for the new database (choose from the enum).", case_sensitive=False, ), - ] = NZGMDB_Versions.V4p3, + ] = "4p3", old_version: Annotated[ - Optional[NZGMDB_Versions], + str, typer.Option( - None, - help="The version for the old database (choose from the enum).", case_sensitive=False, ), - ] = NZGMDB_Versions.V4p3, + ] = "4p3", ): """ Generate a HTML report comparing the new version of the database to a previous version. @@ -1131,10 +1284,13 @@ def generate_report( The Top Level directory containing the previous version of the database to compare against. If None, a summary of the new version will be generated instead and comparison plots will not be generated. new_version : NZGMDB_Versions | None - The version for the new database (choose from the enum). Default is NZGMDB_Versions.V4p3. + The version for the new database (e.g., "4p3", "4p4"). Used for labeling in the report. old_version : NZGMDB_Versions | None - The version for the old database (choose from the enum). Default is NZGMDB_Versions.V4p3. + The version for the old database (e.g., "4p3", "4p4"). Used for labeling in the report. """ + new_version = parse_nzgmdb_version(new_version) + old_version = parse_nzgmdb_version(old_version) + html_parts = [] # Start of HTML html_parts.append( @@ -1516,7 +1672,7 @@ def generate_report( else file_structure.PreFlatfileNames.STATION_MAGNITUDE_TABLE_EXTRACTION ) ) - / 3, + // 3, len( pd.read_csv( new_flatfiles_dir / file_structure.PreFlatfileNames.PHASE_ARRIVAL_TABLE @@ -1535,7 +1691,7 @@ def generate_report( else file_structure.PreFlatfileNames.STATION_MAGNITUDE_TABLE_EXTRACTION ) ) - / 3, + // 3, len( pd.read_csv( old_flatifles_dir @@ -1862,154 +2018,72 @@ def generate_report( html_parts.append("") # Add psa count Comparison - html_parts.append("