From cf65a7694de45a59f7c74c746de40830064c3df6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:49:11 +0000 Subject: [PATCH 01/13] Initial plan From f759d02a6831738098f7e195c3d8134230cd99bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:55:18 +0000 Subject: [PATCH 02/13] Implement day/night time filtering for PPSD plots - Add time_filter configuration option with night_start and night_stop times - Implement timestamp parsing from .npz filenames - Add filtering logic to plot_ppsd functions in both PPSD_plotter.py and gui.py - Support time ranges that span midnight (e.g., 22:00 to 06:00) - Update example_config.yaml with documentation on usage - Maintain backward compatibility - feature is optional Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- example/example_config.yaml | 5 + src/PPSD_plotter.py | 104 ++++++++++++++++++- src/__pycache__/PPSD_plotter.cpython-312.pyc | Bin 0 -> 15601 bytes src/__pycache__/gui.cpython-312.pyc | Bin 0 -> 68972 bytes src/gui.py | 102 +++++++++++++++++- 5 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 src/__pycache__/PPSD_plotter.cpython-312.pyc create mode 100644 src/__pycache__/gui.cpython-312.pyc diff --git a/example/example_config.yaml b/example/example_config.yaml index ac43113..6a922a4 100644 --- a/example/example_config.yaml +++ b/example/example_config.yaml @@ -18,6 +18,11 @@ datasets: xaxis_frequency: false response: example/IU_ANMO_RESP.xml timewindow: 600 + # Optional: Filter data by time of day (uncomment to enable) + # time_filter: + # night_start: "22:00" # Start time (HH:MM format) + # night_stop: "06:00" # End time (HH:MM format) + # Note: If night_start > night_stop, the range spans midnight - action: full channels: - 00.BH1 diff --git a/src/PPSD_plotter.py b/src/PPSD_plotter.py index c313ff5..fa37a8d 100644 --- a/src/PPSD_plotter.py +++ b/src/PPSD_plotter.py @@ -8,6 +8,7 @@ from obspy.imaging.cm import pqlx from concurrent.futures import ThreadPoolExecutor from tqdm import tqdm +from datetime import datetime matplotlib.use("Agg") @@ -72,6 +73,91 @@ def find_miniseed(workdir, channel, location=None): return None +def parse_npz_timestamp(filename): + """ + Parse timestamp from npz filename. + Format: yy-mm-dd_HH-MM-SS.ffffff.npz + Returns datetime object or None if parsing fails. + """ + try: + stem = Path(filename).stem # Remove .npz extension + # Parse the timestamp: yy-mm-dd_HH-MM-SS.ffffff + dt = datetime.strptime(stem, '%y-%m-%d_%H-%M-%S.%f') + return dt + except Exception: + return None + + +def is_time_in_range(dt, start_time, end_time): + """ + Check if datetime's time component falls within start_time and end_time. + Handles ranges that span midnight (e.g., 22:00 to 06:00). + + Args: + dt: datetime object + start_time: time object (e.g., time(22, 0)) + end_time: time object (e.g., time(6, 0)) + + Returns: + True if dt.time() is within the range, False otherwise + """ + if start_time is None or end_time is None: + return True + + t = dt.time() + + # Normal range (e.g., 06:00 to 22:00) + if start_time <= end_time: + return start_time <= t <= end_time + # Range spans midnight (e.g., 22:00 to 06:00) + else: + return t >= start_time or t <= end_time + + +def filter_npz_files_by_time(npz_files, time_filter): + """ + Filter npz files based on time_filter configuration. + + Args: + npz_files: list of Path objects + time_filter: dict with optional 'night_start' and 'night_stop' keys + (e.g., {'night_start': '22:00', 'night_stop': '06:00'}) + + Returns: + filtered list of Path objects + """ + if not time_filter: + return npz_files + + night_start_str = time_filter.get('night_start') + night_stop_str = time_filter.get('night_stop') + + if not night_start_str or not night_stop_str: + return npz_files + + try: + # Parse time strings (e.g., "22:00" or "22:00:00") + night_start = datetime.strptime(night_start_str, '%H:%M').time() + night_stop = datetime.strptime(night_stop_str, '%H:%M').time() + except ValueError: + try: + # Try with seconds + night_start = datetime.strptime(night_start_str, '%H:%M:%S').time() + night_stop = datetime.strptime(night_stop_str, '%H:%M:%S').time() + except ValueError: + print("Warning: Invalid time format in time_filter. " + "Using all files.") + return npz_files + + filtered = [] + for file in npz_files: + dt = parse_npz_timestamp(file.name) + if dt and is_time_in_range(dt, night_start, night_stop): + filtered.append(file) + + return filtered + + def calculate_ppsd(workdir, npzfolder, channel, location, inv, tw): workdir = Path(workdir) Path(npzfolder).mkdir(exist_ok=True) @@ -104,7 +190,7 @@ def calculate_ppsd(workdir, npzfolder, channel, location, inv, tw): def plot_ppsd( sampledata, channel, location, inv, npzfolder, output_folder, - tw, plot_kwargs=None + tw, plot_kwargs=None, time_filter=None ): if plot_kwargs is None: @@ -125,7 +211,17 @@ def plot_ppsd( ) trace = matches[0] ppsd = PPSD(trace.stats, inv, ppsd_length=tw) - for file in Path(npzfolder).glob("*.npz"): + + # Get all npz files and filter by time if needed + all_files = list(Path(npzfolder).glob("*.npz")) + filtered_files = filter_npz_files_by_time(all_files, time_filter) + + if time_filter: + nfiltered = len(filtered_files) + ntotal = len(all_files) + print(f"Time filter applied: {nfiltered}/{ntotal} files selected") + + for file in filtered_files: try: ppsd.add_npz(str(file)) except Exception as e: @@ -252,9 +348,11 @@ def process_dataset(entry, tw): if action in ["plot", "full"]: sample = find_miniseed(folder, channel, loc_code) if sample: + time_filter = entry.get("time_filter") plot_ppsd( sample, channel, loc_code, inv, npzfolder, - output_folder, tw, plot_kwargs=plot_kwargs.copy() + output_folder, tw, plot_kwargs=plot_kwargs.copy(), + time_filter=time_filter ) else: print(f"No valid trace found in {folder} for {channel}") diff --git a/src/__pycache__/PPSD_plotter.cpython-312.pyc b/src/__pycache__/PPSD_plotter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd47f05b6dc0121ae240107e4c6bb0d5bdff9432 GIT binary patch literal 15601 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 MdwE=r=w$5w1A~e^UH||9 literal 0 HcmV?d00001 diff --git a/src/gui.py b/src/gui.py index 27f3df0..8d35aa3 100644 --- a/src/gui.py +++ b/src/gui.py @@ -20,6 +20,7 @@ from concurrent.futures import ProcessPoolExecutor, as_completed from tqdm import tqdm import numpy as np +from datetime import datetime from ppsd_plotter_aux import calculate_ppsd_worker, load_inventory, \ find_miniseed_channels, find_miniseed, \ calculate_noise_line @@ -127,6 +128,91 @@ def parse_channel(ch_str): return parts[0], parts[1] +def parse_npz_timestamp(filename): + """ + Parse timestamp from npz filename. + Format: yy-mm-dd_HH-MM-SS.ffffff.npz + Returns datetime object or None if parsing fails. + """ + try: + stem = Path(filename).stem # Remove .npz extension + # Parse the timestamp: yy-mm-dd_HH-MM-SS.ffffff + dt = datetime.strptime(stem, '%y-%m-%d_%H-%M-%S.%f') + return dt + except Exception: + return None + + +def is_time_in_range(dt, start_time, end_time): + """ + Check if datetime's time component falls within start_time and end_time. + Handles ranges that span midnight (e.g., 22:00 to 06:00). + + Args: + dt: datetime object + start_time: time object (e.g., time(22, 0)) + end_time: time object (e.g., time(6, 0)) + + Returns: + True if dt.time() is within the range, False otherwise + """ + if start_time is None or end_time is None: + return True + + t = dt.time() + + # Normal range (e.g., 06:00 to 22:00) + if start_time <= end_time: + return start_time <= t <= end_time + # Range spans midnight (e.g., 22:00 to 06:00) + else: + return t >= start_time or t <= end_time + + +def filter_npz_files_by_time(npz_files, time_filter): + """ + Filter npz files based on time_filter configuration. + + Args: + npz_files: list of Path objects + time_filter: dict with optional 'night_start' and 'night_stop' keys + (e.g., {'night_start': '22:00', 'night_stop': '06:00'}) + + Returns: + filtered list of Path objects + """ + if not time_filter: + return npz_files + + night_start_str = time_filter.get('night_start') + night_stop_str = time_filter.get('night_stop') + + if not night_start_str or not night_stop_str: + return npz_files + + try: + # Parse time strings (e.g., "22:00" or "22:00:00") + night_start = datetime.strptime(night_start_str, '%H:%M').time() + night_stop = datetime.strptime(night_stop_str, '%H:%M').time() + except ValueError: + try: + # Try with seconds + night_start = datetime.strptime(night_start_str, '%H:%M:%S').time() + night_stop = datetime.strptime(night_stop_str, '%H:%M:%S').time() + except ValueError: + print("Warning: Invalid time format in time_filter. " + "Using all files.") + return npz_files + + filtered = [] + for file in npz_files: + dt = parse_npz_timestamp(file.name) + if dt and is_time_in_range(dt, night_start, night_stop): + filtered.append(file) + + return filtered + + def convert_npz_to_text(npzdir): npzdir = Path(npzdir) outdir = npzdir.with_name(npzdir.name + "_text") @@ -314,7 +400,8 @@ def process_dataset_visual(ds, progress_update_callback): npzfolder, int(ds.get("timewindow", 3600)), plot_kwargs.copy(), - custom_noise_line=ds.get("custom_noise_line") + custom_noise_line=ds.get("custom_noise_line"), + time_filter=ds.get("time_filter") ) else: progress_update_callback(progress, f"No data for {ch_label}") @@ -328,7 +415,7 @@ def process_dataset_visual(ds, progress_update_callback): def plot_ppsd_interactive( sampledata, channel, location, inv, npzfolder, tw, plot_kwargs=None, - custom_noise_line=None + custom_noise_line=None, time_filter=None ): if plot_kwargs is None: plot_kwargs = {} @@ -356,7 +443,16 @@ def plot_ppsd_interactive( trace = matches[0] ppsd = PPSD(trace.stats, inv, ppsd_length=tw) - for file in Path(npzfolder).glob("*.npz"): + # Get all npz files and filter by time if needed + all_files = list(Path(npzfolder).glob("*.npz")) + filtered_files = filter_npz_files_by_time(all_files, time_filter) + + if time_filter: + nfiltered = len(filtered_files) + ntotal = len(all_files) + print(f"Time filter applied: {nfiltered}/{ntotal} files selected") + + for file in filtered_files: try: ppsd.add_npz(str(file)) except Exception as e: From 3d79ef67ecf1e153e4ba8b9365a3efe22d26b071 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:01:40 +0000 Subject: [PATCH 03/13] Add comprehensive tests for time filtering and improve config flexibility - Add 10 comprehensive unit tests for time filtering functions - Remove __pycache__ files from git tracking - Update .gitignore to exclude PPSD output files (npz_*/ and *.png) - Make timewindow parameter optional with default value of 3600 Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- .gitignore | 42 +++++ src/PPSD_plotter.py | 4 +- src/__pycache__/PPSD_plotter.cpython-312.pyc | Bin 15601 -> 0 bytes src/__pycache__/gui.cpython-312.pyc | Bin 68972 -> 0 bytes tests/test_time_filter.py | 153 +++++++++++++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 .gitignore delete mode 100644 src/__pycache__/PPSD_plotter.cpython-312.pyc delete mode 100644 src/__pycache__/gui.cpython-312.pyc create mode 100644 tests/test_time_filter.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..945f463 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# PPSD output files +npz_*/ +*.png diff --git a/src/PPSD_plotter.py b/src/PPSD_plotter.py index fa37a8d..b8df162 100644 --- a/src/PPSD_plotter.py +++ b/src/PPSD_plotter.py @@ -292,6 +292,8 @@ def process_dataset(entry, tw): channels = entry["channels"] output_folder = entry.get("output_folder", folder) action = str(entry.get("action", "full")) + # Use timewindow from entry if available, otherwise use global tw + tw = entry.get("timewindow", tw) inv = load_inventory(resp_file) if not inv: @@ -363,7 +365,7 @@ def process_dataset(entry, tw): def main(config_path): config = load_config(config_path) - tw = config["timewindow"] + tw = config.get("timewindow", 3600) # Default to 3600 if not specified num_workers = config.get("num_workers", 1) datasets = config["datasets"] diff --git a/src/__pycache__/PPSD_plotter.cpython-312.pyc b/src/__pycache__/PPSD_plotter.cpython-312.pyc deleted file mode 100644 index fd47f05b6dc0121ae240107e4c6bb0d5bdff9432..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15601 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 MdwE=r=w$5w1A~e^UH||9 diff --git a/tests/test_time_filter.py b/tests/test_time_filter.py new file mode 100644 index 0000000..8013810 --- /dev/null +++ b/tests/test_time_filter.py @@ -0,0 +1,153 @@ +import pytest +from pathlib import Path +from datetime import datetime, time +from src.PPSD_plotter import ( + parse_npz_timestamp, + is_time_in_range, + filter_npz_files_by_time +) + + +def test_parse_npz_timestamp(): + """Test parsing timestamp from npz filename.""" + filename = '25-06-07_07-00-00.019536.npz' + dt = parse_npz_timestamp(filename) + assert dt is not None + assert dt.year == 2025 + assert dt.month == 6 + assert dt.day == 7 + assert dt.hour == 7 + assert dt.minute == 0 + assert dt.second == 0 + + +def test_parse_npz_timestamp_invalid(): + """Test parsing invalid filename returns None.""" + filename = 'invalid_filename.npz' + dt = parse_npz_timestamp(filename) + assert dt is None + + +def test_is_time_in_range_normal(): + """Test time range that doesn't span midnight.""" + dt = datetime(2025, 6, 7, 7, 0, 0) + + # Daytime range: 06:00 to 22:00 + day_start = time(6, 0) + day_end = time(22, 0) + + assert is_time_in_range(dt, day_start, day_end) is True + + # Test time outside range + dt_night = datetime(2025, 6, 7, 23, 0, 0) + assert is_time_in_range(dt_night, day_start, day_end) is False + + +def test_is_time_in_range_spanning_midnight(): + """Test time range that spans midnight.""" + # Nighttime range: 22:00 to 06:00 + night_start = time(22, 0) + night_end = time(6, 0) + + # Time at 23:00 should be in range + dt_night = datetime(2025, 6, 7, 23, 0, 0) + assert is_time_in_range(dt_night, night_start, night_end) is True + + # Time at 02:00 should be in range + dt_early_morning = datetime(2025, 6, 7, 2, 0, 0) + assert is_time_in_range(dt_early_morning, night_start, night_end) is True + + # Time at 07:00 should NOT be in range + dt_day = datetime(2025, 6, 7, 7, 0, 0) + assert is_time_in_range(dt_day, night_start, night_end) is False + + +def test_is_time_in_range_none_values(): + """Test that None values return True (no filtering).""" + dt = datetime(2025, 6, 7, 7, 0, 0) + assert is_time_in_range(dt, None, None) is True + assert is_time_in_range(dt, None, time(22, 0)) is True + assert is_time_in_range(dt, time(6, 0), None) is True + + +def test_filter_npz_files_by_time_no_filter(): + """Test that no filter returns all files.""" + files = [ + Path('25-06-07_07-00-00.019536.npz'), + Path('25-06-07_23-00-00.019536.npz'), + ] + + # No filter + filtered = filter_npz_files_by_time(files, None) + assert len(filtered) == 2 + + # Empty filter + filtered = filter_npz_files_by_time(files, {}) + assert len(filtered) == 2 + + +def test_filter_npz_files_by_time_daytime(): + """Test filtering for daytime hours.""" + files = [ + Path('25-06-07_07-00-00.019536.npz'), # 07:00 - day + Path('25-06-07_23-00-00.019536.npz'), # 23:00 - night + Path('25-06-07_12-00-00.019536.npz'), # 12:00 - day + Path('25-06-07_02-00-00.019536.npz'), # 02:00 - night + ] + + time_filter = {'night_start': '06:00', 'night_stop': '22:00'} + filtered = filter_npz_files_by_time(files, time_filter) + + assert len(filtered) == 2 + assert Path('25-06-07_07-00-00.019536.npz') in filtered + assert Path('25-06-07_12-00-00.019536.npz') in filtered + + +def test_filter_npz_files_by_time_nighttime(): + """Test filtering for nighttime hours (spanning midnight).""" + files = [ + Path('25-06-07_07-00-00.019536.npz'), # 07:00 - day + Path('25-06-07_23-00-00.019536.npz'), # 23:00 - night + Path('25-06-07_12-00-00.019536.npz'), # 12:00 - day + Path('25-06-07_02-00-00.019536.npz'), # 02:00 - night + ] + + time_filter = {'night_start': '22:00', 'night_stop': '06:00'} + filtered = filter_npz_files_by_time(files, time_filter) + + assert len(filtered) == 2 + assert Path('25-06-07_23-00-00.019536.npz') in filtered + assert Path('25-06-07_02-00-00.019536.npz') in filtered + + +def test_filter_npz_files_by_time_invalid_format(): + """Test that invalid time format returns all files.""" + files = [ + Path('25-06-07_07-00-00.019536.npz'), + Path('25-06-07_23-00-00.019536.npz'), + ] + + # Invalid time format + time_filter = {'night_start': 'invalid', 'night_stop': '22:00'} + filtered = filter_npz_files_by_time(files, time_filter) + + # Should return all files when format is invalid + assert len(filtered) == 2 + + +def test_filter_npz_files_by_time_missing_keys(): + """Test that missing keys return all files.""" + files = [ + Path('25-06-07_07-00-00.019536.npz'), + Path('25-06-07_23-00-00.019536.npz'), + ] + + # Missing night_stop + time_filter = {'night_start': '22:00'} + filtered = filter_npz_files_by_time(files, time_filter) + assert len(filtered) == 2 + + # Missing night_start + time_filter = {'night_stop': '06:00'} + filtered = filter_npz_files_by_time(files, time_filter) + assert len(filtered) == 2 From 7164cd82bef6e89456e25ec5dc729e1fc0b030e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:05:22 +0000 Subject: [PATCH 04/13] Refactor time filtering functions to eliminate code duplication - Move parse_npz_timestamp, is_time_in_range, and filter_npz_files_by_time to ppsd_plotter_aux.py - Update PPSD_plotter.py and gui.py to import from ppsd_plotter_aux - Update tests to import from ppsd_plotter_aux - Remove duplicate function definitions from PPSD_plotter.py and gui.py - All tests still pass after refactoring Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- example/test_time_filter_config.yaml | 45 +++++++++ src/PPSD_plotter.py | 134 ++------------------------- src/gui.py | 99 ++------------------ src/ppsd_plotter_aux.py | 86 +++++++++++++++++ tests/test_time_filter.py | 2 +- 5 files changed, 149 insertions(+), 217 deletions(-) create mode 100644 example/test_time_filter_config.yaml diff --git a/example/test_time_filter_config.yaml b/example/test_time_filter_config.yaml new file mode 100644 index 0000000..d5ddaa6 --- /dev/null +++ b/example/test_time_filter_config.yaml @@ -0,0 +1,45 @@ +datasets: +- action: plot + channels: + - 00.BHZ + folder: example/IU.ANMO..D + output_folder: /tmp/ppsd_day_output + plot_kwargs: + cmap: pqlx + cumulative: false + grid: true + show_coverage: true + show_histogram: true + show_mean: false + show_mode: false + show_noise_models: true + show_percentiles: false + xaxis_frequency: false + response: example/IU_ANMO_RESP.xml + timewindow: 600 + # Filter for daytime hours (06:00 to 22:00) + time_filter: + night_start: "06:00" + night_stop: "22:00" +- action: plot + channels: + - 00.BHZ + folder: example/IU.ANMO..D + output_folder: /tmp/ppsd_night_output + plot_kwargs: + cmap: pqlx + cumulative: false + grid: true + show_coverage: true + show_histogram: true + show_mean: false + show_mode: false + show_noise_models: true + show_percentiles: false + xaxis_frequency: false + response: example/IU_ANMO_RESP.xml + timewindow: 600 + # Filter for nighttime hours (22:00 to 06:00) + time_filter: + night_start: "22:00" + night_stop: "06:00" diff --git a/src/PPSD_plotter.py b/src/PPSD_plotter.py index b8df162..92892ad 100644 --- a/src/PPSD_plotter.py +++ b/src/PPSD_plotter.py @@ -8,7 +8,13 @@ from obspy.imaging.cm import pqlx from concurrent.futures import ThreadPoolExecutor from tqdm import tqdm -from datetime import datetime +from ppsd_plotter_aux import ( + find_miniseed, + load_inventory, + parse_npz_timestamp, + is_time_in_range, + filter_npz_files_by_time +) matplotlib.use("Agg") @@ -17,27 +23,6 @@ def load_config(path): return yaml.safe_load(f) -def load_inventory(resp_file): - ext = Path(resp_file).suffix.lower() - - if ext in ['.seed', '.dataless']: - fmt = "SEED" - elif ext == '.xml': - fmt = "STATIONXML" - else: - fmt = None - - try: - if fmt: - inv = read_inventory(resp_file, format=fmt) - else: - inv = read_inventory(resp_file) - return inv - except Exception as e: - print(f"Failed to read inventory {resp_file}: {e}") - return - - def parse_channel(ch_str): parts = ch_str.split(".", 1) if len(parts) == 1: @@ -53,111 +38,6 @@ def safe_bool(val): return False -def find_miniseed(workdir, channel, location=None): - for file in Path(workdir).rglob("*"): - if file.suffix.lower() in [".msd", ".miniseed", ".mseed"]: - try: - st = read(str(file)) - for tr in st: - if location: - if ( - tr.stats.channel == channel and - tr.stats.location == location - ): - return str(file) - else: - if tr.stats.channel == channel: - return str(file) - except Exception as e: - print(f"Skipping {file} due to error: {e}") - return None - - -def parse_npz_timestamp(filename): - """ - Parse timestamp from npz filename. - Format: yy-mm-dd_HH-MM-SS.ffffff.npz - Returns datetime object or None if parsing fails. - """ - try: - stem = Path(filename).stem # Remove .npz extension - # Parse the timestamp: yy-mm-dd_HH-MM-SS.ffffff - dt = datetime.strptime(stem, '%y-%m-%d_%H-%M-%S.%f') - return dt - except Exception: - return None - - -def is_time_in_range(dt, start_time, end_time): - """ - Check if datetime's time component falls within start_time and end_time. - Handles ranges that span midnight (e.g., 22:00 to 06:00). - - Args: - dt: datetime object - start_time: time object (e.g., time(22, 0)) - end_time: time object (e.g., time(6, 0)) - - Returns: - True if dt.time() is within the range, False otherwise - """ - if start_time is None or end_time is None: - return True - - t = dt.time() - - # Normal range (e.g., 06:00 to 22:00) - if start_time <= end_time: - return start_time <= t <= end_time - # Range spans midnight (e.g., 22:00 to 06:00) - else: - return t >= start_time or t <= end_time - - -def filter_npz_files_by_time(npz_files, time_filter): - """ - Filter npz files based on time_filter configuration. - - Args: - npz_files: list of Path objects - time_filter: dict with optional 'night_start' and 'night_stop' keys - (e.g., {'night_start': '22:00', 'night_stop': '06:00'}) - - Returns: - filtered list of Path objects - """ - if not time_filter: - return npz_files - - night_start_str = time_filter.get('night_start') - night_stop_str = time_filter.get('night_stop') - - if not night_start_str or not night_stop_str: - return npz_files - - try: - # Parse time strings (e.g., "22:00" or "22:00:00") - night_start = datetime.strptime(night_start_str, '%H:%M').time() - night_stop = datetime.strptime(night_stop_str, '%H:%M').time() - except ValueError: - try: - # Try with seconds - night_start = datetime.strptime(night_start_str, '%H:%M:%S').time() - night_stop = datetime.strptime(night_stop_str, '%H:%M:%S').time() - except ValueError: - print("Warning: Invalid time format in time_filter. " - "Using all files.") - return npz_files - - filtered = [] - for file in npz_files: - dt = parse_npz_timestamp(file.name) - if dt and is_time_in_range(dt, night_start, night_stop): - filtered.append(file) - - return filtered - - def calculate_ppsd(workdir, npzfolder, channel, location, inv, tw): workdir = Path(workdir) Path(npzfolder).mkdir(exist_ok=True) diff --git a/src/gui.py b/src/gui.py index 8d35aa3..0bb72c4 100644 --- a/src/gui.py +++ b/src/gui.py @@ -20,10 +20,16 @@ from concurrent.futures import ProcessPoolExecutor, as_completed from tqdm import tqdm import numpy as np -from datetime import datetime -from ppsd_plotter_aux import calculate_ppsd_worker, load_inventory, \ - find_miniseed_channels, find_miniseed, \ - calculate_noise_line +from ppsd_plotter_aux import ( + calculate_ppsd_worker, + load_inventory, + find_miniseed_channels, + find_miniseed, + calculate_noise_line, + parse_npz_timestamp, + is_time_in_range, + filter_npz_files_by_time +) from localization_dicts import ALL_LABELS, ALL_SOFTWARE_LABELS, ALL_TOOLTIPS matplotlib.use("TkAgg") @@ -128,91 +134,6 @@ def parse_channel(ch_str): return parts[0], parts[1] -def parse_npz_timestamp(filename): - """ - Parse timestamp from npz filename. - Format: yy-mm-dd_HH-MM-SS.ffffff.npz - Returns datetime object or None if parsing fails. - """ - try: - stem = Path(filename).stem # Remove .npz extension - # Parse the timestamp: yy-mm-dd_HH-MM-SS.ffffff - dt = datetime.strptime(stem, '%y-%m-%d_%H-%M-%S.%f') - return dt - except Exception: - return None - - -def is_time_in_range(dt, start_time, end_time): - """ - Check if datetime's time component falls within start_time and end_time. - Handles ranges that span midnight (e.g., 22:00 to 06:00). - - Args: - dt: datetime object - start_time: time object (e.g., time(22, 0)) - end_time: time object (e.g., time(6, 0)) - - Returns: - True if dt.time() is within the range, False otherwise - """ - if start_time is None or end_time is None: - return True - - t = dt.time() - - # Normal range (e.g., 06:00 to 22:00) - if start_time <= end_time: - return start_time <= t <= end_time - # Range spans midnight (e.g., 22:00 to 06:00) - else: - return t >= start_time or t <= end_time - - -def filter_npz_files_by_time(npz_files, time_filter): - """ - Filter npz files based on time_filter configuration. - - Args: - npz_files: list of Path objects - time_filter: dict with optional 'night_start' and 'night_stop' keys - (e.g., {'night_start': '22:00', 'night_stop': '06:00'}) - - Returns: - filtered list of Path objects - """ - if not time_filter: - return npz_files - - night_start_str = time_filter.get('night_start') - night_stop_str = time_filter.get('night_stop') - - if not night_start_str or not night_stop_str: - return npz_files - - try: - # Parse time strings (e.g., "22:00" or "22:00:00") - night_start = datetime.strptime(night_start_str, '%H:%M').time() - night_stop = datetime.strptime(night_stop_str, '%H:%M').time() - except ValueError: - try: - # Try with seconds - night_start = datetime.strptime(night_start_str, '%H:%M:%S').time() - night_stop = datetime.strptime(night_stop_str, '%H:%M:%S').time() - except ValueError: - print("Warning: Invalid time format in time_filter. " - "Using all files.") - return npz_files - - filtered = [] - for file in npz_files: - dt = parse_npz_timestamp(file.name) - if dt and is_time_in_range(dt, night_start, night_stop): - filtered.append(file) - - return filtered - - def convert_npz_to_text(npzdir): npzdir = Path(npzdir) outdir = npzdir.with_name(npzdir.name + "_text") diff --git a/src/ppsd_plotter_aux.py b/src/ppsd_plotter_aux.py index cd2cb2a..b890cd3 100644 --- a/src/ppsd_plotter_aux.py +++ b/src/ppsd_plotter_aux.py @@ -4,6 +4,7 @@ from obspy.signal import PPSD import sys import numpy as np +from datetime import datetime def find_miniseed_channels(folder): @@ -46,6 +47,91 @@ def find_miniseed(workdir, channel, location=None): return None +def parse_npz_timestamp(filename): + """ + Parse timestamp from npz filename. + Format: yy-mm-dd_HH-MM-SS.ffffff.npz + Returns datetime object or None if parsing fails. + """ + try: + stem = Path(filename).stem # Remove .npz extension + # Parse the timestamp: yy-mm-dd_HH-MM-SS.ffffff + dt = datetime.strptime(stem, '%y-%m-%d_%H-%M-%S.%f') + return dt + except Exception: + return None + + +def is_time_in_range(dt, start_time, end_time): + """ + Check if datetime's time component falls within start_time and end_time. + Handles ranges that span midnight (e.g., 22:00 to 06:00). + + Args: + dt: datetime object + start_time: time object (e.g., time(22, 0)) + end_time: time object (e.g., time(6, 0)) + + Returns: + True if dt.time() is within the range, False otherwise + """ + if start_time is None or end_time is None: + return True + + t = dt.time() + + # Normal range (e.g., 06:00 to 22:00) + if start_time <= end_time: + return start_time <= t <= end_time + # Range spans midnight (e.g., 22:00 to 06:00) + else: + return t >= start_time or t <= end_time + + +def filter_npz_files_by_time(npz_files, time_filter): + """ + Filter npz files based on time_filter configuration. + + Args: + npz_files: list of Path objects + time_filter: dict with optional 'night_start' and 'night_stop' keys + (e.g., {'night_start': '22:00', 'night_stop': '06:00'}) + + Returns: + filtered list of Path objects + """ + if not time_filter: + return npz_files + + night_start_str = time_filter.get('night_start') + night_stop_str = time_filter.get('night_stop') + + if not night_start_str or not night_stop_str: + return npz_files + + try: + # Parse time strings (e.g., "22:00" or "22:00:00") + night_start = datetime.strptime(night_start_str, '%H:%M').time() + night_stop = datetime.strptime(night_stop_str, '%H:%M').time() + except ValueError: + try: + # Try with seconds + night_start = datetime.strptime(night_start_str, '%H:%M:%S').time() + night_stop = datetime.strptime(night_stop_str, '%H:%M:%S').time() + except ValueError: + print("Warning: Invalid time format in time_filter. " + "Using all files.") + return npz_files + + filtered = [] + for file in npz_files: + dt = parse_npz_timestamp(file.name) + if dt and is_time_in_range(dt, night_start, night_stop): + filtered.append(file) + + return filtered + + def calculate_ppsd_worker(job_list, inv_path, tw, folder): inv = load_inventory(inv_path) diff --git a/tests/test_time_filter.py b/tests/test_time_filter.py index 8013810..20817fb 100644 --- a/tests/test_time_filter.py +++ b/tests/test_time_filter.py @@ -1,7 +1,7 @@ import pytest from pathlib import Path from datetime import datetime, time -from src.PPSD_plotter import ( +from src.ppsd_plotter_aux import ( parse_npz_timestamp, is_time_in_range, filter_npz_files_by_time From 93d937ef6e8b0715287c3aa5b8d6a4e53c5747f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:07:13 +0000 Subject: [PATCH 05/13] Address code review feedback: improve code quality - Simplify time format parsing using a loop instead of nested try-except - Add more helpful error messages showing expected formats and invalid input - Define DEFAULT_TIME_WINDOW constant instead of using magic number 3600 - All tests still pass after improvements Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- src/PPSD_plotter.py | 5 ++++- src/ppsd_plotter_aux.py | 27 ++++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/PPSD_plotter.py b/src/PPSD_plotter.py index 92892ad..090b8bd 100644 --- a/src/PPSD_plotter.py +++ b/src/PPSD_plotter.py @@ -17,6 +17,9 @@ ) matplotlib.use("Agg") +# Default PPSD time window in seconds +DEFAULT_TIME_WINDOW = 3600 + def load_config(path): with open(path, 'r') as f: @@ -245,7 +248,7 @@ def process_dataset(entry, tw): def main(config_path): config = load_config(config_path) - tw = config.get("timewindow", 3600) # Default to 3600 if not specified + tw = config.get("timewindow", DEFAULT_TIME_WINDOW) num_workers = config.get("num_workers", 1) datasets = config["datasets"] diff --git a/src/ppsd_plotter_aux.py b/src/ppsd_plotter_aux.py index b890cd3..170a4a5 100644 --- a/src/ppsd_plotter_aux.py +++ b/src/ppsd_plotter_aux.py @@ -109,19 +109,24 @@ def filter_npz_files_by_time(npz_files, time_filter): if not night_start_str or not night_stop_str: return npz_files - try: - # Parse time strings (e.g., "22:00" or "22:00:00") - night_start = datetime.strptime(night_start_str, '%H:%M').time() - night_stop = datetime.strptime(night_stop_str, '%H:%M').time() - except ValueError: + # Try parsing with multiple time formats + time_formats = ['%H:%M', '%H:%M:%S'] + night_start = None + night_stop = None + + for fmt in time_formats: try: - # Try with seconds - night_start = datetime.strptime(night_start_str, '%H:%M:%S').time() - night_stop = datetime.strptime(night_stop_str, '%H:%M:%S').time() + night_start = datetime.strptime(night_start_str, fmt).time() + night_stop = datetime.strptime(night_stop_str, fmt).time() + break except ValueError: - print("Warning: Invalid time format in time_filter. " - "Using all files.") - return npz_files + continue + + if night_start is None or night_stop is None: + print(f"Warning: Invalid time format in time_filter. " + f"Expected 'HH:MM' or 'HH:MM:SS', got start='{night_start_str}', " + f"stop='{night_stop_str}'. Using all files.") + return npz_files filtered = [] for file in npz_files: From db618c339c84cee3d27078f1b3426e3f905b7e65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:08:59 +0000 Subject: [PATCH 06/13] Add documentation for time filtering feature - Document time_filter configuration in README.md - Include usage examples and notes about time range behavior - Mark day/night TODO item as completed - Provide clear examples of daytime and nighttime filtering Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8286b4c..e410010 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This utility automates the calculation, plotting, and export of Power Spectral D ## TODO: -- Custom plotting function and additional plotting parameters (day/night) (?) +- ~~Custom plotting function and additional plotting parameters (day/night)~~ ✅ **Completed** - Linux building (?) - Major refactor to automate all the data and station information collection (?) @@ -54,6 +54,27 @@ All these parameters are now visible in the GUI. Added a possibility to plot a custom RMS noise level on the plot. +### Time Filtering (Day/Night Mode) + +You can optionally filter PPSD data by time of day using the `time_filter` configuration option. This is useful for analyzing noise levels during specific time periods (e.g., daytime vs. nighttime). + +**Example configuration:** +```yaml +datasets: +- folder: data/station + response: response.xml + channels: [BHZ] + time_filter: + night_start: "22:00" # Start time in HH:MM format + night_stop: "06:00" # End time in HH:MM format +``` + +**Notes:** +- Time format: `HH:MM` or `HH:MM:SS` +- If `night_start` > `night_stop`, the range spans midnight (e.g., 22:00 to 06:00 means nighttime) +- The system calculates PPSD for all data, but only uses filtered .npz files when generating plots +- Omit `time_filter` to use all data (default behavior) + ## Output Structure Depending on the action used, the script generates the following output: From 7ec8e35d5dc334fa56d1debea0b2270d51788dd1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:30:52 +0000 Subject: [PATCH 07/13] Add GUI interface for time filtering feature - Add time_filter to DEFAULT_DATASET with None default - Add Time Filter section in DatasetFrame with Start Time and End Time inputs - Add update_time_filter() method to handle user input - Add localization labels and tooltips for time_filter, night_start, night_stop - Add German translations for new UI elements - Time filter UI placed after plot options and before custom noise section - Input validation: requires both start and stop times or neither Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- src/gui.py | 70 +++++++++++++++++++++++++++++++++++++++ src/localization_dicts.py | 16 +++++++-- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/gui.py b/src/gui.py index 0bb72c4..ccfc988 100644 --- a/src/gui.py +++ b/src/gui.py @@ -92,6 +92,7 @@ "action": "full", "timewindow": 3600, "plot_kwargs": DEFAULT_PLOT_KWARGS.copy(), + "time_filter": None, } ACTIONS = ["plot", "calculate", "full", "convert"] @@ -614,6 +615,55 @@ def build(self): self.plot_kwargs_vars[key] = var row += 1 + # Time Filter section + textlabel = PARAM_LABELS.get("time_filter", "Time Filter") + label = ttk.Label(self, text=textlabel) + label.grid(row=row, column=0, columnspan=2, sticky="w") + ToolTip(label, PARAM_TOOLTIPS.get("time_filter", "")) + row += 1 + + # Initialize time_filter_vars + self.time_filter_vars = { + "night_start": tk.StringVar(value=""), + "night_stop": tk.StringVar(value="") + } + + # Load existing time_filter values if present + tf = self.dataset.get("time_filter") + if isinstance(tf, dict): + self.time_filter_vars["night_start"].set( + str(tf.get("night_start", "")) + ) + self.time_filter_vars["night_stop"].set( + str(tf.get("night_stop", "")) + ) + + # Night Start Time + label = ttk.Label( + self, text=PARAM_LABELS.get("night_start", "Start Time") + ":" + ) + label.grid(row=row, column=0, sticky="w") + ToolTip(label, PARAM_TOOLTIPS.get("night_start", "")) + entry = ttk.Entry( + self, textvariable=self.time_filter_vars["night_start"], width=10 + ) + entry.grid(row=row, column=1, sticky="w") + entry.bind("", self.update_time_filter) + row += 1 + + # Night Stop Time + label = ttk.Label( + self, text=PARAM_LABELS.get("night_stop", "End Time") + ":" + ) + label.grid(row=row, column=0, sticky="w") + ToolTip(label, PARAM_TOOLTIPS.get("night_stop", "")) + entry = ttk.Entry( + self, textvariable=self.time_filter_vars["night_stop"], width=10 + ) + entry.grid(row=row, column=1, sticky="w") + entry.bind("", self.update_time_filter) + row += 1 + textlabel = ALL_SOFTWARE_LABELS[CURRENT_LANG].get("custom_noise") label = ttk.Label(self, text=textlabel) label.grid(row=row, column=0, columnspan=2, sticky="w") @@ -826,6 +876,26 @@ def update_custom_noise_field(self, field): not line.get("color")): self.dataset["custom_noise_line"] = None + def update_time_filter(self, event=None): + """Update the time_filter in the dataset.""" + night_start = self.time_filter_vars["night_start"].get().strip() + night_stop = self.time_filter_vars["night_stop"].get().strip() + + # If both fields are empty, remove time_filter + if not night_start and not night_stop: + self.dataset["time_filter"] = None + return + + # If both fields are provided, create time_filter dict + if night_start and night_stop: + self.dataset["time_filter"] = { + "night_start": night_start, + "night_stop": night_stop + } + else: + # If only one field is provided, don't set filter (invalid) + self.dataset["time_filter"] = None + def run_this_dataset(self): self.status_label.config(text="Starting...", foreground="orange") self.progress["value"] = 0 diff --git a/src/localization_dicts.py b/src/localization_dicts.py index 0a86ee3..4918671 100644 --- a/src/localization_dicts.py +++ b/src/localization_dicts.py @@ -19,6 +19,9 @@ "xaxis_frequency": "X Axis Frequency", "action": "Action", "timewindow": "Timewindow", + "time_filter": "Time Filter", + "night_start": "Start Time", + "night_stop": "End Time", } PARAM_TOOLTIPS_EN = { @@ -42,7 +45,10 @@ "xaxis_frequency": "Show frequency instead of period on x-axis.", "amplitude": "Amplitude of the custom noise line (in millig, mg)", "freq_range": "Frequency range in Hz (two floats, e.g. 1.0, 10.0)", - "color": "Line color (select from the list of available names)" + "color": "Line color (select from the list of available names)", + "time_filter": "Filter data by time of day (leave empty to use all data)", + "night_start": "Start time in HH:MM format (e.g. 22:00 for nighttime)", + "night_stop": "End time in HH:MM format (e.g. 06:00 for nighttime)", } SOFTWARE_LABELS_EN = { @@ -94,6 +100,9 @@ "xaxis_frequency": "X-Achse: Frequenz", "action": "Aktion", "timewindow": "Zeitfenster", + "time_filter": "Zeitfilter", + "night_start": "Startzeit", + "night_stop": "Endzeit", } PARAM_TOOLTIPS_DE = { @@ -117,7 +126,10 @@ "xaxis_frequency": "Frequenz statt Periode auf der X-Achse anzeigen.", "amplitude": "Amplitude der benutzerdefinierten Rauschlinie (in Millig, mg)", "freq_range": "Frequenzbereich in Hz (zwei Werte, z. B. 1.0, 10.0)", - "color": "Linienfarbe (aus der Liste verfügbarer Namen wählen)" + "color": "Linienfarbe (aus der Liste verfügbarer Namen wählen)", + "time_filter": "Daten nach Tageszeit filtern (leer lassen für alle Daten)", + "night_start": "Startzeit im Format HH:MM (z. B. 22:00 für Nachtzeit)", + "night_stop": "Endzeit im Format HH:MM (z. B. 06:00 für Nachtzeit)", } SOFTWARE_LABELS_DE = { From 9cba1060aa2a1a8120b0df57cbeb58b9de20e8d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:58:59 +0000 Subject: [PATCH 08/13] Add missing translations for time filter (RU, RS, TR) - Add Russian (RU) translations for time_filter, night_start, night_stop - Add Serbian (RS) translations for time_filter, night_start, night_stop - Add Turkish (TR) translations for time_filter, night_start, night_stop - All languages now have complete localization for time filtering feature Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- src/localization_dicts.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/localization_dicts.py b/src/localization_dicts.py index 4918671..8e0ae2d 100644 --- a/src/localization_dicts.py +++ b/src/localization_dicts.py @@ -180,6 +180,9 @@ "xaxis_frequency": "Частота по оси X", "action": "Действие", "timewindow": "Временное окно", + "time_filter": "Фильтр времени", + "night_start": "Время начала", + "night_stop": "Время окончания", } PARAM_TOOLTIPS_RU = { @@ -203,7 +206,10 @@ "xaxis_frequency": "Показать частоту вместо периода по оси X.", "amplitude": "Амплитуда пользовательской линии шума (в миллиg, mg)", "freq_range": "Частотный диапазон в Гц (две величины, например 1.0, 10.0)", - "color": "Цвет линии (выберите из списка доступных названий)" + "color": "Цвет линии (выберите из списка доступных названий)", + "time_filter": "Фильтровать данные по времени суток (оставьте пустым для использования всех данных)", + "night_start": "Время начала в формате ЧЧ:ММ (например, 22:00 для ночного времени)", + "night_stop": "Время окончания в формате ЧЧ:ММ (например, 06:00 для ночного времени)", } SOFTWARE_LABELS_RU = { @@ -255,6 +261,9 @@ "xaxis_frequency": "X osa: frekvencija", "action": "Akcija", "timewindow": "Vremenski prozor", + "time_filter": "Vremenski filter", + "night_start": "Vreme početka", + "night_stop": "Vreme završetka", } PARAM_TOOLTIPS_RS = { @@ -278,7 +287,10 @@ "xaxis_frequency": "Prikazuj frekvenciju umesto perioda na X osi.", "amplitude": "Amplituda prilagođene linije šuma (u miligima, mg)", "freq_range": "Opseg frekvencije u Hz (dve vrednosti, npr. 1.0, 10.0)", - "color": "Boja linije (izaberite iz liste dostupnih naziva)" + "color": "Boja linije (izaberite iz liste dostupnih naziva)", + "time_filter": "Filtriraj podatke po vremenu dana (ostavite prazno za sve podatke)", + "night_start": "Vreme početka u formatu HH:MM (npr. 22:00 za noćno vreme)", + "night_stop": "Vreme završetka u formatu HH:MM (npr. 06:00 za noćno vreme)", } SOFTWARE_LABELS_RS = { @@ -329,6 +341,9 @@ "xaxis_frequency": "X Ekseninde Frekans", "action": "İşlem", "timewindow": "Zaman Penceresi", + "time_filter": "Zaman Filtresi", + "night_start": "Başlangıç Zamanı", + "night_stop": "Bitiş Zamanı", } PARAM_TOOLTIPS_TR = { @@ -352,7 +367,10 @@ "xaxis_frequency": "X ekseninde periyot yerine frekans göster.", "amplitude": "Özel gürültü çizgisinin genliği (millig cinsinden, mg)", "freq_range": "Frekans aralığı (Hz cinsinden, iki sayı, örn. 1.0, 10.0)", - "color": "Çizgi rengi (mevcut isim listesinden seçiniz)" + "color": "Çizgi rengi (mevcut isim listesinden seçiniz)", + "time_filter": "Verileri günün saatine göre filtrele (tüm verileri kullanmak için boş bırakın)", + "night_start": "Başlangıç zamanı HH:MM formatında (örn. gece için 22:00)", + "night_stop": "Bitiş zamanı HH:MM formatında (örn. gece için 06:00)", } SOFTWARE_LABELS_TR = { From 68ac9cdad38c737b8c3031cfc5577ce03effac63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:10:21 +0000 Subject: [PATCH 09/13] Implement trace splitting for long files with time filtering - Add split_trace_by_time_filter() to handle 24h+ miniseed files - Traces are now split at day/night boundaries during calculation - Split segments are labeled with _day or _night suffix in filenames - Updated calculate_ppsd() to split traces before PPSD processing - Updated calculate_ppsd_worker() in GUI to support trace splitting - Updated filter_npz_files_by_time() to handle both old and new filename formats - Backward compatible: old unlabeled files still work with timestamp filtering - Added comprehensive tests for new labeled file filtering - All 13 tests pass Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- src/PPSD_plotter.py | 30 +++-- src/gui.py | 5 +- src/ppsd_plotter_aux.py | 272 ++++++++++++++++++++++++++++++++++---- tests/test_time_filter.py | 56 +++++++- 4 files changed, 324 insertions(+), 39 deletions(-) diff --git a/src/PPSD_plotter.py b/src/PPSD_plotter.py index 090b8bd..0536795 100644 --- a/src/PPSD_plotter.py +++ b/src/PPSD_plotter.py @@ -13,7 +13,8 @@ load_inventory, parse_npz_timestamp, is_time_in_range, - filter_npz_files_by_time + filter_npz_files_by_time, + split_trace_by_time_filter ) matplotlib.use("Agg") @@ -41,7 +42,7 @@ def safe_bool(val): return False -def calculate_ppsd(workdir, npzfolder, channel, location, inv, tw): +def calculate_ppsd(workdir, npzfolder, channel, location, inv, tw, time_filter=None): workdir = Path(workdir) Path(npzfolder).mkdir(exist_ok=True) @@ -57,13 +58,23 @@ def calculate_ppsd(workdir, npzfolder, channel, location, inv, tw): st = read(str(file)) st = st.select(channel=channel, location=location) for trace in st: - ppsd = PPSD(trace.stats, metadata=inv, ppsd_length=tw) - ppsd.add(trace) - timestamp = trace.stats.starttime.strftime( - '%y-%m-%d_%H-%M-%S.%f' + # Split trace by time filter if needed + trace_segments = split_trace_by_time_filter(trace, time_filter) + + for trace_segment, label in trace_segments: + ppsd = PPSD(trace_segment.stats, metadata=inv, ppsd_length=tw) + ppsd.add(trace_segment) + + # Include label in filename if filtering is applied + timestamp = trace_segment.stats.starttime.strftime( + '%y-%m-%d_%H-%M-%S.%f' ) - outfile = npzfolder / f"{timestamp}.npz" - ppsd.save_npz(str(outfile)) + if label != 'all': + outfile = npzfolder / f"{timestamp}_{label}.npz" + else: + outfile = npzfolder / f"{timestamp}.npz" + + ppsd.save_npz(str(outfile)) except Exception as e: print( f"Error processing {file} for channel={channel}" @@ -228,7 +239,8 @@ def process_dataset(entry, tw): npzfolder = Path(folder) / f"npz_{channel}" if action in ["calculate", "full"]: - calculate_ppsd(folder, npzfolder, channel, loc_code, inv, tw) + time_filter = entry.get("time_filter") + calculate_ppsd(folder, npzfolder, channel, loc_code, inv, tw, time_filter) if action in ["plot", "full"]: sample = find_miniseed(folder, channel, loc_code) diff --git a/src/gui.py b/src/gui.py index ccfc988..4700c79 100644 --- a/src/gui.py +++ b/src/gui.py @@ -211,7 +211,7 @@ def safe_bool(val): def calculate_ppsd( - folder, inv, tw, channel_list, callback=None, max_workers=None + folder, inv, tw, channel_list, callback=None, max_workers=None, time_filter=None ): folder = Path(folder) files = list(folder.rglob("*")) @@ -265,7 +265,7 @@ def update_progress(): with ProcessPoolExecutor(max_workers=cpu_count) as executor: futures = [ - executor.submit(calculate_ppsd_worker, chunk, inv, tw, folder) + executor.submit(calculate_ppsd_worker, chunk, inv, tw, folder, time_filter) for chunk in chunks ] for future in as_completed(futures): @@ -302,6 +302,7 @@ def process_dataset_visual(ds, progress_update_callback): tw=int(ds.get("timewindow", 3600)), channel_list=channels, callback=progress_update_callback, + time_filter=ds.get("time_filter"), ) for i, (loc_code, channel) in enumerate(parsed_channels): diff --git a/src/ppsd_plotter_aux.py b/src/ppsd_plotter_aux.py index 170a4a5..d97d745 100644 --- a/src/ppsd_plotter_aux.py +++ b/src/ppsd_plotter_aux.py @@ -4,7 +4,8 @@ from obspy.signal import PPSD import sys import numpy as np -from datetime import datetime +from datetime import datetime, time as dt_time, timedelta +from obspy import UTCDateTime def find_miniseed_channels(folder): @@ -91,11 +92,16 @@ def is_time_in_range(dt, start_time, end_time): def filter_npz_files_by_time(npz_files, time_filter): """ Filter npz files based on time_filter configuration. + + With the new approach, files are split during calculation and have + _day or _night suffix based on the original time_filter used during calculation. + This function filters files to match the requested time range. Args: npz_files: list of Path objects time_filter: dict with optional 'night_start' and 'night_stop' keys (e.g., {'night_start': '22:00', 'night_stop': '06:00'}) + These specify the desired time range to filter for. Returns: filtered list of Path objects @@ -109,35 +115,236 @@ def filter_npz_files_by_time(npz_files, time_filter): if not night_start_str or not night_stop_str: return npz_files - # Try parsing with multiple time formats - time_formats = ['%H:%M', '%H:%M:%S'] - night_start = None - night_stop = None + # Parse time filter + try: + time_formats = ['%H:%M', '%H:%M:%S'] + filter_start = None + filter_stop = None + + for fmt in time_formats: + try: + filter_start = datetime.strptime(night_start_str, fmt).time() + filter_stop = datetime.strptime(night_stop_str, fmt).time() + break + except ValueError: + continue + + if filter_start is None or filter_stop is None: + print(f"Warning: Invalid time format in time_filter. " + f"Expected 'HH:MM' or 'HH:MM:SS', got start='{night_start_str}', " + f"stop='{night_stop_str}'. Using all files.") + return npz_files + except Exception: + return npz_files - for fmt in time_formats: - try: - night_start = datetime.strptime(night_start_str, fmt).time() - night_stop = datetime.strptime(night_stop_str, fmt).time() - break - except ValueError: - continue + # Determine if the time range spans midnight (night) or not (day) + # If filter_start > filter_stop (e.g., 22:00 to 06:00), it's a night range + # If filter_start <= filter_stop (e.g., 06:00 to 22:00), it's a day range + is_night_range = filter_start > filter_stop - if night_start is None or night_stop is None: - print(f"Warning: Invalid time format in time_filter. " - f"Expected 'HH:MM' or 'HH:MM:SS', got start='{night_start_str}', " - f"stop='{night_stop_str}'. Using all files.") - return npz_files - filtered = [] for file in npz_files: - dt = parse_npz_timestamp(file.name) - if dt and is_time_in_range(dt, night_start, night_stop): - filtered.append(file) + filename = file.stem # Get filename without .npz + + # Check if this is a labeled file (new format) + if filename.endswith('_night'): + # This file contains night data + # Include if we're filtering for night range + if is_night_range: + filtered.append(file) + elif filename.endswith('_day'): + # This file contains day data + # Include if we're filtering for day range + if not is_night_range: + filtered.append(file) + else: + # Old format without label - use timestamp-based filtering + # parse_npz_timestamp expects the full filename with .npz + dt = parse_npz_timestamp(file.name) + if dt and is_time_in_range(dt, filter_start, filter_stop): + filtered.append(file) return filtered -def calculate_ppsd_worker(job_list, inv_path, tw, folder): +def split_trace_by_time_filter(trace, time_filter): + """ + Split a trace into segments based on time_filter. + + Args: + trace: ObsPy Trace object + time_filter: dict with 'night_start' and 'night_stop' keys (HH:MM format) + or None to return trace as-is + + Returns: + list of tuples: [(trace_segment, label), ...] + where label is 'day' or 'night' or 'all' (if no filter) + """ + if not time_filter: + return [(trace, 'all')] + + night_start_str = time_filter.get('night_start') + night_stop_str = time_filter.get('night_stop') + + if not night_start_str or not night_stop_str: + return [(trace, 'all')] + + try: + # Parse time strings + time_formats = ['%H:%M', '%H:%M:%S'] + night_start = None + night_stop = None + + for fmt in time_formats: + try: + night_start = datetime.strptime(night_start_str, fmt).time() + night_stop = datetime.strptime(night_stop_str, fmt).time() + break + except ValueError: + continue + + if night_start is None or night_stop is None: + return [(trace, 'all')] + + except Exception: + return [(trace, 'all')] + + # Get trace time range + starttime = trace.stats.starttime + endtime = trace.stats.endtime + + # Check if entire trace falls within one period + start_dt = starttime.datetime + end_dt = endtime.datetime + + # If trace is within a single period (day or night), no splitting needed + start_in_range = is_time_in_range(start_dt, night_start, night_stop) + end_in_range = is_time_in_range(end_dt, night_start, night_stop) + + # If both start and end are in the same period and trace is short enough + # (less than 12 hours to avoid spanning multiple days) + trace_duration_hours = (endtime - starttime) / 3600.0 + if start_in_range == end_in_range and trace_duration_hours < 12: + label = 'night' if start_in_range else 'day' + return [(trace, label)] + + # Need to split the trace - find all transition times + result = [] + current_time = UTCDateTime(starttime) + + # Iterate through each day in the trace + while current_time < endtime: + current_date = current_time.datetime.date() + + # Calculate transition times for this day + # Night start time on current day + night_start_utc = UTCDateTime( + datetime.combine(current_date, night_start) + ) + + # Determine night end time + if night_start <= night_stop: + # Normal range (e.g., 06:00 to 22:00 is day) + # So night is outside this range + # This means we have TWO night periods in a day + # This case is complex, let's handle the common case first + night_end_utc = UTCDateTime( + datetime.combine(current_date, night_stop) + ) + else: + # Night spans midnight (e.g., 22:00 to 06:00) + # Night ends next day + next_date = current_date + timedelta(days=1) + night_end_utc = UTCDateTime( + datetime.combine(next_date, night_stop) + ) + + # Create segments for this day + # We'll just split at the boundaries and label each segment + day_boundaries = [] + + if night_start <= night_stop: + # Day period is FROM night_stop TO night_start + # (e.g., 06:00-22:00 is day, rest is night) + day_start = UTCDateTime(datetime.combine(current_date, night_stop)) + day_end = UTCDateTime(datetime.combine(current_date, night_start)) + + # Night before day_start + if current_time < day_start and day_start < endtime: + seg_end = min(day_start, endtime) + if seg_end > current_time: + day_boundaries.append((current_time, seg_end, 'night')) + current_time = seg_end + + # Day period + if current_time < day_end and day_end <= endtime: + seg_end = min(day_end, endtime) + if seg_end > current_time: + day_boundaries.append((current_time, seg_end, 'day')) + current_time = seg_end + + # Night after day_end + next_day_start = UTCDateTime( + datetime.combine(current_date + timedelta(days=1), dt_time(0, 0)) + ) + seg_end = min(next_day_start, endtime) + if seg_end > current_time: + day_boundaries.append((current_time, seg_end, 'night')) + + else: + # Night spans midnight (common case) + # Night is FROM night_start TO night_stop (next day) + # Day is FROM night_stop TO night_start + day_start = night_end_utc # night_stop of current day + day_end = night_start_utc # night_start of current day + + # If we're in the night period from previous day + if current_time < day_start and day_start <= endtime: + seg_end = min(day_start, endtime) + if seg_end > current_time: + day_boundaries.append((current_time, seg_end, 'night')) + current_time = seg_end + + # Day period + if current_time < day_end and day_end <= endtime: + seg_end = min(day_end, endtime) + if seg_end > current_time: + day_boundaries.append((current_time, seg_end, 'day')) + current_time = seg_end + + # Night period starting at night_start + next_day_start = UTCDateTime( + datetime.combine(current_date + timedelta(days=1), dt_time(0, 0)) + ) + # Night continues until midnight, then we process next day + seg_end = min(next_day_start, endtime) + if seg_end > current_time: + day_boundaries.append((current_time, seg_end, 'night')) + + # Move to next day + current_time = UTCDateTime( + datetime.combine(current_date + timedelta(days=1), dt_time(0, 0)) + ) + + # Create trace segments + for seg_start, seg_end, label in day_boundaries: + if seg_end > seg_start: + try: + seg_trace = trace.slice(seg_start, seg_end) + if len(seg_trace.data) > 0: + result.append((seg_trace, label)) + except Exception as e: + print(f"Warning: Could not slice trace: {e}") + continue + + # If we couldn't split for some reason, return original + if not result: + return [(trace, 'all')] + + return result + + +def calculate_ppsd_worker(job_list, inv_path, tw, folder, time_filter=None): inv = load_inventory(inv_path) for file, loc, chan in job_list: @@ -160,11 +367,22 @@ def calculate_ppsd_worker(job_list, inv_path, tw, folder): npzfolder = Path(resource_path(folder)) / f"npz_{chan}" npzfolder.mkdir(exist_ok=True) - ppsd = PPSD(tr.stats, metadata=inv, ppsd_length=tw) - ppsd.add(tr) - timestamp = tr.stats.starttime.strftime("%y-%m-%d_%H-%M-%S.%f") - outfile = npzfolder / f"{timestamp}.npz" - ppsd.save_npz(str(outfile)) + + # Split trace by time filter if needed + trace_segments = split_trace_by_time_filter(tr, time_filter) + + for trace_segment, label in trace_segments: + ppsd = PPSD(trace_segment.stats, metadata=inv, ppsd_length=tw) + ppsd.add(trace_segment) + timestamp = trace_segment.stats.starttime.strftime("%y-%m-%d_%H-%M-%S.%f") + + # Include label in filename if filtering is applied + if label != 'all': + outfile = npzfolder / f"{timestamp}_{label}.npz" + else: + outfile = npzfolder / f"{timestamp}.npz" + + ppsd.save_npz(str(outfile)) except Exception as e: print( f"[{os.getpid()}] Error processing {file.name}" diff --git a/tests/test_time_filter.py b/tests/test_time_filter.py index 20817fb..8b20769 100644 --- a/tests/test_time_filter.py +++ b/tests/test_time_filter.py @@ -4,7 +4,8 @@ from src.ppsd_plotter_aux import ( parse_npz_timestamp, is_time_in_range, - filter_npz_files_by_time + filter_npz_files_by_time, + split_trace_by_time_filter ) @@ -151,3 +152,56 @@ def test_filter_npz_files_by_time_missing_keys(): time_filter = {'night_stop': '06:00'} filtered = filter_npz_files_by_time(files, time_filter) assert len(filtered) == 2 + + +def test_filter_npz_files_with_labels_nighttime(): + """Test filtering for files with _night and _day labels (new format).""" + files = [ + Path('25-06-07_07-00-00.019536_day.npz'), + Path('25-06-07_23-00-00.019536_night.npz'), + Path('25-06-08_02-00-00.019536_night.npz'), + Path('25-06-08_12-00-00.019536_day.npz'), + ] + + # Filter for night (22:00 to 06:00) + time_filter = {'night_start': '22:00', 'night_stop': '06:00'} + filtered = filter_npz_files_by_time(files, time_filter) + + assert len(filtered) == 2 + assert Path('25-06-07_23-00-00.019536_night.npz') in filtered + assert Path('25-06-08_02-00-00.019536_night.npz') in filtered + + +def test_filter_npz_files_with_labels_daytime(): + """Test filtering for files with _night and _day labels for daytime.""" + files = [ + Path('25-06-07_07-00-00.019536_day.npz'), + Path('25-06-07_23-00-00.019536_night.npz'), + Path('25-06-08_02-00-00.019536_night.npz'), + Path('25-06-08_12-00-00.019536_day.npz'), + ] + + # Filter for day (06:00 to 22:00) + time_filter = {'night_start': '06:00', 'night_stop': '22:00'} + filtered = filter_npz_files_by_time(files, time_filter) + + assert len(filtered) == 2 + assert Path('25-06-07_07-00-00.019536_day.npz') in filtered + assert Path('25-06-08_12-00-00.019536_day.npz') in filtered + + +def test_filter_npz_files_mixed_format(): + """Test filtering with mix of old (no label) and new (with label) formats.""" + files = [ + Path('25-06-07_07-00-00.019536.npz'), # Old format, 07:00 - day + Path('25-06-07_23-00-00.019536_night.npz'), # New format, night + Path('25-06-08_12-00-00.019536_day.npz'), # New format, day + ] + + # Filter for night (22:00 to 06:00) + time_filter = {'night_start': '22:00', 'night_stop': '06:00'} + filtered = filter_npz_files_by_time(files, time_filter) + + # Should get the new format night file, old format is daytime so excluded + assert len(filtered) == 1 + assert Path('25-06-07_23-00-00.019536_night.npz') in filtered From 3327e8f952bb5287fb78cac67017cde00a517c56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:48:42 +0000 Subject: [PATCH 10/13] Fix midnight boundary bug and add warning for unlabeled files - Fix split_trace_by_time_filter() midnight spanning logic * Correctly set day_start to current day's night_stop (not next day's) * Correctly set day_end to current day's night_start * Now properly captures data from 00:00 to morning time (e.g., 07:00) - Add warning when using time_filter with unlabeled .npz files * Informs users to recalculate for best results * Clarifies that timestamp-based filtering is being used - All 13 tests still pass Co-authored-by: msbdd <99192142+msbdd@users.noreply.github.com> --- src/PPSD_plotter.py | 10 ++++++++++ src/gui.py | 10 ++++++++++ src/ppsd_plotter_aux.py | 19 ++++++++++++------- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/PPSD_plotter.py b/src/PPSD_plotter.py index 0536795..794462a 100644 --- a/src/PPSD_plotter.py +++ b/src/PPSD_plotter.py @@ -108,6 +108,16 @@ def plot_ppsd( # Get all npz files and filter by time if needed all_files = list(Path(npzfolder).glob("*.npz")) + + # Check if we have labeled files (_day or _night) + labeled_files = [f for f in all_files if f.stem.endswith('_day') or f.stem.endswith('_night')] + unlabeled_files = [f for f in all_files if not (f.stem.endswith('_day') or f.stem.endswith('_night'))] + + if time_filter and unlabeled_files and not labeled_files: + print("Warning: Using time_filter with unlabeled .npz files.") + print("For best results, recalculate with 'action: full' or 'action: calculate' to split traces at boundaries.") + print("Currently using timestamp-based filtering on existing files.") + filtered_files = filter_npz_files_by_time(all_files, time_filter) if time_filter: diff --git a/src/gui.py b/src/gui.py index 4700c79..89554b3 100644 --- a/src/gui.py +++ b/src/gui.py @@ -368,6 +368,16 @@ def plot_ppsd_interactive( # Get all npz files and filter by time if needed all_files = list(Path(npzfolder).glob("*.npz")) + + # Check if we have labeled files (_day or _night) + labeled_files = [f for f in all_files if f.stem.endswith('_day') or f.stem.endswith('_night')] + unlabeled_files = [f for f in all_files if not (f.stem.endswith('_day') or f.stem.endswith('_night'))] + + if time_filter and unlabeled_files and not labeled_files: + print("Warning: Using time_filter with unlabeled .npz files.") + print("For best results, recalculate with action='full' or 'calculate' to split traces at boundaries.") + print("Currently using timestamp-based filtering on existing files.") + filtered_files = filter_npz_files_by_time(all_files, time_filter) if time_filter: diff --git a/src/ppsd_plotter_aux.py b/src/ppsd_plotter_aux.py index d97d745..aa25443 100644 --- a/src/ppsd_plotter_aux.py +++ b/src/ppsd_plotter_aux.py @@ -292,31 +292,36 @@ def split_trace_by_time_filter(trace, time_filter): day_boundaries.append((current_time, seg_end, 'night')) else: - # Night spans midnight (common case) + # Night spans midnight (common case: e.g., 22:00 to 07:00) # Night is FROM night_start TO night_stop (next day) # Day is FROM night_stop TO night_start - day_start = night_end_utc # night_stop of current day - day_end = night_start_utc # night_start of current day - # If we're in the night period from previous day + # For the current day: + # - Day starts at night_stop (e.g., 07:00) + # - Day ends at night_start (e.g., 22:00) + # - Night is before day_start and after day_end + + day_start = UTCDateTime(datetime.combine(current_date, night_stop)) + day_end = UTCDateTime(datetime.combine(current_date, night_start)) + + # If we're in the night period from previous day (before day_start) if current_time < day_start and day_start <= endtime: seg_end = min(day_start, endtime) if seg_end > current_time: day_boundaries.append((current_time, seg_end, 'night')) current_time = seg_end - # Day period + # Day period (from day_start to day_end) if current_time < day_end and day_end <= endtime: seg_end = min(day_end, endtime) if seg_end > current_time: day_boundaries.append((current_time, seg_end, 'day')) current_time = seg_end - # Night period starting at night_start + # Night period starting at day_end (after day_end until midnight) next_day_start = UTCDateTime( datetime.combine(current_date + timedelta(days=1), dt_time(0, 0)) ) - # Night continues until midnight, then we process next day seg_end = min(next_day_start, endtime) if seg_end > current_time: day_boundaries.append((current_time, seg_end, 'night')) From f720b18645c3ced220507ee3b917e7b27eef9940 Mon Sep 17 00:00:00 2001 From: Dmitry Sidorov-Biryukov <99192142+msbdd@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:53:21 +0100 Subject: [PATCH 11/13] Update TODO in README for completed tasks Update README with completed TODO item for plotting function. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index e410010..289a40f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ This utility automates the calculation, plotting, and export of Power Spectral D ## TODO: -- ~~Custom plotting function and additional plotting parameters (day/night)~~ ✅ **Completed** - Linux building (?) - Major refactor to automate all the data and station information collection (?) From feb29941f5188b632834dcaeed03dd9abf9afd9a Mon Sep 17 00:00:00 2001 From: Dmitry Sidorov-Biryukov <99192142+msbdd@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:56:18 +0100 Subject: [PATCH 12/13] Delete example/test_time_filter_config.yaml --- example/test_time_filter_config.yaml | 45 ---------------------------- 1 file changed, 45 deletions(-) delete mode 100644 example/test_time_filter_config.yaml diff --git a/example/test_time_filter_config.yaml b/example/test_time_filter_config.yaml deleted file mode 100644 index d5ddaa6..0000000 --- a/example/test_time_filter_config.yaml +++ /dev/null @@ -1,45 +0,0 @@ -datasets: -- action: plot - channels: - - 00.BHZ - folder: example/IU.ANMO..D - output_folder: /tmp/ppsd_day_output - plot_kwargs: - cmap: pqlx - cumulative: false - grid: true - show_coverage: true - show_histogram: true - show_mean: false - show_mode: false - show_noise_models: true - show_percentiles: false - xaxis_frequency: false - response: example/IU_ANMO_RESP.xml - timewindow: 600 - # Filter for daytime hours (06:00 to 22:00) - time_filter: - night_start: "06:00" - night_stop: "22:00" -- action: plot - channels: - - 00.BHZ - folder: example/IU.ANMO..D - output_folder: /tmp/ppsd_night_output - plot_kwargs: - cmap: pqlx - cumulative: false - grid: true - show_coverage: true - show_histogram: true - show_mean: false - show_mode: false - show_noise_models: true - show_percentiles: false - xaxis_frequency: false - response: example/IU_ANMO_RESP.xml - timewindow: 600 - # Filter for nighttime hours (22:00 to 06:00) - time_filter: - night_start: "22:00" - night_stop: "06:00" From 72b345b8e6baf4e31be11b9d93214c389c86ee3a Mon Sep 17 00:00:00 2001 From: Dmitry Sidorov-Biryukov <99192142+msbdd@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:58:46 +0100 Subject: [PATCH 13/13] Delete tests/test_time_filter.py --- tests/test_time_filter.py | 207 -------------------------------------- 1 file changed, 207 deletions(-) delete mode 100644 tests/test_time_filter.py diff --git a/tests/test_time_filter.py b/tests/test_time_filter.py deleted file mode 100644 index 8b20769..0000000 --- a/tests/test_time_filter.py +++ /dev/null @@ -1,207 +0,0 @@ -import pytest -from pathlib import Path -from datetime import datetime, time -from src.ppsd_plotter_aux import ( - parse_npz_timestamp, - is_time_in_range, - filter_npz_files_by_time, - split_trace_by_time_filter -) - - -def test_parse_npz_timestamp(): - """Test parsing timestamp from npz filename.""" - filename = '25-06-07_07-00-00.019536.npz' - dt = parse_npz_timestamp(filename) - assert dt is not None - assert dt.year == 2025 - assert dt.month == 6 - assert dt.day == 7 - assert dt.hour == 7 - assert dt.minute == 0 - assert dt.second == 0 - - -def test_parse_npz_timestamp_invalid(): - """Test parsing invalid filename returns None.""" - filename = 'invalid_filename.npz' - dt = parse_npz_timestamp(filename) - assert dt is None - - -def test_is_time_in_range_normal(): - """Test time range that doesn't span midnight.""" - dt = datetime(2025, 6, 7, 7, 0, 0) - - # Daytime range: 06:00 to 22:00 - day_start = time(6, 0) - day_end = time(22, 0) - - assert is_time_in_range(dt, day_start, day_end) is True - - # Test time outside range - dt_night = datetime(2025, 6, 7, 23, 0, 0) - assert is_time_in_range(dt_night, day_start, day_end) is False - - -def test_is_time_in_range_spanning_midnight(): - """Test time range that spans midnight.""" - # Nighttime range: 22:00 to 06:00 - night_start = time(22, 0) - night_end = time(6, 0) - - # Time at 23:00 should be in range - dt_night = datetime(2025, 6, 7, 23, 0, 0) - assert is_time_in_range(dt_night, night_start, night_end) is True - - # Time at 02:00 should be in range - dt_early_morning = datetime(2025, 6, 7, 2, 0, 0) - assert is_time_in_range(dt_early_morning, night_start, night_end) is True - - # Time at 07:00 should NOT be in range - dt_day = datetime(2025, 6, 7, 7, 0, 0) - assert is_time_in_range(dt_day, night_start, night_end) is False - - -def test_is_time_in_range_none_values(): - """Test that None values return True (no filtering).""" - dt = datetime(2025, 6, 7, 7, 0, 0) - assert is_time_in_range(dt, None, None) is True - assert is_time_in_range(dt, None, time(22, 0)) is True - assert is_time_in_range(dt, time(6, 0), None) is True - - -def test_filter_npz_files_by_time_no_filter(): - """Test that no filter returns all files.""" - files = [ - Path('25-06-07_07-00-00.019536.npz'), - Path('25-06-07_23-00-00.019536.npz'), - ] - - # No filter - filtered = filter_npz_files_by_time(files, None) - assert len(filtered) == 2 - - # Empty filter - filtered = filter_npz_files_by_time(files, {}) - assert len(filtered) == 2 - - -def test_filter_npz_files_by_time_daytime(): - """Test filtering for daytime hours.""" - files = [ - Path('25-06-07_07-00-00.019536.npz'), # 07:00 - day - Path('25-06-07_23-00-00.019536.npz'), # 23:00 - night - Path('25-06-07_12-00-00.019536.npz'), # 12:00 - day - Path('25-06-07_02-00-00.019536.npz'), # 02:00 - night - ] - - time_filter = {'night_start': '06:00', 'night_stop': '22:00'} - filtered = filter_npz_files_by_time(files, time_filter) - - assert len(filtered) == 2 - assert Path('25-06-07_07-00-00.019536.npz') in filtered - assert Path('25-06-07_12-00-00.019536.npz') in filtered - - -def test_filter_npz_files_by_time_nighttime(): - """Test filtering for nighttime hours (spanning midnight).""" - files = [ - Path('25-06-07_07-00-00.019536.npz'), # 07:00 - day - Path('25-06-07_23-00-00.019536.npz'), # 23:00 - night - Path('25-06-07_12-00-00.019536.npz'), # 12:00 - day - Path('25-06-07_02-00-00.019536.npz'), # 02:00 - night - ] - - time_filter = {'night_start': '22:00', 'night_stop': '06:00'} - filtered = filter_npz_files_by_time(files, time_filter) - - assert len(filtered) == 2 - assert Path('25-06-07_23-00-00.019536.npz') in filtered - assert Path('25-06-07_02-00-00.019536.npz') in filtered - - -def test_filter_npz_files_by_time_invalid_format(): - """Test that invalid time format returns all files.""" - files = [ - Path('25-06-07_07-00-00.019536.npz'), - Path('25-06-07_23-00-00.019536.npz'), - ] - - # Invalid time format - time_filter = {'night_start': 'invalid', 'night_stop': '22:00'} - filtered = filter_npz_files_by_time(files, time_filter) - - # Should return all files when format is invalid - assert len(filtered) == 2 - - -def test_filter_npz_files_by_time_missing_keys(): - """Test that missing keys return all files.""" - files = [ - Path('25-06-07_07-00-00.019536.npz'), - Path('25-06-07_23-00-00.019536.npz'), - ] - - # Missing night_stop - time_filter = {'night_start': '22:00'} - filtered = filter_npz_files_by_time(files, time_filter) - assert len(filtered) == 2 - - # Missing night_start - time_filter = {'night_stop': '06:00'} - filtered = filter_npz_files_by_time(files, time_filter) - assert len(filtered) == 2 - - -def test_filter_npz_files_with_labels_nighttime(): - """Test filtering for files with _night and _day labels (new format).""" - files = [ - Path('25-06-07_07-00-00.019536_day.npz'), - Path('25-06-07_23-00-00.019536_night.npz'), - Path('25-06-08_02-00-00.019536_night.npz'), - Path('25-06-08_12-00-00.019536_day.npz'), - ] - - # Filter for night (22:00 to 06:00) - time_filter = {'night_start': '22:00', 'night_stop': '06:00'} - filtered = filter_npz_files_by_time(files, time_filter) - - assert len(filtered) == 2 - assert Path('25-06-07_23-00-00.019536_night.npz') in filtered - assert Path('25-06-08_02-00-00.019536_night.npz') in filtered - - -def test_filter_npz_files_with_labels_daytime(): - """Test filtering for files with _night and _day labels for daytime.""" - files = [ - Path('25-06-07_07-00-00.019536_day.npz'), - Path('25-06-07_23-00-00.019536_night.npz'), - Path('25-06-08_02-00-00.019536_night.npz'), - Path('25-06-08_12-00-00.019536_day.npz'), - ] - - # Filter for day (06:00 to 22:00) - time_filter = {'night_start': '06:00', 'night_stop': '22:00'} - filtered = filter_npz_files_by_time(files, time_filter) - - assert len(filtered) == 2 - assert Path('25-06-07_07-00-00.019536_day.npz') in filtered - assert Path('25-06-08_12-00-00.019536_day.npz') in filtered - - -def test_filter_npz_files_mixed_format(): - """Test filtering with mix of old (no label) and new (with label) formats.""" - files = [ - Path('25-06-07_07-00-00.019536.npz'), # Old format, 07:00 - day - Path('25-06-07_23-00-00.019536_night.npz'), # New format, night - Path('25-06-08_12-00-00.019536_day.npz'), # New format, day - ] - - # Filter for night (22:00 to 06:00) - time_filter = {'night_start': '22:00', 'night_stop': '06:00'} - filtered = filter_npz_files_by_time(files, time_filter) - - # Should get the new format night file, old format is daytime so excluded - assert len(filtered) == 1 - assert Path('25-06-07_23-00-00.019536_night.npz') in filtered