diff --git a/data_acq_and_processing/common_cpp/config/include/run_config.hpp b/data_acq_and_processing/common_cpp/config/include/run_config.hpp index 5342590..d06ac8d 100644 --- a/data_acq_and_processing/common_cpp/config/include/run_config.hpp +++ b/data_acq_and_processing/common_cpp/config/include/run_config.hpp @@ -94,10 +94,23 @@ struct S11PreprocessConfig { PreprocessAssetConfig reference{}; }; +struct PreprocessNotchBandConfig { + float low_hz = 0.0F; + float high_hz = 0.0F; +}; + +struct PreprocessNotchConfig { + bool enabled = false; + std::vector bands_hz{}; + float taper_width_hz = 40'000'000.0F; + std::string taper_type = "cosine"; +}; + struct PreprocessConfig { // Channel-specific preprocessing assets selected by Python GUI layer. S21PreprocessConfig s21{}; S11PreprocessConfig s11{}; + PreprocessNotchConfig notch{}; }; struct GprTxGeometry { diff --git a/data_acq_and_processing/common_cpp/config/src/run_config.cpp b/data_acq_and_processing/common_cpp/config/src/run_config.cpp index 7d9217f..767e2e9 100644 --- a/data_acq_and_processing/common_cpp/config/src/run_config.cpp +++ b/data_acq_and_processing/common_cpp/config/src/run_config.cpp @@ -154,6 +154,46 @@ using Json = nlohmann::json; return asset; } +[[nodiscard]] auto parse_preprocess_notch( + const Json& object, + const std::string& context +) -> PreprocessNotchConfig { + PreprocessNotchConfig notch{}; + notch.enabled = optional_bool(object, "enabled", false); + notch.taper_width_hz = optional_f32(object, "taper_width_hz", 40'000'000.0F); + notch.taper_type = optional_string(object, "taper_type", "cosine"); + + if (const auto* bands_value = optional_field(object, "bands_hz"); bands_value != nullptr) { + const auto* bands_array = as_array(*bands_value, context + ".bands_hz"); + notch.bands_hz.reserve(bands_array->size()); + for (const auto& band_value : *bands_array) { + const auto* band_array = as_array(band_value, context + ".bands_hz[]"); + if (band_array->size() != 2U) { + throw std::runtime_error(context + ".bands_hz[] must contain exactly two numbers"); + } + + PreprocessNotchBandConfig band{}; + band.low_hz = static_cast(as_number((*band_array)[0], context + ".bands_hz[][0]")); + band.high_hz = static_cast(as_number((*band_array)[1], context + ".bands_hz[][1]")); + notch.bands_hz.push_back(band); + } + } + + if (notch.taper_width_hz < 0.0F) { + throw std::runtime_error(context + ".taper_width_hz must be >= 0"); + } + if (notch.taper_type != "cosine" && notch.taper_type != "hard") { + throw std::runtime_error(context + ".taper_type must be 'cosine' or 'hard'"); + } + for (const auto& band : notch.bands_hz) { + if (band.high_hz < band.low_hz) { + throw std::runtime_error(context + ".bands_hz[] high_hz must be >= low_hz"); + } + } + + return notch; +} + [[nodiscard]] auto parse_driver_mode(const std::string& value) -> DriverMode { if (value == "mock") { return DriverMode::Mock; @@ -466,6 +506,10 @@ auto load_run_config(const std::string& path) -> RunConfig { config.preprocess.s11.calibration.load = parse_preprocess_asset(*s11_calibration_obj, "load", "preprocess.s11.calibration"); config.preprocess.s11.reference = parse_preprocess_asset(*s11_obj, "reference", "preprocess.s11"); + + if (const auto* notch_value = optional_field(*preprocess_obj, "notch"); notch_value != nullptr) { + config.preprocess.notch = parse_preprocess_notch(*as_object(*notch_value, "preprocess.notch"), "preprocess.notch"); + } } if (const auto* gpr_value = optional_field(*root_obj, "gpr"); gpr_value != nullptr) { diff --git a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp index 959fbe3..e7fbe8b 100644 --- a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp +++ b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp @@ -1,11 +1,94 @@ #include "data_preprocessor.hpp" +#include #include +#include +#include #include +#include #include #include namespace radar::preprocessing { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +[[nodiscard]] auto should_apply_notch(const config::PreprocessNotchConfig& notch) -> bool { + return notch.enabled && !notch.bands_hz.empty(); +} + +[[nodiscard]] auto build_notch_mask( + std::span frequency_hz, + const config::PreprocessNotchConfig& notch +) -> std::vector { + std::vector mask(frequency_hz.size(), 1.0F); + if (!should_apply_notch(notch)) { + return mask; + } + + const bool hard_taper = notch.taper_type == "hard"; + const double taper_width_hz = std::max(0.0, static_cast(notch.taper_width_hz)); + for (const auto& band : notch.bands_hz) { + const double low_hz = static_cast(band.low_hz); + const double high_hz = static_cast(band.high_hz); + const double trans_low_hz = low_hz - taper_width_hz; + const double trans_high_hz = high_hz + taper_width_hz; + + for (std::size_t index = 0U; index < frequency_hz.size(); ++index) { + const double frequency = static_cast(frequency_hz[index]); + if (frequency >= low_hz && frequency <= high_hz) { + mask[index] = 0.0F; + continue; + } + + if (hard_taper || taper_width_hz <= 0.0) { + if (frequency >= trans_low_hz && frequency <= trans_high_hz) { + mask[index] = 0.0F; + } + continue; + } + + if (frequency >= trans_low_hz && frequency < low_hz) { + const double arg = kPi * (frequency - trans_low_hz) / taper_width_hz; + mask[index] *= static_cast(0.5 * (1.0 - std::cos(arg))); + continue; + } + + if (frequency > high_hz && frequency <= trans_high_hz) { + const double arg = kPi * (frequency - high_hz) / taper_width_hz; + mask[index] *= static_cast(0.5 * (1.0 + std::cos(arg))); + } + } + } + + return mask; +} + +void apply_notch_mask(std::vector& samples, std::span mask) { + const std::size_t point_count = std::min(samples.size(), mask.size()); + for (std::size_t index = 0U; index < point_count; ++index) { + samples[index].re *= mask[index]; + samples[index].im *= mask[index]; + } +} + +[[nodiscard]] auto apply_notch_filter( + const ipc::SweepTraceBlock& trace, + const config::PreprocessNotchConfig& notch +) -> ipc::SweepTraceBlock { + if (!should_apply_notch(notch)) { + return trace; + } + + ipc::SweepTraceBlock filtered = trace; + const auto mask = build_notch_mask(filtered.frequency_hz, notch); + apply_notch_mask(filtered.s21, mask); + apply_notch_mask(filtered.s11, mask); + return filtered; +} + +} // namespace DataPreprocessor::DataPreprocessor( const config::RunConfig& config, @@ -78,7 +161,7 @@ auto DataPreprocessor::preprocess_collection(const ipc::RawSweepCollection& raw_ // Pipeline order is fixed: calibration first, then reference subtraction. const auto calibrated = calibration_master_.apply_to_trace(raw_trace); const auto referenced = reference_master_.apply_to_trace(calibrated); - preprocessed.traces.push_back(referenced); + preprocessed.traces.push_back(apply_notch_filter(referenced, config_.preprocess.notch)); } return preprocessed; diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py index 7ed2a79..f36ce0b 100644 --- a/python_app/gui/controllers/app_window_config/live_processing_mixin.py +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -168,7 +168,8 @@ class AppWindowLiveProcessingMixin: f"cut={self._bscan_cut_m.value():g} m, " f"max_depth={self._bscan_max_depth_m.value():g} m, " f"gain={self._bscan_gain.value():g}, " - f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz)" + f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz, " + f"subtract_mean_ascan={self._bscan_subtract_mean_ascan.isChecked()})" ) elif mode == "gpr": self._log( diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index cdb12a7..2431a52 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -169,6 +169,7 @@ class AppWindowConfigProfileIOMixin: self._bscan_gain, self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz, + self._bscan_subtract_mean_ascan, self._gpr_config_mode, self._gpr_relative_permittivity, self._gpr_tx_geometry_input, @@ -228,6 +229,7 @@ class AppWindowConfigProfileIOMixin: self._bscan_gain.setValue(float(gui_state.processing.bscan.gain)) self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz)) self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz)) + self._bscan_subtract_mean_ascan.setChecked(bool(gui_state.processing.bscan.subtract_mean_ascan)) self._set_combo_current_text(self._gpr_config_mode, str(config.gpr.mode)) self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity)) diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 2a05d6b..d4f50e4 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -157,6 +157,7 @@ class AppWindowConfigStateBuildersMixin: gain=1.0, start_freq_mhz=100.0, stop_freq_mhz=8800.0, + subtract_mean_ascan=False, ), gpr=GuiGprStateModel( input_positions=self._default_gpr_input_positions_from_config(config), @@ -235,6 +236,7 @@ class AppWindowConfigStateBuildersMixin: gain=float(self._bscan_gain.value()), start_freq_mhz=float(self._bscan_start_freq_mhz.value()), stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()), + subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()), ), gpr=GuiGprStateModel( input_positions=self._gpr_input_positions_input.text().strip(), diff --git a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py index ef0df26..77698cb 100644 --- a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py @@ -39,6 +39,8 @@ def _result_tail( def build_bscan_signature( live_config: ProcessingLiveConfig, + *, + subtract_mean_ascan_enabled: bool, result_history: list[ResultCollection], history_limit: int, floor_collection_id: int, @@ -57,11 +59,25 @@ def build_bscan_signature( float(live_config.bscan_gain), float(live_config.bscan_start_freq_mhz), float(live_config.bscan_stop_freq_mhz), + bool(subtract_mean_ascan_enabled), int(floor_collection_id), tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail), ) +def apply_mean_ascan_subtraction( + history: deque[np.ndarray], + *, + enabled: bool, +) -> np.ndarray: + """Subtract mean A-scan across sweeps when enabled.""" + sweeps = np.vstack(history).astype(np.float32, copy=False) + if not enabled: + return sweeps + mean_trace = np.mean(sweeps, axis=0, dtype=np.float32) + return sweeps - mean_trace + + def rebuild_bscan_history_from_results( result_history: list[ResultCollection], history_limit: int, @@ -183,7 +199,10 @@ class AppWindowBscanPlotMixin: if not history or depth_axis is None: return False - sweeps = np.vstack(history).astype(np.float32, copy=False) + sweeps = apply_mean_ascan_subtraction( + history, + enabled=bool(self._bscan_subtract_mean_ascan.isChecked()), + ) if sweeps.size == 0: return False @@ -229,6 +248,7 @@ class AppWindowBscanPlotMixin: result_history = list(self._result_history) return build_bscan_signature( live_config=live_config, + subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()), result_history=result_history, history_limit=self._bscan_history_limit, floor_collection_id=self._bscan_history_floor_collection_id, diff --git a/python_app/gui/controllers/app_window_snapshot_mixin.py b/python_app/gui/controllers/app_window_snapshot_mixin.py index 1c2299f..1eeb882 100644 --- a/python_app/gui/controllers/app_window_snapshot_mixin.py +++ b/python_app/gui/controllers/app_window_snapshot_mixin.py @@ -18,9 +18,9 @@ class AppWindowSnapshotMixin: return snapshot_dir / "config_profile.json" @staticmethod - def _vna_json_config_profile_path(output_root: Path, output_stem: str) -> Path: + def _vna_json_config_profile_path(output_dir: Path) -> Path: """Return companion config-profile path for one VNA-history JSON export batch.""" - return output_root / f"{output_stem}_config_profile.json" + return output_dir / "config_profile.json" def _save_snapshot(self) -> None: """Save runtime snapshot in numpy-directory format.""" @@ -98,8 +98,9 @@ class AppWindowSnapshotMixin: output_stem = str(summary.get("output_stem", "")).strip() if not output_stem: raise RuntimeError("VNA history JSON export did not report output_stem for config companion save") + output_dir = Path(str(summary.get("output_dir", "")).strip() or output_paths[0].parent) - config_profile_path = self._vna_json_config_profile_path(output_root, output_stem) + config_profile_path = self._vna_json_config_profile_path(output_dir) try: self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False) except Exception as exc: # noqa: BLE001 @@ -110,6 +111,7 @@ class AppWindowSnapshotMixin: "VNA history JSON files were saved, but the adjacent config profile could not be written", details=( f"output_root={output_root}\n" + f"output_dir={output_dir}\n" f"config_profile_path={config_profile_path}\n" f"saved_json_files={len(output_paths)}\n" f"{exported_preview}\n\n" diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 7c907ea..db895c1 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -134,6 +134,9 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_stop_freq_mhz.setSingleStep(10.0) owner._bscan_stop_freq_mhz.setValue(float(bscan_defaults.stop_freq_mhz)) + owner._bscan_subtract_mean_ascan = QCheckBox("Subtract mean A-scan") + owner._bscan_subtract_mean_ascan.setChecked(bool(bscan_defaults.subtract_mean_ascan)) + bscan_page = _build_processing_mode_page( owner._processing_mode_pages, [ @@ -143,8 +146,9 @@ def build_processing_group(owner) -> QGroupBox: ("Gain", owner._bscan_gain), ("Start MHz", owner._bscan_start_freq_mhz), ("Stop MHz", owner._bscan_stop_freq_mhz), + owner._bscan_subtract_mean_ascan, ], - split_index=3, + split_index=4, ) owner._processing_mode_pages.addWidget(bscan_page) @@ -314,6 +318,7 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._bscan_subtract_mean_ascan.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed) owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed) owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) diff --git a/python_app/gui/main.py b/python_app/gui/main.py index 4aec4c6..1fae873 100644 --- a/python_app/gui/main.py +++ b/python_app/gui/main.py @@ -14,14 +14,14 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from python_app.gui.app_window import AppWindow -from python_app.gui.theme import apply_dark_theme +from python_app.gui.theme import apply_light_theme def main() -> int: """Run Qt event loop and show main radar control window.""" app = QApplication(sys.argv) - apply_dark_theme(app) - pg.setConfigOptions(antialias=True, foreground="#dbe4f1") + apply_light_theme(app) + pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#334155") window = AppWindow(PROJECT_ROOT) window.showMaximized() return app.exec() diff --git a/python_app/gui/theme.py b/python_app/gui/theme.py index 29b67ea..72db41e 100644 --- a/python_app/gui/theme.py +++ b/python_app/gui/theme.py @@ -6,19 +6,19 @@ from PyQt6.QtGui import QColor, QPalette from PyQt6.QtWidgets import QApplication, QStyleFactory -_DARK_STYLESHEET = """ +_LIGHT_STYLESHEET = """ QMainWindow, QDialog { - background-color: #0f131a; + background-color: #f3f6fb; } QWidget { - color: #e7edf7; + color: #1f2937; font-size: 13px; } QGroupBox { - background-color: #151b24; - border: 1px solid #263142; + background-color: #ffffff; + border: 1px solid #d7dee8; border-radius: 10px; margin-top: 14px; padding: 10px; @@ -28,39 +28,39 @@ QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 6px; - color: #9fb4ce; + color: #526277; font-weight: 600; } QPushButton { - background-color: #1d2735; - border: 1px solid #314055; + background-color: #f8fafc; + border: 1px solid #cad4e1; border-radius: 8px; padding: 7px 12px; } QPushButton:hover { - background-color: #243246; + background-color: #eef3f9; } QPushButton:pressed { - background-color: #1a2432; + background-color: #e4ebf4; } QPushButton:checked { background-color: #2f7ee6; - border-color: #5d88bd; + border-color: #2f7ee6; color: #ffffff; } QPushButton:checked:hover { - background-color: #3c89ee; + background-color: #3b8bf4; } QPushButton:disabled { - color: #6b7d95; - background-color: #151d27; - border-color: #232e3d; + color: #98a4b3; + background-color: #f3f5f8; + border-color: #dde4ec; } QPushButton#settingsToggleButton { @@ -83,11 +83,11 @@ QTextEdit, QComboBox, QSpinBox, QDoubleSpinBox { - background-color: #101721; - border: 1px solid #2e3b4e; + background-color: #ffffff; + border: 1px solid #c9d4e1; border-radius: 7px; padding: 5px 8px; - selection-background-color: #2f7ee6; + selection-background-color: #cfe3ff; } QLineEdit:focus, @@ -96,7 +96,7 @@ QTextEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus { - border: 1px solid #5d88bd; + border: 1px solid #2f7ee6; } QTextEdit#runtimeLogBox { @@ -116,34 +116,34 @@ QScrollArea { } QLabel#statusLabel { - color: #94b4d9; + color: #35507a; font-weight: 600; padding: 2px 1px; } QLabel#hintLabel { - color: #7f94af; + color: #6c7b8d; } """ -def apply_dark_theme(app: QApplication) -> None: - """Apply a minimal modern dark theme shared by all windows.""" +def apply_light_theme(app: QApplication) -> None: + """Apply a minimal light theme shared by all windows.""" app.setStyle(QStyleFactory.create("Fusion")) palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor("#0f131a")) - palette.setColor(QPalette.ColorRole.WindowText, QColor("#e7edf7")) - palette.setColor(QPalette.ColorRole.Base, QColor("#101721")) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#151b24")) - palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#151b24")) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#e7edf7")) - palette.setColor(QPalette.ColorRole.Text, QColor("#e7edf7")) - palette.setColor(QPalette.ColorRole.Button, QColor("#1d2735")) - palette.setColor(QPalette.ColorRole.ButtonText, QColor("#e7edf7")) + palette.setColor(QPalette.ColorRole.Window, QColor("#f3f6fb")) + palette.setColor(QPalette.ColorRole.WindowText, QColor("#1f2937")) + palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#eef3f8")) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff")) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#1f2937")) + palette.setColor(QPalette.ColorRole.Text, QColor("#1f2937")) + palette.setColor(QPalette.ColorRole.Button, QColor("#f8fafc")) + palette.setColor(QPalette.ColorRole.ButtonText, QColor("#1f2937")) palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff")) - palette.setColor(QPalette.ColorRole.Link, QColor("#5d88bd")) + palette.setColor(QPalette.ColorRole.Link, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) app.setPalette(palette) - app.setStyleSheet(_DARK_STYLESHEET) + app.setStyleSheet(_LIGHT_STYLESHEET) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index fae07e7..3fc6dfb 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -152,6 +152,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.bscan.stop_freq_mhz, "gui.processing.bscan", ), + subtract_mean_ascan=_optional_bool( + bscan_object, + "subtract_mean_ascan", + gui.processing.bscan.subtract_mean_ascan, + "gui.processing.bscan", + ), ), gpr=GuiGprStateModel( input_positions=_optional_string( @@ -359,6 +365,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "gain": gui.processing.bscan.gain, "start_freq_mhz": gui.processing.bscan.start_freq_mhz, "stop_freq_mhz": gui.processing.bscan.stop_freq_mhz, + "subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan, }, "gpr": { "input_positions": gui.processing.gpr.input_positions, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index be29138..42dbc81 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -42,6 +42,7 @@ class GuiBscanStateModel: gain: float = 1.0 start_freq_mhz: float = 100.0 stop_freq_mhz: float = 8800.0 + subtract_mean_ascan: bool = False @dataclass(slots=True) diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index efb8823..f0c0c78 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -9,6 +9,7 @@ from python_app.models.run_config_schema import ( GprRxGeometryModel, GprTxGeometryModel, PreprocessAssetModel, + PreprocessNotchModel, RunConfigModel, ) from python_app.models.run_config_validation import load_ring_payload, load_switch_payload, validate_gpr_model @@ -137,6 +138,18 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: _as_dict(s11_preprocess_payload.get("reference"), "preprocess.s11.reference"), model.preprocess.s11.reference, ) + notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch") + model.preprocess.notch = PreprocessNotchModel( + enabled=bool(notch_payload.get("enabled", model.preprocess.notch.enabled)), + taper_width_hz=float(notch_payload.get("taper_width_hz", model.preprocess.notch.taper_width_hz)), + taper_type=str(notch_payload.get("taper_type", model.preprocess.notch.taper_type)), + bands_hz=[], + ) + bands_payload = notch_payload.get("bands_hz", []) + if isinstance(bands_payload, list): + for band in bands_payload: + if isinstance(band, (list, tuple)) and len(band) == 2: + model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1]))) model.gpr.mode = str(gpr_payload.get("mode", model.gpr.mode)) model.gpr.relative_permittivity = float( @@ -282,6 +295,12 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: "bundle_path": model.preprocess.s11.reference.bundle_path, }, }, + "notch": { + "enabled": model.preprocess.notch.enabled, + "bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz], + "taper_width_hz": model.preprocess.notch.taper_width_hz, + "taper_type": model.preprocess.notch.taper_type, + }, }, "gpr": { "mode": model.gpr.mode, diff --git a/python_app/models/run_config_model.py b/python_app/models/run_config_model.py index 5a142ce..08b59c4 100644 --- a/python_app/models/run_config_model.py +++ b/python_app/models/run_config_model.py @@ -8,6 +8,7 @@ from python_app.models.run_config_schema import ( GprTxGeometryModel, LocatorServerRuntimeModel, PreprocessAssetModel, + PreprocessNotchModel, PreprocessModel, RadarModel, RadarSweepModel, @@ -33,6 +34,7 @@ __all__ = [ "GprTxGeometryModel", "LocatorServerRuntimeModel", "PreprocessAssetModel", + "PreprocessNotchModel", "PreprocessModel", "RadarModel", "RadarSweepModel", diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 268ba22..5377e97 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -132,12 +132,23 @@ class S11PreprocessModel: reference: PreprocessAssetModel = field(default_factory=PreprocessAssetModel) +@dataclass(slots=True) +class PreprocessNotchModel: + """Optional frequency-domain notch filter applied after calibration and reference subtraction.""" + + enabled: bool = False + bands_hz: list[tuple[float, float]] = field(default_factory=list) + taper_width_hz: float = 40_000_000.0 + taper_type: str = "cosine" + + @dataclass(slots=True) class PreprocessModel: """Selected preprocessing artifacts for live acquisition.""" s21: S21PreprocessModel = field(default_factory=S21PreprocessModel) s11: S11PreprocessModel = field(default_factory=S11PreprocessModel) + notch: PreprocessNotchModel = field(default_factory=PreprocessNotchModel) @dataclass(slots=True) diff --git a/python_app/runtime/processing_live.json b/python_app/runtime/processing_live.json index 81790b5..d4a4800 100644 --- a/python_app/runtime/processing_live.json +++ b/python_app/runtime/processing_live.json @@ -32,4 +32,4 @@ "gpr_background_mean_count": 10, "history_command_seq": 0, "history_command": "none" -} \ No newline at end of file +} diff --git a/python_app/runtime/run_config.json b/python_app/runtime/run_config.json index 5c1b3be..4fb94ad 100644 --- a/python_app/runtime/run_config.json +++ b/python_app/runtime/run_config.json @@ -108,6 +108,18 @@ "set_name": "", "bundle_path": "" } + }, + "notch": { + "enabled": true, + "bands_hz": [ + [790000000.0, 850000000.0], + [1790000000.0, 1890000000.0], + [1970000000.0, 2050000000.0], + [2080000000.0, 2560000000.0], + [2650000000.0, 2700000000.0] + ], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" } }, "gpr": { @@ -169,4 +181,4 @@ "slot_size_bytes": 2097152 } } -} \ No newline at end of file +} diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index c0a62a5..b679179 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -31,6 +31,11 @@ class NpzStore(StoreApi): self._root_dir = root_dir self._root_dir.mkdir(parents=True, exist_ok=True) + @staticmethod + def _vna_json_output_dir(output_root_dir: Path, output_stem: str) -> Path: + """Return per-export directory for VNA history JSON files.""" + return output_root_dir / output_stem + def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None: """Persist named preprocess set as NPZ and metadata JSON.""" set_dir = self._set_dir(kind, radar_key) @@ -217,7 +222,9 @@ class NpzStore(StoreApi): output_stem = sanitize_path_component(output_stem) output_root_dir.mkdir(parents=True, exist_ok=True) - output_path = output_root_dir / f"{output_stem}_vna_bscan_history.json" + output_dir = self._vna_json_output_dir(output_root_dir, output_stem) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{output_stem}_vna_bscan_history.json" if output_path.exists(): raise FileExistsError(f"Output JSON file already exists: {output_path}") @@ -253,6 +260,7 @@ class NpzStore(StoreApi): summary["channel"] = str(channel) summary["primary_stage"] = str(primary_stage) summary["output_stem"] = output_stem + summary["output_dir"] = str(output_dir) summary["output_path"] = str(output_path) return output_path, summary @@ -275,6 +283,8 @@ class NpzStore(StoreApi): output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S") output_stem = sanitize_path_component(output_stem) output_root_dir.mkdir(parents=True, exist_ok=True) + output_dir = self._vna_json_output_dir(output_root_dir, output_stem) + output_dir.mkdir(parents=True, exist_ok=True) selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories( raw_history, @@ -296,7 +306,7 @@ class NpzStore(StoreApi): output_paths: list[Path] = [] payloads: list[dict[str, Any]] = [] for input_index, output_index in combos: - output_path = output_root_dir / ( + output_path = output_dir / ( f"{output_stem}_i{input_index}_o{output_index}_{channel}_vna_bscan_history.json" ) if output_path.exists(): @@ -330,6 +340,7 @@ class NpzStore(StoreApi): summary["channel"] = str(channel) summary["primary_stage"] = str(primary_stage) summary["output_stem"] = output_stem + summary["output_dir"] = str(output_dir) summary["output_paths"] = [str(path) for path in output_paths] return output_paths, summary diff --git a/run_config.json b/run_config.json index 6dea05f..195fd4f 100644 --- a/run_config.json +++ b/run_config.json @@ -93,6 +93,18 @@ "set_name": "", "bundle_path": "" } + }, + "notch": { + "enabled": true, + "bands_hz": [ + [790000000.0, 850000000.0], + [1790000000.0, 1890000000.0], + [1970000000.0, 2050000000.0], + [2080000000.0, 2560000000.0], + [2650000000.0, 2700000000.0] + ], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" } }, "gpr": {