Source code for instro.scope.config

"""Pydantic config models for JSON config-driven ``InstroScope`` construction."""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from instro.lib.config import (
    FilePublisherConfig,
    NominalCorePublisherConfig,
    PublisherConfigType,
    TimingConfig,
    build_publisher,
)
from instro.lib.registry import DriverEntry, IdnPattern
from instro.lib.transports.visa import VisaConfig
from instro.lib.types import DeviceInfo
from instro.scope.types import (
    AcquisitionMode,
    Coupling,
    ScopeMeasurementType,
    TriggerMode,
    TriggerSlope,
    TriggerType,
)

if TYPE_CHECKING:
    from instro.lib.publishers import Publisher
    from instro.scope.scope import ScopeDriverBase

__all__ = [
    "AcquisitionConfig",
    "ChannelConfig",
    "DeviceInfo",
    "FilePublisherConfig",
    "NominalCorePublisherConfig",
    "ScopeConfig",
    "TimingConfig",
    "TriggerConfig",
    "VisaDriverConfig",
]

SCOPE_VENDOR_REGISTRY: dict[str, DriverEntry] = {
    "Keysight1200X": DriverEntry(
        "instro.scope.drivers.keysight_1200x.Keysight1200X",
        (
            IdnPattern(("KEYSIGHT TECHNOLOGIES",), r"^DSOX120", 2),
            IdnPattern(("KEYSIGHT TECHNOLOGIES",), r"^EDUX105", 2),
        ),
    ),
    "SiglentSDS1000XE": DriverEntry(
        "instro.scope.drivers.siglent_sds1000x_e.SiglentSDS1000XE",
        (
            IdnPattern(("SIGLENT TECHNOLOGIES",), r"^SDS1104X-E", 4),
            IdnPattern(("SIGLENT TECHNOLOGIES",), r"^SDS1202X-E", 2),
            IdnPattern(("SIGLENT TECHNOLOGIES",), r"^SDS1204X-E", 4),
        ),
    ),
    "Tektronix2SeriesMSO": DriverEntry(
        "instro.scope.drivers.tektronix_2series.Tektronix2SeriesMSO",
        (IdnPattern(("TEKTRONIX",), r"^MSO2[24]", 4),),
    ),
}


[docs] class ChannelConfig(BaseModel): """Initial per-channel state and polled measurements, applied through the InstroScope setters on ``open()``.""" model_config = ConfigDict(extra="forbid") vertical_scale: float | None = Field(default=None, gt=0, description="Vertical scale in V/div.") vertical_offset: float | None = Field(default=None, description="Vertical offset in volts.") coupling: Coupling | None = Field(default=None, description="Input coupling, 'AC' or 'DC'.") probe_attenuation: float | None = Field(default=None, gt=0, description="Probe attenuation ratio, e.g. 1, 10, 100.") measurements: list[ScopeMeasurementType] = Field( default_factory=list, description="Built-in measurements to background-poll on this channel." ) @field_validator("measurements") @classmethod def _no_duplicate_measurements(cls, v: list[ScopeMeasurementType]) -> list[ScopeMeasurementType]: if len(set(v)) != len(v): raise ValueError("duplicate measurement types in a channel's measurements list") return v
[docs] class AcquisitionConfig(BaseModel): """Initial acquisition state, applied through the InstroScope setters on ``open()``.""" model_config = ConfigDict(extra="forbid") mode: AcquisitionMode | None = Field(default=None, description="Acquisition mode, e.g. 'NORMAL' or 'AVERAGE'.") average_count: int | None = Field(default=None, ge=2, description="Waveforms to average; requires mode 'AVERAGE'.") horizontal_scale: float | None = Field(default=None, gt=0, description="Timebase in s/div.") start_acquisition_on_open: bool = Field( default=False, description="Start continuous acquisition (``run()``) as the last step of ``open()``." ) @model_validator(mode="after") def _average_count_requires_average_mode(self) -> AcquisitionConfig: if self.average_count is not None and self.mode != AcquisitionMode.AVERAGE: raise ValueError("average_count requires mode 'AVERAGE'") return self
[docs] class TriggerConfig(BaseModel): """Initial trigger state, applied through the InstroScope setters on ``open()``.""" model_config = ConfigDict(extra="forbid") source: int = Field(ge=1, description="1-based analog channel used as the trigger source.") type: TriggerType | None = Field(default=None, description="Trigger type, 'EDGE' or 'PULSE'.") level: float | None = Field(default=None, description="Trigger level in volts.") slope: TriggerSlope | None = Field(default=None, description="Trigger slope, 'RISING', 'FALLING', or 'EITHER'.") mode: TriggerMode | None = Field(default=None, description="Trigger sweep mode, 'AUTO' or 'NORMAL'.")
[docs] class VisaDriverConfig(BaseModel): """Driver config for a VISA-connected scope.""" model_config = ConfigDict(extra="forbid") connection_type: Literal["visa"] = "visa" name: str = Field(description="Scope vendor/model key.") num_channels: int = Field(ge=1, description="Number of analog-input channels.") visa: VisaConfig @field_validator("name") @classmethod def name_must_be_registered(cls, v: str) -> str: if v not in SCOPE_VENDOR_REGISTRY: raise ValueError(f"unknown driver {v!r}") return v
[docs] class ScopeConfig(BaseModel): """Validated config for constructing an InstroScope from JSON.""" model_config = ConfigDict(extra="forbid") version: Literal[1] = 1 instrument: Literal["InstroScope"] = "InstroScope" device: DeviceInfo driver: VisaDriverConfig channels: dict[int, ChannelConfig] = Field( default_factory=dict, description="Per-channel initial state keyed by 1-based channel number." ) acquisition: AcquisitionConfig | None = None trigger: TriggerConfig | None = None timing: TimingConfig | None = None publishers: list[PublisherConfigType] = Field(default_factory=list) @model_validator(mode="after") def _channels_within_num_channels(self) -> ScopeConfig: num_channels = self.driver.num_channels out_of_range = sorted(ch for ch in self.channels if not 1 <= ch <= num_channels) if out_of_range: raise ValueError(f"channels {out_of_range} are outside 1..{num_channels} (driver.num_channels)") if self.trigger is not None and self.trigger.source > num_channels: raise ValueError(f"trigger.source {self.trigger.source} is outside 1..{num_channels} (driver.num_channels)") return self @model_validator(mode="after") def _timing_requires_measurements(self) -> ScopeConfig: if self.timing is not None and not any(ch.measurements for ch in self.channels.values()): raise ValueError( "timing requires at least one channel with a non-empty measurements list: " "background polling has nothing to do otherwise" ) return self
[docs] def resolve_scope_from_config( config: ScopeConfig, ) -> tuple[str, ScopeDriverBase, int, list[Publisher], float | None]: """Resolve a validated ScopeConfig into the ``(name, driver, num_channels, config_publishers, poll_interval)`` InstroScope needs.""" driver_cls = SCOPE_VENDOR_REGISTRY[config.driver.name].load() driver: ScopeDriverBase = driver_cls(config.driver.visa) # type: ignore[call-arg] config_publishers = [build_publisher(p) for p in config.publishers] poll_interval = config.timing.poll_interval if config.timing is not None else None return config.device.name, driver, config.driver.num_channels, config_publishers, poll_interval