From d7393d9e38bdac528980d2c4d0c76e774370720d Mon Sep 17 00:00:00 2001 From: ShehaoLi Date: Sat, 7 Mar 2026 09:10:55 -0800 Subject: [PATCH 1/3] Refactor sweep flow and persistence boundaries --- src/app/application/ports/__init__.py | 11 + src/app/application/ports/instruments.py | 36 +++ src/app/application/ports/persistence.py | 20 ++ .../services/connection_monitor.py | 2 +- .../application/services/sweep/__init__.py | 17 ++ .../services/sweep/calibration_applier.py | 19 ++ .../services/sweep/instrument_configurator.py | 45 ++++ src/app/application/services/sweep/models.py | 31 +++ src/app/application/services/sweep/planner.py | 13 ++ .../sweep/point_measurement_service.py | 42 ++++ .../services/sweep/waveform_acquirer.py | 93 ++++++++ .../application/use_cases/load_measurement.py | 2 +- .../application/use_cases/load_reference.py | 2 +- .../application/use_cases/save_measurement.py | 2 +- .../use_cases/settings_use_case.py | 2 +- src/app/application/use_cases/start_sweep.py | 221 ++++-------------- src/app/domain/auto_range.py | 67 ++++++ .../infrastructure/instruments/awg_adapter.py | 14 +- .../instruments/equips_factory.py | 2 +- .../infrastructure/instruments/osc_adapter.py | 14 +- src/app/infrastructure/instruments/ports.py | 37 +-- .../instruments/vendor_gateway.py | 12 + .../infrastructure/persistence/file_store.py | 18 ++ .../persistence/measurement_exporter.py | 83 +++++++ .../persistence/measurement_loader.py | 82 +++++++ .../persistence/measurement_repo_mat_csv.py | 163 ++----------- .../persistence/repository_ports.py | 21 +- .../persistence/settings_defaults.py | 60 +++++ .../persistence/settings_repo_json.py | 188 ++------------- .../persistence/settings_serializer.py | 94 ++++++++ src/app/presentation/tk/app_window.py | 2 +- src/app/presentation/tk/controller.py | 197 ++++------------ src/app/presentation/tk/mapper.py | 2 +- src/app/presentation/tk/plot_widget.py | 4 +- src/app/presentation/tk/sweep_task_runner.py | 105 +++++++++ src/app/presentation/tk/ui_event_handler.py | 88 +++++++ src/app/presentation/tk/view_model.py | 2 +- src/app/shared/__init__.py | 4 + src/app/shared/cvt_tools.py | 126 ++++++++++ src/app/shared/mapping.py | 170 ++++++++++++++ src/cvtTools.py | 132 +---------- src/mapping.py | 217 +---------------- tests/test_auto_range_policy.py | 54 +++++ tests/test_measurement_io.py | 47 ++++ tests/test_point_measurement_services.py | 126 ++++++++++ tests/test_settings_serializer.py | 30 +++ tests/test_sweep_planner_service.py | 79 +++++++ tests/test_sweep_task_runner.py | 86 +++++++ 48 files changed, 1806 insertions(+), 1078 deletions(-) create mode 100644 src/app/application/ports/__init__.py create mode 100644 src/app/application/ports/instruments.py create mode 100644 src/app/application/ports/persistence.py create mode 100644 src/app/application/services/sweep/__init__.py create mode 100644 src/app/application/services/sweep/calibration_applier.py create mode 100644 src/app/application/services/sweep/instrument_configurator.py create mode 100644 src/app/application/services/sweep/models.py create mode 100644 src/app/application/services/sweep/planner.py create mode 100644 src/app/application/services/sweep/point_measurement_service.py create mode 100644 src/app/application/services/sweep/waveform_acquirer.py create mode 100644 src/app/domain/auto_range.py create mode 100644 src/app/infrastructure/instruments/vendor_gateway.py create mode 100644 src/app/infrastructure/persistence/file_store.py create mode 100644 src/app/infrastructure/persistence/measurement_exporter.py create mode 100644 src/app/infrastructure/persistence/measurement_loader.py create mode 100644 src/app/infrastructure/persistence/settings_defaults.py create mode 100644 src/app/infrastructure/persistence/settings_serializer.py create mode 100644 src/app/presentation/tk/sweep_task_runner.py create mode 100644 src/app/presentation/tk/ui_event_handler.py create mode 100644 src/app/shared/__init__.py create mode 100644 src/app/shared/cvt_tools.py create mode 100644 src/app/shared/mapping.py create mode 100644 tests/test_auto_range_policy.py create mode 100644 tests/test_measurement_io.py create mode 100644 tests/test_point_measurement_services.py create mode 100644 tests/test_settings_serializer.py create mode 100644 tests/test_sweep_planner_service.py create mode 100644 tests/test_sweep_task_runner.py diff --git a/src/app/application/ports/__init__.py b/src/app/application/ports/__init__.py new file mode 100644 index 0000000..b4aa88b --- /dev/null +++ b/src/app/application/ports/__init__.py @@ -0,0 +1,11 @@ +from app.application.ports.instruments import AwgPort, OscPort, ResourceScannerPort +from app.application.ports.persistence import MeasurementRepository, ReferenceRepository, SettingsRepository + +__all__ = [ + "AwgPort", + "MeasurementRepository", + "OscPort", + "ReferenceRepository", + "ResourceScannerPort", + "SettingsRepository", +] diff --git a/src/app/application/ports/instruments.py b/src/app/application/ports/instruments.py new file mode 100644 index 0000000..cdc027b --- /dev/null +++ b/src/app/application/ports/instruments.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Protocol + +import numpy as np + + +class AwgPort(Protocol): + def reset(self) -> None: ... + def output_on(self, channel: int) -> None: ... + def set_impedance(self, mode: str, channel: int) -> None: ... + def set_frequency(self, hz: float, channel: int) -> None: ... + def get_frequency(self, channel: int) -> float: ... + def set_amplitude_vpp(self, vpp: float, channel: int) -> None: ... + def get_amplitude_vpp(self, channel: int) -> float: ... + def close(self) -> None: ... + + +class OscPort(Protocol): + def reset(self) -> None: ... + def output_on(self, channel: int) -> None: ... + def set_timebase(self, window_s: float, offset_s: float | None = None) -> None: ... + def set_vertical(self, channel: int, full_scale_v: float, offset_v: float) -> None: ... + def get_vertical(self, channel: int) -> tuple[float, float]: ... + def set_coupling(self, channel: int, mode: str) -> None: ... + def set_impedance(self, channel: int, mode: str) -> None: ... + def arm_trigger(self, channel: int, level_v: float) -> None: ... + def set_free_run(self) -> None: ... + def single_acquire(self, triggered: bool) -> None: ... + def read_waveform(self, channel: int, points: int | None) -> tuple[np.ndarray, np.ndarray]: ... + def get_sample_rate(self) -> float: ... + def close(self) -> None: ... + + +class ResourceScannerPort(Protocol): + def list_resources(self) -> tuple[str, ...]: ... diff --git a/src/app/application/ports/persistence.py b/src/app/application/ports/persistence.py new file mode 100644 index 0000000..7eaa96f --- /dev/null +++ b/src/app/application/ports/persistence.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Protocol + +from app.application.dto import LoadedMeasurement, SaveArtifacts, SaveTarget +from app.domain.models import AppSettings, ReferenceCurve, SweepResult + + +class SettingsRepository(Protocol): + def load(self) -> AppSettings: ... + def save(self, settings: AppSettings) -> None: ... + + +class MeasurementRepository(Protocol): + def save(self, result: SweepResult, settings: AppSettings, target: SaveTarget) -> SaveArtifacts: ... + def load(self, file_path: str) -> LoadedMeasurement: ... + + +class ReferenceRepository(Protocol): + def load_reference(self, file_path: str) -> ReferenceCurve: ... diff --git a/src/app/application/services/connection_monitor.py b/src/app/application/services/connection_monitor.py index 0aa901c..a8aeb32 100644 --- a/src/app/application/services/connection_monitor.py +++ b/src/app/application/services/connection_monitor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from app.application.events import ConnectionStatusUpdated, EventEmitter -from app.infrastructure.instruments.ports import ResourceScannerPort +from app.application.ports.instruments import ResourceScannerPort class ConnectionMonitor: diff --git a/src/app/application/services/sweep/__init__.py b/src/app/application/services/sweep/__init__.py new file mode 100644 index 0000000..472b809 --- /dev/null +++ b/src/app/application/services/sweep/__init__.py @@ -0,0 +1,17 @@ +from app.application.services.sweep.calibration_applier import CalibrationApplier +from app.application.services.sweep.instrument_configurator import InstrumentConfigurator +from app.application.services.sweep.models import AcquiredPointData, SweepPlan, SweepServiceWarning +from app.application.services.sweep.planner import SweepPlanner +from app.application.services.sweep.point_measurement_service import PointMeasurementService +from app.application.services.sweep.waveform_acquirer import WaveformAcquirer + +__all__ = [ + "AcquiredPointData", + "CalibrationApplier", + "InstrumentConfigurator", + "PointMeasurementService", + "SweepPlan", + "SweepPlanner", + "SweepServiceWarning", + "WaveformAcquirer", +] diff --git a/src/app/application/services/sweep/calibration_applier.py b/src/app/application/services/sweep/calibration_applier.py new file mode 100644 index 0000000..89f0a12 --- /dev/null +++ b/src/app/application/services/sweep/calibration_applier.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import numpy as np + +from app.application.dto import StartSweepCommand +from app.domain.calibration import apply_reference_to_point +from app.domain.enums import CorrectionMode, TriggerMode +from app.domain.models import SweepPoint + + +class CalibrationApplier: + def apply(self, *, point: SweepPoint, cmd: StartSweepCommand) -> SweepPoint: + if not cmd.calibration_enabled or cmd.reference_interpolator is None: + return point + + run_mode = cmd.settings.run_mode + use_phase = run_mode.correction_mode == CorrectionMode.DUAL or run_mode.trigger_mode == TriggerMode.TRIGGERED + ref_value = cmd.reference_interpolator(np.array([point.freq_hz]))[0] + return apply_reference_to_point(point, ref_value, use_phase=use_phase) diff --git a/src/app/application/services/sweep/instrument_configurator.py b/src/app/application/services/sweep/instrument_configurator.py new file mode 100644 index 0000000..45fe797 --- /dev/null +++ b/src/app/application/services/sweep/instrument_configurator.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from app.application.ports.instruments import AwgPort, OscPort +from app.domain.enums import CorrectionMode, TriggerMode +from app.domain.models import AppSettings + + +class InstrumentConfigurator: + def __init__(self, awg: AwgPort, osc: OscPort) -> None: + self._awg = awg + self._osc = osc + + def configure(self, settings: AppSettings) -> None: + setup = settings.setup + run_mode = settings.run_mode + + awg_ch = setup.channels.awg_ch + test_ch = setup.channels.osc_test_ch + + if run_mode.auto_reset: + self._awg.reset() + self._osc.reset() + + self._awg.output_on(awg_ch) + self._awg.set_impedance(setup.awg_settings.impedance.value, awg_ch) + self._awg.set_amplitude_vpp(setup.awg_settings.amplitude_vpp, awg_ch) + + self._osc.output_on(test_ch) + self._osc.set_coupling(test_ch, setup.osc_settings.coupling.value) + self._osc.set_impedance(test_ch, setup.osc_settings.impedance.value) + self._osc.set_vertical(test_ch, setup.osc_settings.full_scale_v, setup.osc_settings.offset_v) + + if run_mode.correction_mode == CorrectionMode.DUAL and setup.channels.osc_ref_ch: + ref_ch = setup.channels.osc_ref_ch + self._osc.output_on(ref_ch) + self._osc.set_coupling(ref_ch, setup.osc_settings.coupling.value) + self._osc.set_impedance(ref_ch, setup.osc_settings.impedance.value) + self._osc.set_vertical(ref_ch, setup.osc_settings.full_scale_v, setup.osc_settings.offset_v) + + if run_mode.trigger_mode == TriggerMode.TRIGGERED: + trig_ch = int(setup.channels.osc_trig_ch or test_ch) + self._osc.output_on(trig_ch) + self._osc.arm_trigger(trig_ch, level_v=0.0) + else: + self._osc.set_free_run() diff --git a/src/app/application/services/sweep/models.py b/src/app/application/services/sweep/models.py new file mode 100644 index 0000000..5d4fe44 --- /dev/null +++ b/src/app/application/services/sweep/models.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + + +@dataclass(slots=True) +class SweepPlan: + freq_points: np.ndarray + + @property + def total_points(self) -> int: + return int(len(self.freq_points)) + + +@dataclass(slots=True) +class SweepServiceWarning: + code: str + message: str + + +@dataclass(slots=True) +class AcquiredPointData: + actual_freq_hz: float + read_amp_vpp: float + test_times: np.ndarray + test_volts: np.ndarray + ref_times: np.ndarray | None = None + ref_volts: np.ndarray | None = None + warnings: list[SweepServiceWarning] = field(default_factory=list) diff --git a/src/app/application/services/sweep/planner.py b/src/app/application/services/sweep/planner.py new file mode 100644 index 0000000..95f3c98 --- /dev/null +++ b/src/app/application/services/sweep/planner.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from app.application.services.sweep.models import SweepPlan +from app.domain.models import AppSettings +from app.domain.sweep_engine import compute_sampling_window_s, generate_frequency_points + + +class SweepPlanner: + def plan(self, settings: AppSettings) -> SweepPlan: + return SweepPlan(freq_points=generate_frequency_points(settings.sweep)) + + def compute_sampling_window_s(self, *, freq_hz: float, sample_rate_hz: float, points: int) -> float: + return compute_sampling_window_s(freq_hz=freq_hz, sample_rate_hz=sample_rate_hz, points=points) diff --git a/src/app/application/services/sweep/point_measurement_service.py b/src/app/application/services/sweep/point_measurement_service.py new file mode 100644 index 0000000..3bec594 --- /dev/null +++ b/src/app/application/services/sweep/point_measurement_service.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from app.application.services.sweep.models import AcquiredPointData +from app.domain.enums import CorrectionMode, TriggerMode +from app.domain.models import AppSettings, SweepPoint +from app.domain.signal_processing import calc_vin_peak, measure_dual_channel, measure_single_channel + + +class PointMeasurementService: + def measure(self, *, settings: AppSettings, acquired: AcquiredPointData) -> SweepPoint: + setup = settings.setup + run_mode = settings.run_mode + + if run_mode.correction_mode == CorrectionMode.DUAL: + gain_linear, gain_db, phase_deg, gain_complex = measure_dual_channel( + acquired.test_times, + acquired.test_volts, + acquired.ref_times if acquired.ref_times is not None else acquired.test_times, + acquired.ref_volts if acquired.ref_volts is not None else acquired.test_volts, + acquired.actual_freq_hz, + ) + else: + vin_peak = calc_vin_peak( + vpp_panel=acquired.read_amp_vpp, + awg_impedance=setup.awg_settings.impedance.value, + osc_impedance=setup.osc_settings.impedance.value, + ) + gain_linear, gain_db, phase_deg, gain_complex = measure_single_channel( + acquired.test_times, + acquired.test_volts, + acquired.actual_freq_hz, + vin_peak, + compute_phase=run_mode.trigger_mode == TriggerMode.TRIGGERED, + ) + + return SweepPoint( + freq_hz=float(acquired.actual_freq_hz), + gain_linear=float(gain_linear), + gain_db=float(gain_db), + phase_deg=float(phase_deg) if phase_deg is not None else None, + gain_complex=complex(gain_complex) if gain_complex is not None else None, + ) diff --git a/src/app/application/services/sweep/waveform_acquirer.py b/src/app/application/services/sweep/waveform_acquirer.py new file mode 100644 index 0000000..f6b06d6 --- /dev/null +++ b/src/app/application/services/sweep/waveform_acquirer.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import numpy as np + +from app.application.ports.instruments import AwgPort, OscPort +from app.application.services.sweep.models import AcquiredPointData, SweepServiceWarning +from app.application.services.sweep.planner import SweepPlanner +from app.domain.auto_range import AutoRangePolicy +from app.domain.enums import CorrectionMode, TriggerMode +from app.domain.models import AppSettings + + +class WaveformAcquirer: + def __init__( + self, + awg: AwgPort, + osc: OscPort, + planner: SweepPlanner, + auto_range_policy: AutoRangePolicy, + ) -> None: + self._awg = awg + self._osc = osc + self._planner = planner + self._auto_range_policy = auto_range_policy + + def acquire(self, *, target_freq_hz: float, settings: AppSettings) -> AcquiredPointData: + setup = settings.setup + run_mode = settings.run_mode + + warnings: list[SweepServiceWarning] = [] + awg_ch = setup.channels.awg_ch + self._awg.set_frequency(float(target_freq_hz), awg_ch) + actual_freq = self._awg.get_frequency(awg_ch) + if not np.isclose(actual_freq, target_freq_hz, atol=1e-3, rtol=5e-6): + warnings.append( + SweepServiceWarning( + code="FREQ_MISMATCH", + message=f"Requested {target_freq_hz:.6f} Hz, actual {actual_freq:.6f} Hz", + ) + ) + + requested_amp = float(setup.awg_settings.amplitude_vpp) + read_amp = self._awg.get_amplitude_vpp(awg_ch) + if not np.isclose(read_amp, requested_amp, atol=1e-2, rtol=1e-3): + warnings.append( + SweepServiceWarning( + code="AMP_MISMATCH", + message=f"Requested {requested_amp:.6f} Vpp, actual {read_amp:.6f} Vpp", + ) + ) + + sample_rate = self._osc.get_sample_rate() + window_s = self._planner.compute_sampling_window_s( + freq_hz=actual_freq, + sample_rate_hz=sample_rate, + points=setup.osc_settings.points, + ) + + self._osc.set_timebase(window_s) + triggered = run_mode.trigger_mode == TriggerMode.TRIGGERED + self._osc.single_acquire(triggered=triggered) + + test_ch = setup.channels.osc_test_ch + times_t, volts_t = self._osc.read_waveform(test_ch, setup.osc_settings.points) + + if run_mode.auto_range: + current_range, current_offset = self._osc.get_vertical(test_ch) + decision = self._auto_range_policy.decide( + volts=volts_t, + current_range_v=current_range, + current_offset_v=current_offset, + requested_offset_v=setup.osc_settings.offset_v, + ) + if decision.changed: + self._osc.set_vertical(test_ch, decision.target_range_v, decision.target_offset_v) + self._osc.single_acquire(triggered=triggered) + times_t, volts_t = self._osc.read_waveform(test_ch, setup.osc_settings.points) + + ref_times = None + ref_volts = None + if run_mode.correction_mode == CorrectionMode.DUAL: + ref_ch = int(setup.channels.osc_ref_ch or test_ch) + ref_times, ref_volts = self._osc.read_waveform(ref_ch, setup.osc_settings.points) + + return AcquiredPointData( + actual_freq_hz=float(actual_freq), + read_amp_vpp=float(read_amp), + test_times=np.asarray(times_t, dtype=float), + test_volts=np.asarray(volts_t, dtype=float), + ref_times=None if ref_times is None else np.asarray(ref_times, dtype=float), + ref_volts=None if ref_volts is None else np.asarray(ref_volts, dtype=float), + warnings=warnings, + ) diff --git a/src/app/application/use_cases/load_measurement.py b/src/app/application/use_cases/load_measurement.py index 766f35e..30552b1 100644 --- a/src/app/application/use_cases/load_measurement.py +++ b/src/app/application/use_cases/load_measurement.py @@ -1,7 +1,7 @@ from __future__ import annotations from app.application.dto import LoadedMeasurement -from app.infrastructure.persistence.repository_ports import MeasurementRepository +from app.application.ports.persistence import MeasurementRepository class LoadMeasurementUseCase: diff --git a/src/app/application/use_cases/load_reference.py b/src/app/application/use_cases/load_reference.py index 6689ac0..f46c695 100644 --- a/src/app/application/use_cases/load_reference.py +++ b/src/app/application/use_cases/load_reference.py @@ -1,8 +1,8 @@ from __future__ import annotations +from app.application.ports.persistence import ReferenceRepository from app.domain.calibration import build_reference_interpolator from app.domain.models import ReferenceCurve -from app.infrastructure.persistence.repository_ports import ReferenceRepository class LoadReferenceUseCase: diff --git a/src/app/application/use_cases/save_measurement.py b/src/app/application/use_cases/save_measurement.py index 557afb7..49fe1c4 100644 --- a/src/app/application/use_cases/save_measurement.py +++ b/src/app/application/use_cases/save_measurement.py @@ -1,8 +1,8 @@ from __future__ import annotations from app.application.dto import SaveArtifacts, SaveTarget +from app.application.ports.persistence import MeasurementRepository from app.domain.models import AppSettings, SweepResult -from app.infrastructure.persistence.repository_ports import MeasurementRepository class SaveMeasurementUseCase: diff --git a/src/app/application/use_cases/settings_use_case.py b/src/app/application/use_cases/settings_use_case.py index b9c98c8..d11f25d 100644 --- a/src/app/application/use_cases/settings_use_case.py +++ b/src/app/application/use_cases/settings_use_case.py @@ -1,7 +1,7 @@ from __future__ import annotations +from app.application.ports.persistence import SettingsRepository from app.domain.models import AppSettings -from app.infrastructure.persistence.repository_ports import SettingsRepository class SettingsUseCase: diff --git a/src/app/application/use_cases/start_sweep.py b/src/app/application/use_cases/start_sweep.py index 08415a5..a4d9394 100644 --- a/src/app/application/use_cases/start_sweep.py +++ b/src/app/application/use_cases/start_sweep.py @@ -4,33 +4,57 @@ import time from datetime import datetime, timezone -import numpy as np - +from app.application.dto import StartSweepCommand from app.application.dto import StartSweepCommand from app.application.events import ( EventEmitter, SweepCompleted, - SweepDataUpdated, SweepFailed, + SweepDataUpdated, SweepProgress, SweepStarted, SweepStopped, SweepWarning, ) -from app.domain.calibration import apply_reference_to_point -from app.domain.enums import CorrectionMode, TriggerMode -from app.domain.models import SweepPoint, SweepResult -from app.domain.signal_processing import calc_vin_peak, measure_dual_channel, measure_single_channel -from app.domain.sweep_engine import compute_sampling_window_s, generate_frequency_points +from app.application.ports.instruments import AwgPort, OscPort +from app.application.services.sweep import ( + CalibrationApplier, + InstrumentConfigurator, + PointMeasurementService, + SweepPlanner, + WaveformAcquirer, +) +from app.domain.auto_range import AutoRangePolicy +from app.domain.models import SweepResult from app.domain.validators import ValidationError, validate_settings -from app.infrastructure.instruments.ports import AwgPort, OscPort class StartSweepUseCase: - def __init__(self, awg: AwgPort, osc: OscPort, stop_event: threading.Event) -> None: + def __init__( + self, + awg: AwgPort, + osc: OscPort, + stop_event: threading.Event, + *, + planner: SweepPlanner | None = None, + configurator: InstrumentConfigurator | None = None, + acquirer: WaveformAcquirer | None = None, + measurement_service: PointMeasurementService | None = None, + calibration_applier: CalibrationApplier | None = None, + ) -> None: self._awg = awg self._osc = osc self._stop_event = stop_event + self._planner = planner or SweepPlanner() + self._configurator = configurator or InstrumentConfigurator(awg=awg, osc=osc) + self._acquirer = acquirer or WaveformAcquirer( + awg=awg, + osc=osc, + planner=self._planner, + auto_range_policy=AutoRangePolicy(), + ) + self._measurement_service = measurement_service or PointMeasurementService() + self._calibration_applier = calibration_applier or CalibrationApplier() def run(self, cmd: StartSweepCommand, emitter: EventEmitter) -> SweepResult: try: @@ -44,111 +68,30 @@ def run(self, cmd: StartSweepCommand, emitter: EventEmitter) -> SweepResult: ) settings = cmd.settings - sweep = settings.sweep - setup = settings.setup - run_mode = settings.run_mode - - freq_points = generate_frequency_points(sweep) - emitter.emit(SweepStarted(total_points=len(freq_points))) + plan = self._planner.plan(settings) + emitter.emit(SweepStarted(total_points=plan.total_points)) - self._configure_instruments(cmd, emitter) + self._configurator.configure(settings) + emitter.emit(SweepWarning(code="READY", message="Instruments configured")) - for index, target_freq in enumerate(freq_points, start=1): + for index, target_freq in enumerate(plan.freq_points, start=1): if self._stop_event.is_set(): result.meta["stopped_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") emitter.emit(SweepStopped(result=result)) return result - awg_ch = setup.channels.awg_ch - self._awg.set_frequency(float(target_freq), awg_ch) - actual_freq = self._awg.get_frequency(awg_ch) - - if not np.isclose(actual_freq, target_freq, atol=1e-3, rtol=5e-6): - emitter.emit( - SweepWarning( - code="FREQ_MISMATCH", - message=( - f"Requested {target_freq:.6f} Hz, actual {actual_freq:.6f} Hz" - ), - ) - ) - - requested_amp = float(setup.awg_settings.amplitude_vpp) - read_amp = self._awg.get_amplitude_vpp(awg_ch) - if not np.isclose(read_amp, requested_amp, atol=1e-2, rtol=1e-3): - emitter.emit( - SweepWarning( - code="AMP_MISMATCH", - message=( - f"Requested {requested_amp:.6f} Vpp, actual {read_amp:.6f} Vpp" - ), - ) - ) + acquired = self._acquirer.acquire(target_freq_hz=float(target_freq), settings=settings) + for warning in acquired.warnings: + emitter.emit(SweepWarning(code=warning.code, message=warning.message)) - sample_rate = self._osc.get_sample_rate() - window_s = compute_sampling_window_s( - freq_hz=actual_freq, - sample_rate_hz=sample_rate, - points=setup.osc_settings.points, - ) - - self._osc.set_timebase(window_s) - triggered = run_mode.trigger_mode == TriggerMode.TRIGGERED - self._osc.single_acquire(triggered=triggered) - - test_ch = setup.channels.osc_test_ch - times_t, volts_t = self._osc.read_waveform(test_ch, setup.osc_settings.points) - - if run_mode.auto_range and self._adjust_auto_range(test_ch, volts_t, setup.osc_settings.offset_v): - self._osc.single_acquire(triggered=triggered) - times_t, volts_t = self._osc.read_waveform(test_ch, setup.osc_settings.points) - - if run_mode.correction_mode == CorrectionMode.DUAL: - ref_ch = int(setup.channels.osc_ref_ch or test_ch) - times_r, volts_r = self._osc.read_waveform(ref_ch, setup.osc_settings.points) - gain_linear, gain_db, phase_deg, gain_complex = measure_dual_channel( - times_t, - volts_t, - times_r, - volts_r, - actual_freq, - ) - else: - vin_peak = calc_vin_peak( - vpp_panel=read_amp, - awg_impedance=setup.awg_settings.impedance.value, - osc_impedance=setup.osc_settings.impedance.value, - ) - gain_linear, gain_db, phase_deg, gain_complex = measure_single_channel( - times_t, - volts_t, - actual_freq, - vin_peak, - compute_phase=triggered, - ) - - point = SweepPoint( - freq_hz=float(actual_freq), - gain_linear=float(gain_linear), - gain_db=float(gain_db), - phase_deg=float(phase_deg) if phase_deg is not None else None, - gain_complex=complex(gain_complex) if gain_complex is not None else None, - ) - - if cmd.calibration_enabled and cmd.reference_interpolator is not None: - use_phase = ( - run_mode.correction_mode == CorrectionMode.DUAL - or run_mode.trigger_mode == TriggerMode.TRIGGERED - ) - ref_value = cmd.reference_interpolator(np.array([point.freq_hz]))[0] - point = apply_reference_to_point(point, ref_value, use_phase=use_phase) + point = self._measurement_service.measure(settings=settings, acquired=acquired) + point = self._calibration_applier.apply(point=point, cmd=cmd) result.append(point) - emitter.emit(SweepProgress(freq_hz=point.freq_hz, point_index=index, total_points=len(freq_points))) + emitter.emit(SweepProgress(freq_hz=point.freq_hz, point_index=index, total_points=plan.total_points)) emitter.emit(SweepDataUpdated(last_point=point, partial_result=result)) - # Give stop signals a chance to be observed in long hardware loops. time.sleep(0.001) result.meta["completed_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") @@ -161,77 +104,3 @@ def run(self, cmd: StartSweepCommand, emitter: EventEmitter) -> SweepResult: except Exception as exc: # noqa: BLE001 emitter.emit(SweepFailed(error_code="SWEEP_RUNTIME", message=str(exc))) return SweepResult() - - def _configure_instruments(self, cmd: StartSweepCommand, emitter: EventEmitter) -> None: - settings = cmd.settings - setup = settings.setup - run_mode = settings.run_mode - - awg_ch = setup.channels.awg_ch - test_ch = setup.channels.osc_test_ch - - if run_mode.auto_reset: - self._awg.reset() - self._osc.reset() - - self._awg.output_on(awg_ch) - self._awg.set_impedance(setup.awg_settings.impedance.value, awg_ch) - self._awg.set_amplitude_vpp(setup.awg_settings.amplitude_vpp, awg_ch) - - self._osc.output_on(test_ch) - self._osc.set_coupling(test_ch, setup.osc_settings.coupling.value) - self._osc.set_impedance(test_ch, setup.osc_settings.impedance.value) - self._osc.set_vertical(test_ch, setup.osc_settings.full_scale_v, setup.osc_settings.offset_v) - - if run_mode.correction_mode == CorrectionMode.DUAL and setup.channels.osc_ref_ch: - self._osc.output_on(setup.channels.osc_ref_ch) - self._osc.set_coupling(setup.channels.osc_ref_ch, setup.osc_settings.coupling.value) - self._osc.set_impedance(setup.channels.osc_ref_ch, setup.osc_settings.impedance.value) - self._osc.set_vertical( - setup.channels.osc_ref_ch, - setup.osc_settings.full_scale_v, - setup.osc_settings.offset_v, - ) - - if run_mode.trigger_mode == TriggerMode.TRIGGERED: - trig_ch = int(setup.channels.osc_trig_ch or test_ch) - self._osc.output_on(trig_ch) - self._osc.arm_trigger(trig_ch, level_v=0.0) - else: - self._osc.set_free_run() - - emitter.emit(SweepWarning(code="READY", message="Instruments configured")) - - def _adjust_auto_range(self, channel: int, volts: np.ndarray, requested_offset_v: float) -> bool: - if volts is None or len(volts) == 0: - return False - - vmax = float(np.max(volts)) - vmin = float(np.min(volts)) - vpp = vmax - vmin - midpoint = (vmax + vmin) / 2.0 - - current_range, current_offset = self._osc.get_vertical(channel) - if current_range <= 0: - return False - - ratio = vpp / current_range - target_range = current_range - target_offset = requested_offset_v - - if ratio > 0.85: - target_range = vpp / 0.7 - elif 0.0 < ratio < 0.55: - target_range = max(vpp / 0.7, current_range * 0.5) - - if abs(midpoint - current_offset) > (current_range * 0.2): - target_offset = midpoint - - range_changed = not np.isclose(target_range, current_range, rtol=1e-2, atol=1e-3) - offset_changed = not np.isclose(target_offset, current_offset, rtol=1e-2, atol=1e-3) - - if range_changed or offset_changed: - self._osc.set_vertical(channel, float(target_range), float(target_offset)) - return True - - return False diff --git a/src/app/domain/auto_range.py b/src/app/domain/auto_range.py new file mode 100644 index 0000000..079a3df --- /dev/null +++ b/src/app/domain/auto_range.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(slots=True) +class AutoRangeDecision: + changed: bool + target_range_v: float + target_offset_v: float + + +class AutoRangePolicy: + def __init__( + self, + *, + high_threshold: float = 0.85, + low_threshold: float = 0.55, + target_fill_ratio: float = 0.7, + offset_threshold_ratio: float = 0.2, + ) -> None: + self._high_threshold = high_threshold + self._low_threshold = low_threshold + self._target_fill_ratio = target_fill_ratio + self._offset_threshold_ratio = offset_threshold_ratio + + def decide( + self, + *, + volts: np.ndarray, + current_range_v: float, + current_offset_v: float, + requested_offset_v: float, + ) -> AutoRangeDecision: + if volts is None or len(volts) == 0 or current_range_v <= 0: + return AutoRangeDecision( + changed=False, + target_range_v=float(current_range_v), + target_offset_v=float(current_offset_v), + ) + + vmax = float(np.max(volts)) + vmin = float(np.min(volts)) + vpp = vmax - vmin + midpoint = (vmax + vmin) / 2.0 + + target_range = float(current_range_v) + target_offset = float(requested_offset_v) + ratio = vpp / current_range_v + + if ratio > self._high_threshold: + target_range = vpp / self._target_fill_ratio + elif 0.0 < ratio < self._low_threshold: + target_range = max(vpp / self._target_fill_ratio, current_range_v * 0.5) + + if abs(midpoint - current_offset_v) > (current_range_v * self._offset_threshold_ratio): + target_offset = midpoint + + range_changed = not np.isclose(target_range, current_range_v, rtol=1e-2, atol=1e-3) + offset_changed = not np.isclose(target_offset, current_offset_v, rtol=1e-2, atol=1e-3) + return AutoRangeDecision( + changed=bool(range_changed or offset_changed), + target_range_v=float(target_range), + target_offset_v=float(target_offset), + ) diff --git a/src/app/infrastructure/instruments/awg_adapter.py b/src/app/infrastructure/instruments/awg_adapter.py index 4c256da..00af7ab 100644 --- a/src/app/infrastructure/instruments/awg_adapter.py +++ b/src/app/infrastructure/instruments/awg_adapter.py @@ -1,17 +1,11 @@ from __future__ import annotations +from app.infrastructure.instruments.vendor_gateway import create_vendor_instrument + + class EquipsAwgAdapter: def __init__(self, model: str, visa_address: str) -> None: - try: - from equips import inst_mapping - except ModuleNotFoundError as exc: - raise RuntimeError( - "Missing runtime dependency: pyvisa/equips is required for instrument access" - ) from exc - - if model not in inst_mapping: - raise ValueError(f"Unsupported AWG model: {model}") - self._inst = inst_mapping[model](name=model, visa_address=visa_address) + self._inst = create_vendor_instrument(model=model, visa_address=visa_address) def reset(self) -> None: self._inst.rst() diff --git a/src/app/infrastructure/instruments/equips_factory.py b/src/app/infrastructure/instruments/equips_factory.py index 25bae2a..7ce5794 100644 --- a/src/app/infrastructure/instruments/equips_factory.py +++ b/src/app/infrastructure/instruments/equips_factory.py @@ -2,11 +2,11 @@ from dataclasses import dataclass +from app.application.ports.instruments import AwgPort, OscPort from app.domain.enums import ConnectionMode from app.domain.models import InstrumentEndpoint, InstrumentSetup from app.infrastructure.instruments.awg_adapter import EquipsAwgAdapter from app.infrastructure.instruments.osc_adapter import EquipsOscAdapter -from app.infrastructure.instruments.ports import AwgPort, OscPort @dataclass(slots=True) diff --git a/src/app/infrastructure/instruments/osc_adapter.py b/src/app/infrastructure/instruments/osc_adapter.py index 51827fd..2422bb6 100644 --- a/src/app/infrastructure/instruments/osc_adapter.py +++ b/src/app/infrastructure/instruments/osc_adapter.py @@ -2,18 +2,12 @@ import numpy as np +from app.infrastructure.instruments.vendor_gateway import create_vendor_instrument + + class EquipsOscAdapter: def __init__(self, model: str, visa_address: str) -> None: - try: - from equips import inst_mapping - except ModuleNotFoundError as exc: - raise RuntimeError( - "Missing runtime dependency: pyvisa/equips is required for instrument access" - ) from exc - - if model not in inst_mapping: - raise ValueError(f"Unsupported OSC model: {model}") - self._inst = inst_mapping[model](name=model, visa_address=visa_address) + self._inst = create_vendor_instrument(model=model, visa_address=visa_address) def reset(self) -> None: self._inst.rst() diff --git a/src/app/infrastructure/instruments/ports.py b/src/app/infrastructure/instruments/ports.py index cdc027b..c41f0bd 100644 --- a/src/app/infrastructure/instruments/ports.py +++ b/src/app/infrastructure/instruments/ports.py @@ -1,36 +1,3 @@ -from __future__ import annotations +from app.application.ports.instruments import AwgPort, OscPort, ResourceScannerPort -from typing import Protocol - -import numpy as np - - -class AwgPort(Protocol): - def reset(self) -> None: ... - def output_on(self, channel: int) -> None: ... - def set_impedance(self, mode: str, channel: int) -> None: ... - def set_frequency(self, hz: float, channel: int) -> None: ... - def get_frequency(self, channel: int) -> float: ... - def set_amplitude_vpp(self, vpp: float, channel: int) -> None: ... - def get_amplitude_vpp(self, channel: int) -> float: ... - def close(self) -> None: ... - - -class OscPort(Protocol): - def reset(self) -> None: ... - def output_on(self, channel: int) -> None: ... - def set_timebase(self, window_s: float, offset_s: float | None = None) -> None: ... - def set_vertical(self, channel: int, full_scale_v: float, offset_v: float) -> None: ... - def get_vertical(self, channel: int) -> tuple[float, float]: ... - def set_coupling(self, channel: int, mode: str) -> None: ... - def set_impedance(self, channel: int, mode: str) -> None: ... - def arm_trigger(self, channel: int, level_v: float) -> None: ... - def set_free_run(self) -> None: ... - def single_acquire(self, triggered: bool) -> None: ... - def read_waveform(self, channel: int, points: int | None) -> tuple[np.ndarray, np.ndarray]: ... - def get_sample_rate(self) -> float: ... - def close(self) -> None: ... - - -class ResourceScannerPort(Protocol): - def list_resources(self) -> tuple[str, ...]: ... +__all__ = ["AwgPort", "OscPort", "ResourceScannerPort"] diff --git a/src/app/infrastructure/instruments/vendor_gateway.py b/src/app/infrastructure/instruments/vendor_gateway.py new file mode 100644 index 0000000..b82d3c6 --- /dev/null +++ b/src/app/infrastructure/instruments/vendor_gateway.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +def create_vendor_instrument(model: str, visa_address: str) -> object: + try: + from equips import inst_mapping + except ModuleNotFoundError as exc: + raise RuntimeError("Missing runtime dependency: pyvisa/equips is required for instrument access") from exc + + if model not in inst_mapping: + raise ValueError(f"Unsupported instrument model: {model}") + return inst_mapping[model](name=model, visa_address=visa_address) diff --git a/src/app/infrastructure/persistence/file_store.py b/src/app/infrastructure/persistence/file_store.py new file mode 100644 index 0000000..bc2eca3 --- /dev/null +++ b/src/app/infrastructure/persistence/file_store.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + + +class FileStore: + def __init__(self, path: Path) -> None: + self.path = path + + def exists(self) -> bool: + return self.path.exists() + + def read_text(self, *, encoding: str = "utf-8") -> str: + return self.path.read_text(encoding=encoding) + + def write_text(self, data: str, *, encoding: str = "utf-8") -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(data, encoding=encoding) diff --git a/src/app/infrastructure/persistence/measurement_exporter.py b/src/app/infrastructure/persistence/measurement_exporter.py new file mode 100644 index 0000000..d5c48ff --- /dev/null +++ b/src/app/infrastructure/persistence/measurement_exporter.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import csv +import json +from datetime import datetime +from pathlib import Path + +import numpy as np +from scipy.io import savemat + +from app.application.dto import SaveArtifacts, SaveTarget +from app.domain.exporters import result_to_arrays, settings_to_metadata +from app.domain.models import AppSettings, SweepResult + + +class MeasurementExporter: + def export(self, result: SweepResult, settings: AppSettings, target: SaveTarget) -> SaveArtifacts: + directory = target.base_path.parent + directory.mkdir(parents=True, exist_ok=True) + + stem = target.base_path.stem if target.base_path.suffix else target.base_path.name + prefix = datetime.now().strftime("%Y%m%d_%H_%M_%S_") if target.include_timestamp else "" + file_base = f"{prefix}{stem}" + + mat_path = directory / f"{file_base}.mat" + csv_path = directory / f"{file_base}.csv" + txt_path = directory / f"{file_base}.txt" + + arrays = result_to_arrays(result) + payload: dict[str, object] = { + "schema_version": settings.schema_version, + "metadata_json": json.dumps(settings_to_metadata(settings), ensure_ascii=True), + } + payload.update(arrays) + savemat(mat_path, payload) + + freq = arrays.get("freq_hz", np.array([], dtype=float)) + gain_linear = arrays.get("gain_linear", np.array([], dtype=float)) + gain_db = arrays.get("gain_db", np.array([], dtype=float)) + phase = arrays.get("phase_deg", np.array([], dtype=float)) + + headers = ["freq_hz", "gain_linear", "gain_db", "phase_deg"] + with csv_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(headers) + for idx in range(len(freq)): + writer.writerow( + [ + float(freq[idx]), + float(gain_linear[idx]) if idx < len(gain_linear) else "", + float(gain_db[idx]) if idx < len(gain_db) else "", + float(phase[idx]) if idx < len(phase) else "", + ] + ) + + rows = np.column_stack( + [ + freq, + gain_linear if len(gain_linear) == len(freq) else np.full(len(freq), np.nan), + gain_db if len(gain_db) == len(freq) else np.full(len(freq), np.nan), + phase if len(phase) == len(freq) else np.full(len(freq), np.nan), + ] + ) + np.savetxt(txt_path, rows, delimiter="\t", header="\t".join(headers), comments="") + + gain_plot_path = None + db_plot_path = None + gain_fig = target.figures.get("gain") + db_fig = target.figures.get("db") + if gain_fig is not None: + gain_plot_path = directory / f"{file_base}_gain.png" + gain_fig.savefig(gain_plot_path, dpi=300) + if db_fig is not None: + db_plot_path = directory / f"{file_base}_gain_db.png" + db_fig.savefig(db_plot_path, dpi=300) + + return SaveArtifacts( + mat_path=mat_path, + csv_path=csv_path, + txt_path=txt_path, + gain_plot_path=gain_plot_path, + db_plot_path=db_plot_path, + ) diff --git a/src/app/infrastructure/persistence/measurement_loader.py b/src/app/infrastructure/persistence/measurement_loader.py new file mode 100644 index 0000000..cd87bba --- /dev/null +++ b/src/app/infrastructure/persistence/measurement_loader.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import csv +from pathlib import Path + +import numpy as np +from scipy.io import loadmat + +from app.application.dto import LoadedMeasurement +from app.domain.models import SweepPoint, SweepResult + + +class MeasurementLoader: + def load(self, file_path: str) -> LoadedMeasurement: + path = Path(file_path) + suffix = path.suffix.lower() + + if suffix == ".mat": + payload = loadmat(path) + freq = self._get_array(payload, ["freq_hz", "freq"]) # type: ignore[arg-type] + gain_linear = self._get_array(payload, ["gain_linear", "gain_raw"]) + gain_db = self._get_array(payload, ["gain_db", "gain_db_raw", "gain_db_corr"]) + phase = self._get_array(payload, ["phase_deg", "phase", "phase_deg_corr"], required=False) + elif suffix == ".csv": + freq_l: list[float] = [] + gain_l: list[float] = [] + gain_db_l: list[float] = [] + phase_l: list[float] = [] + with path.open("r", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row in reader: + freq_l.append(float(row.get("freq_hz", "0") or 0.0)) + gain_l.append(float(row.get("gain_linear", "0") or 0.0)) + gain_db_l.append(float(row.get("gain_db", "0") or 0.0)) + if row.get("phase_deg") not in (None, ""): + phase_l.append(float(row["phase_deg"])) + freq = np.array(freq_l, dtype=float) + gain_linear = np.array(gain_l, dtype=float) + gain_db = np.array(gain_db_l, dtype=float) + phase = np.array(phase_l, dtype=float) if phase_l else None + payload = {"freq_hz": freq, "gain_linear": gain_linear, "gain_db": gain_db} + if phase is not None: + payload["phase_deg"] = phase + else: + raise ValueError(f"Unsupported file type: {suffix}") + + points: list[SweepPoint] = [] + for idx in range(len(freq)): + phase_deg = float(phase[idx]) if phase is not None and idx < len(phase) else None + points.append( + SweepPoint( + freq_hz=float(freq[idx]), + gain_linear=float(gain_linear[idx]) if idx < len(gain_linear) else 0.0, + gain_db=float(gain_db[idx]) if idx < len(gain_db) else 0.0, + phase_deg=phase_deg, + gain_complex=( + complex( + float(gain_linear[idx]) * np.cos(np.deg2rad(phase_deg)), + float(gain_linear[idx]) * np.sin(np.deg2rad(phase_deg)), + ) + if phase_deg is not None and idx < len(gain_linear) + else None + ), + ) + ) + + return LoadedMeasurement(result=SweepResult(points=points), raw_payload=payload) + + def _get_array( + self, + payload: dict[str, np.ndarray], + keys: list[str], + *, + required: bool = True, + ) -> np.ndarray | None: + for key in keys: + value = payload.get(key) + if isinstance(value, np.ndarray): + return np.asarray(value, dtype=float).squeeze() + if required: + raise ValueError(f"Missing required keys: {keys}") + return None diff --git a/src/app/infrastructure/persistence/measurement_repo_mat_csv.py b/src/app/infrastructure/persistence/measurement_repo_mat_csv.py index 057285a..edf6f98 100644 --- a/src/app/infrastructure/persistence/measurement_repo_mat_csv.py +++ b/src/app/infrastructure/persistence/measurement_repo_mat_csv.py @@ -1,158 +1,23 @@ from __future__ import annotations -import csv -import json -from datetime import datetime -from pathlib import Path - -import numpy as np -from scipy.io import loadmat, savemat - from app.application.dto import LoadedMeasurement, SaveArtifacts, SaveTarget -from app.domain.exporters import result_to_arrays, settings_to_metadata -from app.domain.models import AppSettings, SweepPoint, SweepResult +from app.domain.models import AppSettings, SweepResult +from app.infrastructure.persistence.measurement_exporter import MeasurementExporter +from app.infrastructure.persistence.measurement_loader import MeasurementLoader class MatCsvMeasurementRepository: - def save(self, result: SweepResult, settings: AppSettings, target: SaveTarget) -> SaveArtifacts: - directory = target.base_path.parent - directory.mkdir(parents=True, exist_ok=True) - - stem = target.base_path.stem if target.base_path.suffix else target.base_path.name - prefix = datetime.now().strftime("%Y%m%d_%H_%M_%S_") if target.include_timestamp else "" - file_base = f"{prefix}{stem}" - - mat_path = directory / f"{file_base}.mat" - csv_path = directory / f"{file_base}.csv" - txt_path = directory / f"{file_base}.txt" - - arrays = result_to_arrays(result) - payload: dict[str, object] = { - "schema_version": settings.schema_version, - "metadata_json": json.dumps(settings_to_metadata(settings), ensure_ascii=True), - } - payload.update(arrays) - savemat(mat_path, payload) - - freq = arrays.get("freq_hz", np.array([], dtype=float)) - gain_linear = arrays.get("gain_linear", np.array([], dtype=float)) - gain_db = arrays.get("gain_db", np.array([], dtype=float)) - phase = arrays.get("phase_deg", np.array([], dtype=float)) - - headers = ["freq_hz", "gain_linear", "gain_db", "phase_deg"] - with csv_path.open("w", newline="", encoding="utf-8") as fh: - writer = csv.writer(fh) - writer.writerow(headers) - for idx in range(len(freq)): - writer.writerow( - [ - float(freq[idx]), - float(gain_linear[idx]) if idx < len(gain_linear) else "", - float(gain_db[idx]) if idx < len(gain_db) else "", - float(phase[idx]) if idx < len(phase) else "", - ] - ) - - rows = np.column_stack( - [ - freq, - gain_linear if len(gain_linear) == len(freq) else np.full(len(freq), np.nan), - gain_db if len(gain_db) == len(freq) else np.full(len(freq), np.nan), - phase if len(phase) == len(freq) else np.full(len(freq), np.nan), - ] - ) - np.savetxt(txt_path, rows, delimiter="\t", header="\t".join(headers), comments="") - - gain_plot_path = None - db_plot_path = None - - gain_fig = target.figures.get("gain") - db_fig = target.figures.get("db") - - if gain_fig is not None: - gain_plot_path = directory / f"{file_base}_gain.png" - gain_fig.savefig(gain_plot_path, dpi=300) - if db_fig is not None: - db_plot_path = directory / f"{file_base}_gain_db.png" - db_fig.savefig(db_plot_path, dpi=300) + def __init__( + self, + *, + exporter: MeasurementExporter | None = None, + loader: MeasurementLoader | None = None, + ) -> None: + self._exporter = exporter or MeasurementExporter() + self._loader = loader or MeasurementLoader() - return SaveArtifacts( - mat_path=mat_path, - csv_path=csv_path, - txt_path=txt_path, - gain_plot_path=gain_plot_path, - db_plot_path=db_plot_path, - ) + def save(self, result: SweepResult, settings: AppSettings, target: SaveTarget) -> SaveArtifacts: + return self._exporter.export(result=result, settings=settings, target=target) def load(self, file_path: str) -> LoadedMeasurement: - path = Path(file_path) - suffix = path.suffix.lower() - - if suffix == ".mat": - payload = loadmat(path) - freq = self._get_array(payload, ["freq_hz", "freq"]) # type: ignore[arg-type] - gain_linear = self._get_array(payload, ["gain_linear", "gain_raw"]) - gain_db = self._get_array(payload, ["gain_db", "gain_db_raw", "gain_db_corr"]) - phase = self._get_array(payload, ["phase_deg", "phase", "phase_deg_corr"], required=False) - - elif suffix == ".csv": - freq_l: list[float] = [] - gain_l: list[float] = [] - gain_db_l: list[float] = [] - phase_l: list[float] = [] - with path.open("r", encoding="utf-8") as fh: - reader = csv.DictReader(fh) - for row in reader: - freq_l.append(float(row.get("freq_hz", "0") or 0.0)) - gain_l.append(float(row.get("gain_linear", "0") or 0.0)) - gain_db_l.append(float(row.get("gain_db", "0") or 0.0)) - if row.get("phase_deg") not in (None, ""): - phase_l.append(float(row["phase_deg"])) - freq = np.array(freq_l, dtype=float) - gain_linear = np.array(gain_l, dtype=float) - gain_db = np.array(gain_db_l, dtype=float) - phase = np.array(phase_l, dtype=float) if phase_l else None - payload = {"freq_hz": freq, "gain_linear": gain_linear, "gain_db": gain_db} - if phase is not None: - payload["phase_deg"] = phase - - else: - raise ValueError(f"Unsupported file type: {suffix}") - - points: list[SweepPoint] = [] - n = len(freq) - for idx in range(n): - phase_deg = float(phase[idx]) if phase is not None and idx < len(phase) else None - points.append( - SweepPoint( - freq_hz=float(freq[idx]), - gain_linear=float(gain_linear[idx]) if idx < len(gain_linear) else 0.0, - gain_db=float(gain_db[idx]) if idx < len(gain_db) else 0.0, - phase_deg=phase_deg, - gain_complex=( - complex( - float(gain_linear[idx]) * np.cos(np.deg2rad(phase_deg)), - float(gain_linear[idx]) * np.sin(np.deg2rad(phase_deg)), - ) - if phase_deg is not None and idx < len(gain_linear) - else None - ), - ) - ) - - return LoadedMeasurement(result=SweepResult(points=points), raw_payload=payload) - - def _get_array( - self, - payload: dict[str, np.ndarray], - keys: list[str], - *, - required: bool = True, - ) -> np.ndarray | None: - for key in keys: - value = payload.get(key) - if isinstance(value, np.ndarray): - return np.asarray(value, dtype=float).squeeze() - if required: - raise ValueError(f"Missing required keys: {keys}") - return None + return self._loader.load(file_path) diff --git a/src/app/infrastructure/persistence/repository_ports.py b/src/app/infrastructure/persistence/repository_ports.py index 7eaa96f..30d3e0f 100644 --- a/src/app/infrastructure/persistence/repository_ports.py +++ b/src/app/infrastructure/persistence/repository_ports.py @@ -1,20 +1,3 @@ -from __future__ import annotations +from app.application.ports.persistence import MeasurementRepository, ReferenceRepository, SettingsRepository -from typing import Protocol - -from app.application.dto import LoadedMeasurement, SaveArtifacts, SaveTarget -from app.domain.models import AppSettings, ReferenceCurve, SweepResult - - -class SettingsRepository(Protocol): - def load(self) -> AppSettings: ... - def save(self, settings: AppSettings) -> None: ... - - -class MeasurementRepository(Protocol): - def save(self, result: SweepResult, settings: AppSettings, target: SaveTarget) -> SaveArtifacts: ... - def load(self, file_path: str) -> LoadedMeasurement: ... - - -class ReferenceRepository(Protocol): - def load_reference(self, file_path: str) -> ReferenceCurve: ... +__all__ = ["MeasurementRepository", "ReferenceRepository", "SettingsRepository"] diff --git a/src/app/infrastructure/persistence/settings_defaults.py b/src/app/infrastructure/persistence/settings_defaults.py new file mode 100644 index 0000000..35e3270 --- /dev/null +++ b/src/app/infrastructure/persistence/settings_defaults.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from app.domain.enums import ConnectionMode, CorrectionMode, CouplingMode, ImpedanceMode, MagnitudePhaseMode, TriggerMode +from app.domain.models import ( + AppSettings, + AwgSettings, + ChannelSelection, + InstrumentEndpoint, + InstrumentSetup, + OscSettings, + RunMode, + SweepSpec, +) +from app.shared.mapping import Mapping + + +class DefaultSettingsFactory: + def create(self) -> AppSettings: + return AppSettings( + schema_version=1, + freq_unit=Mapping.mapping_mhz, + sweep=SweepSpec( + start_hz=1e6, + stop_hz=100e6, + step_hz=1e6, + step_count=100, + is_log=False, + ), + run_mode=RunMode( + correction_mode=CorrectionMode.NONE, + trigger_mode=TriggerMode.FREE_RUN, + auto_range=True, + auto_reset=True, + ), + setup=InstrumentSetup( + awg=InstrumentEndpoint( + model=Mapping.mapping_DSG_4102, + connect_mode=ConnectionMode.AUTO, + visa_address="", + ip_address="0.0.0.0", + ), + osc=InstrumentEndpoint( + model=Mapping.mapping_MDO_34, + connect_mode=ConnectionMode.AUTO, + visa_address="", + ip_address="0.0.0.0", + ), + channels=ChannelSelection(awg_ch=1, osc_test_ch=1, osc_ref_ch=2, osc_trig_ch=2), + awg_settings=AwgSettings(amplitude_vpp=1.0, impedance=ImpedanceMode.R50), + osc_settings=OscSettings( + full_scale_v=1.0, + offset_v=0.0, + points=10_000, + impedance=ImpedanceMode.R50, + coupling=CouplingMode.DC, + ), + ), + magnitude_phase_mode=MagnitudePhaseMode.MAG, + auto_save_data=True, + ) diff --git a/src/app/infrastructure/persistence/settings_repo_json.py b/src/app/infrastructure/persistence/settings_repo_json.py index 8772c66..730e55a 100644 --- a/src/app/infrastructure/persistence/settings_repo_json.py +++ b/src/app/infrastructure/persistence/settings_repo_json.py @@ -1,187 +1,43 @@ from __future__ import annotations import json -from dataclasses import asdict from pathlib import Path -from mapping import Mapping - -from app.domain.enums import ( - ConnectionMode, - CorrectionMode, - CouplingMode, - ImpedanceMode, - MagnitudePhaseMode, - TriggerMode, -) -from app.domain.models import ( - AppSettings, - AwgSettings, - ChannelSelection, - InstrumentEndpoint, - InstrumentSetup, - OscSettings, - RunMode, - SweepSpec, -) +from app.domain.models import AppSettings from app.domain.validators import validate_settings +from app.infrastructure.persistence.file_store import FileStore +from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory +from app.infrastructure.persistence.settings_serializer import SettingsSerializer class JsonSettingsRepository: - def __init__(self, config_path: Path | None = None) -> None: + def __init__( + self, + config_path: Path | None = None, + *, + defaults_factory: DefaultSettingsFactory | None = None, + serializer: SettingsSerializer | None = None, + file_store: FileStore | None = None, + ) -> None: root = Path(__file__).resolve().parents[4] default_path = root / "__config__" / "settings.json" - self._path = config_path or default_path + + self._serializer = serializer or SettingsSerializer() + self._defaults_factory = defaults_factory or DefaultSettingsFactory() + self._file_store = file_store or FileStore(config_path or default_path) def load(self) -> AppSettings: - if not self._path.exists(): - settings = self._default_settings() + if not self._file_store.exists(): + settings = self._defaults_factory.create() self.save(settings) return settings - payload = json.loads(self._path.read_text(encoding="utf-8")) - settings = self._from_dict(payload) + payload = json.loads(self._file_store.read_text(encoding="utf-8")) + settings = self._serializer.from_payload(payload) validate_settings(settings) return settings def save(self, settings: AppSettings) -> None: validate_settings(settings) - self._path.parent.mkdir(parents=True, exist_ok=True) - data = self._to_dict(settings) - self._path.write_text(json.dumps(data, indent=2, ensure_ascii=True), encoding="utf-8") - - def _default_settings(self) -> AppSettings: - return AppSettings( - schema_version=1, - freq_unit=Mapping.mapping_mhz, - sweep=SweepSpec( - start_hz=1e6, - stop_hz=100e6, - step_hz=1e6, - step_count=100, - is_log=False, - ), - run_mode=RunMode( - correction_mode=CorrectionMode.NONE, - trigger_mode=TriggerMode.FREE_RUN, - auto_range=True, - auto_reset=True, - ), - setup=InstrumentSetup( - awg=InstrumentEndpoint( - model=Mapping.mapping_DSG_4102, - connect_mode=ConnectionMode.AUTO, - visa_address="", - ip_address="0.0.0.0", - ), - osc=InstrumentEndpoint( - model=Mapping.mapping_MDO_34, - connect_mode=ConnectionMode.AUTO, - visa_address="", - ip_address="0.0.0.0", - ), - channels=ChannelSelection(awg_ch=1, osc_test_ch=1, osc_ref_ch=2, osc_trig_ch=2), - awg_settings=AwgSettings(amplitude_vpp=1.0, impedance=ImpedanceMode.R50), - osc_settings=OscSettings( - full_scale_v=1.0, - offset_v=0.0, - points=10_000, - impedance=ImpedanceMode.R50, - coupling=CouplingMode.DC, - ), - ), - magnitude_phase_mode=MagnitudePhaseMode.MAG, - auto_save_data=True, - ) - - def _to_dict(self, settings: AppSettings) -> dict[str, object]: - payload = asdict(settings) - payload["run_mode"]["correction_mode"] = settings.run_mode.correction_mode.value - payload["run_mode"]["trigger_mode"] = settings.run_mode.trigger_mode.value - payload["setup"]["awg"]["connect_mode"] = settings.setup.awg.connect_mode.value - payload["setup"]["osc"]["connect_mode"] = settings.setup.osc.connect_mode.value - payload["setup"]["awg_settings"]["impedance"] = settings.setup.awg_settings.impedance.value - payload["setup"]["osc_settings"]["impedance"] = settings.setup.osc_settings.impedance.value - payload["setup"]["osc_settings"]["coupling"] = settings.setup.osc_settings.coupling.value - payload["magnitude_phase_mode"] = settings.magnitude_phase_mode.value - return payload - - def _from_dict(self, payload: dict[str, object]) -> AppSettings: - sweep_payload = payload.get("sweep", {}) - run_payload = payload.get("run_mode", {}) - setup_payload = payload.get("setup", {}) - - awg_payload = setup_payload.get("awg", {}) if isinstance(setup_payload, dict) else {} - osc_payload = setup_payload.get("osc", {}) if isinstance(setup_payload, dict) else {} - channels_payload = setup_payload.get("channels", {}) if isinstance(setup_payload, dict) else {} - awg_settings_payload = setup_payload.get("awg_settings", {}) if isinstance(setup_payload, dict) else {} - osc_settings_payload = setup_payload.get("osc_settings", {}) if isinstance(setup_payload, dict) else {} - - return AppSettings( - schema_version=int(payload.get("schema_version", 1)), - freq_unit=str(payload.get("freq_unit", Mapping.mapping_mhz)), - sweep=SweepSpec( - start_hz=float(sweep_payload.get("start_hz", 1e6)), - stop_hz=float(sweep_payload.get("stop_hz", 100e6)), - step_hz=( - None - if sweep_payload.get("step_hz") is None - else float(sweep_payload.get("step_hz", 1e6)) - ), - step_count=( - None - if sweep_payload.get("step_count") is None - else int(sweep_payload.get("step_count", 100)) - ), - is_log=bool(sweep_payload.get("is_log", False)), - ), - run_mode=RunMode( - correction_mode=CorrectionMode(str(run_payload.get("correction_mode", CorrectionMode.NONE.value))), - trigger_mode=TriggerMode(str(run_payload.get("trigger_mode", TriggerMode.FREE_RUN.value))), - auto_range=bool(run_payload.get("auto_range", True)), - auto_reset=bool(run_payload.get("auto_reset", True)), - ), - setup=InstrumentSetup( - awg=InstrumentEndpoint( - model=str(awg_payload.get("model", Mapping.mapping_DSG_4102)), - connect_mode=ConnectionMode(str(awg_payload.get("connect_mode", ConnectionMode.AUTO.value))), - visa_address=str(awg_payload.get("visa_address", "")), - ip_address=str(awg_payload.get("ip_address", "0.0.0.0")), - ), - osc=InstrumentEndpoint( - model=str(osc_payload.get("model", Mapping.mapping_MDO_34)), - connect_mode=ConnectionMode(str(osc_payload.get("connect_mode", ConnectionMode.AUTO.value))), - visa_address=str(osc_payload.get("visa_address", "")), - ip_address=str(osc_payload.get("ip_address", "0.0.0.0")), - ), - channels=ChannelSelection( - awg_ch=int(channels_payload.get("awg_ch", 1)), - osc_test_ch=int(channels_payload.get("osc_test_ch", 1)), - osc_ref_ch=( - None - if channels_payload.get("osc_ref_ch") is None - else int(channels_payload.get("osc_ref_ch")) - ), - osc_trig_ch=( - None - if channels_payload.get("osc_trig_ch") is None - else int(channels_payload.get("osc_trig_ch")) - ), - ), - awg_settings=AwgSettings( - amplitude_vpp=float(awg_settings_payload.get("amplitude_vpp", 1.0)), - impedance=ImpedanceMode(str(awg_settings_payload.get("impedance", ImpedanceMode.R50.value))), - ), - osc_settings=OscSettings( - full_scale_v=float(osc_settings_payload.get("full_scale_v", 1.0)), - offset_v=float(osc_settings_payload.get("offset_v", 0.0)), - points=int(osc_settings_payload.get("points", 10_000)), - impedance=ImpedanceMode(str(osc_settings_payload.get("impedance", ImpedanceMode.R50.value))), - coupling=CouplingMode(str(osc_settings_payload.get("coupling", CouplingMode.DC.value))), - ), - ), - magnitude_phase_mode=MagnitudePhaseMode( - str(payload.get("magnitude_phase_mode", MagnitudePhaseMode.MAG.value)) - ), - auto_save_data=bool(payload.get("auto_save_data", True)), - ) + payload = self._serializer.to_payload(settings) + self._file_store.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8") diff --git a/src/app/infrastructure/persistence/settings_serializer.py b/src/app/infrastructure/persistence/settings_serializer.py new file mode 100644 index 0000000..925c80b --- /dev/null +++ b/src/app/infrastructure/persistence/settings_serializer.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import asdict + +from app.domain.enums import ConnectionMode, CorrectionMode, CouplingMode, ImpedanceMode, MagnitudePhaseMode, TriggerMode +from app.domain.models import ( + AppSettings, + AwgSettings, + ChannelSelection, + InstrumentEndpoint, + InstrumentSetup, + OscSettings, + RunMode, + SweepSpec, +) +from app.shared.mapping import Mapping + + +class SettingsSerializer: + def to_payload(self, settings: AppSettings) -> dict[str, object]: + payload = asdict(settings) + payload["run_mode"]["correction_mode"] = settings.run_mode.correction_mode.value + payload["run_mode"]["trigger_mode"] = settings.run_mode.trigger_mode.value + payload["setup"]["awg"]["connect_mode"] = settings.setup.awg.connect_mode.value + payload["setup"]["osc"]["connect_mode"] = settings.setup.osc.connect_mode.value + payload["setup"]["awg_settings"]["impedance"] = settings.setup.awg_settings.impedance.value + payload["setup"]["osc_settings"]["impedance"] = settings.setup.osc_settings.impedance.value + payload["setup"]["osc_settings"]["coupling"] = settings.setup.osc_settings.coupling.value + payload["magnitude_phase_mode"] = settings.magnitude_phase_mode.value + return payload + + def from_payload(self, payload: dict[str, object]) -> AppSettings: + sweep_payload = payload.get("sweep", {}) + run_payload = payload.get("run_mode", {}) + setup_payload = payload.get("setup", {}) + + awg_payload = setup_payload.get("awg", {}) if isinstance(setup_payload, dict) else {} + osc_payload = setup_payload.get("osc", {}) if isinstance(setup_payload, dict) else {} + channels_payload = setup_payload.get("channels", {}) if isinstance(setup_payload, dict) else {} + awg_settings_payload = setup_payload.get("awg_settings", {}) if isinstance(setup_payload, dict) else {} + osc_settings_payload = setup_payload.get("osc_settings", {}) if isinstance(setup_payload, dict) else {} + + return AppSettings( + schema_version=int(payload.get("schema_version", 1)), + freq_unit=str(payload.get("freq_unit", Mapping.mapping_mhz)), + sweep=SweepSpec( + start_hz=float(sweep_payload.get("start_hz", 1e6)), + stop_hz=float(sweep_payload.get("stop_hz", 100e6)), + step_hz=None if sweep_payload.get("step_hz") is None else float(sweep_payload.get("step_hz", 1e6)), + step_count=None + if sweep_payload.get("step_count") is None + else int(sweep_payload.get("step_count", 100)), + is_log=bool(sweep_payload.get("is_log", False)), + ), + run_mode=RunMode( + correction_mode=CorrectionMode(str(run_payload.get("correction_mode", CorrectionMode.NONE.value))), + trigger_mode=TriggerMode(str(run_payload.get("trigger_mode", TriggerMode.FREE_RUN.value))), + auto_range=bool(run_payload.get("auto_range", True)), + auto_reset=bool(run_payload.get("auto_reset", True)), + ), + setup=InstrumentSetup( + awg=InstrumentEndpoint( + model=str(awg_payload.get("model", Mapping.mapping_DSG_4102)), + connect_mode=ConnectionMode(str(awg_payload.get("connect_mode", ConnectionMode.AUTO.value))), + visa_address=str(awg_payload.get("visa_address", "")), + ip_address=str(awg_payload.get("ip_address", "0.0.0.0")), + ), + osc=InstrumentEndpoint( + model=str(osc_payload.get("model", Mapping.mapping_MDO_34)), + connect_mode=ConnectionMode(str(osc_payload.get("connect_mode", ConnectionMode.AUTO.value))), + visa_address=str(osc_payload.get("visa_address", "")), + ip_address=str(osc_payload.get("ip_address", "0.0.0.0")), + ), + channels=ChannelSelection( + awg_ch=int(channels_payload.get("awg_ch", 1)), + osc_test_ch=int(channels_payload.get("osc_test_ch", 1)), + osc_ref_ch=None if channels_payload.get("osc_ref_ch") is None else int(channels_payload.get("osc_ref_ch")), + osc_trig_ch=None if channels_payload.get("osc_trig_ch") is None else int(channels_payload.get("osc_trig_ch")), + ), + awg_settings=AwgSettings( + amplitude_vpp=float(awg_settings_payload.get("amplitude_vpp", 1.0)), + impedance=ImpedanceMode(str(awg_settings_payload.get("impedance", ImpedanceMode.R50.value))), + ), + osc_settings=OscSettings( + full_scale_v=float(osc_settings_payload.get("full_scale_v", 1.0)), + offset_v=float(osc_settings_payload.get("offset_v", 0.0)), + points=int(osc_settings_payload.get("points", 10_000)), + impedance=ImpedanceMode(str(osc_settings_payload.get("impedance", ImpedanceMode.R50.value))), + coupling=CouplingMode(str(osc_settings_payload.get("coupling", CouplingMode.DC.value))), + ), + ), + magnitude_phase_mode=MagnitudePhaseMode(str(payload.get("magnitude_phase_mode", MagnitudePhaseMode.MAG.value))), + auto_save_data=bool(payload.get("auto_save_data", True)), + ) diff --git a/src/app/presentation/tk/app_window.py b/src/app/presentation/tk/app_window.py index 56bcc40..bdbb1de 100644 --- a/src/app/presentation/tk/app_window.py +++ b/src/app/presentation/tk/app_window.py @@ -3,7 +3,7 @@ import tkinter as tk from tkinter import ttk -from mapping import Mapping +from app.shared.mapping import Mapping from app.presentation.tk.plot_widget import PlotWidget from app.presentation.tk.view_model import ViewModel diff --git a/src/app/presentation/tk/controller.py b/src/app/presentation/tk/controller.py index 536734c..25e79d9 100644 --- a/src/app/presentation/tk/controller.py +++ b/src/app/presentation/tk/controller.py @@ -1,37 +1,25 @@ from __future__ import annotations import queue -import threading from pathlib import Path -from app.application.dto import SaveTarget, StartSweepCommand -from app.application.events import ( - ConnectionStatusUpdated, - SweepCompleted, - SweepDataUpdated, - SweepFailed, - SweepProgress, - SweepStarted, - SweepStopped, - SweepWarning, -) +from app.application.events import EventEmitter from app.application.services.connection_monitor import ConnectionMonitor from app.application.use_cases.load_measurement import LoadMeasurementUseCase from app.application.use_cases.load_reference import LoadReferenceUseCase from app.application.use_cases.save_measurement import SaveMeasurementUseCase from app.application.use_cases.settings_use_case import SettingsUseCase -from app.application.use_cases.start_sweep import StartSweepUseCase -from app.application.use_cases.stop_sweep import StopSweepUseCase -from app.domain.models import AppSettings, SweepResult -from app.infrastructure.instruments.equips_factory import create_instrument_ports, resolve_visa_address -from app.infrastructure.instruments.ports import ResourceScannerPort +from app.application.ports.instruments import ResourceScannerPort from app.presentation.tk import dialogs from app.presentation.tk.app_window import AppWindow from app.presentation.tk.mapper import settings_to_vm, vm_to_settings +from app.presentation.tk.sweep_task_runner import SweepTaskRunner +from app.presentation.tk.ui_event_handler import UiEventHandler from app.presentation.tk.view_model import ViewModel +from app.infrastructure.instruments.equips_factory import resolve_visa_address -class TkController: +class TkController(EventEmitter): def __init__( self, *, @@ -45,21 +33,21 @@ def __init__( ) -> None: self.window = window self.vm = vm - self.settings_use_case = settings_use_case self.save_measurement_use_case = save_measurement_use_case self.load_measurement_use_case = load_measurement_use_case self.load_reference_use_case = load_reference_use_case self._event_queue: queue.Queue[object] = queue.Queue() - self._latest_result = SweepResult() self._reference_interpolator = None - - self._ports = None - self._sweep_thread: threading.Thread | None = None - self._stop_use_case: StopSweepUseCase | None = None - self._root_dir = Path(__file__).resolve().parents[4] + + self._ui_handler = UiEventHandler(window=window, vm=vm) + self._task_runner = SweepTaskRunner( + emitter=self, + save_measurement_use_case=save_measurement_use_case, + root_dir=self._root_dir, + ) self._monitor = ConnectionMonitor( scanner=scanner, get_awg_address=self._get_awg_target_address, @@ -96,38 +84,22 @@ def emit(self, event: object) -> None: self._event_queue.put(event) def on_start(self) -> None: - if self._sweep_thread and self._sweep_thread.is_alive(): + if self._task_runner.is_running(): return try: settings = vm_to_settings(self.vm) - ports = create_instrument_ports(settings.setup) + self._task_runner.start( + settings=settings, + calibration_enabled=bool(self.vm.calibration_enabled.get()), + reference_interpolator=self._reference_interpolator, + ) + self._ui_handler.prepare_for_sweep_start() except Exception as exc: # noqa: BLE001 dialogs.show_warning(self.window, f"Invalid settings: {exc}") - return - - stop_event = threading.Event() - self._stop_use_case = StopSweepUseCase(stop_event=stop_event) - - cmd = StartSweepCommand( - settings=settings, - calibration_enabled=bool(self.vm.calibration_enabled.get()), - reference_interpolator=self._reference_interpolator, - ) - - self._ports = ports - start_use_case = StartSweepUseCase(awg=ports.awg, osc=ports.osc, stop_event=stop_event) - - self.window.btn_start.configure(state="disabled") - self.window.btn_stop.configure(state="normal") - self.vm.status_text.set("Sweep started") - - self._sweep_thread = threading.Thread(target=self._run_sweep, args=(start_use_case, cmd), daemon=True) - self._sweep_thread.start() def on_stop(self) -> None: - if self._stop_use_case is not None: - self._stop_use_case.stop() + self._task_runner.stop() def on_save_settings(self) -> None: try: @@ -148,7 +120,7 @@ def on_load_settings(self) -> None: dialogs.show_warning(self.window, f"Failed to load settings: {exc}") def on_save_data(self) -> None: - if self._latest_result.is_empty: + if self._ui_handler.latest_result.is_empty: dialogs.show_warning(self.window, "No measurement data available") return @@ -156,7 +128,7 @@ def on_save_data(self) -> None: title="Save measurement", initial_dir=self._root_dir / "__data__", initial_name="measurement", - filetypes=[("All files", "*.*")], + filetypes=[("MAT files", "*.mat"), ("All files", "*.*")], ) if fp is None: return @@ -164,9 +136,9 @@ def on_save_data(self) -> None: try: settings = vm_to_settings(self.vm) artifacts = self.save_measurement_use_case.execute( - result=self._latest_result, + result=self._ui_handler.latest_result, settings=settings, - target=SaveTarget(base_path=fp, include_timestamp=False, figures=self.window.plot_widget.figures()), + target=dialogs_to_target(fp, self.window), ) dialogs.show_info(self.window, f"Saved: {artifacts.mat_path.name}") except Exception as exc: # noqa: BLE001 @@ -183,12 +155,7 @@ def on_load_data(self) -> None: try: loaded = self.load_measurement_use_case.execute(str(fp)) - self._latest_result = loaded.result - self.window.plot_widget.update_result( - self._latest_result, - self.vm.freq_unit.get(), - self.vm.magnitude_phase_mode.get(), - ) + self._ui_handler.set_result(loaded.result) dialogs.show_info(self.window, "Measurement loaded") except Exception as exc: # noqa: BLE001 dialogs.show_warning(self.window, f"Failed to load data: {exc}") @@ -214,125 +181,29 @@ def on_figure_change(self) -> None: self.window.plot_widget.set_mode(self.vm.figure_mode.get()) def on_mag_phase_change(self) -> None: - self.window.plot_widget.update_result( - self._latest_result, - self.vm.freq_unit.get(), - self.vm.magnitude_phase_mode.get(), - ) + self._ui_handler.refresh_plot() def on_close(self) -> None: self._monitor.stop() - if self._stop_use_case is not None: - self._stop_use_case.stop() - try: settings = vm_to_settings(self.vm) self.settings_use_case.save(settings) except Exception: pass - self._close_ports() + self._task_runner.shutdown() self.window.destroy() - def _run_sweep(self, start_use_case: StartSweepUseCase, cmd: StartSweepCommand) -> None: - try: - result = start_use_case.run(cmd, self) - if not result.is_empty: - self._latest_result = result - - if self.vm.auto_save_data.get(): - settings = cmd.settings - target = SaveTarget( - base_path=self._root_dir / "__data__" / "measurement", - include_timestamp=True, - figures={}, - ) - self.save_measurement_use_case.execute(result=result, settings=settings, target=target) - except Exception as exc: # noqa: BLE001 - self.emit(SweepFailed(error_code="SWEEP_THREAD", message=str(exc))) - finally: - self._close_ports() - - def _close_ports(self) -> None: - if self._ports is None: - return - try: - self._ports.awg.close() - except Exception: - pass - try: - self._ports.osc.close() - except Exception: - pass - self._ports = None - def _process_events(self) -> None: try: while True: event = self._event_queue.get_nowait() - self._handle_event(event) + self._ui_handler.handle(event) except queue.Empty: pass finally: self.window.after(100, self._process_events) - def _handle_event(self, event: object) -> None: - if isinstance(event, ConnectionStatusUpdated): - self.window.set_connection_status(event.awg_connected, event.osc_connected) - return - - if isinstance(event, SweepStarted): - self.vm.status_text.set(f"Sweep started ({event.total_points} points)") - return - - if isinstance(event, SweepProgress): - self.vm.status_text.set( - f"Freq {event.freq_hz:.2f} Hz ({event.point_index}/{event.total_points})" - ) - return - - if isinstance(event, SweepDataUpdated): - self._latest_result = event.partial_result - self.window.plot_widget.update_result( - self._latest_result, - self.vm.freq_unit.get(), - self.vm.magnitude_phase_mode.get(), - ) - return - - if isinstance(event, SweepWarning): - if event.code in {"READY", "FREQ_MISMATCH", "AMP_MISMATCH"}: - self.vm.status_text.set(event.message) - else: - dialogs.show_warning(self.window, event.message) - return - - if isinstance(event, SweepFailed): - self.vm.status_text.set(f"Sweep failed: {event.message}") - self.window.btn_start.configure(state="normal") - self.window.btn_stop.configure(state="disabled") - dialogs.show_warning(self.window, event.message) - return - - if isinstance(event, SweepStopped): - self._latest_result = event.result - self.vm.status_text.set("Sweep stopped") - self.window.btn_start.configure(state="normal") - self.window.btn_stop.configure(state="disabled") - return - - if isinstance(event, SweepCompleted): - self._latest_result = event.result - self.vm.status_text.set("Sweep completed") - self.window.plot_widget.update_result( - self._latest_result, - self.vm.freq_unit.get(), - self.vm.magnitude_phase_mode.get(), - ) - self.window.btn_start.configure(state="normal") - self.window.btn_stop.configure(state="disabled") - return - def _get_awg_target_address(self) -> str: try: settings = vm_to_settings(self.vm) @@ -346,3 +217,13 @@ def _get_osc_target_address(self) -> str: return resolve_visa_address(settings.setup.osc) except Exception: return "" + + +def dialogs_to_target(path, window: AppWindow): + from app.application.dto import SaveTarget + + return SaveTarget( + base_path=path, + include_timestamp=False, + figures=window.plot_widget.figures(), + ) diff --git a/src/app/presentation/tk/mapper.py b/src/app/presentation/tk/mapper.py index 14c5714..c67834e 100644 --- a/src/app/presentation/tk/mapper.py +++ b/src/app/presentation/tk/mapper.py @@ -1,6 +1,6 @@ from __future__ import annotations -from cvtTools import CvtTools +from app.shared.cvt_tools import CvtTools from app.domain.enums import ( ConnectionMode, diff --git a/src/app/presentation/tk/plot_widget.py b/src/app/presentation/tk/plot_widget.py index 44c9de1..271412c 100644 --- a/src/app/presentation/tk/plot_widget.py +++ b/src/app/presentation/tk/plot_widget.py @@ -6,8 +6,8 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure -from cvtTools import CvtTools -from mapping import Mapping +from app.shared.cvt_tools import CvtTools +from app.shared.mapping import Mapping from app.domain.models import SweepResult diff --git a/src/app/presentation/tk/sweep_task_runner.py b/src/app/presentation/tk/sweep_task_runner.py new file mode 100644 index 0000000..3735c62 --- /dev/null +++ b/src/app/presentation/tk/sweep_task_runner.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import threading +from collections.abc import Callable +from pathlib import Path + +from app.application.dto import SaveTarget, StartSweepCommand +from app.application.events import SweepFailed +from app.application.use_cases.save_measurement import SaveMeasurementUseCase +from app.application.use_cases.start_sweep import StartSweepUseCase +from app.application.use_cases.stop_sweep import StopSweepUseCase +from app.domain.models import AppSettings, InstrumentSetup, SweepResult +from app.infrastructure.instruments.equips_factory import InstrumentPorts, create_instrument_ports + + +class SweepTaskRunner: + def __init__( + self, + *, + emitter, + save_measurement_use_case: SaveMeasurementUseCase, + root_dir: Path, + ports_factory: Callable[[InstrumentSetup], InstrumentPorts] = create_instrument_ports, + use_case_factory: Callable[..., StartSweepUseCase] = StartSweepUseCase, + ) -> None: + self._emitter = emitter + self._save_measurement_use_case = save_measurement_use_case + self._root_dir = root_dir + self._ports_factory = ports_factory + self._use_case_factory = use_case_factory + + self._ports: InstrumentPorts | None = None + self._sweep_thread: threading.Thread | None = None + self._stop_use_case: StopSweepUseCase | None = None + + def is_running(self) -> bool: + return self._sweep_thread is not None and self._sweep_thread.is_alive() + + def start( + self, + *, + settings: AppSettings, + calibration_enabled: bool, + reference_interpolator: object | None, + ) -> None: + if self.is_running(): + return + + ports = self._ports_factory(settings.setup) + stop_event = threading.Event() + self._stop_use_case = StopSweepUseCase(stop_event=stop_event) + + cmd = StartSweepCommand( + settings=settings, + calibration_enabled=calibration_enabled, + reference_interpolator=reference_interpolator, + ) + start_use_case = self._use_case_factory(awg=ports.awg, osc=ports.osc, stop_event=stop_event) + + self._ports = ports + self._sweep_thread = threading.Thread(target=self._run_sweep, args=(start_use_case, cmd), daemon=True) + self._sweep_thread.start() + + def stop(self) -> None: + if self._stop_use_case is not None: + self._stop_use_case.stop() + + def wait(self, timeout: float | None = None) -> None: + if self._sweep_thread is not None: + self._sweep_thread.join(timeout=timeout) + + def shutdown(self) -> None: + self.stop() + self._close_ports() + + def _run_sweep(self, start_use_case: StartSweepUseCase, cmd: StartSweepCommand) -> None: + try: + result = start_use_case.run(cmd, self._emitter) + if not result.is_empty and cmd.settings.auto_save_data: + target = SaveTarget( + base_path=self._root_dir / "__data__" / "measurement", + include_timestamp=True, + figures={}, + ) + self._save_measurement_use_case.execute(result=result, settings=cmd.settings, target=target) + except Exception as exc: # noqa: BLE001 + self._emitter.emit(SweepFailed(error_code="SWEEP_THREAD", message=str(exc))) + finally: + self._close_ports() + + def _close_ports(self) -> None: + if self._ports is None: + return + + try: + self._ports.awg.close() + except Exception: + pass + + try: + self._ports.osc.close() + except Exception: + pass + + self._ports = None diff --git a/src/app/presentation/tk/ui_event_handler.py b/src/app/presentation/tk/ui_event_handler.py new file mode 100644 index 0000000..0330dea --- /dev/null +++ b/src/app/presentation/tk/ui_event_handler.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from app.application.events import ( + ConnectionStatusUpdated, + SweepCompleted, + SweepDataUpdated, + SweepFailed, + SweepProgress, + SweepStarted, + SweepStopped, + SweepWarning, +) +from app.domain.models import SweepResult +from app.presentation.tk import dialogs +from app.presentation.tk.app_window import AppWindow +from app.presentation.tk.view_model import ViewModel + + +class UiEventHandler: + def __init__(self, *, window: AppWindow, vm: ViewModel) -> None: + self._window = window + self._vm = vm + self._latest_result = SweepResult() + + @property + def latest_result(self) -> SweepResult: + return self._latest_result + + def prepare_for_sweep_start(self) -> None: + self._window.btn_start.configure(state="disabled") + self._window.btn_stop.configure(state="normal") + self._vm.status_text.set("Sweep started") + + def set_result(self, result: SweepResult, *, refresh_plot: bool = True) -> None: + self._latest_result = result + if refresh_plot: + self.refresh_plot() + + def refresh_plot(self) -> None: + self._window.plot_widget.update_result( + self._latest_result, + self._vm.freq_unit.get(), + self._vm.magnitude_phase_mode.get(), + ) + + def handle(self, event: object) -> None: + if isinstance(event, ConnectionStatusUpdated): + self._window.set_connection_status(event.awg_connected, event.osc_connected) + return + + if isinstance(event, SweepStarted): + self._vm.status_text.set(f"Sweep started ({event.total_points} points)") + return + + if isinstance(event, SweepProgress): + self._vm.status_text.set(f"Freq {event.freq_hz:.2f} Hz ({event.point_index}/{event.total_points})") + return + + if isinstance(event, SweepDataUpdated): + self.set_result(event.partial_result) + return + + if isinstance(event, SweepWarning): + if event.code in {"READY", "FREQ_MISMATCH", "AMP_MISMATCH"}: + self._vm.status_text.set(event.message) + else: + dialogs.show_warning(self._window, event.message) + return + + if isinstance(event, SweepFailed): + self._vm.status_text.set(f"Sweep failed: {event.message}") + self._window.btn_start.configure(state="normal") + self._window.btn_stop.configure(state="disabled") + dialogs.show_warning(self._window, event.message) + return + + if isinstance(event, SweepStopped): + self.set_result(event.result, refresh_plot=False) + self._vm.status_text.set("Sweep stopped") + self._window.btn_start.configure(state="normal") + self._window.btn_stop.configure(state="disabled") + return + + if isinstance(event, SweepCompleted): + self.set_result(event.result) + self._vm.status_text.set("Sweep completed") + self._window.btn_start.configure(state="normal") + self._window.btn_stop.configure(state="disabled") diff --git a/src/app/presentation/tk/view_model.py b/src/app/presentation/tk/view_model.py index 5710992..0ee8146 100644 --- a/src/app/presentation/tk/view_model.py +++ b/src/app/presentation/tk/view_model.py @@ -2,7 +2,7 @@ import tkinter as tk -from mapping import Mapping +from app.shared.mapping import Mapping class ViewModel: diff --git a/src/app/shared/__init__.py b/src/app/shared/__init__.py new file mode 100644 index 0000000..7cb28b5 --- /dev/null +++ b/src/app/shared/__init__.py @@ -0,0 +1,4 @@ +from app.shared.cvt_tools import CvtTools +from app.shared.mapping import Mapping + +__all__ = ["CvtTools", "Mapping"] diff --git a/src/app/shared/cvt_tools.py b/src/app/shared/cvt_tools.py new file mode 100644 index 0000000..2585b17 --- /dev/null +++ b/src/app/shared/cvt_tools.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import math +import re + +import numpy as np + + +class CvtTools: + @staticmethod + def parse_general_val(input: str, default_unit: str | None = None) -> float | int: + input = input.replace(" ", "") + input_match = re.search(r"([+-]?\d*(?:\.\d+)?(?:[eE][+-]?\d+)?)([A-Za-zµ]?)", input) + if input_match is None: + return 0.0 + + input_val = input_match.group(1) + input_unit = input_match.group(2) + + try: + input_val = int(input_val) if input_val.isdigit() else float(input_val) + except Exception: + return 0.0 + + if not input_unit: + val = input_val * CvtTools.convert_general_unit(default_unit) + try: + return int(val) if float(val).is_integer() else val + except Exception: + return val + + prefix = input_unit[0] + scale = CvtTools._scale_for_prefix(prefix) + val = input_val * scale + try: + return int(val) if float(val).is_integer() else val + except Exception: + return val + + @staticmethod + def convert_general_unit(unit: str | None) -> float | int: + if not unit: + return 1 + + input_match = re.search(r"([+-]?\d*(?:\.\d+)?(?:[eE][+-]?\d+)?)([A-Za-zµ]?)", unit) + if input_match is None: + return 1 + + input_unit = input_match.group(2) + if not input_unit: + return 1 + + return CvtTools._scale_for_prefix(input_unit[0]) + + @staticmethod + def parse_to_hz(freq: str, default_unit: str = "") -> float: + new_freq = CvtTools.parse_general_val(input=freq, default_unit=default_unit) + return float(new_freq) if new_freq else 0.0 + + @staticmethod + def parse_to_Vpp(vpp: str) -> float: + vpp = vpp.replace(" ", "") + vpp_match = re.search(r"([+-]?\d*(?:\.\d+)?(?:[eE][+-]?\d+)?)([A-Za-zµ]*)", vpp) + if vpp_match is None: + return 0.0 + + vpp_val = vpp_match.group(1) + vpp_unit = vpp_match.group(2) + + if not vpp_val: + return 0.0 + + value = float(vpp_val) + if not vpp_unit: + scale = 1.0 + elif "Vpp".lower() in vpp_unit.lower(): + scale = 1.0 + elif "Vpk".lower() in vpp_unit.lower(): + scale = 2.0 + elif "Vrms".lower() in vpp_unit.lower(): + scale = math.sqrt(8) + else: + scale = 1.0 + + if vpp_unit and vpp_unit[0] == "m": + scale *= 0.001 + return value * scale + + @staticmethod + def parse_to_V(volts: str) -> float | int: + return CvtTools.parse_general_val(input=volts) + + @staticmethod + def _parabolic_interp_delta(m1: float, m0: float, p1: float) -> float: + eps = 1e-30 + m1 = np.log(max(m1, eps)) + m0 = np.log(max(m0, eps)) + p1 = np.log(max(p1, eps)) + denom = m1 - 2 * m0 + p1 + if abs(denom) < 1e-12: + return 0.0 + return 0.5 * (m1 - p1) / denom + + @staticmethod + def _complex_tone_at(times: np.ndarray, volts_ac: np.ndarray, f_hz: float, window: np.ndarray | None = None) -> complex: + if window is None: + return np.sum(volts_ac * np.exp(-1j * 2 * np.pi * f_hz * times)) + return np.sum(window * volts_ac * np.exp(-1j * 2 * np.pi * f_hz * times)) + + @staticmethod + def _scale_for_prefix(prefix: str) -> float: + if prefix in ("G", "g"): + return 1e9 + if prefix == "M": + return 1e6 + if prefix in ("k", "K"): + return 1e3 + if prefix == "m": + return 1e-3 + if prefix in ("u", "µ", "μ"): + return 1e-6 + if prefix in ("n", "N"): + return 1e-9 + if prefix in ("p", "P"): + return 1e-12 + return 1.0 diff --git a/src/app/shared/mapping.py b/src/app/shared/mapping.py new file mode 100644 index 0000000..09247e2 --- /dev/null +++ b/src/app/shared/mapping.py @@ -0,0 +1,170 @@ +from __future__ import annotations + + +class Mapping: + label_for_input_ui = "Input Control Panel" + label_for_file_menu = "File" + label_for_config_menu = "Device Manager" + label_for_device_configure_window = "Advanced Settings" + label_for_exit = "Exit" + + label_for_auto_lan = "Connection Mode" + label_for_visa_address = "VISA address" + label_for_ip_address = "IP address" + label_for_auto = "Auto" + label_for_lan = "LAN" + + label_for_chan_index = "Chan" + label_for_test_chan = "Test Chan" + label_for_ref_chan = "Ref Chan" + label_for_trig_chan = "Trig Chan" + + label_for_set_start_frequency = "Start Freq" + label_for_set_stop_frequency = "Stop Freq" + label_for_set_step_freq = "Freq Step" + label_for_set_step_num = "Step Ct" + label_for_set_center_frequency = "Center Freq" + label_for_set_interval_frequency = "Freq Span" + label_for_log = "Log" + label_for_freq_unit = "Unit" + label_for_points = "Max Samp" + label_for_freq = "Freq" + label_for_set_amp = "Amp" + label_for_set_imp = "Imp" + label_for_imp_r50 = "R50" + label_for_imp_inf = "High-Z" + label_for_coup = "Coup" + label_for_yoffset = "Center V" + label_for_range = "Full-Scale V" + label_for_auto_range = "Auto" + + label_for_single_chan_correct = "Single Chan" + label_for_duo_chan_correct = "Dual Chan" + label_for_no_correct = "No Cali" + label_for_set_ref = "Set As Ref" + label_for_load_ref = "Load Ref" + label_for_enable_ref = "Enable Cali" + + label_for_figure_gain = "Gain" + label_for_figure_gain_db = "dB" + label_for_figure_phase = "Phase (deg)" + label_for_figure_gain_freq = f"{label_for_figure_gain}_vs_{label_for_freq}" + label_for_figure_gaindb_freq = f"{label_for_figure_gain_db}_vs_{label_for_freq}" + + label_for_load_file_to_show = "Load for display" + label_for_load_file_to_ref = "Load for ref" + label_for_load_config = "Load config" + label_for_save_file = "Save data" + label_for_save_config = "Save config" + label_for_file_is_saved = "File saved" + label_for_sub_folder_data = "__data__" + + error_file_not_save = "Failed to save data!!!" + error_fail_auto_save = "Automatic save failed!!!" + title_alert = "Warning" + + mapping_auto_detect = "Auto Detect" + label_for_device_type_awg = "AWG" + label_for_device_type_osc = "OSC" + + mapping_DSG_4102 = "DSG4102" + mapping_DSG_836 = "DSG836" + mapping_MDO_34 = "MDO34" + mapping_MDO_3024 = "MDO3024" + mapping_DHO_1202 = "DHO1202" + mapping_DHO_1204 = "DHO1204" + + mapping_hz = "Hz" + mapping_khz = "KHz" + mapping_mhz = "MHz" + mapping_ghz = "GHz" + + mapping_imp_r50 = "50" + mapping_imp_high_z = "INF" + + mapping_vpp = "Vpp" + mapping_vpk = "Vpk" + mapping_vrms = "Vrms" + + mapping_file_ext_mat = ".mat" + mapping_file_ext_csv = ".csv" + mapping_file_ext_txt = ".txt" + mapping_file_ext_png = ".png" + + mapping_coup_ac = "AC" + mapping_coup_dc = "DC" + + mapping_state_on = "ON" + mapping_state_off = "OFF" + + values_awg = [mapping_DSG_4102, mapping_DSG_836] + values_osc = [mapping_MDO_34, mapping_MDO_3024, mapping_DHO_1202, mapping_DHO_1204] + values_device_type = [label_for_device_type_awg, label_for_device_type_osc] + values_freq_unit = [mapping_hz, mapping_khz, mapping_mhz, mapping_ghz] + values_device_num_list = [1, 2, 3, 4] + values_test_load_off_figure = [label_for_figure_gain_freq, label_for_figure_gaindb_freq] + values_correct_modes = [label_for_no_correct, label_for_single_chan_correct, label_for_duo_chan_correct] + values_coup = [mapping_coup_ac, mapping_coup_dc] + + mapping_freq = "freq" + mapping_gain_raw = "gain_raw" + mapping_gain_db_raw = "gain_db_raw" + mapping_phase_deg = "phase" + mapping_gain_corr = "gain_corr" + mapping_gain_db_corr = "gain_db_corr" + mapping_phase_deg_corr = "phase_corr" + mapping_gain_complex = "gain_complex" + + label_for_free_run = "free run" + label_for_triggered = "triggered" + values_trig_mode = [label_for_free_run, label_for_triggered] + + mapping_color_for_phase_line = "tab:red" + + label_for_mag = "Magnitude" + label_for_phase = "Phase" + label_for_mag_and_phase = "Magnitude + Phase" + values_mag_or_phase = [label_for_mag, label_for_phase, label_for_mag_and_phase] + + default_data_fn = "Test_File" + default_show_selection_font = ("Microsoft YaHei", 18) + default_text_font = ("Microsoft YaHei", 10) + default_terminal_bg = "black" + default_terminal_fg = "white" + + default_start_freq = "1.0" + default_stop_freq = "100.0" + default_step_freq = "1.0" + default_step_num = "100" + default_is_log_freq_enabled = mapping_state_off + default_freq_unit = mapping_mhz + default_samp_pts = "10000" + + default_awg_amp = "1.0" + default_awg_imp = "50" + default_yoffset = "0.0" + default_range = "1.0" + default_osc_imp = "50" + default_osc_coup = mapping_coup_dc + + default_is_auto_range = mapping_state_on + default_correct_mode = label_for_no_correct + default_is_correct_enabled = mapping_state_off + default_trig_mode = label_for_free_run + default_is_auto_save = mapping_state_on + default_is_auto_reset = mapping_state_on + + default_awg_name = mapping_DSG_4102 + default_osc_name = mapping_MDO_34 + + default_awg_chan_index = "1" + default_osc_test_chan_index = "1" + default_osc_trig_chan_index = "2" + default_osc_ref_chan_index = "2" + + default_awg_connect_mode = label_for_auto + default_osc_connect_mode = label_for_auto + default_awg_visa = "" + default_osc_visa = "" + default_awg_ip = "0.0.0.0" + default_osc_ip = "0.0.0.0" diff --git a/src/cvtTools.py b/src/cvtTools.py index 2d3264a..c3e7df2 100644 --- a/src/cvtTools.py +++ b/src/cvtTools.py @@ -1,129 +1,3 @@ -import re -import math -import numpy as np - -class CvtTools: - - @staticmethod - def parse_general_val(input: str, default_unit: str=None) -> float|int: - """ - Parse a numeric string with an optional prefix and return the scaled value. - Empty or invalid text resolves to 0, and unknown prefixes default to a scale of 1. - """ - input = input.replace(" ", "") - input_match = re.search(r"([+-]?\d*(?:\.\d+)?(?:[eE][+-]?\d+)?)([A-Za-zµ]?)", input) - input_val = input_match.group(1) - input_unit = input_match.group(2) - - try: - input_val = int(input_val) if input_val.isdigit() else float(input_val) - except: - return 0.0 - - if not input_unit: - val = input_val * CvtTools.convert_general_unit(default_unit) - # Prefer ints for integral results so downstream callers can treat counts as integers. - try: - return int(val) if float(val).is_integer() else val - except Exception: - return val - prefix = input_unit[0] - - if prefix in ('G', 'g'): scale = 1e9 - elif prefix == 'M': scale = 1e6 - elif prefix in ('k', 'K'): scale = 1e3 - elif prefix == 'm': scale = 1e-3 - elif prefix in ('u', 'µ', 'μ'):scale = 1e-6 - elif prefix in ('n', 'N'): scale = 1e-9 - elif prefix in ('p', 'P'): scale = 1e-12 - else: scale = 1 - - val = input_val * scale - try: - return int(val) if float(val).is_integer() else val - except Exception: - return val - - @staticmethod - def convert_general_unit(unit: str) -> float|int: - """ - Parse the prefix multiplier only; empty strings and unknown prefixes return 1. - """ - if not unit: return 1 - input_match = re.search(r"([+-]?\d*(?:\.\d+)?(?:[eE][+-]?\d+)?)([A-Za-zµ]?)", unit) - input_unit = input_match.group(2) - - if not input_unit: return 1 - prefix = input_unit[0] - - if prefix in ('G', 'g'): scale = 1e9 - elif prefix == 'M': scale = 1e6 - elif prefix in ('k', 'K'): scale = 1e3 - elif prefix == 'm': scale = 1e-3 - elif prefix in ('u', 'µ', 'μ'):scale = 1e-6 - elif prefix in ('n', 'N'): scale = 1e-9 - elif prefix in ('p', 'P'): scale = 1e-12 - else: scale = 1 - - return scale - - @staticmethod - def parse_to_hz(freq: str, default_unit: str = "") -> float: - """ - Parse frequency text with an optional default unit. - """ - new_freq = CvtTools.parse_general_val(input=freq, default_unit=default_unit) - - return new_freq if new_freq else 0 - - @staticmethod - def parse_to_Vpp(vpp: str) -> float: - """ - Parse voltage text and convert it to Vpp. - Supports: Vpp = 1x, Vpk = 2x, Vrms = sqrt(8) * Vpp, and an optional milli prefix. - """ - vpp = vpp.replace(" ", "") - vpp_macth = re.search(r"([+-]?\d*(?:\.\d+)?(?:[eE][+-]?\d+)?)([A-Za-zµ]*)", vpp) - vpp_val = vpp_macth.group(1) - vpp_unit = vpp_macth.group(2) - - if not vpp_val: return "" - vpp_val = float(vpp_val) - - if not vpp_unit: scale = 1 - elif "Vpp".lower() in vpp_unit.lower(): scale = 1 - elif "Vpk".lower() in vpp_unit.lower(): scale = 2 - elif "Vrms".lower() in vpp_unit.lower(): scale = math.sqrt(8) - else: scale = 1 - - if vpp_unit and vpp_unit[0] == "m": scale *= 0.001 - return vpp_val * scale - - @staticmethod - def parse_to_V(volts: str): - """ - Generic voltage parser helper. - """ - return CvtTools.parse_general_val(input=volts) - - @staticmethod - def _parabolic_interp_delta(m1, m0, p1): - """ - Estimate the peak offset via log|X| parabolic interpolation on three points. - """ - eps = 1e-30 - m1 = np.log(max(m1, eps)) - m0 = np.log(max(m0, eps)) - p1 = np.log(max(p1, eps)) - denom = (m1 - 2*m0 + p1) - if abs(denom) < 1e-12: return 0.0 - return 0.5 * (m1 - p1) / denom - - @staticmethod - def _complex_tone_at(times, volts_ac, f_hz, window=None): - """ - Compute a single-point DFT at f_hz and return the complex coefficient. - """ - if window is None: - return np.sum(volts_ac * np.exp(-1j * 2*np.pi * f_hz * times)) - return np.sum(window * volts_ac * np.exp(-1j * 2*np.pi * f_hz * times)) +from app.shared.cvt_tools import CvtTools + +__all__ = ["CvtTools"] diff --git a/src/mapping.py b/src/mapping.py index 40a0340..7bc0b48 100644 --- a/src/mapping.py +++ b/src/mapping.py @@ -1,216 +1,3 @@ -from __future__ import annotations +from app.shared.mapping import Mapping -class Mapping: - # ========================= messages ========================= - label_for_input_ui = "Input Control Panel" - label_for_file_menu = "File" - label_for_config_menu = "Device Manager" - label_for_device_configure_window = "Advanced Settings" - label_for_exit = "Exit" - - # ========================= connection ========================= - label_for_auto_lan = "Connection Mode" - label_for_visa_address = "VISA address" - label_for_ip_address = "IP address" - label_for_auto = "Auto" - label_for_lan = "LAN" - - # ========================= channel ========================= - label_for_chan_index = "Chan" - label_for_test_chan = "Test Chan" - label_for_ref_chan = "Ref Chan" - label_for_trig_chan = "Trig Chan" - - # ========================= awg/osc setting ========================= - label_for_set_start_frequency = "Start Freq" - label_for_set_stop_frequency = "Stop Freq" - label_for_set_step_freq = "Freq Step" - label_for_set_step_num = "Step Ct" - label_for_set_center_frequency = "Center Freq" - label_for_set_interval_frequency = "Freq Span" - label_for_log = "Log" - label_for_freq_unit = "Unit" - label_for_points = "Max Samp" - label_for_freq = "Freq" - label_for_set_amp = "Amp" - label_for_set_imp = "Imp" - label_for_imp_r50 = "R50" - label_for_imp_inf = "High-Z" - label_for_coup = "Coup" - label_for_yoffset = "Center V" - label_for_range = "Full-Scale V" - label_for_auto_range = "Auto" - - # ========================= correct/reference ========================= - label_for_single_chan_correct = "Single Chan" - label_for_duo_chan_correct = "Dual Chan" - label_for_no_correct = "No Cali" - label_for_set_ref = "Set As Ref" - label_for_load_ref = "Load Ref" - label_for_enable_ref = "Enable Cali" - - # ========================= figure ========================= - label_for_figure_gain = "Gain" - label_for_figure_gain_db = "dB" - label_for_figure_phase = "Phase (deg)" - label_for_figure_gain_freq = f"{label_for_figure_gain}_vs_{label_for_freq}" - label_for_figure_gaindb_freq = f"{label_for_figure_gain_db}_vs_{label_for_freq}" - - # ========================= load/save data ========================= - label_for_load_file_to_show = "Load for display" - label_for_load_file_to_ref = "Load for ref" - label_for_load_config = "Load config" - label_for_save_file = "Save data" - label_for_save_config = "Save config" - label_for_file_is_saved = "File saved" - label_for_sub_folder_data = "__data__" - - # ========================= errors & titles ========================= - error_file_not_save = "Failed to save data!!!" - error_fail_auto_save = "Automatic save failed!!!" - title_alert = "Warning" - - # ========================= mappings / options ========================= - mapping_auto_detect = "Auto Detect" - label_for_device_type_awg = "AWG" - label_for_device_type_osc = "OSC" - - mapping_DSG_4102 = "DSG4102" - mapping_DSG_836 = "DSG836" - mapping_MDO_34 = "MDO34" - mapping_MDO_3024 = "MDO3024" - mapping_DHO_1202 = "DHO1202" - mapping_DHO_1204 = "DHO1204" - - mapping_hz = "Hz" - mapping_khz = "KHz" - mapping_mhz = "MHz" - mapping_ghz = "GHz" - - mapping_imp_r50 = "50" - mapping_imp_high_z = "INF" - - mapping_vpp = "Vpp" - mapping_vpk = "Vpk" - mapping_vrms = "Vrms" - - mapping_file_ext_mat = ".mat" - mapping_file_ext_csv = ".csv" - mapping_file_ext_txt = ".txt" - mapping_file_ext_png = ".png" - - mapping_coup_ac = "AC" - mapping_coup_dc = "DC" - - mapping_state_on = "ON" - mapping_state_off = "OFF" - - # ========================= combobox values ========================= - values_awg = [ - mapping_DSG_4102, - mapping_DSG_836, - ] - values_osc = [ - mapping_MDO_34, - mapping_MDO_3024, - mapping_DHO_1202, - mapping_DHO_1204, - ] - values_device_type = [ - label_for_device_type_awg, - label_for_device_type_osc, - ] - values_freq_unit = [ - mapping_hz, - mapping_khz, - mapping_mhz, - mapping_ghz, - ] - values_device_num_list = [1, 2, 3, 4] - values_test_load_off_figure = [ - label_for_figure_gain_freq, - label_for_figure_gaindb_freq, - ] - values_correct_modes = [ - label_for_no_correct, - label_for_single_chan_correct, - label_for_duo_chan_correct, - ] - values_coup = [ - mapping_coup_ac, - mapping_coup_dc, - ] - - # ========================= data keys ========================= - mapping_freq = "freq" - mapping_gain_raw = "gain_raw" - mapping_gain_db_raw = "gain_db_raw" - mapping_phase_deg = "phase" - mapping_gain_corr = "gain_corr" - mapping_gain_db_corr = "gain_db_corr" - mapping_phase_deg_corr = "phase_corr" - mapping_gain_complex = "gain_complex" - - # ========================= trigger modes ========================= - label_for_free_run = "free run" - label_for_triggered = "triggered" - values_trig_mode = [label_for_free_run, label_for_triggered] - - # ========================= colors ========================= - mapping_color_for_phase_line = "tab:red" - - # ========================= plot options ========================= - label_for_mag = "Magnitude" - label_for_phase = "Phase" - label_for_mag_and_phase = "Magnitude + Phase" - values_mag_or_phase = [label_for_mag, label_for_phase, label_for_mag_and_phase] - - # ========================= defaults (UI/fonts/theme) ========================= - default_data_fn = "Test_File" - default_show_selection_font = ("Microsoft YaHei", 18) - default_text_font = ("Microsoft YaHei", 10) - default_terminal_bg = "black" - default_terminal_fg = "white" - - # ========================= defaults (general sweep) ========================= - default_start_freq = "1.0" - default_stop_freq = "100.0" - default_step_freq = "1.0" - default_step_num = "100" - default_is_log_freq_enabled = mapping_state_off - default_freq_unit = mapping_mhz - default_samp_pts = "10000" - - # ========================= defaults (AWG/OSC settings) ========================= - default_awg_amp = "1.0" - default_awg_imp = "50" - default_yoffset = "0.0" - default_range = "1.0" - default_osc_imp = "50" - default_osc_coup = mapping_coup_dc - - # ========================= defaults (modes/switches) ========================= - default_is_auto_range = mapping_state_on - default_correct_mode = label_for_no_correct - default_is_correct_enabled = mapping_state_off - default_trig_mode = label_for_free_run - default_is_auto_save = mapping_state_on - default_is_auto_reset = mapping_state_on - - # ========================= defaults (device names) ========================= - default_awg_name = mapping_DSG_4102 - default_osc_name = mapping_MDO_34 - - # ========================= defaults (channel indices) ========================= - default_awg_chan_index = "1" - default_osc_test_chan_index = "1" - default_osc_trig_chan_index = "2" - default_osc_ref_chan_index = "2" - - # ========================= defaults (connection) ========================= - default_awg_connect_mode = label_for_auto - default_osc_connect_mode = label_for_auto - default_awg_visa = "" - default_osc_visa = "" - default_awg_ip = "0.0.0.0" - default_osc_ip = "0.0.0.0" +__all__ = ["Mapping"] diff --git a/tests/test_auto_range_policy.py b/tests/test_auto_range_policy.py new file mode 100644 index 0000000..ab4cb24 --- /dev/null +++ b/tests/test_auto_range_policy.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import unittest + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.domain.auto_range import AutoRangePolicy + + +class AutoRangePolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.policy = AutoRangePolicy() + + def test_expand_range_when_signal_near_clipping(self) -> None: + decision = self.policy.decide( + volts=np.array([-0.45, 0.45]), + current_range_v=1.0, + current_offset_v=0.0, + requested_offset_v=0.0, + ) + + self.assertTrue(decision.changed) + self.assertGreater(decision.target_range_v, 1.0) + self.assertAlmostEqual(decision.target_offset_v, 0.0) + + def test_shrink_range_when_signal_small(self) -> None: + decision = self.policy.decide( + volts=np.array([-0.1, 0.1]), + current_range_v=1.0, + current_offset_v=0.0, + requested_offset_v=0.0, + ) + + self.assertTrue(decision.changed) + self.assertAlmostEqual(decision.target_range_v, 0.5, delta=1e-6) + + def test_adjust_offset_when_midpoint_drifts(self) -> None: + decision = self.policy.decide( + volts=np.array([0.35, 0.45, 0.55]), + current_range_v=1.0, + current_offset_v=0.0, + requested_offset_v=0.0, + ) + + self.assertTrue(decision.changed) + self.assertAlmostEqual(decision.target_offset_v, 0.45, delta=1e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_measurement_io.py b/tests/test_measurement_io.py new file mode 100644 index 0000000..588d982 --- /dev/null +++ b/tests/test_measurement_io.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import math +import sys +from pathlib import Path +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.application.dto import SaveTarget +from app.domain.models import SweepPoint, SweepResult +from app.infrastructure.persistence.measurement_exporter import MeasurementExporter +from app.infrastructure.persistence.measurement_loader import MeasurementLoader +from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory + + +class MeasurementIOTests(unittest.TestCase): + def test_export_and_load_round_trip_for_mat_and_csv(self) -> None: + settings = DefaultSettingsFactory().create() + result = SweepResult( + points=[ + SweepPoint(freq_hz=1_000.0, gain_linear=1.0, gain_db=0.0, phase_deg=5.0), + SweepPoint(freq_hz=2_000.0, gain_linear=2.0, gain_db=20.0 * math.log10(2.0), phase_deg=10.0), + ] + ) + exporter = MeasurementExporter() + loader = MeasurementLoader() + + with tempfile.TemporaryDirectory() as td: + artifacts = exporter.export( + result=result, + settings=settings, + target=SaveTarget(base_path=Path(td) / "measurement", include_timestamp=False, figures={}), + ) + + loaded_mat = loader.load(str(artifacts.mat_path)) + loaded_csv = loader.load(str(artifacts.csv_path)) + + self.assertEqual(len(loaded_mat.result.points), 2) + self.assertEqual(len(loaded_csv.result.points), 2) + self.assertAlmostEqual(loaded_mat.result.points[1].gain_linear, 2.0, delta=1e-6) + self.assertAlmostEqual(loaded_csv.result.points[1].phase_deg or 0.0, 10.0, delta=1e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_point_measurement_services.py b/tests/test_point_measurement_services.py new file mode 100644 index 0000000..a6eef9c --- /dev/null +++ b/tests/test_point_measurement_services.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import math +import sys +from pathlib import Path +import unittest + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.application.dto import StartSweepCommand +from app.application.services.sweep.calibration_applier import CalibrationApplier +from app.application.services.sweep.models import AcquiredPointData +from app.application.services.sweep.point_measurement_service import PointMeasurementService +from app.domain.enums import ConnectionMode, CorrectionMode, CouplingMode, ImpedanceMode, MagnitudePhaseMode, TriggerMode +from app.domain.models import ( + AppSettings, + AwgSettings, + ChannelSelection, + InstrumentEndpoint, + InstrumentSetup, + OscSettings, + RunMode, + SweepPoint, + SweepSpec, +) + + +def build_settings(*, correction_mode: CorrectionMode, trigger_mode: TriggerMode) -> AppSettings: + return AppSettings( + schema_version=1, + freq_unit="Hz", + sweep=SweepSpec(start_hz=1_000.0, stop_hz=1_000.0, step_hz=1_000.0, step_count=None, is_log=False), + run_mode=RunMode( + correction_mode=correction_mode, + trigger_mode=trigger_mode, + auto_range=False, + auto_reset=True, + ), + setup=InstrumentSetup( + awg=InstrumentEndpoint(model="DSG4102", connect_mode=ConnectionMode.AUTO), + osc=InstrumentEndpoint(model="MDO34", connect_mode=ConnectionMode.AUTO), + channels=ChannelSelection(awg_ch=1, osc_test_ch=1, osc_ref_ch=2, osc_trig_ch=2), + awg_settings=AwgSettings(amplitude_vpp=1.0, impedance=ImpedanceMode.R50), + osc_settings=OscSettings( + full_scale_v=1.0, + offset_v=0.0, + points=4000, + impedance=ImpedanceMode.R50, + coupling=CouplingMode.DC, + ), + ), + magnitude_phase_mode=MagnitudePhaseMode.MAG, + auto_save_data=False, + ) + + +class PointMeasurementServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.service = PointMeasurementService() + self.calibration = CalibrationApplier() + + def test_single_channel_triggered_returns_phase(self) -> None: + fs = 200_000 + f0 = 2_000 + t = np.arange(0.0, 0.03, 1.0 / fs) + volts = 0.5 * np.sin(2.0 * np.pi * f0 * t) + + point = self.service.measure( + settings=build_settings(correction_mode=CorrectionMode.NONE, trigger_mode=TriggerMode.TRIGGERED), + acquired=AcquiredPointData( + actual_freq_hz=f0, + read_amp_vpp=1.0, + test_times=t, + test_volts=volts, + ), + ) + + self.assertAlmostEqual(point.gain_linear, 1.0, delta=0.05) + self.assertIsNotNone(point.phase_deg) + self.assertIsNotNone(point.gain_complex) + + def test_dual_channel_measurement_returns_ratio(self) -> None: + fs = 200_000 + f0 = 5_000 + t = np.arange(0.0, 0.03, 1.0 / fs) + ref = np.sin(2.0 * np.pi * f0 * t) + test = 2.0 * np.sin(2.0 * np.pi * f0 * t + math.radians(30.0)) + + point = self.service.measure( + settings=build_settings(correction_mode=CorrectionMode.DUAL, trigger_mode=TriggerMode.FREE_RUN), + acquired=AcquiredPointData( + actual_freq_hz=f0, + read_amp_vpp=1.0, + test_times=t, + test_volts=test, + ref_times=t, + ref_volts=ref, + ), + ) + + self.assertAlmostEqual(point.gain_linear, 2.0, delta=0.1) + self.assertAlmostEqual(point.phase_deg or 0.0, 30.0, delta=5.0) + + def test_calibration_applier_corrects_gain_and_phase(self) -> None: + point = SweepPoint( + freq_hz=5_000.0, + gain_linear=2.0, + gain_db=20.0 * math.log10(2.0), + phase_deg=30.0, + gain_complex=2.0 * np.exp(1j * np.deg2rad(30.0)), + ) + cmd = StartSweepCommand( + settings=build_settings(correction_mode=CorrectionMode.DUAL, trigger_mode=TriggerMode.TRIGGERED), + calibration_enabled=True, + reference_interpolator=lambda _xs: np.array([2.0 * np.exp(1j * np.deg2rad(10.0))]), + ) + + corrected = self.calibration.apply(point=point, cmd=cmd) + self.assertAlmostEqual(corrected.gain_linear, 1.0, delta=1e-6) + self.assertAlmostEqual(corrected.phase_deg or 0.0, 20.0, delta=1e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_serializer.py b/tests/test_settings_serializer.py new file mode 100644 index 0000000..82088ff --- /dev/null +++ b/tests/test_settings_serializer.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.domain.enums import TriggerMode +from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory +from app.infrastructure.persistence.settings_serializer import SettingsSerializer + + +class SettingsSerializerTests(unittest.TestCase): + def test_schema_version_one_round_trip(self) -> None: + serializer = SettingsSerializer() + settings = DefaultSettingsFactory().create() + settings.run_mode.trigger_mode = TriggerMode.TRIGGERED + settings.setup.awg.visa_address = "USB::MOCK::INSTR" + + payload = serializer.to_payload(settings) + loaded = serializer.from_payload(payload) + + self.assertEqual(loaded.schema_version, 1) + self.assertEqual(loaded.run_mode.trigger_mode, TriggerMode.TRIGGERED) + self.assertEqual(loaded.setup.awg.visa_address, "USB::MOCK::INSTR") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sweep_planner_service.py b/tests/test_sweep_planner_service.py new file mode 100644 index 0000000..e81efcc --- /dev/null +++ b/tests/test_sweep_planner_service.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.application.services.sweep.planner import SweepPlanner +from app.domain.enums import ConnectionMode, CorrectionMode, CouplingMode, ImpedanceMode, MagnitudePhaseMode, TriggerMode +from app.domain.models import ( + AppSettings, + AwgSettings, + ChannelSelection, + InstrumentEndpoint, + InstrumentSetup, + OscSettings, + RunMode, + SweepSpec, +) + + +def build_settings(*, is_log: bool) -> AppSettings: + return AppSettings( + schema_version=1, + freq_unit="Hz", + sweep=SweepSpec( + start_hz=1.0, + stop_hz=100.0 if is_log else 5.0, + step_hz=None if is_log else 2.0, + step_count=5 if is_log else None, + is_log=is_log, + ), + run_mode=RunMode( + correction_mode=CorrectionMode.NONE, + trigger_mode=TriggerMode.FREE_RUN, + auto_range=False, + auto_reset=True, + ), + setup=InstrumentSetup( + awg=InstrumentEndpoint(model="DSG4102", connect_mode=ConnectionMode.AUTO), + osc=InstrumentEndpoint(model="MDO34", connect_mode=ConnectionMode.AUTO), + channels=ChannelSelection(awg_ch=1, osc_test_ch=1, osc_ref_ch=2, osc_trig_ch=2), + awg_settings=AwgSettings(amplitude_vpp=1.0, impedance=ImpedanceMode.R50), + osc_settings=OscSettings( + full_scale_v=1.0, + offset_v=0.0, + points=10_000, + impedance=ImpedanceMode.R50, + coupling=CouplingMode.DC, + ), + ), + magnitude_phase_mode=MagnitudePhaseMode.MAG, + auto_save_data=False, + ) + + +class SweepPlannerServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.planner = SweepPlanner() + + def test_linear_plan_matches_expected_points(self) -> None: + plan = self.planner.plan(build_settings(is_log=False)) + self.assertEqual(plan.freq_points.tolist(), [1.0, 3.0, 5.0]) + self.assertEqual(plan.total_points, 3) + + def test_log_plan_uses_step_count(self) -> None: + plan = self.planner.plan(build_settings(is_log=True)) + self.assertEqual(plan.total_points, 5) + self.assertAlmostEqual(float(plan.freq_points[0]), 1.0) + self.assertAlmostEqual(float(plan.freq_points[-1]), 100.0) + + def test_sampling_window_is_positive(self) -> None: + window = self.planner.compute_sampling_window_s(freq_hz=1e3, sample_rate_hz=1e6, points=10_000) + self.assertGreater(window, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sweep_task_runner.py b/tests/test_sweep_task_runner.py new file mode 100644 index 0000000..ed0f344 --- /dev/null +++ b/tests/test_sweep_task_runner.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import threading +import unittest +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.application.events import SweepCompleted +from app.domain.models import SweepPoint, SweepResult +from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory +from app.presentation.tk.sweep_task_runner import SweepTaskRunner + + +class FakeSaveMeasurementUseCase: + def __init__(self) -> None: + self.calls: list[tuple[object, object, object]] = [] + + def execute(self, result, settings, target): + self.calls.append((result, settings, target)) + return SimpleNamespace(mat_path=Path("measurement.mat")) + + +class FakeEmitter: + def __init__(self) -> None: + self.events: list[object] = [] + + def emit(self, event: object) -> None: + self.events.append(event) + + +class FakePort: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class FakeStartUseCase: + def __init__(self, result: SweepResult) -> None: + self._result = result + + def run(self, cmd, emitter): + emitter.emit(SweepCompleted(result=self._result)) + return self._result + + +class SweepTaskRunnerTests(unittest.TestCase): + def test_runner_auto_saves_completed_result(self) -> None: + settings = DefaultSettingsFactory().create() + settings.auto_save_data = True + save_use_case = FakeSaveMeasurementUseCase() + emitter = FakeEmitter() + awg = FakePort() + osc = FakePort() + result = SweepResult(points=[SweepPoint(freq_hz=1_000.0, gain_linear=1.0, gain_db=0.0)]) + + def ports_factory(_setup): + return SimpleNamespace(awg=awg, osc=osc, awg_address="A", osc_address="B") + + def use_case_factory(*, awg, osc, stop_event: threading.Event): + _ = (awg, osc, stop_event) + return FakeStartUseCase(result) + + runner = SweepTaskRunner( + emitter=emitter, + save_measurement_use_case=save_use_case, + root_dir=Path("."), + ports_factory=ports_factory, + use_case_factory=use_case_factory, + ) + + runner.start(settings=settings, calibration_enabled=False, reference_interpolator=None) + runner.wait(timeout=1.0) + + self.assertEqual(len(save_use_case.calls), 1) + self.assertTrue(awg.closed) + self.assertTrue(osc.closed) + self.assertTrue(any(isinstance(event, SweepCompleted) for event in emitter.events)) + + +if __name__ == "__main__": + unittest.main() From b052bf8eda1dba18b56bb3b5c509d08dc48f5caf Mon Sep 17 00:00:00 2001 From: ShehaoLi Date: Sat, 16 May 2026 15:30:52 -0700 Subject: [PATCH 2/3] Refactor app maturity and extensibility --- .github/workflows/ci.yml | 19 ++ .gitignore | 27 ++ README.md | 138 ++++++++--- UserGuide/README.md | 8 + demo_data/README.md | 23 ++ docs/architecture.md | 53 ++-- docs/case_study.md | 43 ++++ docs/extending.md | 71 ++++++ docs/images/README.md | 8 + docs/images/sweep_result.png | Bin 0 -> 107308 bytes docs/operator_guide.md | 70 ++++++ docs/safety.md | 40 +++ pyproject.toml | 42 ++++ requirements-dev.txt | 3 + requirements.txt | 18 +- src/app/application/ports/instruments.py | 16 ++ .../services/connection_monitor.py | 8 +- .../application/services/sweep_task_runner.py | 130 ++++++++++ src/app/application/use_cases/start_sweep.py | 1 - src/app/bootstrap.py | 57 +++++ src/app/domain/calibration.py | 5 +- src/app/domain/exporters.py | 24 +- .../infrastructure/instruments/awg_adapter.py | 5 +- .../instruments/equips_factory.py | 13 +- src/app/infrastructure/instruments/ports.py | 4 +- .../persistence/measurement_exporter.py | 11 +- .../persistence/measurement_loader.py | 26 +- .../persistence/settings_repo_json.py | 4 +- src/app/presentation/tk/app_window.py | 230 ++---------------- src/app/presentation/tk/control_panel.py | 222 +++++++++++++++++ src/app/presentation/tk/controller.py | 64 +++-- src/app/presentation/tk/sweep_task_runner.py | 104 +------- src/app/runtime/__init__.py | 2 + src/app/runtime/paths.py | 33 +++ src/equips.py | 18 +- src/main.py | 31 +-- tests/test_app_paths.py | 26 ++ tests/test_architecture_boundaries.py | 63 +++++ tests/test_measurement_io.py | 37 +++ tests/test_point_measurement_services.py | 18 ++ tests/test_start_sweep_use_case.py | 3 + tests/test_sweep_task_runner.py | 44 +++- 42 files changed, 1296 insertions(+), 466 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 docs/case_study.md create mode 100644 docs/extending.md create mode 100644 docs/images/README.md create mode 100644 docs/images/sweep_result.png create mode 100644 docs/operator_guide.md create mode 100644 docs/safety.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 src/app/application/services/sweep_task_runner.py create mode 100644 src/app/bootstrap.py create mode 100644 src/app/presentation/tk/control_panel.py create mode 100644 src/app/runtime/__init__.py create mode 100644 src/app/runtime/paths.py create mode 100644 tests/test_app_paths.py create mode 100644 tests/test_architecture_boundaries.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3a2a78a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: python -m pip install --upgrade pip + - run: python -m pip install -r requirements.txt + - run: PYTHONPATH=src python -m unittest discover -s tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4e7a21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +.DS_Store + +# Python bytecode and test/tool caches +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.matplotlib-cache/ +.coverage +htmlcov/ + +# Local environments +.venv/ +venv/ +env/ + +# Build and packaging output +build/ +dist/ +*.egg-info/ + +# Runtime files created by the desktop app +__config__/ +__data__/ +*.log diff --git a/README.md b/README.md index 7c497dd..b89f29a 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,136 @@ -# LoadoffTest (Decoupled Architecture) +# Auto-Load-off-Test -LoadoffTest is a Python desktop tool for AWG/OSC sweep measurement and calibration. +[![CI](https://github.com/lishehao-ctrl/Auto-Load-off-Test/actions/workflows/ci.yml/badge.svg)](https://github.com/lishehao-ctrl/Auto-Load-off-Test/actions/workflows/ci.yml) -This version is fully refactored into a layered architecture: +Auto-Load-off-Test is a local Python desktop tool for AWG/oscilloscope sweep measurement, calibration, plotting, and data export. -- `presentation` (Tkinter UI only) -- `application` (use cases and event flow) -- `domain` (pure business models and algorithms) -- `infrastructure` (instrument adapters and persistence) +It turns a repetitive manual lab workflow into a layered application: -## Project Layout +- configure an arbitrary waveform generator (AWG) +- configure oscilloscope acquisition channels +- sweep frequency points +- measure gain and optional phase +- apply reference calibration +- export MAT/CSV/TXT data and optional plot images + +## Why It Exists + +Manual AWG/oscilloscope sweep measurements are repetitive and easy to misconfigure. This project separates the workflow into testable layers so the sweep math, signal processing, settings serialization, and use-case flow can be verified without physical instruments. + +## Architecture ```text src/ main.py app/ - presentation/tk/ - application/ - domain/ - infrastructure/ + bootstrap.py desktop composition root + runtime/ runtime paths and environment helpers + presentation/tk/ Tkinter UI and plotting + application/ use cases, DTOs, events, ports + domain/ pure models, validation, sweep math, DSP + infrastructure/ instrument adapters and persistence + equips.py legacy vendor/instrument compatibility layer +``` + +```mermaid +flowchart LR + UI["Tkinter UI"] --> APP["Application Use Cases"] + APP --> DOMAIN["Domain Models / Sweep / DSP"] + APP --> PORTS["Instrument Ports"] + PORTS --> INFRA["AWG / OSC Adapters"] + INFRA --> LEGACY["equips.py Vendor Layer"] + APP --> PERSIST["Settings + Measurement Persistence"] ``` -Legacy coupled modules (`src/ui.py`, `src/test.py`, `src/channel.py`, `src/deviceMng.py`) are removed. +The UI and use cases do not call `src/equips.py` directly. That file is treated as a legacy vendor compatibility layer and is wrapped by infrastructure adapters. -## Run +## Requirements + +- Python 3.10 or newer +- Tkinter, usually included with the Python installer on macOS/Windows +- For live instrument use: + - supported AWG and oscilloscope models from `src/app/shared/mapping.py` + - VISA access through `pyvisa` / `pyvisa-py` + - correct LAN/VISA addresses for the instruments + +Automated tests do not require AWG/OSC hardware. + +## Install ```bash -python3 src/main.py +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt ``` -## Configuration +For development tooling: -Settings are stored in JSON: +```bash +python -m pip install -r requirements-dev.txt +``` + +The project also exposes an optional console script when installed as a package: + +```bash +python -m pip install -e . +auto-load-off-test +``` + +## Run The Desktop App -- `__config__/settings.json` +```bash +python src/main.py +``` -Schema version is tracked in the settings payload (`schema_version`). +Settings are stored at: + +```text +__config__/settings.json +``` + +## Run Tests Without Hardware + +```bash +PYTHONPATH=src python -m unittest discover -s tests +``` + +The test suite uses pure domain tests and mocked instrument ports. It covers sweep generation, signal processing, settings serialization, measurement I/O, start-sweep event flow, and the sweep task runner. ## Output Files -Save operation writes: +Saving a measurement writes: - `*.mat` - `*.csv` - `*.txt` -- plot images (`*_gain.png`, `*_gain_db.png`) when figure handles are provided +- `*_gain.png` and `*_gain_db.png` when plot figures are supplied -## Testing +Auto-save writes timestamped files under: -Run automated tests: - -```bash -python3 -m unittest discover -s tests +```text +__data__/measurement/ ``` -Tests cover: +Example result generated from `demo_data/Demo(2).mat`: + +![Demo sweep result](docs/images/sweep_result.png) + +## Safety Notes + +This is a local lab automation tool, not a certified production test platform. Operators are responsible for confirming the connected instrument model, address, voltage range, frequency range, impedance, coupling, and device-under-test limits before running a live sweep. + +See [docs/safety.md](docs/safety.md) for stop/shutdown behavior and hardware assumptions. + +## Documentation -- domain sweep generation -- signal processing behavior -- start-sweep use case event flow with mock ports -- settings repository round-trip +- [Architecture](docs/architecture.md) +- [Operator Guide](docs/operator_guide.md) +- [Safety Notes](docs/safety.md) +- [Extending The Application](docs/extending.md) +- [Case Study](docs/case_study.md) +- [Demo Data](demo_data/README.md) -## Notes +## Project Status -- The application remains local single-process. -- No HTTP backend is introduced. -- UI thread safety is enforced through event queue dispatch (`Tk.after`). +The refactored app is local, single-process, and hardware-adapter based. Its strongest engineering signal is the separation between UI, use-case orchestration, pure domain logic, persistence, and instrument side effects. diff --git a/UserGuide/README.md b/UserGuide/README.md index 8b13789..337fcf6 100644 --- a/UserGuide/README.md +++ b/UserGuide/README.md @@ -1 +1,9 @@ +# User Guide +The original operator guide is kept as a Word document: + +- `网络分析仪_使用说明.docx` + +For GitHub review and day-to-day repository navigation, use the Markdown guide: + +- `../docs/operator_guide.md` diff --git a/demo_data/README.md b/demo_data/README.md index 8b13789..84c1871 100644 --- a/demo_data/README.md +++ b/demo_data/README.md @@ -1 +1,24 @@ +# Demo Data +This folder contains sample MAT files that can be used to inspect the measurement data shape without connecting instruments. + +## Files + +- `Deme(1).mat` + - Contains 15 frequency points. + - Keys observed: `freq`, `gain_db_raw`, `config`. + - Useful for checking older/raw gain-only measurement loading behavior. + +- `Demo(2).mat` + - Contains 50 frequency points. + - Keys observed: `freq`, `gain_db_corr`, `phase_corr`, `config`. + - Useful for checking corrected gain/phase measurement structure. + +## How To Use + +1. Start the desktop app with `python src/main.py`. +2. Use the load-measurement action. +3. Select one of the MAT files in this directory. +4. Confirm the plot and loaded point count look reasonable. + +These files are sample data for review and local testing. They are not a substitute for live instrument verification. diff --git a/docs/architecture.md b/docs/architecture.md index b6c631e..e18f105 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,17 +1,32 @@ # Architecture +Auto-Load-off-Test is organized as a local desktop application with explicit boundaries between UI code, use-case orchestration, pure domain logic, persistence, and hardware side effects. + +## Layer Diagram + +```mermaid +flowchart LR + UI["presentation/tk
Tkinter widgets, variables, dialogs, plots"] --> APP["application
use cases, DTOs, events, ports"] + APP --> DOMAIN["domain
models, validation, sweep math, DSP, calibration"] + APP --> PORTS["ports
AwgPort, OscPort, repositories"] + PORTS --> INFRA["infrastructure
adapters, scanner, JSON/MAT/CSV IO"] + INFRA --> LEGACY["src/equips.py
legacy vendor compatibility layer"] +``` + ## Layers +- `app/bootstrap.py` + - Desktop composition root. Wires repositories, use cases, scanner, instrument factories, runtime paths, and the Tk controller. - `app/presentation/tk` - Tk widgets, variable bindings, dialogs, chart rendering. - Consumes application events and dispatches user intents. - `app/application` - - Use-case orchestration (`start_sweep`, `stop_sweep`, `save/load`, `settings`). - - Emits typed events for UI; no Tk or message boxes. + - Use-case orchestration for start/stop sweep, save/load, reference loading, and settings. + - Emits typed events for UI; no Tk widgets or message boxes. - `app/domain` - - Pure dataclasses, enums, validation, sweep generation, DSP, calibration. + - Pure dataclasses, enums, validation, sweep generation, DSP, calibration, and export array shaping. - `app/infrastructure` - - Adapter wrappers around `equips.py`. + - Adapter wrappers around `src/equips.py`. - JSON settings and MAT/CSV/TXT persistence. ## Dependency Rules @@ -20,35 +35,45 @@ Allowed: - `presentation -> application` - `application -> domain` -- `application -> infrastructure` (ports / repositories) +- `application -> ports` +- `infrastructure -> ports` - `infrastructure -> domain` +- `infrastructure -> src/equips.py` Forbidden: -- `domain` importing Tkinter / PyVISA / Matplotlib -- `application` showing dialogs (`messagebox` / `filedialog`) -- UI accessing `equips` directly +- `domain` importing Tkinter, PyVISA, serial, or Matplotlib. +- `application` showing dialogs through `messagebox` or `filedialog`. +- UI or use cases accessing `src/equips.py` directly. ## Event Flow 1. UI collects parameters from `ViewModel`. -2. Controller maps to `AppSettings` and starts `StartSweepUseCase` in worker thread. -3. Use case emits: +2. `TkController` maps the view model to `AppSettings`. +3. `SweepTaskRunner` starts `StartSweepUseCase` in a worker thread. +4. Use case emits: - `SweepStarted` - `SweepProgress` - `SweepDataUpdated` - `SweepWarning` / `SweepFailed` - `SweepCompleted` / `SweepStopped` -4. Controller polls event queue on main thread via `after()` and updates UI safely. +5. Controller polls the event queue on the Tk main thread via `after()` and updates UI safely. ## Instrument Access -- Instrument model + address resolve through `equips_factory`. -- AWG and OSC commands are executed via `AwgPort` / `OscPort` adapters. +- Instrument model and address resolution go through `equips_factory`. +- AWG and OSC commands are executed through `AwgPort` and `OscPort` adapters. - Connection scanning is provided by `PyVisaResourceScanner` and `ConnectionMonitor`. +- `src/equips.py` is intentionally treated as a vendor compatibility layer. It contains legacy SCPI/serial behavior that should not be casually refactored without physical instrument verification. ## Persistence - Settings: `__config__/settings.json` -- Measurement files: MAT/CSV/TXT (+ optional plot PNG) +- Measurement files: MAT/CSV/TXT plus optional plot PNG files - Reference files: MAT + +Runtime locations are centralized through `AppPaths` in `app/runtime/paths.py`. + +## Test Strategy + +The automated tests stay hardware-free by using pure domain tests and fake instrument ports. Live instrument verification remains a manual/operator workflow. diff --git a/docs/case_study.md b/docs/case_study.md new file mode 100644 index 0000000..cacfd1f --- /dev/null +++ b/docs/case_study.md @@ -0,0 +1,43 @@ +# Case Study + +## Problem + +Manual AWG/oscilloscope sweep measurement is repetitive and error-prone. An operator must configure generator output, oscilloscope channels, trigger mode, acquisition timing, calibration/reference behavior, and data export for each run. + +## Constraints + +- The application controls physical instruments through VISA/LAN/serial paths. +- The UI must stay responsive while long sweeps run. +- Sweep math and signal processing should be testable without hardware. +- Instrument-specific commands should be isolated from application logic. +- Output data should be usable in analysis tools through MAT/CSV/TXT files. + +## Architecture + +The refactor separates the workflow into four main layers: + +- `presentation/tk`: Tkinter controls, dialogs, event handling, and plots. +- `application`: use cases, events, DTOs, and ports. +- `domain`: settings models, validation, sweep generation, signal processing, calibration, and export shaping. +- `infrastructure`: instrument adapters, resource scanning, settings persistence, and measurement IO. + +The legacy `src/equips.py` driver file remains as a vendor compatibility layer and is wrapped by infrastructure adapters. + +## Testing Strategy + +The automated tests avoid physical instruments by using: + +- pure tests for sweep generation, signal processing, auto range, and serialization +- fake AWG/OSC ports for the start-sweep use case +- temporary directories for measurement export/load round trips +- task-runner tests around threading, auto-save, cleanup, and warnings + +This keeps the core behavior reviewable on any development machine. + +## Output + +The app exports measurement data as MAT, CSV, and TXT files. Plot PNGs can be saved when the UI provides figure handles. + +## What This Demonstrates + +This project demonstrates real-world engineering in a physical-system context: separating hardware side effects from testable logic, preserving a practical desktop workflow, and improving maintainability without pretending the tool is a certified lab platform. diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..8130d2a --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,71 @@ +# Extending The Application + +This project is intentionally structured around extension seams rather than direct imports between every layer. + +## Composition Root + +`src/app/bootstrap.py` is the desktop composition root. It wires: + +- repositories +- use cases +- instrument scanner +- instrument port factory +- address resolver +- Tk controller +- runtime paths + +`src/main.py` should stay thin. If the app later gains CLI, scripted, or simulated run modes, add a new composition function instead of pushing more wiring into UI classes. + +## Runtime Paths + +`AppPaths` in `src/app/runtime/paths.py` centralizes the repo/runtime directories: + +- `__config__/settings.json` +- `__data__/` +- `__data__/measurement/` + +Use `AppPaths` instead of recomputing `Path(__file__).parents[...]` in new code. + +## Adding A New Instrument + +1. Add or verify the model label in `src/app/shared/mapping.py`. +2. Add the vendor driver mapping in `src/equips.py` only if the low-level SCPI behavior is known. +3. Prefer adding behavior through `app.infrastructure.instruments` adapters rather than calling `equips.py` from UI or use cases. +4. Keep `AwgPort` / `OscPort` as the application contract. +5. Add hardware-free tests with fake ports before doing live bench validation. + +## Adding A New Persistence Format + +Keep use cases depending on repository ports. Format-specific logic belongs under `app.infrastructure.persistence`. + +For a new measurement format: + +1. Add a loader/exporter implementation in infrastructure. +2. Keep `SweepResult` and `AppSettings` as the domain boundary. +3. Add round-trip tests with temporary directories. +4. Do not put file dialogs or Tk concerns in persistence code. + +## Adding A New UI Field + +New user-facing sweep/settings fields usually touch these files: + +- `domain/models.py` +- `domain/validators.py` +- `infrastructure/persistence/settings_serializer.py` +- `infrastructure/persistence/settings_defaults.py` +- `presentation/tk/view_model.py` +- `presentation/tk/control_panel.py` +- `presentation/tk/mapper.py` +- tests for serializer and mapping behavior + +If a field changes instrument behavior, add coverage at the application service or use-case level with fake ports. + +## Boundary Tests + +`tests/test_architecture_boundaries.py` prevents the main layering rules from drifting: + +- domain stays pure +- application does not import presentation or infrastructure +- presentation does not import infrastructure + +Treat failures in those tests as design feedback, not just lint failures. diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..3105f54 --- /dev/null +++ b/docs/images/README.md @@ -0,0 +1,8 @@ +# Screenshot Capture Notes + +Expected portfolio screenshots: + +- `main_ui.png`: the configured desktop app before a sweep. +- `sweep_result.png`: a completed or loaded sweep result. The current file is generated from `demo_data/Demo(2).mat`. + +Capture these from the real Tk desktop app. Do not replace them with generated mockups, because the value of this project is that it controls a real lab workflow. diff --git a/docs/images/sweep_result.png b/docs/images/sweep_result.png new file mode 100644 index 0000000000000000000000000000000000000000..fff7681ba16f1a478e3bbd70735ea74de4f55f09 GIT binary patch literal 107308 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�uh92qBrx$T9SE=Cgb@~KK$mdxAzCo;P^MSVx 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 OsVJ`^mo97c;6DJFx^_zd literal 0 HcmV?d00001 diff --git a/docs/operator_guide.md b/docs/operator_guide.md new file mode 100644 index 0000000..d892d3c --- /dev/null +++ b/docs/operator_guide.md @@ -0,0 +1,70 @@ +# Operator Guide + +This guide summarizes the live workflow for the desktop app. The original Word guide in `UserGuide/` can remain as a detailed operator artifact, but this Markdown version is readable directly on GitHub. + +## 1. Connect Instruments + +1. Connect the AWG output to the device under test. +2. Connect the oscilloscope test channel to the measured output. +3. For dual-channel correction, connect the oscilloscope reference channel. +4. For triggered operation, connect or select the trigger channel. +5. Confirm VISA/LAN visibility with the instrument scanner or external VISA tooling. + +## 2. Configure Sweep Parameters + +- Start/stop frequency define the sweep range. +- Linear mode uses a frequency step. +- Log mode uses a step count. +- AWG amplitude is configured in Vpp. +- Oscilloscope range and offset define the vertical acquisition window. +- Coupling and impedance should match the probe, DUT, and measurement setup. + +## 3. Choose Correction And Trigger Mode + +- No correction: gain is computed against the configured AWG amplitude. +- Dual-channel correction: gain and phase are computed against a measured reference channel. +- Reference calibration: a loaded reference MAT file can correct measured points. +- Free-run mode captures without arming an edge trigger. +- Triggered mode arms the selected oscilloscope trigger channel. + +## 4. Run A Sweep + +1. Review hardware settings and safety limits. +2. Start the sweep. +3. Watch progress and warnings. +4. Stop if the DUT, waveform, range, or instrument state looks wrong. +5. Save the measurement if auto-save is disabled. + +## 5. Output Files + +Saving a measurement writes MAT, CSV, and TXT files. If plot figures are supplied, gain and gain-dB PNG files are also written. + +The CSV columns are: + +- `freq_hz` +- `gain_linear` +- `gain_db` +- `phase_deg` + +Missing phase values are exported as blank cells in CSV and `nan` values in TXT/MAT arrays. + +## 6. Demo Data + +The files in `demo_data/` can be loaded through the measurement loader path to inspect historical/sampled measurement structure without instruments. See `demo_data/README.md`. + +## 7. Troubleshooting + +- No resources visible: check VISA backend, LAN connectivity, USB/GPIB cable, or serial permissions. +- Sweep fails immediately: verify model label, address, impedance/coupling combinations, and numeric settings. +- Flat or clipped waveform: reduce AWG amplitude or adjust oscilloscope range/offset. +- Unexpected phase: verify reference channel, trigger mode, and cable/probe delays. +- Save/load failure: confirm output directory permissions and supported file suffixes. + +## 8. Screenshots + +For portfolio documentation, capture: + +- `docs/images/main_ui.png`: app configured before a sweep. +- `docs/images/sweep_result.png`: completed sweep with plotted result. + +Screenshots should be captured from the real desktop app rather than mocked or generated images. diff --git a/docs/safety.md b/docs/safety.md new file mode 100644 index 0000000..446b01b --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,40 @@ +# Safety Notes + +Auto-Load-off-Test controls physical lab instruments. It should be used as a local engineering tool by an operator who understands the connected AWG, oscilloscope, cables, probes, load impedance, and device-under-test limits. + +## Scope + +This project is not a certified production test platform. It does not replace lab safety procedures, instrument manuals, current/voltage limits, or operator judgment. + +## Hardware Assumptions + +- Supported model labels are defined in `src/app/shared/mapping.py`. +- Live operation uses VISA/LAN/serial access through `src/equips.py` via infrastructure adapters. +- Default settings are conservative examples, not a guarantee that a connected DUT is safe. +- The operator must verify AWG amplitude, frequency range, impedance, coupling mode, oscilloscope vertical range, and trigger configuration before starting a sweep. + +## Stop And Shutdown Behavior + +- Pressing Stop sets a shared stop event. +- The sweep loop checks that event between frequency points and emits `SweepStopped` with the partial result. +- Runner shutdown signals stop and waits briefly for the worker thread before closing instrument ports. +- AWG shutdown attempts to turn the configured output channel off before closing the port. +- If output-off or port-close fails, the runner emits a `SweepWarning` so the UI/event log can surface the cleanup failure. + +## Exception Behavior + +- Validation failures emit `SweepFailed` with a validation code. +- Runtime sweep failures emit `SweepFailed` and return an empty result. +- Cleanup failures should not hide the original sweep result, but they should be visible as warnings. + +## Operator Responsibility + +Before live measurement: + +1. Confirm the selected instrument models and VISA addresses. +2. Confirm load impedance and coupling. +3. Confirm AWG amplitude and sweep frequency limits. +4. Confirm oscilloscope range, offset, and trigger channel. +5. Keep physical access to instrument front panels and emergency stop procedures. + +Automated tests use mocked ports and do not validate real hardware behavior. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f67b2ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "auto-load-off-test" +version = "0.1.0" +description = "Desktop AWG/oscilloscope sweep measurement automation tool." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.23", + "scipy>=1.10", + "matplotlib>=3.7", + "mplcursors>=0.5", + "pyvisa>=1.13", + "pyserial>=3.5", + "pyvisa-py>=0.7", +] + +[project.scripts] +auto-load-off-test = "main:main" + +[project.optional-dependencies] +dev = [ + "ruff>=0.4", +] +build = [ + "pyinstaller>=6", +] + +[tool.setuptools] +package-dir = {"" = "src"} +py-modules = ["main", "equips", "mapping", "cvtTools"] + +[tool.setuptools.packages.find] +where = ["src"] +include = ["app*"] + +[tool.ruff] +line-length = 120 +target-version = "py310" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..64394a9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +ruff>=0.4 + diff --git a/requirements.txt b/requirements.txt index 8222286..46bc67f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,7 @@ -requests -PyGithub -pyinstaller -numpy -scipy -matplotlib -mplcursors -pyvisa -pyserial -pyvisa-py -importlib-metadata; python_version < "3.8" +numpy>=1.23 +scipy>=1.10 +matplotlib>=3.7 +mplcursors>=0.5 +pyvisa>=1.13 +pyserial>=3.5 +pyvisa-py>=0.7 diff --git a/src/app/application/ports/instruments.py b/src/app/application/ports/instruments.py index cdc027b..1e44c2a 100644 --- a/src/app/application/ports/instruments.py +++ b/src/app/application/ports/instruments.py @@ -1,13 +1,18 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Protocol import numpy as np +from app.domain.models import InstrumentSetup + class AwgPort(Protocol): def reset(self) -> None: ... def output_on(self, channel: int) -> None: ... + def output_off(self, channel: int) -> None: ... def set_impedance(self, mode: str, channel: int) -> None: ... def set_frequency(self, hz: float, channel: int) -> None: ... def get_frequency(self, channel: int) -> float: ... @@ -34,3 +39,14 @@ def close(self) -> None: ... class ResourceScannerPort(Protocol): def list_resources(self) -> tuple[str, ...]: ... + + +@dataclass(slots=True) +class InstrumentPorts: + awg: AwgPort + osc: OscPort + awg_address: str + osc_address: str + + +InstrumentPortsFactory = Callable[[InstrumentSetup], InstrumentPorts] diff --git a/src/app/application/services/connection_monitor.py b/src/app/application/services/connection_monitor.py index a8aeb32..7997d18 100644 --- a/src/app/application/services/connection_monitor.py +++ b/src/app/application/services/connection_monitor.py @@ -34,6 +34,8 @@ def start(self) -> None: def stop(self) -> None: self._stop.set() + if self._thread and self._thread.is_alive() and threading.current_thread() is not self._thread: + self._thread.join(timeout=self._interval_s * 2) def _run(self) -> None: while not self._stop.is_set(): @@ -41,8 +43,10 @@ def _run(self) -> None: osc_connected = False try: resources = self._scanner.list_resources() - awg_connected = self._get_awg_address() in resources if self._get_awg_address() else False - osc_connected = self._get_osc_address() in resources if self._get_osc_address() else False + awg_address = self._get_awg_address() + osc_address = self._get_osc_address() + awg_connected = awg_address in resources if awg_address else False + osc_connected = osc_address in resources if osc_address else False except Exception: awg_connected = False osc_connected = False diff --git a/src/app/application/services/sweep_task_runner.py b/src/app/application/services/sweep_task_runner.py new file mode 100644 index 0000000..818a97b --- /dev/null +++ b/src/app/application/services/sweep_task_runner.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import threading +from collections.abc import Callable +from pathlib import Path + +from app.application.dto import SaveTarget, StartSweepCommand +from app.application.events import EventEmitter, SweepFailed, SweepWarning +from app.application.ports.instruments import InstrumentPorts, InstrumentPortsFactory +from app.application.use_cases.save_measurement import SaveMeasurementUseCase +from app.application.use_cases.start_sweep import StartSweepUseCase +from app.application.use_cases.stop_sweep import StopSweepUseCase +from app.domain.models import AppSettings + + +class SweepTaskRunner: + def __init__( + self, + *, + emitter: EventEmitter, + save_measurement_use_case: SaveMeasurementUseCase, + auto_save_dir: Path, + ports_factory: InstrumentPortsFactory, + use_case_factory: Callable[..., StartSweepUseCase] = StartSweepUseCase, + ) -> None: + self._emitter = emitter + self._save_measurement_use_case = save_measurement_use_case + self._auto_save_dir = auto_save_dir + self._ports_factory = ports_factory + self._use_case_factory = use_case_factory + + self._ports: InstrumentPorts | None = None + self._ports_lock = threading.Lock() + self._sweep_thread: threading.Thread | None = None + self._stop_use_case: StopSweepUseCase | None = None + self._active_awg_channel: int | None = None + + def is_running(self) -> bool: + return self._sweep_thread is not None and self._sweep_thread.is_alive() + + def start( + self, + *, + settings: AppSettings, + calibration_enabled: bool, + reference_interpolator: object | None, + ) -> None: + if self.is_running(): + return + + ports = self._ports_factory(settings.setup) + stop_event = threading.Event() + self._stop_use_case = StopSweepUseCase(stop_event=stop_event) + + cmd = StartSweepCommand( + settings=settings, + calibration_enabled=calibration_enabled, + reference_interpolator=reference_interpolator, + ) + start_use_case = self._use_case_factory(awg=ports.awg, osc=ports.osc, stop_event=stop_event) + + with self._ports_lock: + self._ports = ports + self._active_awg_channel = settings.setup.channels.awg_ch + self._sweep_thread = threading.Thread(target=self._run_sweep, args=(start_use_case, cmd), daemon=True) + self._sweep_thread.start() + + def stop(self) -> None: + if self._stop_use_case is not None: + self._stop_use_case.stop() + + def wait(self, timeout: float | None = None) -> None: + if self._sweep_thread is not None: + self._sweep_thread.join(timeout=timeout) + + def shutdown(self, timeout: float = 2.0) -> None: + self.stop() + self.wait(timeout=timeout) + if self.is_running(): + self._emit_warning( + code="SHUTDOWN_TIMEOUT", + message="Sweep worker did not stop before shutdown timeout; ports will close when the worker exits.", + ) + return + self._close_ports() + + def _run_sweep(self, start_use_case: StartSweepUseCase, cmd: StartSweepCommand) -> None: + try: + result = start_use_case.run(cmd, self._emitter) + if not result.is_empty and cmd.settings.auto_save_data: + target = SaveTarget( + base_path=self._auto_save_dir / "measurement", + include_timestamp=True, + figures={}, + ) + self._save_measurement_use_case.execute(result=result, settings=cmd.settings, target=target) + except Exception as exc: # noqa: BLE001 + self._emitter.emit(SweepFailed(error_code="SWEEP_THREAD", message=str(exc))) + finally: + self._close_ports() + + def _close_ports(self) -> None: + with self._ports_lock: + ports = self._ports + awg_channel = self._active_awg_channel + self._ports = None + self._active_awg_channel = None + + if ports is None: + return + + if awg_channel is not None: + try: + ports.awg.output_off(awg_channel) + except Exception as exc: # noqa: BLE001 + self._emit_warning(code="AWG_OUTPUT_OFF_FAILED", message=str(exc)) + + try: + ports.awg.close() + except Exception as exc: # noqa: BLE001 + self._emit_warning(code="AWG_CLOSE_FAILED", message=str(exc)) + + try: + ports.osc.close() + except Exception as exc: # noqa: BLE001 + self._emit_warning(code="OSC_CLOSE_FAILED", message=str(exc)) + + def _emit_warning(self, *, code: str, message: str) -> None: + self._emitter.emit(SweepWarning(code=code, message=message)) + diff --git a/src/app/application/use_cases/start_sweep.py b/src/app/application/use_cases/start_sweep.py index a4d9394..b0fb1e8 100644 --- a/src/app/application/use_cases/start_sweep.py +++ b/src/app/application/use_cases/start_sweep.py @@ -4,7 +4,6 @@ import time from datetime import datetime, timezone -from app.application.dto import StartSweepCommand from app.application.dto import StartSweepCommand from app.application.events import ( EventEmitter, diff --git a/src/app/bootstrap.py b/src/app/bootstrap.py new file mode 100644 index 0000000..c5f3e26 --- /dev/null +++ b/src/app/bootstrap.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.application.use_cases.load_measurement import LoadMeasurementUseCase +from app.application.use_cases.load_reference import LoadReferenceUseCase +from app.application.use_cases.save_measurement import SaveMeasurementUseCase +from app.application.use_cases.settings_use_case import SettingsUseCase +from app.infrastructure.instruments.equips_factory import create_instrument_ports, resolve_visa_address +from app.infrastructure.instruments.resource_scanner import PyVisaResourceScanner +from app.infrastructure.persistence.measurement_repo_mat_csv import MatCsvMeasurementRepository +from app.infrastructure.persistence.reference_repo_mat import MatReferenceRepository +from app.infrastructure.persistence.settings_repo_json import JsonSettingsRepository +from app.presentation.tk.app_window import AppWindow +from app.presentation.tk.controller import TkController +from app.runtime.paths import AppPaths + + +@dataclass(slots=True) +class DesktopApp: + window: AppWindow + controller: TkController + paths: AppPaths + + def run(self) -> None: + self.controller.initialize() + self.window.mainloop() + + +def build_desktop_app(paths: AppPaths | None = None) -> DesktopApp: + app_paths = paths or AppPaths.default() + window = AppWindow() + vm = window.vm + + settings_repo = JsonSettingsRepository(config_path=app_paths.settings_path) + measurement_repo = MatCsvMeasurementRepository() + reference_repo = MatReferenceRepository() + save_measurement_use_case = SaveMeasurementUseCase(measurement_repo) + + controller = TkController( + window=window, + vm=vm, + settings_use_case=SettingsUseCase(settings_repo), + save_measurement_use_case=save_measurement_use_case, + load_measurement_use_case=LoadMeasurementUseCase(measurement_repo), + load_reference_use_case=LoadReferenceUseCase(reference_repo), + scanner=PyVisaResourceScanner(), + ports_factory=create_instrument_ports, + resolve_address=resolve_visa_address, + paths=app_paths, + ) + return DesktopApp(window=window, controller=controller, paths=app_paths) + + +def run_desktop_app(paths: AppPaths | None = None) -> None: + build_desktop_app(paths=paths).run() + diff --git a/src/app/domain/calibration.py b/src/app/domain/calibration.py index 22729da..5445f26 100644 --- a/src/app/domain/calibration.py +++ b/src/app/domain/calibration.py @@ -91,7 +91,10 @@ def apply_reference_to_point(point: SweepPoint, ref_value: complex | float, use_ else: raw = complex(point.gain_linear, 0.0) - corrected = raw / complex(ref_value) + ref_complex = complex(ref_value) + if abs(ref_complex) < eps: + ref_complex = complex(eps, 0.0) + corrected = raw / ref_complex gain_linear = float(np.abs(corrected)) gain_db = float(20.0 * np.log10(max(gain_linear, eps))) phase_deg = float(np.degrees(np.angle(corrected))) diff --git a/src/app/domain/exporters.py b/src/app/domain/exporters.py index 614f754..5fe7428 100644 --- a/src/app/domain/exporters.py +++ b/src/app/domain/exporters.py @@ -12,8 +12,18 @@ def result_to_arrays(result: SweepResult) -> dict[str, np.ndarray]: gain = np.array([p.gain_linear for p in result.points], dtype=float) gain_db = np.array([p.gain_db for p in result.points], dtype=float) - phase_values = [p.phase_deg for p in result.points if p.phase_deg is not None] - complex_values = [p.gain_complex for p in result.points if p.gain_complex is not None] + phase_values = np.array( + [np.nan if p.phase_deg is None else float(p.phase_deg) for p in result.points], + dtype=float, + ) + complex_real = np.array( + [np.nan if p.gain_complex is None else float(p.gain_complex.real) for p in result.points], + dtype=float, + ) + complex_imag = np.array( + [np.nan if p.gain_complex is None else float(p.gain_complex.imag) for p in result.points], + dtype=float, + ) arrays: dict[str, np.ndarray] = { "freq_hz": freq, @@ -21,11 +31,11 @@ def result_to_arrays(result: SweepResult) -> dict[str, np.ndarray]: "gain_db": gain_db, } - if phase_values: - arrays["phase_deg"] = np.array(phase_values, dtype=float) - if complex_values: - arrays["gain_complex_real"] = np.array([v.real for v in complex_values], dtype=float) - arrays["gain_complex_imag"] = np.array([v.imag for v in complex_values], dtype=float) + if np.any(~np.isnan(phase_values)): + arrays["phase_deg"] = phase_values + if np.any(~np.isnan(complex_real)) or np.any(~np.isnan(complex_imag)): + arrays["gain_complex_real"] = complex_real + arrays["gain_complex_imag"] = complex_imag return arrays diff --git a/src/app/infrastructure/instruments/awg_adapter.py b/src/app/infrastructure/instruments/awg_adapter.py index 00af7ab..15d165b 100644 --- a/src/app/infrastructure/instruments/awg_adapter.py +++ b/src/app/infrastructure/instruments/awg_adapter.py @@ -11,7 +11,10 @@ def reset(self) -> None: self._inst.rst() def output_on(self, channel: int) -> None: - self._inst.set_on(ch=channel) + self._inst.set_on(on=True, ch=channel) + + def output_off(self, channel: int) -> None: + self._inst.set_on(on=False, ch=channel) def set_impedance(self, mode: str, channel: int) -> None: self._inst.set_imp(imp=mode, ch=channel) diff --git a/src/app/infrastructure/instruments/equips_factory.py b/src/app/infrastructure/instruments/equips_factory.py index 7ce5794..d563d77 100644 --- a/src/app/infrastructure/instruments/equips_factory.py +++ b/src/app/infrastructure/instruments/equips_factory.py @@ -1,23 +1,12 @@ from __future__ import annotations -from dataclasses import dataclass - -from app.application.ports.instruments import AwgPort, OscPort +from app.application.ports.instruments import InstrumentPorts from app.domain.enums import ConnectionMode from app.domain.models import InstrumentEndpoint, InstrumentSetup from app.infrastructure.instruments.awg_adapter import EquipsAwgAdapter from app.infrastructure.instruments.osc_adapter import EquipsOscAdapter -@dataclass(slots=True) -class InstrumentPorts: - awg: AwgPort - osc: OscPort - awg_address: str - osc_address: str - - - def resolve_visa_address(endpoint: InstrumentEndpoint) -> str: if endpoint.connect_mode == ConnectionMode.LAN and endpoint.ip_address.strip(): return f"TCPIP0::{endpoint.ip_address.strip()}::INSTR" diff --git a/src/app/infrastructure/instruments/ports.py b/src/app/infrastructure/instruments/ports.py index c41f0bd..c5fb368 100644 --- a/src/app/infrastructure/instruments/ports.py +++ b/src/app/infrastructure/instruments/ports.py @@ -1,3 +1,3 @@ -from app.application.ports.instruments import AwgPort, OscPort, ResourceScannerPort +from app.application.ports.instruments import AwgPort, InstrumentPorts, InstrumentPortsFactory, OscPort, ResourceScannerPort -__all__ = ["AwgPort", "OscPort", "ResourceScannerPort"] +__all__ = ["AwgPort", "OscPort", "ResourceScannerPort", "InstrumentPorts", "InstrumentPortsFactory"] diff --git a/src/app/infrastructure/persistence/measurement_exporter.py b/src/app/infrastructure/persistence/measurement_exporter.py index d5c48ff..cc1ba27 100644 --- a/src/app/infrastructure/persistence/measurement_exporter.py +++ b/src/app/infrastructure/persistence/measurement_exporter.py @@ -49,7 +49,7 @@ def export(self, result: SweepResult, settings: AppSettings, target: SaveTarget) float(freq[idx]), float(gain_linear[idx]) if idx < len(gain_linear) else "", float(gain_db[idx]) if idx < len(gain_db) else "", - float(phase[idx]) if idx < len(phase) else "", + _optional_float(phase, idx), ] ) @@ -81,3 +81,12 @@ def export(self, result: SweepResult, settings: AppSettings, target: SaveTarget) gain_plot_path=gain_plot_path, db_plot_path=db_plot_path, ) + + +def _optional_float(values: np.ndarray, idx: int) -> float | str: + if idx >= len(values): + return "" + value = float(values[idx]) + if np.isnan(value): + return "" + return value diff --git a/src/app/infrastructure/persistence/measurement_loader.py b/src/app/infrastructure/persistence/measurement_loader.py index cd87bba..822184f 100644 --- a/src/app/infrastructure/persistence/measurement_loader.py +++ b/src/app/infrastructure/persistence/measurement_loader.py @@ -18,9 +18,11 @@ def load(self, file_path: str) -> LoadedMeasurement: if suffix == ".mat": payload = loadmat(path) freq = self._get_array(payload, ["freq_hz", "freq"]) # type: ignore[arg-type] - gain_linear = self._get_array(payload, ["gain_linear", "gain_raw"]) gain_db = self._get_array(payload, ["gain_db", "gain_db_raw", "gain_db_corr"]) - phase = self._get_array(payload, ["phase_deg", "phase", "phase_deg_corr"], required=False) + gain_linear = self._get_array(payload, ["gain_linear", "gain_raw"], required=False) + if gain_linear is None: + gain_linear = np.power(10.0, gain_db / 20.0) + phase = self._get_array(payload, ["phase_deg", "phase", "phase_deg_corr", "phase_corr"], required=False) elif suffix == ".csv": freq_l: list[float] = [] gain_l: list[float] = [] @@ -32,12 +34,13 @@ def load(self, file_path: str) -> LoadedMeasurement: freq_l.append(float(row.get("freq_hz", "0") or 0.0)) gain_l.append(float(row.get("gain_linear", "0") or 0.0)) gain_db_l.append(float(row.get("gain_db", "0") or 0.0)) - if row.get("phase_deg") not in (None, ""): - phase_l.append(float(row["phase_deg"])) + phase_value = row.get("phase_deg") + phase_l.append(float(phase_value) if phase_value not in (None, "") else np.nan) freq = np.array(freq_l, dtype=float) gain_linear = np.array(gain_l, dtype=float) gain_db = np.array(gain_db_l, dtype=float) - phase = np.array(phase_l, dtype=float) if phase_l else None + phase_values = np.array(phase_l, dtype=float) if phase_l else None + phase = phase_values if phase_values is not None and np.any(~np.isnan(phase_values)) else None payload = {"freq_hz": freq, "gain_linear": gain_linear, "gain_db": gain_db} if phase is not None: payload["phase_deg"] = phase @@ -46,7 +49,7 @@ def load(self, file_path: str) -> LoadedMeasurement: points: list[SweepPoint] = [] for idx in range(len(freq)): - phase_deg = float(phase[idx]) if phase is not None and idx < len(phase) else None + phase_deg = _optional_phase(phase, idx) points.append( SweepPoint( freq_hz=float(freq[idx]), @@ -76,7 +79,16 @@ def _get_array( for key in keys: value = payload.get(key) if isinstance(value, np.ndarray): - return np.asarray(value, dtype=float).squeeze() + return np.atleast_1d(np.asarray(value, dtype=float).squeeze()) if required: raise ValueError(f"Missing required keys: {keys}") return None + + +def _optional_phase(phase: np.ndarray | None, idx: int) -> float | None: + if phase is None or idx >= len(phase): + return None + value = float(phase[idx]) + if np.isnan(value): + return None + return value diff --git a/src/app/infrastructure/persistence/settings_repo_json.py b/src/app/infrastructure/persistence/settings_repo_json.py index 730e55a..6c2e879 100644 --- a/src/app/infrastructure/persistence/settings_repo_json.py +++ b/src/app/infrastructure/persistence/settings_repo_json.py @@ -8,6 +8,7 @@ from app.infrastructure.persistence.file_store import FileStore from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory from app.infrastructure.persistence.settings_serializer import SettingsSerializer +from app.runtime.paths import AppPaths class JsonSettingsRepository: @@ -19,8 +20,7 @@ def __init__( serializer: SettingsSerializer | None = None, file_store: FileStore | None = None, ) -> None: - root = Path(__file__).resolve().parents[4] - default_path = root / "__config__" / "settings.json" + default_path = AppPaths.default().settings_path self._serializer = serializer or SettingsSerializer() self._defaults_factory = defaults_factory or DefaultSettingsFactory() diff --git a/src/app/presentation/tk/app_window.py b/src/app/presentation/tk/app_window.py index bdbb1de..3a46c11 100644 --- a/src/app/presentation/tk/app_window.py +++ b/src/app/presentation/tk/app_window.py @@ -1,10 +1,8 @@ from __future__ import annotations import tkinter as tk -from tkinter import ttk - -from app.shared.mapping import Mapping +from app.presentation.tk.control_panel import ControlPanel from app.presentation.tk.plot_widget import PlotWidget from app.presentation.tk.view_model import ViewModel @@ -28,7 +26,9 @@ def __init__(self, vm: ViewModel | None = None) -> None: right = tk.Frame(container) right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=8, pady=8) - self._build_controls(left) + self.control_panel = ControlPanel(left, self.vm) + self.control_panel.pack(fill=tk.Y) + self._alias_control_widgets() self.plot_widget = PlotWidget(right) self.plot_widget.frame.pack(fill=tk.BOTH, expand=True) @@ -49,196 +49,6 @@ def __init__(self, vm: ViewModel | None = None) -> None: self.osc_light = self.canvas_osc.create_oval(2, 2, 12, 12, fill="red") tk.Label(status_bar, text="OSC").pack(side=tk.RIGHT) - def _build_controls(self, parent: tk.Misc) -> None: - row = 0 - - def add_label(text: str, r: int, c: int = 0) -> None: - tk.Label(parent, text=text).grid(row=r, column=c, sticky="w", padx=3, pady=2) - - add_label("AWG model", row) - ttk.Combobox(parent, textvariable=self.vm.awg_model, values=Mapping.values_awg, width=12).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - add_label("OSC model", row) - ttk.Combobox(parent, textvariable=self.vm.osc_model, values=Mapping.values_osc, width=12).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - add_label("AWG conn", row) - ttk.Combobox(parent, textvariable=self.vm.awg_connect_mode, values=["auto", "lan"], width=12).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - add_label("AWG VISA", row) - tk.Entry(parent, textvariable=self.vm.awg_visa, width=28).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("AWG IP", row) - tk.Entry(parent, textvariable=self.vm.awg_ip, width=28).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC conn", row) - ttk.Combobox(parent, textvariable=self.vm.osc_connect_mode, values=["auto", "lan"], width=12).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - add_label("OSC VISA", row) - tk.Entry(parent, textvariable=self.vm.osc_visa, width=28).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC IP", row) - tk.Entry(parent, textvariable=self.vm.osc_ip, width=28).grid(row=row, column=1, sticky="ew") - row += 1 - - ttk.Separator(parent, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=2, sticky="ew", pady=8) - row += 1 - - add_label("Freq unit", row) - ttk.Combobox(parent, textvariable=self.vm.freq_unit, values=Mapping.values_freq_unit, width=8).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - add_label("Start", row) - tk.Entry(parent, textvariable=self.vm.start_freq).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("Stop", row) - tk.Entry(parent, textvariable=self.vm.stop_freq).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("Step", row) - tk.Entry(parent, textvariable=self.vm.step_freq).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("Step count", row) - tk.Entry(parent, textvariable=self.vm.step_count).grid(row=row, column=1, sticky="ew") - row += 1 - - tk.Checkbutton(parent, text="Log sweep", variable=self.vm.is_log).grid(row=row, column=0, columnspan=2, sticky="w") - row += 1 - - ttk.Separator(parent, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=2, sticky="ew", pady=8) - row += 1 - - add_label("AWG amp (Vpp)", row) - tk.Entry(parent, textvariable=self.vm.awg_amp).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("AWG imp", row) - ttk.Combobox(parent, textvariable=self.vm.awg_imp, values=["50", "INF"], width=8).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC range", row) - tk.Entry(parent, textvariable=self.vm.osc_range).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC offset", row) - tk.Entry(parent, textvariable=self.vm.osc_offset).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC points", row) - tk.Entry(parent, textvariable=self.vm.osc_points).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC imp", row) - ttk.Combobox(parent, textvariable=self.vm.osc_imp, values=["50", "INF"], width=8).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC coup", row) - ttk.Combobox(parent, textvariable=self.vm.osc_coupling, values=["DC", "AC"], width=8).grid(row=row, column=1, sticky="ew") - row += 1 - - ttk.Separator(parent, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=2, sticky="ew", pady=8) - row += 1 - - add_label("AWG ch", row) - tk.Entry(parent, textvariable=self.vm.awg_ch).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC test ch", row) - tk.Entry(parent, textvariable=self.vm.osc_test_ch).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC ref ch", row) - tk.Entry(parent, textvariable=self.vm.osc_ref_ch).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("OSC trig ch", row) - tk.Entry(parent, textvariable=self.vm.osc_trig_ch).grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("Correction", row) - ttk.Combobox(parent, textvariable=self.vm.correction_mode, values=["none", "single", "dual"], width=10).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - add_label("Trigger", row) - ttk.Combobox(parent, textvariable=self.vm.trigger_mode, values=["free_run", "triggered"], width=10).grid( - row=row, column=1, sticky="ew" - ) - row += 1 - - tk.Checkbutton(parent, text="Auto range", variable=self.vm.auto_range).grid(row=row, column=0, columnspan=2, sticky="w") - row += 1 - tk.Checkbutton(parent, text="Auto reset", variable=self.vm.auto_reset).grid(row=row, column=0, columnspan=2, sticky="w") - row += 1 - tk.Checkbutton(parent, text="Enable calibration", variable=self.vm.calibration_enabled).grid( - row=row, column=0, columnspan=2, sticky="w" - ) - row += 1 - tk.Checkbutton(parent, text="Auto save data", variable=self.vm.auto_save_data).grid( - row=row, column=0, columnspan=2, sticky="w" - ) - row += 1 - - add_label("Figure", row) - self.cmb_figure = ttk.Combobox(parent, textvariable=self.vm.figure_mode, values=["gain", "gain_db"], width=10) - self.cmb_figure.grid(row=row, column=1, sticky="ew") - row += 1 - - add_label("Display", row) - self.cmb_mag_phase = ttk.Combobox( - parent, - textvariable=self.vm.magnitude_phase_mode, - values=["magnitude", "phase", "magnitude_phase"], - width=14, - ) - self.cmb_mag_phase.grid(row=row, column=1, sticky="ew") - row += 1 - - buttons = tk.Frame(parent) - buttons.grid(row=row, column=0, columnspan=2, sticky="ew", pady=10) - - self.btn_start = tk.Button(buttons, text="Start", width=9) - self.btn_start.pack(side=tk.LEFT, padx=2) - self.btn_stop = tk.Button(buttons, text="Stop", width=9, state="disabled") - self.btn_stop.pack(side=tk.LEFT, padx=2) - - self.btn_save_data = tk.Button(buttons, text="Save Data", width=9) - self.btn_save_data.pack(side=tk.LEFT, padx=2) - self.btn_load_data = tk.Button(buttons, text="Load Data", width=9) - self.btn_load_data.pack(side=tk.LEFT, padx=2) - - row += 1 - buttons2 = tk.Frame(parent) - buttons2.grid(row=row, column=0, columnspan=2, sticky="ew", pady=5) - - self.btn_load_ref = tk.Button(buttons2, text="Load Ref", width=9) - self.btn_load_ref.pack(side=tk.LEFT, padx=2) - self.btn_save_settings = tk.Button(buttons2, text="Save Settings", width=11) - self.btn_save_settings.pack(side=tk.LEFT, padx=2) - self.btn_load_settings = tk.Button(buttons2, text="Load Settings", width=11) - self.btn_load_settings.pack(side=tk.LEFT, padx=2) - - parent.grid_columnconfigure(1, weight=1) - def bind_actions( self, *, @@ -253,16 +63,17 @@ def bind_actions( on_figure_change, on_mag_phase_change, ) -> None: - self.btn_start.configure(command=on_start) - self.btn_stop.configure(command=on_stop) - self.btn_save_data.configure(command=on_save_data) - self.btn_load_data.configure(command=on_load_data) - self.btn_load_ref.configure(command=on_load_ref) - self.btn_save_settings.configure(command=on_save_settings) - self.btn_load_settings.configure(command=on_load_settings) - - self.cmb_figure.bind("<>", lambda _e: on_figure_change()) - self.cmb_mag_phase.bind("<>", lambda _e: on_mag_phase_change()) + self.control_panel.bind_actions( + on_start=on_start, + on_stop=on_stop, + on_save_data=on_save_data, + on_load_data=on_load_data, + on_load_ref=on_load_ref, + on_save_settings=on_save_settings, + on_load_settings=on_load_settings, + on_figure_change=on_figure_change, + on_mag_phase_change=on_mag_phase_change, + ) self._on_close = on_close def set_connection_status(self, awg_connected: bool, osc_connected: bool) -> None: @@ -274,3 +85,14 @@ def on_close(self) -> None: self._on_close() else: self.destroy() + + def _alias_control_widgets(self) -> None: + self.btn_start = self.control_panel.btn_start + self.btn_stop = self.control_panel.btn_stop + self.btn_save_data = self.control_panel.btn_save_data + self.btn_load_data = self.control_panel.btn_load_data + self.btn_load_ref = self.control_panel.btn_load_ref + self.btn_save_settings = self.control_panel.btn_save_settings + self.btn_load_settings = self.control_panel.btn_load_settings + self.cmb_figure = self.control_panel.cmb_figure + self.cmb_mag_phase = self.control_panel.cmb_mag_phase diff --git a/src/app/presentation/tk/control_panel.py b/src/app/presentation/tk/control_panel.py new file mode 100644 index 0000000..510ed55 --- /dev/null +++ b/src/app/presentation/tk/control_panel.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + +from app.presentation.tk.view_model import ViewModel +from app.shared.mapping import Mapping + + +class ControlPanel(tk.Frame): + def __init__(self, parent: tk.Misc, vm: ViewModel) -> None: + super().__init__(parent) + self._vm = vm + self._build() + + def bind_actions( + self, + *, + on_start, + on_stop, + on_save_data, + on_load_data, + on_load_ref, + on_save_settings, + on_load_settings, + on_figure_change, + on_mag_phase_change, + ) -> None: + self.btn_start.configure(command=on_start) + self.btn_stop.configure(command=on_stop) + self.btn_save_data.configure(command=on_save_data) + self.btn_load_data.configure(command=on_load_data) + self.btn_load_ref.configure(command=on_load_ref) + self.btn_save_settings.configure(command=on_save_settings) + self.btn_load_settings.configure(command=on_load_settings) + + self.cmb_figure.bind("<>", lambda _e: on_figure_change()) + self.cmb_mag_phase.bind("<>", lambda _e: on_mag_phase_change()) + + def _build(self) -> None: + row = 0 + + def add_label(text: str, r: int, c: int = 0) -> None: + tk.Label(self, text=text).grid(row=r, column=c, sticky="w", padx=3, pady=2) + + add_label("AWG model", row) + ttk.Combobox(self, textvariable=self._vm.awg_model, values=Mapping.values_awg, width=12).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + add_label("OSC model", row) + ttk.Combobox(self, textvariable=self._vm.osc_model, values=Mapping.values_osc, width=12).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + add_label("AWG conn", row) + ttk.Combobox(self, textvariable=self._vm.awg_connect_mode, values=["auto", "lan"], width=12).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + add_label("AWG VISA", row) + tk.Entry(self, textvariable=self._vm.awg_visa, width=28).grid(row=row, column=1, sticky="ew") + row += 1 + + add_label("AWG IP", row) + tk.Entry(self, textvariable=self._vm.awg_ip, width=28).grid(row=row, column=1, sticky="ew") + row += 1 + + add_label("OSC conn", row) + ttk.Combobox(self, textvariable=self._vm.osc_connect_mode, values=["auto", "lan"], width=12).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + add_label("OSC VISA", row) + tk.Entry(self, textvariable=self._vm.osc_visa, width=28).grid(row=row, column=1, sticky="ew") + row += 1 + + add_label("OSC IP", row) + tk.Entry(self, textvariable=self._vm.osc_ip, width=28).grid(row=row, column=1, sticky="ew") + row += 1 + + self._separator(row) + row += 1 + + add_label("Freq unit", row) + ttk.Combobox(self, textvariable=self._vm.freq_unit, values=Mapping.values_freq_unit, width=8).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + for label, variable in ( + ("Start", self._vm.start_freq), + ("Stop", self._vm.stop_freq), + ("Step", self._vm.step_freq), + ("Step count", self._vm.step_count), + ): + add_label(label, row) + tk.Entry(self, textvariable=variable).grid(row=row, column=1, sticky="ew") + row += 1 + + tk.Checkbutton(self, text="Log sweep", variable=self._vm.is_log).grid( + row=row, column=0, columnspan=2, sticky="w" + ) + row += 1 + + self._separator(row) + row += 1 + + for label, variable in ( + ("AWG amp (Vpp)", self._vm.awg_amp), + ("OSC range", self._vm.osc_range), + ("OSC offset", self._vm.osc_offset), + ("OSC points", self._vm.osc_points), + ): + if label == "OSC range": + add_label("AWG imp", row) + ttk.Combobox(self, textvariable=self._vm.awg_imp, values=["50", "INF"], width=8).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + add_label(label, row) + tk.Entry(self, textvariable=variable).grid(row=row, column=1, sticky="ew") + row += 1 + + add_label("OSC imp", row) + ttk.Combobox(self, textvariable=self._vm.osc_imp, values=["50", "INF"], width=8).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + add_label("OSC coup", row) + ttk.Combobox(self, textvariable=self._vm.osc_coupling, values=["DC", "AC"], width=8).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + self._separator(row) + row += 1 + + for label, variable in ( + ("AWG ch", self._vm.awg_ch), + ("OSC test ch", self._vm.osc_test_ch), + ("OSC ref ch", self._vm.osc_ref_ch), + ("OSC trig ch", self._vm.osc_trig_ch), + ): + add_label(label, row) + tk.Entry(self, textvariable=variable).grid(row=row, column=1, sticky="ew") + row += 1 + + add_label("Correction", row) + ttk.Combobox(self, textvariable=self._vm.correction_mode, values=["none", "single", "dual"], width=10).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + add_label("Trigger", row) + ttk.Combobox(self, textvariable=self._vm.trigger_mode, values=["free_run", "triggered"], width=10).grid( + row=row, column=1, sticky="ew" + ) + row += 1 + + for label, variable in ( + ("Auto range", self._vm.auto_range), + ("Auto reset", self._vm.auto_reset), + ("Enable calibration", self._vm.calibration_enabled), + ("Auto save data", self._vm.auto_save_data), + ): + tk.Checkbutton(self, text=label, variable=variable).grid(row=row, column=0, columnspan=2, sticky="w") + row += 1 + + add_label("Figure", row) + self.cmb_figure = ttk.Combobox(self, textvariable=self._vm.figure_mode, values=["gain", "gain_db"], width=10) + self.cmb_figure.grid(row=row, column=1, sticky="ew") + row += 1 + + add_label("Display", row) + self.cmb_mag_phase = ttk.Combobox( + self, + textvariable=self._vm.magnitude_phase_mode, + values=["magnitude", "phase", "magnitude_phase"], + width=14, + ) + self.cmb_mag_phase.grid(row=row, column=1, sticky="ew") + row += 1 + + row = self._build_primary_buttons(row) + self._build_secondary_buttons(row) + self.grid_columnconfigure(1, weight=1) + + def _separator(self, row: int) -> None: + ttk.Separator(self, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=2, sticky="ew", pady=8) + + def _build_primary_buttons(self, row: int) -> int: + buttons = tk.Frame(self) + buttons.grid(row=row, column=0, columnspan=2, sticky="ew", pady=10) + + self.btn_start = tk.Button(buttons, text="Start", width=9) + self.btn_start.pack(side=tk.LEFT, padx=2) + self.btn_stop = tk.Button(buttons, text="Stop", width=9, state="disabled") + self.btn_stop.pack(side=tk.LEFT, padx=2) + + self.btn_save_data = tk.Button(buttons, text="Save Data", width=9) + self.btn_save_data.pack(side=tk.LEFT, padx=2) + self.btn_load_data = tk.Button(buttons, text="Load Data", width=9) + self.btn_load_data.pack(side=tk.LEFT, padx=2) + return row + 1 + + def _build_secondary_buttons(self, row: int) -> None: + buttons = tk.Frame(self) + buttons.grid(row=row, column=0, columnspan=2, sticky="ew", pady=5) + + self.btn_load_ref = tk.Button(buttons, text="Load Ref", width=9) + self.btn_load_ref.pack(side=tk.LEFT, padx=2) + self.btn_save_settings = tk.Button(buttons, text="Save Settings", width=11) + self.btn_save_settings.pack(side=tk.LEFT, padx=2) + self.btn_load_settings = tk.Button(buttons, text="Load Settings", width=11) + self.btn_load_settings.pack(side=tk.LEFT, padx=2) + diff --git a/src/app/presentation/tk/controller.py b/src/app/presentation/tk/controller.py index 25e79d9..12fa4e8 100644 --- a/src/app/presentation/tk/controller.py +++ b/src/app/presentation/tk/controller.py @@ -1,22 +1,24 @@ from __future__ import annotations import queue -from pathlib import Path +import threading +from collections.abc import Callable from app.application.events import EventEmitter +from app.application.services.sweep_task_runner import SweepTaskRunner from app.application.services.connection_monitor import ConnectionMonitor from app.application.use_cases.load_measurement import LoadMeasurementUseCase from app.application.use_cases.load_reference import LoadReferenceUseCase from app.application.use_cases.save_measurement import SaveMeasurementUseCase from app.application.use_cases.settings_use_case import SettingsUseCase -from app.application.ports.instruments import ResourceScannerPort +from app.application.ports.instruments import InstrumentPortsFactory, ResourceScannerPort +from app.domain.models import InstrumentEndpoint from app.presentation.tk import dialogs from app.presentation.tk.app_window import AppWindow from app.presentation.tk.mapper import settings_to_vm, vm_to_settings -from app.presentation.tk.sweep_task_runner import SweepTaskRunner from app.presentation.tk.ui_event_handler import UiEventHandler from app.presentation.tk.view_model import ViewModel -from app.infrastructure.instruments.equips_factory import resolve_visa_address +from app.runtime.paths import AppPaths class TkController(EventEmitter): @@ -30,6 +32,9 @@ def __init__( load_measurement_use_case: LoadMeasurementUseCase, load_reference_use_case: LoadReferenceUseCase, scanner: ResourceScannerPort, + ports_factory: InstrumentPortsFactory, + resolve_address: Callable[[InstrumentEndpoint], str], + paths: AppPaths | None = None, ) -> None: self.window = window self.vm = vm @@ -40,18 +45,23 @@ def __init__( self._event_queue: queue.Queue[object] = queue.Queue() self._reference_interpolator = None - self._root_dir = Path(__file__).resolve().parents[4] + self._paths = paths or AppPaths.default() + self._resolve_address = resolve_address + self._connection_target_lock = threading.Lock() + self._awg_target_address = "" + self._osc_target_address = "" self._ui_handler = UiEventHandler(window=window, vm=vm) self._task_runner = SweepTaskRunner( emitter=self, save_measurement_use_case=save_measurement_use_case, - root_dir=self._root_dir, + auto_save_dir=self._paths.measurement_dir, + ports_factory=ports_factory, ) self._monitor = ConnectionMonitor( scanner=scanner, - get_awg_address=self._get_awg_target_address, - get_osc_address=self._get_osc_target_address, + get_awg_address=self._get_cached_awg_target_address, + get_osc_address=self._get_cached_osc_target_address, emitter=self, ) @@ -75,6 +85,7 @@ def initialize(self) -> None: except Exception as exc: # noqa: BLE001 dialogs.show_warning(self.window, f"Failed to load settings: {exc}") + self._refresh_connection_targets() self._monitor.start() self.window.after(100, self._process_events) self.on_figure_change() @@ -89,6 +100,7 @@ def on_start(self) -> None: try: settings = vm_to_settings(self.vm) + self._refresh_connection_targets(settings) self._task_runner.start( settings=settings, calibration_enabled=bool(self.vm.calibration_enabled.get()), @@ -105,6 +117,7 @@ def on_save_settings(self) -> None: try: settings = vm_to_settings(self.vm) self.settings_use_case.save(settings) + self._refresh_connection_targets(settings) dialogs.show_info(self.window, "Settings saved") except Exception as exc: # noqa: BLE001 dialogs.show_warning(self.window, f"Failed to save settings: {exc}") @@ -113,6 +126,7 @@ def on_load_settings(self) -> None: try: settings = self.settings_use_case.load() settings_to_vm(settings, self.vm) + self._refresh_connection_targets(settings) self.on_figure_change() self.on_mag_phase_change() dialogs.show_info(self.window, "Settings loaded") @@ -126,7 +140,7 @@ def on_save_data(self) -> None: fp = dialogs.ask_save_file( title="Save measurement", - initial_dir=self._root_dir / "__data__", + initial_dir=self._paths.data_dir, initial_name="measurement", filetypes=[("MAT files", "*.mat"), ("All files", "*.*")], ) @@ -147,7 +161,7 @@ def on_save_data(self) -> None: def on_load_data(self) -> None: fp = dialogs.ask_open_file( title="Load measurement", - initial_dir=self._root_dir / "__data__", + initial_dir=self._paths.data_dir, filetypes=[("Measurement", "*.mat *.csv"), ("All files", "*.*")], ) if fp is None: @@ -163,7 +177,7 @@ def on_load_data(self) -> None: def on_load_reference(self) -> None: fp = dialogs.ask_open_file( title="Load reference", - initial_dir=self._root_dir / "__data__", + initial_dir=self._paths.data_dir, filetypes=[("MAT files", "*.mat"), ("All files", "*.*")], ) if fp is None: @@ -202,21 +216,29 @@ def _process_events(self) -> None: except queue.Empty: pass finally: + self._refresh_connection_targets() self.window.after(100, self._process_events) - def _get_awg_target_address(self) -> str: + def _refresh_connection_targets(self, settings=None) -> None: try: - settings = vm_to_settings(self.vm) - return resolve_visa_address(settings.setup.awg) + settings = settings or vm_to_settings(self.vm) + awg_address = self._resolve_address(settings.setup.awg) + osc_address = self._resolve_address(settings.setup.osc) except Exception: - return "" + awg_address = "" + osc_address = "" - def _get_osc_target_address(self) -> str: - try: - settings = vm_to_settings(self.vm) - return resolve_visa_address(settings.setup.osc) - except Exception: - return "" + with self._connection_target_lock: + self._awg_target_address = awg_address + self._osc_target_address = osc_address + + def _get_cached_awg_target_address(self) -> str: + with self._connection_target_lock: + return self._awg_target_address + + def _get_cached_osc_target_address(self) -> str: + with self._connection_target_lock: + return self._osc_target_address def dialogs_to_target(path, window: AppWindow): diff --git a/src/app/presentation/tk/sweep_task_runner.py b/src/app/presentation/tk/sweep_task_runner.py index 3735c62..8c65208 100644 --- a/src/app/presentation/tk/sweep_task_runner.py +++ b/src/app/presentation/tk/sweep_task_runner.py @@ -1,105 +1,5 @@ from __future__ import annotations -import threading -from collections.abc import Callable -from pathlib import Path +from app.application.services.sweep_task_runner import SweepTaskRunner -from app.application.dto import SaveTarget, StartSweepCommand -from app.application.events import SweepFailed -from app.application.use_cases.save_measurement import SaveMeasurementUseCase -from app.application.use_cases.start_sweep import StartSweepUseCase -from app.application.use_cases.stop_sweep import StopSweepUseCase -from app.domain.models import AppSettings, InstrumentSetup, SweepResult -from app.infrastructure.instruments.equips_factory import InstrumentPorts, create_instrument_ports - - -class SweepTaskRunner: - def __init__( - self, - *, - emitter, - save_measurement_use_case: SaveMeasurementUseCase, - root_dir: Path, - ports_factory: Callable[[InstrumentSetup], InstrumentPorts] = create_instrument_ports, - use_case_factory: Callable[..., StartSweepUseCase] = StartSweepUseCase, - ) -> None: - self._emitter = emitter - self._save_measurement_use_case = save_measurement_use_case - self._root_dir = root_dir - self._ports_factory = ports_factory - self._use_case_factory = use_case_factory - - self._ports: InstrumentPorts | None = None - self._sweep_thread: threading.Thread | None = None - self._stop_use_case: StopSweepUseCase | None = None - - def is_running(self) -> bool: - return self._sweep_thread is not None and self._sweep_thread.is_alive() - - def start( - self, - *, - settings: AppSettings, - calibration_enabled: bool, - reference_interpolator: object | None, - ) -> None: - if self.is_running(): - return - - ports = self._ports_factory(settings.setup) - stop_event = threading.Event() - self._stop_use_case = StopSweepUseCase(stop_event=stop_event) - - cmd = StartSweepCommand( - settings=settings, - calibration_enabled=calibration_enabled, - reference_interpolator=reference_interpolator, - ) - start_use_case = self._use_case_factory(awg=ports.awg, osc=ports.osc, stop_event=stop_event) - - self._ports = ports - self._sweep_thread = threading.Thread(target=self._run_sweep, args=(start_use_case, cmd), daemon=True) - self._sweep_thread.start() - - def stop(self) -> None: - if self._stop_use_case is not None: - self._stop_use_case.stop() - - def wait(self, timeout: float | None = None) -> None: - if self._sweep_thread is not None: - self._sweep_thread.join(timeout=timeout) - - def shutdown(self) -> None: - self.stop() - self._close_ports() - - def _run_sweep(self, start_use_case: StartSweepUseCase, cmd: StartSweepCommand) -> None: - try: - result = start_use_case.run(cmd, self._emitter) - if not result.is_empty and cmd.settings.auto_save_data: - target = SaveTarget( - base_path=self._root_dir / "__data__" / "measurement", - include_timestamp=True, - figures={}, - ) - self._save_measurement_use_case.execute(result=result, settings=cmd.settings, target=target) - except Exception as exc: # noqa: BLE001 - self._emitter.emit(SweepFailed(error_code="SWEEP_THREAD", message=str(exc))) - finally: - self._close_ports() - - def _close_ports(self) -> None: - if self._ports is None: - return - - try: - self._ports.awg.close() - except Exception: - pass - - try: - self._ports.osc.close() - except Exception: - pass - - self._ports = None +__all__ = ["SweepTaskRunner"] diff --git a/src/app/runtime/__init__.py b/src/app/runtime/__init__.py new file mode 100644 index 0000000..fdc032d --- /dev/null +++ b/src/app/runtime/__init__.py @@ -0,0 +1,2 @@ +"""Runtime composition helpers for the desktop application.""" + diff --git a/src/app/runtime/paths.py b/src/app/runtime/paths.py new file mode 100644 index 0000000..4b6ccfb --- /dev/null +++ b/src/app/runtime/paths.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class AppPaths: + root_dir: Path + config_dir: Path + data_dir: Path + + @classmethod + def from_root(cls, root_dir: Path) -> "AppPaths": + root = root_dir.resolve() + return cls( + root_dir=root, + config_dir=root / "__config__", + data_dir=root / "__data__", + ) + + @classmethod + def default(cls) -> "AppPaths": + return cls.from_root(Path(__file__).resolve().parents[3]) + + @property + def settings_path(self) -> Path: + return self.config_dir / "settings.json" + + @property + def measurement_dir(self) -> Path: + return self.data_dir / "measurement" + diff --git a/src/equips.py b/src/equips.py index b59c67e..49b0773 100644 --- a/src/equips.py +++ b/src/equips.py @@ -1,14 +1,10 @@ -# Environment setup notes -# cd C:\Users\15038\Desktop\HardWare\mm_report -# python -m venv venv -# .\venv\Scripts\Activate.ps1 -# python issues.py -# pip install requests PyGithub pyinstaller -# pyinstaller --onefile --name mm_test.exe equips_v0.py - -"""equip driver Module deal with base equips operation. - new equips are encouraged to be inherited from bATEinst_base -""" +"""Legacy vendor/instrument compatibility layer. + +The refactored application does not call this module from UI or use cases. +It is wrapped by infrastructure adapters under ``app.infrastructure.instruments``. +Keep hardware-command changes conservative unless they can be verified on real +instruments. +""" import time import traceback diff --git a/src/main.py b/src/main.py index ed7953a..0c9cacc 100644 --- a/src/main.py +++ b/src/main.py @@ -1,37 +1,10 @@ from __future__ import annotations -from app.application.use_cases.load_measurement import LoadMeasurementUseCase -from app.application.use_cases.load_reference import LoadReferenceUseCase -from app.application.use_cases.save_measurement import SaveMeasurementUseCase -from app.application.use_cases.settings_use_case import SettingsUseCase -from app.infrastructure.instruments.resource_scanner import PyVisaResourceScanner -from app.infrastructure.persistence.measurement_repo_mat_csv import MatCsvMeasurementRepository -from app.infrastructure.persistence.reference_repo_mat import MatReferenceRepository -from app.infrastructure.persistence.settings_repo_json import JsonSettingsRepository -from app.presentation.tk.app_window import AppWindow -from app.presentation.tk.controller import TkController - def main() -> None: - window = AppWindow() - vm = window.vm - - settings_repo = JsonSettingsRepository() - measurement_repo = MatCsvMeasurementRepository() - reference_repo = MatReferenceRepository() - - controller = TkController( - window=window, - vm=vm, - settings_use_case=SettingsUseCase(settings_repo), - save_measurement_use_case=SaveMeasurementUseCase(measurement_repo), - load_measurement_use_case=LoadMeasurementUseCase(measurement_repo), - load_reference_use_case=LoadReferenceUseCase(reference_repo), - scanner=PyVisaResourceScanner(), - ) - controller.initialize() + from app.bootstrap import run_desktop_app - window.mainloop() + run_desktop_app() if __name__ == "__main__": diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py new file mode 100644 index 0000000..bb61d16 --- /dev/null +++ b/tests/test_app_paths.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.runtime.paths import AppPaths + + +class AppPathsTests(unittest.TestCase): + def test_from_root_derives_runtime_paths(self) -> None: + with tempfile.TemporaryDirectory() as td: + paths = AppPaths.from_root(Path(td)) + + self.assertEqual(paths.config_dir.name, "__config__") + self.assertEqual(paths.data_dir.name, "__data__") + self.assertEqual(paths.settings_path.name, "settings.json") + self.assertEqual(paths.measurement_dir.name, "measurement") + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_architecture_boundaries.py b/tests/test_architecture_boundaries.py new file mode 100644 index 0000000..2bf0710 --- /dev/null +++ b/tests/test_architecture_boundaries.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import ast +import sys +from pathlib import Path +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_APP = PROJECT_ROOT / "src" / "app" + + +def imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + return modules + + +def py_files(base: Path) -> list[Path]: + return [path for path in base.rglob("*.py") if "__pycache__" not in path.parts] + + +class ArchitectureBoundaryTests(unittest.TestCase): + def test_domain_stays_pure(self) -> None: + forbidden_prefixes = ("tkinter", "pyvisa", "serial", "matplotlib", "app.infrastructure", "app.presentation") + offenders = [] + for path in py_files(SRC_APP / "domain"): + for module in imported_modules(path): + if module.startswith(forbidden_prefixes): + offenders.append((path.relative_to(PROJECT_ROOT), module)) + + self.assertEqual(offenders, []) + + def test_application_does_not_import_infrastructure_or_presentation(self) -> None: + forbidden_prefixes = ("app.infrastructure", "app.presentation") + offenders = [] + for path in py_files(SRC_APP / "application"): + for module in imported_modules(path): + if module.startswith(forbidden_prefixes): + offenders.append((path.relative_to(PROJECT_ROOT), module)) + + self.assertEqual(offenders, []) + + def test_presentation_does_not_import_infrastructure(self) -> None: + offenders = [] + for path in py_files(SRC_APP / "presentation"): + for module in imported_modules(path): + if module.startswith("app.infrastructure"): + offenders.append((path.relative_to(PROJECT_ROOT), module)) + + self.assertEqual(offenders, []) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_measurement_io.py b/tests/test_measurement_io.py index 588d982..7c59b28 100644 --- a/tests/test_measurement_io.py +++ b/tests/test_measurement_io.py @@ -42,6 +42,43 @@ def test_export_and_load_round_trip_for_mat_and_csv(self) -> None: self.assertAlmostEqual(loaded_mat.result.points[1].gain_linear, 2.0, delta=1e-6) self.assertAlmostEqual(loaded_csv.result.points[1].phase_deg or 0.0, 10.0, delta=1e-6) + def test_sparse_phase_values_keep_row_alignment(self) -> None: + settings = DefaultSettingsFactory().create() + result = SweepResult( + points=[ + SweepPoint(freq_hz=1_000.0, gain_linear=1.0, gain_db=0.0, phase_deg=None), + SweepPoint(freq_hz=2_000.0, gain_linear=2.0, gain_db=20.0 * math.log10(2.0), phase_deg=10.0), + ] + ) + exporter = MeasurementExporter() + loader = MeasurementLoader() + + with tempfile.TemporaryDirectory() as td: + artifacts = exporter.export( + result=result, + settings=settings, + target=SaveTarget(base_path=Path(td) / "measurement", include_timestamp=False, figures={}), + ) + loaded_csv = loader.load(str(artifacts.csv_path)) + csv_text = artifacts.csv_path.read_text(encoding="utf-8") + + self.assertIsNone(loaded_csv.result.points[0].phase_deg) + self.assertAlmostEqual(loaded_csv.result.points[1].phase_deg or 0.0, 10.0, delta=1e-6) + self.assertIn("1000.0,1.0,0.0,", csv_text) + + def test_demo_data_mat_files_load(self) -> None: + loader = MeasurementLoader() + demo_dir = Path(__file__).resolve().parents[1] / "demo_data" + + raw = loader.load(str(demo_dir / "Deme(1).mat")) + corrected = loader.load(str(demo_dir / "Demo(2).mat")) + + self.assertEqual(len(raw.result.points), 15) + self.assertEqual(len(corrected.result.points), 50) + self.assertIsNone(raw.result.points[0].phase_deg) + self.assertIsNotNone(corrected.result.points[0].phase_deg) + self.assertGreater(raw.result.points[0].gain_linear, 0.0) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_point_measurement_services.py b/tests/test_point_measurement_services.py index a6eef9c..89c5ab6 100644 --- a/tests/test_point_measurement_services.py +++ b/tests/test_point_measurement_services.py @@ -121,6 +121,24 @@ def test_calibration_applier_corrects_gain_and_phase(self) -> None: self.assertAlmostEqual(corrected.gain_linear, 1.0, delta=1e-6) self.assertAlmostEqual(corrected.phase_deg or 0.0, 20.0, delta=1e-6) + def test_calibration_applier_handles_zero_reference(self) -> None: + point = SweepPoint( + freq_hz=5_000.0, + gain_linear=1.0, + gain_db=0.0, + phase_deg=0.0, + gain_complex=1.0 + 0.0j, + ) + cmd = StartSweepCommand( + settings=build_settings(correction_mode=CorrectionMode.DUAL, trigger_mode=TriggerMode.TRIGGERED), + calibration_enabled=True, + reference_interpolator=lambda _xs: np.array([0.0 + 0.0j]), + ) + + corrected = self.calibration.apply(point=point, cmd=cmd) + self.assertTrue(math.isfinite(corrected.gain_linear)) + self.assertTrue(math.isfinite(corrected.gain_db)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_start_sweep_use_case.py b/tests/test_start_sweep_use_case.py index 1b21ac5..a03b1b8 100644 --- a/tests/test_start_sweep_use_case.py +++ b/tests/test_start_sweep_use_case.py @@ -43,6 +43,9 @@ def reset(self) -> None: def output_on(self, channel: int) -> None: _ = channel + def output_off(self, channel: int) -> None: + _ = channel + def set_impedance(self, mode: str, channel: int) -> None: _ = (mode, channel) diff --git a/tests/test_sweep_task_runner.py b/tests/test_sweep_task_runner.py index ed0f344..6926d7a 100644 --- a/tests/test_sweep_task_runner.py +++ b/tests/test_sweep_task_runner.py @@ -8,10 +8,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from app.application.events import SweepCompleted +from app.application.events import SweepCompleted, SweepWarning from app.domain.models import SweepPoint, SweepResult from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory -from app.presentation.tk.sweep_task_runner import SweepTaskRunner +from app.application.services.sweep_task_runner import SweepTaskRunner class FakeSaveMeasurementUseCase: @@ -34,8 +34,15 @@ def emit(self, event: object) -> None: class FakePort: def __init__(self) -> None: self.closed = False + self.output_off_channels: list[int] = [] + self.fail_close = False + + def output_off(self, channel: int) -> None: + self.output_off_channels.append(channel) def close(self) -> None: + if self.fail_close: + raise RuntimeError("close failed") self.closed = True @@ -68,7 +75,7 @@ def use_case_factory(*, awg, osc, stop_event: threading.Event): runner = SweepTaskRunner( emitter=emitter, save_measurement_use_case=save_use_case, - root_dir=Path("."), + auto_save_dir=Path("."), ports_factory=ports_factory, use_case_factory=use_case_factory, ) @@ -77,10 +84,41 @@ def use_case_factory(*, awg, osc, stop_event: threading.Event): runner.wait(timeout=1.0) self.assertEqual(len(save_use_case.calls), 1) + self.assertEqual(awg.output_off_channels, [settings.setup.channels.awg_ch]) self.assertTrue(awg.closed) self.assertTrue(osc.closed) self.assertTrue(any(isinstance(event, SweepCompleted) for event in emitter.events)) + def test_runner_emits_warning_when_port_close_fails(self) -> None: + settings = DefaultSettingsFactory().create() + save_use_case = FakeSaveMeasurementUseCase() + emitter = FakeEmitter() + awg = FakePort() + osc = FakePort() + awg.fail_close = True + result = SweepResult(points=[SweepPoint(freq_hz=1_000.0, gain_linear=1.0, gain_db=0.0)]) + + def ports_factory(_setup): + return SimpleNamespace(awg=awg, osc=osc, awg_address="A", osc_address="B") + + def use_case_factory(*, awg, osc, stop_event: threading.Event): + _ = (awg, osc, stop_event) + return FakeStartUseCase(result) + + runner = SweepTaskRunner( + emitter=emitter, + save_measurement_use_case=save_use_case, + auto_save_dir=Path("."), + ports_factory=ports_factory, + use_case_factory=use_case_factory, + ) + + runner.start(settings=settings, calibration_enabled=False, reference_interpolator=None) + runner.wait(timeout=1.0) + + warnings = [event for event in emitter.events if isinstance(event, SweepWarning)] + self.assertTrue(any(event.code == "AWG_CLOSE_FAILED" for event in warnings)) + if __name__ == "__main__": unittest.main() From fde44456a2f594bcb81247166e0aaccf17e5d0ad Mon Sep 17 00:00:00 2001 From: ShehaoLi Date: Sat, 16 May 2026 15:49:54 -0700 Subject: [PATCH 3/3] Address PR review feedback --- docs/extending.md | 2 ++ docs/safety.md | 4 ++++ src/app/presentation/tk/controller.py | 12 +++++++++--- src/app/runtime/paths.py | 10 ++++++++-- tests/test_app_paths.py | 22 ++++++++++++++++++++-- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/extending.md b/docs/extending.md index 8130d2a..2e214ec 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -24,6 +24,8 @@ This project is intentionally structured around extension seams rather than dire - `__data__/` - `__data__/measurement/` +By default, runtime paths are rooted at the process working directory. Set `AUTO_LOAD_OFF_TEST_ROOT` to force a specific writable runtime location for packaged installs or lab workstations. + Use `AppPaths` instead of recomputing `Path(__file__).parents[...]` in new code. ## Adding A New Instrument diff --git a/docs/safety.md b/docs/safety.md index 446b01b..1c99793 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -38,3 +38,7 @@ Before live measurement: 5. Keep physical access to instrument front panels and emergency stop procedures. Automated tests use mocked ports and do not validate real hardware behavior. + +## Runtime File Location + +Settings and auto-save output default to the process working directory. Set `AUTO_LOAD_OFF_TEST_ROOT` to use an explicit writable runtime directory on lab machines or packaged installs. diff --git a/src/app/presentation/tk/controller.py b/src/app/presentation/tk/controller.py index 12fa4e8..6684e52 100644 --- a/src/app/presentation/tk/controller.py +++ b/src/app/presentation/tk/controller.py @@ -50,6 +50,7 @@ def __init__( self._connection_target_lock = threading.Lock() self._awg_target_address = "" self._osc_target_address = "" + self._closing = False self._ui_handler = UiEventHandler(window=window, vm=vm) self._task_runner = SweepTaskRunner( @@ -198,6 +199,7 @@ def on_mag_phase_change(self) -> None: self._ui_handler.refresh_plot() def on_close(self) -> None: + self._closing = True self._monitor.stop() try: settings = vm_to_settings(self.vm) @@ -206,18 +208,22 @@ def on_close(self) -> None: pass self._task_runner.shutdown() + self._drain_event_queue() self.window.destroy() def _process_events(self) -> None: + self._drain_event_queue() + if not self._closing: + self._refresh_connection_targets() + self.window.after(100, self._process_events) + + def _drain_event_queue(self) -> None: try: while True: event = self._event_queue.get_nowait() self._ui_handler.handle(event) except queue.Empty: pass - finally: - self._refresh_connection_targets() - self.window.after(100, self._process_events) def _refresh_connection_targets(self, settings=None) -> None: try: diff --git a/src/app/runtime/paths.py b/src/app/runtime/paths.py index 4b6ccfb..7e107cc 100644 --- a/src/app/runtime/paths.py +++ b/src/app/runtime/paths.py @@ -1,9 +1,13 @@ from __future__ import annotations from dataclasses import dataclass +import os from pathlib import Path +APP_ROOT_ENV = "AUTO_LOAD_OFF_TEST_ROOT" + + @dataclass(frozen=True, slots=True) class AppPaths: root_dir: Path @@ -21,7 +25,10 @@ def from_root(cls, root_dir: Path) -> "AppPaths": @classmethod def default(cls) -> "AppPaths": - return cls.from_root(Path(__file__).resolve().parents[3]) + configured_root = os.environ.get(APP_ROOT_ENV) + if configured_root: + return cls.from_root(Path(configured_root)) + return cls.from_root(Path.cwd()) @property def settings_path(self) -> Path: @@ -30,4 +37,3 @@ def settings_path(self) -> Path: @property def measurement_dir(self) -> Path: return self.data_dir / "measurement" - diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py index bb61d16..18ecb4d 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -1,13 +1,15 @@ from __future__ import annotations +import os import sys from pathlib import Path import tempfile import unittest +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from app.runtime.paths import AppPaths +from app.runtime.paths import APP_ROOT_ENV, AppPaths class AppPathsTests(unittest.TestCase): @@ -20,7 +22,23 @@ def test_from_root_derives_runtime_paths(self) -> None: self.assertEqual(paths.settings_path.name, "settings.json") self.assertEqual(paths.measurement_dir.name, "measurement") + def test_default_uses_working_directory(self) -> None: + with tempfile.TemporaryDirectory() as td, patch.dict(os.environ, {}, clear=True): + previous = Path.cwd() + try: + os.chdir(td) + paths = AppPaths.default() + finally: + os.chdir(previous) + + self.assertEqual(paths.root_dir, Path(td).resolve()) + + def test_default_can_be_overridden_by_environment(self) -> None: + with tempfile.TemporaryDirectory() as td, patch.dict(os.environ, {APP_ROOT_ENV: td}, clear=True): + paths = AppPaths.default() + + self.assertEqual(paths.root_dir, Path(td).resolve()) + if __name__ == "__main__": unittest.main() -