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("

Quality pSA Record Count Comparison

") - html_parts.append('
') + html_parts.append("

pSA Record Count Comparison

") - fig, ax = plt.subplots(figsize=(16, 6), dpi=300) + img_base64_new = plot_usable_period_records(full_new, quality_new, "New NZGMDB") - # Generate fmin plots if compare_version_directory: - filtered_quality_old = apply_fmin_filter_df(quality_old, pre_4p3=False) - old_record_count = (~filtered_quality_old[PSA_KEYS].isna()).sum(axis=0) - ax.plot( - PERIODS, - old_record_count.loc[PSA_KEYS], - label="Old NZGMDB", - ) - - filtered_quality_new = apply_fmin_filter_df(quality_new) - new_record_count = (~filtered_quality_new[PSA_KEYS].isna()).sum(axis=0) - ax.plot( - PERIODS, - new_record_count.loc[PSA_KEYS], - label="New NZGMDB", - ) - - ax.set_xlabel("Period (s)") - ax.set_xscale("log") - ax.set_ylabel("Count") - ax.set_xlim(0.01, 10.0) - ax.legend() - ax.grid(linewidth=0.5, alpha=0.5, linestyle="--") - - fig.tight_layout() - - # Convert to base64 - buf = BytesIO() - fig.savefig(buf, format="png", bbox_inches="tight") - plt.close(fig) - buf.seek(0) - img_base64 = base64.b64encode(buf.read()).decode("utf-8") - # Embed in HTML - html_parts.append(f'') - html_parts.append("
") - - # Add IM Compare - html_parts.append("

IM Comparison

") - html_parts.append('
') - - quality_old_record_index = quality_old.set_index("record_id") - quality_new_record_index = quality_new.set_index("record_id") + img_base64_old = plot_usable_period_records(full_old, quality_old, "Old NZGMDB") + html_parts.append("
") + html_parts.append(f'') + html_parts.append(f'') + html_parts.append("
") + else: + html_parts.append('
') + html_parts.append(f'') + html_parts.append("
") - # IM Compare - shared_record_ids = np.intersect1d( - quality_old_record_index.index.values.astype(str), - quality_new_record_index.index.values.astype(str), - ) + if compare_version_directory: + # Add IM Compare + html_parts.append("

IM Comparison

") + html_parts.append('
') - plot_ims = [ - "PGV", - "PGA", - "pSA_0.01", - "pSA_0.1", - "pSA_0.5", - "pSA_1.0", - "pSA_3.0", - "pSA_10.0", - ] + quality_old_record_index = quality_old.set_index("record_id") + quality_new_record_index = quality_new.set_index("record_id") - fig, axs = get_fig_axes(len(plot_ims), 2, -1, ind_figsize=(8, 6)) + # IM Compare + shared_record_ids = np.intersect1d( + quality_old_record_index.index.values.astype(str), + quality_new_record_index.index.values.astype(str), + ) - for i, (cur_im, cur_ax) in enumerate(zip(plot_ims, axs)): - cur_old = quality_old_record_index.loc[shared_record_ids, cur_im] - cur_new = quality_new_record_index.loc[shared_record_ids, cur_im] + plot_ims = [ + "PGV", + "PGA", + "pSA_0.01", + "pSA_0.1", + "pSA_0.5", + "pSA_1.0", + "pSA_3.0", + "pSA_10.0", + ] - cur_max = max(cur_old.max(), cur_new.max()) + fig, axs = get_fig_axes(len(plot_ims), 2, -1, ind_figsize=(8, 6)) - cur_ax.scatter(cur_old, cur_new, s=1) - cur_ax.set_xlabel("Old NZGMDB") - cur_ax.set_ylabel("New NZGMDB") - cur_ax.set_title(cur_im) - cur_ax.plot( - [0, cur_max], [0, cur_max], color="black", linestyle="--", linewidth=0.5 - ) - cur_ax.set_xlim(0, cur_max) - cur_ax.set_ylim(0, cur_max) + for i, (cur_im, cur_ax) in enumerate(zip(plot_ims, axs)): + cur_old = quality_old_record_index.loc[shared_record_ids, cur_im] + cur_new = quality_new_record_index.loc[shared_record_ids, cur_im] - # If the cur_im is PGA or includes pSA, set the x and y scales to log - if cur_im == "PGA" or cur_im.startswith("pSA_"): - cur_ax.set_xscale("log") - cur_ax.set_yscale("log") - cur_ax.set_xlim(0.001, cur_max) - cur_ax.set_ylim(0.001, cur_max) + cur_max = max(cur_old.max(), cur_new.max()) - cur_ax.grid(which="both", linewidth=0.5, alpha=0.5, linestyle="--") + cur_ax.scatter(cur_old, cur_new, s=1) + cur_ax.set_xlabel("Old NZGMDB") + cur_ax.set_ylabel("New NZGMDB") + cur_ax.set_title(cur_im) + cur_ax.plot( + [0, cur_max], [0, cur_max], color="black", linestyle="--", linewidth=0.5 + ) + cur_ax.set_xlim(0, cur_max) + cur_ax.set_ylim(0, cur_max) - # Convert to base64 - buf = BytesIO() - fig.savefig(buf, format="png", bbox_inches="tight") - plt.close(fig) - buf.seek(0) - img_base64 = base64.b64encode(buf.read()).decode("utf-8") - # Embed in HTML - html_parts.append(f'') - html_parts.append("
") + # If the cur_im is PGA or includes pSA, set the x and y scales to log + if cur_im == "PGA" or cur_im.startswith("pSA_"): + cur_ax.set_xscale("log") + cur_ax.set_yscale("log") + cur_ax.set_xlim(0.001, cur_max) + cur_ax.set_ylim(0.001, cur_max) - html_parts.append( - """ -

GMM Input Comparison

-
- """ - ) - # GMM Input comparison - input_cols = [ - "ev_lat", - "ev_lon", - "ev_depth", - "mag", - "strike", - "dip", - "rake", - "z_tor", - "r_jb", - "r_rup", - "r_x", - "Vs30", - "Z1.0", - "Z2.5", - ] - # Generate figures and embed as base64 - for i, col in enumerate(input_cols): - cur_x_data = quality_old_record_index.loc[shared_record_ids, col].values - cur_y_data = quality_new_record_index.loc[shared_record_ids, col].values - nan_mask = np.isnan(cur_x_data) | np.isnan(cur_y_data) - cur_x_data = cur_x_data[~nan_mask] - cur_y_data = cur_y_data[~nan_mask] - lims = ( - np.quantile(cur_x_data, 0.01), - np.quantile(cur_x_data, 0.99), - ) - fig, ax = plt.subplots(figsize=(8, 6), dpi=300) - ax.scatter(cur_x_data, cur_y_data, alpha=0.5, s=1) - ax.plot(lims, lims, color="k") - ax.set_xlabel("Old NZGMDB") - ax.set_ylabel("New NZGMDB") - ax.grid(linewidth=0.5, alpha=0.5, linestyle="--") - ax.set_xlim(lims) - ax.set_ylim(lims) - ax.set_aspect("equal") - ax.set_title(f"{col} - N: {len(cur_x_data)}") - fig.tight_layout() + cur_ax.grid(which="both", linewidth=0.5, alpha=0.5, linestyle="--") # Convert to base64 buf = BytesIO() @@ -2019,6 +2093,62 @@ def generate_report( img_base64 = base64.b64encode(buf.read()).decode("utf-8") # Embed in HTML html_parts.append(f'') + html_parts.append("
") + + html_parts.append( + """ +

GMM Input Comparison

+
+ """ + ) + # GMM Input comparison + input_cols = [ + "ev_lat", + "ev_lon", + "ev_depth", + "mag", + "strike", + "dip", + "rake", + "z_tor", + "r_jb", + "r_rup", + "r_x", + "Vs30", + "Z1.0", + "Z2.5", + ] + # Generate figures and embed as base64 + for i, col in enumerate(input_cols): + cur_x_data = quality_old_record_index.loc[shared_record_ids, col].values + cur_y_data = quality_new_record_index.loc[shared_record_ids, col].values + nan_mask = np.isnan(cur_x_data) | np.isnan(cur_y_data) + cur_x_data = cur_x_data[~nan_mask] + cur_y_data = cur_y_data[~nan_mask] + lims = ( + np.quantile(cur_x_data, 0.01), + np.quantile(cur_x_data, 0.99), + ) + fig, ax = plt.subplots(figsize=(8, 6), dpi=300) + ax.scatter(cur_x_data, cur_y_data, alpha=0.5, s=1) + ax.plot(lims, lims, color="k") + ax.set_xlabel("Old NZGMDB") + ax.set_ylabel("New NZGMDB") + ax.grid(linewidth=0.5, alpha=0.5, linestyle="--") + ax.set_xlim(lims) + ax.set_ylim(lims) + ax.set_aspect("equal") + ax.set_title(f"{col} - N: {len(cur_x_data)}") + fig.tight_layout() + + # Convert to base64 + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight") + plt.close(fig) + buf.seek(0) + img_base64 = base64.b64encode(buf.read()).decode("utf-8") + # Embed in HTML + html_parts.append(f'') # End of Section html_parts.append("
") @@ -2026,3 +2156,7 @@ def generate_report( # Save report with open(output_file, "w") as f: f.write("".join(html_parts)) + + +if __name__ == "__main__": + app() diff --git a/nzgmdb/scripts/real_time_eq_runs.py b/nzgmdb/scripts/real_time_eq_runs.py index c7607a82..f81d7510 100644 --- a/nzgmdb/scripts/real_time_eq_runs.py +++ b/nzgmdb/scripts/real_time_eq_runs.py @@ -129,7 +129,7 @@ def reply_to_message_on_slack(thread_ts: str, reply_message: str): def download_earthquake_data( - start_date: datetime, end_date: datetime, mag_filter: float + start_date: datetime.datetime, end_date: datetime.datetime, mag_filter: float ) -> pd.DataFrame: """ Download the earthquake data from the GeoNet API @@ -237,9 +237,7 @@ def run_event( conda_sh: Annotated[Path, typer.Argument(exists=True, file_okay=True)], gmc_activate: Annotated[str, typer.Argument()], gmc_predict_activate: Annotated[str, typer.Argument()], - ko_matrix_path: Annotated[ - Path, typer.Argument(exists=True, file_okay=False) - ], + ko_matrix_path: Annotated[Path, typer.Argument(exists=True, file_okay=False)], add_seismic_now: Annotated[bool, typer.Option(is_flag=True)] = False, machine: Annotated[ cfg.MachineName, @@ -510,7 +508,9 @@ def poll_earthquake_data( init_start_date = None while True: # Get the last 10 minutes worth of data and check if there are any new events - end_date = datetime.datetime.utcnow() - datetime.timedelta(minutes=1) + end_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + minutes=1 + ) # If an event was just executed, ensures we capture any events that may have been missed during the execution start_date = ( end_date - datetime.timedelta(minutes=10) diff --git a/nzgmdb/scripts/run_gmc.py b/nzgmdb/scripts/run_gmc.py index 8b7b39f3..781c3de8 100644 --- a/nzgmdb/scripts/run_gmc.py +++ b/nzgmdb/scripts/run_gmc.py @@ -5,7 +5,7 @@ import functools import multiprocessing from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional import numpy as np import pandas as pd @@ -73,6 +73,7 @@ def process_batch( gmc_predict_activate: str, phase_arrival_table_ffp: Path, prob_series_ffp: Path, + xml_dir: Path, ): """ Process a single subfolder: extract features and run predictions. @@ -99,6 +100,8 @@ def process_batch( The full file path to the phase arrival table prob_series_ffp : Path The full file path to the prob_series hdf5 file. + xml_dir : Path + The directory containing the stationxml files. Raises ------ @@ -128,7 +131,7 @@ def process_batch( ) else: # Activate gmc environment and extract features for the subfolder - features_command = f"python {gmc_scripts_path}/extract_features.py {gmc_dir} {waveform_dir} mseed --ko_matrices_dir {ko_matrices_dir} --record_list_ffp {batch_txt} --phase_arrival_table {phase_arrival_table_ffp} --prob_series {prob_series_ffp}" + features_command = f"python {gmc_scripts_path}/extract_features.py {gmc_dir} {waveform_dir} mseed --ko_matrices_dir {ko_matrices_dir} --record_list_ffp {batch_txt} --phase_arrival_table {phase_arrival_table_ffp} --prob_series {prob_series_ffp} --xml_dir {xml_dir}" shell_commands.run_command( features_command, conda_sh, gmc_activate, log_file_path_features ) @@ -195,22 +198,26 @@ def run_gmc_processing( int, typer.Option(), ] = 1, + gmc_n_batches: Annotated[ + Optional[int], + typer.Option(), + ] = None, waveform_dir: Annotated[ - Path, + Optional[Path], typer.Option( exists=True, file_okay=False, ), ] = None, output_dir: Annotated[ - Path, + Optional[Path], typer.Option( exists=True, file_okay=False, ), ] = None, bypass_records_ffp: Annotated[ - Path, + Optional[Path], typer.Option(), ] = None, ): @@ -236,6 +243,9 @@ def run_gmc_processing( Command to activate the GMC predict environment to run predictions. n_procs : int, optional Number of processes to use for multiprocessing (default is 1). + gmc_n_batches : int, optional + Number of batches to split the mseed files into for processing. + If None, it will be set to the number of processes (default is None). waveform_dir : Path, optional Directory containing all waveform files. output_dir : Path, optional @@ -270,11 +280,13 @@ def run_gmc_processing( # Get all the mseed files mseed_files = list(waveform_dir.rglob("*.mseed")) - # Ensure that n_procs is equal too or less than the number of mseed files + # Ensure that n_procs and n_batches is equal too or less than the number of mseed files n_procs = min(n_procs, len(mseed_files)) + n_batches = gmc_n_batches or n_procs + n_batches = min(n_batches, len(mseed_files)) - # Split them into even batches based on number of mseeds and n_procs - mseed_batches = np.array_split(mseed_files, n_procs) + # Split them into even batches based on number of mseeds and n_batches + mseed_batches = np.array_split(mseed_files, n_batches) # Create a partial function with common arguments pre-filled process_partial = functools.partial( @@ -287,17 +299,26 @@ def run_gmc_processing( gmc_predict_activate=gmc_predict_activate, phase_arrival_table_ffp=phase_arrival_table_ffp, prob_series_ffp=prob_series_ffp, + xml_dir=file_structure.get_stationxml_dir(main_dir), ) - # Use multiprocessing with starmap and the partial function - with multiprocessing.Pool(n_procs) as p: - p.starmap( - process_partial, - [ - (batch, (gmc_dir / f"batch_{idx}")) - for idx, batch in enumerate(mseed_batches) - ], - ) + pending_jobs = [] + for idx, batch in enumerate(mseed_batches): + batch_dir = gmc_dir / f"batch_{idx}" + predictions_output = batch_dir / file_structure.FlatfileNames.GMC_PREDICTIONS + + if predictions_output.exists(): + print(f"Skipping Batch {idx} (found existing gmc_predictions.csv)") + continue + + pending_jobs.append((list(batch), batch_dir)) + + if not pending_jobs: + print("All batches already have gmc_predictions.csv; nothing to run.") + else: + # Use multiprocessing with starmap and the partial function (pending only) + with multiprocessing.Pool(n_procs) as p: + p.starmap(process_partial, pending_jobs) # For each subfolder combine the gmc_predictions.csv into a single file dfs = [] diff --git a/nzgmdb/scripts/run_nzgmdb.py b/nzgmdb/scripts/run_nzgmdb.py index 8a5d2c46..31a7a6ff 100644 --- a/nzgmdb/scripts/run_nzgmdb.py +++ b/nzgmdb/scripts/run_nzgmdb.py @@ -4,7 +4,7 @@ from datetime import datetime from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional import typer @@ -29,16 +29,24 @@ def fetch_geonet_data( n_procs: Annotated[int, typer.Option()] = 1, batch_size: Annotated[int, typer.Option()] = 500, only_event_ids: Annotated[ - list[str], typer.Option(callback=lambda x: [] if x is None else x[0].split(",")) + Optional[list[str]], + typer.Option(callback=lambda x: [] if x is None else x[0].split(",")), ] = None, only_sites: Annotated[ - list[str], typer.Option(callback=lambda x: [] if x is None else x[0].split(",")) + Optional[list[str]], + typer.Option(callback=lambda x: [] if x is None else x[0].split(",")), ] = None, only_record_ids_ffp: Annotated[ - Path, typer.Option(exists=True, dir_okay=False) + Optional[Path], typer.Option(exists=True, dir_okay=False) ] = None, real_time: Annotated[bool, typer.Option()] = False, mp_sites: Annotated[bool, typer.Option()] = False, + add_tmp_arrays: Annotated[ + bool, + typer.Option( + is_flag=True, + ), + ] = False, ): """ Fetch earthquake data from Geonet and generate the earthquake source and station magnitude tables. @@ -65,6 +73,8 @@ def fetch_geonet_data( If True, the function will run in real-time mode by using a different client (default is False). mp_sites : bool, optional If True, the function will use multiprocessing over sites instead of events (default is False). + add_tmp_arrays : bool, optional + If True, temporary arrays will be added to the database run (default is False). """ geonet.parse_geonet_information( main_dir, @@ -77,6 +87,7 @@ def fetch_geonet_data( only_record_ids_ffp, real_time, mp_sites, + add_tmp_arrays, ) @@ -98,7 +109,7 @@ def extract_waveforms( ], n_procs: Annotated[int, typer.Option()] = 1, only_record_ids_ffp: Annotated[ - Path, + Optional[Path], typer.Option( exists=True, dir_okay=False, @@ -108,6 +119,13 @@ def extract_waveforms( int, typer.Option(), ] = 1000, + tmp_array_dir: Annotated[ + Optional[Path], + typer.Option( + exists=True, + file_okay=False, + ), + ] = None, ): """ Extract waveforms using the station extraction table and save them as MiniSEED files. @@ -125,9 +143,16 @@ def extract_waveforms( The full file path to a set of record IDs to only run for. If provided, only these records will be processed. batch_size : int, optional The batch size for how many extracted waveforms to process before checkpointing (default is 1000). + tmp_array_dir : Path, optional + The directory the saved temporary array data is to be used for waveform extraction. """ waveform_extraction.extract_waveforms( - main_dir, station_extraction_table_ffp, n_procs, only_record_ids_ffp, batch_size + main_dir, + station_extraction_table_ffp, + n_procs, + only_record_ids_ffp, + batch_size, + tmp_array_dir, ) @@ -188,6 +213,10 @@ def make_phase_arrival_table( typer.Argument(), ], n_procs: Annotated[int, typer.Option()] = 1, + n_batches: Annotated[ + Optional[int], + typer.Option(), + ] = None, bypass_records_ffp: Annotated[ Path, typer.Option( @@ -214,6 +243,9 @@ def make_phase_arrival_table( The command to activate the environment for running PhaseNet. n_procs : int, optional Number of processes to use (default is 1). + n_batches : int, optional + The number of batches to split the mseed files into for processing. + If not provided, it will be determined automatically based on the number of mseed files and n_procs. bypass_records_ffp : Path, optional The full file path to the bypass records file for custom P-wave index values. This allows you to specify custom P-wave index values for records that may not be @@ -225,7 +257,9 @@ def make_phase_arrival_table( conda_sh, env_activate_command, n_procs, + n_batches, bypass_records_ffp, + xml_dir=file_structure.get_stationxml_dir(main_dir), ) @@ -493,9 +527,17 @@ def run_im_calculation( ), ] = False, intensity_measures: Annotated[ - list[IM], + str | None, typer.Option( - callback=lambda x: [IM(i) for i in x[0].split(",")], + callback=lambda x: ( + None + if x is None or (isinstance(x, (list, tuple)) and not x) + else [ + IM(i.strip()) + for i in (x[0] if isinstance(x, (list, tuple)) else x).split(",") + if i.strip() + ] + ), ), ] = None, ): @@ -542,6 +584,12 @@ def generate_site_table_basin( file_okay=False, ), ], + add_tmp_arrays: Annotated[ + bool, + typer.Option( + is_flag=True, + ), + ] = False, ): """ Generate the site table basin flatfile. @@ -554,15 +602,20 @@ def generate_site_table_basin( The main directory of the NZGMDB results (Highest level directory). nzcvm_data_ffp : Path The full file path to the nzcvm_data repository that stores the basin information. + add_tmp_arrays : bool, optional + If True, temporary arrays will be added to the site table (default is False). """ main_dir.mkdir(parents=True, exist_ok=True) # Generate the site basin flatfile flatfile_dir = file_structure.get_flatfile_dir(main_dir) flatfile_dir.mkdir(parents=True, exist_ok=True) - site_df = sites.create_site_table_response() + site_df, station_df = sites.create_site_table_response(add_tmp_arrays) site_df = sites.add_site_basins(site_df, nzcvm_data_ffp) + station_df.to_csv( + flatfile_dir / file_structure.PreFlatfileNames.STATION_TABLE, index=False + ) site_df.to_csv( flatfile_dir / file_structure.PreFlatfileNames.SITE_TABLE, index=False ) @@ -782,17 +835,13 @@ def run_full_nzgmdb( file_okay=False, ), ], - gmc_procs: Annotated[ - int, - typer.Option(), - ] = 1, n_procs: Annotated[int, typer.Option()] = 1, checkpoint: Annotated[ bool, typer.Option(), ] = False, only_event_ids: Annotated[ - list[str], + Optional[list[str]], typer.Option( callback=lambda x: [] if x is None else x[0].split(","), ), @@ -813,11 +862,19 @@ def run_full_nzgmdb( geonet_batch_size: Annotated[ int, typer.Option(), - ] = 500, + ] = 100, snr_batch_size: Annotated[ int, typer.Option(), ] = 5000, + phase_arrival_n_batches: Annotated[ + int, + typer.Option(), + ] = None, + gmc_n_batches: Annotated[ + int, + typer.Option(), + ] = None, real_time: Annotated[ bool, typer.Option(), @@ -837,6 +894,17 @@ def run_full_nzgmdb( dir_okay=False, ), ] = None, + add_tmp_arrays: Annotated[ + bool, + typer.Option(), + ] = False, + tmp_array_data_dir: Annotated[ + Path, + typer.Option( + exists=True, + file_okay=False, + ), + ] = None, machine: Annotated[ cfg.MachineName, typer.Option( @@ -850,7 +918,9 @@ def run_full_nzgmdb( This function orchestrates the full pipeline of NZGMDB, executing all necessary steps sequentially. Steps Included: + - Generate site table with basin information - Fetch Geonet data + - Waveform extraction - Merge tectonic domains - Generate phase arrival table - Calculate SNR @@ -883,8 +953,6 @@ def run_full_nzgmdb( Command to activate gmc_predict environment to run the predictions. ko_matrix_path : Path Path to the ko matrix directory - gmc_procs : int, optional - Number of processes to use for GMC (default is 1). n_procs : int, optional The number of processes to use (default is 1). checkpoint : bool, optional @@ -899,6 +967,12 @@ def run_full_nzgmdb( The batch size for Geonet data retrieval (default is 500). snr_batch_size : int, optional The batch size for the SNR calculation (default is 5000). + phase_arrival_n_batches : int, optional + The number of batches to split the phase arrival calculation into + (default is None, which will automatically determine the number of batches based on the number of records and n_procs). + gmc_n_batches : int, optional + The number of batches to split the GMC prediction step into + (default is None, which will automatically determine the number of batches based on the number of records and gmc_procs). real_time : bool, optional If True, the function will run in real-time mode using a different client (default is False). upload : bool, optional @@ -907,12 +981,22 @@ def run_full_nzgmdb( If True, the function will create a quality database (default is False). bypass_records_ffp : Path, optional The full file path to the bypass records file, if applicable. + add_tmp_arrays : bool, optional + If True, temporary arrays will be added to the database run (default is False). + tmp_array_data_dir : Path, optional + The directory containing temporary array data, required if add_tmp_arrays is True. machine : cfg.MachineName, optional The machine name to use for process configuration (default is None). """ main_dir.mkdir(parents=True, exist_ok=True) config = cfg.Config() + # Check that if add_tmp_arrays is True, tmp_array_data_dir is provided + if add_tmp_arrays and tmp_array_data_dir is None: + raise ValueError( + "tmp_array_data_dir must be provided if add_tmp_arrays is True." + ) + # Generate the site basin flatfile flatfile_dir = file_structure.get_flatfile_dir(main_dir) flatfile_dir.mkdir(parents=True, exist_ok=True) @@ -921,7 +1005,7 @@ def run_full_nzgmdb( and (flatfile_dir / file_structure.PreFlatfileNames.SITE_TABLE).exists() ): print("Generating site table basin flatfile") - generate_site_table_basin(main_dir, nzcvm_data_ffp) + generate_site_table_basin(main_dir, nzcvm_data_ffp, add_tmp_arrays) # Fetch the Geonet data if not ( @@ -947,6 +1031,7 @@ def run_full_nzgmdb( only_sites, only_record_ids_ffp, real_time, + add_tmp_arrays=add_tmp_arrays, ) # Extract Waveforms @@ -970,6 +1055,7 @@ def run_full_nzgmdb( extract_n_procs, only_record_ids_ffp, batch_size=geonet_batch_size, + tmp_array_dir=tmp_array_data_dir, ) # Merge the tectonic domains @@ -1018,7 +1104,9 @@ def run_full_nzgmdb( conda_sh, gmc_activate, phase_n_procs, + phase_arrival_n_batches, bypass_records_ffp, + xml_dir=file_structure.get_stationxml_dir(main_dir), ) # Generate SNR @@ -1072,7 +1160,7 @@ def run_full_nzgmdb( ): print("Running GMC") gmc_n_procs = ( - gmc_procs + n_procs if machine is None else config.get_n_procs(machine, cfg.WorkflowStep.GMC) ) @@ -1084,6 +1172,7 @@ def run_full_nzgmdb( gmc_activate, gmc_predict_activate, gmc_n_procs, + gmc_n_batches, bypass_records_ffp=bypass_records_ffp, ) diff --git a/nzgmdb/scripts/upload_to_dropbox.py b/nzgmdb/scripts/upload_to_dropbox.py index c1aac9ad..424937df 100644 --- a/nzgmdb/scripts/upload_to_dropbox.py +++ b/nzgmdb/scripts/upload_to_dropbox.py @@ -443,7 +443,7 @@ def download_dropbox_archive( if not ignore_xml: xml_dir.mkdir(exist_ok=True) zips_to_download.append( - f"{dropbox_version_dir}/{xml_zip}", zip_dir / xml_zip, xml_dir + (f"{dropbox_version_dir}/{xml_zip}", zip_dir / xml_zip, xml_dir) ) # Ensure there is something to download diff --git a/nzgmdb/temp_arrays/backup_to_dropbox.py b/nzgmdb/temp_arrays/backup_to_dropbox.py new file mode 100755 index 00000000..c6864e87 --- /dev/null +++ b/nzgmdb/temp_arrays/backup_to_dropbox.py @@ -0,0 +1,342 @@ +import csv +import subprocess +from collections.abc import Iterator +from pathlib import Path +from typing import TypedDict + +import typer + +app = typer.Typer(pretty_exceptions_enable=False) + +MANIFEST_HEADER = [ + "type", + "net", + "name", + "local_path", + "zip_name", + "status", + "bytes", +] + + +class ManifestRow(TypedDict): + """A single row in the Dropbox backup manifest CSV.""" + + type: str + net: str + name: str + local_path: str + zip_name: str + status: str + bytes: int + + +def zip_directory(src_dir: Path, out_dir: Path) -> Path: + """Create a zip file for a directory. + + This uses the system `zip` executable (fast + reliable) and skips work if the + expected zip already exists. + + Parameters + ---------- + src_dir : Path + Source directory to zip. + out_dir : Path + Output directory where the zip file will be written. + + Returns + ------- + Path + Path to the created (or existing) zip file. + """ + out_dir.mkdir(parents=True, exist_ok=True) + zip_path = out_dir / f"{src_dir.name}.zip" + + if zip_path.exists(): + return zip_path + + subprocess.check_call( + ["zip", "-r", "-1", str(zip_path), src_dir.name], + cwd=src_dir.parent, + ) + + return zip_path + + +def upload_and_verify(local_file: Path, dropbox_dir: str) -> bool: + """Upload a file to Dropbox using rclone and verify by file size. + + Parameters + ---------- + local_file : Path + File to upload. + dropbox_dir : str + Rclone remote path (directory) to upload into. + + Returns + ------- + bool + True if the remote file exists and its size matches the local file. + """ + subprocess.check_call( + ["rclone", "copy", str(local_file), dropbox_dir], + ) + + local_size = local_file.stat().st_size + + out = ( + subprocess.check_output( + [ + "rclone", + "lsf", + "--format=s", + f"{dropbox_dir}/{local_file.name}", + ] + ) + .decode() + .strip() + ) + + return bool(out) and int(out) == local_size + + +def load_manifest(path: Path) -> dict[str, ManifestRow]: + """Load an upload manifest keyed by ``zip_name``. + + Parameters + ---------- + path : Path + Path to the manifest CSV. + + Returns + ------- + dict[str, ManifestRow] + Manifest rows keyed by ``zip_name``. Returns an empty dict if the file + does not exist. + """ + rows: dict[str, ManifestRow] = {} + + if not path.exists(): + return rows + + with path.open(newline="") as f: + reader: csv.DictReader[str] = csv.DictReader(f) + for row in reader: + # DictReader gives us strings; coerce the known int column. + bytes_value = int(row.get("bytes") or 0) + zip_name = row.get("zip_name") + if not zip_name: + # Keep behavior conservative: skip malformed rows. + continue + + rows[zip_name] = ManifestRow( + type=row.get("type", ""), + net=row.get("net", ""), + name=row.get("name", ""), + local_path=row.get("local_path", ""), + zip_name=zip_name, + status=row.get("status", ""), + bytes=bytes_value, + ) + + return rows + + +def append_manifest_row(path: Path, row: ManifestRow) -> None: + """Append a single row to the manifest CSV, creating it if needed. + + Parameters + ---------- + path : Path + Path to the manifest CSV. + row : ManifestRow + The manifest row to append. + + Returns + ------- + None + """ + new_file = not path.exists() + + with path.open("a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=MANIFEST_HEADER) + if new_file: + writer.writeheader() + writer.writerow(row) + + +def update_manifest_status(path: Path, zip_name: str, status: str, size: int) -> None: + """Update an existing manifest row with a new status and size. + + Parameters + ---------- + path : Path + Path to the manifest CSV. + zip_name : str + Zip filename key for the entry to update. + status : str + New status string (e.g. "DONE", "FAILED"). + size : int + Size of the zip file in bytes. + """ + rows = load_manifest(path) + rows[zip_name]["status"] = status + rows[zip_name]["bytes"] = size + + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=MANIFEST_HEADER) + writer.writeheader() + writer.writerows(rows.values()) + + +def discover_stationxml(stationxml_dir: Path) -> Iterator[ManifestRow]: + """Discover the StationXML directory entry. + + Parameters + ---------- + stationxml_dir : Path + Path to the directory containing StationXML files. + + Returns + ------- + Iterator[ManifestRow] + An iterator yielding a single manifest row representing the StationXML + directory. + """ + yield ManifestRow( + type="stationxml", + net="", + name=stationxml_dir.name, + local_path=str(stationxml_dir), + zip_name=f"{stationxml_dir.name}.zip", + status="PENDING", + bytes=0, + ) + + +def discover_waveforms(waveforms_root: Path) -> Iterator[ManifestRow]: + """Discover waveform leaf directories to back up. + + The expected directory structure is ``waveforms///`` where each + ``leaf`` directory is zipped independently. + + Parameters + ---------- + waveforms_root : Path + Root directory containing per-network waveform directories. + + Returns + ------- + Iterator[ManifestRow] + Iterator of manifest rows for each leaf directory. + """ + for net_dir in sorted(waveforms_root.iterdir()): + if not net_dir.is_dir(): + continue + + for leaf in sorted(net_dir.iterdir()): + if not leaf.is_dir(): + continue + + yield ManifestRow( + type="waveforms", + net=net_dir.name, + name=leaf.name, + local_path=str(leaf), + zip_name=f"{leaf.name}.zip", + status="PENDING", + bytes=0, + ) + + +def process_entry( + entry: ManifestRow, tmp_zip_dir: Path, manifest_path: Path, dropbox_path: str +) -> None: + """Zip, upload, verify, and update manifest for a single entry. + + Parameters + ---------- + entry : ManifestRow + Manifest entry describing what to back up. + tmp_zip_dir : Path + Directory where zips are created temporarily. + manifest_path : Path + Path to the manifest CSV. + dropbox_path : str + Base rclone Dropbox remote path. + """ + src = Path(entry["local_path"]) + zip_path = zip_directory(src, tmp_zip_dir) + + if entry["type"] == "stationxml": + dropbox_target = f"{dropbox_path}/stationxml" + else: + dropbox_target = f"{dropbox_path}/waveforms/{entry['net']}" + + try: + ok = upload_and_verify(zip_path, dropbox_target) + except (OSError, subprocess.CalledProcessError) as e: + print(f"ERROR uploading {zip_path.name}: {e}") + update_manifest_status(manifest_path, entry["zip_name"], "FAILED", 0) + return + + if ok: + size = zip_path.stat().st_size + update_manifest_status(manifest_path, entry["zip_name"], "DONE", size) + zip_path.unlink() + print(f"DONE {zip_path.name}") + else: + update_manifest_status(manifest_path, entry["zip_name"], "FAILED", 0) + print(f"FAILED {zip_path.name}") + + +@app.command() +def run( + data_root: Path = typer.Argument( + ..., help="Root directory containing waveforms/ and stationxml/" + ), + dropbox_path: str = typer.Argument( + ..., + help="Rclone Dropbox path to upload to.", + ), +) -> None: + """Resume-safe Dropbox backup with manifest tracking. + + Parameters + ---------- + data_root : Path + Root directory containing ``waveforms/`` and ``stationxml/``. + dropbox_path : str + Base rclone Dropbox remote path to upload to. + """ + manifest = data_root / "dropbox_manifest.csv" + manifest_rows = load_manifest(manifest) + + # Discover + register new entries + stationxml = data_root / "stationxml" + waveforms = data_root / "waveforms" + + tmp_zip_dir = data_root / "tmp_zips" + tmp_zip_dir.mkdir(parents=True, exist_ok=True) + + for entry in discover_stationxml(stationxml): + if entry["zip_name"] not in manifest_rows: + append_manifest_row(manifest, entry) + + for entry in discover_waveforms(waveforms): + if entry["zip_name"] not in manifest_rows: + append_manifest_row(manifest, entry) + + # Reload after discovery + manifest_rows = load_manifest(manifest) + + pending = [row for row in manifest_rows.values() if row["status"] != "DONE"] + + print(f"Pending uploads: {len(pending)}") + + for entry in pending: + process_entry(entry, tmp_zip_dir, manifest, dropbox_path) + + +if __name__ == "__main__": + app() diff --git a/nzgmdb/temp_arrays/check_data.py b/nzgmdb/temp_arrays/check_data.py new file mode 100644 index 00000000..da58933e --- /dev/null +++ b/nzgmdb/temp_arrays/check_data.py @@ -0,0 +1,293 @@ +from pathlib import Path + +import pandas as pd +import typer +from obspy import UTCDateTime + +app = typer.Typer(pretty_exceptions_enable=False) + +# Month length in seconds (30 days) +MONTH_SECONDS = 30 * 24 * 3600 + + +def _format_end_for_filename(end_dt: UTCDateTime | str) -> str: + """Format an end datetime into the MiniSEED filename timestamp form. + + Parameters + ---------- + end_dt : UTCDateTime | str + An ObsPy ``UTCDateTime`` or any string parseable by ``UTCDateTime``. + + Returns + ------- + str + Timestamp string formatted like ``YYYYMMDDTHHMMSSZ``. + + Raises + ------ + ValueError + If ``end_dt`` cannot be parsed by ``UTCDateTime``. + """ + if not isinstance(end_dt, UTCDateTime): + end_dt = UTCDateTime(end_dt) + return end_dt.strftime("%Y%m%dT%H%M%SZ") + + +def _row_mseed_dir(output_dir: Path, mseed_dirname: str, row: pd.Series) -> Path: + """Build the expected output MiniSEED directory for a CSV row. + + Parameters + ---------- + output_dir : Path + Root output directory containing the MiniSEED subdirectory. + mseed_dirname : str + Name of the MiniSEED subdirectory (typically ``"waveforms"``). + row : pandas.Series + A row from the input CSV containing at least ``net``, ``sta``, ``loc``, + and ``chan``. + + Returns + ------- + Path + Path like ``///___``. + """ + net = str(row["net"]).strip() + sta = str(row["sta"]).strip() + loc_field = str(row["loc"]) + chan_prefix = str(row["chan"]).strip() + + record_sub = f"{net}_{sta}_{chan_prefix}_{loc_field}" + return output_dir / mseed_dirname / net / record_sub + + +def is_row_done(row: pd.Series, *, output_dir: Path, mseed_dirname: str) -> bool: + """Check whether a CSV row has fully completed waveform download. + + A row is considered **done** if the expected MiniSEED output directory + contains at least one ``.mseed`` file whose final ``__END`` timestamp equals + the row's ``end_date``. + + Notes + ----- + This check is filesystem-driven and intentionally conservative: + + - Only files whose channel code ends with ``"Z"`` are considered. + - Any filesystem error or unexpected filename format causes this function + to return ``False`` (so the row can be retried). + + Parameters + ---------- + row : pandas.Series + A row of the input CSV. + output_dir : Path + Root output directory. + mseed_dirname : str + Name of the MiniSEED subdirectory (typically ``"waveforms"``). + + Returns + ------- + bool + ``True`` if the row appears fully completed, otherwise ``False``. + """ + mseed_path = _row_mseed_dir(output_dir, mseed_dirname, row) + + if not mseed_path.is_dir(): + return False + + target_end = _format_end_for_filename(row["end_date"]) + + try: + for path in mseed_path.iterdir(): + if path.suffix != ".mseed": + continue + + stem = path.stem + + # Filename parts expected like: NET.STA..CHAN__START__END + parts = stem.split(".") + if len(parts) <= 3: + continue + + chan_check = parts[3].split("__", 1)[0] # remove any suffix after __ + if not chan_check.endswith("Z"): + continue + + if "__" not in stem: + continue + + last = stem.rsplit("__", 1)[-1] + if last == target_end: + return True + except OSError: + # Any filesystem error -> treat as not done so row will be retried. + return False + + return False + + +def is_row_started(row: pd.Series, *, output_dir: Path, mseed_dirname: str) -> bool: + """Check whether any waveform data exists for a CSV row. + + Parameters + ---------- + row : pandas.Series + A row of the input CSV. + output_dir : Path + Root output directory. + mseed_dirname : str + Name of the MiniSEED subdirectory (typically ``"waveforms"``). + + Returns + ------- + bool + ``True`` if at least one ``.mseed`` file exists for the row. + """ + mseed_path = _row_mseed_dir(output_dir, mseed_dirname, row) + + if not mseed_path.is_dir(): + return False + + return any(p.suffix == ".mseed" for p in mseed_path.iterdir()) + + +def evaluate_download_completeness( + csv_file: Path, + output_csv: Path, + output_dir: Path, + provider: str = "IRIS", +) -> pd.DataFrame: + """Evaluate and summarize download completeness for waveform requests. + + The evaluation is performed at two levels: + + - **completed**: at least one MiniSEED file exists with a final ``__END`` + timestamp equal to the row ``end_date``. + - **started**: at least one MiniSEED file exists for the row. + + Parameters + ---------- + csv_file : Path + Input CSV describing waveform requests. + output_csv : Path + Output CSV to write the evaluation results to. + output_dir : Path + Root output directory containing the MiniSEED data. + provider : str, optional + Provider name to filter by, by default "IRIS". + + Returns + ------- + pandas.DataFrame + The evaluated dataframe including the ``completed`` and ``started`` + boolean columns. + + Raises + ------ + ValueError + If required columns are missing from the input CSV. + """ + df = pd.read_csv(csv_file, dtype={"loc": str}, keep_default_na=False) + + required_cols = { + "provider", + "net", + "sta", + "loc", + "chan", + "start_date", + "end_date", + } + missing = sorted(required_cols - set(df.columns)) + if missing: + raise ValueError(f"Input CSV missing required columns: {missing}") + + df = df[df["provider"] == provider].reset_index(drop=True) + + print(f"Evaluating {len(df)} rows for completeness...") + + mseed_dirname = "waveforms" + + df["completed"] = df.apply( + is_row_done, axis=1, output_dir=output_dir, mseed_dirname=mseed_dirname + ) + df["started"] = df.apply( + is_row_started, axis=1, output_dir=output_dir, mseed_dirname=mseed_dirname + ) + + total = len(df) + completed = int(df["completed"].sum()) + started = int(df["started"].sum()) + + partial = int(((df["started"]) & (~df["completed"])).sum()) + none = int((~df["started"]).sum()) + + print("\n===== DOWNLOAD SUMMARY =====") + print(f"Total rows : {total}") + print(f"Fully completed : {completed}") + print(f"Started (any data) : {started}") + print(f"Partial (started only): {partial}") + print(f"No data at all : {none}") + + cols = ["provider", "net", "sta", "loc", "chan", "start_date", "end_date"] + + if none > 0: + print("\n===== ROWS WITH NO DATA =====") + print(df.loc[~df["started"], cols].to_string(index=False)) + + if partial > 0: + print("\n===== PARTIALLY DOWNLOADED ROWS =====") + print(df.loc[df["started"] & ~df["completed"], cols].to_string(index=False)) + + output_csv.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(output_csv, index=False) + print(f"\nWrote evaluation CSV to:\n {output_csv}") + + return df + + +@app.command() +def run( + csv_file: Path = typer.Argument( + ..., exists=True, dir_okay=False, help="Input waveform request CSV." + ), + output_csv: Path = typer.Argument( + ..., dir_okay=False, help="Output CSV to write evaluation results to." + ), + output_dir: Path = typer.Argument( + ..., + exists=False, + file_okay=False, + help="Root output directory containing the MiniSEED data.", + ), + provider: str = typer.Option( + "IRIS", + help="Provider name to filter by.", + ), +) -> None: + """Evaluate MiniSEED download completeness for a request table. + + Parameters + ---------- + csv_file : Path + Input CSV describing waveform requests. + output_csv : Path + Output CSV to write the evaluation results. + output_dir : Path + Root output directory containing the MiniSEED data. + provider : str + Provider name to filter by. + + Returns + ------- + None + """ + evaluate_download_completeness( + csv_file=csv_file, + output_csv=output_csv, + output_dir=output_dir, + provider=provider, + ) + + +if __name__ == "__main__": + app() diff --git a/nzgmdb/temp_arrays/get_stations.py b/nzgmdb/temp_arrays/get_stations.py new file mode 100644 index 00000000..d44c093d --- /dev/null +++ b/nzgmdb/temp_arrays/get_stations.py @@ -0,0 +1,381 @@ +"""Fetch station/channel metadata from FDSN providers and write to CSV.""" + +from collections.abc import Iterable +from pathlib import Path +from typing import Annotated + +import geopandas as gpd +import nzgeom.coastlines +import pandas as pd +import typer +from obspy import UTCDateTime +from obspy.clients.fdsn import Client as FDSN_Client +from shapely.geometry import Point + +app = typer.Typer(pretty_exceptions_enable=False) + +URL_MAPPINGS = { + "AUSPASS": "http://auspass.edu.au", + "BGR": "http://eida.bgr.de", + "EIDA": "http://eida-federator.ethz.ch", + "ETH": "http://eida.ethz.ch", + "EMSC": "http://www.seismicportal.eu", + "GEONET": "http://service.geonet.org.nz", + "GEOFON": "http://geofon.gfz-potsdam.de", + "GFZ": "http://geofon.gfz-potsdam.de", + "ICGC": "http://ws.icgc.cat", + "IESDMC": "http://batsws.earth.sinica.edu.tw", + "INGV": "http://webservices.ingv.it", + "IPGP": "http://ws.ipgp.fr", + "IRIS": "http://service.iris.edu", + "IRISPH5": "http://service.iris.edu", + "ISC": "http://www.isc.ac.uk", + "KNMI": "http://rdsa.knmi.nl", + "KOERI": "http://eida.koeri.boun.edu.tr", + "LMU": "https://erde.geophysik.uni-muenchen.de", + "NCEDC": "https://service.ncedc.org", + "NIEP": "http://eida-sc3.infp.ro", + "NOA": "http://eida.gein.noa.gr", + "ODC": "http://www.orfeus-eu.org", + "ORFEUS": "http://www.orfeus-eu.org", + "RESIF": "http://ws.resif.fr", + "RESIFPH5": "http://ph5ws.resif.fr", + "RASPISHAKE": "https://data.raspberryshake.org", + "SCEDC": "http://service.scedc.caltech.edu", + "TEXNET": "http://rtserve.beg.utexas.edu", + "UIB-NORSAR": "http://eida.geo.uib.no", + "USGS": "http://earthquake.usgs.gov", + "USP": "http://sismo.iag.usp.br", +} + + +def is_point_inside_nz(lat: float, lon: float, *, nz_coast: gpd.GeoDataFrame) -> bool: + """Return True if the given point lies within NZ coastline polygons. + + Parameters + ---------- + lat : float + Latitude in degrees. + lon : float + Longitude in degrees. + nz_coast : geopandas.GeoDataFrame + Coastline polygons in EPSG:4326. + + Returns + ------- + bool + True if the point is contained in any polygon. + """ + + p = Point(lon, lat) + + # Iterate geometries directly so we don't rely on pandas/geopandas methods here. + return any(getattr(g, "contains")(p) for g in nz_coast.geometry) + + +def _parse_time(value: str) -> UTCDateTime: + """Parse a time value into ``UTCDateTime``. + + Parameters + ---------- + value : str + Input time string parseable by ObsPy. If "now" (case-insensitive), the + current time is used. + + Returns + ------- + UTCDateTime + Parsed time. + + Raises + ------ + ValueError + If the value cannot be parsed. + """ + if value.strip().lower() == "now": + return UTCDateTime() + return UTCDateTime(value) + + +def _iter_providers(providers: list[str] | None) -> list[str]: + """Build the provider list for querying. + + Parameters + ---------- + providers : list[str] | None + Optional list of provider names. If None or empty, uses all known + providers. + + Returns + ------- + list[str] + Provider names. + """ + if not providers: + return sorted(URL_MAPPINGS) + return providers + + +def collect_station_rows( + providers: list[str], + starttime: UTCDateTime, + endtime: UTCDateTime, + minlatitude: float, + maxlatitude: float, + minlongitude: float, + maxlongitude: float, + coastline_filter: bool, +) -> list[list[object]]: + """Collect station/channel rows from configured providers. + + Parameters + ---------- + providers : list[str] + Provider names. + starttime : UTCDateTime + Metadata start time. + endtime : UTCDateTime + Metadata end time. + minlatitude, maxlatitude : float + Latitude bounding box. + minlongitude, maxlongitude : float + Longitude bounding box. + coastline_filter : bool + If True, filter stations to those within NZ coastline polygons. + + Returns + ------- + list[list[object]] + Rows matching the output CSV schema. + """ + nz_coast = ( + nzgeom.coastlines.get_NZ_coastlines().to_crs("EPSG:4326") + if coastline_filter + else None + ) + + all_station_info: list[list[object]] = [] + + for provider in providers: + client = FDSN_Client(base_url=provider) + networks = client.get_stations( + starttime=starttime, + endtime=endtime, + minlatitude=minlatitude, + maxlatitude=maxlatitude, + minlongitude=minlongitude, + maxlongitude=maxlongitude, + level="network", + ) + + network_codes = [net.code for net in networks] + print("Processing provider:", provider, "with networks:", len(network_codes)) + + for net_code in network_codes: + inv = client.get_stations( + network=net_code, + starttime=starttime, + endtime=endtime, + level="channel", + minlatitude=minlatitude, + maxlatitude=maxlatitude, + minlongitude=minlongitude, + maxlongitude=maxlongitude, + ) + + for network in inv: + for station in network: + lat = float(station.latitude) + lon = float(station.longitude) + + if nz_coast is not None and not is_point_inside_nz( + lat, lon, nz_coast=nz_coast + ): + continue + + current_channels: set[tuple[str, str]] = set() + for channel in station: + chan_id = (channel.location_code, channel.code[:2]) + if chan_id in current_channels: + continue + + current_channels.add(chan_id) + all_station_info.append( + [ + provider, + network.code, + station.code, + lat, + lon, + getattr(station, "elevation", None), + channel.code[:2], + channel.location_code, + getattr(channel, "start_date", None), + getattr(channel, "end_date", None), + ] + ) + + return all_station_info + + +def build_station_dataframe(rows: Iterable[list[object]]) -> pd.DataFrame: + """Build the station/channel dataframe. + + Parameters + ---------- + rows : Iterable[list[object]] + Station/channel rows. + + Returns + ------- + pandas.DataFrame + DataFrame containing station/channel metadata. + """ + station_df = pd.DataFrame( + list(rows), + columns=[ + "provider", + "net", + "sta", + "lat", + "lon", + "elev", + "chan", + "loc", + "start_date", + "end_date", + ], + ) + + if station_df.empty: + return station_df + + return station_df.drop_duplicates( + ["provider", "net", "sta", "chan", "loc"] + ).reset_index(drop=True) + + +def write_station_csv(df: pd.DataFrame, out_csv: Path) -> None: + """Write station/channel metadata to CSV. + + Parameters + ---------- + df : pandas.DataFrame + DataFrame to write. + out_csv : Path + Output file path. + + Returns + ------- + None + This function returns ``None``. + """ + out_csv.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(out_csv, index=False) + + +@app.command() +def get_stations( + out_all_csv: Annotated[ + Path, + typer.Argument( + dir_okay=False, + help="Output CSV for all channels.", + ), + ], + out_filtered_csv: Annotated[ + Path | None, + typer.Option( + dir_okay=False, + help=( + "Optional output CSV for desired channels only. If omitted, the filtered file is not written." + ), + ), + ] = None, + provider: Annotated[ + list[str], + typer.Option( + "--provider", + help="Provider(s) to query. Repeatable. If omitted, queries all known providers.", + ), + ] = None, + starttime: Annotated[ + str, + typer.Option(help="Start time for station metadata (e.g. 2000-01-01)."), + ] = "2000-01-01", + endtime: Annotated[ + str, + typer.Option(help="End time for station metadata (or 'now')."), + ] = "now", + min_latitude: Annotated[float, typer.Option(help="Minimum latitude.")] = -49.0, + max_latitude: Annotated[float, typer.Option(help="Maximum latitude.")] = -32.0, + min_longitude: Annotated[float, typer.Option(help="Minimum longitude.")] = 165.0, + max_longitude: Annotated[float, typer.Option(help="Maximum longitude.")] = -176.9, + coastline_filter: Annotated[ + bool, + typer.Option( + "--coastline-filter/--no-coastline-filter", + help="Filter to stations inside NZ coastline polygons.", + ), + ] = True, + desired_channels: Annotated[ + list[str], + typer.Option( + "--desired-channel", + help="Desired 2-char channel prefixes for the filtered output. Repeatable.", + ), + ] = None, +) -> None: + """Query station/channel metadata and write CSV outputs. + + Parameters + ---------- + out_all_csv : Path + Output CSV for all station/channel rows. + out_filtered_csv : Path | None + Optional output CSV for filtered station/channel rows. + provider : list[str], optional + Provider(s) to query. Repeatable. + starttime : str + Start time for station metadata. + endtime : str + End time for station metadata. + min_latitude : float + Minimum latitude. + max_latitude : float + Maximum latitude. + min_longitude : float + Minimum longitude. + max_longitude : float + Maximum longitude. + coastline_filter : bool + If True, filter to stations in NZ coastline polygons. + desired_channels : list[str], optional + Desired 2-character channel prefixes for ``out_filtered_csv``. + """ + providers = _iter_providers(provider) + + rows = collect_station_rows( + providers=providers, + starttime=_parse_time(starttime), + endtime=_parse_time(endtime), + minlatitude=min_latitude, + maxlatitude=max_latitude, + minlongitude=min_longitude, + maxlongitude=max_longitude, + coastline_filter=coastline_filter, + ) + + station_df = build_station_dataframe(rows) + write_station_csv(station_df, out_all_csv) + + if out_filtered_csv is None: + return + + desired = desired_channels if desired_channels else ["HH", "BH", "HN", "BN"] + filtered_df = station_df[station_df["chan"].isin(desired)].reset_index(drop=True) + write_station_csv(filtered_df, out_filtered_csv) + + +if __name__ == "__main__": + app() diff --git a/nzgmdb/temp_arrays/mass_download_data.py b/nzgmdb/temp_arrays/mass_download_data.py new file mode 100644 index 00000000..7168736b --- /dev/null +++ b/nzgmdb/temp_arrays/mass_download_data.py @@ -0,0 +1,485 @@ +import json +from pathlib import Path +from typing import Annotated + +import pandas as pd +import typer +from obspy import UTCDateTime +from obspy.clients.fdsn.mass_downloader import ( + GlobalDomain, + MassDownloader, + Restrictions, +) + +app = typer.Typer(pretty_exceptions_enable=False) + +# Month length in seconds (15 days) +MONTH_SECONDS = 15 * 24 * 3600 +# Minimum chunk size (seconds) when backing off after 413 / manifest-too-large. +# 3600s = 1 hour. +MIN_CHUNK_SECONDS: int = 3600 + + +def _format_end_for_filename(end_dt: UTCDateTime | str) -> str: + """Format an end datetime into the MiniSEED filename timestamp form. + + Parameters + ---------- + end_dt : UTCDateTime | str + An ObsPy ``UTCDateTime`` or any string parseable by ``UTCDateTime``. + + Returns + ------- + str + Timestamp string formatted like ``YYYYMMDDTHHMMSSZ``. + """ + if not isinstance(end_dt, UTCDateTime): + end_dt = UTCDateTime(end_dt) + return end_dt.strftime("%Y%m%dT%H%M%SZ") + + +def save_results( + results: list[dict[str, object] | object], + results_csv: Path, +) -> None: + """Write a single results CSV from per-row download results. + + This function is intentionally defensive: + + - It computes the union of keys across result dictionaries. + - It normalizes missing keys to empty strings. + - It serializes nested objects (dict/list) as JSON for readability. + + Parameters + ---------- + results : list[dict[str, object] | object] + Download results. Most entries are dicts, but non-dicts will be written + under a ``value`` column. + results_csv : Path + Output CSV file. + """ + if not results: + results_csv.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame().to_csv(results_csv, index=False) + return + + all_keys: set[str] = set() + for r in results: + if isinstance(r, dict): + all_keys.update(r.keys()) + else: + all_keys.add("value") + + preferred = [ + "idx", + "provider", + "net", + "sta", + "status", + "error", + "attempts", + "chunklength", + "timestamp", + "mseed_path", + "xml_path", + ] + cols = [k for k in preferred if k in all_keys] + sorted( + k for k in all_keys if k not in preferred + ) + + norm_rows: list[dict[str, object]] = [] + for r in results: + if not isinstance(r, dict): + row: dict[str, object] = {"value": str(r)} + norm_rows.append(row) + continue + + row = {} + for k in cols: + v = r.get(k, "") + if isinstance(v, (dict, list)): + v = json.dumps(v, ensure_ascii=False) + elif v is None: + v = "" + elif not isinstance(v, (str, int, float, bool)): + v = str(v) + + row[k] = v + norm_rows.append(row) + + results_csv.parent.mkdir(parents=True, exist_ok=True) + + df = pd.DataFrame(norm_rows, columns=cols) + df.to_csv(results_csv, index=False, encoding="utf-8") + + +def is_row_done( + row: pd.Series, + output_dir: Path, + mseed_dirname: str, +) -> bool: + """Check whether a CSV row has fully completed waveform download. + + A row is considered **done** if the expected MiniSEED output directory + contains at least one ``.mseed`` file whose final ``__END`` timestamp equals + the row's ``end_date``. + + Notes + ----- + This check is filesystem-driven and intentionally conservative: + + - Only files whose channel code ends with ``"Z"`` are considered. + - Any filesystem error or unexpected filename format causes this function + to return ``False`` (so the row can be retried). + + Parameters + ---------- + row : pandas.Series + A row of the input CSV. + output_dir : Path + Root output directory. + mseed_dirname : str + Name of the MiniSEED subdirectory (typically ``"waveforms"``). + + Returns + ------- + bool + ``True`` if the row appears fully completed, otherwise ``False``. + """ + net = str(row["net"]).strip() + sta = str(row["sta"]).strip() + loc_field = str(row["loc"]).strip() + chan_prefix = str(row["chan"]).strip() + + record_sub = f"{net}_{sta}_{chan_prefix}_{loc_field}" + mseed_path = output_dir / mseed_dirname / net / record_sub + + if not mseed_path.is_dir(): + return False + + target_end = _format_end_for_filename(row["end_date"]) + + try: + for path in mseed_path.iterdir(): + if path.suffix != ".mseed": + continue + + stem = path.stem + + # Filename parts expected like: NET.STA..CHAN__START__END + parts = stem.split(".") + if len(parts) <= 3: + continue + + chan_check = parts[3].split("__", 1)[0] + if not chan_check.endswith("Z"): + continue + + if "__" not in stem: + continue + + last = stem.rsplit("__", 1)[-1] + if last == target_end: + return True + except OSError: + return False + + return False + + +def create_output_dirs( + net: str, + sta: str, + chan_prefix: str, + loc: str, + output_dir: Path, + mseed_dirname: str, + stationxml_dirname: str, +) -> tuple[Path, Path]: + """Create output directories for a network/station/channel/location. + + Parameters + ---------- + net : str + Network code. + sta : str + Station code. + chan_prefix : str + 2-character channel prefix (e.g. ``"HH"``). + loc : str + Location code used for naming the output directory. + output_dir : Path + Root output directory. + mseed_dirname : str + MiniSEED subdirectory name. + stationxml_dirname : str + StationXML subdirectory name. + + Returns + ------- + tuple[Path, Path] + Tuple of ``(mseed_path, xml_path)``. + """ + record_sub = f"{net}_{sta}_{chan_prefix}_{loc}" + + mseed_path = output_dir / mseed_dirname / net / record_sub + xml_path = output_dir / stationxml_dirname / net / record_sub + + mseed_path.mkdir(parents=True, exist_ok=True) + xml_path.mkdir(parents=True, exist_ok=True) + + return mseed_path, xml_path + + +def download_task( + task: tuple[int, str, dict[str, object]], + *, + output_dir: Path, + mseed_dirname: str, + stationxml_dirname: str, + month_seconds: int, + min_chunk_seconds: int, +) -> dict[str, int | str] | None: + """Download waveform and StationXML data for a single CSV row. + + Parameters + ---------- + task : tuple[int, str, dict[str, object]] + A 3-tuple of ``(idx, provider, row_dict)``. + output_dir : Path + Root output directory. + mseed_dirname : str + MiniSEED subdirectory name. + stationxml_dirname : str + StationXML subdirectory name. + month_seconds : int + Default chunk length (seconds) for large windows. + min_chunk_seconds : int + Minimum chunk length (seconds) when backing off after 413 errors. + + Returns + ------- + dict[str, int | str] | None + A result dictionary with keys including ``idx``, ``status``, ``provider``, + ``net``, ``sta``, and optionally ``error``. + + Raises + ------ + Exception + Re-raises exceptions encountered during download attempts when the + request cannot be reduced any further. + """ + idx, provider, row = task + + try: + net = str(row["net"]).strip() + sta = str(row["sta"]).strip() + loc_field = str(row["loc"]).strip() + loc = "*" if loc_field == "NA" else loc_field + chan_prefix = str(row["chan"]).strip() + channel = f"{chan_prefix}?" + + start = UTCDateTime(row["start_date"]) + end = UTCDateTime(row["end_date"]) + + total_window = end - start + chunk_base = int(min(total_window, month_seconds)) + + max_attempts = 4 + attempt = 1 + + print( + f"[{idx}] Provider={provider} Downloading {net}.{sta} {channel} {start} -> {end} chunk={chunk_base}s" + ) + + mseed_path, xml_path = create_output_dirs( + net, + sta, + chan_prefix, + loc_field, + output_dir=output_dir, + mseed_dirname=mseed_dirname, + stationxml_dirname=stationxml_dirname, + ) + + while attempt <= max_attempts: + chunklength = int(min(total_window, chunk_base)) + if chunklength < 1: + chunklength = 1 + + domain = GlobalDomain() + restrictions = Restrictions( + starttime=start, + endtime=end, + chunklength_in_sec=chunklength, + network=net, + station=sta, + location=loc, + channel=channel, + reject_channels_with_gaps=False, + minimum_length=0.0, + minimum_interstation_distance_in_m=0.0, + ) + + try: + mdl = MassDownloader(providers=[provider]) + mdl.download( + domain, + restrictions, + mseed_storage=str(mseed_path), + stationxml_storage=str(xml_path), + ) + + print(f"[{idx}] Done") + return { + "idx": idx, + "status": "ok", + "provider": provider, + "net": net, + "sta": sta, + } + except Exception as exc: # noqa: BLE001 + err_text = f"{exc!r} {exc}" + is_manifest_too_large = ( + "Estimated manifest size" in err_text + or "Request Entity Too Large" in err_text + or "413" in err_text + ) + + if is_manifest_too_large: + if chunk_base <= min_chunk_seconds: + print( + f"[{idx}] Server denied request and chunk is already at minimum ({chunk_base}s). Giving up." + ) + raise + + old = chunk_base + chunk_base = max(min_chunk_seconds, chunk_base // 2) + print( + f"[{idx}] Server denied request (413). Reducing chunk base {old}s -> {chunk_base}s and retrying." + ) + attempt += 1 + continue + + raise + + except Exception as exc: # noqa: BLE001 + print( + f"[{idx}] ERROR provider={provider} net={row.get('net')} sta={row.get('sta')}: {exc}" + ) + return { + "idx": idx, + "status": "error", + "error": str(exc), + "provider": provider, + } + + +@app.command() +def run( + csv_file: Annotated[ + Path, + typer.Argument( + ..., exists=True, dir_okay=False, help="Input station/channel CSV." + ), + ], + output_dir: Annotated[ + Path, + typer.Argument( + ..., + file_okay=False, + help="Root output directory for waveforms and StationXML.", + ), + ], + results_csv: Annotated[ + Path | None, + typer.Option( + dir_okay=False, + help="Optional output CSV for per-row download results (default: /download_results.csv).", + ), + ] = None, + mseed_dirname: Annotated[ + str, + typer.Option(help="MiniSEED subdirectory name."), + ] = "waveforms", + stationxml_dirname: Annotated[ + str, + typer.Option(help="StationXML subdirectory name."), + ] = "stationxml", +) -> None: + """Download waveforms + StationXML for each row in a station/channel request table. + + Parameters + ---------- + csv_file : Path + Input CSV describing requests. Must contain columns: + ``net``, ``sta``, ``loc``, ``chan``, ``start_date``, ``end_date``, ``provider``. + output_dir : Path + Root output directory. + results_csv : Path | None, optional + Output CSV for results. If omitted, defaults to + ``/download_results.csv``. + mseed_dirname : str, optional + MiniSEED subdirectory name. + stationxml_dirname : str, optional + StationXML subdirectory name. + + Returns + ------- + None + This function returns ``None``. + + Raises + ------ + ValueError + If required columns are missing from the input CSV. + """ + df = pd.read_csv(csv_file, dtype={"loc": str}, keep_default_na=False) + + required_cols = {"net", "sta", "loc", "chan", "start_date", "end_date", "provider"} + missing = sorted(required_cols - set(df.columns)) + if missing: + raise ValueError(f"CSV must contain: {missing}") + + output_dir.mkdir(parents=True, exist_ok=True) + + if results_csv is None: + results_csv = output_dir / "download_results.csv" + + tasks: list[tuple[int, str, dict[str, object]]] = [] + skipped = 0 + for idx, row in df.reset_index(drop=True).iterrows(): + if is_row_done(row, output_dir=output_dir, mseed_dirname=mseed_dirname): + skipped += 1 + continue + + tasks.append((int(idx), str(row["provider"]), row.to_dict())) + + print( + f"Starting sequential run for {len(tasks)} tasks (skipped {skipped} already done)" + ) + + results: list[dict[str, object]] = [] + for task in tasks: + results.append( + download_task( + task, + output_dir=output_dir, + mseed_dirname=mseed_dirname, + stationxml_dirname=stationxml_dirname, + month_seconds=MONTH_SECONDS, + min_chunk_seconds=MIN_CHUNK_SECONDS, + ) + ) + + save_results(results, results_csv=results_csv) + + oks = sum(1 for r in results if r.get("status") == "ok") + errs = sum(1 for r in results if r.get("status") == "error") + print( + f"Completed: {oks} succeeded, {errs} failed (skipped {skipped} previously done)" + ) + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index ffe01990..25eeb5c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ readme = "README.md" requires-python = "==3.12.*" dynamic = ["version", "dependencies"] +[project.optional-dependencies] +test = ["pytest"] +dev = ["ty"] + [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} @@ -78,4 +82,12 @@ checks = [ "RT03", "RT04", "YD01", -] \ No newline at end of file +] + +[tool.ty.rules] +no-matching-overload = "ignore" +unresolved-attribute = "ignore" +not-subscriptable = "ignore" +possibly-missing-submodule = "ignore" +invalid-type-arguments = "ignore" +unresolved-import = "ignore" diff --git a/requirements.txt b/requirements.txt index e987d2c4..a61a4ba7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ typer>0.12.3 pandas -numpy<2.0.0 +numpy>=2.0 obspy mseedlib scipy @@ -13,6 +13,7 @@ gmprocess h5py pooch matplotlib +rasterio oq_wrapper>=2025.12.3, qcore-utils>=2025.12.1, source_modelling>=2025.12.1, diff --git a/tests/test_sites.py b/tests/test_sites.py new file mode 100644 index 00000000..b175eb04 --- /dev/null +++ b/tests/test_sites.py @@ -0,0 +1,355 @@ +from collections.abc import Iterator +from pathlib import Path +from types import TracebackType +from typing import Any, Self + +import numpy as np +import pandas as pd +import pytest +from rasterio.io import MemoryFile +from rasterio.transform import from_origin + +from nzgmdb.data_retrieval import sites + + +class _DummyFionaCollection: + """ + Minimal context manager that mimics a Fiona Collection. + + Parameters + ---------- + shapes : list + Iterable of shape-like records to yield when iterated. + + Returns + ------- + _DummyFionaCollection + Context manager instance that can be iterated over. + """ + + def __init__(self, shapes: list[Any]) -> None: # noqa: D107 + self._shapes = shapes + + def __enter__(self) -> Self: # noqa: D105 + return self + + def __exit__( # noqa: D105 + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: + return False + + def __iter__(self) -> Iterator[Any]: # noqa: D105 + return iter(self._shapes) + + +def _make_test_geotiff( + width: int = 10, + height: int = 10, + nodata: float = -9999.0, +) -> MemoryFile: + """ + Create an in-memory single-band GeoTIFF for testing. + + Parameters + ---------- + width : int, default=10 + Raster width in pixels. + height : int, default=10 + Raster height in pixels. + nodata : float, default=-9999.0 + NoData value written to the dataset (and to one pixel in the raster). + + Returns + ------- + memfile : rasterio.io.MemoryFile + In-memory GeoTIFF containing a simple gradient field with a NoData pixel. + """ + data = np.arange(width * height, dtype=np.float32).reshape(height, width) + transform = from_origin(0.0, 10.0, 1.0, 1.0) + data[0, 0] = nodata + + memfile = MemoryFile() + with memfile.open( + driver="GTiff", + height=height, + width=width, + count=1, + dtype=data.dtype, + crs="EPSG:4326", + transform=transform, + nodata=nodata, + ) as ds: + ds.write(data, 1) + + return memfile + + +def test_sample_points_from_geotiff_inside_outside_and_nodata() -> None: + """ + Test that GeoTIFF sampling returns finite values for in-bounds points, and + returns NaN for out-of-bounds or NoData pixels. + + Returns + ------- + None + """ + memfile = _make_test_geotiff() + with memfile.open() as ds: + points = np.array( + [ + [9.5, 0.5], + [5.5, 5.5], + [20.0, 5.0], + [-5.0, 5.0], + [5.0, 20.0], + ], + dtype=float, + ) + out = sites.sample_points_from_geotiff(ds.name, points).ravel() + + assert out.shape == (len(points),) + assert np.isnan(out[0]) + assert np.isfinite(out[1]) + assert np.isnan(out[2]) + assert np.isnan(out[3]) + assert np.isnan(out[4]) + + +def test_fill_gaps_with_nearest_fills_nans_and_preserves_finite() -> None: + """ + Test that `fill_gaps_with_nearest` fills NaN entries while preserving + existing finite values. + + Returns + ------- + None + """ + coords = np.array( + [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], + dtype=float, + ) + values = np.array([10.0, 20.0, np.nan, 40.0], dtype=float) + + filled = sites.fill_gaps_with_nearest(coords, values, k=3) + + assert filled.shape == values.shape + assert np.isfinite(filled[2]) + assert filled[0] == 10.0 + assert filled[1] == 20.0 + assert filled[3] == 40.0 + + +@pytest.mark.parametrize( + "points", + [ + np.array( + [ + [-36.8485, 174.7633], + [-41.2865, 174.7762], + [-43.5321, 172.6362], + [-45.0312, 168.6626], + ], + dtype=float, + ), + np.array( + [[-34.0, 172.5], [-47.5, 166.0], [-41.0, 179.9], [-41.0, 166.5]], + dtype=float, + ), + np.array( + [[-30.0, 174.0], [-50.0, 170.0], [-41.0, 160.0], [-41.0, -175.0]], + dtype=float, + ), + np.array( + [ + [48.8566, 2.3522], + [51.5074, -0.1278], + [52.5200, 13.4050], + [41.9028, 12.4964], + ], + dtype=float, + ), + np.vstack( + [ + np.array([[-36.8485, 174.7633], [-41.2865, 174.7762]], dtype=float), + np.array([[48.8566, 2.3522], [51.5074, -0.1278]], dtype=float), + np.array([[-43.5321, 172.6362], [-45.0312, 168.6626]], dtype=float), + ] + ), + ], +) +def test_site_updates_vs30_and_z1_fields_only_when_available( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + points: np.ndarray, +) -> None: + """ + Integration-style test of site table population logic using monkeypatches. + + Verifies expected output columns exist and that Vs30 and Z1.0 / Z2.5 values + (and their reference/quality fields) are only set when data are available. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Pytest fixture used to patch external dependencies. + tmp_path : pathlib.Path + Pytest fixture providing a temporary directory for test artifacts. + points : numpy.ndarray, shape (N, 2) + Input points as [lat, lon] used to construct a minimal metadata table. + + Returns + ------- + None + """ + + class _Cfg: + def get_value(self, key: str): + if key == "channel_codes": + return "HHZ" + if key == "bbox": + return [0.0, -90.0, 360.0, 90.0] + if key == "nzcvm_version": + return "test" + return None + + monkeypatch.setattr(sites.cfg, "Config", _Cfg) + + class _DummyInv(list): + pass + + class _DummyClient: + def __init__(self, *_args, **_kwargs): + pass + + def get_stations(self, *args, **kwargs): + return _DummyInv() + + monkeypatch.setattr(sites, "FDSN_Client", _DummyClient) + + monkeypatch.setattr( + sites.fiona, + "open", + lambda *_a, **_k: _DummyFionaCollection([]), + ) + + # Do NOT monkeypatch NZGMDB_DATA.abspath (read-only property). + # Instead, patch fetch() to return a temp file path for the tif and anything else requested. + combined_tif = tmp_path / "combined_mvn_wgs84.tif" + combined_tif.touch() + + def _fetch(name: str, *args, **kwargs): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.touch(exist_ok=True) + return str(p) + + monkeypatch.setattr(sites.NZGMDB_DATA, "fetch", _fetch) + + geo = pd.DataFrame( + { + "Name": [f"S{i}" for i in range(len(points))], + "Lat": points[:, 0], + "Long": points[:, 1], + "Elevation": np.zeros(len(points)), + "Vs30_median": [np.nan] * len(points), + "Sigmaln_Vs30": [np.nan] * len(points), + "T_median": [np.nan] * len(points), + "sigmaln_T": [np.nan] * len(points), + "Q_T": [None] * len(points), + "D_T": [None] * len(points), + "T_Ref": [None] * len(points), + "Z1.0_median": [np.nan] * len(points), + "sigmaln_Z1.0": [np.nan] * len(points), + "Z1.0_Ref": [None] * len(points), + "Z2.5_median": [np.nan] * len(points), + "sigmaln_Z2.5": [np.nan] * len(points), + "Z2.5_Ref": [None] * len(points), + "NZS1170SiteClass": [None] * len(points), + } + ) + monkeypatch.setattr(pd, "read_csv", lambda *_a, **_k: geo) + + def _find_domain_from_shapes(df: pd.DataFrame, _shapes: list) -> pd.DataFrame: + out = df.copy() + out["domain_no"] = 1 + return out + + monkeypatch.setattr( + sites.tect_domain, "find_domain_from_shapes", _find_domain_from_shapes + ) + + def _compute_station_thresholds( + stations: pd.DataFrame, model_version: str = None + ) -> pd.DataFrame: + n = len(stations) + return pd.DataFrame( + { + "Z1.0(km)": np.full(n, 0.5), + "Z2.5(km)": np.full(n, 2.0), + "sigma": np.full(n, 0.25), + }, + index=stations.index, + ) + + monkeypatch.setattr( + sites.threshold, "compute_station_thresholds", _compute_station_thresholds + ) + + def _sample_points_from_geotiff( + file_path: str, + latlon_points: np.ndarray, + band: int = 1, + ) -> np.ndarray: + n = len(latlon_points) + vals = np.linspace(100.0, 500.0, n).astype(float) + if n >= 2: + vals[0] = np.nan + return vals.reshape(-1, 1) + + monkeypatch.setattr( + sites, "sample_points_from_geotiff", _sample_points_from_geotiff + ) + + def _fill_gaps_with_nearest( + coords: np.ndarray, + values: np.ndarray, + invalid_mask: np.ndarray = None, + k: int = 8, + ) -> np.ndarray: + values = np.asarray(values, dtype=float).copy() + values[np.isnan(values)] = 250.0 + return values + + monkeypatch.setattr(sites, "fill_gaps_with_nearest", _fill_gaps_with_nearest) + + site_df = sites.create_site_table_response() + + for col in [ + "sta", + "lat", + "lon", + "Vs30", + "Q_Vs30", + "Vs30_Ref", + "Z1.0", + "Z2.5", + "Z1.0_ref", + "Z2.5_ref", + "Q_Z1.0", + "Q_Z2.5", + ]: + assert col in site_df.columns + + assert site_df["Z1.0"].notna().any() + assert site_df["Z2.5"].notna().any() + assert (site_df["Z1.0_ref"].dropna() == "NZCVM (2026)").all() + assert (site_df["Z2.5_ref"].dropna() == "NZCVM (2026)").all() + + vs30_non_nan = site_df["Vs30"].notna() + assert (site_df.loc[vs30_non_nan, "Vs30_Ref"] == "Foster et al. (2019)").all() + assert (site_df.loc[vs30_non_nan, "Q_Vs30"] == "Q3").all() + assert site_df.loc[~vs30_non_nan, "Vs30_Ref"].isna().all() + assert site_df.loc[~vs30_non_nan, "Q_Vs30"].isna().all() diff --git a/wiki/Waveform-Extraction.md b/wiki/Waveform-Extraction.md index 8564bd17..30ad9a3c 100644 --- a/wiki/Waveform-Extraction.md +++ b/wiki/Waveform-Extraction.md @@ -21,6 +21,7 @@ python -m nzgmdb.scripts.run_nzgmdb extract-waveforms