Skip to content

feat(scope): json config-driven instrument creation - #460

Merged
nhschwab merged 12 commits into
mainfrom
instro-578-scope-json-config
Sep 9, 2026
Merged

feat(scope): json config-driven instrument creation#460
nhschwab merged 12 commits into
mainfrom
instro-578-scope-json-config

Conversation

@nhschwab

@nhschwab nhschwab commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Closes INSTRO-578. Implements the schema defined in INSTRO-564.

Adds config-driven workflow for InstroScope. Includes a rename of the existing ScopeConfig to ScopeState to reserve ScopeConfig for the config driven workflow, matching existing instrument configs.

Summary

  • Add instro/scope/config.py with the Pydantic ScopeConfig model, its ChannelConfig / AcquisitionConfig / TriggerConfig blocks, VisaDriverConfig, and SCOPE_VENDOR_REGISTRY for the three registered drivers. Validators enforce the schema rules: channel keys and trigger.source within num_channels, average_count requires AVERAGE mode, no duplicate measurements, and timing requires at least one polled measurement.
  • Add InstroScope(config=..., autostart=False) construction from a ScopeConfig, dict, or JSON path, mirroring InstroPSU / InstroDMM. Direct construction is unchanged.
  • Register each channel's measurements as background daemon functions at construction. autostart=True requires at least one; a manual start() with none registered logs a warning rather than raising, since starting the daemon is also how the in-memory channel buffer gets created.
  • On open(), apply the config through the public setters in the order the hardware needs, then sync_configuration(), then log one warning per field the instrument reports differently from the config (SNAP_REL_TOL = 1%, SNAP_ABS_TOL = 1e-9). A mid-apply failure closes the driver and re-raises, and the next open() re-attempts the apply. Reopening without closing does not reapply.
  • Rename the tracked-state dataclasses ScopeConfig / ChannelConfig / TriggerConfig to ScopeState / ChannelState / TriggerState so the Pydantic models keep the <Category>Config names. sync_configuration() now returns ScopeState. InstroScope has no documented external users yet, so this ships without a major bump (see INSTRO-564 Decision 3).

Apply order on open()

