Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ See [docs/safety.md](docs/safety.md) for stop/shutdown behavior and hardware ass
- [Operator Guide](docs/operator_guide.md)
- [Safety Notes](docs/safety.md)
- [Extending The Application](docs/extending.md)
- [Packaging](docs/packaging.md)
- [Case Study](docs/case_study.md)
- [Hyperframe Demo Fixture](docs/hyperframe_demo.md)
- [Hyperframe Capture Plan](docs/hyperframe_capture_plan.md)
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ flowchart LR
- 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, and export array shaping.
- Pure dataclasses, enums, instrument capability profiles, validation, sweep generation, DSP, calibration, and export array shaping.
- `app/infrastructure`
- Adapter wrappers around `src/equips.py`.
- Adapter registry and wrappers around `src/equips.py`.
- JSON settings and MAT/CSV/TXT persistence.

## Dependency Rules
Expand Down Expand Up @@ -62,8 +62,11 @@ Forbidden:
## Instrument Access

- Instrument model and address resolution go through `equips_factory`.
- Supported model metadata is declared in `domain/instrument_capabilities.py`.
- Adapter construction goes through the explicit infrastructure adapter registry.
- AWG and OSC commands are executed through `AwgPort` and `OscPort` adapters.
- Connection scanning is provided by `PyVisaResourceScanner` and `ConnectionMonitor`.
- Connection scanning is provided by `PyVisaResourceScanner`, `ConnectionMonitor`, and the
discovery/test-connect service. Test-connect uses short `*IDN?` probes and does not start a sweep.
- `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
Expand Down
15 changes: 11 additions & 4 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ 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.
1. Add an `InstrumentCapability` profile in `src/app/domain/instrument_capabilities.py`.
Keep the profile conservative: include only supported channels, modes, limits, transports,
and safety notes that are known or explicitly marked as software assumptions.
2. Register the model in `app.infrastructure.instruments.adapter_registry`.
Current production adapters may wrap `src/equips.py`, but the UI/use cases should not call
`equips.py` directly.
3. Add or verify the vendor driver mapping in `src/equips.py` only when the low-level SCPI behavior
is known and can be bench-validated.
4. Keep `AwgPort` / `OscPort` as the application contract.
5. Add hardware-free tests with fake ports before doing live bench validation.
5. Add hardware-free tests with fake ports and capability-aware validation before live bench validation.
6. Add the model to live validation notes only after scan, IDN, configure, sweep, stop, and export have
been checked on the actual instrument.

## Adding A New Persistence Format

Expand Down
85 changes: 85 additions & 0 deletions docs/packaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Packaging

This project can be packaged for review or lab workstation setup, but packaged
artifacts are not live-hardware validated by default. Hardware use still depends
on the target machine's VISA backend, instrument drivers, cabling, and operator
safety checks.

## Recommended First Target

Use a Windows PyInstaller one-folder build first. One-folder output is easier to
debug than a one-file executable when bundling Tkinter, Matplotlib, SciPy,
PyVISA, and instrument-driver dependencies.

```powershell
powershell -ExecutionPolicy Bypass -File scripts/build_windows_onefolder.ps1
```

The script installs the local package with the optional `build` dependency and
then runs:

```powershell
python -m PyInstaller packaging/pyinstaller/auto_load_off_test_onefolder.spec --clean --noconfirm
```

Expected output:

```text
dist/AutoLoadOffTest/AutoLoadOffTest.exe
```

## External Prerequisites

The package does not bundle lab driver runtimes. A live-hardware workstation
still needs one of these paths configured:

- NI-VISA, Keysight IO Libraries, or another compatible VISA runtime.
- Or a working `pyvisa-py` backend plus any USB/GPIB/serial support libraries
required by the connected instruments.
- OS-level USB/GPIB/serial permissions where relevant.
- Verified instrument addresses and model labels.

## Runtime Data Paths

Set `AUTO_LOAD_OFF_TEST_ROOT` on packaged workstations so settings and saved
measurements do not depend on the launch directory:

```powershell
$env:AUTO_LOAD_OFF_TEST_ROOT = "$env:LOCALAPPDATA\\AutoLoadOffTest"
```

The app writes:

- `__config__/settings.json`
- `__data__/measurement/`

under that root.

## No-Hardware Packaging Smoke

Before using a packaged artifact as portfolio/demo evidence:

1. Launch `dist/AutoLoadOffTest/AutoLoadOffTest.exe`.
2. Confirm the operator console opens without a Python traceback.
3. Click `Load Demo Fixture`.
4. Confirm the plot is visible and labeled `No hardware - simulated fixture`.
5. Confirm the source receipt names `hyperframe_simulated_fixture.mat`.
6. Click `Save Data` and verify MAT/CSV/TXT files are written to a writable path.
7. Close the app and confirm no shutdown error appears.

This smoke check validates packaged UI/data workflow only. It is not live
hardware validation.

## Live-Hardware Packaging Smoke

Do this only on a real lab workstation:

1. Install/verify the VISA backend and drivers.
2. Set `AUTO_LOAD_OFF_TEST_ROOT` to a writable app-data folder.
3. Launch the packaged app.
4. Use `Scan Resources` and `Test Connect`.
5. Confirm IDN/address/model status before starting a sweep.
6. Run a short, conservative sweep into a safe load/DUT.
7. Verify Stop turns the AWG output off and warnings are visible.

Record the instrument models, VISA backend, OS version, and result artifacts.
2 changes: 2 additions & 0 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This project is not a certified production test platform. It does not replace la
## Hardware Assumptions

- Supported model labels are defined in `src/app/shared/mapping.py`.
- Model capability profiles provide software preflight checks for supported channels, modes, and known limits.
They are not a substitute for instrument manuals or live bench validation.
- 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.
Expand Down
66 changes: 66 additions & 0 deletions packaging/pyinstaller/auto_load_off_test_onefolder.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# PyInstaller spec for a Windows one-folder build.
# Run from the repository root:
# python -m PyInstaller packaging/pyinstaller/auto_load_off_test_onefolder.spec --clean --noconfirm

from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]

datas = [
(str(ROOT / "demo_data"), "demo_data"),
(str(ROOT / "docs" / "images" / "auto-load-off-test-point-replay-demo.png"), "docs/images"),
]

block_cipher = None

a = Analysis(
[str(ROOT / "src" / "main.py")],
pathex=[str(ROOT / "src")],
binaries=[],
datas=datas,
hiddenimports=[
"scipy.io",
"scipy.interpolate",
"matplotlib.backends.backend_tkagg",
"mplcursors",
"pyvisa",
"pyvisa_py",
"serial",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="AutoLoadOffTest",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="AutoLoadOffTest",
)
33 changes: 33 additions & 0 deletions scripts/build_windows_onefolder.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
param(
[string]$RuntimeRoot = "$env:LOCALAPPDATA\AutoLoadOffTest"
)

$ErrorActionPreference = "Stop"

$isWindowsPlatform = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
[System.Runtime.InteropServices.OSPlatform]::Windows
)
if (-not $isWindowsPlatform) {
Write-Warning "This packaging spike is intended for Windows. Continuing because PyInstaller may still validate the spec."
}

$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $repoRoot

python -m pip install --upgrade pip
python -m pip install -e ".[build]"

$env:AUTO_LOAD_OFF_TEST_ROOT = $RuntimeRoot
python -m PyInstaller packaging/pyinstaller/auto_load_off_test_onefolder.spec --clean --noconfirm

Write-Host ""
Write-Host "Built: dist\AutoLoadOffTest\AutoLoadOffTest.exe"
Write-Host "Runtime root for smoke testing: $env:AUTO_LOAD_OFF_TEST_ROOT"
Write-Host ""
Write-Host "No-hardware smoke checklist:"
Write-Host "1. Launch dist\AutoLoadOffTest\AutoLoadOffTest.exe"
Write-Host "2. Click Load Demo Fixture"
Write-Host "3. Confirm the plot and 'No hardware - simulated fixture' label are visible"
Write-Host "4. Save Data to a writable folder and verify MAT/CSV/TXT files"
Write-Host ""
Write-Host "Live hardware still requires NI-VISA/Keysight IO Libraries or a working pyvisa backend plus drivers."
4 changes: 4 additions & 0 deletions src/app/application/ports/instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ class ResourceScannerPort(Protocol):
def list_resources(self) -> tuple[str, ...]: ...


class InstrumentIdentityProbePort(Protocol):
def identify(self, address: str, timeout_ms: int | None = None) -> str: ...


@dataclass(slots=True)
class InstrumentPorts:
awg: AwgPort
Expand Down
124 changes: 124 additions & 0 deletions src/app/application/services/instrument_discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone

from app.application.ports.instruments import InstrumentIdentityProbePort, ResourceScannerPort
from app.domain.instrument_capabilities import InstrumentRole, get_capability
from app.domain.models import InstrumentEndpoint, InstrumentSetup


@dataclass(frozen=True, slots=True)
class ResourceScan:
resources: tuple[str, ...]
scanned_at: str


@dataclass(frozen=True, slots=True)
class ConnectionCheck:
role: InstrumentRole
model: str
address: str
status: str
message: str
idn: str = ""
backend: str = "pyvisa"
last_seen: str = ""


class InstrumentDiscoveryService:
def __init__(
self,
*,
scanner: ResourceScannerPort,
identity_probe: InstrumentIdentityProbePort,
timeout_ms: int = 2_000,
) -> None:
self._scanner = scanner
self._identity_probe = identity_probe
self._timeout_ms = timeout_ms

def scan_resources(self) -> ResourceScan:
resources = self._scanner.list_resources()
return ResourceScan(resources=resources, scanned_at=_timestamp())

def test_setup(self, setup: InstrumentSetup, resolve_address) -> tuple[ConnectionCheck, ConnectionCheck]:
return (
self.test_endpoint(
role=InstrumentRole.AWG,
endpoint=setup.awg,
address=resolve_address(setup.awg),
),
self.test_endpoint(
role=InstrumentRole.OSC,
endpoint=setup.osc,
address=resolve_address(setup.osc),
),
)

def test_endpoint(self, *, role: InstrumentRole, endpoint: InstrumentEndpoint, address: str) -> ConnectionCheck:
if not address:
return ConnectionCheck(
role=role,
model=endpoint.model,
address="",
status="address_empty",
message=f"{role.value.upper()} address empty",
)

try:
capability = get_capability(endpoint.model, role)
except ValueError:
return ConnectionCheck(
role=role,
model=endpoint.model,
address=address,
status="unsupported_model",
message=f"Unsupported {role.value.upper()} model: {endpoint.model}",
)

if endpoint.connect_mode not in capability.transports:
return ConnectionCheck(
role=role,
model=endpoint.model,
address=address,
status="unsupported_transport",
message=f"{endpoint.model} does not support {endpoint.connect_mode.value} connection mode",
)

try:
idn = self._identity_probe.identify(address, timeout_ms=self._timeout_ms)
except Exception as exc: # noqa: BLE001
return ConnectionCheck(
role=role,
model=endpoint.model,
address=address,
status="offline",
message=f"{role.value.upper()} offline or unreachable: {exc}",
)

return ConnectionCheck(
role=role,
model=endpoint.model,
address=address,
status="connected",
message=f"{role.value.upper()} connected: {idn}",
idn=idn,
last_seen=_timestamp(),
)


def format_scan_receipt(scan: ResourceScan, *, limit: int = 4) -> str:
if not scan.resources:
return f"No VISA resources found · {scan.scanned_at}"
visible = ", ".join(scan.resources[:limit])
suffix = "" if len(scan.resources) <= limit else f", +{len(scan.resources) - limit} more"
return f"{len(scan.resources)} VISA resources · {visible}{suffix}"


def format_connection_receipt(checks: tuple[ConnectionCheck, ...]) -> str:
return " | ".join(check.message for check in checks)


def _timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
Loading
Loading