feat(scope): json config-driven instrument creation - #460
Conversation
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
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
Claude finished @nhschwab's task in 5m 2s —— View job Review
Overview: Clean port of the One inline note on lifecycle logging left below. No correctness bugs found; didn't run tests/lints per review scope (static-only). |
Greptile SummaryThe PR adds strict JSON/dict-based oscilloscope construction, applies configured channel, acquisition, and trigger state during open, and registers configured measurements for background polling.
Confidence Score: 3/5The 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
|
…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.
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.
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.
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
instro/scope/config.pywith the PydanticScopeConfigmodel, itsChannelConfig/AcquisitionConfig/TriggerConfigblocks,VisaDriverConfig, andSCOPE_VENDOR_REGISTRYfor the three registered drivers. Validators enforce the schema rules: channel keys andtrigger.sourcewithinnum_channels,average_countrequiresAVERAGEmode, no duplicate measurements, andtimingrequires at least one polled measurement.InstroScope(config=..., autostart=False)construction from aScopeConfig, dict, or JSON path, mirroringInstroPSU/InstroDMM. Direct construction is unchanged.measurementsas background daemon functions at construction.autostart=Truerequires at least one; a manualstart()with none registered logs a warning rather than raising, since starting the daemon is also how the in-memory channel buffer gets created.open(), apply the config through the public setters in the order the hardware needs, thensync_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 nextopen()re-attempts the apply. Reopening without closing does not reapply.ScopeConfig/ChannelConfig/TriggerConfigtoScopeState/ChannelState/TriggerStateso the Pydantic models keep the<Category>Confignames.sync_configuration()now returnsScopeState.InstroScopehas 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:
coupling,probe_attenuation, thenvertical_scale,vertical_offset. The probe factor rescales the channel's probe-referred scale and offset on all three drivers.trigger: source, type, slope, level, mode.run()ifacquisition.start_acquisition_on_openis set, so the configured trigger is in place when acquisition starts, and so the next step lands on a running scope.acquisition:average_countbeforemode(Siglent's mode command carries the count inline; Siglent only appliesACQWwhile acquiring), thenhorizontal_scale.setup_measurement()for every configured measurement, so Tektronix has its slots installed before the first poll.sync_configuration()and the snapped-value warnings.Also in this PR
Keysight1200X.get_acquisition_modekeyed 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, andclose()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-finallyshape.Deferred
resolve_scope_from_confighas built publishers: Publisher resource leak when build_publisher raises mid-list in config resolution #418 (INSTRO-588), shared by every instrument.close()with attached publishers: INSTRO-606, shared by every instrument.TRMDhandling: fix(scope): SiglentSDS1000XE run() and set_acquisition_mode() fight over TRMD run state #494 (INSTRO-626), shared root cause for two driver bugs.run()writesTRMD AUTO, overwriting a configuredtrigger.mode: NORMALthat the snap check cannot catch since trigger fields are not read back; andset_acquisition_mode()is dropped by the instrument while stopped, soacquisition.modewithoutstart_acquisition_on_openon a non-running Siglent does not take effect (the snap check does surface that one). Both are driver fixes and stay out of this PR.Docs and examples
docs/guides/instrumentation/oscilloscope.mdx: new "From a JSON Config File" section,config/autostartparameters, apply-order and lifecycle updates, method-reference row.docs/reference/src/instruments/scope.md: includeinstro.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*Statenaming.Test plan
just check-python(ruff format, mypy, ruff check) passesuv run pytest tests packagespassesjust check-examplesreports no drifttests/scope/test_scope_config.pycovers validation rules, the hardware apply order, the snapped-value warning and its rounding tolerance, rollback and re-apply on a failed open, thestart()warning, autostart, and vendor-registry drifttests/scope/test_instro_scope.pycovers the Keysight short-form acquisition-mode read-back