Each step exists because a shipped driver needs it:

  1. Per channel: coupling, probe_attenuation, then vertical_scale, vertical_offset. The probe factor rescales the channel's probe-referred scale and offset on all three drivers.
  2. trigger: source, type, slope, level, mode.
  3. run() if acquisition.start_acquisition_on_open is set, so the configured trigger is in place when acquisition starts, and so the next step lands on a running scope.
  4. acquisition: average_count before mode (Siglent's mode command carries the count inline; Siglent only applies ACQW while acquiring), then horizontal_scale.
  5. setup_measurement() for every configured measurement, so Tektronix has its slots installed before the first poll.
  6. sync_configuration() and the snapped-value warnings.

Also in this PR

  • Keysight1200X.get_acquisition_mode keyed its read-back map on long-form names while the instrument answers with the 4-character short form (AVER, HRES), so AVERAGE and HIGH_RESOLUTION read back as NORMAL. Pre-existing, surfaced by the new post-open check.
  • InstroScope.open() / close() now log lifecycle at info level like the sibling HALs, and close() stops the daemon and publishers before closing the driver (scope was the only HAL doing it the other way round), adopting ELoad's flag-reset-then-finally shape.

Deferred

Docs and examples

  • docs/guides/instrumentation/oscilloscope.mdx: new "From a JSON Config File" section, config / autostart parameters, apply-order and lifecycle updates, method-reference row.
  • docs/reference/src/instruments/scope.md: include instro.scope.config.
  • examples/scope/scope_config.py + scope_config_keysight_1200x.json, with the generated example page and nav entry.
  • AGENTS.md: note the scope config module and the *State naming.

Test plan

  • just check-python (ruff format, mypy, ruff check) passes
  • uv run pytest tests packages passes
  • just check-examples reports no drift
  • tests/scope/test_scope_config.py covers validation rules, the hardware apply order, the snapped-value warning and its rounding tolerance, rollback and re-apply on a failed open, the start() warning, autostart, and vendor-registry drift
  • tests/scope/test_instro_scope.py covers the Keysight short-form acquisition-mode read-back
  • Hardware smoke against a Keysight 1200X and a Siglent SDS1000X-E with the example config (not run; no scope attached). The apply-order fixes are wire-ordering changes that mocks cannot verify.

Add InstroScope(config=...) construction from a ScopeConfig, dict, or JSON
path, implementing the schema defined in INSTRO-564. The config's channels,
acquisition, and trigger blocks apply through the public setters on open(),
followed by sync_configuration() and one warning per field the instrument
reports differently from the config. Each channel's measurements register as
background daemon functions; acquisition.start_acquisition_on_open opts in to
run() as the last step of open().

The tracked-state dataclasses instro.scope.types.ScopeConfig, ChannelConfig,
and TriggerConfig are renamed to ScopeState, ChannelState, and TriggerState so
the Pydantic config models can use the <Category>Config names, and
InstroScope.sync_configuration() now returns ScopeState. InstroScope has no
documented external users yet, so this ships without a major bump.

Closes INSTRO-578
@mintlify

mintlify Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
instro 🟢 Ready View Preview Sep 3, 2026, 3:38 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nhschwab's task in 5m 2s —— View job


Review

  • Gather context (PR diff, AGENTS.md conventions)
  • Review instro/scope/config.py (new Pydantic config module)
  • Review instro/scope/scope.py (config-driven open(), apply order, rollback)
  • Review instro/scope/types.py rename (ScopeState/ChannelState/TriggerState)
  • Review tests (tests/scope/test_scope_config.py, test_instro_scope.py)
  • Review docs/examples sync
  • Post inline findings + overview

Overview: Clean port of the InstroPSU/InstroDMM config-construction pattern to Scope — resolve_scope_from_config, VisaDriverConfig, and the vendor registry all mirror instro/psu/config.py exactly, and the rename to ScopeState/ChannelState/TriggerState correctly frees up ScopeConfig/ChannelConfig/TriggerConfig for the Pydantic models without touching the <Category>Config convention. The open() rollback (_apply_config failure → close driver → re-raise, gated by _config_applied so a bare reopen doesn't reapply) matches DMM's existing _apply_measurement_config shape. The channels → acquisition → trigger → sync → warn → run ordering in _apply_config is exactly what's tested in test_scope_config.py::test_open_applies_config_in_order_then_syncs_then_runs, and model validators (_channels_within_num_channels, _timing_requires_measurements, average_count requiring AVERAGE mode) catch the config-shape errors ahead of time rather than at apply time. Docs/examples/nav are all updated in step per AGENTS.md's table.

One inline note on lifecycle logging left below. No correctness bugs found; didn't run tests/lints per review scope (static-only).

@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds strict JSON/dict-based oscilloscope construction, applies configured channel, acquisition, and trigger state during open, and registers configured measurements for background polling.

  • Introduces the Pydantic scope configuration schema and registered VISA-driver resolution.
  • Separates declarative configuration models from tracked ScopeState dataclasses.
  • Adds open-time application, read-back warnings, optional acquisition start, documentation, examples, and tests.

Confidence Score: 3/5

The PR should not merge until Tektronix measurements are prepared before acquisition and config publishers are closed when construction fails validation.

The new open sequence can begin Tektronix acquisition before required measurement slots exist, and the constructor can abandon already-open publisher resources on its autostart validation path.

Files Needing Attention: instro/scope/scope.py, instro/scope/config.py

Important Files Changed

Filename Overview
instro/scope/config.py Adds strict scope configuration models, driver resolution, and eager publisher construction; registry-based imports remain constrained to fixed entries.
instro/scope/scope.py Adds config-driven construction and open-time application, but starts Tektronix acquisition before measurement setup and misses publisher cleanup for an early validation failure.
instro/scope/types.py Renames tracked configuration dataclasses to state-oriented names without changing their data model.
tests/scope/test_scope_config.py Thoroughly covers validation and generic application order but does not exercise Tektronix pre-acquisition measurement setup or early publisher cleanup.
docs/guides/instrumentation/oscilloscope.mdx Documents config construction, lifecycle, validation, polling, and state synchronization behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Load and validate ScopeConfig] --> B[Resolve driver and publishers]
  B --> C[Construct InstroScope]
  C --> D[Register background measurements]
  D --> E[open driver]
  E --> F[Apply channel settings]
  F --> G[Apply acquisition settings]
  G --> H[Apply trigger settings]
  H --> I[Sync and compare hardware state]
  I --> J{Start acquisition on open?}
  J -->|Yes| K[run]
  J -->|No| L[Return from open]
  K --> L
  L --> M{autostart?}
  M -->|Yes| N[Start background daemon]
Loading
Prompt To Fix All With AI
### Issue 1
instro/scope/scope.py:427-428
**Acquisition precedes measurement setup**

When a Tektronix config enables `start_acquisition_on_open` and background measurements, `run()` starts acquisition before those measurements create their required instrument slots, causing initial background polls to return unavailable or stale results until a later acquisition processes the slots.

### Issue 2
instro/scope/scope.py:363-367
**Validation leaks publisher resources**

When `autostart=True` is used with configured publishers but no background measurements, this validation raises after the publishers have opened their file handles or write streams but before the cleanup handler, leaving those resources open with no constructed scope available to close them.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(scope): JSON config-driven instrume..." | Re-trigger Greptile

Comment thread instro/scope/scope.py
Comment thread instro/scope/scope.py Outdated
@nhschwab nhschwab changed the title feat(scope): JSON config-driven instrument creation feat(scope): json config-driven instrument creation Sep 3, 2026
Comment thread instro/scope/scope.py
Comment thread instro/scope/scope.py Outdated
…tion_on_open

Tektronix computes measurements during acquisition, so the slot must exist
before the scope triggers. Call setup_measurement for every configured
measurement in the open() apply pass, after the trigger block and before
sync_configuration() and run(), matching the pre-install behavior INSTRO-564
describes for measurement registration.
Match the sibling HALs: log Opening/Opened and Closing/Closed at info level,
and stop the daemon and publishers before closing the driver so a running
poll cannot hit a closed transport. Adopt ELoad's shape for close(): reset
the config-applied flag before teardown and close the driver in a finally so
a failing publisher close cannot strand either.
Lift the isclose tolerances into SNAP_REL_TOL and SNAP_ABS_TOL module
constants and widen the relative tolerance from 0.1% to 1%. Scope read-backs
are formatted to as few as 3 significant figures, so rounding alone can move
a value by 0.5%; 1% absorbs that while still catching every 1-2-5 step snap,
which changes the value by 25% or more. Add a regression test for the
rounding case.
Reorder the open() apply pass so mocked-correct sequences also hold on real
scopes:

- Coupling and probe attenuation apply before vertical scale and offset,
  because the probe factor rescales the channel's probe-referred scale.
- run() (when start_acquisition_on_open is set) now fires after the trigger
  block and before the acquisition block: Siglent only applies ACQW while
  acquiring, and Tektronix measurement slots only settle against a live
  acquisition, so preparing them on a stopped scope stalled ~2 s each.
- average_count applies before mode, since Siglent's mode command carries the
  count inline.

Also fix Keysight1200X.get_acquisition_mode, which keyed its read-back map on
long-form names while the instrument answers with the 4-character short form,
so AVERAGE and HIGH_RESOLUTION read back as NORMAL and the new post-open snap
check warned on every open of the shipped example.

start() now warns when no measurements are registered rather than silently
spinning an empty daemon; it does not raise, since start() without methods is
a supported way to create the in-memory channel buffer. The rollback test now
also asserts a reopen after a failed apply re-attempts the config.
Comment thread instro/scope/drivers/keysight_1200x.py
Comment thread instro/scope/types.py
Comment thread instro/scope/scope.py Outdated
Comment thread instro/scope/drivers/keysight_1200x.py
Comment thread instro/scope/scope.py
Comment thread instro/scope/scope.py
Comment thread instro/scope/scope.py
Comment thread instro/scope/scope.py
Comment thread instro/scope/scope.py
Comment thread instro/scope/types.py
Comment thread instro/scope/scope.py Outdated
Comment thread instro/scope/scope.py
Move the autostart-requires-measurements check to run on the validated
ScopeConfig, ahead of resolve_scope_from_config, so a rejected autostart never
constructs a driver or opens a publisher stream that then has nothing to close
it. Direct construction with autostart=True still raises, matching InstroDMM.
The mid-list build_publisher leak remains tracked by #418.
Compute the channel fields the post-open snap check compares as the
intersection of ChannelState's dataclass fields and ChannelConfig's model
fields, so a field added to both is checked without touching this code and
measurements (config-only) drops out on its own. Acquisition fields stay
explicit because the config nests them under a different name than the
flattened ScopeState attributes.
maxleblang
maxleblang previously approved these changes Sep 8, 2026
Assert the derived channel snap fields equal every ChannelConfig field except
measurements, so a field added or renamed on only one of ChannelConfig and
ChannelState fails loudly instead of silently dropping out of the check.
@nhschwab
nhschwab merged commit 1d1c4f8 into main Sep 9, 2026
24 checks passed
@nhschwab
nhschwab deleted the instro-578-scope-json-config branch September 9, 2026 17:16
@nombotv2 nombotv2 Bot mentioned this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants