From 8a746fb47d2b8a9984c02c97f1fa8eb8c5c02035 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:14:43 +0800 Subject: [PATCH 01/63] rf-source: add M0 public contracts --- src/wavebench/instruments/__init__.py | 6 + src/wavebench/instruments/api.py | 12 + src/wavebench/instruments/capabilities.py | 6 + src/wavebench/instruments/registry.py | 2 + .../instruments/rf_source_capabilities.py | 110 ++++ .../instruments/rf_source_extensions.py | 524 ++++++++++++++++++ src/wavebench/plugins/api.py | 3 +- tests/test_rf_source_extensions.py | 234 ++++++++ tests/test_source_extensions.py | 3 +- 9 files changed, 898 insertions(+), 2 deletions(-) create mode 100644 src/wavebench/instruments/rf_source_capabilities.py create mode 100644 src/wavebench/instruments/rf_source_extensions.py create mode 100644 tests/test_rf_source_extensions.py diff --git a/src/wavebench/instruments/__init__.py b/src/wavebench/instruments/__init__.py index 279484c..8c4df10 100644 --- a/src/wavebench/instruments/__init__.py +++ b/src/wavebench/instruments/__init__.py @@ -150,6 +150,7 @@ ) from . import scope_extensions as _scope_extensions from . import source_extensions as _source_extensions +from . import rf_source_extensions as _rf_source_extensions for _scope_extension_name in _scope_extensions.__all__: globals()[_scope_extension_name] = getattr(_scope_extensions, _scope_extension_name) @@ -157,6 +158,9 @@ for _source_extension_name in _source_extensions.__all__: globals()[_source_extension_name] = getattr(_source_extensions, _source_extension_name) +for _rf_source_extension_name in _rf_source_extensions.__all__: + globals()[_rf_source_extension_name] = getattr(_rf_source_extensions, _rf_source_extension_name) + __all__ = [ "ArbitraryQueryProbeResult", "DmmCalculationStatistics", @@ -310,7 +314,9 @@ "open_instrument_driver", *_scope_extensions.__all__, *_source_extensions.__all__, + *_rf_source_extensions.__all__, ] del _scope_extension_name del _source_extension_name +del _rf_source_extension_name diff --git a/src/wavebench/instruments/api.py b/src/wavebench/instruments/api.py index 939005e..7d9a55b 100644 --- a/src/wavebench/instruments/api.py +++ b/src/wavebench/instruments/api.py @@ -11,6 +11,7 @@ from .scope_extensions import ScopeDescriptorExtensions from .source_extensions import SourceDescriptorExtensions +from .rf_source_extensions import RfSourceDescriptorExtensions EXECUTABLE_PLUGIN_API_VERSION = "wavebench.instrument.v2" ScopeCouplingPolicy = Literal["fixed-high-impedance", "switchable-termination", "unknown"] @@ -92,6 +93,7 @@ class InstrumentDescriptor: # Append-only to preserve the positional layout accepted by instrument API v2. scope_extensions: ScopeDescriptorExtensions | None = None source_extensions: SourceDescriptorExtensions | None = None + rf_source_extensions: RfSourceDescriptorExtensions | None = None def __post_init__(self) -> None: if not self.driver_id or self.driver_id.strip() != self.driver_id: @@ -129,6 +131,16 @@ def __post_init__(self) -> None: raise ValueError("source_extensions can only be declared by source descriptors") if not isinstance(self.source_extensions, SourceDescriptorExtensions): raise TypeError("source_extensions has an invalid type") + if self.rf_source_extensions is None: + if self.kind == "rf_source": + raise ValueError("rf_source descriptors require rf_source_extensions") + else: + if self.kind != "rf_source": + raise ValueError( + "rf_source_extensions can only be declared by rf_source descriptors" + ) + if not isinstance(self.rf_source_extensions, RfSourceDescriptorExtensions): + raise TypeError("rf_source_extensions has an invalid type") def with_distribution( self, diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index 8dfe903..653cc48 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -13,6 +13,10 @@ SOURCE_EXTENSION_CAPABILITY_METHODS, validate_source_descriptor, ) +from .rf_source_capabilities import ( + RF_SOURCE_CAPABILITY_METHODS, + validate_rf_source_descriptor, +) _PROFILE_AWARE_WAVEFORM_CAPABILITIES = frozenset( @@ -104,6 +108,7 @@ } CAPABILITY_METHODS.update(SCOPE_CAPABILITY_METHODS) CAPABILITY_METHODS.update(SOURCE_EXTENSION_CAPABILITY_METHODS) +CAPABILITY_METHODS.update(RF_SOURCE_CAPABILITY_METHODS) def require_capabilities( @@ -149,3 +154,4 @@ def validate_declared_capabilities( ) validate_scope_descriptor(descriptor, driver=driver) validate_source_descriptor(descriptor, driver=driver) + validate_rf_source_descriptor(descriptor, driver=driver) diff --git a/src/wavebench/instruments/registry.py b/src/wavebench/instruments/registry.py index 0e485e0..f58051c 100644 --- a/src/wavebench/instruments/registry.py +++ b/src/wavebench/instruments/registry.py @@ -22,6 +22,7 @@ from .scope_extension_capabilities import validate_scope_descriptor from .source_conformance import validate_source_conformance_distribution from .source_extension_capabilities import validate_source_descriptor +from .rf_source_capabilities import validate_rf_source_descriptor from .migrations import BUILTIN_MIGRATION_DISTRIBUTIONS ENTRY_POINT_GROUP = "wavebench.instruments" @@ -211,6 +212,7 @@ def _validate_descriptor( ) validate_scope_descriptor(descriptor) validate_source_descriptor(descriptor) + validate_rf_source_descriptor(descriptor) current = _version_tuple(__version__) if current < _version_tuple(descriptor.wavebench_min_version) or current >= _version_tuple( descriptor.wavebench_max_version diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py new file mode 100644 index 0000000..d64cfcb --- /dev/null +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -0,0 +1,110 @@ +"""Capability registration and descriptor validation for RF signal sources.""" + +from __future__ import annotations + +from collections.abc import Iterable +from types import MappingProxyType +from typing import Mapping + +from packaging.version import InvalidVersion, Version + +from wavebench.errors import ConfigError + +from .rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION, + RfSourceDescriptorExtensions, +) + + +RF_SOURCE_CAPABILITY_METHODS: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + "rf_source.idn": ("idn",), + "rf_source.snapshot": ("get_rf_snapshot",), + } +) + + +def validate_rf_source_descriptor(descriptor: object, driver: object | None = None) -> None: + """Validate the static, read-only RF-source M0 descriptor contract.""" + + capabilities = tuple(getattr(descriptor, "capabilities", ())) + rf_capabilities = tuple( + capability for capability in capabilities if isinstance(capability, str) and capability.startswith("rf_source.") + ) + unknown = sorted(set(rf_capabilities) - set(RF_SOURCE_CAPABILITY_METHODS)) + if unknown: + raise ConfigError( + "RF source descriptor declares unknown capabilities: " + ", ".join(unknown) + ) + + kind = getattr(descriptor, "kind", None) + extensions = getattr(descriptor, "rf_source_extensions", None) + if kind != "rf_source": + if extensions is not None: + raise ConfigError("rf_source_extensions can only be declared by rf_source descriptors") + if rf_capabilities: + raise ConfigError("rf_source capabilities require kind='rf_source'") + return + if extensions is None: + raise ConfigError("rf_source descriptors require rf_source_extensions") + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError("rf_source_extensions has an invalid type") + if extensions.contract_version != RF_SOURCE_CONTRACT_VERSION: + raise ConfigError("rf_source_extensions uses an unsupported contract version") + if any(not isinstance(capability, str) or not capability.startswith("rf_source.") for capability in capabilities): + raise ConfigError("rf_source descriptors can only declare rf_source capabilities") + if "rf_source.idn" not in rf_capabilities: + raise ConfigError("rf_source descriptors require the rf_source.idn capability") + _validate_rf_source_version_range(descriptor) + if driver is not None: + for capability in rf_capabilities: + for method_name in RF_SOURCE_CAPABILITY_METHODS[capability]: + method = getattr(driver, method_name, None) + if not callable(method): + raise TypeError( + f"descriptor declares capability {capability!r}, but driver lacks " + f"callable method {method_name}" + ) + + +def validate_rf_source_plugin_dependencies( + descriptor: object, + dependencies: Iterable[str], +) -> None: + """Reserve a public dependency-validation hook for RF-source plugin wheels. + + M0 only freezes the descriptor's version interval. The general plugin + lifecycle already proves one active WaveBench dependency before entry-point + import; later RF-specific releases can tighten this hook without changing + the descriptor schema. + """ + + del dependencies + if getattr(descriptor, "kind", None) == "rf_source": + _validate_rf_source_version_range(descriptor) + + +def _validate_rf_source_version_range(descriptor: object) -> None: + minimum_text = getattr(descriptor, "wavebench_min_version", "") + maximum_text = getattr(descriptor, "wavebench_max_version", "") + try: + minimum = Version(minimum_text) + maximum = Version(maximum_text) + required = Version(RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION) + except (InvalidVersion, TypeError) as exc: + raise ConfigError("RF source descriptor versions must use valid PEP 440 syntax") from exc + if minimum >= maximum: + raise ConfigError("RF source descriptor version range must satisfy min < max") + if minimum < required: + raise ConfigError( + "RF source descriptors require wavebench_min_version >= " + f"{RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION}" + ) + + +__all__ = [ + "RF_SOURCE_CAPABILITY_METHODS", + "validate_rf_source_descriptor", + "validate_rf_source_plugin_dependencies", +] diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py new file mode 100644 index 0000000..2ca0da9 --- /dev/null +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -0,0 +1,524 @@ +"""Public contracts for RF signal-source plugins. + +This module deliberately models radio-frequency sources independently from +the ``source`` domain used by function and arbitrary waveform generators. +It contains only static descriptors and read-only snapshots; it never opens a +transport or sends SCPI commands. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, is_dataclass +from enum import StrEnum +from hashlib import sha256 +import json +from math import isfinite +import re +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar, runtime_checkable + +from .contracts import InstrumentDriver + + +RF_SOURCE_CONTRACT_VERSION = "wavebench.rf_source.v1" +RF_SOURCE_SNAPSHOT_SCHEMA = "wavebench.rf_source.snapshot.v1" +RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" +RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.24" + +_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") + + +def _require_bool(value: object, label: str) -> None: + if not isinstance(value, bool): + raise ValueError(f"{label} must be boolean") + + +def _require_token(value: object, label: str) -> None: + if not isinstance(value, str) or _SAFE_TOKEN.fullmatch(value) is None: + raise ValueError(f"{label} must be a short safe token") + + +def _require_finite( + value: object, + label: str, + *, + minimum: float | None = None, + maximum: float | None = None, +) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ValueError(f"{label} must be finite") + if minimum is not None and value < minimum: + raise ValueError(f"{label} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{label} must be <= {maximum}") + + +def _require_enum_tuple( + values: object, + enum_type: type[StrEnum], + label: str, + *, + allow_empty: bool = False, +) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + if any(not isinstance(value, enum_type) for value in values): + raise ValueError(f"{label} entries have an invalid type") + names = tuple(value.value for value in values) + if len(set(names)) != len(names) or tuple(sorted(names)) != names: + raise ValueError(f"{label} must be sorted by value and unique") + + +def _require_token_tuple( + values: object, + label: str, + *, + allow_empty: bool = False, +) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + for value in values: + _require_token(value, label) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +def _contains_nonfinite(value: object) -> bool: + if isinstance(value, float): + return not isfinite(value) + if isinstance(value, tuple): + return any(_contains_nonfinite(item) for item in value) + if is_dataclass(value) and not isinstance(value, type): + return any(_contains_nonfinite(getattr(value, item.name)) for item in fields(value)) + return False + + +class RfAvailability(StrEnum): + VALUE = "value" + UNSUPPORTED = "unsupported" + NOT_APPLICABLE = "not_applicable" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +class RfReasonCode(StrEnum): + DESCRIPTOR_UNSUPPORTED = "descriptor_unsupported" + NOT_REQUESTED = "not_requested" + RESPONSE_MISSING_FIELD = "response_missing_field" + RESPONSE_INVALID_VALUE = "response_invalid_value" + DRIVER_SKIPPED_OPTIONAL = "driver_skipped_optional" + PROTOCOL_RECORD_INVALID = "protocol_record_invalid" + SESSION_NOT_HEALTHY = "session_not_healthy" + UNKNOWN_STATE = "unknown_state" + + +class RfModulationState(StrEnum): + DISABLED = "disabled" + ENABLED = "enabled" + + +class RfPulseState(StrEnum): + DISABLED = "disabled" + ENABLED = "enabled" + + +class RfSweepState(StrEnum): + DISABLED = "disabled" + ENABLED = "enabled" + + +class RfFeature(StrEnum): + CW = "cw" + MODULATION = "modulation" + OUTPUT = "output" + PULSE = "pulse" + SWEEP = "sweep" + + +class RfFeatureDirection(StrEnum): + ARM = "arm" + CONFIGURE = "configure" + DISABLE = "disable" + ENABLE = "enable" + FIRE = "fire" + READ = "read" + STOP = "stop" + TRIGGER = "trigger" + + +T = TypeVar("T") + + +@dataclass(frozen=True, slots=True) +class RfObserved(Generic[T]): + """A typed value or a stable, non-sensitive reason why it is unavailable.""" + + availability: RfAvailability + value: T | None = None + reason_code: RfReasonCode | None = None + + def __post_init__(self) -> None: + if not isinstance(self.availability, RfAvailability): + raise ValueError("RF observation availability has an invalid type") + if self.availability is RfAvailability.VALUE: + if self.value is None: + raise ValueError("VALUE RF observations must carry a value") + if self.reason_code is not None: + raise ValueError("VALUE RF observations cannot carry a reason_code") + if _contains_nonfinite(self.value): + raise ValueError("VALUE RF observations cannot contain non-finite floats") + else: + if self.value is not None: + raise ValueError("non-VALUE RF observations cannot carry a value") + if not isinstance(self.reason_code, RfReasonCode): + raise ValueError("non-VALUE RF observations require a registered reason_code") + + @classmethod + def value_of(cls, value: T) -> "RfObserved[T]": + return cls(availability=RfAvailability.VALUE, value=value) + + @classmethod + def missing( + cls, + availability: RfAvailability, + reason_code: RfReasonCode, + ) -> "RfObserved[T]": + if availability is RfAvailability.VALUE: + raise ValueError("missing RF observations cannot use VALUE") + return cls(availability=availability, reason_code=reason_code) + + +@dataclass(frozen=True, slots=True) +class RfOutputPortProfile: + port_id: str + frequency_min_hz: float + frequency_max_hz: float + power_min_dbm: float + power_max_dbm: float + power_reference_impedance_ohm: float + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF output port_id") + _require_finite(self.frequency_min_hz, "RF frequency_min_hz", minimum=0.0) + _require_finite( + self.frequency_max_hz, + "RF frequency_max_hz", + minimum=self.frequency_min_hz, + ) + _require_finite(self.power_min_dbm, "RF power_min_dbm") + _require_finite( + self.power_max_dbm, + "RF power_max_dbm", + minimum=self.power_min_dbm, + ) + _require_finite( + self.power_reference_impedance_ohm, + "RF power_reference_impedance_ohm", + minimum=0.0, + ) + if self.power_reference_impedance_ohm <= 0.0: + raise ValueError("RF power_reference_impedance_ohm must be positive") + + +@dataclass(frozen=True, slots=True) +class RfSourceTopology: + ports: tuple[RfOutputPortProfile, ...] + + def __post_init__(self) -> None: + if not isinstance(self.ports, tuple) or not self.ports or any( + not isinstance(port, RfOutputPortProfile) for port in self.ports + ): + raise ValueError("RF source topology ports have an invalid type") + port_ids = tuple(port.port_id for port in self.ports) + if len(set(port_ids)) != len(port_ids) or tuple(sorted(port_ids)) != port_ids: + raise ValueError("RF source topology ports must be sorted and unique") + + +@dataclass(frozen=True, slots=True) +class RfProtectionStatus: + active_codes: tuple[str, ...] + + def __post_init__(self) -> None: + _require_token_tuple(self.active_codes, "RF protection active_codes", allow_empty=True) + + +@dataclass(frozen=True, slots=True) +class RfProtectionConditionPolicy: + code: str + blocks_output_enable: bool + + def __post_init__(self) -> None: + _require_token(self.code, "RF protection condition code") + _require_bool(self.blocks_output_enable, "RF protection blocks_output_enable") + + +@dataclass(frozen=True, slots=True) +class RfPortSnapshot: + port_id: str + frequency_hz: RfObserved[float] + power_dbm: RfObserved[float] + output_enabled: RfObserved[bool] + modulation: RfObserved[RfModulationState] + pulse: RfObserved[RfPulseState] + sweep: RfObserved[RfSweepState] + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF port snapshot port_id") + _require_observed_number(self.frequency_hz, "RF port snapshot frequency_hz", minimum=0.0) + _require_observed_number(self.power_dbm, "RF port snapshot power_dbm") + _require_observed_value(self.output_enabled, bool, "RF port snapshot output_enabled") + _require_observed_value( + self.modulation, + RfModulationState, + "RF port snapshot modulation", + ) + _require_observed_value(self.pulse, RfPulseState, "RF port snapshot pulse") + _require_observed_value(self.sweep, RfSweepState, "RF port snapshot sweep") + + +@dataclass(frozen=True, slots=True) +class RfSourceSnapshot: + ports: tuple[RfPortSnapshot, ...] + protection: RfObserved[RfProtectionStatus] + + def __post_init__(self) -> None: + if not isinstance(self.ports, tuple) or not self.ports or any( + not isinstance(port, RfPortSnapshot) for port in self.ports + ): + raise ValueError("RF source snapshot ports have an invalid type") + port_ids = tuple(port.port_id for port in self.ports) + if len(set(port_ids)) != len(port_ids) or tuple(sorted(port_ids)) != port_ids: + raise ValueError("RF source snapshot ports must be sorted and unique") + _require_observed_value( + self.protection, + RfProtectionStatus, + "RF source snapshot protection", + ) + + def as_dict(self) -> dict[str, object]: + return rf_source_snapshot_document(self) + + +@dataclass(frozen=True, slots=True) +class RfCwProfile: + frequency_readable: bool + power_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.frequency_readable, "RF CW frequency_readable") + _require_bool(self.power_readable, "RF CW power_readable") + + +@dataclass(frozen=True, slots=True) +class RfOutputProfile: + output_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.output_readable, "RF output output_readable") + + +@dataclass(frozen=True, slots=True) +class RfModulationProfile: + state_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF modulation state_readable") + + +@dataclass(frozen=True, slots=True) +class RfPulseProfile: + state_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF pulse state_readable") + + +@dataclass(frozen=True, slots=True) +class RfSweepProfile: + state_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF sweep state_readable") + + +RfFeatureProfile: TypeAlias = ( + RfCwProfile | RfOutputProfile | RfModulationProfile | RfPulseProfile | RfSweepProfile +) + +_FEATURE_PROFILE_TYPES: dict[RfFeature, type[RfFeatureProfile]] = { + RfFeature.CW: RfCwProfile, + RfFeature.MODULATION: RfModulationProfile, + RfFeature.OUTPUT: RfOutputProfile, + RfFeature.PULSE: RfPulseProfile, + RfFeature.SWEEP: RfSweepProfile, +} + + +@dataclass(frozen=True, slots=True) +class RfFeatureCapability: + feature: RfFeature + directions: tuple[RfFeatureDirection, ...] + port_ids: tuple[str, ...] + profile: RfFeatureProfile + + def __post_init__(self) -> None: + if not isinstance(self.feature, RfFeature): + raise ValueError("RF feature has an invalid type") + _require_enum_tuple(self.directions, RfFeatureDirection, "RF feature directions") + _require_token_tuple(self.port_ids, "RF feature port_ids") + if not isinstance(self.profile, _FEATURE_PROFILE_TYPES[self.feature]): + raise ValueError("RF feature profile does not match feature") + + +@dataclass(frozen=True, slots=True) +class RfSourceDescriptorExtensions: + contract_version: Literal["wavebench.rf_source.v1"] + topology: RfSourceTopology + features: tuple[RfFeatureCapability, ...] = () + protection_conditions: tuple[RfProtectionConditionPolicy, ...] = () + + def __post_init__(self) -> None: + if self.contract_version != RF_SOURCE_CONTRACT_VERSION: + raise ValueError("RF source descriptor contract_version is unsupported") + if not isinstance(self.topology, RfSourceTopology): + raise ValueError("RF source descriptor topology has an invalid type") + if not isinstance(self.features, tuple) or any( + not isinstance(feature, RfFeatureCapability) for feature in self.features + ): + raise ValueError("RF source descriptor features have an invalid type") + feature_names = tuple(feature.feature.value for feature in self.features) + if len(set(feature_names)) != len(feature_names) or tuple(sorted(feature_names)) != feature_names: + raise ValueError("RF source descriptor features must be sorted and unique") + topology_port_ids = {port.port_id for port in self.topology.ports} + if any(not set(feature.port_ids) <= topology_port_ids for feature in self.features): + raise ValueError("RF source descriptor feature references an unknown port") + if not isinstance(self.protection_conditions, tuple) or any( + not isinstance(condition, RfProtectionConditionPolicy) + for condition in self.protection_conditions + ): + raise ValueError("RF source descriptor protection_conditions have an invalid type") + condition_codes = tuple(condition.code for condition in self.protection_conditions) + if len(set(condition_codes)) != len(condition_codes) or tuple(sorted(condition_codes)) != condition_codes: + raise ValueError("RF source descriptor protection_conditions must be sorted and unique") + + +@runtime_checkable +class RfSourceDriver(InstrumentDriver, Protocol): + def get_rf_snapshot(self) -> RfSourceSnapshot: ... + + +def _require_observed_number( + observed: object, + label: str, + *, + minimum: float | None = None, +) -> None: + if not isinstance(observed, RfObserved): + raise ValueError(f"{label} has an invalid type") + if observed.availability is RfAvailability.VALUE: + _require_finite(observed.value, label, minimum=minimum) + + +def _require_observed_value( + observed: object, + expected_type: type[object], + label: str, +) -> None: + if not isinstance(observed, RfObserved): + raise ValueError(f"{label} has an invalid type") + if observed.availability is RfAvailability.VALUE and not isinstance( + observed.value, expected_type + ): + raise ValueError(f"{label} has an invalid VALUE type") + + +def rf_source_to_data(value: object) -> object: + """Convert public RF-source values into strict JSON-compatible data.""" + + if isinstance(value, StrEnum): + return value.value + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + if not isfinite(value): + raise ValueError("RF source JSON cannot contain non-finite floats") + return value + if isinstance(value, tuple): + return [rf_source_to_data(item) for item in value] + if is_dataclass(value) and not isinstance(value, type): + payload: dict[str, object] = {"type": type(value).__name__} + for item in fields(value): + payload[item.name] = rf_source_to_data(getattr(value, item.name)) + return payload + raise TypeError(f"unsupported RF source JSON value: {type(value).__name__}") + + +def rf_source_canonical_json(value: object) -> str: + return json.dumps( + rf_source_to_data(value), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def rf_source_digest(value: object) -> str: + return "sha256:" + sha256(rf_source_canonical_json(value).encode("utf-8")).hexdigest() + + +def rf_source_snapshot_document(snapshot: RfSourceSnapshot) -> dict[str, object]: + if not isinstance(snapshot, RfSourceSnapshot): + raise TypeError("snapshot must be RfSourceSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_SNAPSHOT_SCHEMA, **data} + + +def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: + """Build a read-only snapshot artifact without transport-private values.""" + + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.snapshot", + "snapshot": rf_source_snapshot_document(snapshot), + } + + +__all__ = [ + "RF_SOURCE_CONTRACT_VERSION", + "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", + "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", + "RF_SOURCE_SNAPSHOT_SCHEMA", + "RfAvailability", + "RfCwProfile", + "RfFeature", + "RfFeatureCapability", + "RfFeatureDirection", + "RfFeatureProfile", + "RfModulationProfile", + "RfModulationState", + "RfObserved", + "RfOutputPortProfile", + "RfOutputProfile", + "RfPortSnapshot", + "RfProtectionConditionPolicy", + "RfProtectionStatus", + "RfPulseProfile", + "RfPulseState", + "RfReasonCode", + "RfSourceDescriptorExtensions", + "RfSourceDriver", + "RfSourceSnapshot", + "RfSourceTopology", + "RfSweepProfile", + "RfSweepState", + "rf_source_canonical_json", + "rf_source_digest", + "rf_source_snapshot_document", + "rf_source_snapshot_operation_artifact", + "rf_source_to_data", +] diff --git a/src/wavebench/plugins/api.py b/src/wavebench/plugins/api.py index b6d8d29..fb537b6 100644 --- a/src/wavebench/plugins/api.py +++ b/src/wavebench/plugins/api.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Literal -PluginKind = Literal["scope", "source", "power", "dmm", "sweep_analyzer"] +PluginKind = Literal["scope", "source", "rf_source", "power", "dmm", "sweep_analyzer"] PluginOrigin = Literal["builtin", "entry_point", "local"] DiagnosticSeverity = Literal["ok", "warning", "error"] @@ -11,6 +11,7 @@ VALID_PLUGIN_KINDS: tuple[str, ...] = ( "scope", "source", + "rf_source", "power", "dmm", "sweep_analyzer", diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py new file mode 100644 index 0000000..e228be7 --- /dev/null +++ b/tests/test_rf_source_extensions.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.rf_source_capabilities import ( + RF_SOURCE_CAPABILITY_METHODS, + validate_rf_source_descriptor, +) +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + RF_SOURCE_SNAPSHOT_SCHEMA, + RfAvailability, + RfCwProfile, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfOutputProfile, + RfPortSnapshot, + RfProtectionConditionPolicy, + RfProtectionStatus, + RfPulseState, + RfReasonCode, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_source_snapshot_document, + rf_source_snapshot_operation_artifact, +) + + +class RfDriver: + def close(self) -> None: + pass + + def idn(self) -> str: + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return snapshot() + + +def topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfCwProfile(frequency_readable=True, power_readable=True), + ), + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", blocks_output_enable=True), + ), + ) + + +def descriptor(**changes: object) -> InstrumentDescriptor: + value = InstrumentDescriptor( + driver_id="example.rf1", + kind="rf_source", + display_name="Example RF Source", + manufacturer="Example", + models=("RF1",), + aliases=(), + capabilities=("rf_source.idn", "rf_source.snapshot"), + idn_patterns=("EXAMPLE,RF1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: RfDriver(), + wavebench_min_version="0.8.24", + wavebench_max_version="0.9.0", + rf_source_extensions=extensions(), + ) + return replace(value, **changes) + + +def snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def test_rf_source_topology_and_features_are_strict() -> None: + with pytest.raises(ValueError, match="finite"): + RfOutputPortProfile("rf_out", float("nan"), 1.0, -1.0, 1.0, 50.0) + with pytest.raises(ValueError, match="must be positive"): + RfOutputPortProfile("rf_out", 1.0, 2.0, -1.0, 1.0, 0.0) + with pytest.raises(ValueError, match="sorted and unique"): + RfSourceTopology( + ( + RfOutputPortProfile("z", 1.0, 2.0, -1.0, 1.0, 50.0), + RfOutputPortProfile("a", 1.0, 2.0, -1.0, 1.0, 50.0), + ) + ) + with pytest.raises(ValueError, match="does not match feature"): + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ) + with pytest.raises(ValueError, match="unknown port"): + replace( + extensions(), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.READ,), + port_ids=("other",), + profile=RfCwProfile(frequency_readable=True, power_readable=True), + ), + ), + ) + + +def test_rf_observation_and_snapshot_reject_unsafe_values() -> None: + with pytest.raises(ValueError, match="must carry a value"): + RfObserved(RfAvailability.VALUE) + with pytest.raises(ValueError, match="require a registered reason_code"): + RfObserved(RfAvailability.UNKNOWN) + with pytest.raises(ValueError, match="cannot contain non-finite"): + RfObserved.value_of(float("inf")) + with pytest.raises(ValueError, match="invalid VALUE type"): + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1.0), + power_dbm=RfObserved.value_of(0.0), + output_enabled=RfObserved.value_of("ON"), + modulation=RfObserved.missing(RfAvailability.UNSUPPORTED, RfReasonCode.DESCRIPTOR_UNSUPPORTED), + pulse=RfObserved.missing(RfAvailability.UNSUPPORTED, RfReasonCode.DESCRIPTOR_UNSUPPORTED), + sweep=RfObserved.missing(RfAvailability.UNSUPPORTED, RfReasonCode.DESCRIPTOR_UNSUPPORTED), + ) + + +def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None: + value = snapshot() + document = rf_source_snapshot_document(value) + artifact = rf_source_snapshot_operation_artifact(value) + + assert document["schema"] == RF_SOURCE_SNAPSHOT_SCHEMA + assert document["ports"][0]["frequency_hz"]["value"] == 1_000_000.0 + assert artifact == { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.snapshot", + "snapshot": document, + } + + +def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: + value = descriptor() + + assert dict(RF_SOURCE_CAPABILITY_METHODS) == { + "rf_source.idn": ("idn",), + "rf_source.snapshot": ("get_rf_snapshot",), + } + assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( + RF_SOURCE_CAPABILITY_METHODS + ) + validate_rf_source_descriptor(value) + validate_declared_capabilities(value, RfDriver()) + + with pytest.raises(TypeError, match="get_rf_snapshot"): + validate_declared_capabilities( + value, + type("IdentityOnly", (), {"close": lambda self: None, "idn": lambda self: "idn"})(), + ) + with pytest.raises(ConfigError, match="unknown capabilities"): + validate_rf_source_descriptor(replace(value, capabilities=("rf_source.idn", "rf_source.future"))) + + +def test_rf_source_kind_requires_extensions_and_uses_append_only_field() -> None: + with pytest.raises(ValueError, match="require rf_source_extensions"): + InstrumentDescriptor( + driver_id="example.missing", + kind="rf_source", + display_name="Missing", + manufacturer="Example", + models=("RF1",), + aliases=(), + capabilities=("rf_source.idn",), + idn_patterns=(), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: RfDriver(), + ) + with pytest.raises(ConfigError, match="require the rf_source.idn"): + validate_rf_source_descriptor(replace(descriptor(), capabilities=("rf_source.snapshot",))) diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index dcf69dc..be45cd7 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -496,11 +496,12 @@ def test_source_descriptor_append_only_and_replace_compatible() -> None: descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) names = [item.name for item in fields(InstrumentDescriptor)] - assert names[-4:] == [ + assert names[-5:] == [ "config_fields", "resource_schemes", "scope_extensions", "source_extensions", + "rf_source_extensions", ] assert replace(descriptor, summary="changed").source_extensions is descriptor.source_extensions From 6fa9c489424234d288c29e73039591eec5639901 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:21:43 +0800 Subject: [PATCH 02/63] rf-source: add M0 readonly service --- src/wavebench/config.py | 146 +++++++++++++++ src/wavebench/services/operation_specs.py | 20 ++- src/wavebench/services/rf_source_service.py | 117 ++++++++++++ tests/test_operation_specs.py | 17 ++ tests/test_rf_source_config.py | 190 ++++++++++++++++++++ tests/test_rf_source_service.py | 170 ++++++++++++++++++ wavebench.example.toml | 19 ++ 7 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 src/wavebench/services/rf_source_service.py create mode 100644 tests/test_rf_source_config.py create mode 100644 tests/test_rf_source_service.py diff --git a/src/wavebench/config.py b/src/wavebench/config.py index 172818c..9dae508 100644 --- a/src/wavebench/config.py +++ b/src/wavebench/config.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from math import isfinite from pathlib import Path +import re import tomllib from .errors import ConfigError @@ -100,6 +101,18 @@ class SourceConfig: access: AccessMode = "read_write" terminations: tuple["SourceTerminationConfig", ...] = () + +@dataclass(frozen=True) +class RfSourceConfig: + """Configuration isolated from the Vpp/channel-oriented source domain.""" + + driver: str + resource: str | None + options: dict[str, object] = field(default_factory=dict) + access: AccessMode = "read_write" + safety_ports: tuple["RfPortSafetyConfig", ...] = () + + @dataclass(frozen=True) class PowerConfig: driver: str @@ -168,6 +181,17 @@ class SourceTerminationConfig: minimum_ohm: float | None = None maximum_ohm: float | None = None + +@dataclass(frozen=True) +class RfPortSafetyConfig: + """Static local safety declaration for one descriptor-defined RF port.""" + + port_id: str + minimum_frequency_hz: float + maximum_frequency_hz: float + maximum_power_dbm: float + actual_termination_ohm: float + @dataclass(frozen=True) class TuiConfig: log_max_lines: int = 10_000 @@ -266,6 +290,74 @@ def _source_terminations(raw: dict[str, object]) -> tuple[SourceTerminationConfi return tuple(parsed) +_RF_PORT_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") + + +def _rf_source_safety_ports(raw: dict[str, object]) -> tuple[RfPortSafetyConfig, ...]: + safety = raw.get("safety", {}) + if not isinstance(safety, dict): + raise ConfigError("rf_source.safety must be a TOML table") + values = safety.get("ports", []) + if not isinstance(values, list): + raise ConfigError("rf_source.safety.ports must be an array of TOML tables") + + parsed: list[RfPortSafetyConfig] = [] + for index, value in enumerate(values): + path = f"rf_source.safety.ports[{index}]" + if not isinstance(value, dict): + raise ConfigError(f"{path} must be a TOML table") + port_id = value.get("port_id") + if not isinstance(port_id, str) or _RF_PORT_ID.fullmatch(port_id) is None: + raise ConfigError(f"{path}.port_id must be a short safe port ID") + fields = ( + "minimum_frequency_hz", + "maximum_frequency_hz", + "maximum_power_dbm", + "actual_termination_ohm", + ) + missing = [name for name in fields if name not in value] + if missing: + raise ConfigError(f"{path} is missing required field(s): {', '.join(missing)}") + minimum_frequency_hz = _finite_number( + value["minimum_frequency_hz"], + path=f"{path}.minimum_frequency_hz", + ) + maximum_frequency_hz = _finite_number( + value["maximum_frequency_hz"], + path=f"{path}.maximum_frequency_hz", + ) + maximum_power_dbm = _finite_number( + value["maximum_power_dbm"], + path=f"{path}.maximum_power_dbm", + ) + actual_termination_ohm = _finite_number( + value["actual_termination_ohm"], + path=f"{path}.actual_termination_ohm", + ) + if minimum_frequency_hz <= 0: + raise ConfigError(f"{path}.minimum_frequency_hz must be > 0") + if maximum_frequency_hz < minimum_frequency_hz: + raise ConfigError( + f"{path}.maximum_frequency_hz must be >= {path}.minimum_frequency_hz" + ) + if actual_termination_ohm <= 0: + raise ConfigError(f"{path}.actual_termination_ohm must be > 0") + parsed.append( + RfPortSafetyConfig( + port_id=port_id, + minimum_frequency_hz=minimum_frequency_hz, + maximum_frequency_hz=maximum_frequency_hz, + maximum_power_dbm=maximum_power_dbm, + actual_termination_ohm=actual_termination_ohm, + ) + ) + parsed.sort(key=lambda item: item.port_id) + port_ids = tuple(item.port_id for item in parsed) + if len(set(port_ids)) != len(port_ids): + raise ConfigError("rf_source.safety.ports port_id values must be unique") + return tuple(parsed) + + def _instrument_options(raw: dict, section: str) -> dict[str, object]: options = raw.get("options", {}) if not isinstance(options, dict): @@ -286,6 +378,8 @@ class WaveBenchConfig: quality: QualityConfig = QualityConfig() safety_limits: SafetyLimitsConfig = SafetyLimitsConfig() tui: TuiConfig = TuiConfig() + # Append-only: preserve the public positional layout of existing config fields. + rf_source: RfSourceConfig | None = None def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig": if timeout_ms <= 0: @@ -310,6 +404,7 @@ def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_resource(self, resource: str) -> "WaveBenchConfig": @@ -333,6 +428,7 @@ def with_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_output_overrides( @@ -364,6 +460,7 @@ def with_output_overrides( quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_waveform_overrides( @@ -414,6 +511,7 @@ def with_waveform_overrides( quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_source_resource(self, resource: str) -> "WaveBenchConfig": @@ -448,6 +546,7 @@ def with_source_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_power_resource(self, resource: str) -> "WaveBenchConfig": @@ -481,6 +580,7 @@ def with_power_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_dmm_resource(self, resource: str) -> "WaveBenchConfig": @@ -528,6 +628,34 @@ def with_dmm_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, + ) + + def with_rf_source_resource(self, resource: str) -> "WaveBenchConfig": + rf_source = self.rf_source or RfSourceConfig( + driver="rigol.dsg830", + resource=None, + ) + return WaveBenchConfig( + connection=self.connection, + scope=self.scope, + autoscale=self.autoscale, + waveform=self.waveform, + output=self.output, + source_path=self.source_path, + source=self.source, + power=self.power, + dmm=self.dmm, + quality=self.quality, + safety_limits=self.safety_limits, + tui=self.tui, + rf_source=RfSourceConfig( + driver=rf_source.driver, + resource=resource, + options=rf_source.options, + access=rf_source.access, + safety_ports=rf_source.safety_ports, + ), ) def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: @@ -569,6 +697,21 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: access=normalize_access_mode(src.get("access", "read_write"), "source.access"), terminations=_source_terminations(src), ) + rf_raw = raw.get("rf_source") + rf_source = None + if rf_raw is not None: + if not isinstance(rf_raw, dict): + raise ConfigError("rf_source must be a TOML table") + rf_source = RfSourceConfig( + driver=str(rf_raw.get("driver", "rigol.dsg830")), + resource=str(rf_raw["resource"]) if "resource" in rf_raw else None, + options=_instrument_options(rf_raw, "rf_source"), + access=normalize_access_mode( + rf_raw.get("access", "read_write"), + "rf_source.access", + ), + safety_ports=_rf_source_safety_ports(rf_raw), + ) pwr = raw.get("power") power = None if pwr is not None: @@ -677,6 +820,7 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: log_max_lines=int(tui_raw.get("log_max_lines", 10_000)), log_keep_lines_after_trim=int(tui_raw.get("log_keep_lines_after_trim", 1_000)), ), + rf_source=rf_source, ) except KeyError as exc: raise ConfigError(f"missing required config key: {exc}") from exc @@ -735,6 +879,8 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: raise ConfigError("source.default_channel must be >= 1") if config.source.settle_ms_after_set_frequency < 0: raise ConfigError("source.settle_ms_after_set_frequency must be >= 0") + if config.rf_source is not None: + validate_instrument_reference(config.rf_source.driver, expected_kind="rf_source") if config.dmm is not None: validate_instrument_reference(config.dmm.driver, expected_kind="dmm") if config.dmm.backend.lower() not in {"serial", "lan", "visa", "pyvisa"}: diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 5cacabf..7ea2aaa 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -55,7 +55,9 @@ _EFFECTS = frozenset({"offline", "observe", "stateful_read", "write", "acquire"}) _LEASE_MODES = frozenset({"none", "shared", "exclusive"}) -_INSTRUMENT_KINDS = frozenset({"scope", "source", "power", "dmm", "sweep_analyzer"}) +_INSTRUMENT_KINDS = frozenset( + {"scope", "source", "rf_source", "power", "dmm", "sweep_analyzer"} +) _SESSION_PURPOSES = frozenset({"normal", "recovery", "verification", "lifecycle"}) _ERROR_CHECK_MINIMUMS = frozenset({"required", "if_supported", "disabled"}) @@ -1019,6 +1021,22 @@ def _spec( _spec("source.set_square_duty_cycle", "source", required_capabilities=("source.set_square_duty_cycle",), effect="write", changed_fields=("square_duty_cycle",), restore_coverage="basic", risk_flags=("signal_output", "state_drift")), _spec("source.arbitrary_probe", "source", required_capabilities=("source.arbitrary_probe",), effect="stateful_read"), _spec("source.arbitrary_upload", "source", required_capabilities=("source.arbitrary_upload",), effect="write", changed_fields=("arbitrary_payload",), risk_flags=("signal_output", "volatile_payload")), + _spec( + "rf_source.idn", + "rf_source", + required_capabilities=("rf_source.idn",), + effect="observe", + ), + _spec( + "rf_source.snapshot", + "rf_source", + required_capabilities=("rf_source.snapshot",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + error_check_minimum="disabled", + risk_flags=("state_dependent_query",), + ), _spec("power.idn", "power", required_capabilities=("power.idn",), effect="observe"), _spec("power.status", "power", required_capabilities=("power.status",), effect="stateful_read"), _spec("power.measurement", "power", required_capabilities=("power.measurement",), effect="stateful_read"), diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py new file mode 100644 index 0000000..2292e02 --- /dev/null +++ b/src/wavebench/services/rf_source_service.py @@ -0,0 +1,117 @@ +"""Read-only M0 service for RF signal sources.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, cast + +from wavebench.config import RfSourceConfig, WaveBenchConfig +from wavebench.errors import ConfigError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.capabilities import require_capabilities +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.rf_source_extensions import RfSourceDriver, RfSourceSnapshot +from wavebench.logging import CommandLogger +from wavebench.services.access_policy import access_policy +from wavebench.services.operation_specs import require_operation_spec +from wavebench.services.resource_lease import ResourceLease +from wavebench.services.session_alias import SessionStateAliasMixin +from wavebench.transport.base import InstrumentTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +@dataclass +class RfSourceService(SessionStateAliasMixin): + """Open one configured RF source session for an explicitly read-only operation.""" + + config: WaveBenchConfig + logger: CommandLogger + session: RfSourceDriver | None = None + descriptor: InstrumentDescriptor | None = None + transport: InstrumentTransport | None = None + session_state: InstrumentSessionState | None = None + lease: ResourceLease | None = None + + def _require(self, operation: str, *capabilities: str) -> None: + rf_source = self._rf_source_config() + access_policy(getattr(rf_source, "access", "read_write"), "rf_source.access").require( + require_operation_spec(operation), + operation=operation, + ) + descriptor = self.descriptor or resolve_instrument_descriptor( + rf_source.driver, + expected_kind="rf_source", + ) + self.descriptor = descriptor + require_capabilities(descriptor, capabilities, operation=operation) + + def _rf_source_config(self) -> RfSourceConfig: + if self.config.rf_source is None or not self.config.rf_source.resource: + raise ConfigError( + "rf_source resource is not configured. Set [rf_source].resource or pass --resource." + ) + return self.config.rf_source + + def _open_rf_source(self) -> RfSourceDriver: + rf_source = self._rf_source_config() + self._prepare_session_open("rf_source") + if self.lease is None: + self.lease = ResourceLease( + resource=rf_source.resource or "", + operation="rf_source.session", + ) + opened = open_instrument_driver( + driver_reference=rf_source.driver, + expected_kind="rf_source", + resource=rf_source.resource or "", + configured_backend=self.config.connection.backend, + timeout_ms=self.config.connection.timeout_ms, + opc_timeout_ms=self.config.connection.opc_timeout_ms, + read_retry_attempts=self.config.connection.read_retry_attempts, + read_retry_delay_ms=self.config.connection.read_retry_delay_ms, + logger=self.logger, + options=getattr(rf_source, "options", {}), + access=getattr(rf_source, "access", "read_write"), + lease=self.lease, + ) + self.descriptor = opened.descriptor + self.transport = opened.transport + self.session_state = getattr(opened, "session_state", None) + return cast(RfSourceDriver, opened.driver) + + def audit_snapshot(self) -> dict[str, Any] | None: + snapshot = getattr(self.transport, "audit_snapshot", None) + return snapshot() if callable(snapshot) else None + + def open_session(self) -> RfSourceDriver: + return self._open_rf_source() + + @contextmanager + def _rf_source_session(self) -> Iterator[RfSourceDriver]: + if self.session is not None: + yield self.session + return + rf_source = self._open_rf_source() + try: + yield rf_source + finally: + rf_source.close() + + def idn(self) -> str: + self._require("rf_source.idn", "rf_source.idn") + with self._rf_source_session() as rf_source: + return rf_source.idn() + + def snapshot(self) -> RfSourceSnapshot: + self._require("rf_source.snapshot", "rf_source.snapshot") + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + return rf_source.get_rf_snapshot() + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError("rf_source.snapshot requires a healthy session") + return rf_source.get_rf_snapshot() diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index 07a0d10..5a99019 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -43,6 +43,23 @@ def test_source_output_spec_describes_mutation_and_restore_boundary() -> None: assert spec.as_dict()["required_capabilities"] == ["source.output"] +def test_rf_source_m0_specs_are_read_only_and_exclusive() -> None: + identity = require_operation_spec("rf_source.idn") + snapshot = require_operation_spec("rf_source.snapshot") + + assert identity.instrument_kind == "rf_source" + assert identity.required_capabilities == ("rf_source.idn",) + assert identity.effect == "observe" + assert identity.mutates is False + assert snapshot.instrument_kind == "rf_source" + assert snapshot.required_capabilities == ("rf_source.snapshot",) + assert snapshot.effect == "stateful_read" + assert snapshot.mutates is False + assert snapshot.lease_mode == "exclusive" + assert snapshot.restore_coverage == "none-read-only" + assert snapshot.error_check_minimum == "disabled" + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_config.py b/tests/test_rf_source_config.py new file mode 100644 index 0000000..866a9c9 --- /dev/null +++ b/tests/test_rf_source_config.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfPortSafetyConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, + load_config, +) +from wavebench.errors import ConfigError + + +def _config_text(port_block: str) -> str: + return f'''\ +[connection] +resource = "TCPIP::scope::INSTR" + +[scope] + +[rf_source] +driver = "example.rf1" +resource = "TCPIP::rf::INSTR" +access = "read_only" + +[[rf_source.safety.ports]] +{port_block} +''' + + +def _accept_references(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str]]: + calls: list[tuple[str, str]] = [] + + def accept(driver: str, *, expected_kind: str) -> None: + calls.append((driver, expected_kind)) + + monkeypatch.setattr( + "wavebench.instruments.registry.validate_instrument_reference", + accept, + ) + return calls + + +def test_loads_isolated_rf_source_config_and_safety_ports( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text( + _config_text( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''' + ), + encoding="utf-8", + ) + calls = _accept_references(monkeypatch) + + config = load_config(path) + + assert config.rf_source == RfSourceConfig( + driver="example.rf1", + resource="TCPIP::rf::INSTR", + access="read_only", + safety_ports=( + RfPortSafetyConfig( + port_id="rf_out", + minimum_frequency_hz=9_000.0, + maximum_frequency_hz=3_000_000_000.0, + maximum_power_dbm=-20.0, + actual_termination_ohm=50.0, + ), + ), + ) + assert ("example.rf1", "rf_source") in calls + + +@pytest.mark.parametrize( + ("port_block", "message"), + ( + ( + '''\ +port_id = "rf out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''', + "port_id", + ), + ( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 3000000000 +maximum_frequency_hz = 9000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''', + "maximum_frequency_hz", + ), + ( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = nan +''', + "actual_termination_ohm", + ), + ), +) +def test_rejects_invalid_rf_port_safety_declarations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + port_block: str, + message: str, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text(_config_text(port_block), encoding="utf-8") + _accept_references(monkeypatch) + + with pytest.raises(ConfigError, match=message): + load_config(path) + + +def test_rejects_duplicate_rf_port_safety_declarations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text( + _config_text( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''' + ), + encoding="utf-8", + ) + _accept_references(monkeypatch) + + with pytest.raises(ConfigError, match="must be unique"): + load_config(path) + + +def test_resource_override_preserves_rf_source_safety_declarations() -> None: + safety_port = RfPortSafetyConfig("rf_out", 9_000.0, 3_000_000_000.0, -20.0, 50.0) + config = WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf1", + resource="TCPIP::old-rf::INSTR", + access="read_only", + safety_ports=(safety_port,), + ), + ) + + updated = config.with_rf_source_resource("TCPIP::new-rf::INSTR") + + assert updated.rf_source is not None + assert updated.rf_source.resource == "TCPIP::new-rf::INSTR" + assert updated.rf_source.access == "read_only" + assert updated.rf_source.safety_ports == (safety_port,) diff --git a/tests/test_rf_source_service.py b/tests/test_rf_source_service.py new file mode 100644 index 0000000..4511e97 --- /dev/null +++ b/tests/test_rf_source_service.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfModulationState, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceSnapshot, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +class FakeRfDriver: + def __init__(self) -> None: + self.calls: list[str] = [] + + def close(self) -> None: + self.calls.append("close") + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + return _snapshot() + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _config(*, access: str = "read_only") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf1", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace(driver_id="example.rf1", capabilities=capabilities) + + +def test_idn_and_snapshot_are_one_shot_read_only_operations(monkeypatch: pytest.MonkeyPatch) -> None: + driver = FakeRfDriver() + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + monkeypatch.setattr(service, "_open_rf_source", lambda: driver) + + assert service.idn() == "EXAMPLE,RF1,0,1" + assert service.snapshot().ports[0].port_id == "rf_out" + assert driver.calls == ["idn", "close", "snapshot", "close"] + + +def test_snapshot_capability_and_access_are_checked_before_opening_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + descriptor=_descriptor("rf_source.idn"), + ) + opened = False + + def fail_open() -> FakeRfDriver: + nonlocal opened + opened = True + return FakeRfDriver() + + monkeypatch.setattr(service, "_open_rf_source", fail_open) + with pytest.raises(ConfigError, match="rf_source.snapshot"): + service.snapshot() + assert opened is False + + disabled = RfSourceService( + config=_config(access="disabled"), + logger=CommandLogger(), + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with patch.object(disabled, "_open_rf_source") as open_disabled: + with pytest.raises(AccessDeniedError, match="rf_source.idn"): + disabled.idn() + open_disabled.assert_not_called() + + +def test_snapshot_rejects_nonhealthy_bound_session_before_driver_call() -> None: + driver = FakeRfDriver() + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + session=driver, + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + session_state=InstrumentSessionState(health=SessionHealth.UNCERTAIN), + ) + + with pytest.raises(ConfigError, match="healthy session"): + service.snapshot() + + assert driver.calls == [] + + +def test_one_shot_service_passes_owned_lease_to_factory() -> None: + driver = FakeRfDriver() + descriptor = _descriptor("rf_source.idn", "rf_source.snapshot") + opened = SimpleNamespace( + descriptor=descriptor, + transport=None, + session_state=None, + driver=driver, + ) + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + descriptor=descriptor, + ) + + with patch( + "wavebench.services.rf_source_service.open_instrument_driver", + return_value=opened, + ) as factory: + assert service.idn() == "EXAMPLE,RF1,0,1" + + lease = factory.call_args.kwargs["lease"] + assert lease.resource == "tcpip::rf::instr" + assert driver.calls == ["idn", "close"] diff --git a/wavebench.example.toml b/wavebench.example.toml index 9c4803e..f78e46c 100644 --- a/wavebench.example.toml +++ b/wavebench.example.toml @@ -176,6 +176,25 @@ settle_ms_after_set_frequency = 500 # maximum_ohm = 50.5 +# RF signal sources use an independent domain: Hz and dBm on stable port IDs, +# not the Vpp/channel model above. The M0 commands are read-only only. +# Install the matching RF plugin before enabling this section. +# [rf_source] +# driver = "rigol.dsg830" +# resource = "TCPIP::192.0.2.13::INSTR" +# access = "read_only" + +# Future RF energy operations require one complete local declaration per port. +# `actual_termination_ohm` is the physical termination evidence; it is not a +# display-load setting and WaveBench never converts dBm to Vpp. +# [[rf_source.safety.ports]] +# port_id = "rf_out" +# minimum_frequency_hz = 9000 +# maximum_frequency_hz = 3000000000 +# maximum_power_dbm = -20 +# actual_termination_ohm = 50 + + [power] # Power supply driver. Current read-only power support targets Rigol DP800 series. driver = "dp800" From f3ae6d72b6ae13de5899e3586f3489d158cafd5c Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:28:25 +0800 Subject: [PATCH 03/63] rf-source: expose M0 readonly CLI and doctor --- src/wavebench/cli.py | 20 ++++++++++ src/wavebench/cli_parser.py | 14 ++++++- src/wavebench/doctor.py | 9 +++++ tests/test_rf_source_cli.py | 67 ++++++++++++++++++++++++++++++++++ tests/test_rf_source_doctor.py | 54 +++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/test_rf_source_cli.py create mode 100644 tests/test_rf_source_doctor.py diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 5940d98..19bfbee 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -99,6 +99,7 @@ from .plugins.scpi import has_scpi_doctor_errors, load_scpi_plugin, probe_scpi_plugin, scpi_plugin_doctor_records from .services.scope_service import ScopeService from .services.source_service import SourceService +from .services.rf_source_service import RfSourceService from .services.power_service import PowerService from .services.dmm_service import DmmService from .services.run_plan import format_run_plan_schema, load_run_plan @@ -197,6 +198,13 @@ def _load_source_service(args: argparse.Namespace) -> SourceService: return SourceService(config=config, logger=CommandLogger()) +def _load_rf_source_service(args: argparse.Namespace) -> RfSourceService: + config = load_config(args.config) + if args.resource: + config = config.with_rf_source_resource(args.resource) + return RfSourceService(config=config, logger=CommandLogger()) + + def _load_power_service(args: argparse.Namespace) -> PowerService: config = load_config(args.config) if args.resource: @@ -1520,6 +1528,18 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "set-duty": _print_source_status(service.set_square_duty_cycle(channel=args.channel, duty_percent=args.duty_percent)) return 0 + if args.domain == "rf-source": + service = _load_rf_source_service(args) + if args.command == "idn": + print(service.idn()) + return 0 + if args.command == "status": + result = service.snapshot() + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.domain == "sweep": service = _load_sweep_service(args) if args.command == "discrete": diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 6e8fbca..f293631 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -37,6 +37,7 @@ def build_parser() -> argparse.ArgumentParser: scope_parser = subparsers.add_parser("scope", help="Oscilloscope commands") source_parser = subparsers.add_parser("source", help="Signal generator commands") + rf_source_parser = subparsers.add_parser("rf-source", help="RF signal source commands") power_parser = subparsers.add_parser("power", help="Power supply commands") dmm_parser = subparsers.add_parser("dmm", help="Digital multimeter commands") sweep_parser = subparsers.add_parser("sweep", help="Source/scope sweep commands") @@ -96,7 +97,7 @@ def build_parser() -> argparse.ArgumentParser: ) capability_explain.add_argument( "--kind", - choices=("scope", "source", "power", "dmm"), + choices=("scope", "source", "rf_source", "power", "dmm"), default=None, help="Instrument kind when selecting a configured driver / 仪器类型", ) @@ -153,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser: ) plugin_list.add_argument( "--kind", - choices=("scope", "source", "power", "dmm"), + choices=("scope", "source", "rf_source", "power", "dmm"), default=None, help="Filter plugins by instrument kind / 按仪器类型过滤", ) @@ -644,6 +645,15 @@ def build_parser() -> argparse.ArgumentParser: protection_set.add_argument("--ocp", choices=["on", "off"], default=None) add_runtime_options(protection_set) + rf_source_sub = rf_source_parser.add_subparsers(dest="command", required=True) + rf_source_idn = rf_source_sub.add_parser("idn", help="Query RF source *IDN?") + add_runtime_options(rf_source_idn) + rf_source_status = rf_source_sub.add_parser( + "status", + help="Query a typed, read-only RF source snapshot", + ) + add_runtime_options(rf_source_status) + source_sub = source_parser.add_subparsers(dest="command", required=True) source_idn = source_sub.add_parser("idn", help="Query source *IDN?") diff --git a/src/wavebench/doctor.py b/src/wavebench/doctor.py index 69db913..a1ad5a0 100644 --- a/src/wavebench/doctor.py +++ b/src/wavebench/doctor.py @@ -195,6 +195,15 @@ def _doctor_targets(config: WaveBenchConfig) -> list[DoctorTarget]: expected_idn_tokens=_driver_expected_tokens(config.source.driver), ) ) + if config.rf_source is not None: + targets.append( + DoctorTarget( + name="rf_source", + driver=config.rf_source.driver, + resource=config.rf_source.resource, + expected_idn_tokens=_driver_expected_tokens(config.rf_source.driver), + ) + ) if config.power is not None: targets.append( DoctorTarget( diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py new file mode 100644 index 0000000..b121c2e --- /dev/null +++ b/tests/test_rf_source_cli.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import io +import json +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from wavebench.cli import _load_rf_source_service, build_parser, main + + +def test_rf_source_parser_accepts_read_only_commands_and_runtime_options() -> None: + identity = build_parser().parse_args( + ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] + ) + status = build_parser().parse_args(["rf-source", "status"]) + + assert (identity.domain, identity.command) == ("rf-source", "idn") + assert identity.config == "rf.toml" + assert identity.resource == "TCPIP::rf::INSTR" + assert (status.domain, status.command) == ("rf-source", "status") + + +def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: + service = Mock() + service.idn.return_value = "EXAMPLE,RF1,0,1" + service.snapshot.return_value = SimpleNamespace( + as_dict=lambda: { + "schema": "wavebench.rf_source.snapshot.v1", + "ports": [], + "protection": {"availability": "unknown"}, + } + ) + + identity_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + identity_stdout + ): + assert main(["rf-source", "idn"]) == 0 + assert identity_stdout.getvalue().strip() == "EXAMPLE,RF1,0,1" + service.idn.assert_called_once_with() + + status_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + status_stdout + ): + assert main(["--json", "rf-source", "status"]) == 0 + payload = json.loads(status_stdout.getvalue()) + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"]["schema"] == "wavebench.rf_source.snapshot.v1" + service.snapshot.assert_called_once_with() + + +def test_rf_source_resource_override_does_not_touch_source_config() -> None: + updated = object() + config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) + args = SimpleNamespace(config="rf.toml", resource="TCPIP::rf::INSTR") + + with patch("wavebench.cli.load_config", return_value=config), patch( + "wavebench.cli.RfSourceService" + ) as service_type: + result = _load_rf_source_service(args) + + config.with_rf_source_resource.assert_called_once_with("TCPIP::rf::INSTR") + service_type.assert_called_once() + assert service_type.call_args.kwargs["config"] is updated + assert result is service_type.return_value diff --git a/tests/test_rf_source_doctor.py b/tests/test_rf_source_doctor.py new file mode 100644 index 0000000..731c4d5 --- /dev/null +++ b/tests/test_rf_source_doctor.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.doctor import doctor_records + + +def _config(*, rf_resource: str | None) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", "RTM2032", 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig(driver="example.rf1", resource=rf_resource), + ) + + +def test_doctor_adds_rf_source_as_identity_only_target() -> None: + records = doctor_records( + _config(rf_resource="TCPIP::rf::INSTR"), + idn_probe=lambda resource, timeout_ms: { + "TCPIP::scope::INSTR": "Rohde&Schwarz,RTM2032,0,0", + "TCPIP::rf::INSTR": "Example,RF1,0,0", + }.get(resource), + ) + + assert [(record.target, record.severity) for record in records] == [ + ("scope", "ok"), + ("rf_source", "ok"), + ] + + +def test_doctor_reports_unconfigured_rf_source_resource_without_querying() -> None: + queried: list[str] = [] + records = doctor_records( + _config(rf_resource=None), + idn_probe=lambda resource, timeout_ms: queried.append(resource) or "unused", + ) + + rf_source = next(record for record in records if record.target == "rf_source") + assert rf_source.severity == "warning" + assert rf_source.idn is None + assert "" not in queried From 55474beff69e0e8220a8407ef07fc6cacac35e35 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:32:03 +0800 Subject: [PATCH 04/63] rf-source: add M0 run status --- src/wavebench/services/execution_intent.py | 1 + src/wavebench/services/run_artifacts.py | 31 +++ src/wavebench/services/run_plan.py | 3 + src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 75 ++++++- tests/test_rf_source_run.py | 215 +++++++++++++++++++++ 6 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 tests/test_rf_source_run.py diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 8fc8e98..e4d07cc 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -23,6 +23,7 @@ "scope.capture": "scope.capture", "sweep.frequency_response": "scope.capture_waveforms", "source.status": "source.status", + "rf_source.status": "rf_source.snapshot", "source.arb_load": "source.arbitrary_upload", "source.set_freq": "source.set_frequency", "source.set_func": "source.set_function", diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py index b6f053b..e60696d 100644 --- a/src/wavebench/services/run_artifacts.py +++ b/src/wavebench/services/run_artifacts.py @@ -7,6 +7,7 @@ from typing import Any from wavebench.errors import ensure_error_envelope +from wavebench.instruments.rf_source_extensions import RF_SOURCE_OPERATION_ARTIFACT_SCHEMA from wavebench.instruments.source_extensions import SOURCE_OPERATION_ARTIFACT_SCHEMA from wavebench.services.run_plan import RunPlan from wavebench.services.source_state import RestorableSourceState @@ -70,6 +71,32 @@ def _validated_source_operations( return source_operations +def _validated_rf_source_operations( + rf_source_operations: list[dict[str, Any]] | None, +) -> list[dict[str, Any]] | None: + """Accept only schema-labelled RF-source operation artifacts.""" + + if rf_source_operations is None: + return None + if not isinstance(rf_source_operations, list) or any( + not isinstance(item, dict) for item in rf_source_operations + ): + raise TypeError("rf_source_operations must be a list of operation artifact objects") + if not rf_source_operations: + return None + for artifact in rf_source_operations: + if artifact.get("schema") != RF_SOURCE_OPERATION_ARTIFACT_SCHEMA: + raise ValueError("RF source operation artifact has an unsupported schema") + operation = artifact.get("operation") + if ( + not isinstance(operation, str) + or not operation.startswith("rf_source.") + or operation.strip() != operation + ): + raise ValueError("RF source operation artifact must have a trimmed rf_source.* operation") + return rf_source_operations + + def write_run_files( *, plan: RunPlan, @@ -82,6 +109,7 @@ def write_run_files( restore_error: dict[str, Any] | None = None, provenance: dict[str, Any] | None = None, source_operations: list[dict[str, Any]] | None = None, + rf_source_operations: list[dict[str, Any]] | None = None, ) -> None: run_data: dict[str, Any] = { "status": status, @@ -127,6 +155,9 @@ def write_run_files( validated_source_operations = _validated_source_operations(source_operations) if validated_source_operations is not None: run_data["source_operations"] = validated_source_operations + validated_rf_source_operations = _validated_rf_source_operations(rf_source_operations) + if validated_rf_source_operations is not None: + run_data["rf_source_operations"] = validated_rf_source_operations run_json_path.write_text( json.dumps(run_data, indent=2, ensure_ascii=False), encoding="utf-8", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index b4b65ba..03a8e13 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -21,6 +21,7 @@ "scope.capture", "sweep.frequency_response", "source.status", + "rf_source.status", "source.set_freq", "source.arb_load", "source.set_func", @@ -170,6 +171,7 @@ "on_failure", }, "source.status": {"channel", "on_failure"}, + "rf_source.status": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, "source.arb_load": {"channel", "offset_v", "sample_rate_hz", "max_points", "byte_order", "output_on", "on_failure"}, "source.set_func": {"channel", "on_failure"}, @@ -228,6 +230,7 @@ "scope.capture": "Trigger one acquisition, write a capture package, and optionally evaluate quality/expect checks. Use target_vpp or vertical_scale_v_per_div to fit the waveform vertically before capture.", "sweep.frequency_response": "Sweep a source through discrete frequencies, capture reference and response channels in one acquisition per point, and write a Bode response CSV.", "source.status": "Read signal-generator channel state without changing output.", + "rf_source.status": "Read a typed RF-source snapshot without changing output.", "source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.", "source.set_freq": "Set fixed source frequency in Hz; config may force FIX mode first.", "source.set_func": "Set source waveform function, for example SIN or SQU.", diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index e0b8689..f3a81fa 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -21,6 +21,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "scope.capture", "sweep.frequency_response", "source.status", + "rf_source.status", "source.set_freq", "source.arb_load", "source.set_func", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index fd12d0f..d305f8e 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -22,6 +22,7 @@ ) from wavebench.instruments.capabilities import require_capabilities from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.rf_source_extensions import rf_source_snapshot_operation_artifact from wavebench.instruments.source_extensions import ( PatchAction, PatchValue, @@ -52,6 +53,7 @@ from wavebench.logging import CommandLogger from wavebench.services.power_service import PowerService from wavebench.services.dmm_service import DmmService +from wavebench.services.rf_source_service import RfSourceService from wavebench.services.frequency_response import ( analyze_frequency_response_point, build_fit_document, @@ -142,6 +144,8 @@ class RunInstrumentServices: compare=False, repr=False, ) + # Append-only: retain the positional layout of existing run service bundles. + rf_source: RfSourceService | None = None def audit_snapshot(self) -> dict[str, Any] | None: """Return transport-native counters without deriving them from logs.""" @@ -152,6 +156,7 @@ def audit_snapshot(self) -> dict[str, Any] | None: ("source", self.source), ("power", self.power), ("dmm", self.dmm), + ("rf_source", self.rf_source), ): if service is None: continue @@ -240,6 +245,17 @@ def verify(self, plan: RunPlan) -> list[RunPreflightRecord]: idn=self._source_service().idn(), ) ) + if "rf_source" in instruments: + rf_source = self.config.rf_source + if rf_source is None or not rf_source.resource: + raise ConfigError("rf_source resource is required by this run plan") + records.append( + RunPreflightRecord( + instrument="rf_source", + resource=rf_source.resource, + idn=self._rf_source_service().idn(), + ) + ) if "power" in instruments: power = self.config.power if power is None or not power.resource: @@ -401,6 +417,8 @@ def add_source_output_gate_capability() -> None: ensure_calibration_dependencies() elif step.kind == "source.status": add("source", "source.status") + elif step.kind == "rf_source.status": + add("rf_source", "rf_source.snapshot") elif step.kind == "source.set_freq": add("source", "source.set_frequency") source = self.config.source @@ -567,6 +585,7 @@ def run( records: list[RunStepRecord] = [] source_operations: list[dict[str, Any]] = [] + rf_source_operations: list[dict[str, Any]] = [] run_json_path = run_dir / "run.json" summary_csv_path = run_dir / "summary.csv" restore_state: list[RestorableSourceState] | None = None @@ -589,6 +608,10 @@ def append_source_operation_artifact(value: object) -> None: if isinstance(value, dict): source_operations.append(value) + def append_rf_source_operation_artifact(value: object) -> None: + if isinstance(value, dict): + rf_source_operations.append(value) + def refresh_provenance() -> None: instrument_io = services.audit_snapshot() if instrument_io is not None: @@ -627,6 +650,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) services.close_reporters.append(report_close_errors) @@ -674,10 +698,16 @@ def report_close_errors() -> None: append_source_operation_artifact( record.artifact.get("source_operation") ) + append_rf_source_operation_artifact( + record.artifact.get("rf_source_operation") + ) if step_failure is not None: append_source_operation_artifact( getattr(step_failure, "source_operation_artifact", None) ) + append_rf_source_operation_artifact( + getattr(step_failure, "rf_source_operation_artifact", None) + ) safety_gate = self._safety_gate_for_step(plan, step) gate_triggered = safety_gate["enabled"] and record.status in { "failed", @@ -776,6 +806,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) if restore_error is not None: raise ConfigError( @@ -793,6 +824,9 @@ def report_close_errors() -> None: append_source_operation_artifact( getattr(failure, "source_operation_artifact", None) ) + append_rf_source_operation_artifact( + getattr(failure, "rf_source_operation_artifact", None) + ) restore_error = restore_source_state( restore_state, source_service_factory=lambda: self._source_service(services=services), @@ -815,6 +849,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) if isinstance(exc, _FrequencyResponseExecutionError): raise failure from None @@ -869,6 +904,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) raise ConfigError("run plan source state restore failed: " + restore_error["message"]) @@ -886,6 +922,7 @@ def report_close_errors() -> None: restore_error=None, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) result = RunResult( run_dir=run_dir, @@ -1133,6 +1170,9 @@ def _run_step( elif step.kind == "source.status": status = self._source_service(services=services).status(channel=step.fields.get("channel")) artifact = {"source_status": _status_payload(status)} + elif step.kind == "rf_source.status": + snapshot = self._rf_source_service(services=services).snapshot() + artifact = {"rf_source_operation": rf_source_snapshot_operation_artifact(snapshot)} elif step.kind == "source.basic_configure_v2": fields = step.fields _, source_operation = self._source_service(services=services).configure_basic_v2( @@ -2475,6 +2515,7 @@ def lease_for(resource: str) -> ResourceLease: source: SourceService | None = None power: PowerService | None = None dmm: DmmService | None = None + rf_source: RfSourceService | None = None source_guard = SourceStateGuard() power_guard = PowerStateGuard() @@ -2567,6 +2608,28 @@ def lease_for(resource: str) -> ResourceLease: session_state=bootstrap.session_state, lease=dmm_lease, ) + if "rf_source" in instruments: + logger = CommandLogger() + rf_source_config = self.config.rf_source + if rf_source_config is None or not rf_source_config.resource: + raise ConfigError("rf_source resource is required by this run plan") + rf_source_lease = lease_for(rf_source_config.resource) + bootstrap = RfSourceService( + config=self.config, + logger=logger, + lease=rf_source_lease, + ) + session = bootstrap.open_session() + stack.callback(close_session, session, "rf_source", bootstrap.transport) + rf_source = RfSourceService( + config=self.config, + logger=logger, + session=session, + descriptor=bootstrap.descriptor, + transport=bootstrap.transport, + session_state=bootstrap.session_state, + lease=rf_source_lease, + ) yield RunInstrumentServices( scope=scope, @@ -2574,13 +2637,14 @@ def lease_for(resource: str) -> ResourceLease: power=power, dmm=dmm, close_errors=close_errors, + rf_source=rf_source, ) def _plan_resource_values(self, instruments: set[str]) -> list[str]: resources: list[str] = [] if "scope" in instruments: resources.append(self.config.connection.resource) - for kind in ("source", "power", "dmm"): + for kind in ("source", "rf_source", "power", "dmm"): if kind not in instruments: continue section = getattr(self.config, kind) @@ -2599,6 +2663,15 @@ def _source_service(self, *, services: RunInstrumentServices | None = None) -> S return services.source return SourceService(config=self.config, logger=CommandLogger()) + def _rf_source_service( + self, + *, + services: RunInstrumentServices | None = None, + ) -> RfSourceService: + if services is not None and services.rf_source is not None: + return services.rf_source + return RfSourceService(config=self.config, logger=CommandLogger()) + def _dmm_service(self, *, services: RunInstrumentServices | None = None) -> DmmService: if services is not None and services.dmm is not None: return services.dmm diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py new file mode 100644 index 0000000..ac209d4 --- /dev/null +++ b/tests/test_rf_source_run.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfModulationState, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceSnapshot, + RfSweepState, + rf_source_snapshot_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.execution_intent import build_execution_intent +from wavebench.services.run_artifacts import RunStepRecord, write_run_files +from wavebench.services.run_plan import STEP_SCHEMAS, load_run_plan +from wavebench.services.run_service import RunInstrumentServices, RunService + + +def _config(directory: str) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig( + Path(directory) / "data" / "raw", + "timestamp_label", + True, + True, + True, + True, + False, + ), + source_path=Path(directory) / "wavebench.toml", + rf_source=RfSourceConfig( + driver="example.rf1", + resource="TCPIP::rf::INSTR", + access="read_only", + ), + ) + + +def _plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text('[[steps]]\nkind = "rf_source.status"\n', encoding="utf-8") + return load_run_plan(path) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf1", + kind="rf_source", + capabilities=capabilities, + ) + + +def test_rf_source_status_schema_and_intent_are_read_only() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + intent = build_execution_intent(plan, _config(directory)) + + assert STEP_SCHEMAS["rf_source.status"].required == () + assert intent.operations == ( + { + "step_index": 0, + "step_kind": "rf_source.status", + "operation": "rf_source.snapshot", + "instrument_kind": "rf_source", + "effect": "stateful_read", + "lease_mode": "exclusive", + "changed_fields": [], + "restore_coverage": "none-read-only", + "session_purpose": "normal", + "required_verified_fields": [], + "verification_fields": [], + "timeout_source": "connection.timeout_ms", + "risk_flags": ["state_dependent_query"], + "parameters": {}, + "policy": {"on_failure": "stop", "safety_gate": {}}, + }, + ) + + +def test_rf_source_status_requires_snapshot_capability_before_session_opens() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.snapshot"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_status_verify_and_run_use_the_isolated_service_and_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + descriptor = _descriptor("rf_source.idn", "rf_source.snapshot") + snapshot = _snapshot() + service = RunService(config=_config(directory), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ), patch("wavebench.services.run_service.RfSourceService") as rf_service_type: + rf_service_type.return_value.idn.return_value = "EXAMPLE,RF1,0,1" + records = service.verify(plan) + + assert [(record.instrument, record.idn) for record in records] == [ + ("rf_source", "EXAMPLE,RF1,0,1"), + ] + + rf_service = SimpleNamespace(snapshot=Mock(return_value=snapshot), audit_snapshot=lambda: None) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + result = OfflineRfRunService(config=_config(directory), logger=CommandLogger()).run(plan) + + rf_service.snapshot.assert_called_once_with() + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + artifact = rf_source_snapshot_operation_artifact(snapshot) + assert result.steps[0].artifact == {"rf_source_operation": artifact} + assert run_data["rf_source_operations"] == [artifact] + assert "source_operations" not in run_data + + +def test_rf_source_operation_artifacts_are_validated_in_a_separate_root_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + artifact = rf_source_snapshot_operation_artifact(_snapshot()) + run_json_path = Path(directory) / "run.json" + write_run_files( + plan=plan, + run_json_path=run_json_path, + summary_csv_path=Path(directory) / "summary.csv", + status="ok", + records=[ + RunStepRecord( + index=0, + kind="rf_source.status", + status="ok", + fields={}, + artifact={"rf_source_operation": artifact}, + ) + ], + error=None, + rf_source_operations=[artifact], + ) + + data = json.loads(run_json_path.read_text(encoding="utf-8")) + assert data["rf_source_operations"] == [artifact] + assert "source_operations" not in data + + with pytest.raises(ValueError, match="rf_source"): + write_run_files( + plan=plan, + run_json_path=run_json_path, + summary_csv_path=Path(directory) / "summary.csv", + status="ok", + records=[], + error=None, + rf_source_operations=[ + {"schema": "wavebench.rf_source.operation.v1", "operation": "source.status"} + ], + ) From e8ff1be32ae3442db4498577d83e81b449ad25fd Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:36:33 +0800 Subject: [PATCH 05/63] release: reserve 0.8.25 for rf-source M0 --- pyproject.toml | 2 +- src/wavebench/instruments/rf_source_extensions.py | 2 +- tests/test_rf_source_extensions.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f25e4bb..1a746ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench" -version = "0.8.24" +version = "0.8.25" description = "Lightweight VISA/SCPI measurement bench for contest debugging" readme = "README.md" requires-python = ">=3.11" diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 2ca0da9..0ce272d 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -22,7 +22,7 @@ RF_SOURCE_CONTRACT_VERSION = "wavebench.rf_source.v1" RF_SOURCE_SNAPSHOT_SCHEMA = "wavebench.rf_source.snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" -RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.24" +RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" _SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index e228be7..4ff5fd5 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -102,7 +102,7 @@ def descriptor(**changes: object) -> InstrumentDescriptor: option_specs=(), permissions=("instrument.io",), factory=lambda context: RfDriver(), - wavebench_min_version="0.8.24", + wavebench_min_version="0.8.25", wavebench_max_version="0.9.0", rf_source_extensions=extensions(), ) From cf53e1436a4fcb847765aeccff759165237cb11e Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:42:10 +0800 Subject: [PATCH 06/63] rf-source: validate plugin dependency intervals --- .../instruments/rf_source_capabilities.py | 56 ++++++++++++++++--- src/wavebench/plugins/lifecycle.py | 5 ++ tests/test_rf_source_extensions.py | 29 ++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index d64cfcb..b155ca0 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -6,6 +6,8 @@ from types import MappingProxyType from typing import Mapping +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version from wavebench.errors import ConfigError @@ -72,17 +74,53 @@ def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], ) -> None: - """Reserve a public dependency-validation hook for RF-source plugin wheels. + """Cross-check an RF-source descriptor against its wheel metadata.""" - M0 only freezes the descriptor's version interval. The general plugin - lifecycle already proves one active WaveBench dependency before entry-point - import; later RF-specific releases can tighten this hook without changing - the descriptor schema. - """ + if getattr(descriptor, "kind", None) != "rf_source": + return + _validate_rf_source_version_range(descriptor) - del dependencies - if getattr(descriptor, "kind", None) == "rf_source": - _validate_rf_source_version_range(descriptor) + requirements: list[Requirement] = [] + for dependency in dependencies: + if not isinstance(dependency, str): + raise ConfigError("RF source wheel dependency metadata must contain strings") + try: + requirement = Requirement(dependency) + except InvalidRequirement as exc: + raise ConfigError("RF source wheel has an invalid Requires-Dist entry") from exc + if canonicalize_name(requirement.name) == "wavebench" and ( + requirement.marker is None or requirement.marker.evaluate() + ): + requirements.append(requirement) + if len(requirements) != 1: + raise ConfigError( + "RF source wheel must declare exactly one active WaveBench dependency for its descriptor" + ) + requirement = requirements[0] + + try: + minimum = Version(getattr(descriptor, "wavebench_min_version", "")) + maximum = Version(getattr(descriptor, "wavebench_max_version", "")) + except (InvalidVersion, TypeError) as exc: # pragma: no cover - checked above + raise ConfigError("RF source descriptor versions must use valid PEP 440 syntax") from exc + specifiers = tuple(requirement.specifier) + has_floor = any( + item.operator == ">=" and Version(item.version) == minimum + for item in specifiers + ) + has_ceiling = any( + item.operator == "<" and Version(item.version) == maximum + for item in specifiers + ) + if not has_floor or not has_ceiling: + raise ConfigError( + "RF source wheel WaveBench dependency must explicitly include " + f">={minimum},<{maximum} to match the descriptor" + ) + if minimum not in requirement.specifier or maximum in requirement.specifier: + raise ConfigError( + "RF source wheel WaveBench dependency expands or excludes its descriptor interval" + ) def _validate_rf_source_version_range(descriptor: object) -> None: diff --git a/src/wavebench/plugins/lifecycle.py b/src/wavebench/plugins/lifecycle.py index dc1c079..ccd36b5 100644 --- a/src/wavebench/plugins/lifecycle.py +++ b/src/wavebench/plugins/lifecycle.py @@ -778,6 +778,7 @@ def _postflight(self, record: dict[str, str]) -> dict[str, object]: from wavebench.instruments.registry import _validate_descriptor from wavebench.instruments.source_conformance import validate_source_conformance_distribution from wavebench.instruments.source_extension_capabilities import validate_source_plugin_dependencies +from wavebench.instruments.rf_source_capabilities import validate_rf_source_plugin_dependencies ( expected_name, @@ -833,6 +834,10 @@ def installed_metadata_hash(suffix): descriptor, tuple(dist.metadata.get_all("Requires-Dist") or ()), ) +validate_rf_source_plugin_dependencies( + descriptor, + tuple(dist.metadata.get_all("Requires-Dist") or ()), +) with zipfile.ZipFile(wheel_path) as archive: record_names = [name for name in archive.namelist() if name.endswith(".dist-info/RECORD")] if len(record_names) != 1: diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 4ff5fd5..653b9bd 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -10,6 +10,7 @@ from wavebench.instruments.rf_source_capabilities import ( RF_SOURCE_CAPABILITY_METHODS, validate_rf_source_descriptor, + validate_rf_source_plugin_dependencies, ) from wavebench.instruments.rf_source_extensions import ( RF_SOURCE_CONTRACT_VERSION, @@ -232,3 +233,31 @@ def test_rf_source_kind_requires_extensions_and_uses_append_only_field() -> None ) with pytest.raises(ConfigError, match="require the rf_source.idn"): validate_rf_source_descriptor(replace(descriptor(), capabilities=("rf_source.snapshot",))) + + +def test_rf_source_wheel_dependency_must_match_descriptor_interval() -> None: + value = descriptor() + + validate_rf_source_plugin_dependencies(value, ("wavebench>=0.8.25,<0.9",)) + validate_rf_source_plugin_dependencies( + value, + ( + "wavebench>=0.8.25,<0.9,!=0.8.26", + 'wavebench>=99; python_version < "3.0"', + ), + ) + + with pytest.raises(ConfigError, match="explicitly include >=0.8.25,<0.9.0"): + validate_rf_source_plugin_dependencies(value, ("wavebench>=0.8,<0.9",)) + with pytest.raises(ConfigError, match="expands or excludes"): + validate_rf_source_plugin_dependencies( + value, + ("wavebench>=0.8.25,<0.9,!=0.8.25",), + ) + with pytest.raises(ConfigError, match="exactly one active"): + validate_rf_source_plugin_dependencies( + value, + ('wavebench>=0.8.25,<0.9; python_version < "3.0"',), + ) + with pytest.raises(ConfigError, match="invalid Requires-Dist"): + validate_rf_source_plugin_dependencies(value, ("wavebench=>not-a-version",)) From 11ed85b903b9f0813a862b76b96a9fe2b51c38fc Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:10:35 +0800 Subject: [PATCH 07/63] docs: document RF source M0 boundaries --- README.md | 4 +- docs/README.md | 4 +- docs/README_EN.md | 3 +- docs/project/README.md | 2 + ...21\351\207\214\347\250\213\347\242\221.md" | 111 +++++ ...67\346\272\220\350\256\276\350\256\241.md" | 380 ++++++++++++++++++ ...01\347\250\213\350\256\276\350\256\241.md" | 2 + ...07\346\212\275\350\261\241\345\261\202.md" | 10 +- ...71\347\233\256\350\276\271\347\225\214.md" | 7 + .../WaveBench_CLI\345\275\242\346\200\201.md" | 17 +- ...07\344\273\266\346\240\274\345\274\217.md" | 55 ++- ...345\231\250\346\217\222\344\273\266API.md" | 27 +- 12 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 "docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" create mode 100644 "docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" diff --git a/README.md b/README.md index c4a9388..988bef2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ WaveBench 是一个用 Python 编写的实验室自动测量台,面向电子设计竞赛调试和日常实验。它把仪器控制、实验步骤和采集证据放在同一条命令链中,支持先离线检查 plan,再决定是否连接硬件。 -当前仓库开发线为 `0.8.24`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 +当前仓库开发线为 `0.8.25`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 ## 🌟 特别鸣谢 @@ -120,6 +120,8 @@ wavebench tui --fake 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 +RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0 只读合同;DSG830 插件的 production descriptor 暂仅开放 `rf_source.idn`,尚未通过 A1 snapshot 实机证据,也没有 RF 写入能力。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 + ## 三条常用路径 ### source → scope 完整流程 diff --git a/docs/README.md b/docs/README.md index 11d2f06..d95268d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ [English](README_EN.md) · 中文 -WaveBench 是一个用 Python 编写的实验室自动测量台,提供 CLI、实验性 TUI、显式 run plan、采集包和离线报告。当前开发线为 `0.8.24`,最新稳定 tag 为 `v0.8.0`。版本变化见 [更新日志](../CHANGELOG.md);旧版本原始文档可切换到对应 Git tag 查看。 +WaveBench 是一个用 Python 编写的实验室自动测量台,提供 CLI、实验性 TUI、显式 run plan、采集包和离线报告。当前开发线为 `0.8.25`,最新稳定 tag 为 `v0.8.0`。版本变化见 [更新日志](../CHANGELOG.md);旧版本原始文档可切换到对应 Git tag 查看。 > [!WARNING] > 部分命令会连接并控制真实仪器。示例会区分离线检查、连接读取和硬件写入;执行写入前,应确认接线和限制值。 @@ -54,6 +54,8 @@ wavebench run check --plan /tmp/wavebench-demo.toml - [设备抽象层](project/design/WaveBench_设备抽象层.md) - [多仪器流程设计](project/design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](project/design/WaveBench_sweep状态恢复设计.md) +- [RF 信号源领域设计](project/design/WaveBench_RF信号源设计.md):当前 M0 与后续写入合同的边界。 +- [RF 信号源开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库状态与 A1–A5 证据门。 - [TUI 终端控制面板](project/guides/WaveBench_TUI终端控制面板.md) 目录分类见 [project/README](project/README.md)。本页只负责入口,不把阶段记录当作当前使用说明。 diff --git a/docs/README_EN.md b/docs/README_EN.md index d379500..82ad75c 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -2,7 +2,7 @@ [中文文档](README.md) · English -WaveBench is a Python measurement bench for laboratory debugging. It combines explicit instrument commands, run plans, capture packages, and offline reports. It requires Python 3.11 or newer. The current development line is `0.8.24`; the latest stable tag is `v0.8.0`. +WaveBench is a Python measurement bench for laboratory debugging. It combines explicit instrument commands, run plans, capture packages, and offline reports. It requires Python 3.11 or newer. The current development line is `0.8.25`; the latest stable tag is `v0.8.0`. > [!WARNING] > Some commands connect to and change real instruments. Check wiring, input impedance, output state, and voltage/current limits before running a hardware action. @@ -59,6 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) +- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core M0 is read-only; DSG830 production remains identity-only before A1 evidence. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git a/docs/project/README.md b/docs/project/README.md index 94fbfc5..35f2091 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -24,6 +24,8 @@ - [设备抽象层](design/WaveBench_设备抽象层.md) - [多仪器流程设计](design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](design/WaveBench_sweep状态恢复设计.md) +- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):当前 M0 只读合同、后续写入设计与安全边界。 +- [RF 信号源开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core 与 DSG830 插件的当前状态、依赖和 A1–A5 实机证据门。 ## rfcs:接口提案与决策 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" new file mode 100644 index 0000000..279eac5 --- /dev/null +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -0,0 +1,111 @@ +# WaveBench RF 信号源开发里程碑 + +[领域设计](WaveBench_RF信号源设计.md) · [项目文档分类](../README.md) + +本文把 RF 信号源领域设计拆成可交付、可验证的双仓库工作项。它不替代当前程序事实源,也不将离线合同写成真实仪器能力。 + +## 当前基线 + +| 范围 | 当前状态 | 说明 | +| --- | --- | --- | +| Core `0.8.25` 开发线 | M0 离线完成 | 已有 `rf_source` kind、配置、只读 Service/CLI/doctor、`rf_source.status` run 路径、artifact 和 descriptor extension;未实现 RF 写入事务。 | +| DSG830 包 `0.2.0` | M0 离线完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology 与严格 snapshot parser;production descriptor 仅 `rf_source.idn`。 | +| 真实仪器证据 | A1–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 前不能提升 `rf_source.snapshot`。 | + +## 双仓库交付规则 + +| 规则 | 要求 | +| --- | --- | +| Core 优先 | 新 kind、descriptor extension、capability registry、配置、Service、CLI、run 和 artifact 先在 Core 开发线完成;正式 wheel 验收还需要已发布的 Core。 | +| 插件跟随 | DSG830 已迁移为 `kind="rf_source"`,并把 `Requires-Dist: wavebench` 与 descriptor 版本门同步为 `>=0.8.25,<0.9`;当前双仓库开发依赖匹配的 Core checkout/版本范围。 | +| 测试隔离 | 后续 capability 可以只出现在 fake descriptor 中,用于离线测试;production descriptor 不得提前声明。 | +| 证据提升 | capability 进入 production descriptor 前,必须有对应 A 级实机证据,记录型号、固件、选件、端口、端接和最终 RF OFF 状态。 | +| 失败语义 | 不确定写入不重试;只有 session health 允许时,M2 才可最多执行一次目标端口 RF OFF recovery。 | + +## 里程碑总览 + +| 阶段 | 状态 | Core 交付 | DSG830 交付 | 完成条件 | +| --- | --- | --- | --- | --- | +| Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | +| M0 | 离线完成,等待 A1 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | 生产包仍只可声明 `rf_source.idn`;snapshot 等待 A1。 | +| M1 | 未开始 | OFF-only CW 配置 | `:FREQ`/`:LEV` 写后独立回读 | 输出 ON、活动 feature、越界或状态缺失时零写拒绝。 | +| M2 | 未开始 | RF 输出安全事务 | `:OUTP` ON/OFF 及 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次 OFF recovery。 | +| M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | +| M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | + +## Seed:历史种子包 + +DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包元数据与离线测试。它使用普通 `source` 名称空间,是 Core M0 前维持打包与开发环境接入的过渡实现;当前包已迁移,不应作为现状描述。 + +不允许把下列内容从 Seed 推导出来: + +- 历史 `source.idn` 种子本身可证明当前 `rf_source` 的能力; +- 频率、dBm 功率、RF 输出、调制、Pulse 或 Sweep 已可用; +- `source.idn` 种子可进入普通 source 的 Vpp、channel 或 run plan 工作流。 + +## M0:只读领域与迁移(离线完成) + +### Core + +- 已添加 `rf_source` plugin kind 和 append-only `InstrumentDescriptor.rf_source_extensions`。 +- 已冻结 topology、`port_id`、`RfObserved`、snapshot、protection policy、Protocol 与 descriptor validator 的最小类型合同。 +- 已添加 `rf_source.idn`、`rf_source.snapshot` capability 与 OperationSpec;复用 access policy、resource lease、guarded transport 和 session health。 +- 已添加独立 `[rf_source]` 与按 `port_id` 配置的安全字段;不复用 `SourceConfig`、`source.terminations` 或 Vpp 限制。 +- 已添加只读 `rf-source idn`/`rf-source status`、doctor IDN target,以及 `rf_source.status` 的 run schema、check、verify、intent、lifecycle、dispatch 和独立 artifact namespace。 + +### DSG830 + +- 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 +- 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 +- 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;它目前只用于离线测试。 +- A1 前 production descriptor 保持 `rf_source.idn`,不声明 `rf_source.snapshot`;后续 M1–M4 capability 只能存在于 fake descriptor 或离线 driver 测试中。 + +### 离线完成条件 + +- descriptor 导入和静态校验没有 I/O;每个状态 query、解析分支和坏响应都有测试。 +- Core 的 registry、配置、CLI、doctor、run check/verify/intent 与 artifact 测试通过。 +- 插件开发依赖区间与 descriptor 版本门均为 `>=0.8.25,<0.9`;正式 wheel 验收等待已发布的 Core 版本。 + +## M1:OFF-only CW 配置 + +Core 提供独立 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm`、端口范围检查和 `wavebench.rf_source.operation.v1` artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。 + +DSG830 driver 只实现已冻结的 `:FREQ` 与 `:LEV` 映射及独立回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 + +## M2:RF 输出安全事务 + +Core 添加每端口安全预检、`rf_source.output_enable`/`rf_source.output_disable`、端接匹配检查、blocking protection policy 和 run safety gate 的 `rf_source_ports`。RF OFF 不依赖频率、功率、端接或 protection readback;RF ON 必须逐项满足所有前置条件。 + +DSG830 driver 实现 `:OUTP ON|OFF` 与独立 readback。ON 结果不明、写后 readback 失败或 protection 变化时,不重试 ON;只有 session health 允许时,才最多发送一次目标端口 OFF 并回读。production descriptor 直到 A2 后才可声明 output capability。 + +## M3:调制 + +Core 冻结 AM/FM/PM profile、request/result、operation context、CLI、run step 与 artifact 字段。DSG830 先限定到手册可审计的内部 Sine 调制子集。任何输出未 OFF、profile 不支持或 postcondition 不符的请求都必须零写拒绝。 + +production descriptor 的调制 capability 需要 A4 证据;离线 driver 和 fake descriptor 的完整测试不能替代它。 + +## M4:Pulse 与 Step Sweep + +Core 冻结 Pulse/Sweep profile、`arm`/`trigger`/`fire`/`stop` 的 operation 映射和安全规则。`RfPortSnapshot` 中的 Pulse、Sweep 状态必须可区分,不能将外部 trigger、后面板辅助输出或设备私有模式默认为安全。 + +DSG830 只进入手册与离线测试均覆盖的 Pulse/frequency-only Step Sweep 子集。fake descriptor 可以覆盖 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 + +## A1–A5:实机证据门 + +| 证据 | 范围 | 可以提升的 production capability | +| --- | --- | --- | +| A1 | 只读 snapshot | `rf_source.snapshot` | +| A2 | RF OFF/ON、readback 与最终 OFF | `rf_source.output` | +| A3 | CW 环回、频率与 dBm 功率 | `rf_source.cw_configure` | +| A4 | 调制、Pulse、Step Sweep | 对应 M3/M4 capability | +| A5 | 外部 trigger 或同步接线 | trigger/fire/同步相关 capability | + +每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 + +## 推荐实施顺序 + +1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 +2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 +3. 下一步先准备并取得 A1 的只读 snapshot 证据;在证据前不得把 DSG830 parser 暴露为 production capability。 +4. 再用 fake descriptor 完成 M1/M2 的零写拒绝、postcondition 与 recovery 测试,并实现 DSG830 的对应离线 SCPI 映射。 +5. 取得 A2、A3 证据后,按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" new file mode 100644 index 0000000..bfcf92a --- /dev/null +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -0,0 +1,380 @@ +# WaveBench RF 信号源领域设计 + +## 文档定位 + +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已经实现 M0 的只读合同;本文同时保留 M1–M4 的写入设计和 A1–A5 的实机证据门,不能将后两者误写成当前能力。 + +阅读顺序如下: + +1. 本文界定领域模型、安全规则和 production capability 的证据门槛。 +2. [RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md) 说明 Core 与 DSG830 插件的交付顺序。 +3. [设备抽象层](WaveBench_设备抽象层.md) 和 [多仪器流程设计](WaveBench_多仪器协同流程设计.md)说明当前通用分层与 run plan 边界。 +4. 当前可执行命令、配置字段和 step kind 仍以 `wavebench --help`、`wavebench run schema`、`wavebench.example.toml` 与参考文档为准。 + +## 当前状态 + +| 范围 | 当前状态 | 边界 | +| --- | --- | --- | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、`rf_source.idn`/`rf_source.snapshot` 合同、只读 Service/CLI/doctor、`rf_source.status` run step 和独立 artifact。 | M0 只读能力;没有 RF 写入事务或 RF safety gate。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology 与严格 snapshot parser。 | production descriptor 仅声明 `rf_source.idn`。 | +| 实机证据 | A1–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 不能声明 `rf_source.snapshot` 或任何写 capability。 | + +普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 + +除明确标为「当前 M0」的内容外,本文中的 M1–M4、production snapshot/写 capability 与 A1–A5 均为目标合同或证据门;不得将它们写成当前可控制真实仪器的能力。 + +## 术语与证据级别 + +| 术语 | 含义 | +| --- | --- | +| 当前能力 | 已在当前 Core 或当前 production descriptor 中声明,并由程序入口实际消费的能力。 | +| 离线合同 | model、parser、fake transport、SCPI 映射和包装测试的结果;不授权真实仪器写入。 | +| 测试 descriptor | 只在 fake transport 测试中声明后续 capability 的 descriptor,不得随生产包对真实设备开放。 | +| production descriptor | 面向已联网设备的公开 descriptor;只能声明已有对应实机证据的 capability。 | + +## 目标 + +WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输出、调制、脉冲和扫频为主要控制对象的射频信号源,不复用普通函数发生器的 `source`、Vpp、offset、channel、ARB 或波形模型。 + +RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 + +本文覆盖 M0 的当前只读实现,以及 M1–M4 的离线开发和 fake transport 验证边界。A1–A5 实机验收、production snapshot/write capability 声明和发行包推广另行处理;离线代码不能替代这些证据。 + +## 范围与非目标 + +### 目标交付 + +- M0 已提供 `rf_source` plugin kind、配置、capability、model、driver Protocol、只读 Service/CLI/doctor、run status 和 artifact namespace。 +- 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 +- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同。 +- 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 + +### 明确不做 + +- 默认测试不访问、查询或写入已联网的真实仪器;实机 I/O 只能在单项 A 级证据流程中执行。 +- 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 +- 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 +- 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability。 + +## 分层与职责 + +| 层 | 职责 | 不承担的职责 | +| --- | --- | --- | +| 核心 `rf_source` 域 | 公共 model、capability、访问控制、资源租约、session health、安全预检、Service、CLI、run plan 与 artifact | 厂商 SCPI、厂商响应解析、设备功能猜测 | +| 插件 descriptor | 稳定 driver ID、输出端口拓扑、支持功能、有效范围、功率参考与证据引用 | 建立连接、扫描资源、隐式授权 | +| 插件 driver | SCPI 命令、响应解析、写后设备 readback、私有状态映射、`close()` | 读取完整配置、另建 transport、直接写 run artifact、重试能量操作 | +| 实验室配置 | 当前 resource、访问模式、端口安全限制、实际端接声明 | 替代设备能力或实机证据 | + +核心复用既有 registry、factory、`DriverContext`、`GuardedAuditedTransport`、资源租约、access policy 与 session health。它不复用 `SourceDriver`、`SourceStatus`、`SourceService`、Source V2 extension、Vpp safety limit 或普通 source restore。 + +## 通用对象模型 + +### 输出端口与拓扑 + +RF 输出端口使用 descriptor 声明的稳定 `port_id`,而不是数字 channel。例如,一个单端口设备可声明 `"rf_out"`;多端口设备可声明多个不同端口。`port_id` 只在同一 driver ID 内稳定,不承担物理接头型号、实际端接或资源地址语义。 + +```python +@dataclass(frozen=True, slots=True) +class RfOutputPortProfile: + port_id: str + frequency_min_hz: float + frequency_max_hz: float + power_min_dbm: float + power_max_dbm: float + power_reference_impedance_ohm: float + + +@dataclass(frozen=True, slots=True) +class RfSourceTopology: + ports: tuple[RfOutputPortProfile, ...] +``` + +所有范围端点必须有限,最小值不得大于最大值;端口 ID 必须非空、排序稳定且不重复。`power_reference_impedance_ohm` 表示设备用来定义 dBm 的固定参考阻抗,不等于 DUT 的实际端接。 + +### 可观测状态 + +不同设备对状态的读取能力不同。公共 model 使用独立的 `RfObserved[T]` 表示值及其可用性: + +```python +from typing import Generic, TypeVar + + +T = TypeVar("T") + + +class RfAvailability(StrEnum): + VALUE = "value" + UNSUPPORTED = "unsupported" + NOT_APPLICABLE = "not_applicable" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class RfObserved(Generic[T]): + availability: RfAvailability + value: T | None = None + reason_code: RfReasonCode | None = None +``` + +`VALUE` 必须携带通过类型与有限值校验的值;其它可用性必须使用 `value=None`。安全相关字段不是 `VALUE` 时,所有依赖该字段的能量增加 operation 必须在写入前拒绝。 + +`reason_code` 只用于稳定、脱敏的原因标识,不能保存原始 SCPI 响应或厂商私有错误文本。M0 必须通过边界测试冻结 `VALUE` 的有限数/布尔值校验,以及非 `VALUE` 情况下 `value` 与 `reason_code` 的组合规则。 + +```python +@dataclass(frozen=True, slots=True) +class RfPortSnapshot: + port_id: str + frequency_hz: RfObserved[float] + power_dbm: RfObserved[float] + output_enabled: RfObserved[bool] + modulation: RfObserved["RfModulationState"] + pulse: RfObserved["RfPulseState"] + sweep: RfObserved["RfSweepState"] + + +@dataclass(frozen=True, slots=True) +class RfSourceSnapshot: + ports: tuple[RfPortSnapshot, ...] + protection: RfObserved["RfProtectionStatus"] +``` + +`RfModulationState`、`RfPulseState` 与 `RfSweepState` 是封闭的类型化状态,不是自由 mapping。某个 feature 的状态模型尚未完成时,driver 必须返回非 `VALUE`,不能用猜测字段填充。M3/M4 在相应 capability 进入 descriptor 前冻结这些状态的枚举、模式和 readback 语义。 + +`RfProtectionStatus` 只保留规范化的活动 condition。descriptor 对每个已知 condition 声明明确 policy;出现未声明、未知或无法解释的 active code 时,一律阻止 RF ON。artifact 不保存原始状态寄存器、SCPI 响应或厂商私有文本。 + +```python +@dataclass(frozen=True, slots=True) +class RfProtectionStatus: + active_codes: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class RfProtectionConditionPolicy: + code: str + blocks_output_enable: bool +``` + +### 功能 profile 与 capability + +descriptor 在 `rf_source_extensions` 中声明 topology、功能和方向。它必须位于 `InstrumentDescriptor` 的末尾,不能复用或改变既有 `source_extensions` 的布局。核心只接受 `kind="rf_source"` 的 descriptor 提供该字段;目标 operation 缺少所需 capability、profile、driver method 或版本门时,必须在仪器 operation 前拒绝。 + +```python +class RfFeature(StrEnum): + CW = "cw" + OUTPUT = "output" + MODULATION = "modulation" + PULSE = "pulse" + SWEEP = "sweep" + + +class RfFeatureDirection(StrEnum): + READ = "read" + CONFIGURE = "configure" + ENABLE = "enable" + DISABLE = "disable" + ARM = "arm" + TRIGGER = "trigger" + FIRE = "fire" + STOP = "stop" +``` + +```python +@dataclass(frozen=True, slots=True) +class RfFeatureCapability: + feature: RfFeature + directions: tuple[RfFeatureDirection, ...] + port_ids: tuple[str, ...] + profile: "RfFeatureProfile" + + +@dataclass(frozen=True, slots=True) +class RfSourceDescriptorExtensions: + contract_version: Literal["wavebench.rf_source.v1"] + topology: RfSourceTopology + features: tuple[RfFeatureCapability, ...] + protection_conditions: tuple[RfProtectionConditionPolicy, ...] +``` + +每个 protection policy 的 `code` 必须非空且唯一。Core 以 policy 集合识别已知 condition;只有 `blocks_output_enable=False` 的已知 active code 可以不阻断 RF ON。不存在 policy 的 active code 必须拒绝 RF ON。 + +`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfPulseProfile` 或 `RfSweepProfile` 的封闭联合。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 + +每个 `RfFeatureCapability` 必须指定 feature、direction、适用端口、静态限制和可读回字段。静态 profile 只能收紧设备支持范围,不能授权未声明的 operation。`rf_source.pulse_trigger` 对应 `PULSE / TRIGGER`;`rf_source.sweep_fire` 对应 `SWEEP / FIRE`;其他 operation 也必须在 M0–M4 的 descriptor validator 中有唯一映射。 + +Core 在调用目标 driver operation 前校验 request、access、descriptor 静态 schema、capability 名称、profile、版本和配置。factory 返回 driver 后再校验 capability 所需方法;现有 factory 可以在构造 driver 时打开已配置 transport,因此不承诺「方法校验发生在建立连接之前」。descriptor 导入和静态校验不得进行 I/O,且任何 SCPI operation 都不得绕过上述校验。 + +标准 capability 与 driver 方法如下: + +| capability | driver 方法 | 作用 | +| --- | --- | --- | +| `rf_source.idn` | `idn()` | 身份查询 | +| `rf_source.snapshot` | `get_rf_snapshot()` | 只读完整快照 | +| `rf_source.cw_configure` | `configure_cw(request)` | 端口频率与 dBm 功率配置 | +| `rf_source.output` | `set_rf_output(request)` | 单端口 RF ON/OFF | +| `rf_source.modulation_configure` | `configure_rf_modulation(request)` | 已声明的 AM/FM/PM 配置 | +| `rf_source.pulse_configure` | `configure_rf_pulse(request)` | 已声明的 Pulse 配置 | +| `rf_source.pulse_trigger` | `trigger_rf_pulse(request)` | 已声明的 Pulse 触发 | +| `rf_source.sweep_configure` | `configure_rf_sweep(request)` | 已声明的 Sweep 配置 | +| `rf_source.sweep_arm` | `arm_rf_sweep(request)` | 准备 Sweep | +| `rf_source.sweep_fire` | `fire_rf_sweep(request)` | 发起已准备 Sweep | +| `rf_source.sweep_stop` | `stop_rf_sweep(request)` | 停止 Sweep | + +所有 request 均显式携带 `port_id`。同一 operation 不可借默认端口、当前前面板选择或 driver 私有缓存猜测目标端口。 + +`rf-source set-frequency` 与 `rf-source set-power` 是独立的 CLI/run 操作名,但共同使用 `rf_source.cw_configure` capability 和同一类 CW request/postcondition 合同;一个 operation 不能借另一个 operation 的成功隐式取得写入授权。 + +## 通用写入与安全合同 + +### 配置模型 + +`[rf_source]` 是独立配置段。端口安全限制按 descriptor 的 `port_id` 声明: + +```toml +[rf_source] +driver = "vendor.model" +resource = "" +access = "read_only" + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +``` + +每个会执行 RF ON、fire 或其它可能增加 RF 端口能量的 operation 都要求目标端口拥有完整安全配置。配置范围只能收紧 descriptor 的设备范围。`actual_termination_ohm` 必须是有限正数,并绑定当前端口;M0–M4 仅在它与 descriptor 的 `power_reference_impedance_ohm` 精确相等时允许使用 dBm 输出安全判断,不进行阻抗或电压换算。 + +### Operation 顺序 + +CW、调制、Pulse 与 Sweep 配置必须按以下顺序执行: + +1. 在目标 operation 的 SCPI I/O 前校验 request、access、capability 与 descriptor profile;只有可能增加端口能量的 operation 才同时校验完整安全配置; +2. 获得独占资源租约,创建一次受管 driver session; +3. 读取 fresh snapshot,确认目标 RF 输出为 OFF,且无与 operation 冲突的活动 feature; +4. 调用一次对应 driver 配置方法; +5. 读取独立 postcondition,逐字段比较请求值、端口状态和隐式变化; +6. 成功后返回类型化结果与脱敏 artifact。 + +主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。若 session health 仍允许 recovery I/O,核心最多执行一次目标端口 RF OFF 并独立回读;否则将 session 保持在更保守状态。 + +RF ON 是独立 operation。其 preflight 必须确认: + +- `access = "read_write"`; +- target port 的频率、dBm 功率、输出状态、调制、Pulse、Sweep 和 protection 全部为 `VALUE`; +- frequency 与 power 同时在端口安全配置及 descriptor profile 范围内; +- 实际端接与设备 dBm 参考阻抗相等; +- 没有活动的 blocking protection condition; +- 没有尚未获得专项输出安全规则的调制、Pulse 或 Sweep 状态。 + +RF OFF 不依赖频率、功率、端接或 protection readback;它仍受 access、session health 和单次写入规则限制。 + +Sweep arm 是 OFF-only 准备 operation,必须保持目标端口 RF OFF。Sweep fire 与 Pulse trigger 是潜在能量操作。它们必须使用独立、一次性安全决定,不能因先前的 configure、arm 或 output ON 成功而自动获得许可。core 不会隐式打开输出、触发外部端口或开启后面板辅助输出。 + +## Service、CLI、doctor 与 run plan + +### 当前 M0 + +`RfSourceService` 当前负责 `rf_source.idn` 与 `rf_source.snapshot` 的 capability、access、资源租约、session health 和类型化 snapshot。driver 只执行已冻结的设备动作,CLI 不直接生成 SCPI。 + +当前命令和 run 入口为: + +```text +wavebench rf-source idn +wavebench rf-source status +rf_source.status +``` + +`rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。当前 `rf_source.status` 产生独立的 `wavebench.rf_source.operation.v1` snapshot artifact。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 + +因此,DSG830 的 production descriptor 当前可执行身份查询,但不能通过 Core 的 status 路径读取 snapshot。这是 A1 前的有意门禁,不是 driver parser 缺失。 + +### M1–M4 目标 + +后续写入命令和 run step 仍是设计合同,尚未进入当前 CLI 或 run schema: + +```text +wavebench rf-source set-frequency --port PORT_ID HZ +wavebench rf-source set-power --port PORT_ID DBM +wavebench rf-source output --port PORT_ID on|off +wavebench rf-source modulation configure-am ... +wavebench rf-source modulation configure-fm ... +wavebench rf-source modulation configure-pm ... +wavebench rf-source pulse configure ... +wavebench rf-source pulse trigger ... +wavebench rf-source sweep configure ... +wavebench rf-source sweep arm ... +wavebench rf-source sweep fire ... +wavebench rf-source sweep stop ... +``` + +```text +rf_source.set_frequency +rf_source.set_power_dbm +rf_source.output_disable +rf_source.output_enable +rf_source.modulation_configure +rf_source.pulse_configure +rf_source.pulse_trigger +rf_source.sweep_configure +rf_source.sweep_arm +rf_source.sweep_fire +rf_source.sweep_stop +``` + +这些目标 step 都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。M2 的 run safety gate 将独立处理 `rf_source.*`:失败时只针对已知受影响端口请求 RF OFF,不访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 + +## M0–M4 里程碑 + +下表同时标出当前进度和交付边界。Core 与 DSG830 插件的依赖、完成条件和状态见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + +| 里程碑 | 通用核心交付 | 首个适配器离线交付 | 离线验证标准 | +| --- | --- | --- | --- | +| M0(当前) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;production descriptor 保持 IDN-only | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 前不提升 snapshot。 | +| M1 | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率写后回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | +| M2 | per-port 输出事务、安全预检、RF OFF recovery | RF ON/OFF 写后 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | +| M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | +| M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | + +M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 + +production descriptor 在 A1 前只声明 `rf_source.idn`;A1 通过后才可新增 `rf_source.snapshot`。A2、A3、A4、A5 分别是 RF output、CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的提升门槛。未取得对应 evidence 时不得声明或提升 production descriptor capability。 + +## 首个适配器:RIGOL DSG830 + +DSG830 只为通用合同提供第一组设备映射,不改变核心类型或安全规则。DSG800 Programming Guide 中可用于首轮离线实现的事实如下: + +| 通用功能 | DSG830 SCPI | DSG830 静态范围或状态 | +| --- | --- | --- | +| 身份 | `*IDN?` | IDN 包含 `DSG830`。 | +| CW 频率 | `:FREQ ` / `:FREQ?` | `9 kHz–3 GHz`。 | +| CW dBm 功率 | `:LEV ` / `:LEV?` | `-110 dBm–20 dBm`;query 默认 dBm。 | +| RF 输出 | `:OUTP ON|OFF` / `:OUTP?` | 单个 `rf_out` 端口。 | +| 调制状态 | `:MOD:STAT?` | `0`/`1`;AM/FM/PM 配置子集进入 M3。 | +| Pulse 状态 | `:PULM:STAT?` | `0`/`1`;配置和触发进入 M4。 | +| Sweep 状态 | `:SWE:STAT?` | `OFF`、`FREQ`、`LEV` 或组合;frequency-only Step Sweep 子集进入 M4。 | +| 保护状态 | `:STAT:QUES:POW:COND?` | 位 0 ALC unlocked、位 1 output power protection、位 2 heater detector;未知高位按阻断处理。 | + +手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 + +DSG830 的 production `descriptor()` 在 A1 前仅声明 `rf_source.idn`;A1 通过后才可新增 `rf_source.snapshot`。`get_rf_snapshot()` 和严格 parser 已用于离线 fake transport 测试,但不能借此向已联网设备开放 snapshot、频率、功率、输出、调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 + +## 测试与发布边界 + +- 所有公共 model、profile 与 request 在实现前先有边界、非有限数、布尔值、未知枚举和不匹配端口的失败测试。 +- 所有 Service 写路径先有零写拒绝与写后回读失败测试,再实现最小 transaction。 +- `StatefulRfTransport` 模拟严格 query、单次写入、忽略写入、写前异常、写后 query 异常、protection 变化与 session health。 +- driver 测试精确断言 SCPI 命令、值格式、query 顺序、写入次数与 postcondition;不以 fake 的调用次数代替外部可见状态断言。 +- 默认测试不扫描端口、不读取本地实验室配置、不连接仪器、不执行真实 SCPI。 +- 核心验证包括聚焦 pytest、完整 pytest、ruff 和 `git diff --check`;插件额外包括包级 pytest、ruff、wheel/package check 与安装 dry-run。 +- production descriptor 的 capability 提升只接受对应的 A1–A5 证据,不接受「代码已实现」或 fake 测试替代。 + +## 实施记录与边界 + +- 核心开发分支:`Scaxlibur/feat/rf-source-core`。 +- DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 +- Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 +- M0 验证只覆盖离线代码和安全的 scope 只读预检;DSG830 A1 snapshot 证据尚未开始,不能据此提升 production capability。 +- `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index d30a01c..c83d4c2 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -252,6 +252,8 @@ sleep `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core M0 已在 `run schema` 中提供只读 `rf_source.status`:它使用独立的类型化 snapshot artifact,并要求 descriptor 声明 `rf_source.snapshot`。DSG830 production descriptor 在 A1 前未声明该 capability,因此不会打开 transport 执行 status。M1–M4 的频率、功率、输出和端口级 RF OFF safety gate 仍未进入 run schema。详见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + `scope.capture` 可以额外声明: ```toml diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index c403f51..a6091b4 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -27,7 +27,7 @@ class Instrument: ### Metadata 与可执行插件分层 -`wavebench.drivers` V1 继续提供只读 metadata;`wavebench.instruments` V2 提供可信的可执行 driver factory。Service 只依赖 `ScopeDriver` / `SourceDriver` / `PowerDriver` / `DmmDriver` contracts,并通过统一 registry/factory 创建内置或外部 driver。 +`wavebench.drivers` V1 继续提供只读 metadata;`wavebench.instruments` V2 提供可信的可执行 driver factory。当前 Service 依赖 `ScopeDriver` / `SourceDriver` / `RfSourceDriver` / `PowerDriver` / `DmmDriver` contracts,并通过统一 registry/factory 创建内置或外部 driver。 插件只负责设备差异。核心继续掌握 resource、transport factory、安全限制、Service、run plan 和 artifact。未选中的第三方插件默认不导入;`plugin ... --load` 才会显式加载并诊断全部可执行 descriptor。 @@ -248,6 +248,14 @@ Service 层可以按以下顺序组合这些动作: 设置信号 → 等待稳定 → 采集波形 → 保存数据 → 计算指标 ``` +## RF 信号源:当前 M0 与后续阶段 + +上述 `SignalGenerator` 示例只描述普通函数/任意波形发生器。RF 信号源以频率、dBm 功率、RF 输出和稳定 `port_id` 为主,不能把它映射为普通 `SourceDriver` 的 Vpp、offset、数字 channel 或波形接口。 + +当前 M0 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、`rf-source idn`/`rf-source status` 和 `rf_source.status` run step。当前只读 Service 仍受 capability、access、资源租约与 session health 约束;descriptor 未声明 `rf_source.snapshot` 时,status 会在 transport I/O 前被拒绝。 + +M1–M4 的安全预检、写入 transaction 与写入 run step 尚未实现。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + ## 早期目录示意 以下目录反映早期设计,不作为当前源码树的路径清单。当前入口以代码和插件开发指南为准。 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 6424574..2a23ecc 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,11 +19,18 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | +| RF 信号源 | M0 只读插件领域:身份查询、类型化 snapshot 合同、配置、CLI 与 `rf_source.status` run step | 不复用普通 source;当前 DSG830 production descriptor 仅 `rf_source.idn`,snapshot 与全部 RF 写入仍需实机证据 | | run plan | source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | | 插件 | V2 Python 插件、V1 metadata 和声明式 SCPI 检查 | Python 插件是可信代码,不是安全沙箱 | +## RF 信号源的分阶段边界 + +RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 + +当前 M0 只提供 Core 侧只读合同。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。在对应 Core、插件和 A1–A5 证据完成前,不能把 snapshot 或写入设计写成已支持能力。 + ## 推荐工作顺序 1. 使用 `run template` 生成 plan。 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 3a0d2c5..d23c269 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -13,7 +13,7 @@ wavebench 当前一级命令按设备或功能划分: ```text -scope source power dmm sweep run +scope source rf-source power dmm sweep run capture mcp tui net doctor plugin capability lock ``` @@ -31,7 +31,7 @@ wavebench run --help | 类别 | 示例 | 行为 | |---|---|---| | 离线 | `run schema`、`run template`、`run check`、`run intent`、`run report`、`run compare`、`run resume`、`capability explain`、`lock status`、`capture inspect`、`tui --fake` | 不连接仪器;报告、比较、检查、能力解释、锁查询和意图生成只读取本地文件 | -| 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`source snapshot-v2`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | +| 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`source snapshot-v2`、`rf-source idn`/`status`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | | 显式写入或触发 | `scope auto`、`scope fetch/capture`、source / power setter、`run plan` | 可能改变设置、触发采集或切换输出 | 执行硬件写入前,应先确认接线、输入阻抗、输出状态和安全限制。CLI 不会自动发送 `*RST`,也不会因为设置电压或幅度而自动打开输出。 @@ -79,6 +79,19 @@ wavebench --json source snapshot-v2 --config wavebench.toml 关系,不接受单通道或 raw query 参数。普通模式输出缩进 JSON;`--json` 使用 `wavebench.cli.result.v1` envelope。命令不授权任何 Source V2 写入。 +安装匹配的 RF 插件后,RF 信号源使用独立命令域: + +```bash +wavebench rf-source idn --config wavebench.toml +wavebench rf-source status --config wavebench.toml +wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only +``` + +`rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。当前 DSG830 production +descriptor 只声明身份查询,故 status 的拒绝是预期安全边界;它不表示可以改用 raw SCPI。RF M0 不提供 +频率、功率、RF 输出、调制、Pulse 或 Sweep 写入命令。 + 已声明对应写 capability 的插件还可以配置跨通道关系: ```bash diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index ce9cabe..9bbd03e 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -88,7 +88,7 @@ wavebench scope capture --channel 2 ## 仪器访问策略 -`[scope]`、`[source]`、`[power]` 和 `[dmm]` 都支持 `access` 字段。该字段只控制 +`[scope]`、`[source]`、`[rf_source]`、`[power]` 和 `[dmm]` 都支持 `access` 字段。该字段只控制 WaveBench 发起的仪器操作,不会修改配置文件,也不会替代操作系统或仪器自身的权限控制。 ```toml @@ -170,6 +170,20 @@ ensure_fix_mode_on_set_frequency = true settle_ms_after_set_frequency = 500 access = "read_write" +# RF M0 是独立的只读域;当前建议显式使用 read_only。 +[rf_source] +driver = "rigol.dsg830" +resource = "TCPIP::192.0.2.13::INSTR" +access = "read_only" + +# 该静态声明为后续能量操作预留;M0 不会使用它执行写入。 +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 + [power] driver = "dp800" resource = "TCPIP::192.0.2.12::INSTR" @@ -416,6 +430,45 @@ maximum_ohm = 50.5 实际端接与仪器显示的 `HiZ`、`50 Ω` 或其它 load setting 是不同事实。配置项只声明实验台已确认的 外部端接;未配置不会被核心根据显示负载自动推断。 +## `[rf_source]` + +```toml +[rf_source] +driver = "rigol.dsg830" +resource = "TCPIP::192.0.2.13::INSTR" +access = "read_only" + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +``` + +`[rf_source]` 是独立于普通 `[source]` 的 RF 信号源配置。它使用 plugin descriptor 的稳定 +`port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。当前 M0 可使用 +`wavebench rf-source idn`;`wavebench rf-source status` 还要求 production descriptor 声明 +`rf_source.snapshot`。DSG830 在 A1 实机证据前仅声明 `rf_source.idn`,因此 status 会在打开 +transport 前被拒绝。 + +字段说明: + +- `driver`:已安装 RF 插件的 canonical driver ID;默认值是 `rigol.dsg830`,但只有已安装插件才可解析。 +- `resource`:RF 信号源的 VISA 资源串;可由 `rf-source` 命令的 `--resource` 临时覆盖。 +- `access`:沿用通用访问策略。M0 只需 `read_only`,实际写 capability 发布前不应配置或依赖 `read_write`。 +- `options`:可选的插件私有配置表;公开配置不要放入真实资源之外的凭据或实验室专有数据。 + +`[[rf_source.safety.ports]]` 是按端口声明的本地静态安全证据。每项必须提供唯一 `port_id`、有限的 +`minimum_frequency_hz`、`maximum_frequency_hz`、`maximum_power_dbm` 和正数 +`actual_termination_ohm`;最大频率不得小于最小频率。它不改变仪器显示的负载设置,也不会把 dBm +换算为 Vpp。M0 只解析和校验该声明,尚不执行 RF 写入或 safety gate;后续能量相关 capability 才会 +使用它进行准入判断。 + +RF 的 capability、A1–A5 证据门和 DSG830 的当前 production 边界见 +[RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)和 +[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md)。 + ## `[power]` ```toml diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index 27e9051..868d834 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -120,7 +120,7 @@ def descriptor() -> InstrumentDescriptor: | 字段 | 核心强制 | 接口约定和用途 | | --- | --- | --- | | `driver_id` | 非空、无首尾空白;外置插件必须与 entry point 名一致 | 使用小写 ASCII canonical ID,推荐 `vendor.model` 形式;发布后不得复用给其他设备族 | -| `kind` | 配置解析时必须与目标槽位一致 | 只能是 `scope`、`source`、`power`、`dmm` 或 `sweep_analyzer` | +| `kind` | 配置解析时必须与目标槽位一致 | 只能是 `scope`、`source`、`rf_source`、`power`、`dmm` 或 `sweep_analyzer` | | `display_name` | 仅要求构造参数存在 | 面向用户的简短名称,不承担型号匹配 | | `manufacturer` | 仅要求构造参数存在 | 使用厂商正式名称 | | `models` | 至少包含一项 | 每项应为非空型号名称;不要把营销系列名当作已验证型号 | @@ -141,6 +141,7 @@ def descriptor() -> InstrumentDescriptor: | `config_fields` | 当前只展示;为空时由 `option_specs` 推导 `options.` | 只列出用户实际可配置的字段,不代表核心会按此字段授权 | | `scope_extensions` | 仅允许 scope descriptor 使用,类型必须为 `ScopeDescriptorExtensions` | 为 R1.3 capability 提供静态截图、采集控制、trace、标准 waveform bounded profile 和 average capture V2 profile;旧插件保持 `None` | | `source_extensions` | 仅允许 source descriptor 使用,类型必须为 `SourceDescriptorExtensions` | 为 `source.snapshot_v2` 及各已声明的 Source V2 写 capability 提供 topology、feature profile 和查询合同;旧插件保持 `None` | +| `rf_source_extensions` | `rf_source` descriptor 必须提供,且其它 kind 不得提供;类型必须为 `RfSourceDescriptorExtensions` | M0 的独立 RF topology、feature 和 protection policy;该字段 append-only,旧插件保持 `None` | ### `scope_coupling_policy` @@ -583,6 +584,30 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 +### RF 信号源 M0 + +`rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source +能力。descriptor 必须同时满足以下静态条件: + +- 只声明 `rf_source.*` capability,且至少包含 `rf_source.idn`;当前 Core 只识别 + `rf_source.idn` 和 `rf_source.snapshot`。 +- 提供 `rf_source_extensions`,其 contract version、拓扑、端口 ID、feature 和 protection policy 必须通过 + Core 校验。 +- `wavebench_min_version` 不低于 `0.8.25`,并且小于 `wavebench_max_version`。 +- 打包检查时,wheel 必须有且仅有一条生效的 `wavebench` 依赖,并显式使用与 descriptor 相同的 + `>=wavebench_min_version, Date: Wed, 26 Aug 2026 20:15:48 +0800 Subject: [PATCH 08/63] docs: define RF snapshot evidence gate --- ...21\351\207\214\347\250\213\347\242\221.md" | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 279eac5..87b5658 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -102,6 +102,27 @@ DSG830 只进入手册与离线测试均覆盖的 Pulse/frequency-only Step Sw 每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 +### A1:只读 snapshot 验收流程 + +A1 不是将 `rf_source.snapshot` 临时加入 production descriptor,也不能通过 `rf-source status` 绕过现有 +capability 门禁。验收使用一次性、非 production 的本地 evidence harness;production descriptor 在整个 +流程中保持仅 `rf_source.idn`。 + +1. 使用独立的本地 TOML 副本,不修改现有 `wavebench.toml`。副本中的 `[rf_source]` 必须指定已核对的 + canonical driver、资源和 `access = "read_only"`;不得使用 `read_write`、网络扫描或资源猜测。 +2. harness 通过受 guard 的 transport 和独占 resource lease 创建单个 session,只调用手册已审计的 snapshot + query,且不添加 query 重试:`*IDN?`、`:FREQ?`、`:LEV?`、`:OUTP?`、`:MOD:STAT?`、`:PULM:STAT?`、 + `:SWE:STAT?`、`:STAT:QUES:POW:COND?`。 +3. 禁止 `*RST`、错误队列、RF OFF/ON、频率/功率/调制/Pulse/Sweep setter、trigger、capture 和任何 + `write`/`write_bytes`。A1 失败时不在该只读流程中尝试 recovery OFF。 +4. 本地证据只保存 A1 标签、时间、Core/插件版本、canonical driver、脱敏的型号/固件/选件信息、`rf_out` + 的类型化 snapshot、session 结果和 guard audit 摘要。不得保存资源、序列号、完整 IDN、原始响应或命令日志。 +5. 成功条件为:parser 完整成功、目标 RF 输出明确为 OFF、session 健康且关闭成功、audit 显示 + `access=read_only`、query 数量与预期一致、所有写计数和 `instrument_mutation_writes` 均为零。输出为 ON、 + 状态未知、保护/解析异常、session 异常或关闭失败均为未通过,不能提升 capability。 + +只有上述证据由人工复核后,才可以在单独补丁中把 `rf_source.snapshot` 加入 DSG830 production descriptor。 + ## 推荐实施顺序 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 From eb65f2730376c0d2fd343eb2c7917c0fda52ea49 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:07:58 +0800 Subject: [PATCH 09/63] docs: require complete RF A1 evidence metadata --- ...4\200\345\217\221\351\207\214\347\250\213\347\242\221.md" | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 87b5658..a08e1ac 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -116,7 +116,10 @@ capability 门禁。验收使用一次性、非 production 的本地 evidence ha 3. 禁止 `*RST`、错误队列、RF OFF/ON、频率/功率/调制/Pulse/Sweep setter、trigger、capture 和任何 `write`/`write_bytes`。A1 失败时不在该只读流程中尝试 recovery OFF。 4. 本地证据只保存 A1 标签、时间、Core/插件版本、canonical driver、脱敏的型号/固件/选件信息、`rf_out` - 的类型化 snapshot、session 结果和 guard audit 摘要。不得保存资源、序列号、完整 IDN、原始响应或命令日志。 + 的类型化 snapshot、已人工确认的实际端接、session 结果和 guard audit 摘要。隔离 TOML 的 + `[a1_evidence]` 必须显式记录端口、有限正数端接和已确认的选件列表;firmware 从同一次 `*IDN?` 的 + 受限字段提取,不新增查询。不得从连接器标签、scope coupling 或型号名称推导端接,也不得保存资源、 + 序列号、完整 IDN、原始响应或命令日志。 5. 成功条件为:parser 完整成功、目标 RF 输出明确为 OFF、session 健康且关闭成功、audit 显示 `access=read_only`、query 数量与预期一致、所有写计数和 `instrument_mutation_writes` 均为零。输出为 ON、 状态未知、保护/解析异常、session 异常或关闭失败均为未通过,不能提升 capability。 From 27ec272851810d01a1a8737f16e79d747866656e Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:50:05 +0800 Subject: [PATCH 10/63] docs: record DSG830 A1 snapshot promotion --- README.md | 2 +- docs/README_EN.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 25 +++++++++---------- ...67\346\272\220\350\256\276\350\256\241.md" | 18 ++++++------- ...01\347\250\213\350\256\276\350\256\241.md" | 2 +- ...71\347\233\256\350\276\271\347\225\214.md" | 4 +-- .../WaveBench_CLI\345\275\242\346\200\201.md" | 6 ++--- ...07\344\273\266\346\240\274\345\274\217.md" | 4 +-- 8 files changed, 31 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 988bef2..2607e82 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ wavebench tui --fake 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 -RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0 只读合同;DSG830 插件的 production descriptor 暂仅开放 `rf_source.idn`,尚未通过 A1 snapshot 实机证据,也没有 RF 写入能力。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 +RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0 只读合同;DSG830 已完成 A1 只读快照证据,production descriptor 开放 `rf_source.idn` 和 `rf_source.snapshot`,但没有 RF 写入能力。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 ## 三条常用路径 diff --git a/docs/README_EN.md b/docs/README_EN.md index 82ad75c..65f92f7 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -59,7 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) -- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core M0 is read-only; DSG830 production remains identity-only before A1 evidence. +- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core M0 is read-only; DSG830 A1 evidence permits production identity and read-only snapshot, while RF writes remain gated. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index a08e1ac..63c8b25 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -9,8 +9,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | | Core `0.8.25` 开发线 | M0 离线完成 | 已有 `rf_source` kind、配置、只读 Service/CLI/doctor、`rf_source.status` run 路径、artifact 和 descriptor extension;未实现 RF 写入事务。 | -| DSG830 包 `0.2.0` | M0 离线完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology 与严格 snapshot parser;production descriptor 仅 `rf_source.idn`。 | -| 真实仪器证据 | A1–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 前不能提升 `rf_source.snapshot`。 | +| DSG830 包 `0.2.0` | M0 离线完成;A1 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology 与严格 snapshot parser;production descriptor 声明只读 `rf_source.idn` 与 `rf_source.snapshot`。 | +| 真实仪器证据 | A1 已完成;A2–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 仅提升 `rf_source.snapshot`。 | ## 双仓库交付规则 @@ -27,7 +27,7 @@ | 阶段 | 状态 | Core 交付 | DSG830 交付 | 完成条件 | | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | -| M0 | 离线完成,等待 A1 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | 生产包仍只可声明 `rf_source.idn`;snapshot 等待 A1。 | +| M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 未开始 | OFF-only CW 配置 | `:FREQ`/`:LEV` 写后独立回读 | 输出 ON、活动 feature、越界或状态缺失时零写拒绝。 | | M2 | 未开始 | RF 输出安全事务 | `:OUTP` ON/OFF 及 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次 OFF recovery。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | @@ -57,8 +57,8 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 -- 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;它目前只用于离线测试。 -- A1 前 production descriptor 保持 `rf_source.idn`,不声明 `rf_source.snapshot`;后续 M1–M4 capability 只能存在于 fake descriptor 或离线 driver 测试中。 +- 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 +- A1 已提升 `rf_source.snapshot`;后续 M1–M4 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 ### 离线完成条件 @@ -102,14 +102,13 @@ DSG830 只进入手册与离线测试均覆盖的 Pulse/frequency-only Step Sw 每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 -### A1:只读 snapshot 验收流程 +### A1:已完成的只读 snapshot 验收 -A1 不是将 `rf_source.snapshot` 临时加入 production descriptor,也不能通过 `rf-source status` 绕过现有 -capability 门禁。验收使用一次性、非 production 的本地 evidence harness;production descriptor 在整个 -流程中保持仅 `rf_source.idn`。 +A1 已使用一次性、非 production 的本地 evidence harness 完成并经复核。当时没有临时将 +`rf_source.snapshot` 加入 production descriptor,也没有通过 `rf-source status` 绕过 capability 门禁。验收期间,production descriptor 始终保持仅 `rf_source.idn`。 1. 使用独立的本地 TOML 副本,不修改现有 `wavebench.toml`。副本中的 `[rf_source]` 必须指定已核对的 - canonical driver、资源和 `access = "read_only"`;不得使用 `read_write`、网络扫描或资源猜测。 + canonical driver、资源和 `access = "read_only"`;不得使用 `read_write` 或资源猜测。若需网络发现,必须在 harness 之外以有界、单独授权的流程完成,人工复核后才写入副本,且发现结果不进入证据。 2. harness 通过受 guard 的 transport 和独占 resource lease 创建单个 session,只调用手册已审计的 snapshot query,且不添加 query 重试:`*IDN?`、`:FREQ?`、`:LEV?`、`:OUTP?`、`:MOD:STAT?`、`:PULM:STAT?`、 `:SWE:STAT?`、`:STAT:QUES:POW:COND?`。 @@ -124,12 +123,12 @@ capability 门禁。验收使用一次性、非 production 的本地 evidence ha `access=read_only`、query 数量与预期一致、所有写计数和 `instrument_mutation_writes` 均为零。输出为 ON、 状态未知、保护/解析异常、session 异常或关闭失败均为未通过,不能提升 capability。 -只有上述证据由人工复核后,才可以在单独补丁中把 `rf_source.snapshot` 加入 DSG830 production descriptor。 +本次证据已由人工复核,并在对应插件补丁中把 `rf_source.snapshot` 加入 DSG830 production descriptor。该提升不包含任何 RF 写 capability。 ## 推荐实施顺序 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 -3. 下一步先准备并取得 A1 的只读 snapshot 证据;在证据前不得把 DSG830 parser 暴露为 production capability。 -4. 再用 fake descriptor 完成 M1/M2 的零写拒绝、postcondition 与 recovery 测试,并实现 DSG830 的对应离线 SCPI 映射。 +3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 +4. 下一步用 fake descriptor 完成 M1/M2 的零写拒绝、postcondition 与 recovery 测试,并实现 DSG830 的对应离线 SCPI 映射。 5. 取得 A2、A3 证据后,按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index bfcf92a..d49590b 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -16,12 +16,12 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、`rf_source.idn`/`rf_source.snapshot` 合同、只读 Service/CLI/doctor、`rf_source.status` run step 和独立 artifact。 | M0 只读能力;没有 RF 写入事务或 RF safety gate。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology 与严格 snapshot parser。 | production descriptor 仅声明 `rf_source.idn`。 | -| 实机证据 | A1–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 不能声明 `rf_source.snapshot` 或任何写 capability。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology 与严格 snapshot parser;A1 只读证据已经完成。 | production descriptor 声明 `rf_source.idn` 与 `rf_source.snapshot`;没有 RF 写 capability。 | +| 实机证据 | A1 已完成;A2–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 不能声明任何写 capability。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「当前 M0」的内容外,本文中的 M1–M4、production snapshot/写 capability 与 A1–A5 均为目标合同或证据门;不得将它们写成当前可控制真实仪器的能力。 +除明确标为「当前 M0」或「已完成 A1」的内容外,本文中的 M1–M4、production 写 capability 与 A2–A5 均为目标合同或证据门;不得将它们写成当前可控制真实仪器的能力。 ## 术语与证据级别 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现,以及 M1–M4 的离线开发和 fake transport 验证边界。A1–A5 实机验收、production snapshot/write capability 声明和发行包推广另行处理;离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1 的提升边界,以及 M1–M4 的离线开发和 fake transport 验证边界。A2–A5 实机验收、production 写 capability 声明和发行包推广另行处理;离线代码不能替代这些证据。 ## 范围与非目标 @@ -289,7 +289,7 @@ rf_source.status `rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。当前 `rf_source.status` 产生独立的 `wavebench.rf_source.operation.v1` snapshot artifact。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 -因此,DSG830 的 production descriptor 当前可执行身份查询,但不能通过 Core 的 status 路径读取 snapshot。这是 A1 前的有意门禁,不是 driver parser 缺失。 +DSG830 已完成 A1,因此 production descriptor 可通过 Core 的 status 路径读取只读 snapshot。该提升只覆盖固定的状态 query,不授权频率、功率、RF 输出、调制、Pulse、Sweep、fire 或 trigger 控制。 ### M1–M4 目标 @@ -332,7 +332,7 @@ rf_source.sweep_stop | 里程碑 | 通用核心交付 | 首个适配器离线交付 | 离线验证标准 | | --- | --- | --- | --- | -| M0(当前) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;production descriptor 保持 IDN-only | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 前不提升 snapshot。 | +| M0(当前) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1 | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率写后回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | | M2 | per-port 输出事务、安全预检、RF OFF recovery | RF ON/OFF 写后 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | | M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | @@ -340,7 +340,7 @@ rf_source.sweep_stop M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -production descriptor 在 A1 前只声明 `rf_source.idn`;A1 通过后才可新增 `rf_source.snapshot`。A2、A3、A4、A5 分别是 RF output、CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的提升门槛。未取得对应 evidence 时不得声明或提升 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`;A2、A3、A4、A5 分别是 RF output、CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的提升门槛。未取得对应 evidence 时不得声明或提升 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -359,7 +359,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1 前仅声明 `rf_source.idn`;A1 通过后才可新增 `rf_source.snapshot`。`get_rf_snapshot()` 和严格 parser 已用于离线 fake transport 测试,但不能借此向已联网设备开放 snapshot、频率、功率、输出、调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1 完成后声明 `rf_source.idn` 与 `rf_source.snapshot`。`get_rf_snapshot()` 可通过该只读入口观察状态;严格 parser 与 A1 证据仍不能借此向已联网设备开放频率、功率、输出、调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -376,5 +376,5 @@ DSG830 的 production `descriptor()` 在 A1 前仅声明 `rf_source.idn`;A1 - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0 验证只覆盖离线代码和安全的 scope 只读预检;DSG830 A1 snapshot 证据尚未开始,不能据此提升 production capability。 +- M0 离线验证已完成;DSG830 A1 snapshot 证据已通过并仅提升 production 的只读 snapshot。A2–A5 仍未开始,不能据此提升任何写 capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index c83d4c2..40cab5a 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -252,7 +252,7 @@ sleep `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 -RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core M0 已在 `run schema` 中提供只读 `rf_source.status`:它使用独立的类型化 snapshot artifact,并要求 descriptor 声明 `rf_source.snapshot`。DSG830 production descriptor 在 A1 前未声明该 capability,因此不会打开 transport 执行 status。M1–M4 的频率、功率、输出和端口级 RF OFF safety gate 仍未进入 run schema。详见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core M0 已在 `run schema` 中提供只读 `rf_source.status`:它使用独立的类型化 snapshot artifact,并要求 descriptor 声明 `rf_source.snapshot`。DSG830 已完成 A1,并在 production descriptor 中声明该 capability,因此 status 可通过已配置的只读 session 读取快照。M1–M4 的频率、功率、输出和端口级 RF OFF safety gate 仍未进入 run schema。详见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 `scope.capture` 可以额外声明: diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 2a23ecc..2ad3dd9 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,7 +19,7 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| RF 信号源 | M0 只读插件领域:身份查询、类型化 snapshot 合同、配置、CLI 与 `rf_source.status` run step | 不复用普通 source;当前 DSG830 production descriptor 仅 `rf_source.idn`,snapshot 与全部 RF 写入仍需实机证据 | +| RF 信号源 | M0 只读插件领域:身份查询、类型化 snapshot 合同、配置、CLI 与 `rf_source.status` run step | 不复用普通 source;DSG830 已完成 A1,只声明 `rf_source.idn` 和 `rf_source.snapshot`,全部 RF 写入仍需实机证据 | | run plan | source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | @@ -29,7 +29,7 @@ WaveBench 优先解决以下问题: RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 -当前 M0 只提供 Core 侧只读合同。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。在对应 Core、插件和 A1–A5 证据完成前,不能把 snapshot 或写入设计写成已支持能力。 +当前 M0 提供 Core 侧只读合同。DSG830 已凭 A1 证据开放 production snapshot;写入设计仍须等待对应的 Core、插件和 A2–A5 证据,不能写成已支持能力。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 推荐工作顺序 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index d23c269..4e5d7b7 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -88,9 +88,9 @@ wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_ ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 -`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。当前 DSG830 production -descriptor 只声明身份查询,故 status 的拒绝是预期安全边界;它不表示可以改用 raw SCPI。RF M0 不提供 -频率、功率、RF 输出、调制、Pulse 或 Sweep 写入命令。 +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1,并在 +production descriptor 中声明这两个只读 capability;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw +SCPI。RF M0 不提供频率、功率、RF 输出、调制、Pulse 或 Sweep 写入命令。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index 9bbd03e..fd73afe 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -449,8 +449,8 @@ actual_termination_ohm = 50 `[rf_source]` 是独立于普通 `[source]` 的 RF 信号源配置。它使用 plugin descriptor 的稳定 `port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。当前 M0 可使用 `wavebench rf-source idn`;`wavebench rf-source status` 还要求 production descriptor 声明 -`rf_source.snapshot`。DSG830 在 A1 实机证据前仅声明 `rf_source.idn`,因此 status 会在打开 -transport 前被拒绝。 +`rf_source.snapshot`。DSG830 已完成 A1,并声明 `rf_source.idn` 和 `rf_source.snapshot`,所以可在 +已配置的只读 session 中执行 status;其他未声明 snapshot 的插件仍会在打开 transport 前被拒绝。 字段说明: From bd9472f15704f4d4578f20748f6de49685dc6bc3 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:05:47 +0800 Subject: [PATCH 11/63] rf-source: add offline CW contract --- ...21\351\207\214\347\250\213\347\242\221.md" | 8 +- .../instruments/rf_source_capabilities.py | 25 +- .../instruments/rf_source_extensions.py | 64 ++++- src/wavebench/services/operation_specs.py | 20 ++ src/wavebench/services/rf_source_service.py | 210 +++++++++++++++- tests/test_operation_specs.py | 19 ++ tests/test_rf_source_extensions.py | 57 +++++ tests/test_rf_source_service.py | 236 +++++++++++++++++- 8 files changed, 621 insertions(+), 18 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 63c8b25..92944dc 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -28,7 +28,7 @@ | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | -| M1 | 未开始 | OFF-only CW 配置 | `:FREQ`/`:LEV` 写后独立回读 | 输出 ON、活动 feature、越界或状态缺失时零写拒绝。 | +| M1 | 离线进行中 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | 已有 typed request/result、Service 和 fake 测试;CLI、run step 与正式离线验收仍待完成。 | | M2 | 未开始 | RF 输出安全事务 | `:OUTP` ON/OFF 及 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次 OFF recovery。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -68,9 +68,9 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 ## M1:OFF-only CW 配置 -Core 提供独立 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm`、端口范围检查和 `wavebench.rf_source.operation.v1` artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。 +Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查和 OFF-only Service 事务。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。CLI、run step、artifact 与完整离线验收仍待完成。 -DSG830 driver 只实现已冻结的 `:FREQ` 与 `:LEV` 映射及独立回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 ## M2:RF 输出安全事务 @@ -130,5 +130,5 @@ A1 已使用一次性、非 production 的本地 evidence harness 完成并经 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 -4. 下一步用 fake descriptor 完成 M1/M2 的零写拒绝、postcondition 与 recovery 测试,并实现 DSG830 的对应离线 SCPI 映射。 +4. 正在用 fake descriptor 完成 M1 的零写拒绝与 postcondition 测试,并实现 DSG830 的对应离线 SCPI 映射;随后单独推进 M2 的 safety preflight 与 recovery。 5. 取得 A2、A3 证据后,按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index b155ca0..5583b21 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -15,6 +15,9 @@ from .rf_source_extensions import ( RF_SOURCE_CONTRACT_VERSION, RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION, + RfCwProfile, + RfFeature, + RfFeatureDirection, RfSourceDescriptorExtensions, ) @@ -23,12 +26,13 @@ { "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), + "rf_source.cw_configure": ("configure_cw",), } ) def validate_rf_source_descriptor(descriptor: object, driver: object | None = None) -> None: - """Validate the static, read-only RF-source M0 descriptor contract.""" + """Validate the static RF-source descriptor contract.""" capabilities = tuple(getattr(descriptor, "capabilities", ())) rf_capabilities = tuple( @@ -58,6 +62,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No raise ConfigError("rf_source descriptors can only declare rf_source capabilities") if "rf_source.idn" not in rf_capabilities: raise ConfigError("rf_source descriptors require the rf_source.idn capability") + if "rf_source.cw_configure" in rf_capabilities: + _validate_cw_configure_feature(extensions) _validate_rf_source_version_range(descriptor) if driver is not None: for capability in rf_capabilities: @@ -70,6 +76,23 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No ) +def _validate_cw_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.CW), + None, + ) + if feature is None or RfFeatureDirection.CONFIGURE not in feature.directions: + raise ConfigError( + "rf_source.cw_configure requires an RF CW feature with configure direction" + ) + if not isinstance(feature.profile, RfCwProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.cw_configure requires an RF CW profile") + if not (feature.profile.frequency_configurable or feature.profile.power_configurable): + raise ConfigError( + "rf_source.cw_configure requires a configurable RF CW frequency or power field" + ) + + def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 0ce272d..99ff790 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -2,8 +2,8 @@ This module deliberately models radio-frequency sources independently from the ``source`` domain used by function and arbitrary waveform generators. -It contains only static descriptors and read-only snapshots; it never opens a -transport or sends SCPI commands. +It contains static descriptors, typed requests/results, and snapshots; it +never opens a transport or sends SCPI commands. """ from __future__ import annotations @@ -306,10 +306,18 @@ def as_dict(self) -> dict[str, object]: class RfCwProfile: frequency_readable: bool power_readable: bool + frequency_configurable: bool = False + power_configurable: bool = False def __post_init__(self) -> None: _require_bool(self.frequency_readable, "RF CW frequency_readable") _require_bool(self.power_readable, "RF CW power_readable") + _require_bool(self.frequency_configurable, "RF CW frequency_configurable") + _require_bool(self.power_configurable, "RF CW power_configurable") + if self.frequency_configurable and not self.frequency_readable: + raise ValueError("RF configurable frequency requires readable frequency") + if self.power_configurable and not self.power_readable: + raise ValueError("RF configurable power requires readable power") @dataclass(frozen=True, slots=True) @@ -405,10 +413,60 @@ def __post_init__(self) -> None: raise ValueError("RF source descriptor protection_conditions must be sorted and unique") +@dataclass(frozen=True, slots=True) +class RfCwRequest: + """One OFF-only CW update for one explicitly addressed RF output port.""" + + port_id: str + frequency_hz: float | None = None + power_dbm: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF CW request port_id") + frequency_requested = self.frequency_hz is not None + power_requested = self.power_dbm is not None + if frequency_requested == power_requested: + raise ValueError("RF CW request must set exactly one of frequency_hz or power_dbm") + if frequency_requested: + _require_finite( + self.frequency_hz, + "RF CW request frequency_hz", + minimum=0.0, + ) + if power_requested: + _require_finite(self.power_dbm, "RF CW request power_dbm") + + +@dataclass(frozen=True, slots=True) +class RfCwResult: + """A single CW field confirmed by an independent postcondition snapshot.""" + + port_id: str + frequency_hz: float | None = None + power_dbm: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF CW result port_id") + frequency_confirmed = self.frequency_hz is not None + power_confirmed = self.power_dbm is not None + if frequency_confirmed == power_confirmed: + raise ValueError("RF CW result must confirm exactly one of frequency_hz or power_dbm") + if frequency_confirmed: + _require_finite( + self.frequency_hz, + "RF CW result frequency_hz", + minimum=0.0, + ) + if power_confirmed: + _require_finite(self.power_dbm, "RF CW result power_dbm") + + @runtime_checkable class RfSourceDriver(InstrumentDriver, Protocol): def get_rf_snapshot(self) -> RfSourceSnapshot: ... + def configure_cw(self, request: RfCwRequest) -> None: ... + def _require_observed_number( observed: object, @@ -495,6 +553,8 @@ def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[st "RF_SOURCE_SNAPSHOT_SCHEMA", "RfAvailability", "RfCwProfile", + "RfCwRequest", + "RfCwResult", "RfFeature", "RfFeatureCapability", "RfFeatureDirection", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 7ea2aaa..c85b5d2 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1037,6 +1037,26 @@ def _spec( error_check_minimum="disabled", risk_flags=("state_dependent_query",), ), + _spec( + "rf_source.set_frequency", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.cw_configure"), + effect="write", + changed_fields=("rf_source.port.frequency_hz",), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "signal_level", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.set_power_dbm", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.cw_configure"), + effect="write", + changed_fields=("rf_source.port.power_dbm",), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "signal_level", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec("power.idn", "power", required_capabilities=("power.idn",), effect="observe"), _spec("power.status", "power", required_capabilities=("power.status",), effect="stateful_read"), _spec("power.measurement", "power", required_capabilities=("power.measurement",), effect="stateful_read"), diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 2292e02..c10bef6 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -1,4 +1,4 @@ -"""Read-only M0 service for RF signal sources.""" +"""M0 read-only and M1 OFF-only CW service for RF signal sources.""" from __future__ import annotations @@ -13,7 +13,23 @@ from wavebench.instruments.capabilities import require_capabilities from wavebench.instruments.factory import open_instrument_driver from wavebench.instruments.registry import resolve_instrument_descriptor -from wavebench.instruments.rf_source_extensions import RfSourceDriver, RfSourceSnapshot +from wavebench.instruments.rf_source_extensions import ( + RfAvailability, + RfCwProfile, + RfCwRequest, + RfCwResult, + RfFeature, + RfFeatureDirection, + RfModulationState, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceDriver, + RfSourceSnapshot, + RfSweepState, +) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy from wavebench.services.operation_specs import require_operation_spec @@ -25,7 +41,7 @@ @dataclass class RfSourceService(SessionStateAliasMixin): - """Open one configured RF source session for an explicitly read-only operation.""" + """Open one configured RF source session for a bounded RF operation.""" config: WaveBenchConfig logger: CommandLogger @@ -115,3 +131,191 @@ def snapshot(self) -> RfSourceSnapshot: if session_state.health is not SessionHealth.HEALTHY: raise ConfigError("rf_source.snapshot requires a healthy session") return rf_source.get_rf_snapshot() + + def configure_cw(self, request: RfCwRequest) -> RfCwResult: + """Apply one OFF-only CW field and independently confirm the result. + + M1 deliberately permits exactly one write per call. It does not retry a + failed or mismatched write and leaves RF OFF recovery to the later M2 + output transaction. + """ + + if not isinstance(request, RfCwRequest): + raise ConfigError("rf_source CW configuration requires RfCwRequest") + operation = ( + "rf_source.set_frequency" + if request.frequency_hz is not None + else "rf_source.set_power_dbm" + ) + self._require(operation, "rf_source.snapshot", "rf_source.cw_configure") + port_profile, cw_profile = self._validate_cw_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + self._validate_cw_preflight( + request, + preflight_snapshot, + port_profile, + cw_profile, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_cw(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + return self._validate_cw_postcondition( + request, + postcondition_snapshot, + port_profile, + cw_profile, + operation=operation, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_cw_postcondition_unverified", + ) + raise + + def _validate_cw_descriptor( + self, + request: RfCwRequest, + operation: str, + ) -> tuple[RfOutputPortProfile, RfCwProfile]: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + port_profile = next( + (port for port in extensions.topology.ports if port.port_id == request.port_id), + None, + ) + if port_profile is None: + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.CW), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfCwProfile) + ): + raise ConfigError(f"{operation} requires a configurable CW profile for the target port") + profile = feature.profile + if request.frequency_hz is not None: + if not profile.frequency_configurable: + raise ConfigError(f"{operation} requires a configurable CW frequency profile") + if not port_profile.frequency_min_hz <= request.frequency_hz <= port_profile.frequency_max_hz: + raise ConfigError(f"{operation} request frequency_hz is outside the descriptor range") + else: + assert request.power_dbm is not None + if not profile.power_configurable: + raise ConfigError(f"{operation} requires a configurable CW power profile") + if not port_profile.power_min_dbm <= request.power_dbm <= port_profile.power_max_dbm: + raise ConfigError(f"{operation} request power_dbm is outside the descriptor range") + return port_profile, profile + + def _validate_cw_preflight( + self, + request: RfCwRequest, + snapshot: RfSourceSnapshot, + port_profile: RfOutputPortProfile, + profile: RfCwProfile, + *, + operation: str, + ) -> RfPortSnapshot: + del port_profile, profile + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): # defensive: snapshot validates this. + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return port + + def _validate_cw_postcondition( + self, + request: RfCwRequest, + snapshot: RfSourceSnapshot, + port_profile: RfOutputPortProfile, + profile: RfCwProfile, + *, + operation: str, + ) -> RfCwResult: + port = self._validate_cw_preflight( + request, + snapshot, + port_profile, + profile, + operation=operation, + ) + if request.frequency_hz is not None: + frequency_hz = self._observed_value( + port.frequency_hz, + f"{operation} requires a readable frequency_hz readback", + ) + if frequency_hz != request.frequency_hz: + raise ConfigError(f"{operation} frequency_hz readback does not match request") + return RfCwResult(port_id=request.port_id, frequency_hz=float(frequency_hz)) + power_dbm = self._observed_value( + port.power_dbm, + f"{operation} requires a readable power_dbm readback", + ) + assert request.power_dbm is not None + if power_dbm != request.power_dbm: + raise ConfigError(f"{operation} power_dbm readback does not match request") + return RfCwResult(port_id=request.port_id, power_dbm=float(power_dbm)) + + @staticmethod + def _snapshot_port( + snapshot: RfSourceSnapshot, + port_id: str, + *, + operation: str, + ) -> RfPortSnapshot: + port = next((item for item in snapshot.ports if item.port_id == port_id), None) + if port is None: + raise ConfigError(f"{operation} snapshot omitted the target RF port") + return port + + @staticmethod + def _observed_value(observed: object, message: str) -> object: + if getattr(observed, "availability", None) is not RfAvailability.VALUE: + raise ConfigError(message) + return getattr(observed, "value", None) diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index 5a99019..a1b47b9 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -60,6 +60,25 @@ def test_rf_source_m0_specs_are_read_only_and_exclusive() -> None: assert snapshot.error_check_minimum == "disabled" +def test_rf_source_m1_cw_specs_require_snapshot_and_cw_capability() -> None: + frequency = require_operation_spec("rf_source.set_frequency") + power = require_operation_spec("rf_source.set_power_dbm") + + assert frequency.instrument_kind == "rf_source" + assert frequency.required_capabilities == ( + "rf_source.snapshot", + "rf_source.cw_configure", + ) + assert frequency.effect == "write" + assert frequency.changed_fields == ("rf_source.port.frequency_hz",) + assert frequency.restore_coverage == "none" + assert "rf_output_must_be_off" in frequency.risk_flags + assert frequency.safe_alternatives == ("rf_source.snapshot",) + assert power.required_capabilities == frequency.required_capabilities + assert power.effect == "write" + assert power.changed_fields == ("rf_source.port.power_dbm",) + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 653b9bd..d0aa049 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -18,6 +18,8 @@ RF_SOURCE_SNAPSHOT_SCHEMA, RfAvailability, RfCwProfile, + RfCwRequest, + RfCwResult, RfFeature, RfFeatureCapability, RfFeatureDirection, @@ -49,6 +51,9 @@ def idn(self) -> str: def get_rf_snapshot(self) -> RfSourceSnapshot: return snapshot() + def configure_cw(self, request: RfCwRequest) -> None: + del request + def topology() -> RfSourceTopology: return RfSourceTopology( @@ -89,6 +94,26 @@ def extensions() -> RfSourceDescriptorExtensions: ) +def cw_extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfCwProfile( + frequency_readable=True, + power_readable=True, + frequency_configurable=True, + power_configurable=True, + ), + ), + ), + ) + + def descriptor(**changes: object) -> InstrumentDescriptor: value = InstrumentDescriptor( driver_id="example.rf1", @@ -146,6 +171,12 @@ def test_rf_source_topology_and_features_are_strict() -> None: port_ids=("rf_out",), profile=RfOutputProfile(output_readable=True), ) + with pytest.raises(ValueError, match="requires readable frequency"): + RfCwProfile( + frequency_readable=False, + power_readable=True, + frequency_configurable=True, + ) with pytest.raises(ValueError, match="unknown port"): replace( extensions(), @@ -179,6 +210,17 @@ def test_rf_observation_and_snapshot_reject_unsafe_values() -> None: ) +def test_rf_cw_request_and_result_require_one_finite_field() -> None: + assert RfCwRequest(port_id="rf_out", frequency_hz=1_000_000.0).frequency_hz == 1_000_000.0 + assert RfCwResult(port_id="rf_out", power_dbm=-20.0).power_dbm == -20.0 + with pytest.raises(ValueError, match="exactly one"): + RfCwRequest(port_id="rf_out") + with pytest.raises(ValueError, match="exactly one"): + RfCwRequest(port_id="rf_out", frequency_hz=1.0, power_dbm=0.0) + with pytest.raises(ValueError, match="finite"): + RfCwResult(port_id="rf_out", frequency_hz=float("nan")) + + def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None: value = snapshot() document = rf_source_snapshot_document(value) @@ -199,6 +241,7 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: assert dict(RF_SOURCE_CAPABILITY_METHODS) == { "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), + "rf_source.cw_configure": ("configure_cw",), } assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( RF_SOURCE_CAPABILITY_METHODS @@ -213,6 +256,20 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: ) with pytest.raises(ConfigError, match="unknown capabilities"): validate_rf_source_descriptor(replace(value, capabilities=("rf_source.idn", "rf_source.future"))) + with pytest.raises(ConfigError, match="CW feature"): + validate_rf_source_descriptor( + replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.cw_configure"), + ) + ) + cw_descriptor = replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.cw_configure"), + rf_source_extensions=cw_extensions(), + ) + validate_rf_source_descriptor(cw_descriptor) + validate_declared_capabilities(cw_descriptor, RfDriver()) def test_rf_source_kind_requires_extensions_and_uses_append_only_field() -> None: diff --git a/tests/test_rf_source_service.py b/tests/test_rf_source_service.py index 4511e97..4ab34d2 100644 --- a/tests/test_rf_source_service.py +++ b/tests/test_rf_source_service.py @@ -17,12 +17,22 @@ ) from wavebench.errors import AccessDeniedError, ConfigError from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, RfModulationState, RfObserved, + RfCwProfile, + RfCwRequest, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfOutputPortProfile, RfPortSnapshot, + RfProtectionConditionPolicy, RfProtectionStatus, RfPulseState, + RfSourceDescriptorExtensions, RfSourceSnapshot, + RfSourceTopology, RfSweepState, ) from wavebench.logging import CommandLogger @@ -46,20 +56,61 @@ def get_rf_snapshot(self) -> RfSourceSnapshot: return _snapshot() -def _snapshot() -> RfSourceSnapshot: +class FakeRfWriteDriver: + def __init__( + self, + snapshots: list[RfSourceSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.snapshots = list(snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfCwRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.snapshots: + raise AssertionError("unexpected RF snapshot query") + return self.snapshots.pop(0) + + def configure_cw(self, request: RfCwRequest) -> None: + self.calls.append("configure_cw") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake CW write failed after transmission") + + +def _snapshot( + *, + frequency_hz: float = 1_000_000.0, + power_dbm: float = -30.0, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: return RfSourceSnapshot( ports=( RfPortSnapshot( port_id="rf_out", - frequency_hz=RfObserved.value_of(1_000_000.0), - power_dbm=RfObserved.value_of(-30.0), - output_enabled=RfObserved.value_of(False), - modulation=RfObserved.value_of(RfModulationState.DISABLED), - pulse=RfObserved.value_of(RfPulseState.DISABLED), - sweep=RfObserved.value_of(RfSweepState.DISABLED), + frequency_hz=RfObserved.value_of(frequency_hz), + power_dbm=RfObserved.value_of(power_dbm), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), ), ), - protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), ) @@ -83,6 +134,71 @@ def _descriptor(*capabilities: str) -> SimpleNamespace: return SimpleNamespace(driver_id="example.rf1", capabilities=capabilities) +def _cw_descriptor( + *capabilities: str, + frequency_configurable: bool = True, + power_configurable: bool = True, +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf1", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfCwProfile( + frequency_readable=True, + power_readable=True, + frequency_configurable=frequency_configurable, + power_configurable=power_configurable, + ), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", True), + ), + ), + ) + + +def _cw_service( + snapshots: list[RfSourceSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, FakeRfWriteDriver]: + driver = FakeRfWriteDriver(snapshots, raise_after_write=raise_after_write) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _cw_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + def test_idn_and_snapshot_are_one_shot_read_only_operations(monkeypatch: pytest.MonkeyPatch) -> None: driver = FakeRfDriver() service = RfSourceService( @@ -168,3 +284,107 @@ def test_one_shot_service_passes_owned_lease_to_factory() -> None: lease = factory.call_args.kwargs["lease"] assert lease.resource == "tcpip::rf::instr" assert driver.calls == ["idn", "close"] + + +def test_cw_configuration_uses_one_write_and_independent_snapshot_readback() -> None: + request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) + service, driver = _cw_service( + [_snapshot(), _snapshot(frequency_hz=2_000_000.0)] + ) + + result = service.configure_cw(request) + + assert result.port_id == "rf_out" + assert result.frequency_hz == 2_000_000.0 + assert result.power_dbm is None + assert driver.requests == [request] + assert driver.calls == ["snapshot", "configure_cw", "snapshot"] + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_snapshot(output_enabled=True), "target RF output OFF"), + (_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_cw_configuration_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _cw_service([snapshot]) + + with pytest.raises(ConfigError, match=message): + service.configure_cw(RfCwRequest(port_id="rf_out", power_dbm=-20.0)) + + assert driver.requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_cw_configuration_checks_capability_access_and_static_profile_before_write() -> None: + request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) + missing_capability = _cw_descriptor("rf_source.idn", "rf_source.snapshot") + service, driver = _cw_service([_snapshot()], descriptor=missing_capability) + + with pytest.raises(ConfigError, match="rf_source.cw_configure"): + service.configure_cw(request) + assert driver.calls == [] + + read_only, read_only_driver = _cw_service( + [_snapshot()], + access="read_only", + ) + with pytest.raises(AccessDeniedError, match="rf_source.set_frequency"): + read_only.configure_cw(request) + assert read_only_driver.calls == [] + + frequency_disabled = _cw_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + frequency_configurable=False, + ) + profile_service, profile_driver = _cw_service( + [_snapshot()], + descriptor=frequency_disabled, + ) + with pytest.raises(ConfigError, match="configurable CW frequency"): + profile_service.configure_cw(request) + assert profile_driver.calls == [] + + range_service, range_driver = _cw_service([_snapshot()]) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_cw(RfCwRequest(port_id="rf_out", frequency_hz=1.0)) + assert range_driver.calls == [] + + +def test_cw_configuration_mismatch_or_write_failure_is_not_retried() -> None: + request = RfCwRequest(port_id="rf_out", power_dbm=-10.0) + mismatch_service, mismatch_driver = _cw_service( + [_snapshot(), _snapshot(power_dbm=-11.0)] + ) + + with pytest.raises(ConfigError, match="power_dbm readback does not match"): + mismatch_service.configure_cw(request) + + assert mismatch_driver.requests == [request] + assert mismatch_driver.calls == ["snapshot", "configure_cw", "snapshot"] + assert mismatch_service.session_state is not None + assert mismatch_service.session_state.health is SessionHealth.UNCERTAIN + + failed_service, failed_driver = _cw_service( + [_snapshot()], + raise_after_write=True, + ) + with pytest.raises(ConfigError, match="failed after transmission"): + failed_service.configure_cw(request) + + assert failed_driver.requests == [request] + assert failed_driver.calls == ["snapshot", "configure_cw"] + assert failed_service.session_state is not None + assert failed_service.session_state.health is SessionHealth.UNCERTAIN From efaf12ad95353eb532371b9896720e3c0a252b2a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:09:45 +0800 Subject: [PATCH 12/63] rf-source: add offline CW CLI --- ...21\351\207\214\347\250\213\347\242\221.md" | 4 +- ...67\346\272\220\350\256\276\350\256\241.md" | 9 ++-- .../WaveBench_CLI\345\275\242\346\200\201.md" | 7 +++- src/wavebench/cli.py | 19 +++++++++ src/wavebench/cli_parser.py | 14 +++++++ tests/test_rf_source_cli.py | 42 ++++++++++++++++++- 6 files changed, 88 insertions(+), 7 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 92944dc..20dc313 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -28,7 +28,7 @@ | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | -| M1 | 离线进行中 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | 已有 typed request/result、Service 和 fake 测试;CLI、run step 与正式离线验收仍待完成。 | +| M1 | 离线进行中 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | 已有 typed request/result、Service、CLI 和 fake 测试;run step、artifact 与正式离线验收仍待完成。 | | M2 | 未开始 | RF 输出安全事务 | `:OUTP` ON/OFF 及 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次 OFF recovery。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -68,7 +68,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 ## M1:OFF-only CW 配置 -Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查和 OFF-only Service 事务。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。CLI、run step、artifact 与完整离线验收仍待完成。 +Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务与对应 CLI。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。run step、artifact 与完整离线验收仍待完成。 DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index d49590b..f0145e7 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -293,11 +293,16 @@ DSG830 已完成 A1,因此 production descriptor 可通过 Core 的 status 路 ### M1–M4 目标 -后续写入命令和 run step 仍是设计合同,尚未进入当前 CLI 或 run schema: +M1 已注册以下 OFF-only CW CLI;它们仍受 descriptor capability、`read_write` access、CW profile 和 fresh snapshot preflight 共同门控。DSG830 的 production descriptor 没有 `rf_source.cw_configure`,因此这些命令不能控制已联网的 DSG830。 ```text wavebench rf-source set-frequency --port PORT_ID HZ wavebench rf-source set-power --port PORT_ID DBM +``` + +M1 的 run step、artifact,以及 M2–M4 的写入 CLI 和 run step 仍是设计合同,尚未进入当前 run schema: + +```text wavebench rf-source output --port PORT_ID on|off wavebench rf-source modulation configure-am ... wavebench rf-source modulation configure-fm ... @@ -311,8 +316,6 @@ wavebench rf-source sweep stop ... ``` ```text -rf_source.set_frequency -rf_source.set_power_dbm rf_source.output_disable rf_source.output_enable rf_source.modulation_configure diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 4e5d7b7..25f5b4d 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -84,13 +84,18 @@ wavebench --json source snapshot-v2 --config wavebench.toml ```bash wavebench rf-source idn --config wavebench.toml wavebench rf-source status --config wavebench.toml +wavebench rf-source set-frequency --port PORT_ID HZ --config wavebench.toml +wavebench rf-source set-power --port PORT_ID DBM --config wavebench.toml wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 `rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1,并在 production descriptor 中声明这两个只读 capability;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw -SCPI。RF M0 不提供频率、功率、RF 输出、调制、Pulse 或 Sweep 写入命令。 +SCPI。M1 已注册 `set-frequency` 与 `set-power` 的 OFF-only CW CLI;它们还要求 +`rf_source.cw_configure`、`read_write` 访问、已声明的 CW profile 和完整的只读 preflight。当前 DSG830 +production descriptor 不声明该写 capability,因此命令会在打开 transport 前拒绝。RF 输出、调制、Pulse 和 +Sweep 写入命令仍不存在。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 19bfbee..9a0bf0a 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -87,6 +87,7 @@ ScopeTraceData, ScopeTraceRef, ) +from .instruments.rf_source_extensions import RfCwRequest from .mcp_http import ( resolve_mcp_token, serve_mcp_http, @@ -1540,6 +1541,24 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "set-frequency": + result = service.configure_cw( + RfCwRequest(port_id=args.port, frequency_hz=args.frequency_hz) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "set-power": + result = service.configure_cw( + RfCwRequest(port_id=args.port, power_dbm=args.power_dbm) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.domain == "sweep": service = _load_sweep_service(args) if args.command == "discrete": diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index f293631..6b13e1d 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -653,6 +653,20 @@ def build_parser() -> argparse.ArgumentParser: help="Query a typed, read-only RF source snapshot", ) add_runtime_options(rf_source_status) + rf_source_set_frequency = rf_source_sub.add_parser( + "set-frequency", + help="Configure one RF port frequency while its RF output is OFF", + ) + rf_source_set_frequency.add_argument("--port", required=True) + rf_source_set_frequency.add_argument("frequency_hz", type=float) + add_runtime_options(rf_source_set_frequency) + rf_source_set_power = rf_source_sub.add_parser( + "set-power", + help="Configure one RF port dBm level while its RF output is OFF", + ) + rf_source_set_power.add_argument("--port", required=True) + rf_source_set_power.add_argument("power_dbm", type=float) + add_runtime_options(rf_source_set_power) source_sub = source_parser.add_subparsers(dest="command", required=True) diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index b121c2e..6a1757a 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -7,18 +7,30 @@ from unittest.mock import Mock, patch from wavebench.cli import _load_rf_source_service, build_parser, main +from wavebench.instruments.rf_source_extensions import RfCwRequest, RfCwResult -def test_rf_source_parser_accepts_read_only_commands_and_runtime_options() -> None: +def test_rf_source_parser_accepts_read_only_and_off_only_cw_commands() -> None: identity = build_parser().parse_args( ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] ) status = build_parser().parse_args(["rf-source", "status"]) + frequency = build_parser().parse_args( + ["rf-source", "set-frequency", "--port", "rf_out", "4000000"] + ) + power = build_parser().parse_args( + ["rf-source", "set-power", "--port", "rf_out", "-20"] + ) assert (identity.domain, identity.command) == ("rf-source", "idn") assert identity.config == "rf.toml" assert identity.resource == "TCPIP::rf::INSTR" assert (status.domain, status.command) == ("rf-source", "status") + assert (frequency.domain, frequency.command) == ("rf-source", "set-frequency") + assert frequency.port == "rf_out" + assert frequency.frequency_hz == 4_000_000.0 + assert (power.domain, power.command) == ("rf-source", "set-power") + assert power.power_dbm == -20.0 def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: @@ -51,6 +63,34 @@ def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: service.snapshot.assert_called_once_with() +def test_rf_source_cli_dispatches_each_off_only_cw_request() -> None: + service = Mock() + service.configure_cw.side_effect = [ + RfCwResult(port_id="rf_out", frequency_hz=4_000_000.0), + RfCwResult(port_id="rf_out", power_dbm=-20.0), + ] + + frequency_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + frequency_stdout + ): + assert main(["--json", "rf-source", "set-frequency", "--port", "rf_out", "4000000"]) == 0 + frequency_payload = json.loads(frequency_stdout.getvalue()) + assert frequency_payload["result"]["frequency_hz"] == 4_000_000.0 + + power_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + power_stdout + ): + assert main(["--json", "rf-source", "set-power", "--port", "rf_out", "-20"]) == 0 + power_payload = json.loads(power_stdout.getvalue()) + assert power_payload["result"]["power_dbm"] == -20.0 + assert service.configure_cw.call_args_list == [ + ((RfCwRequest(port_id="rf_out", frequency_hz=4_000_000.0),), {}), + ((RfCwRequest(port_id="rf_out", power_dbm=-20.0),), {}), + ] + + def test_rf_source_resource_override_does_not_touch_source_config() -> None: updated = object() config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) From 7a4bcb62c3fba5504c2b0cbfc8b0fd913e60edde Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:19:40 +0800 Subject: [PATCH 13/63] rf-source: wire offline CW run steps --- ...21\351\207\214\347\250\213\347\242\221.md" | 4 +- ...67\346\272\220\350\256\276\350\256\241.md" | 2 +- .../instruments/rf_source_extensions.py | 33 ++++++ src/wavebench/services/execution_intent.py | 2 + src/wavebench/services/rf_source_service.py | 35 +++++- src/wavebench/services/run_plan.py | 24 +++++ src/wavebench/services/run_safety.py | 2 + src/wavebench/services/run_service.py | 27 ++++- tests/test_rf_source_extensions.py | 34 ++++++ tests/test_rf_source_run.py | 102 +++++++++++++++++- tests/test_rf_source_service.py | 14 +++ 11 files changed, 272 insertions(+), 7 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 20dc313..6a86c03 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -28,7 +28,7 @@ | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | -| M1 | 离线进行中 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | 已有 typed request/result、Service、CLI 和 fake 测试;run step、artifact 与正式离线验收仍待完成。 | +| M1 | 离线进行中 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | 已有 typed request/result、Service、CLI、run step、artifact 和 fake 测试;production capability 仍关闭,完整离线验收仍待完成。 | | M2 | 未开始 | RF 输出安全事务 | `:OUTP` ON/OFF 及 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次 OFF recovery。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -68,7 +68,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 ## M1:OFF-only CW 配置 -Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务与对应 CLI。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。run step、artifact 与完整离线验收仍待完成。 +Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。完整离线验收仍待完成。 DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index f0145e7..0c65f37 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -300,7 +300,7 @@ wavebench rf-source set-frequency --port PORT_ID HZ wavebench rf-source set-power --port PORT_ID DBM ``` -M1 的 run step、artifact,以及 M2–M4 的写入 CLI 和 run step 仍是设计合同,尚未进入当前 run schema: +M1 的 run step 为 `rf_source.set_frequency` 与 `rf_source.set_power_dbm`,每个 step 都要求 `port_id` 与一个有限数值,并写入脱敏的 preflight/postcondition snapshot artifact。它们与 CLI 一样仍受 production capability 门禁;DSG830 的 production descriptor 不声明 `rf_source.cw_configure`。M2–M4 的写入 CLI 和 run step 仍是设计合同,尚未进入当前 run schema: ```text wavebench rf-source output --port PORT_ID on|off diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 99ff790..bce6bc1 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -546,6 +546,38 @@ def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[st } +def rf_source_cw_operation_artifact( + request: RfCwRequest, + result: RfCwResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, +) -> dict[str, object]: + """Build one redacted M1 CW operation artifact from typed evidence.""" + + if not isinstance(request, RfCwRequest): + raise TypeError("request must be RfCwRequest") + if not isinstance(result, RfCwResult): + raise TypeError("result must be RfCwResult") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + operation = ( + "rf_source.set_frequency" + if request.frequency_hz is not None + else "rf_source.set_power_dbm" + ) + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": operation, + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + } + + __all__ = [ "RF_SOURCE_CONTRACT_VERSION", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", @@ -577,6 +609,7 @@ def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[st "RfSweepProfile", "RfSweepState", "rf_source_canonical_json", + "rf_source_cw_operation_artifact", "rf_source_digest", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index e4d07cc..db4fce1 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -24,6 +24,8 @@ "sweep.frequency_response": "scope.capture_waveforms", "source.status": "source.status", "rf_source.status": "rf_source.snapshot", + "rf_source.set_frequency": "rf_source.set_frequency", + "rf_source.set_power_dbm": "rf_source.set_power_dbm", "source.arb_load": "source.arbitrary_upload", "source.set_freq": "source.set_frequency", "source.set_func": "source.set_function", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index c10bef6..5bc2e03 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -29,6 +29,7 @@ RfSourceDriver, RfSourceSnapshot, RfSweepState, + rf_source_cw_operation_artifact, ) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy @@ -39,6 +40,13 @@ from wavebench.transport.session import InstrumentSessionState, SessionHealth +@dataclass(frozen=True) +class _RfCwTransaction: + result: RfCwResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + + @dataclass class RfSourceService(SessionStateAliasMixin): """Open one configured RF source session for a bounded RF operation.""" @@ -133,6 +141,26 @@ def snapshot(self) -> RfSourceSnapshot: return rf_source.get_rf_snapshot() def configure_cw(self, request: RfCwRequest) -> RfCwResult: + return self._configure_cw_transaction(request).result + + def configure_cw_with_artifact( + self, + request: RfCwRequest, + ) -> tuple[RfCwResult, dict[str, object]]: + """Apply M1 CW once and retain typed pre/postcondition evidence.""" + + transaction = self._configure_cw_transaction(request) + return ( + transaction.result, + rf_source_cw_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + ), + ) + + def _configure_cw_transaction(self, request: RfCwRequest) -> _RfCwTransaction: """Apply one OFF-only CW field and independently confirm the result. M1 deliberately permits exactly one write per call. It does not retry a @@ -169,13 +197,18 @@ def configure_cw(self, request: RfCwRequest) -> RfCwResult: main_entered = True rf_source.configure_cw(request) postcondition_snapshot = rf_source.get_rf_snapshot() - return self._validate_cw_postcondition( + result = self._validate_cw_postcondition( request, postcondition_snapshot, port_profile, cw_profile, operation=operation, ) + return _RfCwTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + ) except BaseException: if main_entered and session_state.health is SessionHealth.HEALTHY: session_state.degrade( diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 03a8e13..fbf040e 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -22,6 +22,8 @@ "sweep.frequency_response", "source.status", "rf_source.status", + "rf_source.set_frequency", + "rf_source.set_power_dbm", "source.set_freq", "source.arb_load", "source.set_func", @@ -65,6 +67,8 @@ "source.set_vpp": ("value_vpp",), "source.set_duty": ("duty_percent",), "source.output": ("state",), + "rf_source.set_frequency": ("port_id", "frequency_hz"), + "rf_source.set_power_dbm": ("port_id", "power_dbm"), "source.basic_configure_v2": ("channel",), "source.output_enable_v2": ("channel",), "source.output_disable_v2": ("channel",), @@ -172,6 +176,8 @@ }, "source.status": {"channel", "on_failure"}, "rf_source.status": {"on_failure"}, + "rf_source.set_frequency": {"on_failure"}, + "rf_source.set_power_dbm": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, "source.arb_load": {"channel", "offset_v", "sample_rate_hz", "max_points", "byte_order", "output_on", "on_failure"}, "source.set_func": {"channel", "on_failure"}, @@ -231,6 +237,8 @@ "sweep.frequency_response": "Sweep a source through discrete frequencies, capture reference and response channels in one acquisition per point, and write a Bode response CSV.", "source.status": "Read signal-generator channel state without changing output.", "rf_source.status": "Read a typed RF-source snapshot without changing output.", + "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", + "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", "source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.", "source.set_freq": "Set fixed source frequency in Hz; config may force FIX mode first.", "source.set_func": "Set source waveform function, for example SIN or SQU.", @@ -622,6 +630,15 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["state"] = state elif kind == "source.set_freq": fields["frequency_hz"] = _positive_float(fields["frequency_hz"], f"{prefix}.frequency_hz") + elif kind == "rf_source.set_frequency": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + fields["frequency_hz"] = _positive_float( + fields["frequency_hz"], + f"{prefix}.frequency_hz", + ) + elif kind == "rf_source.set_power_dbm": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") elif kind == "source.arb_load": fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file") fields["frequency_hz"] = _positive_float(fields["frequency_hz"], f"{prefix}.frequency_hz") @@ -1132,6 +1149,13 @@ def _finite_float(value: Any, name: str) -> float: return result +def _rf_port_id(value: Any, name: str) -> str: + token = _non_empty_str(value, name) + if _SOURCE_STORAGE_TOKEN.fullmatch(token) is None: + raise ConfigError(f"{name} must be a short safe RF port ID") + return token + + def _table(raw: Any, name: str) -> dict[str, Any]: if raw is None: return {} diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index f3a81fa..6de3d5b 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -22,6 +22,8 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "sweep.frequency_response", "source.status", "rf_source.status", + "rf_source.set_frequency", + "rf_source.set_power_dbm", "source.set_freq", "source.arb_load", "source.set_func", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index d305f8e..8c4888f 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -22,7 +22,10 @@ ) from wavebench.instruments.capabilities import require_capabilities from wavebench.instruments.registry import resolve_instrument_descriptor -from wavebench.instruments.rf_source_extensions import rf_source_snapshot_operation_artifact +from wavebench.instruments.rf_source_extensions import ( + RfCwRequest, + rf_source_snapshot_operation_artifact, +) from wavebench.instruments.source_extensions import ( PatchAction, PatchValue, @@ -419,6 +422,8 @@ def add_source_output_gate_capability() -> None: add("source", "source.status") elif step.kind == "rf_source.status": add("rf_source", "rf_source.snapshot") + elif step.kind in {"rf_source.set_frequency", "rf_source.set_power_dbm"}: + add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") elif step.kind == "source.set_freq": add("source", "source.set_frequency") source = self.config.source @@ -1173,6 +1178,26 @@ def _run_step( elif step.kind == "rf_source.status": snapshot = self._rf_source_service(services=services).snapshot() artifact = {"rf_source_operation": rf_source_snapshot_operation_artifact(snapshot)} + elif step.kind == "rf_source.set_frequency": + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_cw_with_artifact( + RfCwRequest( + port_id=step.fields["port_id"], + frequency_hz=step.fields["frequency_hz"], + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.set_power_dbm": + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_cw_with_artifact( + RfCwRequest( + port_id=step.fields["port_id"], + power_dbm=step.fields["power_dbm"], + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind == "source.basic_configure_v2": fields = step.fields _, source_operation = self._source_service(services=services).configure_basic_v2( diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index d0aa049..4262b05 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -38,6 +38,7 @@ RfSweepState, rf_source_snapshot_document, rf_source_snapshot_operation_artifact, + rf_source_cw_operation_artifact, ) @@ -235,6 +236,39 @@ def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None } +def test_rf_cw_operation_artifact_uses_typed_pre_and_postcondition_evidence() -> None: + request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) + result = RfCwResult(port_id="rf_out", frequency_hz=2_000_000.0) + preflight = snapshot() + postcondition = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(2_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + artifact = rf_source_cw_operation_artifact( + request, + result, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + + assert artifact["schema"] == RF_SOURCE_OPERATION_ARTIFACT_SCHEMA + assert artifact["operation"] == "rf_source.set_frequency" + assert artifact["request"]["frequency_hz"] == 2_000_000.0 + assert artifact["result"]["frequency_hz"] == 2_000_000.0 + assert "resource" not in str(artifact) + + def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: value = descriptor() diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index ac209d4..83fcab8 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -20,6 +20,8 @@ ) from wavebench.errors import ConfigError from wavebench.instruments.rf_source_extensions import ( + RfCwRequest, + RfCwResult, RfModulationState, RfObserved, RfPortSnapshot, @@ -27,6 +29,7 @@ RfPulseState, RfSourceSnapshot, RfSweepState, + rf_source_cw_operation_artifact, rf_source_snapshot_operation_artifact, ) from wavebench.logging import CommandLogger @@ -36,7 +39,7 @@ from wavebench.services.run_service import RunInstrumentServices, RunService -def _config(directory: str) -> WaveBenchConfig: +def _config(directory: str, *, access: str = "read_only") -> WaveBenchConfig: return WaveBenchConfig( connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), scope=ScopeConfig("rtm2032", None, 1, False, True), @@ -55,7 +58,7 @@ def _config(directory: str) -> WaveBenchConfig: rf_source=RfSourceConfig( driver="example.rf1", resource="TCPIP::rf::INSTR", - access="read_only", + access=access, # type: ignore[arg-type] ), ) @@ -66,6 +69,15 @@ def _plan(directory: str): return load_run_plan(path) +def _cw_plan(directory: str, *, kind: str, field: str, value: float): + path = Path(directory) / "plan.toml" + path.write_text( + f'[[steps]]\nkind = "{kind}"\nport_id = "rf_out"\n{field} = {value}\n', + encoding="utf-8", + ) + return load_run_plan(path) + + def _snapshot() -> RfSourceSnapshot: return RfSourceSnapshot( ports=( @@ -213,3 +225,89 @@ def test_rf_source_operation_artifacts_are_validated_in_a_separate_root_namespac {"schema": "wavebench.rf_source.operation.v1", "operation": "source.status"} ], ) + + +def test_rf_source_cw_steps_require_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + plan = _cw_plan( + directory, + kind="rf_source.set_frequency", + field="frequency_hz", + value=2_000_000.0, + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.cw_configure"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_cw_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _cw_plan( + directory, + kind="rf_source.set_power_dbm", + field="power_dbm", + value=-10.0, + ) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.set_power_dbm" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == {"port_id": "rf_out", "power_dbm": -10.0} + + preflight = _snapshot() + postcondition = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-10.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + result_value = RfCwResult(port_id="rf_out", power_dbm=-10.0) + artifact = rf_source_cw_operation_artifact( + request=RfCwRequest(port_id="rf_out", power_dbm=-10.0), + result=result_value, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + rf_service = SimpleNamespace( + configure_cw_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_cw_with_artifact.assert_called_once() + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] diff --git a/tests/test_rf_source_service.py b/tests/test_rf_source_service.py index 4ab34d2..32c83af 100644 --- a/tests/test_rf_source_service.py +++ b/tests/test_rf_source_service.py @@ -22,6 +22,7 @@ RfObserved, RfCwProfile, RfCwRequest, + RfCwResult, RfFeature, RfFeatureCapability, RfFeatureDirection, @@ -388,3 +389,16 @@ def test_cw_configuration_mismatch_or_write_failure_is_not_retried() -> None: assert failed_driver.calls == ["snapshot", "configure_cw"] assert failed_service.session_state is not None assert failed_service.session_state.health is SessionHealth.UNCERTAIN + + +def test_cw_configuration_with_artifact_preserves_pre_and_postcondition_snapshots() -> None: + request = RfCwRequest(port_id="rf_out", power_dbm=-10.0) + service, driver = _cw_service([_snapshot(), _snapshot(power_dbm=-10.0)]) + + result, artifact = service.configure_cw_with_artifact(request) + + assert result == RfCwResult(port_id="rf_out", power_dbm=-10.0) + assert driver.requests == [request] + assert artifact["operation"] == "rf_source.set_power_dbm" + assert artifact["preflight_snapshot"]["ports"][0]["power_dbm"]["value"] == -30.0 + assert artifact["postcondition_snapshot"]["ports"][0]["power_dbm"]["value"] == -10.0 From ace29dbf20ecde09aad5f93f1b414b7009841508 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:55:22 +0800 Subject: [PATCH 14/63] rf-source: add guarded offline output transaction --- .../instruments/rf_source_capabilities.py | 23 + .../instruments/rf_source_extensions.py | 62 +++ src/wavebench/services/operation_specs.py | 20 + src/wavebench/services/rf_source_service.py | 345 +++++++++++++- tests/test_operation_specs.py | 16 + tests/test_rf_source_extensions.py | 77 +++ tests/test_rf_source_service.py | 442 +++++++++++++++++- tests/test_scope_extension_registry.py | 3 +- 8 files changed, 983 insertions(+), 5 deletions(-) diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 5583b21..af11c5b 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -18,6 +18,7 @@ RfCwProfile, RfFeature, RfFeatureDirection, + RfOutputProfile, RfSourceDescriptorExtensions, ) @@ -27,6 +28,7 @@ "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), "rf_source.cw_configure": ("configure_cw",), + "rf_source.output": ("set_rf_output",), } ) @@ -64,6 +66,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No raise ConfigError("rf_source descriptors require the rf_source.idn capability") if "rf_source.cw_configure" in rf_capabilities: _validate_cw_configure_feature(extensions) + if "rf_source.output" in rf_capabilities: + _validate_output_feature(extensions) _validate_rf_source_version_range(descriptor) if driver is not None: for capability in rf_capabilities: @@ -93,6 +97,25 @@ def _validate_cw_configure_feature(extensions: RfSourceDescriptorExtensions) -> ) +def _validate_output_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.OUTPUT), + None, + ) + if ( + feature is None + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + ): + raise ConfigError( + "rf_source.output requires matching RF output ENABLE and DISABLE directions" + ) + if not isinstance(feature.profile, RfOutputProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.output requires an RF output profile") + if not feature.profile.output_readable: + raise ConfigError("rf_source.output requires readable RF output state") + + def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index bce6bc1..39deb00 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -461,12 +461,40 @@ def __post_init__(self) -> None: _require_finite(self.power_dbm, "RF CW result power_dbm") +@dataclass(frozen=True, slots=True) +class RfOutputRequest: + """One explicit RF output state request for one descriptor-defined port.""" + + port_id: str + enabled: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF output request port_id") + _require_bool(self.enabled, "RF output request enabled") + + +@dataclass(frozen=True, slots=True) +class RfOutputResult: + """An RF output target confirmed by a fresh postcondition snapshot.""" + + port_id: str + enabled: bool + write_completed: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF output result port_id") + _require_bool(self.enabled, "RF output result enabled") + _require_bool(self.write_completed, "RF output result write_completed") + + @runtime_checkable class RfSourceDriver(InstrumentDriver, Protocol): def get_rf_snapshot(self) -> RfSourceSnapshot: ... def configure_cw(self, request: RfCwRequest) -> None: ... + def set_rf_output(self, request: RfOutputRequest) -> None: ... + def _require_observed_number( observed: object, @@ -578,6 +606,37 @@ def rf_source_cw_operation_artifact( } +def rf_source_output_operation_artifact( + request: RfOutputRequest, + result: RfOutputResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, +) -> dict[str, object]: + """Build one redacted M2 RF-output artifact from typed evidence.""" + + if not isinstance(request, RfOutputRequest): + raise TypeError("request must be RfOutputRequest") + if not isinstance(result, RfOutputResult): + raise TypeError("result must be RfOutputResult") + if request.port_id != result.port_id or request.enabled is not result.enabled: + raise ValueError("RF output request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": ( + "rf_source.output_enable" if request.enabled else "rf_source.output_disable" + ), + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + } + + __all__ = [ "RF_SOURCE_CONTRACT_VERSION", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", @@ -596,6 +655,8 @@ def rf_source_cw_operation_artifact( "RfObserved", "RfOutputPortProfile", "RfOutputProfile", + "RfOutputRequest", + "RfOutputResult", "RfPortSnapshot", "RfProtectionConditionPolicy", "RfProtectionStatus", @@ -613,5 +674,6 @@ def rf_source_cw_operation_artifact( "rf_source_digest", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", + "rf_source_output_operation_artifact", "rf_source_to_data", ] diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index c85b5d2..bd834cf 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1057,6 +1057,26 @@ def _spec( risk_flags=("rf_output_must_be_off", "signal_level", "state_drift"), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.output_enable", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.output"), + effect="write", + changed_fields=("rf_source.port.output_enabled",), + restore_coverage="none", + risk_flags=("dangerous_output", "rf_output_enable", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.output_disable", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.output"), + effect="write", + changed_fields=("rf_source.port.output_enabled",), + restore_coverage="none", + risk_flags=("safe_output_disable", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec("power.idn", "power", required_capabilities=("power.idn",), effect="observe"), _spec("power.status", "power", required_capabilities=("power.status",), effect="stateful_read"), _spec("power.measurement", "power", required_capabilities=("power.measurement",), effect="stateful_read"), diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 5bc2e03..607102d 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -1,4 +1,4 @@ -"""M0 read-only and M1 OFF-only CW service for RF signal sources.""" +"""Read-only, OFF-only CW, and guarded RF-output service for RF sources.""" from __future__ import annotations @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any, cast -from wavebench.config import RfSourceConfig, WaveBenchConfig +from wavebench.config import RfPortSafetyConfig, RfSourceConfig, WaveBenchConfig from wavebench.errors import ConfigError from wavebench.instruments.api import InstrumentDescriptor from wavebench.instruments.capabilities import require_capabilities @@ -22,6 +22,9 @@ RfFeatureDirection, RfModulationState, RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfOutputResult, RfPortSnapshot, RfProtectionStatus, RfPulseState, @@ -30,6 +33,7 @@ RfSourceSnapshot, RfSweepState, rf_source_cw_operation_artifact, + rf_source_output_operation_artifact, ) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy @@ -37,7 +41,12 @@ from wavebench.services.resource_lease import ResourceLease from wavebench.services.session_alias import SessionStateAliasMixin from wavebench.transport.base import InstrumentTransport -from wavebench.transport.session import InstrumentSessionState, SessionHealth +from wavebench.transport.session import ( + InstrumentSessionState, + SessionHealth, + SessionPurpose, + SessionTransactionCoordinator, +) @dataclass(frozen=True) @@ -47,6 +56,13 @@ class _RfCwTransaction: postcondition_snapshot: RfSourceSnapshot +@dataclass(frozen=True) +class _RfOutputTransaction: + result: RfOutputResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + + @dataclass class RfSourceService(SessionStateAliasMixin): """Open one configured RF source session for a bounded RF operation.""" @@ -217,6 +233,329 @@ def _configure_cw_transaction(self, request: RfCwRequest) -> _RfCwTransaction: ) raise + def set_output(self, request: RfOutputRequest) -> RfOutputResult: + return self._set_output_transaction(request).result + + def set_output_with_artifact( + self, + request: RfOutputRequest, + ) -> tuple[RfOutputResult, dict[str, object]]: + """Apply M2 RF output once and retain typed pre/postcondition evidence.""" + + transaction = self._set_output_transaction(request) + return ( + transaction.result, + rf_source_output_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + ), + ) + + def _set_output_transaction(self, request: RfOutputRequest) -> _RfOutputTransaction: + """Execute one per-port M2 output transaction with bounded OFF recovery.""" + + if not isinstance(request, RfOutputRequest): + raise ConfigError("rf_source output control requires RfOutputRequest") + operation = "rf_source.output_enable" if request.enabled else "rf_source.output_disable" + self._require(operation, "rf_source.snapshot", "rf_source.output") + port_profile, output_profile, extensions = self._validate_output_descriptor( + request, + operation, + ) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + if request.enabled: + current_enabled = self._validate_output_enable_snapshot( + request, + preflight_snapshot, + port_profile, + output_profile, + extensions, + operation=operation, + ) + else: + current_enabled = self._output_state_if_observed( + self._snapshot_port( + preflight_snapshot, + request.port_id, + operation=operation, + ) + ) + if current_enabled is request.enabled: + return _RfOutputTransaction( + result=RfOutputResult( + port_id=request.port_id, + enabled=request.enabled, + write_completed=False, + ), + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=preflight_snapshot, + ) + + main_entered = False + try: + main_entered = True + rf_source.set_rf_output(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + if request.enabled: + postcondition_enabled = self._validate_output_enable_snapshot( + request, + postcondition_snapshot, + port_profile, + output_profile, + extensions, + operation=operation, + ) + if postcondition_enabled is not True: + raise ConfigError( + f"{operation} postcondition reports RF output OFF or unknown" + ) + else: + self._validate_output_disable_snapshot( + request, + postcondition_snapshot, + operation=operation, + ) + return _RfOutputTransaction( + result=RfOutputResult( + port_id=request.port_id, + enabled=request.enabled, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + ) + except BaseException as exc: + if main_entered: + if request.enabled: + self._degrade_output_session_uncertain(session_state) + recovery = self._recover_rf_output_off( + rf_source, + request.port_id, + operation=operation, + ) + try: + setattr(exc, "rf_source_recovery", recovery) + except Exception: + pass + else: + self._degrade_output_session_poisoned(session_state) + raise + + def _validate_output_descriptor( + self, + request: RfOutputRequest, + operation: str, + ) -> tuple[RfOutputPortProfile, RfOutputProfile, RfSourceDescriptorExtensions]: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + port_profile = next( + (port for port in extensions.topology.ports if port.port_id == request.port_id), + None, + ) + if port_profile is None: + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.OUTPUT), + None, + ) + if ( + feature is None + or request.port_id not in feature.port_ids + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + or not isinstance(feature.profile, RfOutputProfile) + or not feature.profile.output_readable + ): + raise ConfigError(f"{operation} requires a readable output profile for the target port") + return port_profile, feature.profile, extensions + + def _validate_output_enable_snapshot( + self, + request: RfOutputRequest, + snapshot: RfSourceSnapshot, + port_profile: RfOutputPortProfile, + output_profile: RfOutputProfile, + extensions: RfSourceDescriptorExtensions, + *, + operation: str, + ) -> bool: + del output_profile + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + safety_port = self._output_safety_port(request.port_id, operation=operation) + frequency_hz = self._observed_value( + port.frequency_hz, + f"{operation} requires a readable RF frequency", + ) + power_dbm = self._observed_value( + port.power_dbm, + f"{operation} requires a readable RF power", + ) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(frequency_hz, (int, float)) or isinstance(frequency_hz, bool): + raise ConfigError(f"{operation} requires a valid RF frequency") + if not isinstance(power_dbm, (int, float)) or isinstance(power_dbm, bool): + raise ConfigError(f"{operation} requires a valid RF power") + if not isinstance(output_enabled, bool): + raise ConfigError(f"{operation} requires a valid RF output state") + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if not ( + port_profile.frequency_min_hz <= frequency_hz <= port_profile.frequency_max_hz + and safety_port.minimum_frequency_hz + <= frequency_hz + <= safety_port.maximum_frequency_hz + ): + raise ConfigError(f"{operation} requires RF frequency within descriptor and safety ranges") + if not ( + port_profile.power_min_dbm <= power_dbm <= port_profile.power_max_dbm + and power_dbm <= safety_port.maximum_power_dbm + ): + raise ConfigError(f"{operation} requires RF power within descriptor and safety ranges") + if safety_port.actual_termination_ohm != port_profile.power_reference_impedance_ohm: + raise ConfigError(f"{operation} requires actual termination to match RF power reference") + policies = {item.code: item for item in extensions.protection_conditions} + unknown = sorted(set(protection.active_codes) - set(policies)) + if unknown: + raise ConfigError(f"{operation} rejects an unknown active protection condition") + if any(policies[code].blocks_output_enable for code in protection.active_codes): + raise ConfigError(f"{operation} rejects an active blocking protection condition") + return output_enabled + + def _validate_output_disable_snapshot( + self, + request: RfOutputRequest, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> None: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} postcondition reports RF output ON or unknown") + + def _output_safety_port(self, port_id: str, *, operation: str) -> RfPortSafetyConfig: + safety_port = next( + (item for item in self._rf_source_config().safety_ports if item.port_id == port_id), + None, + ) + if safety_port is None: + raise ConfigError(f"{operation} requires complete safety configuration for the target RF port") + return safety_port + + @staticmethod + def _output_state_if_observed(port: RfPortSnapshot) -> bool | None: + if port.output_enabled.availability is not RfAvailability.VALUE: + return None + value = port.output_enabled.value + return value if isinstance(value, bool) else None + + def _recover_rf_output_off( + self, + rf_source: RfSourceDriver, + port_id: str, + *, + operation: str, + ) -> dict[str, str]: + """Attempt exactly one bounded, same-port OFF recovery after a failed ON.""" + + session_state = self.session_state + if session_state is None or session_state.health in { + SessionHealth.POISONED, + SessionHealth.CLOSED, + }: + return {"status": "not_attempted", "reason": "session_unavailable"} + coordinator = SessionTransactionCoordinator(session_state) + fields = ("rf_source.port.output_enabled",) + timeout_ms = self.config.connection.timeout_ms + request = RfOutputRequest(port_id=port_id, enabled=False) + try: + with coordinator.authorize( + operation_id="rf_source.output_recovery", + purpose=SessionPurpose.RECOVERY, + allowed_io={"write"}, + fields=fields, + timeout_ms=timeout_ms, + max_steps=1, + ): + rf_source.set_rf_output(request) + except BaseException: + return {"status": "off_failed", "session_health": session_state.health.value} + if session_state.health in {SessionHealth.POISONED, SessionHealth.CLOSED}: + return {"status": "off_sent_unverified", "reason": "session_unavailable"} + try: + with coordinator.authorize( + operation_id="rf_source.output_recovery_verify", + purpose=SessionPurpose.RECOVERY, + allowed_io={"query"}, + fields=fields, + timeout_ms=timeout_ms, + max_steps=16, + ): + snapshot = rf_source.get_rf_snapshot() + self._validate_output_disable_snapshot( + request, + snapshot, + operation=operation, + ) + except BaseException: + return {"status": "off_sent_unverified", "session_health": session_state.health.value} + return {"status": "off_verified", "session_health": session_state.health.value} + + @staticmethod + def _degrade_output_session_uncertain(session_state: InstrumentSessionState) -> None: + if session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_output_enable_unverified", + ) + + @staticmethod + def _degrade_output_session_poisoned(session_state: InstrumentSessionState) -> None: + if session_state.health not in {SessionHealth.POISONED, SessionHealth.CLOSED}: + session_state.degrade( + SessionHealth.POISONED, + reason="rf_output_disable_unverified", + ) + def _validate_cw_descriptor( self, request: RfCwRequest, diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index a1b47b9..d4f70f1 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -79,6 +79,22 @@ def test_rf_source_m1_cw_specs_require_snapshot_and_cw_capability() -> None: assert power.changed_fields == ("rf_source.port.power_dbm",) +def test_rf_source_m2_output_specs_require_snapshot_and_output_capability() -> None: + enable = require_operation_spec("rf_source.output_enable") + disable = require_operation_spec("rf_source.output_disable") + + assert enable.instrument_kind == "rf_source" + assert enable.required_capabilities == ("rf_source.snapshot", "rf_source.output") + assert enable.effect == "write" + assert enable.changed_fields == ("rf_source.port.output_enabled",) + assert enable.restore_coverage == "none" + assert "dangerous_output" in enable.risk_flags + assert enable.safe_alternatives == ("rf_source.snapshot",) + assert disable.required_capabilities == enable.required_capabilities + assert disable.effect == "write" + assert "safe_output_disable" in disable.risk_flags + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 4262b05..80086ef 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -27,6 +27,8 @@ RfObserved, RfOutputPortProfile, RfOutputProfile, + RfOutputRequest, + RfOutputResult, RfPortSnapshot, RfProtectionConditionPolicy, RfProtectionStatus, @@ -39,6 +41,7 @@ rf_source_snapshot_document, rf_source_snapshot_operation_artifact, rf_source_cw_operation_artifact, + rf_source_output_operation_artifact, ) @@ -55,6 +58,9 @@ def get_rf_snapshot(self) -> RfSourceSnapshot: def configure_cw(self, request: RfCwRequest) -> None: del request + def set_rf_output(self, request: RfOutputRequest) -> None: + del request + def topology() -> RfSourceTopology: return RfSourceTopology( @@ -115,6 +121,25 @@ def cw_extensions() -> RfSourceDescriptorExtensions: ) +def output_extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + ) + + def descriptor(**changes: object) -> InstrumentDescriptor: value = InstrumentDescriptor( driver_id="example.rf1", @@ -222,6 +247,15 @@ def test_rf_cw_request_and_result_require_one_finite_field() -> None: RfCwResult(port_id="rf_out", frequency_hz=float("nan")) +def test_rf_output_request_and_result_require_explicit_boolean_state() -> None: + assert RfOutputRequest(port_id="rf_out", enabled=True).enabled is True + assert RfOutputResult(port_id="rf_out", enabled=False, write_completed=False).write_completed is False + with pytest.raises(ValueError, match="boolean"): + RfOutputRequest(port_id="rf_out", enabled=1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="boolean"): + RfOutputResult(port_id="rf_out", enabled=False, write_completed=0) # type: ignore[arg-type] + + def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None: value = snapshot() document = rf_source_snapshot_document(value) @@ -269,6 +303,34 @@ def test_rf_cw_operation_artifact_uses_typed_pre_and_postcondition_evidence() -> assert "resource" not in str(artifact) +def test_rf_output_operation_artifact_uses_typed_pre_and_postcondition_evidence() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + result = RfOutputResult(port_id="rf_out", enabled=True, write_completed=True) + preflight = snapshot() + postcondition = replace( + preflight, + ports=(replace(preflight.ports[0], output_enabled=RfObserved.value_of(True)),), + ) + + artifact = rf_source_output_operation_artifact( + request, + result, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + + assert artifact["operation"] == "rf_source.output_enable" + assert artifact["result"]["write_completed"] is True + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + with pytest.raises(ValueError, match="same target"): + rf_source_output_operation_artifact( + request, + RfOutputResult(port_id="rf_out", enabled=False, write_completed=True), + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + + def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: value = descriptor() @@ -276,6 +338,7 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), "rf_source.cw_configure": ("configure_cw",), + "rf_source.output": ("set_rf_output",), } assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( RF_SOURCE_CAPABILITY_METHODS @@ -304,6 +367,20 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: ) validate_rf_source_descriptor(cw_descriptor) validate_declared_capabilities(cw_descriptor, RfDriver()) + with pytest.raises(ConfigError, match="output ENABLE and DISABLE"): + validate_rf_source_descriptor( + replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + ) + ) + output_descriptor = replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + rf_source_extensions=output_extensions(), + ) + validate_rf_source_descriptor(output_descriptor) + validate_declared_capabilities(output_descriptor, RfDriver()) def test_rf_source_kind_requires_extensions_and_uses_append_only_field() -> None: diff --git a/tests/test_rf_source_service.py b/tests/test_rf_source_service.py index 32c83af..0a9e86e 100644 --- a/tests/test_rf_source_service.py +++ b/tests/test_rf_source_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -10,6 +11,7 @@ AutoscaleConfig, ConnectionConfig, OutputConfig, + RfPortSafetyConfig, RfSourceConfig, ScopeConfig, WaveBenchConfig, @@ -18,6 +20,7 @@ from wavebench.errors import AccessDeniedError, ConfigError from wavebench.instruments.rf_source_extensions import ( RF_SOURCE_CONTRACT_VERSION, + RfAvailability, RfModulationState, RfObserved, RfCwProfile, @@ -27,10 +30,14 @@ RfFeatureCapability, RfFeatureDirection, RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfOutputResult, RfPortSnapshot, RfProtectionConditionPolicy, RfProtectionStatus, RfPulseState, + RfReasonCode, RfSourceDescriptorExtensions, RfSourceSnapshot, RfSourceTopology, @@ -38,6 +45,8 @@ ) from wavebench.logging import CommandLogger from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState, SessionHealth @@ -89,6 +98,88 @@ def configure_cw(self, request: RfCwRequest) -> None: raise ConfigError("fake CW write failed after transmission") +class FakeRfOutputDriver: + def __init__( + self, + snapshots: list[RfSourceSnapshot], + *, + raise_after_enable: bool = False, + raise_after_disable: bool = False, + ) -> None: + self.snapshots = list(snapshots) + self.raise_after_enable = raise_after_enable + self.raise_after_disable = raise_after_disable + self.calls: list[str] = [] + self.output_requests: list[RfOutputRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.snapshots: + raise AssertionError("unexpected RF snapshot query") + return self.snapshots.pop(0) + + def set_rf_output(self, request: RfOutputRequest) -> None: + self.calls.append("set_rf_output") + self.output_requests.append(request) + if request.enabled and self.raise_after_enable: + raise ConfigError("fake RF ON failed after transmission") + if not request.enabled and self.raise_after_disable: + raise ConfigError("fake RF OFF failed after transmission") + + +class _GuardedOutputTransport: + resource = "fake-rf-output" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del replay + self.queries.append(command) + return "ok" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + return None + + +class GuardedRfOutputDriver: + def __init__(self, snapshots: list[RfSourceSnapshot], state: InstrumentSessionState) -> None: + self.inner = _GuardedOutputTransport() + self.transport = GuardedAuditedTransport(self.inner, session_state=state) + self.snapshots = list(snapshots) + self.output_requests: list[RfOutputRequest] = [] + + def close(self) -> None: + self.transport.close() + + def idn(self) -> str: + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.transport.query("RF:SNAPSHOT?") + if not self.snapshots: + raise AssertionError("unexpected RF snapshot query") + return self.snapshots.pop(0) + + def set_rf_output(self, request: RfOutputRequest) -> None: + self.output_requests.append(request) + self.transport.write("RF:OUTPUT ON" if request.enabled else "RF:OUTPUT OFF") + + def _snapshot( *, frequency_hz: float = 1_000_000.0, @@ -115,7 +206,11 @@ def _snapshot( ) -def _config(*, access: str = "read_only") -> WaveBenchConfig: +def _config( + *, + access: str = "read_only", + safety_ports: tuple[RfPortSafetyConfig, ...] = (), +) -> WaveBenchConfig: return WaveBenchConfig( connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), scope=ScopeConfig("rtm2032", None, 1, False, True), @@ -127,6 +222,7 @@ def _config(*, access: str = "read_only") -> WaveBenchConfig: driver="example.rf1", resource="TCPIP::rf::INSTR", access=access, # type: ignore[arg-type] + safety_ports=safety_ports, ), ) @@ -200,6 +296,90 @@ def _cw_service( return service, driver +def _output_descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf1", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", True), + RfProtectionConditionPolicy("status_notice", False), + ), + ), + ) + + +def _output_safety_port(*, termination_ohm: float = 50.0) -> RfPortSafetyConfig: + return RfPortSafetyConfig( + port_id="rf_out", + minimum_frequency_hz=9_000.0, + maximum_frequency_hz=3_000_000_000.0, + maximum_power_dbm=0.0, + actual_termination_ohm=termination_ohm, + ) + + +def _output_service( + snapshots: list[RfSourceSnapshot], + *, + access: str = "read_write", + safety_ports: tuple[RfPortSafetyConfig, ...] | None = None, + descriptor: SimpleNamespace | None = None, + raise_after_enable: bool = False, + raise_after_disable: bool = False, +) -> tuple[RfSourceService, FakeRfOutputDriver]: + driver = FakeRfOutputDriver( + snapshots, + raise_after_enable=raise_after_enable, + raise_after_disable=raise_after_disable, + ) + service = RfSourceService( + config=_config( + access=access, + safety_ports=( + (_output_safety_port(),) + if safety_ports is None + else safety_ports + ), + ), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _output_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + def test_idn_and_snapshot_are_one_shot_read_only_operations(monkeypatch: pytest.MonkeyPatch) -> None: driver = FakeRfDriver() service = RfSourceService( @@ -402,3 +582,263 @@ def test_cw_configuration_with_artifact_preserves_pre_and_postcondition_snapshot assert artifact["operation"] == "rf_source.set_power_dbm" assert artifact["preflight_snapshot"]["ports"][0]["power_dbm"]["value"] == -30.0 assert artifact["postcondition_snapshot"]["ports"][0]["power_dbm"]["value"] == -10.0 + + +def test_rf_output_enable_uses_one_write_and_independent_snapshot_readback() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + service, driver = _output_service([_snapshot(), _snapshot(output_enabled=True)]) + + result, artifact = service.set_output_with_artifact(request) + + assert result == RfOutputResult(port_id="rf_out", enabled=True, write_completed=True) + assert driver.output_requests == [request] + assert driver.calls == ["snapshot", "set_rf_output", "snapshot"] + assert artifact["operation"] == "rf_source.output_enable" + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + + +@pytest.mark.parametrize( + ("snapshot", "safety_ports", "message"), + ( + (_snapshot(), (), "complete safety configuration"), + (_snapshot(), (_output_safety_port(termination_ohm=75.0),), "actual termination"), + (_snapshot(power_dbm=1.0), None, "RF power within descriptor and safety ranges"), + (_snapshot(modulation=RfModulationState.ENABLED), None, "modulation disabled"), + (_snapshot(pulse=RfPulseState.ENABLED), None, "Pulse disabled"), + (_snapshot(sweep=RfSweepState.ENABLED), None, "Sweep disabled"), + (_snapshot(protection_codes=("overtemperature",)), None, "blocking protection"), + (_snapshot(protection_codes=("unknown_code",)), None, "unknown active protection"), + ( + replace( + _snapshot(), + ports=( + replace( + _snapshot().ports[0], + frequency_hz=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ), + ), + ), + None, + "readable RF frequency", + ), + ( + replace( + _snapshot(), + protection=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ), + None, + "readable protection", + ), + ), +) +def test_rf_output_enable_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + safety_ports: tuple[RfPortSafetyConfig, ...] | None, + message: str, +) -> None: + service, driver = _output_service([snapshot], safety_ports=safety_ports) + + with pytest.raises(ConfigError, match=message): + service.set_output(RfOutputRequest(port_id="rf_out", enabled=True)) + + assert driver.output_requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_rf_output_enable_checks_access_capability_and_static_profile_before_write() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + missing_capability = _output_descriptor("rf_source.idn", "rf_source.snapshot") + service, driver = _output_service([_snapshot()], descriptor=missing_capability) + + with pytest.raises(ConfigError, match="rf_source.output"): + service.set_output(request) + assert driver.calls == [] + + read_only, read_only_driver = _output_service([_snapshot()], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.output_enable"): + read_only.set_output(request) + assert read_only_driver.calls == [] + + incomplete_profile = SimpleNamespace( + driver_id="example.rf1", + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + ), + ) + profile_service, profile_driver = _output_service( + [_snapshot()], + descriptor=incomplete_profile, + ) + with pytest.raises(ConfigError, match="readable output profile"): + profile_service.set_output(request) + assert profile_driver.calls == [] + + +def test_rf_output_is_idempotent_without_a_write() -> None: + on_service, on_driver = _output_service([_snapshot(output_enabled=True)]) + on_result = on_service.set_output(RfOutputRequest(port_id="rf_out", enabled=True)) + + assert on_result == RfOutputResult(port_id="rf_out", enabled=True, write_completed=False) + assert on_driver.output_requests == [] + assert on_driver.calls == ["snapshot"] + + off_service, off_driver = _output_service( + [_snapshot(output_enabled=False)], + safety_ports=(), + ) + off_result = off_service.set_output(RfOutputRequest(port_id="rf_out", enabled=False)) + + assert off_result == RfOutputResult(port_id="rf_out", enabled=False, write_completed=False) + assert off_driver.output_requests == [] + assert off_driver.calls == ["snapshot"] + + +def test_rf_output_disable_ignores_on_only_unknowns_and_confirms_off() -> None: + before = _snapshot(output_enabled=True) + before = replace( + before, + ports=( + replace( + before.ports[0], + frequency_hz=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + power_dbm=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ), + ), + protection=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ) + service, driver = _output_service( + [before, _snapshot(output_enabled=False)], + safety_ports=(), + ) + + result = service.set_output(RfOutputRequest(port_id="rf_out", enabled=False)) + + assert result == RfOutputResult(port_id="rf_out", enabled=False, write_completed=True) + assert driver.output_requests == [RfOutputRequest(port_id="rf_out", enabled=False)] + assert driver.calls == ["snapshot", "set_rf_output", "snapshot"] + + +def test_rf_output_enable_postcondition_or_write_failure_runs_one_off_recovery() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + mismatch_service, mismatch_driver = _output_service( + [_snapshot(), _snapshot(output_enabled=False), _snapshot(output_enabled=False)] + ) + + with pytest.raises(ConfigError, match="postcondition reports RF output OFF") as mismatch: + mismatch_service.set_output(request) + + assert mismatch_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert mismatch.value.rf_source_recovery == { + "status": "off_verified", + "session_health": "uncertain", + } + assert mismatch_service.session_state is not None + assert mismatch_service.session_state.health is SessionHealth.UNCERTAIN + + failed_service, failed_driver = _output_service( + [_snapshot(), _snapshot(output_enabled=False)], + raise_after_enable=True, + ) + with pytest.raises(ConfigError, match="RF ON failed after transmission") as failed: + failed_service.set_output(request) + + assert failed_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert failed.value.rf_source_recovery["status"] == "off_verified" + assert failed_service.session_state is not None + assert failed_service.session_state.health is SessionHealth.UNCERTAIN + + +def test_rf_output_disable_unknown_result_is_not_retried() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=False) + service, driver = _output_service( + [_snapshot(output_enabled=True)], + raise_after_disable=True, + ) + + with pytest.raises(ConfigError, match="RF OFF failed after transmission"): + service.set_output(request) + + assert driver.output_requests == [request] + assert driver.calls == ["snapshot", "set_rf_output"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.POISONED + + +def test_rf_output_recovery_uses_bounded_guarded_io_on_uncertain_session() -> None: + state = InstrumentSessionState() + driver = GuardedRfOutputDriver( + [_snapshot(), _snapshot(output_enabled=False), _snapshot(output_enabled=False)], + state, + ) + service = RfSourceService( + config=_config(access="read_write", safety_ports=(_output_safety_port(),)), + logger=CommandLogger(), + session=driver, + descriptor=_output_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ), + transport=driver.transport, + session_state=state, + ) + + with pytest.raises(ConfigError, match="postcondition reports RF output OFF") as raised: + service.set_output(RfOutputRequest(port_id="rf_out", enabled=True)) + + assert driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert driver.inner.writes == ["RF:OUTPUT ON", "RF:OUTPUT OFF"] + assert driver.transport.counters.write_completed == 2 + assert driver.transport.counters.query_calls == 3 + assert raised.value.rf_source_recovery == { + "status": "off_verified", + "session_health": "uncertain", + } + assert state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_scope_extension_registry.py b/tests/test_scope_extension_registry.py index ede0fa1..b706d3d 100644 --- a/tests/test_scope_extension_registry.py +++ b/tests/test_scope_extension_registry.py @@ -170,11 +170,12 @@ def test_public_scope_capability_requires_new_core_floor() -> None: def test_scope_descriptor_extension_is_append_only_for_positional_compatibility() -> None: names = [field.name for field in fields(InstrumentDescriptor)] - assert names[-4:] == [ + assert names[-5:] == [ "config_fields", "resource_schemes", "scope_extensions", "source_extensions", + "rf_source_extensions", ] assert [field.name for field in fields(ScopeDescriptorExtensions)] == [ "screenshot_profile", From 633e149acefd6ba7d1d215fb88a5a9409c0d88ad Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:55:37 +0800 Subject: [PATCH 15/63] rf-source: route offline output through CLI and run --- src/wavebench/cli.py | 11 +- src/wavebench/cli_parser.py | 7 ++ src/wavebench/services/execution_intent.py | 2 + src/wavebench/services/run_plan.py | 10 ++ src/wavebench/services/run_safety.py | 2 + src/wavebench/services/run_service.py | 36 ++++++ tests/test_rf_source_cli.py | 45 ++++++- tests/test_rf_source_run.py | 130 +++++++++++++++++++++ 8 files changed, 240 insertions(+), 3 deletions(-) diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 9a0bf0a..25df95f 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -87,7 +87,7 @@ ScopeTraceData, ScopeTraceRef, ) -from .instruments.rf_source_extensions import RfCwRequest +from .instruments.rf_source_extensions import RfCwRequest, RfOutputRequest from .mcp_http import ( resolve_mcp_token, serve_mcp_http, @@ -1559,6 +1559,15 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "output": + result = service.set_output( + RfOutputRequest(port_id=args.port, enabled=args.state == "on") + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.domain == "sweep": service = _load_sweep_service(args) if args.command == "discrete": diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 6b13e1d..0176d50 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -667,6 +667,13 @@ def build_parser() -> argparse.ArgumentParser: rf_source_set_power.add_argument("--port", required=True) rf_source_set_power.add_argument("power_dbm", type=float) add_runtime_options(rf_source_set_power) + rf_source_output = rf_source_sub.add_parser( + "output", + help="Set one RF port output on or off after a safety-checked snapshot", + ) + rf_source_output.add_argument("--port", required=True) + rf_source_output.add_argument("state", choices=["on", "off"]) + add_runtime_options(rf_source_output) source_sub = source_parser.add_subparsers(dest="command", required=True) diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index db4fce1..9f5f920 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -26,6 +26,8 @@ "rf_source.status": "rf_source.snapshot", "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", + "rf_source.output_enable": "rf_source.output_enable", + "rf_source.output_disable": "rf_source.output_disable", "source.arb_load": "source.arbitrary_upload", "source.set_freq": "source.set_frequency", "source.set_func": "source.set_function", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index fbf040e..4d2b16c 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -24,6 +24,8 @@ "rf_source.status", "rf_source.set_frequency", "rf_source.set_power_dbm", + "rf_source.output_enable", + "rf_source.output_disable", "source.set_freq", "source.arb_load", "source.set_func", @@ -69,6 +71,8 @@ "source.output": ("state",), "rf_source.set_frequency": ("port_id", "frequency_hz"), "rf_source.set_power_dbm": ("port_id", "power_dbm"), + "rf_source.output_enable": ("port_id",), + "rf_source.output_disable": ("port_id",), "source.basic_configure_v2": ("channel",), "source.output_enable_v2": ("channel",), "source.output_disable_v2": ("channel",), @@ -178,6 +182,8 @@ "rf_source.status": {"on_failure"}, "rf_source.set_frequency": {"on_failure"}, "rf_source.set_power_dbm": {"on_failure"}, + "rf_source.output_enable": {"on_failure"}, + "rf_source.output_disable": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, "source.arb_load": {"channel", "offset_v", "sample_rate_hz", "max_points", "byte_order", "output_on", "on_failure"}, "source.set_func": {"channel", "on_failure"}, @@ -239,6 +245,8 @@ "rf_source.status": "Read a typed RF-source snapshot without changing output.", "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", + "rf_source.output_enable": "Enable one RF port only after a fresh safety snapshot confirms the configured load, frequency, power, and inactive modulation, Pulse, Sweep, and blocking protection conditions.", + "rf_source.output_disable": "Disable one RF port and confirm OFF without requiring frequency, power, or protection readback.", "source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.", "source.set_freq": "Set fixed source frequency in Hz; config may force FIX mode first.", "source.set_func": "Set source waveform function, for example SIN or SQU.", @@ -639,6 +647,8 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non elif kind == "rf_source.set_power_dbm": fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") + elif kind in {"rf_source.output_enable", "rf_source.output_disable"}: + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") elif kind == "source.arb_load": fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file") fields["frequency_hz"] = _positive_float(fields["frequency_hz"], f"{prefix}.frequency_hz") diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 6de3d5b..a836902 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -24,6 +24,8 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.status", "rf_source.set_frequency", "rf_source.set_power_dbm", + "rf_source.output_enable", + "rf_source.output_disable", "source.set_freq", "source.arb_load", "source.set_func", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 8c4888f..5e72484 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -24,6 +24,7 @@ from wavebench.instruments.registry import resolve_instrument_descriptor from wavebench.instruments.rf_source_extensions import ( RfCwRequest, + RfOutputRequest, rf_source_snapshot_operation_artifact, ) from wavebench.instruments.source_extensions import ( @@ -57,6 +58,8 @@ from wavebench.services.power_service import PowerService from wavebench.services.dmm_service import DmmService from wavebench.services.rf_source_service import RfSourceService +from wavebench.services.access_policy import access_policy +from wavebench.services.operation_specs import require_operation_spec from wavebench.services.frequency_response import ( analyze_frequency_response_point, build_fit_document, @@ -288,8 +291,29 @@ def check(self, plan: RunPlan) -> None: reject_unsupported_steps(plan) self._check_frequency_response_baselines(plan) self._check_frequency_response_resumes(plan) + self._check_rf_source_access(plan) self._check_plan_capabilities(plan) + def _check_rf_source_access(self, plan: RunPlan) -> None: + """Reject RF operations by access policy before run lifecycle opens a session.""" + + operations = { + "rf_source.status": "rf_source.snapshot", + "rf_source.set_frequency": "rf_source.set_frequency", + "rf_source.set_power_dbm": "rf_source.set_power_dbm", + "rf_source.output_enable": "rf_source.output_enable", + "rf_source.output_disable": "rf_source.output_disable", + } + relevant = [operations[step.kind] for step in plan.steps if step.kind in operations] + if not relevant: + return + rf_source = self.config.rf_source + if rf_source is None: + raise ConfigError("rf_source resource is required by this run plan") + policy = access_policy(rf_source.access, "rf_source.access") + for operation in relevant: + policy.require(require_operation_spec(operation), operation=operation) + def _check_frequency_response_resumes(self, plan: RunPlan) -> None: """Validate resume CSVs before any instrument session is opened.""" @@ -424,6 +448,8 @@ def add_source_output_gate_capability() -> None: add("rf_source", "rf_source.snapshot") elif step.kind in {"rf_source.set_frequency", "rf_source.set_power_dbm"}: add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") + elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: + add("rf_source", "rf_source.snapshot", "rf_source.output") elif step.kind == "source.set_freq": add("source", "source.set_frequency") source = self.config.source @@ -1198,6 +1224,16 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} + elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: + _, rf_source_operation = self._rf_source_service( + services=services + ).set_output_with_artifact( + RfOutputRequest( + port_id=step.fields["port_id"], + enabled=step.kind == "rf_source.output_enable", + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind == "source.basic_configure_v2": fields = step.fields _, source_operation = self._source_service(services=services).configure_basic_v2( diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index 6a1757a..96f6aea 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -7,10 +7,15 @@ from unittest.mock import Mock, patch from wavebench.cli import _load_rf_source_service, build_parser, main -from wavebench.instruments.rf_source_extensions import RfCwRequest, RfCwResult +from wavebench.instruments.rf_source_extensions import ( + RfCwRequest, + RfCwResult, + RfOutputRequest, + RfOutputResult, +) -def test_rf_source_parser_accepts_read_only_and_off_only_cw_commands() -> None: +def test_rf_source_parser_accepts_read_only_cw_and_output_commands() -> None: identity = build_parser().parse_args( ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] ) @@ -21,6 +26,9 @@ def test_rf_source_parser_accepts_read_only_and_off_only_cw_commands() -> None: power = build_parser().parse_args( ["rf-source", "set-power", "--port", "rf_out", "-20"] ) + output = build_parser().parse_args( + ["rf-source", "output", "--port", "rf_out", "on"] + ) assert (identity.domain, identity.command) == ("rf-source", "idn") assert identity.config == "rf.toml" @@ -31,6 +39,9 @@ def test_rf_source_parser_accepts_read_only_and_off_only_cw_commands() -> None: assert frequency.frequency_hz == 4_000_000.0 assert (power.domain, power.command) == ("rf-source", "set-power") assert power.power_dbm == -20.0 + assert (output.domain, output.command) == ("rf-source", "output") + assert output.port == "rf_out" + assert output.state == "on" def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: @@ -91,6 +102,36 @@ def test_rf_source_cli_dispatches_each_off_only_cw_request() -> None: ] +def test_rf_source_cli_dispatches_each_output_request() -> None: + service = Mock() + service.set_output.side_effect = [ + RfOutputResult(port_id="rf_out", enabled=True, write_completed=True), + RfOutputResult(port_id="rf_out", enabled=False, write_completed=False), + ] + + on_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + on_stdout + ): + assert main(["--json", "rf-source", "output", "--port", "rf_out", "on"]) == 0 + on_payload = json.loads(on_stdout.getvalue()) + assert on_payload["result"]["enabled"] is True + assert on_payload["result"]["write_completed"] is True + + off_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + off_stdout + ): + assert main(["--json", "rf-source", "output", "--port", "rf_out", "off"]) == 0 + off_payload = json.loads(off_stdout.getvalue()) + assert off_payload["result"]["enabled"] is False + assert off_payload["result"]["write_completed"] is False + assert service.set_output.call_args_list == [ + ((RfOutputRequest(port_id="rf_out", enabled=True),), {}), + ((RfOutputRequest(port_id="rf_out", enabled=False),), {}), + ] + + def test_rf_source_resource_override_does_not_touch_source_config() -> None: updated = object() config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index 83fcab8..e08f5c3 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -30,6 +30,9 @@ RfSourceSnapshot, RfSweepState, rf_source_cw_operation_artifact, + RfOutputRequest, + RfOutputResult, + rf_source_output_operation_artifact, rf_source_snapshot_operation_artifact, ) from wavebench.logging import CommandLogger @@ -78,6 +81,15 @@ def _cw_plan(directory: str, *, kind: str, field: str, value: float): return load_run_plan(path) +def _output_plan(directory: str, *, kind: str): + path = Path(directory) / "plan.toml" + path.write_text( + f'[[steps]]\nkind = "{kind}"\nport_id = "rf_out"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + def _snapshot() -> RfSourceSnapshot: return RfSourceSnapshot( ports=( @@ -246,6 +258,29 @@ def test_rf_source_cw_steps_require_capability_before_opening_a_session() -> Non open_services.assert_not_called() +def test_rf_source_cw_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + plan = _cw_plan( + directory, + kind="rf_source.set_frequency", + field="frequency_hz", + value=2_000_000.0, + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(plan) + + open_services.assert_not_called() + + def test_rf_source_cw_step_has_write_intent_and_separate_artifact_namespace() -> None: with TemporaryDirectory() as directory: plan = _cw_plan( @@ -311,3 +346,98 @@ def _run_safety_guards(self, run_plan, *, services=None): assert run_result.steps[0].artifact == {"rf_source_operation": artifact} run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) assert run_data["rf_source_operations"] == [artifact] + + +def test_rf_source_output_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + plan = _output_plan(directory, kind="rf_source.output_enable") + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.output"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_output_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + plan = _output_plan(directory, kind="rf_source.output_disable") + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_output_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _output_plan(directory, kind="rf_source.output_enable") + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.output_enable" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == {"port_id": "rf_out"} + + preflight = _snapshot() + postcondition = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(True), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + request = RfOutputRequest(port_id="rf_out", enabled=True) + result_value = RfOutputResult(port_id="rf_out", enabled=True, write_completed=True) + artifact = rf_source_output_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + rf_service = SimpleNamespace( + set_output_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.set_output_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] From 0a519f9ec1a77c3272ca857936728934fc930af3 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:55:53 +0800 Subject: [PATCH 16/63] docs: clarify RF source offline milestones --- docs/project/README.md | 4 +- ...21\351\207\214\347\250\213\347\242\221.md" | 16 ++--- ...67\346\272\220\350\256\276\350\256\241.md" | 70 +++++++++---------- .../WaveBench_CLI\345\275\242\346\200\201.md" | 12 ++-- ...07\344\273\266\346\240\274\345\274\217.md" | 21 +++--- ...345\231\250\346\217\222\344\273\266API.md" | 17 +++-- 6 files changed, 75 insertions(+), 65 deletions(-) diff --git a/docs/project/README.md b/docs/project/README.md index 35f2091..556b02b 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -24,8 +24,8 @@ - [设备抽象层](design/WaveBench_设备抽象层.md) - [多仪器流程设计](design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):当前 M0 只读合同、后续写入设计与安全边界。 -- [RF 信号源开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core 与 DSG830 插件的当前状态、依赖和 A1–A5 实机证据门。 +- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):生产只读边界、M1/M2 离线合同、后续写入设计与安全规则。 +- [RF 信号源开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core 与 DSG830 插件的同步状态、依赖和 A1–A5 实机证据门。 ## rfcs:接口提案与决策 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 6a86c03..a196080 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,8 +8,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0 离线完成 | 已有 `rf_source` kind、配置、只读 Service/CLI/doctor、`rf_source.status` run 路径、artifact 和 descriptor extension;未实现 RF 写入事务。 | -| DSG830 包 `0.2.0` | M0 离线完成;A1 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology 与严格 snapshot parser;production descriptor 声明只读 `rf_source.idn` 与 `rf_source.snapshot`。 | +| Core `0.8.25` 开发线 | M0、M1 离线完成;M2 离线进行中 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 仍只读。 | +| DSG830 包 `0.2.0` | M0、M1 离线完成;M2 离线进行中;A1 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与离线 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明只读 `rf_source.idn` 与 `rf_source.snapshot`。 | | 真实仪器证据 | A1 已完成;A2–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 仅提升 `rf_source.snapshot`。 | ## 双仓库交付规则 @@ -28,8 +28,8 @@ | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | -| M1 | 离线进行中 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | 已有 typed request/result、Service、CLI、run step、artifact 和 fake 测试;production capability 仍关闭,完整离线验收仍待完成。 | -| M2 | 未开始 | RF 输出安全事务 | `:OUTP` ON/OFF 及 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次 OFF recovery。 | +| M1 | 离线完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact 和 fake 测试已完成;production capability 仍关闭。 | +| M2 | 离线进行中 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;production capability 仍关闭。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -68,15 +68,15 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 ## M1:OFF-only CW 配置 -Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。完整离线验收仍待完成。 +Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收已完成,production capability 仍关闭。 DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 ## M2:RF 输出安全事务 -Core 添加每端口安全预检、`rf_source.output_enable`/`rf_source.output_disable`、端接匹配检查、blocking protection policy 和 run safety gate 的 `rf_source_ports`。RF OFF 不依赖频率、功率、端接或 protection readback;RF ON 必须逐项满足所有前置条件。 +Core 正在离线完善 `RfOutputRequest`/result、`rf_source.output_enable`/`rf_source.output_disable` OperationSpec、descriptor 输出 profile 校验、每端口 safety 预检、CLI、run schema/intent/dispatch 与带 preflight/postcondition snapshot 的 artifact。RF ON 必须确认完整 safety 配置、端接匹配、频率与 dBm 功率范围、已关闭的调制/Pulse/Sweep,以及只含已知非阻断项的 protection。RF OFF 不依赖频率、功率、端接或 protection readback。 -DSG830 driver 实现 `:OUTP ON|OFF` 与独立 readback。ON 结果不明、写后 readback 失败或 protection 变化时,不重试 ON;只有 session health 允许时,才最多发送一次目标端口 OFF 并回读。production descriptor 直到 A2 后才可声明 output capability。 +ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 只实现离线 `:OUTP ON|OFF` 单次映射,Core 负责所有 snapshot readback 与 recovery;production descriptor 直到 A2 后才可声明 `rf_source.output`。 ## M3:调制 @@ -130,5 +130,5 @@ A1 已使用一次性、非 production 的本地 evidence harness 完成并经 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 -4. 正在用 fake descriptor 完成 M1 的零写拒绝与 postcondition 测试,并实现 DSG830 的对应离线 SCPI 映射;随后单独推进 M2 的 safety preflight 与 recovery。 +4. 已完成 M1 的 fake descriptor 零写拒绝、postcondition 测试和 DSG830 离线 SCPI 映射;正在以 fake/guarded transport 完成 M2 的 safety preflight、OFF recovery 与 run 路由。 5. 取得 A2、A3 证据后,按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 0c65f37..5adba6e 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已经实现 M0 的只读合同;本文同时保留 M1–M4 的写入设计和 A1–A5 的实机证据门,不能将后两者误写成当前能力。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读合同、M1 离线 CW 合同,并在推进 M2 的离线输出事务;生产环境仍只有经 A1 证据提升的只读能力。本文同时保留 M3–M4 的设计和 A1–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 阅读顺序如下: @@ -15,13 +15,13 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、`rf_source.idn`/`rf_source.snapshot` 合同、只读 Service/CLI/doctor、`rf_source.status` run step 和独立 artifact。 | M0 只读能力;没有 RF 写入事务或 RF safety gate。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology 与严格 snapshot parser;A1 只读证据已经完成。 | production descriptor 声明 `rf_source.idn` 与 `rf_source.snapshot`;没有 RF 写 capability。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW 事务,以及 M2 的离线端口输出事务、CLI、run step 与 artifact。 | 生产可用能力仍是 M0 只读;M1/M2 只能由 fake descriptor 覆盖。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、离线 `:FREQ`/`:LEV`/`:OUTP` 映射;A1 只读证据已经完成。 | production descriptor 仅声明 `rf_source.idn` 与 `rf_source.snapshot`;没有 RF 写 capability。 | | 实机证据 | A1 已完成;A2–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 不能声明任何写 capability。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「当前 M0」或「已完成 A1」的内容外,本文中的 M1–M4、production 写 capability 与 A2–A5 均为目标合同或证据门;不得将它们写成当前可控制真实仪器的能力。 +除明确标为「生产只读」「离线已完成」或「离线进行中」的内容外,本文中的 M3–M4、production 写 capability 与 A2–A5 均为目标合同或证据门。M1/M2 的离线实现也不得写成当前可控制真实仪器的能力。 ## 术语与证据级别 @@ -45,6 +45,8 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 ### 目标交付 - M0 已提供 `rf_source` plugin kind、配置、capability、model、driver Protocol、只读 Service/CLI/doctor、run status 和 artifact namespace。 +- M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;它只供离线 fake descriptor 使用。 +- M2 正在完善端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;它同样只供离线 fake descriptor 使用。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 - 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 @@ -275,35 +277,45 @@ Sweep arm 是 OFF-only 准备 operation,必须保持目标端口 RF OFF。Swee ## Service、CLI、doctor 与 run plan -### 当前 M0 +### 当前已实现的入口 -`RfSourceService` 当前负责 `rf_source.idn` 与 `rf_source.snapshot` 的 capability、access、资源租约、session health 和类型化 snapshot。driver 只执行已冻结的设备动作,CLI 不直接生成 SCPI。 - -当前命令和 run 入口为: +`RfSourceService` 统一负责 capability、access、资源租约、session health 和类型化 snapshot;driver 只执行已冻结的设备动作,CLI 不直接生成 SCPI。当前入口按能力层次分为三组: ```text +# 生产只读:DSG830 已由 A1 提升 wavebench rf-source idn wavebench rf-source status rf_source.status + +# 离线 M1:必须同时具备 read_write、CW capability 和 OFF-only preflight +wavebench rf-source set-frequency --port PORT_ID HZ +wavebench rf-source set-power --port PORT_ID DBM +rf_source.set_frequency +rf_source.set_power_dbm + +# 离线 M2:必须同时具备 read_write、output capability 和端口级 safety preflight +wavebench rf-source output --port PORT_ID on|off +rf_source.output_enable +rf_source.output_disable ``` -`rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。当前 `rf_source.status` 产生独立的 `wavebench.rf_source.operation.v1` snapshot artifact。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 +`rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 -DSG830 已完成 A1,因此 production descriptor 可通过 Core 的 status 路径读取只读 snapshot。该提升只覆盖固定的状态 query,不授权频率、功率、RF 输出、调制、Pulse、Sweep、fire 或 trigger 控制。 +M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 production descriptor 不声明 `rf_source.cw_configure` 或 `rf_source.output`,因此 M1/M2 CLI 和 run step 不能对已联网的 DSG830 发送写入。 -### M1–M4 目标 +### M1、M2 的离线写入合同 -M1 已注册以下 OFF-only CW CLI;它们仍受 descriptor capability、`read_write` access、CW profile 和 fresh snapshot preflight 共同门控。DSG830 的 production descriptor 没有 `rf_source.cw_configure`,因此这些命令不能控制已联网的 DSG830。 +M1 是 OFF-only CW 配置:目标端口必须明确为 OFF,调制、Pulse、Sweep 与 protection 不得冲突;每次调用只写一个频率或 dBm 字段,并用独立 snapshot 回读确认。写后结果不明时不重试。 -```text -wavebench rf-source set-frequency --port PORT_ID HZ -wavebench rf-source set-power --port PORT_ID DBM -``` +M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端接与 dBm 参考阻抗一致、频率与 dBm 功率处于设备和实验室配置范围内、调制/Pulse/Sweep 都关闭,并且 protection 仅含已知的非阻断状态。RF OFF 只依赖目标输出状态,不要求频率、功率、端接或 protection 可读。ON 写入或其 readback 结果不明时,session 降为不确定状态,并且只在受 guard 的 recovery 预算内最多执行一次同端口 OFF 和 OFF 回读;OFF 写入结果不明时不重试,session 降为 poisoned。 + +上述合同只由 fake descriptor 和 fake/guarded transport 验证。A2、A3 之前不得把它们纳入 DSG830 production descriptor,也不得把人工确认的实验室端接当作写入授权。 -M1 的 run step 为 `rf_source.set_frequency` 与 `rf_source.set_power_dbm`,每个 step 都要求 `port_id` 与一个有限数值,并写入脱敏的 preflight/postcondition snapshot artifact。它们与 CLI 一样仍受 production capability 门禁;DSG830 的 production descriptor 不声明 `rf_source.cw_configure`。M2–M4 的写入 CLI 和 run step 仍是设计合同,尚未进入当前 run schema: +### M3–M4 目标 + +M3–M4 的写入 CLI 和 run step 仍是设计合同,尚未进入当前 run schema: ```text -wavebench rf-source output --port PORT_ID on|off wavebench rf-source modulation configure-am ... wavebench rf-source modulation configure-fm ... wavebench rf-source modulation configure-pm ... @@ -315,19 +327,7 @@ wavebench rf-source sweep fire ... wavebench rf-source sweep stop ... ``` -```text -rf_source.output_disable -rf_source.output_enable -rf_source.modulation_configure -rf_source.pulse_configure -rf_source.pulse_trigger -rf_source.sweep_configure -rf_source.sweep_arm -rf_source.sweep_fire -rf_source.sweep_stop -``` - -这些目标 step 都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。M2 的 run safety gate 将独立处理 `rf_source.*`:失败时只针对已知受影响端口请求 RF OFF,不访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 +这些目标 step 都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。它们不得访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 ## M0–M4 里程碑 @@ -335,9 +335,9 @@ rf_source.sweep_stop | 里程碑 | 通用核心交付 | 首个适配器离线交付 | 离线验证标准 | | --- | --- | --- | --- | -| M0(当前) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | -| M1 | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率写后回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | -| M2 | per-port 输出事务、安全预检、RF OFF recovery | RF ON/OFF 写后 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | +| M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | +| M1(离线完成) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | +| M2(离线进行中) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | | M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | @@ -368,7 +368,7 @@ DSG830 的 production `descriptor()` 在 A1 完成后声明 `rf_source.idn` 与 - 所有公共 model、profile 与 request 在实现前先有边界、非有限数、布尔值、未知枚举和不匹配端口的失败测试。 - 所有 Service 写路径先有零写拒绝与写后回读失败测试,再实现最小 transaction。 -- `StatefulRfTransport` 模拟严格 query、单次写入、忽略写入、写前异常、写后 query 异常、protection 变化与 session health。 +- `StatefulRfTransport` 与 guarded fake transport 模拟严格 query、单次写入、忽略写入、写前异常、写后 query 异常、protection 变化、recovery 预算与 session health。 - driver 测试精确断言 SCPI 命令、值格式、query 顺序、写入次数与 postcondition;不以 fake 的调用次数代替外部可见状态断言。 - 默认测试不扫描端口、不读取本地实验室配置、不连接仪器、不执行真实 SCPI。 - 核心验证包括聚焦 pytest、完整 pytest、ruff 和 `git diff --check`;插件额外包括包级 pytest、ruff、wheel/package check 与安装 dry-run。 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 25f5b4d..a2eb092 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -32,7 +32,7 @@ wavebench run --help |---|---|---| | 离线 | `run schema`、`run template`、`run check`、`run intent`、`run report`、`run compare`、`run resume`、`capability explain`、`lock status`、`capture inspect`、`tui --fake` | 不连接仪器;报告、比较、检查、能力解释、锁查询和意图生成只读取本地文件 | | 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`source snapshot-v2`、`rf-source idn`/`status`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | -| 显式写入或触发 | `scope auto`、`scope fetch/capture`、source / power setter、`run plan` | 可能改变设置、触发采集或切换输出 | +| 显式写入或触发 | `scope auto`、`scope fetch/capture`、source / power setter、已获 capability 的 `rf-source` setter、`run plan` | 可能改变设置、触发采集或切换输出 | 执行硬件写入前,应先确认接线、输入阻抗、输出状态和安全限制。CLI 不会自动发送 `*RST`,也不会因为设置电压或幅度而自动打开输出。 @@ -86,16 +86,18 @@ wavebench rf-source idn --config wavebench.toml wavebench rf-source status --config wavebench.toml wavebench rf-source set-frequency --port PORT_ID HZ --config wavebench.toml wavebench rf-source set-power --port PORT_ID DBM --config wavebench.toml +wavebench rf-source output --port PORT_ID on|off --config wavebench.toml wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 `rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1,并在 production descriptor 中声明这两个只读 capability;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw -SCPI。M1 已注册 `set-frequency` 与 `set-power` 的 OFF-only CW CLI;它们还要求 -`rf_source.cw_configure`、`read_write` 访问、已声明的 CW profile 和完整的只读 preflight。当前 DSG830 -production descriptor 不声明该写 capability,因此命令会在打开 transport 前拒绝。RF 输出、调制、Pulse 和 -Sweep 写入命令仍不存在。 +SCPI。M1 的 `set-frequency` 与 `set-power` 还要求 `rf_source.cw_configure`、`read_write` 访问、已声明的 +CW profile 和完整的 OFF-only preflight。M2 的 `output` 还要求 `rf_source.output`、`read_write` 访问、可读 +output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 +当前 DSG830 production descriptor 不声明任一写 capability,因此这些命令会在打开 transport 前拒绝。调制、 +Pulse 和 Sweep 写入命令仍不存在。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index fd73afe..d4c1716 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -170,13 +170,13 @@ ensure_fix_mode_on_set_frequency = true settle_ms_after_set_frequency = 500 access = "read_write" -# RF M0 是独立的只读域;当前建议显式使用 read_only。 +# DSG830 production 当前只开放 M0 只读;read_only 是默认安全选择。 [rf_source] driver = "rigol.dsg830" resource = "TCPIP::192.0.2.13::INSTR" access = "read_only" -# 该静态声明为后续能量操作预留;M0 不会使用它执行写入。 +# M2 的离线 ON preflight 会消费该静态证据;它不授予 production 写入能力。 [[rf_source.safety.ports]] port_id = "rf_out" minimum_frequency_hz = 9000 @@ -447,23 +447,26 @@ actual_termination_ohm = 50 ``` `[rf_source]` 是独立于普通 `[source]` 的 RF 信号源配置。它使用 plugin descriptor 的稳定 -`port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。当前 M0 可使用 -`wavebench rf-source idn`;`wavebench rf-source status` 还要求 production descriptor 声明 -`rf_source.snapshot`。DSG830 已完成 A1,并声明 `rf_source.idn` 和 `rf_source.snapshot`,所以可在 -已配置的只读 session 中执行 status;其他未声明 snapshot 的插件仍会在打开 transport 前被拒绝。 +`port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。M0 提供 +`wavebench rf-source idn` 与 `wavebench rf-source status`;后者要求 production descriptor 声明 +`rf_source.snapshot`。M1 的频率/功率 CLI 和 M2 的输出 CLI 已有离线路由,但还分别要求对应 capability、 +`read_write` 访问和 fresh safety preflight。DSG830 已完成 A1,并只声明 `rf_source.idn` 与 +`rf_source.snapshot`,所以可在已配置的只读 session 中执行 status;任何写 CLI 都会在打开 transport 前被 +拒绝,直到对应 A 级证据提升 capability。 字段说明: - `driver`:已安装 RF 插件的 canonical driver ID;默认值是 `rigol.dsg830`,但只有已安装插件才可解析。 - `resource`:RF 信号源的 VISA 资源串;可由 `rf-source` 命令的 `--resource` 临时覆盖。 -- `access`:沿用通用访问策略。M0 只需 `read_only`,实际写 capability 发布前不应配置或依赖 `read_write`。 +- `access`:沿用通用访问策略。`read_only` 是 production 状态查询的默认选择;`read_write` 本身不授予写入, + 还必须同时具备对应 descriptor capability、profile、fresh safety preflight 和实机证据。 - `options`:可选的插件私有配置表;公开配置不要放入真实资源之外的凭据或实验室专有数据。 `[[rf_source.safety.ports]]` 是按端口声明的本地静态安全证据。每项必须提供唯一 `port_id`、有限的 `minimum_frequency_hz`、`maximum_frequency_hz`、`maximum_power_dbm` 和正数 `actual_termination_ohm`;最大频率不得小于最小频率。它不改变仪器显示的负载设置,也不会把 dBm -换算为 Vpp。M0 只解析和校验该声明,尚不执行 RF 写入或 safety gate;后续能量相关 capability 才会 -使用它进行准入判断。 +换算为 Vpp。M2 的离线 RF ON 事务会使用它进行准入判断;production descriptor 缺少 output capability 时, +该事务仍会在打开 transport 前拒绝。 RF 的 capability、A1–A5 证据门和 DSG830 的当前 production 边界见 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)和 diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index 868d834..f2f67e7 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -584,15 +584,17 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 -### RF 信号源 M0 +### RF 信号源 M0–M2(production 当前仅 M0) `rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source 能力。descriptor 必须同时满足以下静态条件: -- 只声明 `rf_source.*` capability,且至少包含 `rf_source.idn`;当前 Core 只识别 - `rf_source.idn` 和 `rf_source.snapshot`。 +- 只声明 `rf_source.*` capability,且至少包含 `rf_source.idn`;当前 Core 识别 + `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 - 提供 `rf_source_extensions`,其 contract version、拓扑、端口 ID、feature 和 protection policy 必须通过 Core 校验。 +- 声明 `rf_source.cw_configure` 时,CW feature 必须有 `CONFIGURE` direction 和至少一个可配置字段;声明 + `rf_source.output` 时,output feature 必须同时有 `ENABLE`/`DISABLE` direction 与可读 output state。 - `wavebench_min_version` 不低于 `0.8.25`,并且小于 `wavebench_max_version`。 - 打包检查时,wheel 必须有且仅有一条生效的 `wavebench` 依赖,并显式使用与 descriptor 相同的 `>=wavebench_min_version, Date: Wed, 26 Aug 2026 22:59:48 +0800 Subject: [PATCH 17/63] docs: mark RF output offline milestone complete --- ...345\217\221\351\207\214\347\250\213\347\242\221.md" | 10 +++++----- ...345\217\267\346\272\220\350\256\276\350\256\241.md" | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index a196080..eafc69f 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,8 +8,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0、M1 离线完成;M2 离线进行中 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 仍只读。 | -| DSG830 包 `0.2.0` | M0、M1 离线完成;M2 离线进行中;A1 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与离线 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明只读 `rf_source.idn` 与 `rf_source.snapshot`。 | +| Core `0.8.25` 开发线 | M0、M1、M2 离线完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 仍只读。 | +| DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与离线 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明只读 `rf_source.idn` 与 `rf_source.snapshot`。 | | 真实仪器证据 | A1 已完成;A2–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 仅提升 `rf_source.snapshot`。 | ## 双仓库交付规则 @@ -29,7 +29,7 @@ | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact 和 fake 测试已完成;production capability 仍关闭。 | -| M2 | 离线进行中 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;production capability 仍关闭。 | +| M2 | 离线完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;production capability 仍关闭。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -74,7 +74,7 @@ DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次 ## M2:RF 输出安全事务 -Core 正在离线完善 `RfOutputRequest`/result、`rf_source.output_enable`/`rf_source.output_disable` OperationSpec、descriptor 输出 profile 校验、每端口 safety 预检、CLI、run schema/intent/dispatch 与带 preflight/postcondition snapshot 的 artifact。RF ON 必须确认完整 safety 配置、端接匹配、频率与 dBm 功率范围、已关闭的调制/Pulse/Sweep,以及只含已知非阻断项的 protection。RF OFF 不依赖频率、功率、端接或 protection readback。 +Core 已在离线环境中完成 `RfOutputRequest`/result、`rf_source.output_enable`/`rf_source.output_disable` OperationSpec、descriptor 输出 profile 校验、每端口 safety 预检、CLI、run schema/intent/dispatch 与带 preflight/postcondition snapshot 的 artifact。RF ON 必须确认完整 safety 配置、端接匹配、频率与 dBm 功率范围、已关闭的调制/Pulse/Sweep,以及只含已知非阻断项的 protection。RF OFF 不依赖频率、功率、端接或 protection readback。 ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 只实现离线 `:OUTP ON|OFF` 单次映射,Core 负责所有 snapshot readback 与 recovery;production descriptor 直到 A2 后才可声明 `rf_source.output`。 @@ -130,5 +130,5 @@ A1 已使用一次性、非 production 的本地 evidence harness 完成并经 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 -4. 已完成 M1 的 fake descriptor 零写拒绝、postcondition 测试和 DSG830 离线 SCPI 映射;正在以 fake/guarded transport 完成 M2 的 safety preflight、OFF recovery 与 run 路由。 +4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;后续仅在获得授权后单独评审 A2。 5. 取得 A2、A3 证据后,按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 5adba6e..09e59c3 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读合同、M1 离线 CW 合同,并在推进 M2 的离线输出事务;生产环境仍只有经 A1 证据提升的只读能力。本文同时保留 M3–M4 的设计和 A1–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 离线 CW 和 M2 离线输出合同;生产环境仍只有经 A1 证据提升的只读能力。本文同时保留 M3–M4 的设计和 A1–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 阅读顺序如下: @@ -46,7 +46,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - M0 已提供 `rf_source` plugin kind、配置、capability、model、driver Protocol、只读 Service/CLI/doctor、run status 和 artifact namespace。 - M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;它只供离线 fake descriptor 使用。 -- M2 正在完善端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;它同样只供离线 fake descriptor 使用。 +- M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;它同样只供离线 fake descriptor 使用。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 - 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 @@ -337,7 +337,7 @@ wavebench rf-source sweep stop ... | --- | --- | --- | --- | | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1(离线完成) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | -| M2(离线进行中) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | +| M2(离线完成) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | | M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | From 290197600ef057cfb6d5bd641ae726c3d7277bcf Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:04:36 +0800 Subject: [PATCH 18/63] docs: record DSG830 A2 output promotion --- README.md | 2 +- docs/README.md | 4 +-- docs/README_EN.md | 2 +- docs/project/README.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 24 +++++++++----- ...67\346\272\220\350\256\276\350\256\241.md" | 32 +++++++++---------- ...01\347\250\213\350\256\276\350\256\241.md" | 15 +++++++-- ...07\346\212\275\350\261\241\345\261\202.md" | 6 ++-- ...71\347\233\256\350\276\271\347\225\214.md" | 6 ++-- .../WaveBench_CLI\345\275\242\346\200\201.md" | 11 ++++--- ...07\344\273\266\346\240\274\345\274\217.md" | 16 +++++----- ...345\231\250\346\217\222\344\273\266API.md" | 4 +-- wavebench.example.toml | 8 +++-- 13 files changed, 76 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 2607e82..95055e7 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ wavebench tui --fake 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 -RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0 只读合同;DSG830 已完成 A1 只读快照证据,production descriptor 开放 `rf_source.idn` 和 `rf_source.snapshot`,但没有 RF 写入能力。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 +RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0–M2 合同;DSG830 已完成 A1 只读快照和 A2 受控输出证据,production descriptor 开放 `rf_source.idn`、`rf_source.snapshot` 和 `rf_source.output`。后者只覆盖具有完整 safety 配置的 `rf_out` ON/OFF;CW、调制、Pulse、Sweep 和触发仍由后续证据门控制。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 ## 三条常用路径 diff --git a/docs/README.md b/docs/README.md index d95268d..c8686d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,8 +54,8 @@ wavebench run check --plan /tmp/wavebench-demo.toml - [设备抽象层](project/design/WaveBench_设备抽象层.md) - [多仪器流程设计](project/design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](project/design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](project/design/WaveBench_RF信号源设计.md):当前 M0 与后续写入合同的边界。 -- [RF 信号源开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库状态与 A1–A5 证据门。 +- [RF 信号源领域设计](project/design/WaveBench_RF信号源设计.md):当前 M0–M2 合同、端口级输出安全规则与后续写入边界。 +- [RF 信号源开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库状态与 A1–A5 证据门;DSG830 已完成 A1/A2,仅开放 `rf_source.output` 写入。 - [TUI 终端控制面板](project/guides/WaveBench_TUI终端控制面板.md) 目录分类见 [project/README](project/README.md)。本页只负责入口,不把阶段记录当作当前使用说明。 diff --git a/docs/README_EN.md b/docs/README_EN.md index 65f92f7..ef56bfa 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -59,7 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) -- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core M0 is read-only; DSG830 A1 evidence permits production identity and read-only snapshot, while RF writes remain gated. +- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M2 contracts; DSG830 A1/A2 evidence permits production identity, snapshot, and safety-gated `rf_source.output` ON/OFF, while CW and later RF writes remain gated. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git a/docs/project/README.md b/docs/project/README.md index 556b02b..f08f1db 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -24,7 +24,7 @@ - [设备抽象层](design/WaveBench_设备抽象层.md) - [多仪器流程设计](design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):生产只读边界、M1/M2 离线合同、后续写入设计与安全规则。 +- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):M0–M2 合同、已开放的端口级输出边界、后续写入设计与安全规则。 - [RF 信号源开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core 与 DSG830 插件的同步状态、依赖和 A1–A5 实机证据门。 ## rfcs:接口提案与决策 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index eafc69f..875f49c 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0、M1、M2 离线完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 仍只读。 | -| DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与离线 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明只读 `rf_source.idn` 与 `rf_source.snapshot`。 | -| 真实仪器证据 | A1 已完成;A2–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 仅提升 `rf_source.snapshot`。 | +| Core `0.8.25` 开发线 | M0、M1、M2 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1、A2 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot` 和 `rf_source.output`。 | +| 真实仪器证据 | A1、A2 已完成;A3–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 仅提升端口级 output。 | ## 双仓库交付规则 @@ -29,7 +29,7 @@ | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact 和 fake 测试已完成;production capability 仍关闭。 | -| M2 | 离线完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;production capability 仍关闭。 | +| M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -58,7 +58,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;后续 M1–M4 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor。M1 的 CW capability 与 M3/M4 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 ### 离线完成条件 @@ -76,7 +76,7 @@ DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次 Core 已在离线环境中完成 `RfOutputRequest`/result、`rf_source.output_enable`/`rf_source.output_disable` OperationSpec、descriptor 输出 profile 校验、每端口 safety 预检、CLI、run schema/intent/dispatch 与带 preflight/postcondition snapshot 的 artifact。RF ON 必须确认完整 safety 配置、端接匹配、频率与 dBm 功率范围、已关闭的调制/Pulse/Sweep,以及只含已知非阻断项的 protection。RF OFF 不依赖频率、功率、端接或 protection readback。 -ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 只实现离线 `:OUTP ON|OFF` 单次映射,Core 负责所有 snapshot readback 与 recovery;production descriptor 直到 A2 后才可声明 `rf_source.output`。 +ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 使用单次 `:OUTP ON|OFF` 映射,Core 负责所有 snapshot readback 与 recovery;A2 已通过并将 `rf_source.output` 加入 production descriptor,不提升 CW 或后续 capability。 ## M3:调制 @@ -125,10 +125,18 @@ A1 已使用一次性、非 production 的本地 evidence harness 完成并经 本次证据已由人工复核,并在对应插件补丁中把 `rf_source.snapshot` 加入 DSG830 production descriptor。该提升不包含任何 RF 写 capability。 +### A2:已完成的受控 RF 输出验收 + +A2 使用一次性、非 production 的本地 evidence harness 完成并经复核。验收前使用有界网络发现确定候选资源,随后只在隔离 TOML 中使用已复核的 RF 和 scope 资源;发现结果、资源地址、序列号、原始响应、命令与波形均不进入证据。RF 配置与 scope 配置在静态预检阶段均保持 `read_only`,读重试关闭;只有显式执行阶段才在内存中创建受限的 write session。 + +主序列确认初始 RF OFF、一次 RF ON、独立 readback、一次 RF OFF 和独立 readback。启用前的 fresh snapshot 同时验证端口安全配置、实际端接、频率、功率、调制、Pulse、Sweep 和 protection。若 ON 的结果不明,Core 最多执行一次受授权的 OFF recovery;若 OFF transaction 已开始但结果不明,不重试。验收记录确认最终 RF OFF、关闭成功和无结果不明的 guard audit。scope 对 CH1/CH2 的当前缓冲区观察作为补充,CH2 的 50 Ω 输入由独立配置明确确认,不替代 RF readback。 + +该证据已将 DSG830 的 `rf_source.output` 加入 production descriptor。普通 CLI 和 run step 仍必须使用 `read_write`、完整端口 safety 配置和 fresh preflight;A3 之前不得开放 `rf_source.cw_configure`。 + ## 推荐实施顺序 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 -4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;后续仅在获得授权后单独评审 A2。 -5. 取得 A2、A3 证据后,按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 +4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 +5. 取得 A3 及后续证据后,继续按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 09e59c3..d040d50 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 离线 CW 和 M2 离线输出合同;生产环境仍只有经 A1 证据提升的只读能力。本文同时保留 M3–M4 的设计和 A1–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW 和 M2 端口输出合同;DSG830 已凭 A1/A2 证据开放 snapshot 与受 safety 限制的 output。本文同时保留 M3–M4 的设计和 A3–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 阅读顺序如下: @@ -15,13 +15,13 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW 事务,以及 M2 的离线端口输出事务、CLI、run step 与 artifact。 | 生产可用能力仍是 M0 只读;M1/M2 只能由 fake descriptor 覆盖。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、离线 `:FREQ`/`:LEV`/`:OUTP` 映射;A1 只读证据已经完成。 | production descriptor 仅声明 `rf_source.idn` 与 `rf_source.snapshot`;没有 RF 写 capability。 | -| 实机证据 | A1 已完成;A2–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 不能声明任何写 capability。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW 事务,以及 M2 端口输出事务、CLI、run step 与 artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射;A1/A2 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot` 和受 safety 限制的 `rf_source.output`;CW 与后续写 capability 仍关闭。 | +| 实机证据 | A1、A2 已完成;A3–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 只开放 snapshot 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「离线已完成」或「离线进行中」的内容外,本文中的 M3–M4、production 写 capability 与 A2–A5 均为目标合同或证据门。M1/M2 的离线实现也不得写成当前可控制真实仪器的能力。 +除明确标为「生产只读」「A2 已提升」「离线已完成」或「离线进行中」的内容外,本文中的 M3–M4、CW 与其它 production 写 capability、A3–A5 均为目标合同或证据门。M1 的离线实现不得写成当前可控制真实仪器的能力;M2 仅在已取得 A2 证据的插件上开放端口级 output。 ## 术语与证据级别 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1 的提升边界,以及 M1–M4 的离线开发和 fake transport 验证边界。A2–A5 实机验收、production 写 capability 声明和发行包推广另行处理;离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。A3–A5 实机验收、其它 production 写 capability 声明和发行包推广另行处理;离线代码不能替代这些证据。 ## 范围与非目标 @@ -46,7 +46,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - M0 已提供 `rf_source` plugin kind、配置、capability、model、driver Protocol、只读 Service/CLI/doctor、run status 和 artifact namespace。 - M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;它只供离线 fake descriptor 使用。 -- M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;它同样只供离线 fake descriptor 使用。 +- M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 - 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 @@ -57,7 +57,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 - 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 - 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 -- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`。 ## 分层与职责 @@ -293,7 +293,7 @@ wavebench rf-source set-power --port PORT_ID DBM rf_source.set_frequency rf_source.set_power_dbm -# 离线 M2:必须同时具备 read_write、output capability 和端口级 safety preflight +# 生产 M2:仅在已完成 A2 的插件上,且必须同时具备 read_write、output capability 和端口级 safety preflight wavebench rf-source output --port PORT_ID on|off rf_source.output_enable rf_source.output_disable @@ -301,15 +301,15 @@ rf_source.output_disable `rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 -M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 production descriptor 不声明 `rf_source.cw_configure` 或 `rf_source.output`,因此 M1/M2 CLI 和 run step 不能对已联网的 DSG830 发送写入。 +M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 production descriptor 仍不声明 `rf_source.cw_configure`,但已由 A2 声明 `rf_source.output`;因此 M1 CLI 和 run step 仍不能对已联网的 DSG830 写入,而 M2 仅在 `read_write`、完整端口 safety 配置和 fresh preflight 同时成立时可执行。 -### M1、M2 的离线写入合同 +### M1 的离线 CW 与 M2 的生产输出合同 M1 是 OFF-only CW 配置:目标端口必须明确为 OFF,调制、Pulse、Sweep 与 protection 不得冲突;每次调用只写一个频率或 dBm 字段,并用独立 snapshot 回读确认。写后结果不明时不重试。 M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端接与 dBm 参考阻抗一致、频率与 dBm 功率处于设备和实验室配置范围内、调制/Pulse/Sweep 都关闭,并且 protection 仅含已知的非阻断状态。RF OFF 只依赖目标输出状态,不要求频率、功率、端接或 protection 可读。ON 写入或其 readback 结果不明时,session 降为不确定状态,并且只在受 guard 的 recovery 预算内最多执行一次同端口 OFF 和 OFF 回读;OFF 写入结果不明时不重试,session 降为 poisoned。 -上述合同只由 fake descriptor 和 fake/guarded transport 验证。A2、A3 之前不得把它们纳入 DSG830 production descriptor,也不得把人工确认的实验室端接当作写入授权。 +M1 的 CW 合同仍只由 fake descriptor 和 fake/guarded transport 验证,A3 前不得把它纳入 DSG830 production descriptor。M2 已由 A2 在真实设备上完成受控 ON/OFF、独立 readback 与最终 OFF 验收,因而只将 `rf_source.output` 纳入 production descriptor;人工确认的实验室端接本身仍不构成任何额外写入授权。 ### M3–M4 目标 @@ -337,13 +337,13 @@ wavebench rf-source sweep stop ... | --- | --- | --- | --- | | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1(离线完成) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | -| M2(离线完成) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF。 | +| M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`;A2、A3、A4、A5 分别是 RF output、CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的提升门槛。未取得对应 evidence 时不得声明或提升 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`。A3、A4、A5 分别仍是 CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -362,7 +362,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1 完成后声明 `rf_source.idn` 与 `rf_source.snapshot`。`get_rf_snapshot()` 可通过该只读入口观察状态;严格 parser 与 A1 证据仍不能借此向已联网设备开放频率、功率、输出、调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2 完成后声明 `rf_source.idn`、`rf_source.snapshot` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。严格 parser 与 A1/A2 证据不开放频率、功率、调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -379,5 +379,5 @@ DSG830 的 production `descriptor()` 在 A1 完成后声明 `rf_source.idn` 与 - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0 离线验证已完成;DSG830 A1 snapshot 证据已通过并仅提升 production 的只读 snapshot。A2–A5 仍未开始,不能据此提升任何写 capability。 +- M0–M2 离线验证已完成;DSG830 A1 snapshot 与 A2 受控输出证据已通过,production 仅提升 snapshot 和 `rf_source.output`。A3–A5 仍未开始,不能据此提升 CW、调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index 40cab5a..e21d878 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -63,7 +63,7 @@ wavebench run verify --config wavebench.toml --plan plans/dp800_scope_probe_volt ## 资源租约 -`run plan` 在打开任何仪器 session 前,会为计划涉及的 `scope`、`source`、`power` 和 +`run plan` 在打开任何仪器 session 前,会为计划涉及的 `scope`、`source`、`rf_source`、`power` 和 `dmm` 资源按规范化资源键排序,并一次性取得本机独占租约。任一资源已被 其他进程占用时,后续 transport 不会打开,已取得的前置租约会全部释放,并返回稳定错误码 `resource_busy`。 @@ -228,13 +228,16 @@ source.set_func source.set_vpp source.output source.set_duty +rf_source.status +rf_source.output_enable +rf_source.output_disable power.status power.set power.output sleep ``` -当前执行器已经实装并实机验证的动作是: +当前执行器已实装的动作如下;对具体仪器执行仍取决于 descriptor capability、access 和各自的安全条件: ```text power.status @@ -247,12 +250,15 @@ source.set_func source.set_vpp source.set_duty source.output +rf_source.status +rf_source.output_enable +rf_source.output_disable sleep ``` `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 -RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core M0 已在 `run schema` 中提供只读 `rf_source.status`:它使用独立的类型化 snapshot artifact,并要求 descriptor 声明 `rf_source.snapshot`。DSG830 已完成 A1,并在 production descriptor 中声明该 capability,因此 status 可通过已配置的只读 session 读取快照。M1–M4 的频率、功率、输出和端口级 RF OFF safety gate 仍未进入 run schema。详见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、`rf_source.output_enable` 和 `rf_source.output_disable`:它们使用独立的类型化 RF artifact,并分别要求 snapshot 或 output capability。DSG830 已完成 A1/A2,production descriptor 声明 `rf_source.snapshot` 和受 safety 限制的 `rf_source.output`,因此 status 可在 `read_only` session 中读取快照,输出 step 仅在 `read_write`、完整端口 safety 配置与 fresh preflight 同时成立时执行。频率、功率与 M3/M4 capability 仍未开放。详见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 `scope.capture` 可以额外声明: @@ -311,6 +317,9 @@ Supported `[[steps]]` kinds: | `source.set_freq` | `frequency_hz` | `channel` | | `source.set_duty` | `duty_percent` | `channel` | | `source.output` | `state` | `channel` | +| `rf_source.status` | - | `on_failure` | +| `rf_source.output_enable` | `port_id` | `on_failure` | +| `rf_source.output_disable` | `port_id` | `on_failure` | | `scope.auto` | - | `on_failure`, `safety_gate` | | `scope.capture` | - | `channel`, `label`, `points`, `time_range_s`, `window_frequency_hz`, `target_cycles`, `expect_frequency_hz`, `frequency_tolerance`, `save_csv`, `save_npy`, `quality_gate`, `auto_recover`, `on_failure`, `safety_gate`, `[steps.expect]` | | `sleep` | `duration_s` | `on_failure`, `safety_gate` | diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index a6091b4..a96e84a 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -248,13 +248,13 @@ Service 层可以按以下顺序组合这些动作: 设置信号 → 等待稳定 → 采集波形 → 保存数据 → 计算指标 ``` -## RF 信号源:当前 M0 与后续阶段 +## RF 信号源:当前 M0–M2 与后续阶段 上述 `SignalGenerator` 示例只描述普通函数/任意波形发生器。RF 信号源以频率、dBm 功率、RF 输出和稳定 `port_id` 为主,不能把它映射为普通 `SourceDriver` 的 Vpp、offset、数字 channel 或波形接口。 -当前 M0 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、`rf-source idn`/`rf-source status` 和 `rf_source.status` run step。当前只读 Service 仍受 capability、access、资源租约与 session health 约束;descriptor 未声明 `rf_source.snapshot` 时,status 会在 transport I/O 前被拒绝。 +当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、`rf-source idn`/`rf-source status`/`rf-source output` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 -M1–M4 的安全预检、写入 transaction 与写入 run step 尚未实现。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +DSG830 已由 A1/A2 将 snapshot 和受 safety 限制的 `rf_source.output` 提升到 production。CW 写入仍待 A3,调制、Pulse、Sweep 与 trigger 仍待后续实现和对应证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 早期目录示意 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 2ad3dd9..000e676 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,8 +19,8 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| RF 信号源 | M0 只读插件领域:身份查询、类型化 snapshot 合同、配置、CLI 与 `rf_source.status` run step | 不复用普通 source;DSG830 已完成 A1,只声明 `rf_source.idn` 和 `rf_source.snapshot`,全部 RF 写入仍需实机证据 | -| run plan | source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样 | +| RF 信号源 | M0–M2 插件领域:身份查询、类型化 snapshot、端口级输出合同、配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2,声明 `rf_source.idn`、`rf_source.snapshot` 和受 safety 限制的 `rf_source.output`;CW 与后续写入仍需实机证据 | +| run plan | source、rf_source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样;RF 输出仍受 capability、access 和端口 safety 限制 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | | 插件 | V2 Python 插件、V1 metadata 和声明式 SCPI 检查 | Python 插件是可信代码,不是安全沙箱 | @@ -29,7 +29,7 @@ WaveBench 优先解决以下问题: RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 -当前 M0 提供 Core 侧只读合同。DSG830 已凭 A1 证据开放 production snapshot;写入设计仍须等待对应的 Core、插件和 A2–A5 证据,不能写成已支持能力。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +当前 Core 已提供 M0–M2 合同。DSG830 已凭 A1 证据开放 production snapshot,并凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`。这不授权 CW、调制、Pulse、Sweep 或 trigger;这些能力仍须等待各自的 A3–A5 证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 推荐工作顺序 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index a2eb092..6f0666a 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -88,15 +88,16 @@ wavebench rf-source set-frequency --port PORT_ID HZ --config wavebench.toml wavebench rf-source set-power --port PORT_ID DBM --config wavebench.toml wavebench rf-source output --port PORT_ID on|off --config wavebench.toml wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only +wavebench capability explain rf_source.output_enable --driver rigol.dsg830 --kind rf_source --access read_write ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 -`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1,并在 -production descriptor 中声明这两个只读 capability;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2,并在 +production descriptor 中声明两个只读 capability 与 `rf_source.output`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw SCPI。M1 的 `set-frequency` 与 `set-power` 还要求 `rf_source.cw_configure`、`read_write` 访问、已声明的 -CW profile 和完整的 OFF-only preflight。M2 的 `output` 还要求 `rf_source.output`、`read_write` 访问、可读 -output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 -当前 DSG830 production descriptor 不声明任一写 capability,因此这些命令会在打开 transport 前拒绝。调制、 +CW profile 和完整的 OFF-only preflight,DSG830 仍缺该 capability。M2 的 `output` 要求 `rf_source.output`、 +`read_write` 访问、可读 output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 +DSG830 仅对这条端口级 ON/OFF 路径开放 production 写入,缺少 `read_write`、安全配置或 fresh preflight 时仍在写入前拒绝。调制、 Pulse 和 Sweep 写入命令仍不存在。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index d4c1716..980e274 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -170,13 +170,13 @@ ensure_fix_mode_on_set_frequency = true settle_ms_after_set_frequency = 500 access = "read_write" -# DSG830 production 当前只开放 M0 只读;read_only 是默认安全选择。 +# DSG830 已完成 A1/A2;read_only 仍是身份与状态查询的默认安全选择。 [rf_source] driver = "rigol.dsg830" resource = "TCPIP::192.0.2.13::INSTR" access = "read_only" -# M2 的离线 ON preflight 会消费该静态证据;它不授予 production 写入能力。 +# `rf-source output` 的 ON preflight 会消费该静态证据;它不授权 CW 或其它 RF 写入能力。 [[rf_source.safety.ports]] port_id = "rf_out" minimum_frequency_hz = 9000 @@ -449,10 +449,10 @@ actual_termination_ohm = 50 `[rf_source]` 是独立于普通 `[source]` 的 RF 信号源配置。它使用 plugin descriptor 的稳定 `port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。M0 提供 `wavebench rf-source idn` 与 `wavebench rf-source status`;后者要求 production descriptor 声明 -`rf_source.snapshot`。M1 的频率/功率 CLI 和 M2 的输出 CLI 已有离线路由,但还分别要求对应 capability、 -`read_write` 访问和 fresh safety preflight。DSG830 已完成 A1,并只声明 `rf_source.idn` 与 -`rf_source.snapshot`,所以可在已配置的只读 session 中执行 status;任何写 CLI 都会在打开 transport 前被 -拒绝,直到对应 A 级证据提升 capability。 +`rf_source.snapshot`。M1 的频率/功率 CLI 和 M2 的输出 CLI 都要求对应 capability、`read_write` 访问和 +fresh safety preflight。DSG830 已完成 A1/A2,声明 `rf_source.idn`、`rf_source.snapshot` 与 +`rf_source.output`:status 可在已配置的只读 session 中执行,端口 ON/OFF 还要求切换为 `read_write` 并提供 +完整安全配置。CW 与其它写 CLI 仍会在打开 transport 前被 capability 门禁拒绝,直到对应 A 级证据提升。 字段说明: @@ -465,8 +465,8 @@ actual_termination_ohm = 50 `[[rf_source.safety.ports]]` 是按端口声明的本地静态安全证据。每项必须提供唯一 `port_id`、有限的 `minimum_frequency_hz`、`maximum_frequency_hz`、`maximum_power_dbm` 和正数 `actual_termination_ohm`;最大频率不得小于最小频率。它不改变仪器显示的负载设置,也不会把 dBm -换算为 Vpp。M2 的离线 RF ON 事务会使用它进行准入判断;production descriptor 缺少 output capability 时, -该事务仍会在打开 transport 前拒绝。 +换算为 Vpp。M2 的 RF ON 事务会使用它进行准入判断;若某个 descriptor 缺少 output capability,事务仍会在 +打开 transport 前拒绝。DSG830 已声明该 capability,但 `read_write`、完整安全配置和 fresh snapshot 缺一不可。 RF 的 capability、A1–A5 证据门和 DSG830 的当前 production 边界见 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)和 diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index f2f67e7..f6889a4 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -584,7 +584,7 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 -### RF 信号源 M0–M2(production 当前仅 M0) +### RF 信号源 M0–M2(DSG830 production 已含 A2 output) `rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source 能力。descriptor 必须同时满足以下静态条件: @@ -604,7 +604,7 @@ capability 的高级配置保持 V1。插件不得把 capability 注册视为 | `rf_source.idn` | `idn` | `wavebench rf-source idn`、doctor IDN target | | `rf_source.snapshot` | `get_rf_snapshot` | `wavebench rf-source status`、`rf_source.status` run step | | `rf_source.cw_configure` | `configure_cw` | `rf-source set-frequency`/`set-power`、对应 run step;OFF-only 离线合同 | -| `rf_source.output` | `set_rf_output` | `rf-source output`、`rf_source.output_enable`/`output_disable` run step;端口级离线合同 | +| `rf_source.output` | `set_rf_output` | `rf-source output`、`rf_source.output_enable`/`output_disable` run step;端口级 ON/OFF,受 capability、access、profile 和 fresh safety preflight 共同门禁 | `RfSourceDriver`、`RfSourceSnapshot`、`RfSourceDescriptorExtensions` 和相关类型均从 `wavebench.instruments` 导入。`rf_source.snapshot` 缺失时,status 入口会在 transport I/O 前拒绝;实现 diff --git a/wavebench.example.toml b/wavebench.example.toml index f78e46c..d83fadb 100644 --- a/wavebench.example.toml +++ b/wavebench.example.toml @@ -177,14 +177,16 @@ settle_ms_after_set_frequency = 500 # RF signal sources use an independent domain: Hz and dBm on stable port IDs, -# not the Vpp/channel model above. The M0 commands are read-only only. -# Install the matching RF plugin before enabling this section. +# not the Vpp/channel model above. `read_only` remains the safe default for +# identity/status. Install the matching RF plugin before enabling this section. # [rf_source] # driver = "rigol.dsg830" # resource = "TCPIP::192.0.2.13::INSTR" # access = "read_only" -# Future RF energy operations require one complete local declaration per port. +# DSG830 A2 output ON/OFF additionally requires `access = "read_write"`, its +# production `rf_source.output` capability, and one complete local declaration +# per port. CW frequency/power writes remain separately gated. # `actual_termination_ohm` is the physical termination evidence; it is not a # display-load setting and WaveBench never converts dBm to Vpp. # [[rf_source.safety.ports]] From 57def04c7cf433a52333c30df5a52d6c768f7b22 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:30:55 +0800 Subject: [PATCH 19/63] docs: prepare DSG830 A3 CW evidence --- ...21\351\207\214\347\250\213\347\242\221.md" | 22 +++++++++++++++---- ...67\346\272\220\350\256\276\350\256\241.md" | 8 +++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 875f49c..95c1f95 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -10,7 +10,7 @@ | --- | --- | --- | | Core `0.8.25` 开发线 | M0、M1、M2 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 能力由各插件证据逐项决定。 | | DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1、A2 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot` 和 `rf_source.output`。 | -| 真实仪器证据 | A1、A2 已完成;A3–A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 仅提升端口级 output。 | +| 真实仪器证据 | A1、A2 已完成;A3 本地 harness 已完成,实机证据待执行;A4、A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 仅提升端口级 output,A3 尚未提升 CW。 | ## 双仓库交付规则 @@ -28,7 +28,7 @@ | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | -| M1 | 离线完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact 和 fake 测试已完成;production capability 仍关闭。 | +| M1 | 离线完成;A3 harness 已完成,实机证据待执行 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试和本地 A3 harness 已完成;production capability 仍关闭。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -70,7 +70,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收已完成,production capability 仍关闭。 -DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入;production descriptor 仍不声明 CW write capability,直到 A3。 +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的本地 harness 与 fake 回归已完成,但 production descriptor 仍不声明 CW write capability,直到实机证据通过并复核。 ## M2:RF 输出安全事务 @@ -133,10 +133,24 @@ A2 使用一次性、非 production 的本地 evidence harness 完成并经复 该证据已将 DSG830 的 `rf_source.output` 加入 production descriptor。普通 CLI 和 run step 仍必须使用 `read_write`、完整端口 safety 配置和 fresh preflight;A3 之前不得开放 `rf_source.cw_configure`。 +### A3:CW 环回准备完成,实机证据待执行 + +DSG830 插件源码 checkout 已提供本地 `tools/a3_cw_evidence.py`、回归测试和不含资源地址的 setup 模板。它不进入 +wheel 或 sdist,不修改 production descriptor;当前普通 CLI 与 run step 仍会在 capability 门禁处拒绝 CW 写入。 + +静态预检要求 RF 与 scope 配置保持 `read_only`、读重试关闭且资源不同。显式 `--execute` 后,harness 才在内存中建立 +只包含一个频点、一个低功率上限和实际端接声明的 write 配置。主序列为:初始 RF OFF snapshot、一次频率写入及独立 +readback、一次功率写入及独立 readback、一次由已验证 M2 能力执行的 RF ON/OFF、CH2 当前 `DEF` 缓冲区读取,以及最终 +RF OFF 的独立 readback。未确认最终 OFF、任一 CW readback 不符或 CH2 未观察到可见信号都会使 A3 失败。 + +CH2 的 50 Ω 端接只是在 setup 中明确声明的电气安全前提。scope 只提供「可见信号」补充证据,不进行 dBm 与 Vpp 换算, +也不代替源端频率/功率回读。CH1 接入的低频辅助输出是独立端口,A3 不读取、不控制,也不从其观测推断 RF 输出状态或 +通过条件。成功路径结束时 RF 输出必须为 OFF;频率与功率将保留在 setup 指定的测试值。 + ## 推荐实施顺序 1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 -5. 取得 A3 及后续证据后,继续按 capability 而非按「整台仪器已支持」逐项提升 production descriptor;M3/M4 保持独立工作。 +5. A3 的本地 harness 已完成;执行并复核受控实机证据后,才将 `rf_source.cw_configure` 加入 production descriptor。后续 capability 仍按单项证据提升;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index d040d50..ab1dd8a 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -17,7 +17,7 @@ | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW 事务,以及 M2 端口输出事务、CLI、run step 与 artifact。 | production capability 仍由各插件的实机证据逐项决定。 | | DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射;A1/A2 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot` 和受 safety 限制的 `rf_source.output`;CW 与后续写 capability 仍关闭。 | -| 实机证据 | A1、A2 已完成;A3–A5 尚未形成可提升 capability 的记录。 | DSG830 production descriptor 只开放 snapshot 和端口级 output。 | +| 实机证据 | A1、A2 已完成;A3 的本地 harness 已完成,但尚未形成可提升 capability 的实机记录;A4、A5 未开始。 | DSG830 production descriptor 只开放 snapshot 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。A3–A5 实机验收、其它 production 写 capability 声明和发行包推广另行处理;离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。A3 的本地 evidence harness 已就绪,但 A3–A5 实机验收、其它 production 写 capability 声明和发行包推广仍另行处理;离线代码不能替代这些证据。 ## 范围与非目标 @@ -343,7 +343,7 @@ wavebench rf-source sweep stop ... M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`。A3、A4、A5 分别仍是 CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`。A3 的本地 evidence harness 已完成,但 A3、A4、A5 仍分别是 CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -379,5 +379,5 @@ DSG830 的 production `descriptor()` 在 A1/A2 完成后声明 `rf_source.idn` - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M2 离线验证已完成;DSG830 A1 snapshot 与 A2 受控输出证据已通过,production 仅提升 snapshot 和 `rf_source.output`。A3–A5 仍未开始,不能据此提升 CW、调制、Pulse、Sweep 或 trigger capability。 +- M0–M2 离线验证已完成;DSG830 A1 snapshot 与 A2 受控输出证据已通过,production 仅提升 snapshot 和 `rf_source.output`。A3 的本地 CW evidence harness 与回归已完成,但尚未执行实机证据,不能据此提升 CW、调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 From 37c971adc905ae7ed04c895b086ceee293c63901 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:44:14 +0800 Subject: [PATCH 20/63] docs: record DSG830 A3 CW promotion --- README.md | 2 +- docs/README.md | 4 +-- docs/README_EN.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 31 ++++++++++--------- ...67\346\272\220\350\256\276\350\256\241.md" | 30 +++++++++--------- ...07\346\212\275\350\261\241\345\261\202.md" | 4 +-- .../WaveBench_CLI\345\275\242\346\200\201.md" | 8 ++--- ...345\231\250\346\217\222\344\273\266API.md" | 4 +-- 8 files changed, 43 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 95055e7..389d1c1 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ wavebench tui --fake 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 -RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0–M2 合同;DSG830 已完成 A1 只读快照和 A2 受控输出证据,production descriptor 开放 `rf_source.idn`、`rf_source.snapshot` 和 `rf_source.output`。后者只覆盖具有完整 safety 配置的 `rf_out` ON/OFF;CW、调制、Pulse、Sweep 和触发仍由后续证据门控制。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 +RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0–M2 合同;DSG830 已完成 A1 只读快照、A2 受控输出和 A3 CW 环回证据,production descriptor 开放 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。CW 只覆盖目标 RF OFF 时的单字段频率/dBm 功率写入,输出只覆盖具有完整 safety 配置的 `rf_out` ON/OFF;调制、Pulse、Sweep 和触发仍由后续证据门控制。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 ## 三条常用路径 diff --git a/docs/README.md b/docs/README.md index c8686d2..f23bfdd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,8 +54,8 @@ wavebench run check --plan /tmp/wavebench-demo.toml - [设备抽象层](project/design/WaveBench_设备抽象层.md) - [多仪器流程设计](project/design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](project/design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](project/design/WaveBench_RF信号源设计.md):当前 M0–M2 合同、端口级输出安全规则与后续写入边界。 -- [RF 信号源开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库状态与 A1–A5 证据门;DSG830 已完成 A1/A2,仅开放 `rf_source.output` 写入。 +- [RF 信号源领域设计](project/design/WaveBench_RF信号源设计.md):当前 M0–M2 合同、OFF-only CW/端口级输出安全规则与后续写入边界。 +- [RF 信号源开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库状态与 A1–A5 证据门;DSG830 已完成 A1/A2/A3,开放 `rf_source.cw_configure` 和 `rf_source.output` 写入。 - [TUI 终端控制面板](project/guides/WaveBench_TUI终端控制面板.md) 目录分类见 [project/README](project/README.md)。本页只负责入口,不把阶段记录当作当前使用说明。 diff --git a/docs/README_EN.md b/docs/README_EN.md index ef56bfa..ec29389 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -59,7 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) -- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M2 contracts; DSG830 A1/A2 evidence permits production identity, snapshot, and safety-gated `rf_source.output` ON/OFF, while CW and later RF writes remain gated. +- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M2 contracts; DSG830 A1/A2/A3 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, and safety-gated `rf_source.output` ON/OFF, while later RF writes remain gated. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 95c1f95..03b01ae 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -9,8 +9,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | | Core `0.8.25` 开发线 | M0、M1、M2 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1、A2 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot` 和 `rf_source.output`。 | -| 真实仪器证据 | A1、A2 已完成;A3 本地 harness 已完成,实机证据待执行;A4、A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 仅提升端口级 output,A3 尚未提升 CW。 | +| DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1、A2、A3 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | +| 真实仪器证据 | A1、A2、A3 已完成;A4、A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | ## 双仓库交付规则 @@ -28,7 +28,7 @@ | --- | --- | --- | --- | --- | | Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | -| M1 | 离线完成;A3 harness 已完成,实机证据待执行 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试和本地 A3 harness 已完成;production capability 仍关闭。 | +| M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | @@ -58,7 +58,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor。M1 的 CW capability 与 M3/M4 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor。M3/M4 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 ### 离线完成条件 @@ -68,15 +68,15 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 ## M1:OFF-only CW 配置 -Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收已完成,production capability 仍关闭。 +Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收和 A3 受控实机证据均已完成;DSG830 production 已开放 CW capability。 -DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的本地 harness 与 fake 回归已完成,但 production descriptor 仍不声明 CW write capability,直到实机证据通过并复核。 +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;调制、Pulse、Sweep 与 trigger 仍继续关闭。 ## M2:RF 输出安全事务 Core 已在离线环境中完成 `RfOutputRequest`/result、`rf_source.output_enable`/`rf_source.output_disable` OperationSpec、descriptor 输出 profile 校验、每端口 safety 预检、CLI、run schema/intent/dispatch 与带 preflight/postcondition snapshot 的 artifact。RF ON 必须确认完整 safety 配置、端接匹配、频率与 dBm 功率范围、已关闭的调制/Pulse/Sweep,以及只含已知非阻断项的 protection。RF OFF 不依赖频率、功率、端接或 protection readback。 -ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 使用单次 `:OUTP ON|OFF` 映射,Core 负责所有 snapshot readback 与 recovery;A2 已通过并将 `rf_source.output` 加入 production descriptor,不提升 CW 或后续 capability。 +ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 使用单次 `:OUTP ON|OFF` 映射,Core 负责所有 snapshot readback 与 recovery;A2 已将 `rf_source.output` 加入 production descriptor,后续 A3 单独提升 CW,不提升 M3/M4 或其它 capability。 ## M3:调制 @@ -131,21 +131,22 @@ A2 使用一次性、非 production 的本地 evidence harness 完成并经复 主序列确认初始 RF OFF、一次 RF ON、独立 readback、一次 RF OFF 和独立 readback。启用前的 fresh snapshot 同时验证端口安全配置、实际端接、频率、功率、调制、Pulse、Sweep 和 protection。若 ON 的结果不明,Core 最多执行一次受授权的 OFF recovery;若 OFF transaction 已开始但结果不明,不重试。验收记录确认最终 RF OFF、关闭成功和无结果不明的 guard audit。scope 对 CH1/CH2 的当前缓冲区观察作为补充,CH2 的 50 Ω 输入由独立配置明确确认,不替代 RF readback。 -该证据已将 DSG830 的 `rf_source.output` 加入 production descriptor。普通 CLI 和 run step 仍必须使用 `read_write`、完整端口 safety 配置和 fresh preflight;A3 之前不得开放 `rf_source.cw_configure`。 +该证据已将 DSG830 的 `rf_source.output` 加入 production descriptor。普通 CLI 和 run step 仍必须使用 `read_write`、完整端口 safety 配置和 fresh preflight;A2 本身不提升 CW,后者由后续 A3 单独验证。 -### A3:CW 环回准备完成,实机证据待执行 +### A3:已完成的 CW 环回验收 -DSG830 插件源码 checkout 已提供本地 `tools/a3_cw_evidence.py`、回归测试和不含资源地址的 setup 模板。它不进入 -wheel 或 sdist,不修改 production descriptor;当前普通 CLI 与 run step 仍会在 capability 门禁处拒绝 CW 写入。 +DSG830 插件源码 checkout 保留本地 `tools/a3_cw_evidence.py`、回归测试和不含资源地址的 setup 模板。它不进入 +wheel 或 sdist。受控序列已通过并经复核,production descriptor 现在声明 `rf_source.cw_configure`;historical harness 会以 +`production_cw_gate_changed` 拒绝重跑。 静态预检要求 RF 与 scope 配置保持 `read_only`、读重试关闭且资源不同。显式 `--execute` 后,harness 才在内存中建立 只包含一个频点、一个低功率上限和实际端接声明的 write 配置。主序列为:初始 RF OFF snapshot、一次频率写入及独立 readback、一次功率写入及独立 readback、一次由已验证 M2 能力执行的 RF ON/OFF、CH2 当前 `DEF` 缓冲区读取,以及最终 RF OFF 的独立 readback。未确认最终 OFF、任一 CW readback 不符或 CH2 未观察到可见信号都会使 A3 失败。 -CH2 的 50 Ω 端接只是在 setup 中明确声明的电气安全前提。scope 只提供「可见信号」补充证据,不进行 dBm 与 Vpp 换算, -也不代替源端频率/功率回读。CH1 接入的低频辅助输出是独立端口,A3 不读取、不控制,也不从其观测推断 RF 输出状态或 -通过条件。成功路径结束时 RF 输出必须为 OFF;频率与功率将保留在 setup 指定的测试值。 +CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 只提供「可见信号」补充证据,不进行 dBm 与 Vpp 换算, +也不代替源端频率/功率回读。CH1 接入的低频辅助输出是独立端口,A3 未读取、未控制,也未从其观测推断 RF 输出状态或 +通过条件。本次证据确认两项 CW 源端回读、CH2 可见信号、4 次完成写入、72 次查询、健康关闭和最终 RF OFF;频率与功率保留为 setup 指定的测试值。 ## 推荐实施顺序 @@ -153,4 +154,4 @@ CH2 的 50 Ω 端接只是在 setup 中明确声明的电气安全前提。scope 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 -5. A3 的本地 harness 已完成;执行并复核受控实机证据后,才将 `rf_source.cw_configure` 加入 production descriptor。后续 capability 仍按单项证据提升;M3/M4 保持独立工作。 +5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。后续 capability 仍按单项证据提升;M3/M4 保持独立工作。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index ab1dd8a..b8587fc 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW 和 M2 端口输出合同;DSG830 已凭 A1/A2 证据开放 snapshot 与受 safety 限制的 output。本文同时保留 M3–M4 的设计和 A3–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW 和 M2 端口输出合同;DSG830 已凭 A1/A2/A3 证据开放 snapshot、OFF-only CW 与受 safety 限制的 output。本文同时保留 M3–M4 的设计和 A4–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 阅读顺序如下: @@ -16,12 +16,12 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW 事务,以及 M2 端口输出事务、CLI、run step 与 artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射;A1/A2 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot` 和受 safety 限制的 `rf_source.output`;CW 与后续写 capability 仍关闭。 | -| 实机证据 | A1、A2 已完成;A3 的本地 harness 已完成,但尚未形成可提升 capability 的实机记录;A4、A5 未开始。 | DSG830 production descriptor 只开放 snapshot 和端口级 output。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射;A1/A2/A3 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | +| 实机证据 | A1、A2、A3 已完成;A4、A5 未开始。 | DSG830 production descriptor 开放 snapshot、OFF-only CW 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「A2 已提升」「离线已完成」或「离线进行中」的内容外,本文中的 M3–M4、CW 与其它 production 写 capability、A3–A5 均为目标合同或证据门。M1 的离线实现不得写成当前可控制真实仪器的能力;M2 仅在已取得 A2 证据的插件上开放端口级 output。 +除明确标为「生产只读」「A2 已提升」「A3 已提升」「离线已完成」或「离线进行中」的内容外,本文中的 M3–M4 与其它 production 写 capability、A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output。 ## 术语与证据级别 @@ -38,14 +38,14 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。A3 的本地 evidence harness 已就绪,但 A3–A5 实机验收、其它 production 写 capability 声明和发行包推广仍另行处理;离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。A4–A5 实机验收、其它 production 写 capability 声明和发行包推广仍另行处理;离线代码不能替代这些证据。 ## 范围与非目标 ### 目标交付 - M0 已提供 `rf_source` plugin kind、配置、capability、model、driver Protocol、只读 Service/CLI/doctor、run status 和 artifact namespace。 -- M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;它只供离线 fake descriptor 使用。 +- M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;DSG830 已由 A3 将其提升到 production。 - M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 - 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同。 @@ -57,7 +57,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 - 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 - 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 -- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`。 ## 分层与职责 @@ -287,7 +287,7 @@ wavebench rf-source idn wavebench rf-source status rf_source.status -# 离线 M1:必须同时具备 read_write、CW capability 和 OFF-only preflight +# 生产 M1:仅在已完成 A3 的插件上,且必须同时具备 read_write、CW capability 和 OFF-only preflight wavebench rf-source set-frequency --port PORT_ID HZ wavebench rf-source set-power --port PORT_ID DBM rf_source.set_frequency @@ -301,15 +301,15 @@ rf_source.output_disable `rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 -M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 production descriptor 仍不声明 `rf_source.cw_configure`,但已由 A2 声明 `rf_source.output`;因此 M1 CLI 和 run step 仍不能对已联网的 DSG830 写入,而 M2 仅在 `read_write`、完整端口 safety 配置和 fresh preflight 同时成立时可执行。 +M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 已由 A3 声明 `rf_source.cw_configure`,并由 A2 声明 `rf_source.output`;M1 仅在 `read_write`、目标端口明确 OFF 与完整 OFF-only preflight 同时成立时可执行,M2 还要求完整端口 safety 配置和 fresh preflight。 -### M1 的离线 CW 与 M2 的生产输出合同 +### M1 的生产 CW 与 M2 的生产输出合同 M1 是 OFF-only CW 配置:目标端口必须明确为 OFF,调制、Pulse、Sweep 与 protection 不得冲突;每次调用只写一个频率或 dBm 字段,并用独立 snapshot 回读确认。写后结果不明时不重试。 M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端接与 dBm 参考阻抗一致、频率与 dBm 功率处于设备和实验室配置范围内、调制/Pulse/Sweep 都关闭,并且 protection 仅含已知的非阻断状态。RF OFF 只依赖目标输出状态,不要求频率、功率、端接或 protection 可读。ON 写入或其 readback 结果不明时,session 降为不确定状态,并且只在受 guard 的 recovery 预算内最多执行一次同端口 OFF 和 OFF 回读;OFF 写入结果不明时不重试,session 降为 poisoned。 -M1 的 CW 合同仍只由 fake descriptor 和 fake/guarded transport 验证,A3 前不得把它纳入 DSG830 production descriptor。M2 已由 A2 在真实设备上完成受控 ON/OFF、独立 readback 与最终 OFF 验收,因而只将 `rf_source.output` 纳入 production descriptor;人工确认的实验室端接本身仍不构成任何额外写入授权。 +M1 已由 A3 在真实设备上完成受控频率/功率写入、独立 readback、低功率 RF ON/OFF 环回与最终 OFF 验收,因而将 `rf_source.cw_configure` 纳入 DSG830 production descriptor。M2 已由 A2 将 `rf_source.output` 纳入同一 descriptor;人工确认的实验室端接本身仍不构成调制、Pulse、Sweep、trigger 或其它额外写入授权。 ### M3–M4 目标 @@ -336,14 +336,14 @@ wavebench rf-source sweep stop ... | 里程碑 | 通用核心交付 | 首个适配器离线交付 | 离线验证标准 | | --- | --- | --- | --- | | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | -| M1(离线完成) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试。 | +| M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`。A3 的本地 evidence harness 已完成,但 A3、A4、A5 仍分别是 CW 配置、调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`。A4、A5 仍分别是调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -362,7 +362,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2 完成后声明 `rf_source.idn`、`rf_source.snapshot` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。严格 parser 与 A1/A2 证据不开放频率、功率、调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -379,5 +379,5 @@ DSG830 的 production `descriptor()` 在 A1/A2 完成后声明 `rf_source.idn` - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M2 离线验证已完成;DSG830 A1 snapshot 与 A2 受控输出证据已通过,production 仅提升 snapshot 和 `rf_source.output`。A3 的本地 CW evidence harness 与回归已完成,但尚未执行实机证据,不能据此提升 CW、调制、Pulse、Sweep 或 trigger capability。 +- M0–M2 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。A4–A5 尚未开始,不能据此提升调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index a96e84a..a44f67a 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -252,9 +252,9 @@ Service 层可以按以下顺序组合这些动作: 上述 `SignalGenerator` 示例只描述普通函数/任意波形发生器。RF 信号源以频率、dBm 功率、RF 输出和稳定 `port_id` 为主,不能把它映射为普通 `SourceDriver` 的 Vpp、offset、数字 channel 或波形接口。 -当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、`rf-source idn`/`rf-source status`/`rf-source output` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 +当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 -DSG830 已由 A1/A2 将 snapshot 和受 safety 限制的 `rf_source.output` 提升到 production。CW 写入仍待 A3,调制、Pulse、Sweep 与 trigger 仍待后续实现和对应证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +DSG830 已由 A1/A2/A3 将 snapshot、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output` 提升到 production。调制、Pulse、Sweep 与 trigger 仍待后续实现和对应证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 早期目录示意 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 6f0666a..1d281a9 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -92,12 +92,12 @@ wavebench capability explain rf_source.output_enable --driver rigol.dsg830 --kin ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 -`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2,并在 -production descriptor 中声明两个只读 capability 与 `rf_source.output`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2/A3,并在 +production descriptor 中声明两个只读 capability、`rf_source.cw_configure` 与 `rf_source.output`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw SCPI。M1 的 `set-frequency` 与 `set-power` 还要求 `rf_source.cw_configure`、`read_write` 访问、已声明的 -CW profile 和完整的 OFF-only preflight,DSG830 仍缺该 capability。M2 的 `output` 要求 `rf_source.output`、 +CW profile 和完整的 OFF-only preflight。M2 的 `output` 要求 `rf_source.output`、 `read_write` 访问、可读 output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 -DSG830 仅对这条端口级 ON/OFF 路径开放 production 写入,缺少 `read_write`、安全配置或 fresh preflight 时仍在写入前拒绝。调制、 +DSG830 对 OFF-only CW 与端口级 ON/OFF 路径开放 production 写入;缺少 `read_write`、安全配置或 fresh preflight 时仍在写入前拒绝。调制、 Pulse 和 Sweep 写入命令仍不存在。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index f6889a4..f20c6aa 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -584,7 +584,7 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 -### RF 信号源 M0–M2(DSG830 production 已含 A2 output) +### RF 信号源 M0–M2(DSG830 production 已含 A2 output 与 A3 CW) `rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source 能力。descriptor 必须同时满足以下静态条件: @@ -603,7 +603,7 @@ capability 的高级配置保持 V1。插件不得把 capability 注册视为 | --- | --- | --- | | `rf_source.idn` | `idn` | `wavebench rf-source idn`、doctor IDN target | | `rf_source.snapshot` | `get_rf_snapshot` | `wavebench rf-source status`、`rf_source.status` run step | -| `rf_source.cw_configure` | `configure_cw` | `rf-source set-frequency`/`set-power`、对应 run step;OFF-only 离线合同 | +| `rf_source.cw_configure` | `configure_cw` | `rf-source set-frequency`/`set-power`、对应 run step;OFF-only 合同,DSG830 经 A3 复核后已提升 | | `rf_source.output` | `set_rf_output` | `rf-source output`、`rf_source.output_enable`/`output_disable` run step;端口级 ON/OFF,受 capability、access、profile 和 fresh safety preflight 共同门禁 | `RfSourceDriver`、`RfSourceSnapshot`、`RfSourceDescriptorExtensions` 和相关类型均从 From 84d969ca8c6a6a50db0bcc5186a719688322c6b7 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:57:26 +0800 Subject: [PATCH 21/63] feat: add RF modulation M3 contracts --- .../instruments/rf_source_capabilities.py | 29 ++ .../instruments/rf_source_extensions.py | 327 ++++++++++++++++++ tests/test_rf_source_extensions.py | 4 + tests/test_rf_source_modulation_extensions.py | 310 +++++++++++++++++ 4 files changed, 670 insertions(+) create mode 100644 tests/test_rf_source_modulation_extensions.py diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index af11c5b..260a832 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -18,6 +18,7 @@ RfCwProfile, RfFeature, RfFeatureDirection, + RfModulationProfile, RfOutputProfile, RfSourceDescriptorExtensions, ) @@ -28,6 +29,10 @@ "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), "rf_source.cw_configure": ("configure_cw",), + "rf_source.modulation_configure": ( + "get_rf_modulation_snapshot", + "configure_rf_modulation", + ), "rf_source.output": ("set_rf_output",), } ) @@ -66,6 +71,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No raise ConfigError("rf_source descriptors require the rf_source.idn capability") if "rf_source.cw_configure" in rf_capabilities: _validate_cw_configure_feature(extensions) + if "rf_source.modulation_configure" in rf_capabilities: + _validate_modulation_configure_feature(extensions) if "rf_source.output" in rf_capabilities: _validate_output_feature(extensions) _validate_rf_source_version_range(descriptor) @@ -116,6 +123,28 @@ def _validate_output_feature(extensions: RfSourceDescriptorExtensions) -> None: raise ConfigError("rf_source.output requires readable RF output state") +def _validate_modulation_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.modulation_configure requires an RF modulation feature with " + "configure and read directions" + ) + if not isinstance(feature.profile, RfModulationProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.modulation_configure requires an RF modulation profile") + if not feature.profile.configuration_readable or not feature.profile.mode_profiles: + raise ConfigError( + "rf_source.modulation_configure requires readable bounded modulation mode profiles" + ) + + def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 39deb00..c1164d0 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -21,6 +21,7 @@ RF_SOURCE_CONTRACT_VERSION = "wavebench.rf_source.v1" RF_SOURCE_SNAPSHOT_SCHEMA = "wavebench.rf_source.snapshot.v1" +RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" @@ -120,6 +121,30 @@ class RfModulationState(StrEnum): ENABLED = "enabled" +class RfModulationKind(StrEnum): + AM = "am" + FM = "fm" + PM = "pm" + + +class RfModulationSource(StrEnum): + """The M3 contract intentionally exposes only the instrument-internal source.""" + + INTERNAL = "internal" + + +class RfModulationWaveform(StrEnum): + """The M3 contract intentionally exposes only an internal sine waveform.""" + + SINE = "sine" + + +class RfModulationValueUnit(StrEnum): + PERCENT = "percent" + HZ = "hz" + RAD = "rad" + + class RfPulseState(StrEnum): DISABLED = "disabled" ENABLED = "enabled" @@ -302,6 +327,46 @@ def as_dict(self) -> dict[str, object]: return rf_source_snapshot_document(self) +@dataclass(frozen=True, slots=True) +class RfModulationModeProfile: + """One bounded, readable internal-sine modulation mode declaration.""" + + kind: RfModulationKind + value_unit: RfModulationValueUnit + value_min: float + value_max: float + internal_frequency_min_hz: float + internal_frequency_max_hz: float + source: RfModulationSource = RfModulationSource.INTERNAL + waveform: RfModulationWaveform = RfModulationWaveform.SINE + + def __post_init__(self) -> None: + if not isinstance(self.kind, RfModulationKind): + raise ValueError("RF modulation mode kind has an invalid type") + if not isinstance(self.value_unit, RfModulationValueUnit): + raise ValueError("RF modulation mode value_unit has an invalid type") + if not isinstance(self.source, RfModulationSource): + raise ValueError("RF modulation mode source has an invalid type") + if not isinstance(self.waveform, RfModulationWaveform): + raise ValueError("RF modulation mode waveform has an invalid type") + _require_finite(self.value_min, "RF modulation mode value_min") + _require_finite( + self.value_max, + "RF modulation mode value_max", + minimum=self.value_min, + ) + _require_finite( + self.internal_frequency_min_hz, + "RF modulation mode internal_frequency_min_hz", + minimum=0.0, + ) + _require_finite( + self.internal_frequency_max_hz, + "RF modulation mode internal_frequency_max_hz", + minimum=self.internal_frequency_min_hz, + ) + + @dataclass(frozen=True, slots=True) class RfCwProfile: frequency_readable: bool @@ -331,9 +396,21 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class RfModulationProfile: state_readable: bool + configuration_readable: bool = False + mode_profiles: tuple[RfModulationModeProfile, ...] = () def __post_init__(self) -> None: _require_bool(self.state_readable, "RF modulation state_readable") + _require_bool(self.configuration_readable, "RF modulation configuration_readable") + if not isinstance(self.mode_profiles, tuple) or any( + not isinstance(profile, RfModulationModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF modulation mode_profiles have an invalid type") + kinds = tuple(profile.kind for profile in self.mode_profiles) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds, key=lambda item: item.value)) != kinds: + raise ValueError("RF modulation mode_profiles must be sorted and unique") + if self.configuration_readable and not self.state_readable: + raise ValueError("RF modulation configuration readback requires readable state") @dataclass(frozen=True, slots=True) @@ -461,6 +538,180 @@ def __post_init__(self) -> None: _require_finite(self.power_dbm, "RF CW result power_dbm") +def _validate_modulation_fields( + *, + kind: RfModulationKind, + depth_percent: float | None, + frequency_deviation_hz: float | None, + phase_deviation_rad: float | None, + label: str, +) -> None: + if not isinstance(kind, RfModulationKind): + raise ValueError(f"{label} kind has an invalid type") + fields = { + RfModulationKind.AM: depth_percent, + RfModulationKind.FM: frequency_deviation_hz, + RfModulationKind.PM: phase_deviation_rad, + } + if sum(value is not None for value in fields.values()) != 1 or fields[kind] is None: + raise ValueError(f"{label} must set exactly the parameter for its modulation kind") + for field, value in fields.items(): + if value is not None: + _require_finite(value, f"{label} {field.value} value", minimum=0.0) + + +@dataclass(frozen=True, slots=True) +class RfModulationRequest: + """One bounded internal-sine AM, FM, or PM configuration for one RF port.""" + + port_id: str + kind: RfModulationKind + internal_frequency_hz: float + depth_percent: float | None = None + frequency_deviation_hz: float | None = None + phase_deviation_rad: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation request port_id") + _require_finite( + self.internal_frequency_hz, + "RF modulation request internal_frequency_hz", + minimum=0.0, + ) + _validate_modulation_fields( + kind=self.kind, + depth_percent=self.depth_percent, + frequency_deviation_hz=self.frequency_deviation_hz, + phase_deviation_rad=self.phase_deviation_rad, + label="RF modulation request", + ) + + @property + def value(self) -> float: + value = { + RfModulationKind.AM: self.depth_percent, + RfModulationKind.FM: self.frequency_deviation_hz, + RfModulationKind.PM: self.phase_deviation_rad, + }[self.kind] + assert value is not None + return value + + @property + def value_unit(self) -> RfModulationValueUnit: + return { + RfModulationKind.AM: RfModulationValueUnit.PERCENT, + RfModulationKind.FM: RfModulationValueUnit.HZ, + RfModulationKind.PM: RfModulationValueUnit.RAD, + }[self.kind] + + +@dataclass(frozen=True, slots=True) +class RfModulationResult: + """An internal-sine modulation request confirmed by typed readback.""" + + port_id: str + kind: RfModulationKind + internal_frequency_hz: float + depth_percent: float | None = None + frequency_deviation_hz: float | None = None + phase_deviation_rad: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation result port_id") + _require_finite( + self.internal_frequency_hz, + "RF modulation result internal_frequency_hz", + minimum=0.0, + ) + _validate_modulation_fields( + kind=self.kind, + depth_percent=self.depth_percent, + frequency_deviation_hz=self.frequency_deviation_hz, + phase_deviation_rad=self.phase_deviation_rad, + label="RF modulation result", + ) + + @property + def value(self) -> float: + value = { + RfModulationKind.AM: self.depth_percent, + RfModulationKind.FM: self.frequency_deviation_hz, + RfModulationKind.PM: self.phase_deviation_rad, + }[self.kind] + assert value is not None + return value + + @property + def value_unit(self) -> RfModulationValueUnit: + return { + RfModulationKind.AM: RfModulationValueUnit.PERCENT, + RfModulationKind.FM: RfModulationValueUnit.HZ, + RfModulationKind.PM: RfModulationValueUnit.RAD, + }[self.kind] + + +@dataclass(frozen=True, slots=True) +class RfModulationSnapshot: + """Complete typed readback for one internal-sine modulation mode.""" + + port_id: str + kind: RfModulationKind + source: RfModulationSource + waveform: RfModulationWaveform + internal_frequency_hz: float + depth_percent: float | None = None + frequency_deviation_hz: float | None = None + phase_deviation_rad: float | None = None + enabled_modes: tuple[RfModulationKind, ...] = () + global_enabled: bool = False + fault_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation snapshot port_id") + if not isinstance(self.source, RfModulationSource): + raise ValueError("RF modulation snapshot source has an invalid type") + if not isinstance(self.waveform, RfModulationWaveform): + raise ValueError("RF modulation snapshot waveform has an invalid type") + _require_finite( + self.internal_frequency_hz, + "RF modulation snapshot internal_frequency_hz", + minimum=0.0, + ) + _validate_modulation_fields( + kind=self.kind, + depth_percent=self.depth_percent, + frequency_deviation_hz=self.frequency_deviation_hz, + phase_deviation_rad=self.phase_deviation_rad, + label="RF modulation snapshot", + ) + _require_enum_tuple( + self.enabled_modes, + RfModulationKind, + "RF modulation snapshot enabled_modes", + allow_empty=True, + ) + _require_bool(self.global_enabled, "RF modulation snapshot global_enabled") + _require_token_tuple(self.fault_codes, "RF modulation snapshot fault_codes", allow_empty=True) + + @property + def value(self) -> float: + value = { + RfModulationKind.AM: self.depth_percent, + RfModulationKind.FM: self.frequency_deviation_hz, + RfModulationKind.PM: self.phase_deviation_rad, + }[self.kind] + assert value is not None + return value + + @property + def value_unit(self) -> RfModulationValueUnit: + return { + RfModulationKind.AM: RfModulationValueUnit.PERCENT, + RfModulationKind.FM: RfModulationValueUnit.HZ, + RfModulationKind.PM: RfModulationValueUnit.RAD, + }[self.kind] + + @dataclass(frozen=True, slots=True) class RfOutputRequest: """One explicit RF output state request for one descriptor-defined port.""" @@ -493,6 +744,14 @@ def get_rf_snapshot(self) -> RfSourceSnapshot: ... def configure_cw(self, request: RfCwRequest) -> None: ... + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: ... + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: ... + def set_rf_output(self, request: RfOutputRequest) -> None: ... @@ -564,6 +823,16 @@ def rf_source_snapshot_document(snapshot: RfSourceSnapshot) -> dict[str, object] return {"schema": RF_SOURCE_SNAPSHOT_SCHEMA, **data} +def rf_modulation_snapshot_document(snapshot: RfModulationSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF modulation readback.""" + + if not isinstance(snapshot, RfModulationSnapshot): + raise TypeError("snapshot must be RfModulationSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA, **data} + + def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: """Build a read-only snapshot artifact without transport-private values.""" @@ -606,6 +875,53 @@ def rf_source_cw_operation_artifact( } +def rf_source_modulation_operation_artifact( + request: RfModulationRequest, + result: RfModulationResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_modulation_snapshot: RfModulationSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_snapshot: RfModulationSnapshot, +) -> dict[str, object]: + """Build one redacted M3 modulation operation artifact from typed evidence.""" + + if not isinstance(request, RfModulationRequest): + raise TypeError("request must be RfModulationRequest") + if not isinstance(result, RfModulationResult): + raise TypeError("result must be RfModulationResult") + if ( + request.port_id != result.port_id + or request.kind is not result.kind + or request.internal_frequency_hz != result.internal_frequency_hz + or request.value != result.value + or request.value_unit is not result.value_unit + ): + raise ValueError("RF modulation request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_modulation_snapshot, RfModulationSnapshot): + raise TypeError("preflight_modulation_snapshot must be RfModulationSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_modulation_snapshot, RfModulationSnapshot): + raise TypeError("postcondition_modulation_snapshot must be RfModulationSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.modulation_configure", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_modulation_snapshot": rf_modulation_snapshot_document( + preflight_modulation_snapshot + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_modulation_snapshot": rf_modulation_snapshot_document( + postcondition_modulation_snapshot + ), + } + + def rf_source_output_operation_artifact( request: RfOutputRequest, result: RfOutputResult, @@ -639,6 +955,7 @@ def rf_source_output_operation_artifact( __all__ = [ "RF_SOURCE_CONTRACT_VERSION", + "RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", "RF_SOURCE_SNAPSHOT_SCHEMA", @@ -650,8 +967,16 @@ def rf_source_output_operation_artifact( "RfFeatureCapability", "RfFeatureDirection", "RfFeatureProfile", + "RfModulationKind", + "RfModulationModeProfile", "RfModulationProfile", + "RfModulationRequest", + "RfModulationResult", + "RfModulationSnapshot", + "RfModulationSource", "RfModulationState", + "RfModulationValueUnit", + "RfModulationWaveform", "RfObserved", "RfOutputPortProfile", "RfOutputProfile", @@ -672,6 +997,8 @@ def rf_source_output_operation_artifact( "rf_source_canonical_json", "rf_source_cw_operation_artifact", "rf_source_digest", + "rf_modulation_snapshot_document", + "rf_source_modulation_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", "rf_source_output_operation_artifact", diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 80086ef..0a613c9 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -338,6 +338,10 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), "rf_source.cw_configure": ("configure_cw",), + "rf_source.modulation_configure": ( + "get_rf_modulation_snapshot", + "configure_rf_modulation", + ), "rf_source.output": ("set_rf_output",), } assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( diff --git a/tests/test_rf_source_modulation_extensions.py b/tests/test_rf_source_modulation_extensions.py new file mode 100644 index 0000000..0a1e283 --- /dev/null +++ b/tests/test_rf_source_modulation_extensions.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA, + RfCwRequest, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + RfModulationState, + rf_modulation_snapshot_document, + rf_source_modulation_operation_artifact, +) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _profile() -> RfModulationProfile: + return RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.FM, + value_unit=RfModulationValueUnit.HZ, + value_min=0.1, + value_max=1_000_000.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.PM, + value_unit=RfModulationValueUnit.RAD, + value_min=0.0, + value_max=5.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + ), + ) + + +def _rf_snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _modulation_snapshot( + *, + enabled: bool, +) -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.AM,) if enabled else (), + global_enabled=enabled, + ) + + +def _descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulation", + kind="rf_source", + models=("RF-MOD",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-MOD,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _rf_snapshot() + + def configure_cw(self, request: RfCwRequest) -> None: + del request + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + assert port_id == "rf_out" + assert kind is RfModulationKind.AM + return _modulation_snapshot(enabled=False) + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: + del request + + +def test_modulation_contract_binds_request_value_to_kind() -> None: + am = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + fm = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.FM, + internal_frequency_hz=1_000.0, + frequency_deviation_hz=10_000.0, + ) + pm = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + + assert am.value == 50.0 + assert am.value_unit is RfModulationValueUnit.PERCENT + assert fm.value == 10_000.0 + assert fm.value_unit is RfModulationValueUnit.HZ + assert pm.value == 2.0 + assert pm.value_unit is RfModulationValueUnit.RAD + + with pytest.raises(ValueError, match="exactly the parameter"): + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + frequency_deviation_hz=1.0, + ) + with pytest.raises(ValueError, match="finite"): + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=float("nan"), + phase_deviation_rad=1.0, + ) + + +def test_modulation_profile_and_snapshot_are_strict() -> None: + with pytest.raises(ValueError, match="sorted and unique"): + RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfModulationModeProfile( + kind=RfModulationKind.PM, + value_unit=RfModulationValueUnit.RAD, + value_min=0.0, + value_max=5.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + ), + ) + with pytest.raises(ValueError, match="configuration readback"): + RfModulationProfile(state_readable=False, configuration_readable=True) + with pytest.raises(ValueError, match="sorted by value"): + RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.PM, RfModulationKind.AM), + global_enabled=True, + ) + + +def test_modulation_descriptor_requires_readable_bounded_feature_and_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.modulation_configure"] == ( + "get_rf_modulation_snapshot", + "configure_rf_modulation", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE,), + port_ids=("rf_out",), + profile=_profile(), + ), + ), + ) + with pytest.raises(Exception, match="configure and read"): + validate_rf_source_descriptor(invalid) + + +def test_modulation_artifact_keeps_typed_pre_and_postcondition_evidence() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + result = RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + preflight = _modulation_snapshot(enabled=False) + postcondition = _modulation_snapshot(enabled=True) + + document = rf_modulation_snapshot_document(postcondition) + artifact = rf_source_modulation_operation_artifact( + request, + result, + preflight_snapshot=_rf_snapshot(), + preflight_modulation_snapshot=preflight, + postcondition_snapshot=_rf_snapshot(), + postcondition_modulation_snapshot=postcondition, + ) + + assert document["schema"] == RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA + assert artifact["operation"] == "rf_source.modulation_configure" + assert artifact["postcondition_modulation_snapshot"]["global_enabled"] is True From 0e57486abd7ded93d8e303ca9ce6f30d0d64d05f Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:04:38 +0800 Subject: [PATCH 22/63] feat: add RF modulation M3 transaction --- src/wavebench/services/operation_specs.py | 17 + src/wavebench/services/rf_source_service.py | 296 +++++++++++++++- tests/test_rf_source_modulation_service.py | 355 ++++++++++++++++++++ 3 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 tests/test_rf_source_modulation_service.py diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index bd834cf..730406a 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1057,6 +1057,23 @@ def _spec( risk_flags=("rf_output_must_be_off", "signal_level", "state_drift"), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.modulation_configure", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.modulation_configure"), + effect="write", + changed_fields=( + "rf_source.modulation.kind", + "rf_source.modulation.source", + "rf_source.modulation.waveform", + "rf_source.modulation.value", + "rf_source.modulation.internal_frequency_hz", + "rf_source.modulation.enabled", + ), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "modulation_state", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec( "rf_source.output_enable", "rf_source", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 607102d..7c13c37 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -1,4 +1,4 @@ -"""Read-only, OFF-only CW, and guarded RF-output service for RF sources.""" +"""Read-only, OFF-only CW/modulation, and guarded RF-output service for RF sources.""" from __future__ import annotations @@ -20,7 +20,15 @@ RfCwResult, RfFeature, RfFeatureDirection, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, RfModulationState, + RfModulationWaveform, RfOutputPortProfile, RfOutputProfile, RfOutputRequest, @@ -33,6 +41,7 @@ RfSourceSnapshot, RfSweepState, rf_source_cw_operation_artifact, + rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, ) from wavebench.logging import CommandLogger @@ -56,6 +65,15 @@ class _RfCwTransaction: postcondition_snapshot: RfSourceSnapshot +@dataclass(frozen=True) +class _RfModulationTransaction: + result: RfModulationResult + preflight_snapshot: RfSourceSnapshot + preflight_modulation_snapshot: RfModulationSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_modulation_snapshot: RfModulationSnapshot + + @dataclass(frozen=True) class _RfOutputTransaction: result: RfOutputResult @@ -233,6 +251,96 @@ def _configure_cw_transaction(self, request: RfCwRequest) -> _RfCwTransaction: ) raise + def configure_modulation(self, request: RfModulationRequest) -> RfModulationResult: + return self._configure_modulation_transaction(request).result + + def configure_modulation_with_artifact( + self, + request: RfModulationRequest, + ) -> tuple[RfModulationResult, dict[str, object]]: + """Apply bounded M3 internal-sine modulation and retain typed evidence.""" + + transaction = self._configure_modulation_transaction(request) + return ( + transaction.result, + rf_source_modulation_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_modulation_snapshot=transaction.preflight_modulation_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_modulation_snapshot=transaction.postcondition_modulation_snapshot, + ), + ) + + def _configure_modulation_transaction( + self, + request: RfModulationRequest, + ) -> _RfModulationTransaction: + """Apply one bounded M3 profile without retry or output recovery. + + M3 never enables RF output. It requires all modulation modes to be + disabled before the fixed driver sequence, then independently confirms + only the requested internal-sine mode and global modulation switch. + A failed or mismatched sequence is not retried and leaves the session + uncertain because the instrument-side profile may be partially changed. + """ + + if not isinstance(request, RfModulationRequest): + raise ConfigError("rf_source modulation configuration requires RfModulationRequest") + operation = "rf_source.modulation_configure" + self._require(operation, "rf_source.snapshot", "rf_source.modulation_configure") + mode_profile = self._validate_modulation_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + self._validate_modulation_preflight( + request, + preflight_snapshot, + preflight_modulation_snapshot, + mode_profile, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_rf_modulation(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + result = self._validate_modulation_postcondition( + request, + postcondition_snapshot, + postcondition_modulation_snapshot, + mode_profile, + operation=operation, + ) + return _RfModulationTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + preflight_modulation_snapshot=preflight_modulation_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_modulation_postcondition_unverified", + ) + raise + def set_output(self, request: RfOutputRequest) -> RfOutputResult: return self._set_output_transaction(request).result @@ -596,6 +704,56 @@ def _validate_cw_descriptor( raise ConfigError(f"{operation} request power_dbm is outside the descriptor range") return port_profile, profile + def _validate_modulation_descriptor( + self, + request: RfModulationRequest, + operation: str, + ) -> RfModulationModeProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfModulationProfile) + or not feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable modulation profile for the target port" + ) + mode_profile = next( + (item for item in feature.profile.mode_profiles if item.kind is request.kind), + None, + ) + if mode_profile is None: + raise ConfigError(f"{operation} does not support the requested modulation kind") + if ( + mode_profile.source is not RfModulationSource.INTERNAL + or mode_profile.waveform is not RfModulationWaveform.SINE + or mode_profile.value_unit is not request.value_unit + ): + raise ConfigError(f"{operation} requires an internal-sine profile for the requested kind") + if not mode_profile.value_min <= request.value <= mode_profile.value_max: + raise ConfigError(f"{operation} request value is outside the descriptor range") + if not ( + mode_profile.internal_frequency_min_hz + <= request.internal_frequency_hz + <= mode_profile.internal_frequency_max_hz + ): + raise ConfigError( + f"{operation} request internal_frequency_hz is outside the descriptor range" + ) + return mode_profile + def _validate_cw_preflight( self, request: RfCwRequest, @@ -674,6 +832,142 @@ def _validate_cw_postcondition( raise ConfigError(f"{operation} power_dbm readback does not match request") return RfCwResult(port_id=request.port_id, power_dbm=float(power_dbm)) + def _validate_modulation_preflight( + self, + request: RfModulationRequest, + snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + mode_profile: RfModulationModeProfile, + *, + operation: str, + ) -> None: + self._validate_modulation_rf_snapshot( + request, + snapshot, + expected_modulation_state=RfModulationState.DISABLED, + operation=operation, + ) + self._validate_modulation_snapshot_identity( + request, + modulation_snapshot, + mode_profile, + operation=operation, + ) + if modulation_snapshot.global_enabled or modulation_snapshot.enabled_modes: + raise ConfigError(f"{operation} requires all modulation modes disabled") + if modulation_snapshot.fault_codes: + raise ConfigError(f"{operation} requires no active modulation fault condition") + + def _validate_modulation_postcondition( + self, + request: RfModulationRequest, + snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + mode_profile: RfModulationModeProfile, + *, + operation: str, + ) -> RfModulationResult: + self._validate_modulation_rf_snapshot( + request, + snapshot, + expected_modulation_state=RfModulationState.ENABLED, + operation=operation, + ) + self._validate_modulation_snapshot_identity( + request, + modulation_snapshot, + mode_profile, + operation=operation, + ) + if modulation_snapshot.enabled_modes != (request.kind,): + raise ConfigError(f"{operation} postcondition requires only the requested modulation mode") + if modulation_snapshot.global_enabled is not True: + raise ConfigError(f"{operation} postcondition requires global modulation enabled") + if modulation_snapshot.fault_codes: + raise ConfigError(f"{operation} postcondition reports an active modulation fault condition") + if ( + modulation_snapshot.internal_frequency_hz != request.internal_frequency_hz + or modulation_snapshot.value != request.value + or modulation_snapshot.value_unit is not request.value_unit + ): + raise ConfigError(f"{operation} modulation readback does not match request") + if request.kind is RfModulationKind.AM: + return RfModulationResult( + port_id=request.port_id, + kind=request.kind, + internal_frequency_hz=request.internal_frequency_hz, + depth_percent=request.value, + ) + if request.kind is RfModulationKind.FM: + return RfModulationResult( + port_id=request.port_id, + kind=request.kind, + internal_frequency_hz=request.internal_frequency_hz, + frequency_deviation_hz=request.value, + ) + return RfModulationResult( + port_id=request.port_id, + kind=request.kind, + internal_frequency_hz=request.internal_frequency_hz, + phase_deviation_rad=request.value, + ) + + def _validate_modulation_rf_snapshot( + self, + request: RfModulationRequest, + snapshot: RfSourceSnapshot, + *, + expected_modulation_state: RfModulationState, + operation: str, + ) -> None: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not expected_modulation_state: + expected = expected_modulation_state.value + raise ConfigError(f"{operation} requires modulation state {expected}") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + + @staticmethod + def _validate_modulation_snapshot_identity( + request: RfModulationRequest, + snapshot: RfModulationSnapshot, + mode_profile: RfModulationModeProfile, + *, + operation: str, + ) -> None: + if snapshot.port_id != request.port_id or snapshot.kind is not request.kind: + raise ConfigError(f"{operation} modulation snapshot does not match the requested port and kind") + if snapshot.source is not mode_profile.source or snapshot.waveform is not mode_profile.waveform: + raise ConfigError(f"{operation} requires readable internal-sine modulation source and waveform") + @staticmethod def _snapshot_port( snapshot: RfSourceSnapshot, diff --git a/tests/test_rf_source_modulation_service.py b/tests/test_rf_source_modulation_service.py new file mode 100644 index 0000000..570cf1b --- /dev/null +++ b/tests/test_rf_source_modulation_service.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf.modulation", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _profile() -> RfModulationProfile: + return RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.FM, + value_unit=RfModulationValueUnit.HZ, + value_min=0.1, + value_max=1_000_000.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.PM, + value_unit=RfModulationValueUnit.RAD, + value_min=0.0, + value_max=5.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + ), + ) + + +def _descriptor(*capabilities: str, profile: RfModulationProfile | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulation", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=profile or _profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _modulation_snapshot( + *, + kind: RfModulationKind = RfModulationKind.AM, + enabled: bool = False, + value: float = 50.0, + source: RfModulationSource = RfModulationSource.INTERNAL, + waveform: RfModulationWaveform = RfModulationWaveform.SINE, + faults: tuple[str, ...] = (), +) -> RfModulationSnapshot: + fields: dict[str, object] = { + "port_id": "rf_out", + "kind": kind, + "source": source, + "waveform": waveform, + "internal_frequency_hz": 1_000.0, + "enabled_modes": (kind,) if enabled else (), + "global_enabled": enabled, + "fault_codes": faults, + } + if kind is RfModulationKind.AM: + fields["depth_percent"] = value + elif kind is RfModulationKind.FM: + fields["frequency_deviation_hz"] = value + else: + fields["phase_deviation_rad"] = value + return RfModulationSnapshot(**fields) # type: ignore[arg-type] + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.modulation_snapshots = list(modulation_snapshots) + self.calls: list[str] = [] + self.requests: list[RfModulationRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + self.calls.append("modulation_snapshot") + assert port_id == "rf_out" + if not self.modulation_snapshots: + raise AssertionError("unexpected modulation snapshot") + snapshot = self.modulation_snapshots.pop(0) + assert snapshot.kind is kind + return snapshot + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: + self.calls.append("configure_modulation") + self.requests.append(request) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver(rf_snapshots, modulation_snapshots) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def _am_request(*, depth_percent: float = 50.0) -> RfModulationRequest: + return RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=depth_percent, + ) + + +def test_modulation_uses_one_driver_sequence_and_independent_readback() -> None: + request = _am_request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [_modulation_snapshot(), _modulation_snapshot(enabled=True)], + ) + + result, artifact = service.configure_modulation_with_artifact(request) + + assert result.kind is RfModulationKind.AM + assert result.depth_percent == 50.0 + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "modulation_snapshot", + "configure_modulation", + "snapshot", + "modulation_snapshot", + ] + assert artifact["operation"] == "rf_source.modulation_configure" + assert artifact["postcondition_modulation_snapshot"]["global_enabled"] is True + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("rf_snapshot", "modulation_snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), _modulation_snapshot(), "target RF output OFF"), + ( + _rf_snapshot(pulse=RfPulseState.ENABLED), + _modulation_snapshot(), + "Pulse disabled", + ), + ( + _rf_snapshot(sweep=RfSweepState.ENABLED), + _modulation_snapshot(), + "Sweep disabled", + ), + ( + _rf_snapshot(protection_codes=("overtemperature",)), + _modulation_snapshot(), + "active protection", + ), + (_rf_snapshot(), _modulation_snapshot(enabled=True), "all modulation modes disabled"), + ( + _rf_snapshot(), + _modulation_snapshot(faults=("am_overmodulation",)), + "modulation fault", + ), + ), +) +def test_modulation_rejects_unsafe_preflight_without_write( + rf_snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + message: str, +) -> None: + service, driver = _service([rf_snapshot], [modulation_snapshot]) + + with pytest.raises(ConfigError, match=message): + service.configure_modulation(_am_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot", "modulation_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_checks_capability_access_and_descriptor_range_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.modulation_configure"): + missing.configure_modulation(_am_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.modulation_configure"): + read_only.configure_modulation(_am_request()) + assert read_only_driver.calls == [] + + range_service, range_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_modulation(_am_request(depth_percent=101.0)) + assert range_driver.calls == [] + + +def test_modulation_mismatch_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [_modulation_snapshot(), _modulation_snapshot(enabled=True, value=49.0)], + ) + + with pytest.raises(ConfigError, match="readback does not match"): + service.configure_modulation(_am_request()) + + assert len(driver.requests) == 1 + assert driver.calls == [ + "snapshot", + "modulation_snapshot", + "configure_modulation", + "snapshot", + "modulation_snapshot", + ] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN From c90ed15f98ad73afb038aae02bc9ca3408c4c134 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:14:24 +0800 Subject: [PATCH 23/63] feat: add RF modulation M3 CLI and run support --- src/wavebench/cli.py | 30 +++- src/wavebench/cli_parser.py | 38 +++++ src/wavebench/services/execution_intent.py | 1 + src/wavebench/services/run_plan.py | 44 +++++ src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 33 ++++ tests/test_rf_source_cli.py | 150 +++++++++++++++++ tests/test_rf_source_run.py | 179 +++++++++++++++++++++ 8 files changed, 475 insertions(+), 1 deletion(-) diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 25df95f..2b0e96f 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -87,7 +87,12 @@ ScopeTraceData, ScopeTraceRef, ) -from .instruments.rf_source_extensions import RfCwRequest, RfOutputRequest +from .instruments.rf_source_extensions import ( + RfCwRequest, + RfModulationKind, + RfModulationRequest, + RfOutputRequest, +) from .mcp_http import ( resolve_mcp_token, serve_mcp_http, @@ -1559,6 +1564,29 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "modulation": + modulation_kind = { + "configure-am": RfModulationKind.AM, + "configure-fm": RfModulationKind.FM, + "configure-pm": RfModulationKind.PM, + }[args.modulation_command] + request_fields = { + "port_id": args.port, + "kind": modulation_kind, + "internal_frequency_hz": args.internal_frequency_hz, + } + if modulation_kind is RfModulationKind.AM: + request_fields["depth_percent"] = args.depth_percent + elif modulation_kind is RfModulationKind.FM: + request_fields["frequency_deviation_hz"] = args.frequency_deviation_hz + else: + request_fields["phase_deviation_rad"] = args.phase_deviation_rad + result = service.configure_modulation(RfModulationRequest(**request_fields)) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.command == "output": result = service.set_output( RfOutputRequest(port_id=args.port, enabled=args.state == "on") diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 0176d50..6ac0e9e 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -675,6 +675,44 @@ def build_parser() -> argparse.ArgumentParser: rf_source_output.add_argument("state", choices=["on", "off"]) add_runtime_options(rf_source_output) + rf_source_modulation = rf_source_sub.add_parser( + "modulation", + help="Configure one OFF RF port with bounded internal-sine AM, FM, or PM", + ) + rf_source_modulation_sub = rf_source_modulation.add_subparsers( + dest="modulation_command", + required=True, + ) + for command, value_option, help_text in ( + ("configure-am", "depth-percent", "Configure internal-sine AM while RF output is OFF"), + ( + "configure-fm", + "frequency-deviation-hz", + "Configure internal-sine FM while RF output is OFF", + ), + ( + "configure-pm", + "phase-deviation-rad", + "Configure internal-sine PM while RF output is OFF", + ), + ): + rf_source_modulation_configure = rf_source_modulation_sub.add_parser( + command, + help=help_text, + ) + rf_source_modulation_configure.add_argument("--port", required=True) + rf_source_modulation_configure.add_argument( + f"--{value_option}", + type=float, + required=True, + ) + rf_source_modulation_configure.add_argument( + "--internal-frequency-hz", + type=float, + required=True, + ) + add_runtime_options(rf_source_modulation_configure) + source_sub = source_parser.add_subparsers(dest="command", required=True) source_idn = source_sub.add_parser("idn", help="Query source *IDN?") diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 9f5f920..79a931d 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -26,6 +26,7 @@ "rf_source.status": "rf_source.snapshot", "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", + "rf_source.modulation_configure": "rf_source.modulation_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", "source.arb_load": "source.arbitrary_upload", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 4d2b16c..492a8fe 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -24,6 +24,7 @@ "rf_source.status", "rf_source.set_frequency", "rf_source.set_power_dbm", + "rf_source.modulation_configure", "rf_source.output_enable", "rf_source.output_disable", "source.set_freq", @@ -71,6 +72,11 @@ "source.output": ("state",), "rf_source.set_frequency": ("port_id", "frequency_hz"), "rf_source.set_power_dbm": ("port_id", "power_dbm"), + "rf_source.modulation_configure": ( + "port_id", + "modulation_kind", + "internal_frequency_hz", + ), "rf_source.output_enable": ("port_id",), "rf_source.output_disable": ("port_id",), "source.basic_configure_v2": ("channel",), @@ -182,6 +188,12 @@ "rf_source.status": {"on_failure"}, "rf_source.set_frequency": {"on_failure"}, "rf_source.set_power_dbm": {"on_failure"}, + "rf_source.modulation_configure": { + "depth_percent", + "frequency_deviation_hz", + "phase_deviation_rad", + "on_failure", + }, "rf_source.output_enable": {"on_failure"}, "rf_source.output_disable": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, @@ -245,6 +257,7 @@ "rf_source.status": "Read a typed RF-source snapshot without changing output.", "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", + "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", "rf_source.output_enable": "Enable one RF port only after a fresh safety snapshot confirms the configured load, frequency, power, and inactive modulation, Pulse, Sweep, and blocking protection conditions.", "rf_source.output_disable": "Disable one RF port and confirm OFF without requiring frequency, power, or protection readback.", "source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.", @@ -647,6 +660,37 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non elif kind == "rf_source.set_power_dbm": fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") + elif kind == "rf_source.modulation_configure": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + modulation_kind = _non_empty_str( + fields["modulation_kind"], + f"{prefix}.modulation_kind", + ).lower() + value_fields = { + "am": "depth_percent", + "fm": "frequency_deviation_hz", + "pm": "phase_deviation_rad", + } + expected_value_field = value_fields.get(modulation_kind) + if expected_value_field is None: + raise ConfigError(f"{prefix}.modulation_kind must be one of am, fm, pm") + present_value_fields = [field for field in value_fields.values() if field in fields] + if present_value_fields != [expected_value_field]: + raise ConfigError( + f"{prefix} rf_source.modulation_configure requires only " + f"{expected_value_field} for modulation_kind {modulation_kind}" + ) + fields[expected_value_field] = _finite_float( + fields[expected_value_field], + f"{prefix}.{expected_value_field}", + ) + if fields[expected_value_field] < 0: + raise ConfigError(f"{prefix}.{expected_value_field} must be >= 0") + fields["modulation_kind"] = modulation_kind + fields["internal_frequency_hz"] = _positive_float( + fields["internal_frequency_hz"], + f"{prefix}.internal_frequency_hz", + ) elif kind in {"rf_source.output_enable", "rf_source.output_disable"}: fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") elif kind == "source.arb_load": diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index a836902..1c8a11f 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -24,6 +24,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.status", "rf_source.set_frequency", "rf_source.set_power_dbm", + "rf_source.modulation_configure", "rf_source.output_enable", "rf_source.output_disable", "source.set_freq", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 5e72484..7ed34ed 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -24,6 +24,8 @@ from wavebench.instruments.registry import resolve_instrument_descriptor from wavebench.instruments.rf_source_extensions import ( RfCwRequest, + RfModulationKind, + RfModulationRequest, RfOutputRequest, rf_source_snapshot_operation_artifact, ) @@ -301,6 +303,7 @@ def _check_rf_source_access(self, plan: RunPlan) -> None: "rf_source.status": "rf_source.snapshot", "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", + "rf_source.modulation_configure": "rf_source.modulation_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", } @@ -448,6 +451,8 @@ def add_source_output_gate_capability() -> None: add("rf_source", "rf_source.snapshot") elif step.kind in {"rf_source.set_frequency", "rf_source.set_power_dbm"}: add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") + elif step.kind == "rf_source.modulation_configure": + add("rf_source", "rf_source.snapshot", "rf_source.modulation_configure") elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: add("rf_source", "rf_source.snapshot", "rf_source.output") elif step.kind == "source.set_freq": @@ -1224,6 +1229,34 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.modulation_configure": + fields = step.fields + modulation_kind = RfModulationKind(fields["modulation_kind"]) + if modulation_kind is RfModulationKind.AM: + request = RfModulationRequest( + port_id=fields["port_id"], + kind=modulation_kind, + depth_percent=fields["depth_percent"], + internal_frequency_hz=fields["internal_frequency_hz"], + ) + elif modulation_kind is RfModulationKind.FM: + request = RfModulationRequest( + port_id=fields["port_id"], + kind=modulation_kind, + frequency_deviation_hz=fields["frequency_deviation_hz"], + internal_frequency_hz=fields["internal_frequency_hz"], + ) + else: + request = RfModulationRequest( + port_id=fields["port_id"], + kind=modulation_kind, + phase_deviation_rad=fields["phase_deviation_rad"], + internal_frequency_hz=fields["internal_frequency_hz"], + ) + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_modulation_with_artifact(request) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: _, rf_source_operation = self._rf_source_service( services=services diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index 96f6aea..186fe17 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -10,6 +10,9 @@ from wavebench.instruments.rf_source_extensions import ( RfCwRequest, RfCwResult, + RfModulationKind, + RfModulationRequest, + RfModulationResult, RfOutputRequest, RfOutputResult, ) @@ -29,6 +32,45 @@ def test_rf_source_parser_accepts_read_only_cw_and_output_commands() -> None: output = build_parser().parse_args( ["rf-source", "output", "--port", "rf_out", "on"] ) + modulation_am = build_parser().parse_args( + [ + "rf-source", + "modulation", + "configure-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ] + ) + modulation_fm = build_parser().parse_args( + [ + "rf-source", + "modulation", + "configure-fm", + "--port", + "rf_out", + "--frequency-deviation-hz", + "10000", + "--internal-frequency-hz", + "1000", + ] + ) + modulation_pm = build_parser().parse_args( + [ + "rf-source", + "modulation", + "configure-pm", + "--port", + "rf_out", + "--phase-deviation-rad", + "1.5", + "--internal-frequency-hz", + "1000", + ] + ) assert (identity.domain, identity.command) == ("rf-source", "idn") assert identity.config == "rf.toml" @@ -42,6 +84,13 @@ def test_rf_source_parser_accepts_read_only_cw_and_output_commands() -> None: assert (output.domain, output.command) == ("rf-source", "output") assert output.port == "rf_out" assert output.state == "on" + assert (modulation_am.domain, modulation_am.command) == ("rf-source", "modulation") + assert modulation_am.modulation_command == "configure-am" + assert modulation_am.depth_percent == 50.0 + assert modulation_fm.modulation_command == "configure-fm" + assert modulation_fm.frequency_deviation_hz == 10_000.0 + assert modulation_pm.modulation_command == "configure-pm" + assert modulation_pm.phase_deviation_rad == 1.5 def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: @@ -132,6 +181,107 @@ def test_rf_source_cli_dispatches_each_output_request() -> None: ] +def test_rf_source_cli_dispatches_each_internal_sine_modulation_request() -> None: + service = Mock() + service.configure_modulation.side_effect = [ + RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ), + RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.FM, + frequency_deviation_hz=10_000.0, + internal_frequency_hz=1_000.0, + ), + RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.PM, + phase_deviation_rad=1.5, + internal_frequency_hz=1_000.0, + ), + ] + + commands = ( + [ + "rf-source", + "modulation", + "configure-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ], + [ + "rf-source", + "modulation", + "configure-fm", + "--port", + "rf_out", + "--frequency-deviation-hz", + "10000", + "--internal-frequency-hz", + "1000", + ], + [ + "rf-source", + "modulation", + "configure-pm", + "--port", + "rf_out", + "--phase-deviation-rad", + "1.5", + "--internal-frequency-hz", + "1000", + ], + ) + + with patch("wavebench.cli._load_rf_source_service", return_value=service): + for command in commands: + with redirect_stdout(io.StringIO()): + assert main(["--json", *command]) == 0 + + assert service.configure_modulation.call_args_list == [ + ( + ( + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ), + ), + {}, + ), + ( + ( + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.FM, + frequency_deviation_hz=10_000.0, + internal_frequency_hz=1_000.0, + ), + ), + {}, + ), + ( + ( + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + phase_deviation_rad=1.5, + internal_frequency_hz=1_000.0, + ), + ), + {}, + ), + ] + + def test_rf_source_resource_override_does_not_touch_source_config() -> None: updated = object() config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index e08f5c3..2e3a2f4 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -22,7 +22,13 @@ from wavebench.instruments.rf_source_extensions import ( RfCwRequest, RfCwResult, + RfModulationKind, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, RfModulationState, + RfModulationWaveform, RfObserved, RfPortSnapshot, RfProtectionStatus, @@ -30,6 +36,7 @@ RfSourceSnapshot, RfSweepState, rf_source_cw_operation_artifact, + rf_source_modulation_operation_artifact, RfOutputRequest, RfOutputResult, rf_source_output_operation_artifact, @@ -90,6 +97,26 @@ def _output_plan(directory: str, *, kind: str): return load_run_plan(path) +def _modulation_plan( + directory: str, + *, + modulation_kind: str = "am", + value_field: str = "depth_percent", + value: float = 50.0, +): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_configure"\n' + 'port_id = "rf_out"\n' + f'modulation_kind = "{modulation_kind}"\n' + f"{value_field} = {value}\n" + "internal_frequency_hz = 1000\n", + encoding="utf-8", + ) + return load_run_plan(path) + + def _snapshot() -> RfSourceSnapshot: return RfSourceSnapshot( ports=( @@ -348,6 +375,158 @@ def _run_safety_guards(self, run_plan, *, services=None): assert run_data["rf_source_operations"] == [artifact] +def test_rf_source_modulation_plan_requires_its_matching_value_field() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "modulation_kind": "am", + "depth_percent": 50.0, + "internal_frequency_hz": 1_000.0, + } + + invalid_path = Path(directory) / "invalid-plan.toml" + invalid_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_configure"\n' + 'port_id = "rf_out"\n' + 'modulation_kind = "am"\n' + "frequency_deviation_hz = 1000\n" + "internal_frequency_hz = 1000\n", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="requires only depth_percent"): + load_run_plan(invalid_path) + + +def test_rf_source_modulation_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.modulation_configure"): + service.run(_modulation_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_modulation_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_modulation_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_modulation_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_plan(directory) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.modulation_configure" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "modulation_kind": "am", + "depth_percent": 50.0, + "internal_frequency_hz": 1_000.0, + } + + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ) + result_value = RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ) + preflight_snapshot = _snapshot() + preflight_modulation_snapshot = RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + depth_percent=0.0, + internal_frequency_hz=1_000.0, + ) + postcondition_snapshot = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + postcondition_modulation_snapshot = RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + artifact = rf_source_modulation_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight_snapshot, + preflight_modulation_snapshot=preflight_modulation_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) + rf_service = SimpleNamespace( + configure_modulation_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_modulation_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + def test_rf_source_output_step_requires_capability_before_opening_a_session() -> None: with TemporaryDirectory() as directory: service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) From c406e74484a8c916bb68a92477b441396f43868a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:25:40 +0800 Subject: [PATCH 24/63] docs: clarify RF source M3 boundaries --- README.md | 4 +- docs/project/README.md | 3 +- ...21\351\207\214\347\250\213\347\242\221.md" | 25 ++- ...67\346\272\220\350\256\276\350\256\241.md" | 31 ++-- ...01\347\250\213\350\256\276\350\256\241.md" | 2 +- ...07\346\212\275\350\261\241\345\261\202.md" | 6 +- ...71\347\233\256\350\276\271\347\225\214.md" | 4 +- ...77\347\224\250\346\214\207\345\215\227.md" | 151 ++++++++++++++++++ ...77\347\224\250\346\214\207\345\215\227.md" | 41 +++++ ...345\231\250\346\217\222\344\273\266API.md" | 13 +- 10 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 "docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" diff --git a/README.md b/README.md index 389d1c1..06076e7 100644 --- a/README.md +++ b/README.md @@ -114,13 +114,13 @@ wavebench tui --fake | 信号源 | RIGOL DG4000/DG4202 | 基本波形、频率控制、扫频和任意波上传 | 主包能力 | | 电源 | RIGOL DP800 | 状态、保护、设定值和输出控制 | 主包能力 | | 万用表 | RIGOL DM3000/DM3058 | 常用读数、功能和部分量程/触发状态 | 主包能力 | -| run plan | source、power、scope、dmm、sleep、频响步骤 | 多仪器编排、质量检查和恢复 | 主入口 | +| run plan | source、rf_source、power、scope、dmm、sleep、频响步骤 | 多仪器编排、质量检查和恢复 | 主入口 | | TUI | 电源、万用表、信号源面板 | 人工查看和少量控制 | 实验性 | | 插件 | `wavebench.instruments` 外部 driver | 添加或替换特定仪器实现 | 可选 | 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 -RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0–M2 合同;DSG830 已完成 A1 只读快照、A2 受控输出和 A3 CW 环回证据,production descriptor 开放 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。CW 只覆盖目标 RF OFF 时的单字段频率/dBm 功率写入,输出只覆盖具有完整 safety 配置的 `rf_out` ON/OFF;调制、Pulse、Sweep 和触发仍由后续证据门控制。设计与下一步见[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 +RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0–M3 合同;DSG830 已完成 A1 只读快照、A2 受控输出和 A3 CW 环回证据,production descriptor 开放 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。CW 只覆盖目标 RF OFF 时的单字段频率/dBm 功率写入,输出只覆盖具有完整 safety 配置的 `rf_out` ON/OFF。M3 的内部正弦 AM/FM/PM 已完成离线合同与 driver 映射,但 production capability 仍等待 A4;Pulse、Sweep 和触发也仍由后续证据门控制。日常使用见 [RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md),设计与下一步见 [RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) 和 [RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 ## 三条常用路径 diff --git a/docs/project/README.md b/docs/project/README.md index f08f1db..371c5a5 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -6,6 +6,7 @@ - [CLI 形态](guides/WaveBench_CLI形态.md) - [run plan 使用指南](guides/WaveBench_run_plan_使用指南.md) +- [RF 信号源使用指南](guides/WaveBench_RF信号源使用指南.md):独立 RF 配置、当前 production 边界、M3 离线入口与上机前检查。 - [可安装仪器插件用户指南](guides/WaveBench_可安装仪器插件.md) - [TUI 终端控制面板](guides/WaveBench_TUI终端控制面板.md) - [HTTP MCP 只读接口](guides/WaveBench_HTTP_MCP_只读接口.md) @@ -24,7 +25,7 @@ - [设备抽象层](design/WaveBench_设备抽象层.md) - [多仪器流程设计](design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):M0–M2 合同、已开放的端口级输出边界、后续写入设计与安全规则。 +- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):M0–M3 合同、已开放的 CW/端口输出边界、后续写入设计与安全规则。 - [RF 信号源开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core 与 DSG830 插件的同步状态、依赖和 A1–A5 实机证据门。 ## rfcs:接口提案与决策 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 03b01ae..e3b8bd6 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,8 +8,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0、M1、M2 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW 事务,以及端口输出事务的 Core 合同、CLI、run 路径与 artifact;production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0、M1、M2 离线完成;A1、A2、A3 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser 与 `:FREQ`/`:LEV`/`:OUTP` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | +| Core `0.8.25` 开发线 | M0–M3 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出和内部正弦 AM/FM/PM 的类型合同、Service、CLI、run 路径与 artifact;production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;A1、A2、A3 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射以及内部正弦 AM/FM/PM 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | | 真实仪器证据 | A1、A2、A3 已完成;A4、A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | ## 双仓库交付规则 @@ -30,7 +30,7 @@ | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | -| M3 | 未开始 | 声明式 AM/FM/PM | 已声明的内部 Sine 调制子集 | 只在 OFF 状态、profile 匹配且 postcondition 成立时写入。 | +| M3 | 离线完成;A4 未开始 | 声明式内部正弦 AM/FM/PM profile、事务、CLI、run 与 artifact | 手册范围内的内部 Sine AM/FM/PM 映射与严格 readback | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待 A4。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | ## Seed:历史种子包 @@ -78,11 +78,21 @@ Core 已在离线环境中完成 `RfOutputRequest`/result、`rf_source.output_ ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 使用单次 `:OUTP ON|OFF` 映射,Core 负责所有 snapshot readback 与 recovery;A2 已将 `rf_source.output` 加入 production descriptor,后续 A3 单独提升 CW,不提升 M3/M4 或其它 capability。 -## M3:调制 +## M3:内部正弦调制(离线完成) -Core 冻结 AM/FM/PM profile、request/result、operation context、CLI、run step 与 artifact 字段。DSG830 先限定到手册可审计的内部 Sine 调制子集。任何输出未 OFF、profile 不支持或 postcondition 不符的请求都必须零写拒绝。 +Core 已冻结 `RfModulationModeProfile`、typed request/result、调制 snapshot、 +`rf_source.modulation_configure` OperationSpec、Service、CLI、run step 与 artifact。M3 只描述内部 +Sine AM/FM/PM:AM 使用 percent 深度,FM 使用 Hz 频偏,PM 使用 rad 相偏;每种模式都有独立的内部频率和静态范围。 +run plan 使用 `modulation_kind` 表示 AM/FM/PM,避免与步骤自身的 `kind` 键冲突,并且只能提供与该模式匹配的一个数值字段。 -production descriptor 的调制 capability 需要 A4 证据;离线 driver 和 fake descriptor 的完整测试不能替代它。 +DSG830 driver 已实现固定且无重试的内部正弦写入序列与严格 readback:读取全局调制状态、三种模式的 enable 状态、 +目标模式 source/waveform/数值/内部频率,并对 FM/PM 核对共享 mode type。M3 preflight 要求目标 RF 输出 OFF、AM/FM/PM +均 disabled、Pulse/Sweep disabled 且无活动 protection condition;postcondition 要求 RF 仍 OFF、仅目标模式 enabled、全局调制 +开启且所有目标字段精确匹配。写入或 postcondition 结果不明时不重试,session 降为不确定状态。 + +production descriptor 的调制 capability 仍需要 A4 证据;离线 driver、fake descriptor、CLI 或 run step 的完整测试均不能替代它。 +当前 M2 的 RF ON 合同要求调制 disabled,因此 A4 即使仅提升 M3 配置 capability,也不授权在调制开启时输出 RF;该能力需要后续专门的 +输出安全合同与实机证据。 ## M4:Pulse 与 Step Sweep @@ -154,4 +164,5 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 -5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。后续 capability 仍按单项证据提升;M3/M4 保持独立工作。 +5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 +6. A4 先验证 M3 的 RF-OFF 配置和独立 readback,再讨论任何允许调制开启时 RF 输出的专门安全合同;不得把当前 CH2 可见信号证据外推为调制输出证据。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index b8587fc..be6046a 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,26 +2,26 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW 和 M2 端口输出合同;DSG830 已凭 A1/A2/A3 证据开放 snapshot、OFF-only CW 与受 safety 限制的 output。本文同时保留 M3–M4 的设计和 A4–A5 的实机证据门,不能把离线代码误写成已获准的真实仪器控制能力。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出与 M3 内部正弦调制合同;DSG830 已凭 A1/A2/A3 证据开放 snapshot、OFF-only CW 与受 safety 限制的 output。M3 仍是离线合同,A4–A5 的实机证据门没有被替代。 阅读顺序如下: 1. 本文界定领域模型、安全规则和 production capability 的证据门槛。 2. [RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md) 说明 Core 与 DSG830 插件的交付顺序。 3. [设备抽象层](WaveBench_设备抽象层.md) 和 [多仪器流程设计](WaveBench_多仪器协同流程设计.md)说明当前通用分层与 run plan 边界。 -4. 当前可执行命令、配置字段和 step kind 仍以 `wavebench --help`、`wavebench run schema`、`wavebench.example.toml` 与参考文档为准。 +4. 面向使用者的配置与操作顺序见 [RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md);当前可执行命令、配置字段和 step kind 仍以 `wavebench --help`、`wavebench run schema`、`wavebench.example.toml` 与参考文档为准。 ## 当前状态 | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW 事务,以及 M2 端口输出事务、CLI、run step 与 artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射;A1/A2/A3 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出和 M3 内部正弦 AM/FM/PM 的事务、CLI、run step 与 artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 和内部正弦 AM/FM/PM 映射;A1/A2/A3 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | | 实机证据 | A1、A2、A3 已完成;A4、A5 未开始。 | DSG830 production descriptor 开放 snapshot、OFF-only CW 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「A2 已提升」「A3 已提升」「离线已完成」或「离线进行中」的内容外,本文中的 M3–M4 与其它 production 写 capability、A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output。 +除明确标为「生产只读」「A2 已提升」「A3 已提升」或「离线已完成」的内容外,本文中的 M4、其它 production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M3 已完成离线合同但仍未由 A4 提升。 ## 术语与证据级别 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。A4–A5 实机验收、其它 production 写 capability 声明和发行包推广仍另行处理;离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。M3 已完成 Core 与 DSG830 的离线实现;A4–A5 实机验收、其它 production 写 capability 声明和发行包推广仍另行处理,离线代码不能替代这些证据。 ## 范围与非目标 @@ -260,7 +260,7 @@ CW、调制、Pulse 与 Sweep 配置必须按以下顺序执行: 5. 读取独立 postcondition,逐字段比较请求值、端口状态和隐式变化; 6. 成功后返回类型化结果与脱敏 artifact。 -主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。若 session health 仍允许 recovery I/O,核心最多执行一次目标端口 RF OFF 并独立回读;否则将 session 保持在更保守状态。 +主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。M1 CW 与 M3 调制配置不执行 RF OFF recovery,而是将 session 保持在更保守状态;只有 M2 的 RF ON 事务可在 session health 允许时最多执行一次目标端口 RF OFF 并独立回读。 RF ON 是独立 operation。其 preflight 必须确认: @@ -311,14 +311,15 @@ M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端 M1 已由 A3 在真实设备上完成受控频率/功率写入、独立 readback、低功率 RF ON/OFF 环回与最终 OFF 验收,因而将 `rf_source.cw_configure` 纳入 DSG830 production descriptor。M2 已由 A2 将 `rf_source.output` 纳入同一 descriptor;人工确认的实验室端接本身仍不构成调制、Pulse、Sweep、trigger 或其它额外写入授权。 -### M3–M4 目标 +### M3 离线入口与 M4 目标 -M3–M4 的写入 CLI 和 run step 仍是设计合同,尚未进入当前 run schema: +M3 的写入 CLI 和 run step 已进入当前 Core schema,但其真实仪器使用仍由 production descriptor 的 A4 capability 门决定: ```text wavebench rf-source modulation configure-am ... wavebench rf-source modulation configure-fm ... wavebench rf-source modulation configure-pm ... +rf_source.modulation_configure wavebench rf-source pulse configure ... wavebench rf-source pulse trigger ... wavebench rf-source sweep configure ... @@ -327,7 +328,9 @@ wavebench rf-source sweep fire ... wavebench rf-source sweep stop ... ``` -这些目标 step 都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。它们不得访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 +M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition;固定 driver 写入序列后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 + +Pulse、Sweep 和 trigger 的命令与 step 仍是目标合同,尚未进入当前 run schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。它们不得访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 ## M0–M4 里程碑 @@ -338,7 +341,7 @@ wavebench rf-source sweep stop ... | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | -| M3 | 声明式 AM/FM/PM profile、typed request/result、CLI 与 run step | 内部 Sine 调制序列 | 输出未 OFF、profile 不支持或 postcondition 不符时零写拒绝。 | +| M3(离线完成;A4 未开始) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、Service、CLI、run step 与 artifact | 内部 Sine 调制序列与严格 readback | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待 A4。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 @@ -355,14 +358,14 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 | CW 频率 | `:FREQ ` / `:FREQ?` | `9 kHz–3 GHz`。 | | CW dBm 功率 | `:LEV ` / `:LEV?` | `-110 dBm–20 dBm`;query 默认 dBm。 | | RF 输出 | `:OUTP ON|OFF` / `:OUTP?` | 单个 `rf_out` 端口。 | -| 调制状态 | `:MOD:STAT?` | `0`/`1`;AM/FM/PM 配置子集进入 M3。 | +| 调制状态 | `:MOD:STAT?` | `0`/`1`;M3 还读取 AM/FM/PM enable 状态与目标内部 Sine 参数。 | | Pulse 状态 | `:PULM:STAT?` | `0`/`1`;配置和触发进入 M4。 | | Sweep 状态 | `:SWE:STAT?` | `OFF`、`FREQ`、`LEV` 或组合;frequency-only Step Sweep 子集进入 M4。 | | 保护状态 | `:STAT:QUES:POW:COND?` | 位 0 ALC unlocked、位 1 output power protection、位 2 heater detector;未知高位按阻断处理。 | 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射与 readback,但 production descriptor 在 A4 前不声明 `rf_source.modulation_configure`;严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -379,5 +382,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M2 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。A4–A5 尚未开始,不能据此提升调制、Pulse、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。A4–A5 尚未开始,不能据此提升调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index e21d878..6249d8e 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -258,7 +258,7 @@ sleep `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 -RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、`rf_source.output_enable` 和 `rf_source.output_disable`:它们使用独立的类型化 RF artifact,并分别要求 snapshot 或 output capability。DSG830 已完成 A1/A2,production descriptor 声明 `rf_source.snapshot` 和受 safety 限制的 `rf_source.output`,因此 status 可在 `read_only` session 中读取快照,输出 step 仅在 `read_write`、完整端口 safety 配置与 fresh preflight 同时成立时执行。频率、功率与 M3/M4 capability 仍未开放。详见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、CW 频率/功率步骤、`rf_source.output_enable`/`rf_source.output_disable` 和 M3 的 `rf_source.modulation_configure`;它们使用独立的类型化 RF artifact。DSG830 已完成 A1/A2/A3,production descriptor 声明 `rf_source.snapshot`、`rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`,因此 status 可在 `read_only` session 中读取快照,CW 与输出步骤仅在 `read_write`、相应 capability 和 fresh preflight 同时成立时执行。M3 已完成离线合同但 production capability 仍等待 A4;Pulse、Sweep 和 trigger 仍未开放。详见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md)、[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 `scope.capture` 可以额外声明: diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index a44f67a..cbdaf9e 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -248,13 +248,13 @@ Service 层可以按以下顺序组合这些动作: 设置信号 → 等待稳定 → 采集波形 → 保存数据 → 计算指标 ``` -## RF 信号源:当前 M0–M2 与后续阶段 +## RF 信号源:当前 M0–M3 与后续阶段 上述 `SignalGenerator` 示例只描述普通函数/任意波形发生器。RF 信号源以频率、dBm 功率、RF 输出和稳定 `port_id` 为主,不能把它映射为普通 `SourceDriver` 的 Vpp、offset、数字 channel 或波形接口。 -当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 +当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、内部正弦 AM/FM/PM transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output`/`rf-source modulation configure-*` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 -DSG830 已由 A1/A2/A3 将 snapshot、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output` 提升到 production。调制、Pulse、Sweep 与 trigger 仍待后续实现和对应证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +DSG830 已由 A1/A2/A3 将 snapshot、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output` 提升到 production。调制已完成离线映射,但 `rf_source.modulation_configure` 仍等待 A4;Pulse、Sweep 与 trigger 仍待后续实现和对应证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 早期目录示意 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 000e676..c163d44 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,7 +19,7 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| RF 信号源 | M0–M2 插件领域:身份查询、类型化 snapshot、端口级输出合同、配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2,声明 `rf_source.idn`、`rf_source.snapshot` 和受 safety 限制的 `rf_source.output`;CW 与后续写入仍需实机证据 | +| RF 信号源 | M0–M3 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制合同、配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2/A3,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3 capability 仍需 A4 证据 | | run plan | source、rf_source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样;RF 输出仍受 capability、access 和端口 safety 限制 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | @@ -29,7 +29,7 @@ WaveBench 优先解决以下问题: RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 -当前 Core 已提供 M0–M2 合同。DSG830 已凭 A1 证据开放 production snapshot,并凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`。这不授权 CW、调制、Pulse、Sweep 或 trigger;这些能力仍须等待各自的 A3–A5 证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +当前 Core 已提供 M0–M3 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`,并凭 A3 证据开放 OFF-only `rf_source.cw_configure`。M3 内部正弦调制已完成离线合同与 driver 映射,但 capability 仍等待 A4;Pulse、Sweep 与 trigger 继续等待对应 A4–A5 证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 推荐工作顺序 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" new file mode 100644 index 0000000..f581ea1 --- /dev/null +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -0,0 +1,151 @@ +# WaveBench RF 信号源使用指南 + +[RF 信号源领域设计](../design/WaveBench_RF信号源设计.md) · [RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) · [run plan 使用指南](WaveBench_run_plan_使用指南.md) + +本页说明 RF 信号源的当前使用入口、配置边界和已知限制。它面向实际使用与 plan 编写;领域模型、SCPI 映射和实机验收记录见设计文档与型号插件文档。 + +## 先区分两类信号源 + +`source` 面向函数/任意波形发生器,使用数字 channel、Vpp、offset 和波形模型。`rf_source` 面向以频率、dBm 功率等级、RF 输出和调制状态为主要对象的射频信号源。 + +二者不能互换: + +- 不把 RF 的 dBm 换算为 `source` 的 Vpp。 +- 不把 `rf_out` 当成普通 source channel。 +- 不把 scope 的输入阻抗、连接器标签或型号名称当成 RF 端口的实际端接声明。 +- 不通过普通 `source.*` 操作、原始 SCPI 或临时 descriptor 绕过 `rf_source` 的 capability 与 safety 门。 + +## 当前可用范围 + +| 操作 | Core 状态 | DSG830 production descriptor | 关键边界 | +| --- | --- | --- | --- | +| 身份与状态 | 已开放 | 已开放 | `read_only` 可执行。 | +| CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | +| RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | +| 内部正弦 AM/FM/PM | M3 离线合同已完成 | 未开放 | A4 实机证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | +| Pulse、Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | + +生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 + +## 配置与端接声明 + +RF 使用独立的 `[rf_source]` 段。日常身份查询和状态读取保持 `read_only`: + +```toml +[rf_source] +driver = "rigol.dsg830" +resource = "" +access = "read_only" +``` + +CW 写入或 RF 输出控制需要显式改为 `read_write`,并为每个使用的端口提供 safety 配置: + +```toml +[rf_source] +driver = "rigol.dsg830" +resource = "" +access = "read_write" + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -40 +actual_termination_ohm = 50 +``` + +示例中的 `50` 只适用于已人工确认整个 RF 路径确实以 50 Ω 端接的场景。若 RF 直接接入示波器,示波器 CH2 已设为 50 Ω 只是必要信息之一;线缆、转接件、分配器和实际连接路径也必须一起核对。无法确认时,不应填写猜测值。 + +网络发现只能帮助定位候选设备。候选资源仍须通过只读身份查询、型号核对和隔离配置复核;发现结果不自动写回配置,也不构成写入授权。 + +## 当前生产操作 + +先使用只读入口确认状态: + +```bash +wavebench rf-source idn --config wavebench.toml +wavebench rf-source status --config wavebench.toml +``` + +在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW 和 RF 输出操作: + +```bash +wavebench rf-source set-frequency --config wavebench.toml --port rf_out 1000000 +wavebench rf-source set-power --config wavebench.toml --port rf_out -40 +wavebench rf-source output --config wavebench.toml --port rf_out on +wavebench rf-source output --config wavebench.toml --port rf_out off +``` + +`output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 + +## run plan 中的 RF 步骤 + +CW 和输出步骤使用独立的 `rf_source.*` kind,并把 evidence 写入 `run.json.rf_source_operations`: + +```toml +[[steps]] +kind = "rf_source.set_frequency" +port_id = "rf_out" +frequency_hz = 1000000 + +[[steps]] +kind = "rf_source.set_power_dbm" +port_id = "rf_out" +power_dbm = -40 + +[[steps]] +kind = "rf_source.output_enable" +port_id = "rf_out" + +[[steps]] +kind = "rf_source.output_disable" +port_id = "rf_out" +``` + +先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 + +## M3:内部正弦调制合同 + +Core 已提供三条 M3 命令和一个 run step: + +```text +wavebench rf-source modulation configure-am ... +wavebench rf-source modulation configure-fm ... +wavebench rf-source modulation configure-pm ... +rf_source.modulation_configure +``` + +该合同只覆盖内部 Sine: + +| 模式 | CLI 值字段 | run plan 值字段 | 单位 | +| --- | --- | --- | --- | +| AM | `--depth-percent` | `depth_percent` | percent | +| FM | `--frequency-deviation-hz` | `frequency_deviation_hz` | Hz | +| PM | `--phase-deviation-rad` | `phase_deviation_rad` | rad | + +三种模式都必须提供 `--internal-frequency-hz` 或 `internal_frequency_hz`。run plan 使用 `modulation_kind = "am" | "fm" | "pm"`,而不是复用步骤自身的 `kind` 键;每个步骤只能出现与该模式匹配的一个值字段。 + +```toml +[[steps]] +kind = "rf_source.modulation_configure" +port_id = "rf_out" +modulation_kind = "am" +depth_percent = 25 +internal_frequency_hz = 1000 +``` + +M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disabled、Pulse/Sweep disabled,且没有活动 protection condition。driver 只执行固定的内部正弦序列;Core 会用独立调制 snapshot 验证目标模式、源、波形、数值、内部频率、全局调制开关和 RF 输出仍然 OFF。写入结果不明或 postcondition 不匹配时不重试,session 会降为不确定状态。 + +截至当前,DSG830 production descriptor 不声明 `rf_source.modulation_configure`。因此上述命令和 step 仅用于离线 fake descriptor、开发验证或未来已取得 A4 证据的插件;对当前 production DSG830 会在打开 transport 前被 capability 门拒绝。 + +M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 + +## 上机前检查清单 + +1. 使用网络发现和只读身份查询确认候选设备,再在隔离配置中复核资源与型号。 +2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 +3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 +4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 +5. M3/A4 阶段不使用 raw SCPI、不执行 reset、preset、错误队列、外部调制、Pulse、Sweep、trigger 或 scope 自动量程。 + +需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 diff --git "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" index 2875b05..8afcfc9 100644 --- "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -64,6 +64,47 @@ step 的 `OperationSpec`。`run plan --intent` 会在取得资源租约、打开 - `run plan` 是整次实验持有 session,减少同一 plan 内反复连接/断开的开销,也让 safety、采集和 restore 使用同一批仪器连接。 - 当前第一版不做长 session 断线自动重建;如果 run 中途断线,应该让 run 失败并留下 `run.json` 证据,而不是偷偷重连后继续。 +## RF 信号源步骤 + +RF 使用独立的 `rf_source.*` step,不使用普通 `source` 的 channel、Vpp、restore 或 safety 语义。当前 DSG830 production descriptor 已开放只读状态、OFF-only CW 和受 safety 限制的 RF 输出: + +```toml +[[steps]] +kind = "rf_source.set_frequency" +port_id = "rf_out" +frequency_hz = 1000000 + +[[steps]] +kind = "rf_source.set_power_dbm" +port_id = "rf_out" +power_dbm = -40 + +[[steps]] +kind = "rf_source.output_enable" +port_id = "rf_out" + +[[steps]] +kind = "rf_source.output_disable" +port_id = "rf_out" +``` + +CW 步骤要求 RF 输出明确 OFF,且调制、Pulse、Sweep 与 protection 没有冲突。`rf_source.output_enable` 还会检查每端口安全配置、实际端接、频率、功率和 fresh snapshot;不满足时会在 ON 前拒绝。RF operation 的类型化 artifact 写入 `run.json.rf_source_operations`。 + +Core 已在 schema 中提供 M3 的 `rf_source.modulation_configure`: + +```toml +[[steps]] +kind = "rf_source.modulation_configure" +port_id = "rf_out" +modulation_kind = "fm" +frequency_deviation_hz = 10000 +internal_frequency_hz = 1000 +``` + +`modulation_kind` 为 `am`、`fm` 或 `pm`,分别只能使用 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad`。它只覆盖内部 Sine,要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。当前 DSG830 production descriptor 尚未声明调制 capability,因此该步骤会在 transport I/O 前拒绝;它仅用于离线或未来 A4 已提升的插件。不要把它与 `rf_source.output_enable` 拼成「调制输出」流程,当前 ON 合同要求调制 disabled。 + +RF 的配置、端接判断、CLI 与 A4 边界见 [RF 信号源使用指南](WaveBench_RF信号源使用指南.md)。 + ## 不想手写时先用模板 `run template` 只负责生成标准 TOML plan,不连接仪器、不改配置、不覆盖已有文件(除非传 `--force`)。生成后仍然走普通流程:`run check`、`run verify`、`run plan`、`run report`。 diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index f20c6aa..0c5ff1c 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -584,17 +584,19 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 -### RF 信号源 M0–M2(DSG830 production 已含 A2 output 与 A3 CW) +### RF 信号源 M0–M3(DSG830 production 已含 A2 output 与 A3 CW) `rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source 能力。descriptor 必须同时满足以下静态条件: - 只声明 `rf_source.*` capability,且至少包含 `rf_source.idn`;当前 Core 识别 - `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 + `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.output`。 - 提供 `rf_source_extensions`,其 contract version、拓扑、端口 ID、feature 和 protection policy 必须通过 Core 校验。 - 声明 `rf_source.cw_configure` 时,CW feature 必须有 `CONFIGURE` direction 和至少一个可配置字段;声明 `rf_source.output` 时,output feature 必须同时有 `ENABLE`/`DISABLE` direction 与可读 output state。 +- 声明 `rf_source.modulation_configure` 时,Modulation feature 必须同时有 `CONFIGURE`/`READ` direction、 + `configuration_readable = true`,并至少声明一个内部 Sine `RfModulationModeProfile`。profile 的模式、值单位、值范围和内部频率范围必须与 driver 的严格 readback 一致。 - `wavebench_min_version` 不低于 `0.8.25`,并且小于 `wavebench_max_version`。 - 打包检查时,wheel 必须有且仅有一条生效的 `wavebench` 依赖,并显式使用与 descriptor 相同的 `>=wavebench_min_version, Date: Thu, 27 Aug 2026 01:37:28 +0800 Subject: [PATCH 25/63] fix: support inactive FM PM selection switching --- ...21\351\207\214\347\250\213\347\242\221.md" | 3 +- ...67\346\272\220\350\256\276\350\256\241.md" | 2 +- ...77\347\224\250\346\214\207\345\215\227.md" | 2 +- ...345\231\250\346\217\222\344\273\266API.md" | 2 +- .../instruments/rf_source_extensions.py | 18 ++++- src/wavebench/services/rf_source_service.py | 17 +++++ tests/test_rf_source_modulation_extensions.py | 19 +++++ tests/test_rf_source_modulation_service.py | 75 +++++++++++++++++++ 8 files changed, 133 insertions(+), 5 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index e3b8bd6..98cc58c 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -86,7 +86,8 @@ Sine AM/FM/PM:AM 使用 percent 深度,FM 使用 Hz 频偏,PM 使用 r run plan 使用 `modulation_kind` 表示 AM/FM/PM,避免与步骤自身的 `kind` 键冲突,并且只能提供与该模式匹配的一个数值字段。 DSG830 driver 已实现固定且无重试的内部正弦写入序列与严格 readback:读取全局调制状态、三种模式的 enable 状态、 -目标模式 source/waveform/数值/内部频率,并对 FM/PM 核对共享 mode type。M3 preflight 要求目标 RF 输出 OFF、AM/FM/PM +目标模式 source/waveform/数值/内部频率与 FM/PM 共享 mode type。当前类型与目标 FM/PM 不同但三种模式均 disabled 时,preflight 可继续, +固定写入显式选择目标类型;postcondition 必须核对目标类型。M3 preflight 要求目标 RF 输出 OFF、AM/FM/PM 均 disabled、Pulse/Sweep disabled 且无活动 protection condition;postcondition 要求 RF 仍 OFF、仅目标模式 enabled、全局调制 开启且所有目标字段精确匹配。写入或 postcondition 结果不明时不重试,session 降为不确定状态。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index be6046a..1a51615 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -328,7 +328,7 @@ wavebench rf-source sweep fire ... wavebench rf-source sweep stop ... ``` -M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition;固定 driver 写入序列后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 +M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 Pulse、Sweep 和 trigger 的命令与 step 仍是目标合同,尚未进入当前 run schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。它们不得访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index f581ea1..2923f67 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -134,7 +134,7 @@ depth_percent = 25 internal_frequency_hz = 1000 ``` -M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disabled、Pulse/Sweep disabled,且没有活动 protection condition。driver 只执行固定的内部正弦序列;Core 会用独立调制 snapshot 验证目标模式、源、波形、数值、内部频率、全局调制开关和 RF 输出仍然 OFF。写入结果不明或 postcondition 不匹配时不重试,session 会降为不确定状态。 +M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disabled、Pulse/Sweep disabled,且没有活动 protection condition。FM 与 PM 共享设备的当前选择位:在三种模式均关闭时,preflight 可以观察到另一种 FM/PM 选择,固定写入会明确选择目标类型;postcondition 必须确认已切换到目标类型。Core 用独立调制 snapshot 验证目标模式、源、波形、数值、内部频率、全局调制开关和 RF 输出仍然 OFF。写入结果不明或 postcondition 不匹配时不重试,session 会降为不确定状态。 截至当前,DSG830 production descriptor 不声明 `rf_source.modulation_configure`。因此上述命令和 step 仅用于离线 fake descriptor、开发验证或未来已取得 A4 证据的插件;对当前 production DSG830 会在打开 transport 前被 capability 门拒绝。 diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index 0c5ff1c..110ea2f 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -613,7 +613,7 @@ capability 的高级配置保持 V1。插件不得把 capability 注册视为 `wavebench.instruments` 导入。`rf_source.snapshot` 缺失时,status 入口会在 transport I/O 前拒绝;实现 `get_rf_snapshot()` 本身不会形成隐式 capability。M1/M2/M3 的 CLI 与 run step 也是 capability、access、 profile 和 fresh safety preflight 的共同门禁;实现 `configure_cw()`、`configure_rf_modulation()` 或 `set_rf_output()` 本身不会形成隐式 -capability。M3 request 只能选择内部 Sine AM/FM/PM 中的一种,并分别使用 percent、Hz 或 rad 值字段;driver 不得扩展外部 source、其它波形、IQ、Pulse、Sweep 或 raw SCPI passthrough。production capability 必须按 [RF 信号源开发里程碑](../../design/WaveBench_RF信号源开发里程碑.md) +capability。M3 request 只能选择内部 Sine AM/FM/PM 中的一种,并分别使用 percent、Hz 或 rad 值字段;对共享 FM/PM 选择位的设备,snapshot 必须将当前选择与被查询 profile 分开表示,preflight 只能在所有模式关闭时接受不同选择,postcondition 必须确认目标选择。driver 不得扩展外部 source、其它波形、IQ、Pulse、Sweep 或 raw SCPI passthrough。production capability 必须按 [RF 信号源开发里程碑](../../design/WaveBench_RF信号源开发里程碑.md) 的 A 级实机证据逐项提升,不能由 descriptor 静态校验或 fake transport 测试替代。 ### Power、DMM 和 sweep analyzer diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index c1164d0..439e129 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -652,13 +652,21 @@ def value_unit(self) -> RfModulationValueUnit: @dataclass(frozen=True, slots=True) class RfModulationSnapshot: - """Complete typed readback for one internal-sine modulation mode.""" + """Complete typed readback for one internal-sine modulation mode. + + ``kind`` identifies the stored profile queried by the driver. On devices + with one shared FM/PM front-panel selection, ``selected_fm_pm_kind`` records + that current selection separately. This lets an OFF-only transaction + safely prepare PM while FM is currently selected, then require the target + selection in its postcondition. + """ port_id: str kind: RfModulationKind source: RfModulationSource waveform: RfModulationWaveform internal_frequency_hz: float + selected_fm_pm_kind: RfModulationKind | None = None depth_percent: float | None = None frequency_deviation_hz: float | None = None phase_deviation_rad: float | None = None @@ -684,6 +692,14 @@ def __post_init__(self) -> None: phase_deviation_rad=self.phase_deviation_rad, label="RF modulation snapshot", ) + if self.kind is RfModulationKind.AM: + if self.selected_fm_pm_kind is not None: + raise ValueError("AM modulation snapshots cannot carry an FM/PM selection") + elif self.selected_fm_pm_kind not in { + RfModulationKind.FM, + RfModulationKind.PM, + }: + raise ValueError("FM/PM modulation snapshots require a selected FM/PM kind") _require_enum_tuple( self.enabled_modes, RfModulationKind, diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 7c13c37..99f1a5d 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -851,6 +851,7 @@ def _validate_modulation_preflight( request, modulation_snapshot, mode_profile, + require_selected_fm_pm_kind=False, operation=operation, ) if modulation_snapshot.global_enabled or modulation_snapshot.enabled_modes: @@ -877,6 +878,7 @@ def _validate_modulation_postcondition( request, modulation_snapshot, mode_profile, + require_selected_fm_pm_kind=True, operation=operation, ) if modulation_snapshot.enabled_modes != (request.kind,): @@ -961,12 +963,27 @@ def _validate_modulation_snapshot_identity( snapshot: RfModulationSnapshot, mode_profile: RfModulationModeProfile, *, + require_selected_fm_pm_kind: bool, operation: str, ) -> None: if snapshot.port_id != request.port_id or snapshot.kind is not request.kind: raise ConfigError(f"{operation} modulation snapshot does not match the requested port and kind") if snapshot.source is not mode_profile.source or snapshot.waveform is not mode_profile.waveform: raise ConfigError(f"{operation} requires readable internal-sine modulation source and waveform") + if request.kind is RfModulationKind.AM: + if snapshot.selected_fm_pm_kind is not None: + raise ConfigError(f"{operation} AM snapshot has an unexpected FM/PM selection") + return + if snapshot.selected_fm_pm_kind not in { + RfModulationKind.FM, + RfModulationKind.PM, + }: + raise ConfigError(f"{operation} requires a readable FM/PM selection") + if ( + require_selected_fm_pm_kind + and snapshot.selected_fm_pm_kind is not request.kind + ): + raise ConfigError(f"{operation} postcondition does not select the requested FM/PM kind") @staticmethod def _snapshot_port( diff --git a/tests/test_rf_source_modulation_extensions.py b/tests/test_rf_source_modulation_extensions.py index 0a1e283..add8db9 100644 --- a/tests/test_rf_source_modulation_extensions.py +++ b/tests/test_rf_source_modulation_extensions.py @@ -251,6 +251,25 @@ def test_modulation_profile_and_snapshot_are_strict() -> None: enabled_modes=(RfModulationKind.PM, RfModulationKind.AM), global_enabled=True, ) + with pytest.raises(ValueError, match="require a selected FM/PM kind"): + RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.FM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + frequency_deviation_hz=10_000.0, + ) + with pytest.raises(ValueError, match="cannot carry an FM/PM selection"): + RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + selected_fm_pm_kind=RfModulationKind.FM, + depth_percent=50.0, + ) def test_modulation_descriptor_requires_readable_bounded_feature_and_methods() -> None: diff --git a/tests/test_rf_source_modulation_service.py b/tests/test_rf_source_modulation_service.py index 570cf1b..9a9f5c1 100644 --- a/tests/test_rf_source_modulation_service.py +++ b/tests/test_rf_source_modulation_service.py @@ -154,6 +154,7 @@ def _modulation_snapshot( value: float = 50.0, source: RfModulationSource = RfModulationSource.INTERNAL, waveform: RfModulationWaveform = RfModulationWaveform.SINE, + selected_fm_pm_kind: RfModulationKind | None = None, faults: tuple[str, ...] = (), ) -> RfModulationSnapshot: fields: dict[str, object] = { @@ -162,6 +163,11 @@ def _modulation_snapshot( "source": source, "waveform": waveform, "internal_frequency_hz": 1_000.0, + "selected_fm_pm_kind": ( + selected_fm_pm_kind + if selected_fm_pm_kind is not None + else (kind if kind in {RfModulationKind.FM, RfModulationKind.PM} else None) + ), "enabled_modes": (kind,) if enabled else (), "global_enabled": enabled, "fault_codes": faults, @@ -270,6 +276,75 @@ def test_modulation_uses_one_driver_sequence_and_independent_readback() -> None: assert service.session_state.health is SessionHealth.HEALTHY +def test_modulation_allows_off_only_fm_pm_selection_change_before_fixed_write() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot( + kind=RfModulationKind.PM, + value=2.0, + selected_fm_pm_kind=RfModulationKind.FM, + ), + _modulation_snapshot( + kind=RfModulationKind.PM, + enabled=True, + value=2.0, + selected_fm_pm_kind=RfModulationKind.PM, + ), + ], + ) + + result = service.configure_modulation(request) + + assert result.kind is RfModulationKind.PM + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "modulation_snapshot", + "configure_modulation", + "snapshot", + "modulation_snapshot", + ] + + +def test_modulation_requires_fm_pm_selection_after_fixed_write() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot( + kind=RfModulationKind.PM, + value=2.0, + selected_fm_pm_kind=RfModulationKind.FM, + ), + _modulation_snapshot( + kind=RfModulationKind.PM, + enabled=True, + value=2.0, + selected_fm_pm_kind=RfModulationKind.FM, + ), + ], + ) + + with pytest.raises(ConfigError, match="does not select the requested FM/PM kind"): + service.configure_modulation(request) + + assert driver.requests == [request] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + @pytest.mark.parametrize( ("rf_snapshot", "modulation_snapshot", "message"), ( From 8eeec0f4beab86d30fe090ff98707b686d17bff2 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:06:16 +0800 Subject: [PATCH 26/63] fix: separate RF modulation state preflight --- .../instruments/rf_source_capabilities.py | 1 + .../instruments/rf_source_extensions.py | 78 +++++++++++++++++-- src/wavebench/services/rf_source_service.py | 41 +++++----- tests/test_rf_source_extensions.py | 1 + tests/test_rf_source_modulation_extensions.py | 35 ++++++++- tests/test_rf_source_modulation_service.py | 64 ++++++++++++++- tests/test_rf_source_run.py | 12 +-- 7 files changed, 188 insertions(+), 44 deletions(-) diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 260a832..3a94819 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -30,6 +30,7 @@ "rf_source.snapshot": ("get_rf_snapshot",), "rf_source.cw_configure": ("configure_cw",), "rf_source.modulation_configure": ( + "get_rf_modulation_state", "get_rf_modulation_snapshot", "configure_rf_modulation", ), diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 439e129..fcd50b2 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -21,6 +21,7 @@ RF_SOURCE_CONTRACT_VERSION = "wavebench.rf_source.v1" RF_SOURCE_SNAPSHOT_SCHEMA = "wavebench.rf_source.snapshot.v1" +RF_SOURCE_MODULATION_STATE_SCHEMA = "wavebench.rf_source.modulation_state.v1" RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" @@ -128,15 +129,26 @@ class RfModulationKind(StrEnum): class RfModulationSource(StrEnum): - """The M3 contract intentionally exposes only the instrument-internal source.""" + """Known current modulation-source values. + + M3 configuration profiles remain internal-only. ``EXTERNAL`` exists only + so a driver can report an inactive device state that M3 will explicitly + replace with the requested internal source. + """ INTERNAL = "internal" + EXTERNAL = "external" class RfModulationWaveform(StrEnum): - """The M3 contract intentionally exposes only an internal sine waveform.""" + """Known current internal modulation-waveform values. + + M3 configuration profiles remain sine-only. ``SQUARE`` exists only for + typed readback of a current inactive device state before M3 replaces it. + """ SINE = "sine" + SQUARE = "square" class RfModulationValueUnit(StrEnum): @@ -349,6 +361,10 @@ def __post_init__(self) -> None: raise ValueError("RF modulation mode source has an invalid type") if not isinstance(self.waveform, RfModulationWaveform): raise ValueError("RF modulation mode waveform has an invalid type") + if self.source is not RfModulationSource.INTERNAL: + raise ValueError("RF modulation mode profiles must use the internal source") + if self.waveform is not RfModulationWaveform.SINE: + raise ValueError("RF modulation mode profiles must use the sine waveform") _require_finite(self.value_min, "RF modulation mode value_min") _require_finite( self.value_max, @@ -650,6 +666,37 @@ def value_unit(self) -> RfModulationValueUnit: }[self.kind] +@dataclass(frozen=True, slots=True) +class RfModulationStateSnapshot: + """State-only readback used before an M3 configuration write. + + An inactive device can legitimately retain an external source, a non-sine + waveform, or source-dependent values that are not queryable. M3 only + needs its mode/global/fault state before it replaces that configuration, + so this snapshot deliberately excludes profile fields. + """ + + port_id: str + enabled_modes: tuple[RfModulationKind, ...] = () + global_enabled: bool = False + fault_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation state snapshot port_id") + _require_enum_tuple( + self.enabled_modes, + RfModulationKind, + "RF modulation state snapshot enabled_modes", + allow_empty=True, + ) + _require_bool(self.global_enabled, "RF modulation state snapshot global_enabled") + _require_token_tuple( + self.fault_codes, + "RF modulation state snapshot fault_codes", + allow_empty=True, + ) + + @dataclass(frozen=True, slots=True) class RfModulationSnapshot: """Complete typed readback for one internal-sine modulation mode. @@ -760,6 +807,8 @@ def get_rf_snapshot(self) -> RfSourceSnapshot: ... def configure_cw(self, request: RfCwRequest) -> None: ... + def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: ... + def get_rf_modulation_snapshot( self, port_id: str, @@ -849,6 +898,18 @@ def rf_modulation_snapshot_document(snapshot: RfModulationSnapshot) -> dict[str, return {"schema": RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA, **data} +def rf_modulation_state_snapshot_document( + snapshot: RfModulationStateSnapshot, +) -> dict[str, object]: + """Build a redacted document for one typed RF modulation-state readback.""" + + if not isinstance(snapshot, RfModulationStateSnapshot): + raise TypeError("snapshot must be RfModulationStateSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_MODULATION_STATE_SCHEMA, **data} + + def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: """Build a read-only snapshot artifact without transport-private values.""" @@ -896,7 +957,7 @@ def rf_source_modulation_operation_artifact( result: RfModulationResult, *, preflight_snapshot: RfSourceSnapshot, - preflight_modulation_snapshot: RfModulationSnapshot, + preflight_modulation_state: RfModulationStateSnapshot, postcondition_snapshot: RfSourceSnapshot, postcondition_modulation_snapshot: RfModulationSnapshot, ) -> dict[str, object]: @@ -916,8 +977,8 @@ def rf_source_modulation_operation_artifact( raise ValueError("RF modulation request and result must describe the same target") if not isinstance(preflight_snapshot, RfSourceSnapshot): raise TypeError("preflight_snapshot must be RfSourceSnapshot") - if not isinstance(preflight_modulation_snapshot, RfModulationSnapshot): - raise TypeError("preflight_modulation_snapshot must be RfModulationSnapshot") + if not isinstance(preflight_modulation_state, RfModulationStateSnapshot): + raise TypeError("preflight_modulation_state must be RfModulationStateSnapshot") if not isinstance(postcondition_snapshot, RfSourceSnapshot): raise TypeError("postcondition_snapshot must be RfSourceSnapshot") if not isinstance(postcondition_modulation_snapshot, RfModulationSnapshot): @@ -928,8 +989,8 @@ def rf_source_modulation_operation_artifact( "request": rf_source_to_data(request), "result": rf_source_to_data(result), "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), - "preflight_modulation_snapshot": rf_modulation_snapshot_document( - preflight_modulation_snapshot + "preflight_modulation_state": rf_modulation_state_snapshot_document( + preflight_modulation_state ), "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), "postcondition_modulation_snapshot": rf_modulation_snapshot_document( @@ -971,6 +1032,7 @@ def rf_source_output_operation_artifact( __all__ = [ "RF_SOURCE_CONTRACT_VERSION", + "RF_SOURCE_MODULATION_STATE_SCHEMA", "RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", @@ -988,6 +1050,7 @@ def rf_source_output_operation_artifact( "RfModulationProfile", "RfModulationRequest", "RfModulationResult", + "RfModulationStateSnapshot", "RfModulationSnapshot", "RfModulationSource", "RfModulationState", @@ -1014,6 +1077,7 @@ def rf_source_output_operation_artifact( "rf_source_cw_operation_artifact", "rf_source_digest", "rf_modulation_snapshot_document", + "rf_modulation_state_snapshot_document", "rf_source_modulation_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 99f1a5d..ca8b9e3 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -25,6 +25,7 @@ RfModulationProfile, RfModulationRequest, RfModulationResult, + RfModulationStateSnapshot, RfModulationSnapshot, RfModulationSource, RfModulationState, @@ -69,7 +70,7 @@ class _RfCwTransaction: class _RfModulationTransaction: result: RfModulationResult preflight_snapshot: RfSourceSnapshot - preflight_modulation_snapshot: RfModulationSnapshot + preflight_modulation_state: RfModulationStateSnapshot postcondition_snapshot: RfSourceSnapshot postcondition_modulation_snapshot: RfModulationSnapshot @@ -267,7 +268,7 @@ def configure_modulation_with_artifact( request, transaction.result, preflight_snapshot=transaction.preflight_snapshot, - preflight_modulation_snapshot=transaction.preflight_modulation_snapshot, + preflight_modulation_state=transaction.preflight_modulation_state, postcondition_snapshot=transaction.postcondition_snapshot, postcondition_modulation_snapshot=transaction.postcondition_modulation_snapshot, ), @@ -299,15 +300,11 @@ def _configure_modulation_transaction( if session_state.health is not SessionHealth.HEALTHY: raise ConfigError(f"{operation} requires a healthy session") preflight_snapshot = rf_source.get_rf_snapshot() - preflight_modulation_snapshot = rf_source.get_rf_modulation_snapshot( - request.port_id, - request.kind, - ) + preflight_modulation_state = rf_source.get_rf_modulation_state(request.port_id) self._validate_modulation_preflight( request, preflight_snapshot, - preflight_modulation_snapshot, - mode_profile, + preflight_modulation_state, operation=operation, ) main_entered = False @@ -329,7 +326,7 @@ def _configure_modulation_transaction( return _RfModulationTransaction( result=result, preflight_snapshot=preflight_snapshot, - preflight_modulation_snapshot=preflight_modulation_snapshot, + preflight_modulation_state=preflight_modulation_state, postcondition_snapshot=postcondition_snapshot, postcondition_modulation_snapshot=postcondition_modulation_snapshot, ) @@ -836,8 +833,7 @@ def _validate_modulation_preflight( self, request: RfModulationRequest, snapshot: RfSourceSnapshot, - modulation_snapshot: RfModulationSnapshot, - mode_profile: RfModulationModeProfile, + modulation_state: RfModulationStateSnapshot, *, operation: str, ) -> None: @@ -847,16 +843,11 @@ def _validate_modulation_preflight( expected_modulation_state=RfModulationState.DISABLED, operation=operation, ) - self._validate_modulation_snapshot_identity( - request, - modulation_snapshot, - mode_profile, - require_selected_fm_pm_kind=False, - operation=operation, - ) - if modulation_snapshot.global_enabled or modulation_snapshot.enabled_modes: + if modulation_state.port_id != request.port_id: + raise ConfigError(f"{operation} modulation state does not match the requested port") + if modulation_state.global_enabled or modulation_state.enabled_modes: raise ConfigError(f"{operation} requires all modulation modes disabled") - if modulation_snapshot.fault_codes: + if modulation_state.fault_codes: raise ConfigError(f"{operation} requires no active modulation fault condition") def _validate_modulation_postcondition( @@ -878,6 +869,7 @@ def _validate_modulation_postcondition( request, modulation_snapshot, mode_profile, + require_target_profile=True, require_selected_fm_pm_kind=True, operation=operation, ) @@ -963,13 +955,18 @@ def _validate_modulation_snapshot_identity( snapshot: RfModulationSnapshot, mode_profile: RfModulationModeProfile, *, + require_target_profile: bool, require_selected_fm_pm_kind: bool, operation: str, ) -> None: if snapshot.port_id != request.port_id or snapshot.kind is not request.kind: raise ConfigError(f"{operation} modulation snapshot does not match the requested port and kind") - if snapshot.source is not mode_profile.source or snapshot.waveform is not mode_profile.waveform: - raise ConfigError(f"{operation} requires readable internal-sine modulation source and waveform") + if require_target_profile and ( + snapshot.source is not mode_profile.source or snapshot.waveform is not mode_profile.waveform + ): + raise ConfigError( + f"{operation} postcondition requires the requested internal-sine source and waveform" + ) if request.kind is RfModulationKind.AM: if snapshot.selected_fm_pm_kind is not None: raise ConfigError(f"{operation} AM snapshot has an unexpected FM/PM selection") diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 0a613c9..e777fbd 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -339,6 +339,7 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "rf_source.snapshot": ("get_rf_snapshot",), "rf_source.cw_configure": ("configure_cw",), "rf_source.modulation_configure": ( + "get_rf_modulation_state", "get_rf_modulation_snapshot", "configure_rf_modulation", ), diff --git a/tests/test_rf_source_modulation_extensions.py b/tests/test_rf_source_modulation_extensions.py index add8db9..30f411d 100644 --- a/tests/test_rf_source_modulation_extensions.py +++ b/tests/test_rf_source_modulation_extensions.py @@ -8,6 +8,7 @@ from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor from wavebench.instruments.rf_source_extensions import ( RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_MODULATION_STATE_SCHEMA, RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA, RfCwRequest, RfFeature, @@ -32,7 +33,9 @@ RfSourceTopology, RfSweepState, RfModulationState, + RfModulationStateSnapshot, rf_modulation_snapshot_document, + rf_modulation_state_snapshot_document, rf_source_modulation_operation_artifact, ) @@ -167,6 +170,10 @@ def get_rf_modulation_snapshot( assert kind is RfModulationKind.AM return _modulation_snapshot(enabled=False) + def get_rf_modulation_state(self, port_id: str): + assert port_id == "rf_out" + return RfModulationStateSnapshot(port_id=port_id) + def configure_rf_modulation(self, request: RfModulationRequest) -> None: del request @@ -240,6 +247,26 @@ def test_modulation_profile_and_snapshot_are_strict() -> None: ) with pytest.raises(ValueError, match="configuration readback"): RfModulationProfile(state_readable=False, configuration_readable=True) + with pytest.raises(ValueError, match="internal source"): + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + source=RfModulationSource.EXTERNAL, + ) + with pytest.raises(ValueError, match="sine waveform"): + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + waveform=RfModulationWaveform.SQUARE, + ) with pytest.raises(ValueError, match="sorted by value"): RfModulationSnapshot( port_id="rf_out", @@ -276,6 +303,7 @@ def test_modulation_descriptor_requires_readable_bounded_feature_and_methods() - descriptor = _descriptor() assert CAPABILITY_METHODS["rf_source.modulation_configure"] == ( + "get_rf_modulation_state", "get_rf_modulation_snapshot", "configure_rf_modulation", ) @@ -311,19 +339,22 @@ def test_modulation_artifact_keeps_typed_pre_and_postcondition_evidence() -> Non internal_frequency_hz=1_000.0, depth_percent=50.0, ) - preflight = _modulation_snapshot(enabled=False) + preflight_state = RfModulationStateSnapshot(port_id="rf_out") postcondition = _modulation_snapshot(enabled=True) document = rf_modulation_snapshot_document(postcondition) + state_document = rf_modulation_state_snapshot_document(preflight_state) artifact = rf_source_modulation_operation_artifact( request, result, preflight_snapshot=_rf_snapshot(), - preflight_modulation_snapshot=preflight, + preflight_modulation_state=preflight_state, postcondition_snapshot=_rf_snapshot(), postcondition_modulation_snapshot=postcondition, ) assert document["schema"] == RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA + assert state_document["schema"] == RF_SOURCE_MODULATION_STATE_SCHEMA assert artifact["operation"] == "rf_source.modulation_configure" + assert artifact["preflight_modulation_state"]["enabled_modes"] == [] assert artifact["postcondition_modulation_snapshot"]["global_enabled"] is True diff --git a/tests/test_rf_source_modulation_service.py b/tests/test_rf_source_modulation_service.py index 9a9f5c1..c424482 100644 --- a/tests/test_rf_source_modulation_service.py +++ b/tests/test_rf_source_modulation_service.py @@ -27,6 +27,7 @@ RfModulationSnapshot, RfModulationSource, RfModulationState, + RfModulationStateSnapshot, RfModulationValueUnit, RfModulationWaveform, RfObserved, @@ -214,6 +215,19 @@ def get_rf_modulation_snapshot( assert snapshot.kind is kind return snapshot + def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: + self.calls.append("modulation_state") + assert port_id == "rf_out" + if not self.modulation_snapshots: + raise AssertionError("unexpected modulation state") + snapshot = self.modulation_snapshots.pop(0) + return RfModulationStateSnapshot( + port_id=snapshot.port_id, + enabled_modes=snapshot.enabled_modes, + global_enabled=snapshot.global_enabled, + fault_codes=snapshot.fault_codes, + ) + def configure_rf_modulation(self, request: RfModulationRequest) -> None: self.calls.append("configure_modulation") self.requests.append(request) @@ -265,7 +279,7 @@ def test_modulation_uses_one_driver_sequence_and_independent_readback() -> None: assert driver.requests == [request] assert driver.calls == [ "snapshot", - "modulation_snapshot", + "modulation_state", "configure_modulation", "snapshot", "modulation_snapshot", @@ -276,6 +290,48 @@ def test_modulation_uses_one_driver_sequence_and_independent_readback() -> None: assert service.session_state.health is SessionHealth.HEALTHY +def test_modulation_replaces_an_inactive_external_square_profile_with_internal_sine() -> None: + request = _am_request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot( + source=RfModulationSource.EXTERNAL, + waveform=RfModulationWaveform.SQUARE, + ), + _modulation_snapshot(enabled=True), + ], + ) + + result = service.configure_modulation(request) + + assert result.depth_percent == request.depth_percent + assert driver.requests == [request] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_rejects_a_non_internal_sine_postcondition() -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot(), + _modulation_snapshot( + enabled=True, + source=RfModulationSource.EXTERNAL, + waveform=RfModulationWaveform.SQUARE, + ), + ], + ) + + with pytest.raises(ConfigError, match="postcondition requires the requested internal-sine"): + service.configure_modulation(_am_request()) + + assert len(driver.requests) == 1 + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + def test_modulation_allows_off_only_fm_pm_selection_change_before_fixed_write() -> None: request = RfModulationRequest( port_id="rf_out", @@ -306,7 +362,7 @@ def test_modulation_allows_off_only_fm_pm_selection_change_before_fixed_write() assert driver.requests == [request] assert driver.calls == [ "snapshot", - "modulation_snapshot", + "modulation_state", "configure_modulation", "snapshot", "modulation_snapshot", @@ -383,7 +439,7 @@ def test_modulation_rejects_unsafe_preflight_without_write( service.configure_modulation(_am_request()) assert driver.requests == [] - assert driver.calls == ["snapshot", "modulation_snapshot"] + assert driver.calls == ["snapshot", "modulation_state"] assert service.session_state is not None assert service.session_state.health is SessionHealth.HEALTHY @@ -421,7 +477,7 @@ def test_modulation_mismatch_is_not_retried_and_degrades_session() -> None: assert len(driver.requests) == 1 assert driver.calls == [ "snapshot", - "modulation_snapshot", + "modulation_state", "configure_modulation", "snapshot", "modulation_snapshot", diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index 2e3a2f4..7cf32dd 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -28,6 +28,7 @@ RfModulationSnapshot, RfModulationSource, RfModulationState, + RfModulationStateSnapshot, RfModulationWaveform, RfObserved, RfPortSnapshot, @@ -456,14 +457,7 @@ def test_rf_source_modulation_step_has_write_intent_and_separate_artifact_namesp internal_frequency_hz=1_000.0, ) preflight_snapshot = _snapshot() - preflight_modulation_snapshot = RfModulationSnapshot( - port_id="rf_out", - kind=RfModulationKind.AM, - source=RfModulationSource.INTERNAL, - waveform=RfModulationWaveform.SINE, - depth_percent=0.0, - internal_frequency_hz=1_000.0, - ) + preflight_modulation_state = RfModulationStateSnapshot(port_id="rf_out") postcondition_snapshot = RfSourceSnapshot( ports=( RfPortSnapshot( @@ -492,7 +486,7 @@ def test_rf_source_modulation_step_has_write_intent_and_separate_artifact_namesp request=request, result=result_value, preflight_snapshot=preflight_snapshot, - preflight_modulation_snapshot=preflight_modulation_snapshot, + preflight_modulation_state=preflight_modulation_state, postcondition_snapshot=postcondition_snapshot, postcondition_modulation_snapshot=postcondition_modulation_snapshot, ) From ab4de10fe607339278ff486e5a5b2566bbcfb560 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:30:31 +0800 Subject: [PATCH 27/63] feat: add RF modulation disable transaction --- .../instruments/rf_source_capabilities.py | 26 +++ .../instruments/rf_source_extensions.py | 80 +++++++ src/wavebench/services/operation_specs.py | 18 ++ src/wavebench/services/rf_source_service.py | 220 +++++++++++++++++- tests/test_operation_specs.py | 19 ++ tests/test_rf_source_modulation_extensions.py | 88 +++++++ tests/test_rf_source_modulation_service.py | 151 +++++++++++- 7 files changed, 596 insertions(+), 6 deletions(-) diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 3a94819..d23bfeb 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -34,6 +34,10 @@ "get_rf_modulation_snapshot", "configure_rf_modulation", ), + "rf_source.modulation_disable": ( + "get_rf_modulation_state", + "disable_rf_modulation", + ), "rf_source.output": ("set_rf_output",), } ) @@ -74,6 +78,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No _validate_cw_configure_feature(extensions) if "rf_source.modulation_configure" in rf_capabilities: _validate_modulation_configure_feature(extensions) + if "rf_source.modulation_disable" in rf_capabilities: + _validate_modulation_disable_feature(extensions) if "rf_source.output" in rf_capabilities: _validate_output_feature(extensions) _validate_rf_source_version_range(descriptor) @@ -146,6 +152,26 @@ def _validate_modulation_configure_feature(extensions: RfSourceDescriptorExtensi ) +def _validate_modulation_disable_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.DISABLE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.modulation_disable requires an RF modulation feature with " + "disable and read directions" + ) + if not isinstance(feature.profile, RfModulationProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.modulation_disable requires an RF modulation profile") + if not feature.profile.state_readable: + raise ConfigError("rf_source.modulation_disable requires readable RF modulation state") + + def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index fcd50b2..cf3216f 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -666,6 +666,40 @@ def value_unit(self) -> RfModulationValueUnit: }[self.kind] +@dataclass(frozen=True, slots=True) +class RfModulationDisableRequest: + """Disable exactly one active modulation mode on one RF output port. + + This request deliberately identifies the mode that must be active before + the write. It is not a broad reset: Service preflight rejects an unknown, + mixed-mode, or otherwise unsafe modulation state before the driver sends + its mode-specific disable sequence. + """ + + port_id: str + kind: RfModulationKind + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation disable request port_id") + if not isinstance(self.kind, RfModulationKind): + raise ValueError("RF modulation disable request kind has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfModulationDisableResult: + """A mode-specific modulation-disable request confirmed by typed state readback.""" + + port_id: str + kind: RfModulationKind + write_completed: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation disable result port_id") + if not isinstance(self.kind, RfModulationKind): + raise ValueError("RF modulation disable result kind has an invalid type") + _require_bool(self.write_completed, "RF modulation disable result write_completed") + + @dataclass(frozen=True, slots=True) class RfModulationStateSnapshot: """State-only readback used before an M3 configuration write. @@ -817,6 +851,8 @@ def get_rf_modulation_snapshot( def configure_rf_modulation(self, request: RfModulationRequest) -> None: ... + def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: ... + def set_rf_output(self, request: RfOutputRequest) -> None: ... @@ -999,6 +1035,47 @@ def rf_source_modulation_operation_artifact( } +def rf_source_modulation_disable_operation_artifact( + request: RfModulationDisableRequest, + result: RfModulationDisableResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_modulation_state: RfModulationStateSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_state: RfModulationStateSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one RF modulation-disable operation.""" + + if not isinstance(request, RfModulationDisableRequest): + raise TypeError("request must be RfModulationDisableRequest") + if not isinstance(result, RfModulationDisableResult): + raise TypeError("result must be RfModulationDisableResult") + if request.port_id != result.port_id or request.kind is not result.kind: + raise ValueError("RF modulation disable request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_modulation_state, RfModulationStateSnapshot): + raise TypeError("preflight_modulation_state must be RfModulationStateSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_modulation_state, RfModulationStateSnapshot): + raise TypeError("postcondition_modulation_state must be RfModulationStateSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.modulation_disable", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_modulation_state": rf_modulation_state_snapshot_document( + preflight_modulation_state + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_modulation_state": rf_modulation_state_snapshot_document( + postcondition_modulation_state + ), + } + + def rf_source_output_operation_artifact( request: RfOutputRequest, result: RfOutputResult, @@ -1046,6 +1123,8 @@ def rf_source_output_operation_artifact( "RfFeatureDirection", "RfFeatureProfile", "RfModulationKind", + "RfModulationDisableRequest", + "RfModulationDisableResult", "RfModulationModeProfile", "RfModulationProfile", "RfModulationRequest", @@ -1078,6 +1157,7 @@ def rf_source_output_operation_artifact( "rf_source_digest", "rf_modulation_snapshot_document", "rf_modulation_state_snapshot_document", + "rf_source_modulation_disable_operation_artifact", "rf_source_modulation_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 730406a..04c2c91 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1074,6 +1074,24 @@ def _spec( risk_flags=("rf_output_must_be_off", "modulation_state", "state_drift"), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.modulation_disable", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.modulation_disable"), + effect="write", + changed_fields=( + "rf_source.modulation.enabled_modes", + "rf_source.modulation.global_enabled", + ), + restore_coverage="none", + risk_flags=( + "rf_output_must_be_off", + "modulation_state", + "safe_modulation_disable", + "state_drift", + ), + safe_alternatives=("rf_source.snapshot",), + ), _spec( "rf_source.output_enable", "rf_source", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index ca8b9e3..2bd95da 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -20,6 +20,8 @@ RfCwResult, RfFeature, RfFeatureDirection, + RfModulationDisableRequest, + RfModulationDisableResult, RfModulationKind, RfModulationModeProfile, RfModulationProfile, @@ -42,6 +44,7 @@ RfSourceSnapshot, RfSweepState, rf_source_cw_operation_artifact, + rf_source_modulation_disable_operation_artifact, rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, ) @@ -75,6 +78,15 @@ class _RfModulationTransaction: postcondition_modulation_snapshot: RfModulationSnapshot +@dataclass(frozen=True) +class _RfModulationDisableTransaction: + result: RfModulationDisableResult + preflight_snapshot: RfSourceSnapshot + preflight_modulation_state: RfModulationStateSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_modulation_state: RfModulationStateSnapshot + + @dataclass(frozen=True) class _RfOutputTransaction: result: RfOutputResult @@ -338,6 +350,110 @@ def _configure_modulation_transaction( ) raise + def disable_modulation( + self, + request: RfModulationDisableRequest, + ) -> RfModulationDisableResult: + return self._disable_modulation_transaction(request).result + + def disable_modulation_with_artifact( + self, + request: RfModulationDisableRequest, + ) -> tuple[RfModulationDisableResult, dict[str, object]]: + """Disable one active M3 mode and retain typed pre/postcondition evidence.""" + + transaction = self._disable_modulation_transaction(request) + return ( + transaction.result, + rf_source_modulation_disable_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_modulation_state=transaction.preflight_modulation_state, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_modulation_state=transaction.postcondition_modulation_state, + ), + ) + + def _disable_modulation_transaction( + self, + request: RfModulationDisableRequest, + ) -> _RfModulationDisableTransaction: + """Disable one known active M3 mode without retry or RF-output recovery. + + The operation is intentionally narrow: a write is only permitted when + RF is OFF and typed state proves that exactly ``request.kind`` is active. + An already disabled, internally consistent state is a no-write success. + Any write failure or ambiguous postcondition leaves the session uncertain + for a fresh, independently preflighted recovery attempt. + """ + + if not isinstance(request, RfModulationDisableRequest): + raise ConfigError("rf_source modulation disable requires RfModulationDisableRequest") + operation = "rf_source.modulation_disable" + self._require(operation, "rf_source.snapshot", "rf_source.modulation_disable") + self._validate_modulation_disable_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_modulation_state = rf_source.get_rf_modulation_state(request.port_id) + write_required = self._validate_modulation_disable_preflight( + request, + preflight_snapshot, + preflight_modulation_state, + operation=operation, + ) + if not write_required: + return _RfModulationDisableTransaction( + result=RfModulationDisableResult( + port_id=request.port_id, + kind=request.kind, + write_completed=False, + ), + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=preflight_snapshot, + postcondition_modulation_state=preflight_modulation_state, + ) + + main_entered = False + try: + main_entered = True + rf_source.disable_rf_modulation(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_modulation_state = rf_source.get_rf_modulation_state( + request.port_id + ) + self._validate_modulation_disable_postcondition( + request, + postcondition_snapshot, + postcondition_modulation_state, + operation=operation, + ) + return _RfModulationDisableTransaction( + result=RfModulationDisableResult( + port_id=request.port_id, + kind=request.kind, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_state=postcondition_modulation_state, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_modulation_disable_postcondition_unverified", + ) + raise + def set_output(self, request: RfOutputRequest) -> RfOutputResult: return self._set_output_transaction(request).result @@ -751,6 +867,33 @@ def _validate_modulation_descriptor( ) return mode_profile + def _validate_modulation_disable_descriptor( + self, + request: RfModulationDisableRequest, + operation: str, + ) -> None: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.DISABLE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfModulationProfile) + or not feature.profile.state_readable + ): + raise ConfigError( + f"{operation} requires a readable disable-capable modulation profile for the target port" + ) + def _validate_cw_preflight( self, request: RfCwRequest, @@ -906,6 +1049,59 @@ def _validate_modulation_postcondition( phase_deviation_rad=request.value, ) + def _validate_modulation_disable_preflight( + self, + request: RfModulationDisableRequest, + snapshot: RfSourceSnapshot, + modulation_state: RfModulationStateSnapshot, + *, + operation: str, + ) -> bool: + modulation = self._validate_modulation_safe_rf_snapshot( + request.port_id, + snapshot, + operation=operation, + ) + if modulation_state.port_id != request.port_id: + raise ConfigError(f"{operation} modulation state does not match the requested port") + if modulation_state.fault_codes: + raise ConfigError(f"{operation} requires no active modulation fault condition") + if modulation is RfModulationState.DISABLED: + if modulation_state.global_enabled or modulation_state.enabled_modes: + raise ConfigError( + f"{operation} rejects a disabled RF snapshot with active modulation state" + ) + return False + if modulation_state.enabled_modes != (request.kind,): + raise ConfigError(f"{operation} requires only the requested modulation mode to be active") + if modulation_state.global_enabled is not True: + raise ConfigError(f"{operation} requires global modulation enabled") + return True + + def _validate_modulation_disable_postcondition( + self, + request: RfModulationDisableRequest, + snapshot: RfSourceSnapshot, + modulation_state: RfModulationStateSnapshot, + *, + operation: str, + ) -> None: + modulation = self._validate_modulation_safe_rf_snapshot( + request.port_id, + snapshot, + operation=operation, + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} postcondition requires modulation disabled") + if modulation_state.port_id != request.port_id: + raise ConfigError(f"{operation} modulation state does not match the requested port") + if modulation_state.enabled_modes: + raise ConfigError(f"{operation} postcondition requires all modulation modes disabled") + if modulation_state.global_enabled: + raise ConfigError(f"{operation} postcondition requires global modulation disabled") + if modulation_state.fault_codes: + raise ConfigError(f"{operation} postcondition reports an active modulation fault condition") + def _validate_modulation_rf_snapshot( self, request: RfModulationRequest, @@ -914,7 +1110,23 @@ def _validate_modulation_rf_snapshot( expected_modulation_state: RfModulationState, operation: str, ) -> None: - port = self._snapshot_port(snapshot, request.port_id, operation=operation) + modulation = self._validate_modulation_safe_rf_snapshot( + request.port_id, + snapshot, + operation=operation, + ) + if modulation is not expected_modulation_state: + expected = expected_modulation_state.value + raise ConfigError(f"{operation} requires modulation state {expected}") + + def _validate_modulation_safe_rf_snapshot( + self, + port_id: str, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> RfModulationState: + port = self._snapshot_port(snapshot, port_id, operation=operation) output_enabled = self._observed_value( port.output_enabled, f"{operation} requires a readable RF output state", @@ -925,9 +1137,8 @@ def _validate_modulation_rf_snapshot( port.modulation, f"{operation} requires a readable modulation state", ) - if modulation is not expected_modulation_state: - expected = expected_modulation_state.value - raise ConfigError(f"{operation} requires modulation state {expected}") + if not isinstance(modulation, RfModulationState): + raise ConfigError(f"{operation} requires a valid modulation state") pulse = self._observed_value( port.pulse, f"{operation} requires a readable Pulse state", @@ -948,6 +1159,7 @@ def _validate_modulation_rf_snapshot( raise ConfigError(f"{operation} requires a valid protection state") if protection.active_codes: raise ConfigError(f"{operation} requires no active protection condition") + return modulation @staticmethod def _validate_modulation_snapshot_identity( diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index d4f70f1..65a66f3 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -95,6 +95,25 @@ def test_rf_source_m2_output_specs_require_snapshot_and_output_capability() -> N assert "safe_output_disable" in disable.risk_flags +def test_rf_source_modulation_disable_spec_requires_state_evidence_and_keeps_rf_off() -> None: + disable = require_operation_spec("rf_source.modulation_disable") + + assert disable.instrument_kind == "rf_source" + assert disable.required_capabilities == ( + "rf_source.snapshot", + "rf_source.modulation_disable", + ) + assert disable.effect == "write" + assert disable.changed_fields == ( + "rf_source.modulation.enabled_modes", + "rf_source.modulation.global_enabled", + ) + assert disable.restore_coverage == "none" + assert "rf_output_must_be_off" in disable.risk_flags + assert "safe_modulation_disable" in disable.risk_flags + assert disable.safe_alternatives == ("rf_source.snapshot",) + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_modulation_extensions.py b/tests/test_rf_source_modulation_extensions.py index 30f411d..056b46d 100644 --- a/tests/test_rf_source_modulation_extensions.py +++ b/tests/test_rf_source_modulation_extensions.py @@ -14,6 +14,8 @@ RfFeature, RfFeatureCapability, RfFeatureDirection, + RfModulationDisableRequest, + RfModulationDisableResult, RfModulationKind, RfModulationModeProfile, RfModulationProfile, @@ -36,6 +38,7 @@ RfModulationStateSnapshot, rf_modulation_snapshot_document, rf_modulation_state_snapshot_document, + rf_source_modulation_disable_operation_artifact, rf_source_modulation_operation_artifact, ) @@ -148,6 +151,33 @@ def _descriptor() -> SimpleNamespace: ) +def _disable_descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulation", + kind="rf_source", + models=("RF-MOD",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfModulationProfile(state_readable=True), + ), + ), + ), + ) + + class _Driver: def close(self) -> None: return None @@ -177,6 +207,9 @@ def get_rf_modulation_state(self, port_id: str): def configure_rf_modulation(self, request: RfModulationRequest) -> None: del request + def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: + del request + def test_modulation_contract_binds_request_value_to_kind() -> None: am = RfModulationRequest( @@ -326,6 +359,32 @@ def test_modulation_descriptor_requires_readable_bounded_feature_and_methods() - validate_rf_source_descriptor(invalid) +def test_modulation_disable_descriptor_requires_state_read_and_disable_methods() -> None: + descriptor = _disable_descriptor() + + assert CAPABILITY_METHODS["rf_source.modulation_disable"] == ( + "get_rf_modulation_state", + "disable_rf_modulation", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _disable_descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfModulationProfile(state_readable=True), + ), + ), + ) + with pytest.raises(Exception, match="disable and read"): + validate_rf_source_descriptor(invalid) + + def test_modulation_artifact_keeps_typed_pre_and_postcondition_evidence() -> None: request = RfModulationRequest( port_id="rf_out", @@ -358,3 +417,32 @@ def test_modulation_artifact_keeps_typed_pre_and_postcondition_evidence() -> Non assert artifact["operation"] == "rf_source.modulation_configure" assert artifact["preflight_modulation_state"]["enabled_modes"] == [] assert artifact["postcondition_modulation_snapshot"]["global_enabled"] is True + + +def test_modulation_disable_artifact_keeps_state_only_pre_and_postcondition_evidence() -> None: + request = RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + result = RfModulationDisableResult( + port_id="rf_out", + kind=RfModulationKind.AM, + write_completed=True, + ) + preflight_state = RfModulationStateSnapshot( + port_id="rf_out", + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + postcondition_state = RfModulationStateSnapshot(port_id="rf_out") + + artifact = rf_source_modulation_disable_operation_artifact( + request, + result, + preflight_snapshot=_rf_snapshot(), + preflight_modulation_state=preflight_state, + postcondition_snapshot=_rf_snapshot(), + postcondition_modulation_state=postcondition_state, + ) + + assert artifact["operation"] == "rf_source.modulation_disable" + assert artifact["result"]["write_completed"] is True + assert artifact["preflight_modulation_state"]["global_enabled"] is True + assert artifact["postcondition_modulation_state"]["enabled_modes"] == [] diff --git a/tests/test_rf_source_modulation_service.py b/tests/test_rf_source_modulation_service.py index c424482..0f66fb0 100644 --- a/tests/test_rf_source_modulation_service.py +++ b/tests/test_rf_source_modulation_service.py @@ -20,6 +20,7 @@ RfFeature, RfFeatureCapability, RfFeatureDirection, + RfModulationDisableRequest, RfModulationKind, RfModulationModeProfile, RfModulationProfile, @@ -94,7 +95,14 @@ def _profile() -> RfModulationProfile: ) -def _descriptor(*capabilities: str, profile: RfModulationProfile | None = None) -> SimpleNamespace: +def _descriptor( + *capabilities: str, + profile: RfModulationProfile | None = None, + directions: tuple[RfFeatureDirection, ...] = ( + RfFeatureDirection.CONFIGURE, + RfFeatureDirection.READ, + ), +) -> SimpleNamespace: return SimpleNamespace( driver_id="example.rf.modulation", capabilities=capabilities, @@ -115,7 +123,7 @@ def _descriptor(*capabilities: str, profile: RfModulationProfile | None = None) features=( RfFeatureCapability( feature=RfFeature.MODULATION, - directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + directions=directions, port_ids=("rf_out",), profile=profile or _profile(), ), @@ -192,6 +200,7 @@ def __init__( self.modulation_snapshots = list(modulation_snapshots) self.calls: list[str] = [] self.requests: list[RfModulationRequest] = [] + self.disable_requests: list[RfModulationDisableRequest] = [] def close(self) -> None: self.calls.append("close") @@ -232,6 +241,10 @@ def configure_rf_modulation(self, request: RfModulationRequest) -> None: self.calls.append("configure_modulation") self.requests.append(request) + def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: + self.calls.append("disable_modulation") + self.disable_requests.append(request) + def _service( rf_snapshots: list[RfSourceSnapshot], @@ -265,6 +278,10 @@ def _am_request(*, depth_percent: float = 50.0) -> RfModulationRequest: ) +def _am_disable_request() -> RfModulationDisableRequest: + return RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + + def test_modulation_uses_one_driver_sequence_and_independent_readback() -> None: request = _am_request() service, driver = _service( @@ -484,3 +501,133 @@ def test_modulation_mismatch_is_not_retried_and_degrades_session() -> None: ] assert service.session_state is not None assert service.session_state.health is SessionHealth.UNCERTAIN + + +def _disable_descriptor() -> SimpleNamespace: + return _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.READ), + ) + + +def test_modulation_disable_uses_one_mode_global_sequence_and_independent_state_readback() -> None: + request = _am_disable_request() + service, driver = _service( + [ + _rf_snapshot(modulation=RfModulationState.ENABLED), + _rf_snapshot(modulation=RfModulationState.DISABLED), + ], + [_modulation_snapshot(enabled=True), _modulation_snapshot(enabled=False)], + descriptor=_disable_descriptor(), + ) + + result, artifact = service.disable_modulation_with_artifact(request) + + assert result.write_completed is True + assert driver.disable_requests == [request] + assert driver.calls == [ + "snapshot", + "modulation_state", + "disable_modulation", + "snapshot", + "modulation_state", + ] + assert artifact["operation"] == "rf_source.modulation_disable" + assert artifact["postcondition_modulation_state"]["enabled_modes"] == [] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_disable_is_a_no_write_success_for_an_already_disabled_consistent_state() -> None: + service, driver = _service( + [_rf_snapshot()], + [_modulation_snapshot(enabled=False)], + descriptor=_disable_descriptor(), + ) + + result = service.disable_modulation(_am_disable_request()) + + assert result.write_completed is False + assert driver.disable_requests == [] + assert driver.calls == ["snapshot", "modulation_state"] + + +@pytest.mark.parametrize( + ("rf_snapshot", "modulation_snapshot", "message"), + ( + ( + _rf_snapshot(output_enabled=True, modulation=RfModulationState.ENABLED), + _modulation_snapshot(enabled=True), + "target RF output OFF", + ), + ( + _rf_snapshot(modulation=RfModulationState.ENABLED), + _modulation_snapshot(kind=RfModulationKind.FM, enabled=True), + "only the requested modulation mode", + ), + ), +) +def test_modulation_disable_rejects_unsafe_or_ambiguous_preflight_without_write( + rf_snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + message: str, +) -> None: + service, driver = _service( + [rf_snapshot], + [modulation_snapshot], + descriptor=_disable_descriptor(), + ) + + with pytest.raises(ConfigError, match=message): + service.disable_modulation(_am_disable_request()) + + assert driver.disable_requests == [] + assert driver.calls == ["snapshot", "modulation_state"] + + +def test_modulation_disable_checks_capability_and_access_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.modulation_disable"): + missing.disable_modulation(_am_disable_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service( + [], + [], + access="read_only", + descriptor=_disable_descriptor(), + ) + with pytest.raises(AccessDeniedError, match="rf_source.modulation_disable"): + read_only.disable_modulation(_am_disable_request()) + assert read_only_driver.calls == [] + + +def test_modulation_disable_postcondition_mismatch_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [ + _rf_snapshot(modulation=RfModulationState.ENABLED), + _rf_snapshot(modulation=RfModulationState.ENABLED), + ], + [_modulation_snapshot(enabled=True), _modulation_snapshot(enabled=True)], + descriptor=_disable_descriptor(), + ) + + with pytest.raises(ConfigError, match="postcondition requires modulation disabled"): + service.disable_modulation(_am_disable_request()) + + assert len(driver.disable_requests) == 1 + assert driver.calls == [ + "snapshot", + "modulation_state", + "disable_modulation", + "snapshot", + "modulation_state", + ] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN From 775bb4142e7352fa8891fdb719c0c6997c748fda Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:44:24 +0800 Subject: [PATCH 28/63] docs: organize RF source documentation --- README.md | 8 ++++++- docs/README.md | 6 ++++-- docs/project/README.md | 9 +++++--- ...21\351\207\214\347\250\213\347\242\221.md" | 21 ++++++++++++------- ...67\346\272\220\350\256\276\350\256\241.md" | 19 +++++++++++------ ...77\347\224\250\346\214\207\345\215\227.md" | 19 +++++++++++++++-- 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 06076e7..d9c65f4 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,13 @@ wavebench tui --fake 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 -RF 信号源采用独立于普通 `source` 的领域模型。Core `0.8.25` 已提供 M0–M3 合同;DSG830 已完成 A1 只读快照、A2 受控输出和 A3 CW 环回证据,production descriptor 开放 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。CW 只覆盖目标 RF OFF 时的单字段频率/dBm 功率写入,输出只覆盖具有完整 safety 配置的 `rf_out` ON/OFF。M3 的内部正弦 AM/FM/PM 已完成离线合同与 driver 映射,但 production capability 仍等待 A4;Pulse、Sweep 和触发也仍由后续证据门控制。日常使用见 [RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md),设计与下一步见 [RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) 和 [RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md)。 +## RF 信号源 + +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置,以及具有完整 safety 配置的 `rf_out` ON/OFF;内部正弦 AM/FM/PM、Pulse、Sweep 和触发仍由各自的实机证据门控制。 + +- 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) +- 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) +- Core/插件的同步计划和证据状态:[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md) ## 三条常用路径 diff --git a/docs/README.md b/docs/README.md index f23bfdd..575431a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,10 @@ wavebench run check --plan /tmp/wavebench-demo.toml - [配置文件格式](project/reference/WaveBench_配置文件格式.md):TOML 查找顺序、字段和安全限制。 - 仪器型号命令和编程手册由 [仪器插件仓库](https://github.com/Scaxlibur/wavebench-instrument-plugins) 维护;本仓库只记录 WaveBench 的接入边界。 +### 使用 RF 信号源 + +`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;需要实现新型号或查看证据门时,再阅读[领域设计](project/design/WaveBench_RF信号源设计.md)与[开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 + ### 执行实验 - [run plan 使用指南](project/guides/WaveBench_run_plan_使用指南.md):模板、`run check`、`run verify`、执行、恢复和报告。 @@ -54,8 +58,6 @@ wavebench run check --plan /tmp/wavebench-demo.toml - [设备抽象层](project/design/WaveBench_设备抽象层.md) - [多仪器流程设计](project/design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](project/design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](project/design/WaveBench_RF信号源设计.md):当前 M0–M2 合同、OFF-only CW/端口级输出安全规则与后续写入边界。 -- [RF 信号源开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库状态与 A1–A5 证据门;DSG830 已完成 A1/A2/A3,开放 `rf_source.cw_configure` 和 `rf_source.output` 写入。 - [TUI 终端控制面板](project/guides/WaveBench_TUI终端控制面板.md) 目录分类见 [project/README](project/README.md)。本页只负责入口,不把阶段记录当作当前使用说明。 diff --git a/docs/project/README.md b/docs/project/README.md index 371c5a5..3f15e0f 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -2,11 +2,16 @@ `docs/project/` 按文档用途分组。当前重点是让入口、参考资料和设计说明各自承担单一职责;文件名暂时保留原样,ASCII 文件名迁移另行处理。 +## RF 信号源 + +- [使用指南](guides/WaveBench_RF信号源使用指南.md):配置、端接、当前 production capability 和上机前检查。 +- [领域设计](design/WaveBench_RF信号源设计.md):独立模型、OperationSpec、安全语义和 capability 边界。 +- [开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库分工、实机证据和下一阶段工作。 + ## guides:使用指南 - [CLI 形态](guides/WaveBench_CLI形态.md) - [run plan 使用指南](guides/WaveBench_run_plan_使用指南.md) -- [RF 信号源使用指南](guides/WaveBench_RF信号源使用指南.md):独立 RF 配置、当前 production 边界、M3 离线入口与上机前检查。 - [可安装仪器插件用户指南](guides/WaveBench_可安装仪器插件.md) - [TUI 终端控制面板](guides/WaveBench_TUI终端控制面板.md) - [HTTP MCP 只读接口](guides/WaveBench_HTTP_MCP_只读接口.md) @@ -25,8 +30,6 @@ - [设备抽象层](design/WaveBench_设备抽象层.md) - [多仪器流程设计](design/WaveBench_多仪器协同流程设计.md) - [sweep 状态保存与恢复](design/WaveBench_sweep状态恢复设计.md) -- [RF 信号源领域设计](design/WaveBench_RF信号源设计.md):M0–M3 合同、已开放的 CW/端口输出边界、后续写入设计与安全规则。 -- [RF 信号源开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core 与 DSG830 插件的同步状态、依赖和 A1–A5 实机证据门。 ## rfcs:接口提案与决策 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 98cc58c..1309489 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M3 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出和内部正弦 AM/FM/PM 的类型合同、Service、CLI、run 路径与 artifact;production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;A1、A2、A3 已完成 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射以及内部正弦 AM/FM/PM 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | -| 真实仪器证据 | A1、A2、A3 已完成;A4、A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | +| Core `0.8.25` 开发线 | M0–M3 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出,以及内部正弦 AM/FM/PM 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;A4 受控验证中,尚未通过 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射以及内部正弦 AM/FM/PM 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | +| 真实仪器证据 | A1、A2、A3 已完成;A4 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | ## 双仓库交付规则 @@ -20,7 +20,7 @@ | 插件跟随 | DSG830 已迁移为 `kind="rf_source"`,并把 `Requires-Dist: wavebench` 与 descriptor 版本门同步为 `>=0.8.25,<0.9`;当前双仓库开发依赖匹配的 Core checkout/版本范围。 | | 测试隔离 | 后续 capability 可以只出现在 fake descriptor 中,用于离线测试;production descriptor 不得提前声明。 | | 证据提升 | capability 进入 production descriptor 前,必须有对应 A 级实机证据,记录型号、固件、选件、端口、端接和最终 RF OFF 状态。 | -| 失败语义 | 不确定写入不重试;只有 session health 允许时,M2 才可最多执行一次目标端口 RF OFF recovery。 | +| 失败语义 | 不确定写入不重试;只有 session health 允许时,M2 才可最多执行一次目标端口 RF OFF recovery。M3 配置失败后只允许在新的、独立预检 session 中使用按模式调制关闭事务恢复已知状态。 | ## 里程碑总览 @@ -30,7 +30,7 @@ | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | -| M3 | 离线完成;A4 未开始 | 声明式内部正弦 AM/FM/PM profile、事务、CLI、run 与 artifact | 手册范围内的内部 Sine AM/FM/PM 映射与严格 readback | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待 A4。 | +| M3 | 离线完成;A4 受控验证中,尚未通过 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待 A4。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | ## Seed:历史种子包 @@ -85,16 +85,21 @@ Core 已冻结 `RfModulationModeProfile`、typed request/result、调制 snaps Sine AM/FM/PM:AM 使用 percent 深度,FM 使用 Hz 频偏,PM 使用 rad 相偏;每种模式都有独立的内部频率和静态范围。 run plan 使用 `modulation_kind` 表示 AM/FM/PM,避免与步骤自身的 `kind` 键冲突,并且只能提供与该模式匹配的一个数值字段。 -DSG830 driver 已实现固定且无重试的内部正弦写入序列与严格 readback:读取全局调制状态、三种模式的 enable 状态、 -目标模式 source/waveform/数值/内部频率与 FM/PM 共享 mode type。当前类型与目标 FM/PM 不同但三种模式均 disabled 时,preflight 可继续, +DSG830 driver 已实现固定且无重试的内部正弦写入序列与严格 readback:先读取全局调制状态、三种模式的 enable 状态,只有写后才读取目标 profile 的 source/waveform/数值/内部频率;这样不会因未启用的外部 profile 阻塞安全 preflight。 +FM/PM 的共享 mode type 会与被查询 profile 分开记录。当前类型与目标 FM/PM 不同但三种模式均 disabled 时,preflight 可继续, 固定写入显式选择目标类型;postcondition 必须核对目标类型。M3 preflight 要求目标 RF 输出 OFF、AM/FM/PM 均 disabled、Pulse/Sweep disabled 且无活动 protection condition;postcondition 要求 RF 仍 OFF、仅目标模式 enabled、全局调制 开启且所有目标字段精确匹配。写入或 postcondition 结果不明时不重试,session 降为不确定状态。 +`rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。该 operation 当前只供 A4 本地证据与恢复流程使用,不进入 DSG830 production descriptor。 + production descriptor 的调制 capability 仍需要 A4 证据;离线 driver、fake descriptor、CLI 或 run step 的完整测试均不能替代它。 当前 M2 的 RF ON 合同要求调制 disabled,因此 A4 即使仅提升 M3 配置 capability,也不授权在调制开启时输出 RF;该能力需要后续专门的 输出安全合同与实机证据。 +DSG830 源码 checkout 已提供 A4 的独立本地 harness 和资源无关 setup 模板,并已进入受控硬件验证。一次运行只配置一个内部 Sine +AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是 A4 通过证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。当前尚未取得可提升 `rf_source.modulation_configure` 的合格 A4 证据,不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 + ## M4:Pulse 与 Step Sweep Core 冻结 Pulse/Sweep profile、`arm`/`trigger`/`fire`/`stop` 的 operation 映射和安全规则。`RfPortSnapshot` 中的 Pulse、Sweep 状态必须可区分,不能将外部 trigger、后面板辅助输出或设备私有模式默认为安全。 @@ -166,4 +171,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 -6. A4 先验证 M3 的 RF-OFF 配置和独立 readback,再讨论任何允许调制开启时 RF 输出的专门安全合同;不得把当前 CH2 可见信号证据外推为调制输出证据。 +6. A4 已进入受控验证,但当前尚未取得可提升 capability 的合格记录。先完成 AM/FM/PM 各自的 RF-OFF 单模式配置、读回与关闭恢复证据,再讨论任何允许调制开启时 RF 输出的专门安全合同;不得把当前 CH2 可见信号证据外推为调制输出证据。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 1a51615..d755ca5 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -15,9 +15,9 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出和 M3 内部正弦 AM/FM/PM 的事务、CLI、run step 与 artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM,以及仅用于受控恢复的调制关闭事务。 | production capability 仍由各插件的实机证据逐项决定。 | | DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 和内部正弦 AM/FM/PM 映射;A1/A2/A3 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | -| 实机证据 | A1、A2、A3 已完成;A4、A5 未开始。 | DSG830 production descriptor 开放 snapshot、OFF-only CW 和端口级 output。 | +| 实机证据 | A1、A2、A3 已完成;A4 已进入受控验证但尚无合格证据;A5 未开始。 | DSG830 production descriptor 开放 snapshot、OFF-only CW 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -216,6 +216,7 @@ Core 在调用目标 driver operation 前校验 request、access、descriptor | `rf_source.cw_configure` | `configure_cw(request)` | 端口频率与 dBm 功率配置 | | `rf_source.output` | `set_rf_output(request)` | 单端口 RF ON/OFF | | `rf_source.modulation_configure` | `configure_rf_modulation(request)` | 已声明的 AM/FM/PM 配置 | +| `rf_source.modulation_disable` | `disable_rf_modulation(request)` | 关闭一个已明确识别的调制模式与全局调制开关 | | `rf_source.pulse_configure` | `configure_rf_pulse(request)` | 已声明的 Pulse 配置 | | `rf_source.pulse_trigger` | `trigger_rf_pulse(request)` | 已声明的 Pulse 触发 | | `rf_source.sweep_configure` | `configure_rf_sweep(request)` | 已声明的 Sweep 配置 | @@ -262,6 +263,12 @@ CW、调制、Pulse 与 Sweep 配置必须按以下顺序执行: 主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。M1 CW 与 M3 调制配置不执行 RF OFF recovery,而是将 session 保持在更保守状态;只有 M2 的 RF ON 事务可在 session health 允许时最多执行一次目标端口 RF OFF 并独立回读。 +### 调制关闭与恢复 + +`rf_source.modulation_disable` 是一个独立的、按模式寻址的写事务,不是 reset,也不等同于 RF 输出关闭。它只在目标 RF 输出为 OFF、Pulse/Sweep 已关闭、protection 清晰,并且状态明确表明仅请求的 AM、FM 或 PM 模式已启用时才发送关闭写入;随后必须用 RF snapshot 和调制状态回读确认全局调制及所有模式均已关闭。 + +已一致关闭的状态以零写方式返回。混合模式、状态矛盾、未知状态或写后结果不明都会拒绝或使 session 降为不确定状态,不能改用宽泛关闭命令重试。该 operation 当前用于受控本地证据和恢复流程;生产 DSG830 descriptor 不因此新增 capability,也没有面向日常使用的 CLI 或 run step。 + RF ON 是独立 operation。其 preflight 必须确认: - `access = "read_write"`; @@ -341,12 +348,12 @@ Pulse、Sweep 和 trigger 的命令与 step 仍是目标合同,尚未进入当 | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | -| M3(离线完成;A4 未开始) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、Service、CLI、run step 与 artifact | 内部 Sine 调制序列与严格 readback | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待 A4。 | +| M3(离线完成;A4 受控验证中,尚未通过) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待 A4。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`。A4、A5 仍分别是调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`。A4 的本地 RF-OFF 单模式 harness 已完成 fake 回归并进入受控硬件验证,但尚未取得可提升 capability 的合格证据;A4、A5 仍分别是调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -365,7 +372,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射与 readback,但 production descriptor 在 A4 前不声明 `rf_source.modulation_configure`;严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。两条路径均不读取 scope、不调用 RF output,且 production descriptor 在 A4 复核前不声明 `rf_source.modulation_configure` 或 `rf_source.modulation_disable`。严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -382,5 +389,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。A4–A5 尚未开始,不能据此提升调制、Pulse、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 已进入受控验证,但尚未取得可提升调制 capability 的合格证据。A4–A5 仍不能据此提升调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 2923f67..089a257 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -22,7 +22,7 @@ | 身份与状态 | 已开放 | 已开放 | `read_only` 可执行。 | | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | -| 内部正弦 AM/FM/PM | M3 离线合同已完成 | 未开放 | A4 实机证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | +| 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无合格实机证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | | Pulse、Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -56,7 +56,20 @@ actual_termination_ohm = 50 示例中的 `50` 只适用于已人工确认整个 RF 路径确实以 50 Ω 端接的场景。若 RF 直接接入示波器,示波器 CH2 已设为 50 Ω 只是必要信息之一;线缆、转接件、分配器和实际连接路径也必须一起核对。无法确认时,不应填写猜测值。 -网络发现只能帮助定位候选设备。候选资源仍须通过只读身份查询、型号核对和隔离配置复核;发现结果不自动写回配置,也不构成写入授权。 +网络发现只能帮助定位候选设备。先使用有界扫描,例如: + +```bash +wavebench net discover \ + --subnet 192.0.2.0/24 \ + --ports 5025,5555,111 \ + --timeout-ms 500 \ + --workers 16 \ + --max-hosts 256 \ + --no-idn \ + --no-visa +``` + +候选资源仍须通过只读身份查询、型号核对和隔离配置复核;发现结果不自动写回配置,也不构成写入授权。VXI-11 设备可能只表现为候选端口,未响应 socket `*IDN?` 不等于设备不可用。 ## 当前生产操作 @@ -138,6 +151,8 @@ M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disable 截至当前,DSG830 production descriptor 不声明 `rf_source.modulation_configure`。因此上述命令和 step 仅用于离线 fake descriptor、开发验证或未来已取得 A4 证据的插件;对当前 production DSG830 会在打开 transport 前被 capability 门拒绝。 +DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。A4 已进入受控验证,但尚未取得可提升 capability 的合格证据。 + M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 ## 上机前检查清单 From cc206c9930145d176036eba226cf6034edee5da1 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:09:03 +0800 Subject: [PATCH 29/63] test: cover RF modulation disable capability --- tests/test_rf_source_extensions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index e777fbd..8a490ad 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -343,6 +343,10 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "get_rf_modulation_snapshot", "configure_rf_modulation", ), + "rf_source.modulation_disable": ( + "get_rf_modulation_state", + "disable_rf_modulation", + ), "rf_source.output": ("set_rf_output",), } assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( From db74fabc73ed656cf5c38f4eec8a09f39cfd3e18 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:09:09 +0800 Subject: [PATCH 30/63] docs: record RF modulation validation status --- README.md | 2 +- docs/README.md | 2 +- ...345\217\221\351\207\214\347\250\213\347\242\221.md" | 10 +++++----- ...345\217\267\346\272\220\350\256\276\350\256\241.md" | 8 ++++---- ...344\275\277\347\224\250\346\214\207\345\215\227.md" | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d9c65f4..daa2918 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置,以及具有完整 safety 配置的 `rf_out` ON/OFF;内部正弦 AM/FM/PM、Pulse、Sweep 和触发仍由各自的实机证据门控制。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置,以及具有完整 safety 配置的 `rf_out` ON/OFF。内部正弦调制的 A4 验证已通过 AM、FM 的 RF-OFF 序列,但 PM 仍有严格读回不匹配;由于 capability 覆盖三种模式,调制、Pulse、Sweep 和触发继续保持关闭。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git a/docs/README.md b/docs/README.md index 575431a..4499115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ wavebench run check --plan /tmp/wavebench-demo.toml ### 使用 RF 信号源 -`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;需要实现新型号或查看证据门时,再阅读[领域设计](project/design/WaveBench_RF信号源设计.md)与[开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 +`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;需要实现新型号或查看证据门时,再阅读 [领域设计](project/design/WaveBench_RF信号源设计.md) 与 [开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 ### 执行实验 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 1309489..4189ede 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -9,8 +9,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | | Core `0.8.25` 开发线 | M0–M3 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出,以及内部正弦 AM/FM/PM 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;A4 受控验证中,尚未通过 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射以及内部正弦 AM/FM/PM 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | -| 真实仪器证据 | A1、A2、A3 已完成;A4 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射以及内部正弦 AM/FM/PM 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | +| 真实仪器证据 | A1、A2、A3 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | ## 双仓库交付规则 @@ -30,7 +30,7 @@ | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | -| M3 | 离线完成;A4 受控验证中,尚未通过 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待 A4。 | +| M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | | M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | ## Seed:历史种子包 @@ -98,7 +98,7 @@ production descriptor 的调制 capability 仍需要 A4 证据;离线 driver 输出安全合同与实机证据。 DSG830 源码 checkout 已提供 A4 的独立本地 harness 和资源无关 setup 模板,并已进入受控硬件验证。一次运行只配置一个内部 Sine -AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是 A4 通过证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。当前尚未取得可提升 `rf_source.modulation_configure` 的合格 A4 证据,不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 +AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。AM、FM 的序列已通过;PM 目前在严格读回中不匹配请求值,每次异常后均通过独立恢复回到关闭基线。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是 A4 通过证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。当前尚未取得可提升 `rf_source.modulation_configure` 的完整 A4 证据,不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 ## M4:Pulse 与 Step Sweep @@ -171,4 +171,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 -6. A4 已进入受控验证,但当前尚未取得可提升 capability 的合格记录。先完成 AM/FM/PM 各自的 RF-OFF 单模式配置、读回与关闭恢复证据,再讨论任何允许调制开启时 RF 输出的专门安全合同;不得把当前 CH2 可见信号证据外推为调制输出证据。 +6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index d755ca5..b1934ef 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -17,7 +17,7 @@ | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM,以及仅用于受控恢复的调制关闭事务。 | production capability 仍由各插件的实机证据逐项决定。 | | DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 和内部正弦 AM/FM/PM 映射;A1/A2/A3 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | -| 实机证据 | A1、A2、A3 已完成;A4 已进入受控验证但尚无合格证据;A5 未开始。 | DSG830 production descriptor 开放 snapshot、OFF-only CW 和端口级 output。 | +| 实机证据 | A1、A2、A3 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A5 未开始。 | M3 capability 覆盖三种模式,DSG830 production descriptor 继续只开放 snapshot、OFF-only CW 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -348,12 +348,12 @@ Pulse、Sweep 和 trigger 的命令与 step 仍是目标合同,尚未进入当 | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | -| M3(离线完成;A4 受控验证中,尚未通过) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待 A4。 | +| M3(离线完成;A4 的 AM、FM 已通过,PM 待定位) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待完整 A4。 | | M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`。A4 的本地 RF-OFF 单模式 harness 已完成 fake 回归并进入受控硬件验证,但尚未取得可提升 capability 的合格证据;A4、A5 仍分别是调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`。A4 的 AM、FM RF-OFF 单模式验证已通过并完成关闭恢复;PM 仍有严格读回不匹配,因此尚未形成可提升整体调制 capability 的合格证据。A4、A5 仍分别是调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -389,5 +389,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 已进入受控验证,但尚未取得可提升调制 capability 的合格证据。A4–A5 仍不能据此提升调制、Pulse、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。A4–A5 仍不能据此提升调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 089a257..8a5d38f 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -22,7 +22,7 @@ | 身份与状态 | 已开放 | 已开放 | `read_only` 可执行。 | | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | -| 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无合格实机证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | +| 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无覆盖三种模式的完整合格证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | | Pulse、Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -151,7 +151,7 @@ M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disable 截至当前,DSG830 production descriptor 不声明 `rf_source.modulation_configure`。因此上述命令和 step 仅用于离线 fake descriptor、开发验证或未来已取得 A4 证据的插件;对当前 production DSG830 会在打开 transport 前被 capability 门拒绝。 -DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。A4 已进入受控验证,但尚未取得可提升 capability 的合格证据。 +DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。A4 的 AM、FM RF-OFF 序列已通过;PM 仍有严格读回不匹配,故整体调制 capability 尚未提升。 M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 From a75341095be7b74c6efc4af41df88b2bfe1b4a4a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:25:24 +0800 Subject: [PATCH 31/63] docs: describe RF profile diagnostic boundary --- ...\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" | 2 +- ...\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 4189ede..df52ed2 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -171,4 +171,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 -6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 +6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成 A4 capability 提升证据。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 8a5d38f..1ed4d1a 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -151,7 +151,7 @@ M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disable 截至当前,DSG830 production descriptor 不声明 `rf_source.modulation_configure`。因此上述命令和 step 仅用于离线 fake descriptor、开发验证或未来已取得 A4 证据的插件;对当前 production DSG830 会在打开 transport 前被 capability 门拒绝。 -DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。A4 的 AM、FM RF-OFF 序列已通过;PM 仍有严格读回不匹配,故整体调制 capability 尚未提升。 +DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。显式 `--diagnose` 保留原始 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并要求 transport audit 为零写;它只生成私有诊断记录。A4 的 AM、FM RF-OFF 序列已通过;PM 仍有严格读回不匹配,故整体调制 capability 尚未提升。 M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 From ee790dc41b3dbc099124f86e3f04f17da6cb93fa Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:49:10 +0800 Subject: [PATCH 32/63] feat: add RF pulse configuration contract --- .../instruments/rf_source_capabilities.py | 28 ++ .../instruments/rf_source_extensions.py | 219 +++++++++++ src/wavebench/services/operation_specs.py | 17 + src/wavebench/services/rf_source_service.py | 212 +++++++++++ tests/test_operation_specs.py | 23 ++ tests/test_rf_source_extensions.py | 4 + tests/test_rf_source_pulse_extensions.py | 254 +++++++++++++ tests/test_rf_source_pulse_service.py | 347 ++++++++++++++++++ 8 files changed, 1104 insertions(+) create mode 100644 tests/test_rf_source_pulse_extensions.py create mode 100644 tests/test_rf_source_pulse_service.py diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index d23bfeb..61eca0c 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -20,6 +20,7 @@ RfFeatureDirection, RfModulationProfile, RfOutputProfile, + RfPulseProfile, RfSourceDescriptorExtensions, ) @@ -38,6 +39,10 @@ "get_rf_modulation_state", "disable_rf_modulation", ), + "rf_source.pulse_configure": ( + "get_rf_pulse_snapshot", + "configure_rf_pulse", + ), "rf_source.output": ("set_rf_output",), } ) @@ -80,6 +85,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No _validate_modulation_configure_feature(extensions) if "rf_source.modulation_disable" in rf_capabilities: _validate_modulation_disable_feature(extensions) + if "rf_source.pulse_configure" in rf_capabilities: + _validate_pulse_configure_feature(extensions) if "rf_source.output" in rf_capabilities: _validate_output_feature(extensions) _validate_rf_source_version_range(descriptor) @@ -172,6 +179,27 @@ def _validate_modulation_disable_feature(extensions: RfSourceDescriptorExtension raise ConfigError("rf_source.modulation_disable requires readable RF modulation state") +def _validate_pulse_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.pulse_configure requires an RF pulse feature with configure and read directions" + ) + if not isinstance(feature.profile, RfPulseProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.pulse_configure requires an RF pulse profile") + if not feature.profile.configuration_readable or not feature.profile.mode_profiles: + raise ConfigError( + "rf_source.pulse_configure requires readable bounded RF pulse mode profiles" + ) + + def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index cf3216f..0dbdd36 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -23,6 +23,7 @@ RF_SOURCE_SNAPSHOT_SCHEMA = "wavebench.rf_source.snapshot.v1" RF_SOURCE_MODULATION_STATE_SCHEMA = "wavebench.rf_source.modulation_state.v1" RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" +RF_SOURCE_PULSE_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" @@ -162,6 +163,21 @@ class RfPulseState(StrEnum): ENABLED = "enabled" +class RfPulseSource(StrEnum): + INTERNAL = "internal" + EXTERNAL = "external" + + +class RfPulseMode(StrEnum): + SINGLE = "single" + TRAIN = "train" + + +class RfPulsePolarity(StrEnum): + NORMAL = "normal" + INVERTED = "inverted" + + class RfSweepState(StrEnum): DISABLED = "disabled" ENABLED = "enabled" @@ -432,9 +448,67 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class RfPulseProfile: state_readable: bool + configuration_readable: bool = False + mode_profiles: tuple["RfPulseModeProfile", ...] = () def __post_init__(self) -> None: _require_bool(self.state_readable, "RF pulse state_readable") + _require_bool(self.configuration_readable, "RF pulse configuration_readable") + if not isinstance(self.mode_profiles, tuple) or any( + not isinstance(profile, RfPulseModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF pulse mode_profiles have an invalid type") + identities = tuple((profile.source.value, profile.mode.value) for profile in self.mode_profiles) + if len(set(identities)) != len(identities) or tuple(sorted(identities)) != identities: + raise ValueError("RF pulse mode_profiles must be sorted and unique") + if self.configuration_readable and not self.state_readable: + raise ValueError("RF pulse configuration readback requires readable state") + + +@dataclass(frozen=True, slots=True) +class RfPulseModeProfile: + """One bounded pulse profile that can be configured while RF remains OFF.""" + + source: RfPulseSource + mode: RfPulseMode + polarities: tuple[RfPulsePolarity, ...] + period_min_s: float + period_max_s: float + width_min_s: float + width_max_s: float + minimum_off_time_s: float + + def __post_init__(self) -> None: + if not isinstance(self.source, RfPulseSource): + raise ValueError("RF pulse mode source has an invalid type") + if not isinstance(self.mode, RfPulseMode): + raise ValueError("RF pulse mode has an invalid type") + if self.source is not RfPulseSource.INTERNAL: + raise ValueError("RF pulse mode profiles must use the internal source") + if self.mode is not RfPulseMode.SINGLE: + raise ValueError("RF pulse mode profiles must use the single mode") + _require_enum_tuple(self.polarities, RfPulsePolarity, "RF pulse mode polarities") + _require_finite(self.period_min_s, "RF pulse mode period_min_s", minimum=0.0) + _require_finite( + self.period_max_s, + "RF pulse mode period_max_s", + minimum=self.period_min_s, + ) + _require_finite(self.width_min_s, "RF pulse mode width_min_s", minimum=0.0) + _require_finite( + self.width_max_s, + "RF pulse mode width_max_s", + minimum=self.width_min_s, + ) + _require_finite( + self.minimum_off_time_s, + "RF pulse mode minimum_off_time_s", + minimum=0.0, + ) + if self.minimum_off_time_s <= 0.0: + raise ValueError("RF pulse mode minimum_off_time_s must be positive") + if self.period_max_s < self.width_min_s + self.minimum_off_time_s: + raise ValueError("RF pulse mode ranges cannot satisfy the minimum off time") @dataclass(frozen=True, slots=True) @@ -809,6 +883,89 @@ def value_unit(self) -> RfModulationValueUnit: }[self.kind] +@dataclass(frozen=True, slots=True) +class RfPulseConfigureRequest: + """One RF-OFF internal single-pulse configuration for one RF port. + + The descriptor supplies the fixed source and mode; this request only + carries bounded timing and polarity fields. It deliberately has no trigger, + external-port, or pulse-output field. + """ + + port_id: str + period_s: float + width_s: float + polarity: RfPulsePolarity + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse configure request port_id") + _require_finite(self.period_s, "RF pulse configure request period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse configure request width_s", minimum=0.0) + if self.period_s <= 0.0: + raise ValueError("RF pulse configure request period_s must be positive") + if self.width_s <= 0.0: + raise ValueError("RF pulse configure request width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse configure request width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse configure request polarity has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfPulseConfigureResult: + """A bounded RF pulse configuration confirmed by independent readback.""" + + port_id: str + period_s: float + width_s: float + polarity: RfPulsePolarity + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse configure result port_id") + _require_finite(self.period_s, "RF pulse configure result period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse configure result width_s", minimum=0.0) + if self.period_s <= 0.0: + raise ValueError("RF pulse configure result period_s must be positive") + if self.width_s <= 0.0: + raise ValueError("RF pulse configure result width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse configure result width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse configure result polarity has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfPulseSnapshot: + """Complete typed readback for one pulse profile on one RF port.""" + + port_id: str + source: RfPulseSource + mode: RfPulseMode + period_s: float + width_s: float + polarity: RfPulsePolarity + state: RfPulseState + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse snapshot port_id") + if not isinstance(self.source, RfPulseSource): + raise ValueError("RF pulse snapshot source has an invalid type") + if not isinstance(self.mode, RfPulseMode): + raise ValueError("RF pulse snapshot mode has an invalid type") + _require_finite(self.period_s, "RF pulse snapshot period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse snapshot width_s", minimum=0.0) + if self.period_s <= 0.0: + raise ValueError("RF pulse snapshot period_s must be positive") + if self.width_s <= 0.0: + raise ValueError("RF pulse snapshot width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse snapshot width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse snapshot polarity has an invalid type") + if not isinstance(self.state, RfPulseState): + raise ValueError("RF pulse snapshot state has an invalid type") + + @dataclass(frozen=True, slots=True) class RfOutputRequest: """One explicit RF output state request for one descriptor-defined port.""" @@ -853,6 +1010,10 @@ def configure_rf_modulation(self, request: RfModulationRequest) -> None: ... def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: ... + def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: ... + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: ... + def set_rf_output(self, request: RfOutputRequest) -> None: ... @@ -946,6 +1107,16 @@ def rf_modulation_state_snapshot_document( return {"schema": RF_SOURCE_MODULATION_STATE_SCHEMA, **data} +def rf_pulse_snapshot_document(snapshot: RfPulseSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF pulse readback.""" + + if not isinstance(snapshot, RfPulseSnapshot): + raise TypeError("snapshot must be RfPulseSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_PULSE_SNAPSHOT_SCHEMA, **data} + + def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: """Build a read-only snapshot artifact without transport-private values.""" @@ -1076,6 +1247,44 @@ def rf_source_modulation_disable_operation_artifact( } +def rf_source_pulse_operation_artifact( + request: RfPulseConfigureRequest, + result: RfPulseConfigureResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_pulse_snapshot: RfPulseSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one RF-OFF pulse configuration.""" + + if not isinstance(request, RfPulseConfigureRequest): + raise TypeError("request must be RfPulseConfigureRequest") + if not isinstance(result, RfPulseConfigureResult): + raise TypeError("result must be RfPulseConfigureResult") + if ( + request.port_id != result.port_id + or request.period_s != result.period_s + or request.width_s != result.width_s + or request.polarity is not result.polarity + ): + raise ValueError("RF pulse request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_pulse_snapshot, RfPulseSnapshot): + raise TypeError("postcondition_pulse_snapshot must be RfPulseSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.pulse_configure", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_pulse_snapshot": rf_pulse_snapshot_document(postcondition_pulse_snapshot), + } + + def rf_source_output_operation_artifact( request: RfOutputRequest, result: RfOutputResult, @@ -1112,6 +1321,7 @@ def rf_source_output_operation_artifact( "RF_SOURCE_MODULATION_STATE_SCHEMA", "RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", + "RF_SOURCE_PULSE_SNAPSHOT_SCHEMA", "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", "RF_SOURCE_SNAPSHOT_SCHEMA", "RfAvailability", @@ -1144,6 +1354,13 @@ def rf_source_output_operation_artifact( "RfProtectionConditionPolicy", "RfProtectionStatus", "RfPulseProfile", + "RfPulseConfigureRequest", + "RfPulseConfigureResult", + "RfPulseMode", + "RfPulseModeProfile", + "RfPulsePolarity", + "RfPulseSnapshot", + "RfPulseSource", "RfPulseState", "RfReasonCode", "RfSourceDescriptorExtensions", @@ -1157,8 +1374,10 @@ def rf_source_output_operation_artifact( "rf_source_digest", "rf_modulation_snapshot_document", "rf_modulation_state_snapshot_document", + "rf_pulse_snapshot_document", "rf_source_modulation_disable_operation_artifact", "rf_source_modulation_operation_artifact", + "rf_source_pulse_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", "rf_source_output_operation_artifact", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 04c2c91..196a0c9 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1092,6 +1092,23 @@ def _spec( ), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.pulse_configure", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.pulse_configure"), + effect="write", + changed_fields=( + "rf_source.pulse.source", + "rf_source.pulse.mode", + "rf_source.pulse.period_s", + "rf_source.pulse.width_s", + "rf_source.pulse.polarity", + "rf_source.pulse.state", + ), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "pulse_state", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec( "rf_source.output_enable", "rf_source", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 2bd95da..aabc960 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -38,6 +38,13 @@ RfOutputResult, RfPortSnapshot, RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulseMode, + RfPulseModeProfile, + RfPulseProfile, + RfPulseSnapshot, + RfPulseSource, RfPulseState, RfSourceDescriptorExtensions, RfSourceDriver, @@ -47,6 +54,7 @@ rf_source_modulation_disable_operation_artifact, rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, + rf_source_pulse_operation_artifact, ) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy @@ -87,6 +95,14 @@ class _RfModulationDisableTransaction: postcondition_modulation_state: RfModulationStateSnapshot +@dataclass(frozen=True) +class _RfPulseTransaction: + result: RfPulseConfigureResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_pulse_snapshot: RfPulseSnapshot + + @dataclass(frozen=True) class _RfOutputTransaction: result: RfOutputResult @@ -454,6 +470,85 @@ def _disable_modulation_transaction( ) raise + def configure_pulse(self, request: RfPulseConfigureRequest) -> RfPulseConfigureResult: + return self._configure_pulse_transaction(request).result + + def configure_pulse_with_artifact( + self, + request: RfPulseConfigureRequest, + ) -> tuple[RfPulseConfigureResult, dict[str, object]]: + """Apply one RF-OFF internal single-pulse profile with typed evidence.""" + + transaction = self._configure_pulse_transaction(request) + return ( + transaction.result, + rf_source_pulse_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_pulse_snapshot=transaction.postcondition_pulse_snapshot, + ), + ) + + def _configure_pulse_transaction( + self, + request: RfPulseConfigureRequest, + ) -> _RfPulseTransaction: + """Configure one disabled internal single-pulse profile without triggering. + + The initial M4 slice intentionally cannot arm or trigger a pulse. It + configures timing and polarity while the target RF output, pulse state, + modulation, and Sweep are all OFF, then requires the pulse to remain + disabled after independent profile readback. A failed main write or + postcondition is never retried and leaves the session uncertain. + """ + + if not isinstance(request, RfPulseConfigureRequest): + raise ConfigError("rf_source pulse configuration requires RfPulseConfigureRequest") + operation = "rf_source.pulse_configure" + self._require(operation, "rf_source.snapshot", "rf_source.pulse_configure") + mode_profile = self._validate_pulse_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + self._validate_pulse_preflight( + request, + preflight_snapshot, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_rf_pulse(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_pulse_snapshot = rf_source.get_rf_pulse_snapshot(request.port_id) + result = self._validate_pulse_postcondition( + request, + postcondition_snapshot, + postcondition_pulse_snapshot, + mode_profile, + operation=operation, + ) + return _RfPulseTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_pulse_snapshot=postcondition_pulse_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_pulse_postcondition_unverified", + ) + raise + def set_output(self, request: RfOutputRequest) -> RfOutputResult: return self._set_output_transaction(request).result @@ -817,6 +912,123 @@ def _validate_cw_descriptor( raise ConfigError(f"{operation} request power_dbm is outside the descriptor range") return port_profile, profile + def _validate_pulse_descriptor( + self, + request: RfPulseConfigureRequest, + operation: str, + ) -> RfPulseModeProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfPulseProfile) + or not feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable pulse profile for the target port" + ) + mode_profile = next( + ( + item + for item in feature.profile.mode_profiles + if item.source is RfPulseSource.INTERNAL and item.mode is RfPulseMode.SINGLE + ), + None, + ) + if mode_profile is None: + raise ConfigError(f"{operation} requires an internal single-pulse profile") + if request.polarity not in mode_profile.polarities: + raise ConfigError(f"{operation} request polarity is outside the descriptor profile") + if not mode_profile.period_min_s <= request.period_s <= mode_profile.period_max_s: + raise ConfigError(f"{operation} request period_s is outside the descriptor range") + if not mode_profile.width_min_s <= request.width_s <= mode_profile.width_max_s: + raise ConfigError(f"{operation} request width_s is outside the descriptor range") + if request.width_s > request.period_s - mode_profile.minimum_off_time_s: + raise ConfigError(f"{operation} request width_s violates the descriptor minimum off time") + return mode_profile + + def _validate_pulse_preflight( + self, + request: RfPulseConfigureRequest, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> RfPortSnapshot: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return port + + def _validate_pulse_postcondition( + self, + request: RfPulseConfigureRequest, + snapshot: RfSourceSnapshot, + pulse_snapshot: RfPulseSnapshot, + mode_profile: RfPulseModeProfile, + *, + operation: str, + ) -> RfPulseConfigureResult: + self._validate_pulse_preflight(request, snapshot, operation=operation) + if pulse_snapshot.port_id != request.port_id: + raise ConfigError(f"{operation} pulse snapshot does not match the requested port") + if pulse_snapshot.source is not mode_profile.source or pulse_snapshot.mode is not mode_profile.mode: + raise ConfigError(f"{operation} postcondition requires the declared internal single-pulse profile") + if pulse_snapshot.state is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} postcondition requires Pulse disabled") + if ( + pulse_snapshot.period_s != request.period_s + or pulse_snapshot.width_s != request.width_s + or pulse_snapshot.polarity is not request.polarity + ): + raise ConfigError(f"{operation} pulse readback does not match request") + return RfPulseConfigureResult( + port_id=request.port_id, + period_s=request.period_s, + width_s=request.width_s, + polarity=request.polarity, + ) + def _validate_modulation_descriptor( self, request: RfModulationRequest, diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index 65a66f3..d462349 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -114,6 +114,29 @@ def test_rf_source_modulation_disable_spec_requires_state_evidence_and_keeps_rf_ assert disable.safe_alternatives == ("rf_source.snapshot",) +def test_rf_source_pulse_configure_spec_keeps_rf_and_pulse_off() -> None: + configure = require_operation_spec("rf_source.pulse_configure") + + assert configure.instrument_kind == "rf_source" + assert configure.required_capabilities == ( + "rf_source.snapshot", + "rf_source.pulse_configure", + ) + assert configure.effect == "write" + assert configure.changed_fields == ( + "rf_source.pulse.source", + "rf_source.pulse.mode", + "rf_source.pulse.period_s", + "rf_source.pulse.width_s", + "rf_source.pulse.polarity", + "rf_source.pulse.state", + ) + assert configure.restore_coverage == "none" + assert "rf_output_must_be_off" in configure.risk_flags + assert "pulse_state" in configure.risk_flags + assert configure.safe_alternatives == ("rf_source.snapshot",) + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 8a490ad..5109c14 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -347,6 +347,10 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "get_rf_modulation_state", "disable_rf_modulation", ), + "rf_source.pulse_configure": ( + "get_rf_pulse_snapshot", + "configure_rf_pulse", + ), "rf_source.output": ("set_rf_output",), } assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( diff --git a/tests/test_rf_source_pulse_extensions.py b/tests/test_rf_source_pulse_extensions.py new file mode 100644 index 0000000..9ccbacc --- /dev/null +++ b/tests/test_rf_source_pulse_extensions.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_PULSE_SNAPSHOT_SCHEMA, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulseMode, + RfPulseModeProfile, + RfPulsePolarity, + RfPulseProfile, + RfPulseSnapshot, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_pulse_snapshot_document, + rf_source_pulse_operation_artifact, +) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _pulse_mode() -> RfPulseModeProfile: + return RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_pulse_mode(),), + ) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _pulse_snapshot() -> RfPulseSnapshot: + return RfPulseSnapshot( + port_id="rf_out", + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + state=RfPulseState.DISABLED, + ) + + +def _descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse", + kind="rf_source", + models=("RF-PULSE",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-PULSE,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot() + + def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: + assert port_id == "rf_out" + return _pulse_snapshot() + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: + assert request.port_id == "rf_out" + + +def test_pulse_contract_rejects_unsafe_profiles_and_requests() -> None: + with pytest.raises(ValueError, match="internal source"): + RfPulseModeProfile( + source=RfPulseSource.EXTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.NORMAL,), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=1.0, + minimum_off_time_s=10e-9, + ) + with pytest.raises(ValueError, match="single mode"): + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.TRAIN, + polarities=(RfPulsePolarity.NORMAL,), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=1.0, + minimum_off_time_s=10e-9, + ) + with pytest.raises(ValueError, match="sorted by value"): + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.NORMAL, RfPulsePolarity.INVERTED), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=1.0, + minimum_off_time_s=10e-9, + ) + with pytest.raises(ValueError, match="less than period"): + RfPulseConfigureRequest( + port_id="rf_out", + period_s=1e-3, + width_s=1e-3, + polarity=RfPulsePolarity.NORMAL, + ) + with pytest.raises(ValueError, match="finite"): + RfPulseConfigureRequest( + port_id="rf_out", + period_s=float("nan"), + width_s=1e-6, + polarity=RfPulsePolarity.NORMAL, + ) + + +def test_pulse_descriptor_requires_readable_bounded_profile_and_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.pulse_configure"] == ( + "get_rf_pulse_snapshot", + "configure_rf_pulse", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE,), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + ), + ) + with pytest.raises(Exception, match="configure and read"): + validate_rf_source_descriptor(invalid) + + +def test_pulse_snapshot_document_and_artifact_keep_typed_safe_evidence() -> None: + request = RfPulseConfigureRequest( + port_id="rf_out", + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + result = RfPulseConfigureResult( + port_id="rf_out", + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + pulse_snapshot = _pulse_snapshot() + + document = rf_pulse_snapshot_document(pulse_snapshot) + artifact = rf_source_pulse_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(), + postcondition_snapshot=_snapshot(), + postcondition_pulse_snapshot=pulse_snapshot, + ) + + assert document["schema"] == RF_SOURCE_PULSE_SNAPSHOT_SCHEMA + assert document["source"] == "internal" + assert document["state"] == "disabled" + assert artifact["operation"] == "rf_source.pulse_configure" + assert artifact["request"]["period_s"] == 1e-3 + assert artifact["postcondition_pulse_snapshot"]["width_s"] == 100e-6 + assert "resource" not in str(artifact) diff --git a/tests/test_rf_source_pulse_service.py b/tests/test_rf_source_pulse_service.py new file mode 100644 index 0000000..f326163 --- /dev/null +++ b/tests/test_rf_source_pulse_service.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseMode, + RfPulseModeProfile, + RfPulsePolarity, + RfPulseProfile, + RfPulseSnapshot, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf.pulse", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ), + ), + ) + + +def _descriptor(*capabilities: str, profile: RfPulseProfile | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=profile or _pulse_profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _pulse_snapshot( + *, + period_s: float = 1e-3, + width_s: float = 100e-6, + polarity: RfPulsePolarity = RfPulsePolarity.NORMAL, + source: RfPulseSource = RfPulseSource.INTERNAL, + mode: RfPulseMode = RfPulseMode.SINGLE, + state: RfPulseState = RfPulseState.DISABLED, +) -> RfPulseSnapshot: + return RfPulseSnapshot( + port_id="rf_out", + source=source, + mode=mode, + period_s=period_s, + width_s=width_s, + polarity=polarity, + state=state, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + pulse_snapshots: list[RfPulseSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.pulse_snapshots = list(pulse_snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfPulseConfigureRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: + self.calls.append("pulse_snapshot") + assert port_id == "rf_out" + if not self.pulse_snapshots: + raise AssertionError("unexpected pulse snapshot") + return self.pulse_snapshots.pop(0) + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: + self.calls.append("configure_pulse") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake pulse write failed after transmission") + + +def _request(*, width_s: float = 100e-6) -> RfPulseConfigureRequest: + return RfPulseConfigureRequest( + port_id="rf_out", + period_s=1e-3, + width_s=width_s, + polarity=RfPulsePolarity.NORMAL, + ) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + pulse_snapshots: list[RfPulseSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + pulse_snapshots, + raise_after_write=raise_after_write, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_pulse_configuration_uses_one_write_and_independent_readbacks() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_pulse_snapshot()], + ) + + result, artifact = service.configure_pulse_with_artifact(request) + + assert result.period_s == request.period_s + assert result.width_s == request.width_s + assert result.polarity is request.polarity + assert driver.requests == [request] + assert driver.calls == ["snapshot", "configure_pulse", "snapshot", "pulse_snapshot"] + assert artifact["operation"] == "rf_source.pulse_configure" + assert artifact["postcondition_pulse_snapshot"]["state"] == "disabled" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), "target RF output OFF"), + (_rf_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_rf_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_pulse_configuration_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _service([snapshot], []) + + with pytest.raises(ConfigError, match=message): + service.configure_pulse(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_pulse_configuration_checks_capability_access_and_static_profile_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.pulse_configure"): + missing.configure_pulse(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.pulse_configure"): + read_only.configure_pulse(_request()) + assert read_only_driver.calls == [] + + range_service, range_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_pulse(_request(width_s=1e-9)) + assert range_driver.calls == [] + + off_time_service, off_time_driver = _service([], []) + with pytest.raises(ConfigError, match="minimum off time"): + off_time_service.configure_pulse( + RfPulseConfigureRequest( + port_id="rf_out", + period_s=40e-9, + width_s=35e-9, + polarity=RfPulsePolarity.NORMAL, + ) + ) + assert off_time_driver.calls == [] + + +@pytest.mark.parametrize( + "pulse_snapshot", + ( + _pulse_snapshot(width_s=99e-6), + _pulse_snapshot(source=RfPulseSource.EXTERNAL), + _pulse_snapshot(mode=RfPulseMode.TRAIN), + _pulse_snapshot(state=RfPulseState.ENABLED), + ), +) +def test_pulse_configuration_mismatch_is_not_retried_and_degrades_session( + pulse_snapshot: RfPulseSnapshot, +) -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [pulse_snapshot], + ) + + with pytest.raises(ConfigError): + service.configure_pulse(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_pulse", "snapshot", "pulse_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_pulse_configuration_write_failure_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot()], + [], + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission"): + service.configure_pulse(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_pulse"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + From 82102999d050db488e0c30f51703c3a104c84004 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:04:15 +0800 Subject: [PATCH 33/63] feat: expose safe RF pulse configuration controls --- src/wavebench/cli.py | 16 ++ src/wavebench/cli_parser.py | 22 +++ src/wavebench/services/execution_intent.py | 1 + src/wavebench/services/run_plan.py | 16 ++ src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 18 +++ tests/test_rf_source_cli.py | 73 ++++++++- tests/test_rf_source_run.py | 163 +++++++++++++++++++++ 8 files changed, 309 insertions(+), 1 deletion(-) diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 2b0e96f..bea7345 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -92,6 +92,8 @@ RfModulationKind, RfModulationRequest, RfOutputRequest, + RfPulseConfigureRequest, + RfPulsePolarity, ) from .mcp_http import ( resolve_mcp_token, @@ -1587,6 +1589,20 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "pulse": + result = service.configure_pulse( + RfPulseConfigureRequest( + port_id=args.port, + period_s=args.period_s, + width_s=args.width_s, + polarity=RfPulsePolarity(args.polarity), + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.command == "output": result = service.set_output( RfOutputRequest(port_id=args.port, enabled=args.state == "on") diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 6ac0e9e..2111664 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -713,6 +713,28 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(rf_source_modulation_configure) + rf_source_pulse = rf_source_sub.add_parser( + "pulse", + help="Configure a bounded internal single-pulse profile while RF output is OFF", + ) + rf_source_pulse_sub = rf_source_pulse.add_subparsers( + dest="pulse_command", + required=True, + ) + rf_source_pulse_configure = rf_source_pulse_sub.add_parser( + "configure", + help="Configure a disabled internal single-pulse profile without triggering", + ) + rf_source_pulse_configure.add_argument("--port", required=True) + rf_source_pulse_configure.add_argument("--period-s", type=float, required=True) + rf_source_pulse_configure.add_argument("--width-s", type=float, required=True) + rf_source_pulse_configure.add_argument( + "--polarity", + choices=["normal", "inverted"], + required=True, + ) + add_runtime_options(rf_source_pulse_configure) + source_sub = source_parser.add_subparsers(dest="command", required=True) source_idn = source_sub.add_parser("idn", help="Query source *IDN?") diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 79a931d..a3e0a1b 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -27,6 +27,7 @@ "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.pulse_configure": "rf_source.pulse_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", "source.arb_load": "source.arbitrary_upload", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 492a8fe..53a6f73 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -25,6 +25,7 @@ "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", + "rf_source.pulse_configure", "rf_source.output_enable", "rf_source.output_disable", "source.set_freq", @@ -77,6 +78,7 @@ "modulation_kind", "internal_frequency_hz", ), + "rf_source.pulse_configure": ("port_id", "period_s", "width_s", "polarity"), "rf_source.output_enable": ("port_id",), "rf_source.output_disable": ("port_id",), "source.basic_configure_v2": ("channel",), @@ -194,6 +196,7 @@ "phase_deviation_rad", "on_failure", }, + "rf_source.pulse_configure": set(), "rf_source.output_enable": {"on_failure"}, "rf_source.output_disable": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, @@ -258,6 +261,7 @@ "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", + "rf_source.pulse_configure": "Configure one OFF RF port with a disabled internal single-pulse profile; it does not enable RF output or trigger a pulse.", "rf_source.output_enable": "Enable one RF port only after a fresh safety snapshot confirms the configured load, frequency, power, and inactive modulation, Pulse, Sweep, and blocking protection conditions.", "rf_source.output_disable": "Disable one RF port and confirm OFF without requiring frequency, power, or protection readback.", "source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.", @@ -691,6 +695,18 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["internal_frequency_hz"], f"{prefix}.internal_frequency_hz", ) + elif kind == "rf_source.pulse_configure": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + period_s = _positive_float(fields["period_s"], f"{prefix}.period_s") + width_s = _positive_float(fields["width_s"], f"{prefix}.width_s") + if width_s >= period_s: + raise ConfigError(f"{prefix}.width_s must be less than period_s") + polarity = _non_empty_str(fields["polarity"], f"{prefix}.polarity").lower() + if polarity not in {"normal", "inverted"}: + raise ConfigError(f"{prefix}.polarity must be one of normal, inverted") + fields["period_s"] = period_s + fields["width_s"] = width_s + fields["polarity"] = polarity elif kind in {"rf_source.output_enable", "rf_source.output_disable"}: fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") elif kind == "source.arb_load": diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 1c8a11f..22b294d 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -25,6 +25,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", + "rf_source.pulse_configure", "rf_source.output_enable", "rf_source.output_disable", "source.set_freq", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 7ed34ed..2d59100 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -27,6 +27,8 @@ RfModulationKind, RfModulationRequest, RfOutputRequest, + RfPulseConfigureRequest, + RfPulsePolarity, rf_source_snapshot_operation_artifact, ) from wavebench.instruments.source_extensions import ( @@ -304,6 +306,7 @@ def _check_rf_source_access(self, plan: RunPlan) -> None: "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.pulse_configure": "rf_source.pulse_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", } @@ -453,6 +456,8 @@ def add_source_output_gate_capability() -> None: add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") elif step.kind == "rf_source.modulation_configure": add("rf_source", "rf_source.snapshot", "rf_source.modulation_configure") + elif step.kind == "rf_source.pulse_configure": + add("rf_source", "rf_source.snapshot", "rf_source.pulse_configure") elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: add("rf_source", "rf_source.snapshot", "rf_source.output") elif step.kind == "source.set_freq": @@ -1257,6 +1262,19 @@ def _run_step( services=services ).configure_modulation_with_artifact(request) artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.pulse_configure": + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_pulse_with_artifact( + RfPulseConfigureRequest( + port_id=fields["port_id"], + period_s=fields["period_s"], + width_s=fields["width_s"], + polarity=RfPulsePolarity(fields["polarity"]), + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: _, rf_source_operation = self._rf_source_service( services=services diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index 186fe17..2e4b971 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -15,10 +15,13 @@ RfModulationResult, RfOutputRequest, RfOutputResult, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulsePolarity, ) -def test_rf_source_parser_accepts_read_only_cw_and_output_commands() -> None: +def test_rf_source_parser_accepts_cw_modulation_pulse_and_output_commands() -> None: identity = build_parser().parse_args( ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] ) @@ -71,6 +74,21 @@ def test_rf_source_parser_accepts_read_only_cw_and_output_commands() -> None: "1000", ] ) + pulse = build_parser().parse_args( + [ + "rf-source", + "pulse", + "configure", + "--port", + "rf_out", + "--period-s", + "0.001", + "--width-s", + "0.0001", + "--polarity", + "inverted", + ] + ) assert (identity.domain, identity.command) == ("rf-source", "idn") assert identity.config == "rf.toml" @@ -91,6 +109,14 @@ def test_rf_source_parser_accepts_read_only_cw_and_output_commands() -> None: assert modulation_fm.frequency_deviation_hz == 10_000.0 assert modulation_pm.modulation_command == "configure-pm" assert modulation_pm.phase_deviation_rad == 1.5 + assert (pulse.domain, pulse.command, pulse.pulse_command) == ( + "rf-source", + "pulse", + "configure", + ) + assert pulse.period_s == 0.001 + assert pulse.width_s == 0.0001 + assert pulse.polarity == "inverted" def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: @@ -282,6 +308,51 @@ def test_rf_source_cli_dispatches_each_internal_sine_modulation_request() -> Non ] +def test_rf_source_cli_dispatches_disabled_internal_single_pulse_request() -> None: + service = Mock() + service.configure_pulse.return_value = RfPulseConfigureResult( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert main( + [ + "--json", + "rf-source", + "pulse", + "configure", + "--port", + "rf_out", + "--period-s", + "0.001", + "--width-s", + "0.0001", + "--polarity", + "inverted", + ] + ) == 0 + + payload = json.loads(stdout.getvalue()) + assert payload["result"] == { + "port_id": "rf_out", + "period_s": 0.001, + "width_s": 0.0001, + "polarity": "inverted", + } + service.configure_pulse.assert_called_once_with( + RfPulseConfigureRequest( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + ) + + def test_rf_source_resource_override_does_not_touch_source_config() -> None: updated = object() config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index 7cf32dd..c7d762e 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -33,11 +33,18 @@ RfObserved, RfPortSnapshot, RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulseMode, + RfPulsePolarity, + RfPulseSnapshot, + RfPulseSource, RfPulseState, RfSourceSnapshot, RfSweepState, rf_source_cw_operation_artifact, rf_source_modulation_operation_artifact, + rf_source_pulse_operation_artifact, RfOutputRequest, RfOutputResult, rf_source_output_operation_artifact, @@ -118,6 +125,20 @@ def _modulation_plan( return load_run_plan(path) +def _pulse_plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_configure"\n' + 'port_id = "rf_out"\n' + "period_s = 0.001\n" + "width_s = 0.0001\n" + 'polarity = "inverted"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + def _snapshot() -> RfSourceSnapshot: return RfSourceSnapshot( ports=( @@ -521,6 +542,148 @@ def _run_safety_guards(self, run_plan, *, services=None): assert run_data["rf_source_operations"] == [artifact] +def test_rf_source_pulse_plan_normalizes_the_disabled_internal_single_subset() -> None: + with TemporaryDirectory() as directory: + plan = _pulse_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "period_s": 0.001, + "width_s": 0.0001, + "polarity": "inverted", + } + + invalid_width_path = Path(directory) / "invalid-width.toml" + invalid_width_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_configure"\n' + 'port_id = "rf_out"\n' + "period_s = 0.001\n" + "width_s = 0.001\n" + 'polarity = "normal"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="width_s must be less than period_s"): + load_run_plan(invalid_width_path) + + invalid_polarity_path = Path(directory) / "invalid-polarity.toml" + invalid_polarity_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_configure"\n' + 'port_id = "rf_out"\n' + "period_s = 0.001\n" + "width_s = 0.0001\n" + 'polarity = "external"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="polarity must be one of normal, inverted"): + load_run_plan(invalid_polarity_path) + + +def test_rf_source_pulse_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.pulse_configure"): + service.run(_pulse_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_pulse_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_pulse_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_pulse_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _pulse_plan(directory) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.pulse_configure" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "period_s": 0.001, + "width_s": 0.0001, + "polarity": "inverted", + } + + request = RfPulseConfigureRequest( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + result_value = RfPulseConfigureResult( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + preflight_snapshot = _snapshot() + postcondition_snapshot = _snapshot() + postcondition_pulse_snapshot = RfPulseSnapshot( + port_id="rf_out", + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + state=RfPulseState.DISABLED, + ) + artifact = rf_source_pulse_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_pulse_snapshot=postcondition_pulse_snapshot, + ) + rf_service = SimpleNamespace( + configure_pulse_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_pulse_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + def test_rf_source_output_step_requires_capability_before_opening_a_session() -> None: with TemporaryDirectory() as directory: service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) From acb201c67aba24b441d9d9718eb4b845e6301f5b Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:22:58 +0800 Subject: [PATCH 34/63] docs: describe controlled RF pulse validation boundary --- ...21\351\207\214\347\250\213\347\242\221.md" | 17 +++++++---- ...67\346\272\220\350\256\276\350\256\241.md" | 30 +++++++++++-------- ...77\347\224\250\346\214\207\345\215\227.md" | 18 +++++++++-- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index df52ed2..e15bb58 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M3 合同完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出,以及内部正弦 AM/FM/PM 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 映射以及内部正弦 AM/FM/PM 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | -| 真实仪器证据 | A1、A2、A3 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | +| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 离线完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | +| 真实仪器证据 | A1、A2、A3 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A4 Pulse 工具待实机执行;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | ## 双仓库交付规则 @@ -31,7 +31,8 @@ | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | -| M4 | 未开始 | Pulse/Step Sweep 合同 | 已声明子集、arm/fire/stop 映射 | trigger/fire 只能由专项安全规则与实机证据提升。 | +| M4(Pulse) | 离线完成;A4 Pulse 待实机执行 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;production capability 等待证据。 | +| M4(Step Sweep) | 未开始 | frequency-only Step Sweep 合同 | 待实现 | trigger/fire 只能由专项安全规则与实机证据提升。 | ## Seed:历史种子包 @@ -102,9 +103,13 @@ AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限 ## M4:Pulse 与 Step Sweep -Core 冻结 Pulse/Sweep profile、`arm`/`trigger`/`fire`/`stop` 的 operation 映射和安全规则。`RfPortSnapshot` 中的 Pulse、Sweep 状态必须可区分,不能将外部 trigger、后面板辅助输出或设备私有模式默认为安全。 +M4 当前先完成 Pulse,再处理 frequency-only Step Sweep。`RfPortSnapshot` 中的 Pulse、Sweep 状态必须可区分,不能将外部 trigger、后面板辅助输出或设备私有模式默认为安全。 -DSG830 只进入手册与离线测试均覆盖的 Pulse/frequency-only Step Sweep 子集。fake descriptor 可以覆盖 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 +Pulse 只覆盖 `rf_out` 的 internal/single 子集。request 只包含 period、width 和 polarity;Core 在写入前要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读回 internal/single、全部请求字段和 Pulse 关闭状态。driver 的固定 sequence 以 `:PULM:STAT OFF` 收尾。它不控制 `:PULM:OUT`、RF 输出或任何 trigger,失败时不重试、不追加恢复 setter。 + +源码 checkout 的 `tools/a4_pulse_evidence.py` 与资源无关的 setup 模板为实机证据提供受控入口。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确的 production descriptor 和 50 Ω 端接声明;显式 `--execute` 才在内存中建立临时 `read_write` descriptor。成功路径固定为初始 snapshot、一次 Pulse 配置、独立配置读回和最终 snapshot,预期 38 次 query、6 次配置 write,并确认 RF 与 Pulse 仍关闭。`--diagnose` 保持 `read_only`,固定 22 次 query、零 write。两种模式都不读取 scope、不使用 CH1/CH2、不发送 trigger,证据以 `0600` 保存且不包含资源或原始响应。 + +Step Sweep、Pulse trigger、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 ## A1–A5:实机证据门 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index b1934ef..720112b 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出与 M3 内部正弦调制合同;DSG830 已凭 A1/A2/A3 证据开放 snapshot、OFF-only CW 与受 safety 限制的 output。M3 仍是离线合同,A4–A5 的实机证据门没有被替代。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 配置的合同与控制入口;DSG830 已凭 A1/A2/A3 证据开放 snapshot、OFF-only CW 与受 safety 限制的 output。M3 与 M4 Pulse 仍须经过各自的实机证据门,不能由离线代码替代。 阅读顺序如下: @@ -15,13 +15,13 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM,以及仅用于受控恢复的调制关闭事务。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP` 和内部正弦 AM/FM/PM 映射;A1/A2/A3 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | -| 实机证据 | A1、A2、A3 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A5 未开始。 | M3 capability 覆盖三种模式,DSG830 production descriptor 继续只开放 snapshot、OFF-only CW 和端口级 output。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 配置映射;A1/A2/A3 证据已经完成。 | production descriptor 只声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | +| 实机证据 | A1、A2、A3 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A4 Pulse 工具已完成离线验证,实机证据待执行;A5 未开始。 | M3 capability 覆盖三种模式;M4 Pulse 与 Step Sweep 仍分别等待证据,DSG830 production descriptor 继续只开放 snapshot、OFF-only CW 和端口级 output。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「A2 已提升」「A3 已提升」或「离线已完成」的内容外,本文中的 M4、其它 production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M3 已完成离线合同但仍未由 A4 提升。 +除明确标为「生产只读」「A2 已提升」「A3 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M3 和 M4 Pulse 已完成离线合同,但尚未由 A4 提升。 ## 术语与证据级别 @@ -48,7 +48,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;DSG830 已由 A3 将其提升到 production。 - M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 -- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同。 +- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;其中 M4 当前只完成 internal/single Pulse 配置子集。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 ### 明确不做 @@ -318,7 +318,7 @@ M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端 M1 已由 A3 在真实设备上完成受控频率/功率写入、独立 readback、低功率 RF ON/OFF 环回与最终 OFF 验收,因而将 `rf_source.cw_configure` 纳入 DSG830 production descriptor。M2 已由 A2 将 `rf_source.output` 纳入同一 descriptor;人工确认的实验室端接本身仍不构成调制、Pulse、Sweep、trigger 或其它额外写入授权。 -### M3 离线入口与 M4 目标 +### M3 与 M4 的离线入口 M3 的写入 CLI 和 run step 已进入当前 Core schema,但其真实仪器使用仍由 production descriptor 的 A4 capability 门决定: @@ -327,7 +327,12 @@ wavebench rf-source modulation configure-am ... wavebench rf-source modulation configure-fm ... wavebench rf-source modulation configure-pm ... rf_source.modulation_configure -wavebench rf-source pulse configure ... + +# M4 Pulse:只允许 internal/single 配置,配置后 Pulse 仍保持关闭 +wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted +rf_source.pulse_configure + +# Pulse trigger 与 Step Sweep 仍是目标合同,尚未进入当前 Core schema wavebench rf-source pulse trigger ... wavebench rf-source sweep configure ... wavebench rf-source sweep arm ... @@ -337,7 +342,7 @@ wavebench rf-source sweep stop ... M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 -Pulse、Sweep 和 trigger 的命令与 step 仍是目标合同,尚未进入当前 run schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact。它们不得访问普通 source channel,也不操作外部 trigger、Pulse In/Out 或其他未声明端口。 +`rf_source.pulse_configure` 已进入当前 Core schema,但对未声明该 capability 的 production descriptor 仍会在 transport I/O 前拒绝。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。Pulse trigger、Step Sweep 及其 arm/fire/stop 仍是目标合同,尚未进入当前 schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 ## M0–M4 里程碑 @@ -349,7 +354,8 @@ Pulse、Sweep 和 trigger 的命令与 step 仍是目标合同,尚未进入当 | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(离线完成;A4 的 AM、FM 已通过,PM 待定位) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待完整 A4。 | -| M4 | 声明式 Pulse/Sweep profile、typed request/result、arm/fire/stop、CLI、run step 与 operation spec | 已声明 Pulse 与 Step Sweep 子集 | 错误模式、未声明 option、外部 trigger 或 fire 前置不满足时零写拒绝;fire/trigger 仅由 fake descriptor 覆盖。 | +| M4(Pulse) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;production capability 等待 A4 Pulse 证据。 | +| M4(Step Sweep) | frequency-only Step Sweep profile、configure/arm/fire/stop | 待实现 | 仅在固定 trigger、安全规则和实机证据具备后进入实现;fire/trigger 先只由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 @@ -372,7 +378,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。两条路径均不读取 scope、不调用 RF output,且 production descriptor 在 A4 复核前不声明 `rf_source.modulation_configure` 或 `rf_source.modulation_disable`。严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 仅固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;本地 evidence harness 不读取 scope、不调用 RF output、不使用 Pulse I/O 或 trigger。上述路径均不提升 production descriptor,A4 复核前不得声明 `rf_source.modulation_configure`、`rf_source.modulation_disable` 或 `rf_source.pulse_configure`。严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -389,5 +395,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。A4–A5 仍不能据此提升调制、Pulse、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;实机证据完成前,A4–A5 仍不能提升调制、Pulse、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 1ed4d1a..91ce1ba 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -23,7 +23,8 @@ | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | | 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无覆盖三种模式的完整合格证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | -| Pulse、Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | +| Pulse | M4 离线合同、CLI/run 与受控 evidence harness 已完成 | 未开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;production descriptor 会拒绝。 | +| Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -155,12 +156,25 @@ DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令 M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 +## M4:受控 Pulse 配置合同 + +Core 已提供下列离线入口: + +```text +wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted +rf_source.pulse_configure +``` + +当前 DSG830 production descriptor 不声明 `rf_source.pulse_configure`,因此普通 CLI 或 run plan 会在打开 transport 前拒绝。这一限制是预期行为,不应通过临时 descriptor 或原始 SCPI 绕过。 + +源码 checkout 的 `tools/a4_pulse_evidence.py` 是专门的受控验证工具。它只接受 internal/single、period、width 和 polarity,并在每次配置后保持 Pulse OFF;初始、写后和最终状态都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection。它不调用 RF output、不使用后面板 Pulse I/O、不发送 trigger、不读取 CH1/CH2。`--diagnose` 保持 `read_only` 且零写,`--execute` 才允许一次受审计的配置写入。两种记录均不构成生产 capability 提升,直至实机证据完成并经复核。 + ## 上机前检查清单 1. 使用网络发现和只读身份查询确认候选设备,再在隔离配置中复核资源与型号。 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. M3/A4 阶段不使用 raw SCPI、不执行 reset、preset、错误队列、外部调制、Pulse、Sweep、trigger 或 scope 自动量程。 +5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Sweep、trigger 或 scope 自动量程。A4 Pulse 仅可通过专用受控 harness 执行。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 From f7f4722446531e99b7600c9819e0b41685f5e880 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:38:02 +0800 Subject: [PATCH 35/63] docs: record verified DSG830 pulse capability --- ...7\221\351\207\214\347\250\213\347\242\221.md" | 10 +++++----- ...7\267\346\272\220\350\256\276\350\256\241.md" | 16 ++++++++-------- ...5\277\347\224\250\346\214\207\345\215\227.md" | 14 +++++++++++--- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index e15bb58..e725bd7 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 离线完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 映射;production descriptor 仅声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和 `rf_source.output`。 | -| 真实仪器证据 | A1、A2、A3 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A4 Pulse 工具待实机执行;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW。 | +| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 已提升 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 的 A4 已通过;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 和 `rf_source.pulse_configure`。 | +| 真实仪器证据 | A1、A2、A3、A4 Pulse 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 Pulse 提升 OFF-only Pulse 配置。 | ## 双仓库交付规则 @@ -31,7 +31,7 @@ | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | -| M4(Pulse) | 离线完成;A4 Pulse 待实机执行 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;production capability 等待证据。 | +| M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | 未开始 | frequency-only Step Sweep 合同 | 待实现 | trigger/fire 只能由专项安全规则与实机证据提升。 | ## Seed:历史种子包 @@ -107,7 +107,7 @@ M4 当前先完成 Pulse,再处理 frequency-only Step Sweep。`RfPortSnapshot Pulse 只覆盖 `rf_out` 的 internal/single 子集。request 只包含 period、width 和 polarity;Core 在写入前要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读回 internal/single、全部请求字段和 Pulse 关闭状态。driver 的固定 sequence 以 `:PULM:STAT OFF` 收尾。它不控制 `:PULM:OUT`、RF 输出或任何 trigger,失败时不重试、不追加恢复 setter。 -源码 checkout 的 `tools/a4_pulse_evidence.py` 与资源无关的 setup 模板为实机证据提供受控入口。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确的 production descriptor 和 50 Ω 端接声明;显式 `--execute` 才在内存中建立临时 `read_write` descriptor。成功路径固定为初始 snapshot、一次 Pulse 配置、独立配置读回和最终 snapshot,预期 38 次 query、6 次配置 write,并确认 RF 与 Pulse 仍关闭。`--diagnose` 保持 `read_only`,固定 22 次 query、零 write。两种模式都不读取 scope、不使用 CH1/CH2、不发送 trigger,证据以 `0600` 保存且不包含资源或原始响应。 +源码 checkout 的 `tools/a4_pulse_evidence.py` 与资源无关的 setup 模板完成了 A4 Pulse 受控验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确的 production descriptor 和 50 Ω 端接声明;显式 `--execute` 才在内存中建立临时 `read_write` descriptor。两种已声明 polarity 都通过初始 snapshot、一次 Pulse 配置、独立配置读回和最终 snapshot,均为 38 次 query、6 次配置 write,并确认 RF 与 Pulse 仍关闭。`--diagnose` 保持 `read_only`,固定 22 次 query、零 write。两种模式都不读取 scope、不使用 CH1/CH2、不发送 trigger,证据以 `0600` 保存且不包含资源或原始响应。证据复核后,DSG830 production descriptor 已声明 `rf_source.pulse_configure`;historical harness 现在会拒绝重跑。 Step Sweep、Pulse trigger、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 720112b..2947c96 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 配置的合同与控制入口;DSG830 已凭 A1/A2/A3 证据开放 snapshot、OFF-only CW 与受 safety 限制的 output。M3 与 M4 Pulse 仍须经过各自的实机证据门,不能由离线代码替代。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 配置的合同与控制入口;DSG830 已凭 A1/A2/A3 和 A4 Pulse 证据开放 snapshot、OFF-only CW、受 safety 限制的 output 与 RF-OFF Pulse 配置。M3 仍须通过覆盖 PM 的实机证据门,不能由离线代码替代。 阅读顺序如下: @@ -16,12 +16,12 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 配置映射;A1/A2/A3 证据已经完成。 | production descriptor 只声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3/M4 写 capability 仍关闭。 | -| 实机证据 | A1、A2、A3 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A4 Pulse 工具已完成离线验证,实机证据待执行;A5 未开始。 | M3 capability 覆盖三种模式;M4 Pulse 与 Step Sweep 仍分别等待证据,DSG830 production descriptor 继续只开放 snapshot、OFF-only CW 和端口级 output。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 配置映射;A1/A2/A3/A4 Pulse 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output` 和 RF-OFF `rf_source.pulse_configure`;M3 与 Step Sweep 写 capability 仍关闭。 | +| 实机证据 | A1、A2、A3 和 A4 Pulse 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A5 未开始。 | M3 capability 覆盖三种模式;Step Sweep 仍等待实现与证据,DSG830 production descriptor 已开放 RF-OFF Pulse 配置。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「A2 已提升」「A3 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M3 和 M4 Pulse 已完成离线合同,但尚未由 A4 提升。 +除明确标为「生产只读」「A2 已提升」「A3 已提升」「A4 Pulse 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M4 Pulse 已由 A4 提升,M3 仍未覆盖 PM。 ## 术语与证据级别 @@ -342,7 +342,7 @@ wavebench rf-source sweep stop ... M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 -`rf_source.pulse_configure` 已进入当前 Core schema,但对未声明该 capability 的 production descriptor 仍会在 transport I/O 前拒绝。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。Pulse trigger、Step Sweep 及其 arm/fire/stop 仍是目标合同,尚未进入当前 schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 +`rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。Pulse trigger、Step Sweep 及其 arm/fire/stop 仍是目标合同,尚未进入当前 schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 ## M0–M4 里程碑 @@ -354,7 +354,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(离线完成;A4 的 AM、FM 已通过,PM 待定位) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待完整 A4。 | -| M4(Pulse) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;production capability 等待 A4 Pulse 证据。 | +| M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | | M4(Step Sweep) | frequency-only Step Sweep profile、configure/arm/fire/stop | 待实现 | 仅在固定 trigger、安全规则和实机证据具备后进入实现;fire/trigger 先只由 fake descriptor 覆盖。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 @@ -378,7 +378,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 与 `rf_source.output`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 仅固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;本地 evidence harness 不读取 scope、不调用 RF output、不使用 Pulse I/O 或 trigger。上述路径均不提升 production descriptor,A4 复核前不得声明 `rf_source.modulation_configure`、`rf_source.modulation_disable` 或 `rf_source.pulse_configure`。严格 parser 与 A1/A2/A3 证据不开放调制、Pulse、Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 与 `rf_source.pulse_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。历史 A4 Pulse harness 在 descriptor 提升后拒绝重跑,普通使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。它不读取 scope、不调用 RF output、不使用 Pulse I/O 或 trigger。A4 尚未提升 `rf_source.modulation_configure`、`rf_source.modulation_disable`,严格 parser 与既有证据也不开放 Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -395,5 +395,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3 完成后声明 `rf_source - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;实机证据完成前,A4–A5 仍不能提升调制、Pulse、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。A4–A5 仍不能据此提升调制、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 91ce1ba..bfe65de 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -23,7 +23,7 @@ | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | | 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无覆盖三种模式的完整合格证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | -| Pulse | M4 离线合同、CLI/run 与受控 evidence harness 已完成 | 未开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;production descriptor 会拒绝。 | +| Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -88,6 +88,7 @@ wavebench rf-source set-frequency --config wavebench.toml --port rf_out 1000000 wavebench rf-source set-power --config wavebench.toml --port rf_out -40 wavebench rf-source output --config wavebench.toml --port rf_out on wavebench rf-source output --config wavebench.toml --port rf_out off +wavebench rf-source pulse configure --config wavebench.toml --port rf_out --period-s 0.001 --width-s 0.0001 --polarity normal ``` `output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 @@ -114,6 +115,13 @@ port_id = "rf_out" [[steps]] kind = "rf_source.output_disable" port_id = "rf_out" + +[[steps]] +kind = "rf_source.pulse_configure" +port_id = "rf_out" +period_s = 0.001 +width_s = 0.0001 +polarity = "normal" ``` 先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 @@ -165,9 +173,9 @@ wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s rf_source.pulse_configure ``` -当前 DSG830 production descriptor 不声明 `rf_source.pulse_configure`,因此普通 CLI 或 run plan 会在打开 transport 前拒绝。这一限制是预期行为,不应通过临时 descriptor 或原始 SCPI 绕过。 +DSG830 production descriptor 已声明 `rf_source.pulse_configure`。普通 CLI 或 run plan 仍要求 `read_write`、目标 RF 输出/调制/Pulse/Sweep 关闭、无活动 protection,以及 descriptor 声明的 internal/single profile;任何条件不满足都会在写入前拒绝。 -源码 checkout 的 `tools/a4_pulse_evidence.py` 是专门的受控验证工具。它只接受 internal/single、period、width 和 polarity,并在每次配置后保持 Pulse OFF;初始、写后和最终状态都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection。它不调用 RF output、不使用后面板 Pulse I/O、不发送 trigger、不读取 CH1/CH2。`--diagnose` 保持 `read_only` 且零写,`--execute` 才允许一次受审计的配置写入。两种记录均不构成生产 capability 提升,直至实机证据完成并经复核。 +源码 checkout 的 `tools/a4_pulse_evidence.py` 是已完成的受控验收工具。它只接受 internal/single、period、width 和 polarity,并在每次配置后保持 Pulse OFF;初始、写后和最终状态都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection。它不调用 RF output、不使用后面板 Pulse I/O、不发送 trigger、不读取 CH1/CH2。`--diagnose` 保持 `read_only` 且零写,`--execute` 才允许一次受审计的配置写入。两种 polarity 的证据均通过并经复核,DSG830 已开放该 capability;historical harness 在提升后会拒绝重跑。 ## 上机前检查清单 From d3481d815c58ee758e8d60c00a551711ee52f3ed Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:52:56 +0800 Subject: [PATCH 36/63] feat: add RF step sweep configuration contract --- .../instruments/rf_source_capabilities.py | 28 ++ .../instruments/rf_source_extensions.py | 269 ++++++++++++++++++ src/wavebench/services/operation_specs.py | 20 ++ tests/test_operation_specs.py | 27 ++ tests/test_rf_source_sweep_extensions.py | 244 ++++++++++++++++ 5 files changed, 588 insertions(+) create mode 100644 tests/test_rf_source_sweep_extensions.py diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 61eca0c..15ee688 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -22,6 +22,7 @@ RfOutputProfile, RfPulseProfile, RfSourceDescriptorExtensions, + RfSweepProfile, ) @@ -43,6 +44,10 @@ "get_rf_pulse_snapshot", "configure_rf_pulse", ), + "rf_source.sweep_configure": ( + "get_rf_sweep_snapshot", + "configure_rf_sweep", + ), "rf_source.output": ("set_rf_output",), } ) @@ -87,6 +92,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No _validate_modulation_disable_feature(extensions) if "rf_source.pulse_configure" in rf_capabilities: _validate_pulse_configure_feature(extensions) + if "rf_source.sweep_configure" in rf_capabilities: + _validate_sweep_configure_feature(extensions) if "rf_source.output" in rf_capabilities: _validate_output_feature(extensions) _validate_rf_source_version_range(descriptor) @@ -200,6 +207,27 @@ def _validate_pulse_configure_feature(extensions: RfSourceDescriptorExtensions) ) +def _validate_sweep_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.SWEEP), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.sweep_configure requires an RF Sweep feature with configure and read directions" + ) + if not isinstance(feature.profile, RfSweepProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.sweep_configure requires an RF Sweep profile") + if not feature.profile.configuration_readable or not feature.profile.mode_profiles: + raise ConfigError( + "rf_source.sweep_configure requires readable bounded RF Sweep mode profiles" + ) + + def validate_rf_source_plugin_dependencies( descriptor: object, dependencies: Iterable[str], diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 0dbdd36..88ad873 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -24,6 +24,7 @@ RF_SOURCE_MODULATION_STATE_SCHEMA = "wavebench.rf_source.modulation_state.v1" RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" RF_SOURCE_PULSE_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_snapshot.v1" +RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA = "wavebench.rf_source.sweep_snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" @@ -55,6 +56,21 @@ def _require_finite( raise ValueError(f"{label} must be <= {maximum}") +def _require_integer( + value: object, + label: str, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} must be an integer") + if minimum is not None and value < minimum: + raise ValueError(f"{label} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{label} must be <= {maximum}") + + def _require_enum_tuple( values: object, enum_type: type[StrEnum], @@ -183,6 +199,22 @@ class RfSweepState(StrEnum): ENABLED = "enabled" +class RfSweepType(StrEnum): + STEP = "step" + + +class RfSweepDirection(StrEnum): + FORWARD = "forward" + + +class RfSweepShape(StrEnum): + RAMP = "ramp" + + +class RfSweepSpacing(StrEnum): + LINEAR = "linear" + + class RfFeature(StrEnum): CW = "cw" MODULATION = "modulation" @@ -514,9 +546,83 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class RfSweepProfile: state_readable: bool + configuration_readable: bool = False + mode_profiles: tuple["RfSweepModeProfile", ...] = () def __post_init__(self) -> None: _require_bool(self.state_readable, "RF sweep state_readable") + _require_bool(self.configuration_readable, "RF sweep configuration_readable") + if not isinstance(self.mode_profiles, tuple) or any( + not isinstance(profile, RfSweepModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF sweep mode_profiles have an invalid type") + identities = tuple( + ( + profile.sweep_type.value, + profile.direction.value, + profile.shape.value, + profile.spacing.value, + ) + for profile in self.mode_profiles + ) + if len(set(identities)) != len(identities) or tuple(sorted(identities)) != identities: + raise ValueError("RF sweep mode_profiles must be sorted and unique") + if self.configuration_readable and not self.state_readable: + raise ValueError("RF sweep configuration readback requires readable state") + + +@dataclass(frozen=True, slots=True) +class RfSweepModeProfile: + """One bounded frequency-only Step Sweep profile that remains disabled.""" + + sweep_type: RfSweepType + direction: RfSweepDirection + shape: RfSweepShape + spacing: RfSweepSpacing + frequency_min_hz: float + frequency_max_hz: float + points_min: int + points_max: int + dwell_min_s: float + dwell_max_s: float + + def __post_init__(self) -> None: + if not isinstance(self.sweep_type, RfSweepType): + raise ValueError("RF sweep mode type has an invalid type") + if not isinstance(self.direction, RfSweepDirection): + raise ValueError("RF sweep mode direction has an invalid type") + if not isinstance(self.shape, RfSweepShape): + raise ValueError("RF sweep mode shape has an invalid type") + if not isinstance(self.spacing, RfSweepSpacing): + raise ValueError("RF sweep mode spacing has an invalid type") + if self.sweep_type is not RfSweepType.STEP: + raise ValueError("RF sweep mode profiles must use Step Sweep") + if self.direction is not RfSweepDirection.FORWARD: + raise ValueError("RF sweep mode profiles must use the forward direction") + if self.shape is not RfSweepShape.RAMP: + raise ValueError("RF sweep mode profiles must use ramp shape") + if self.spacing is not RfSweepSpacing.LINEAR: + raise ValueError("RF sweep mode profiles must use linear spacing") + _require_finite(self.frequency_min_hz, "RF sweep mode frequency_min_hz", minimum=0.0) + _require_finite( + self.frequency_max_hz, + "RF sweep mode frequency_max_hz", + minimum=self.frequency_min_hz, + ) + _require_integer(self.points_min, "RF sweep mode points_min", minimum=2) + _require_integer( + self.points_max, + "RF sweep mode points_max", + minimum=self.points_min, + ) + _require_finite(self.dwell_min_s, "RF sweep mode dwell_min_s", minimum=0.0) + _require_finite( + self.dwell_max_s, + "RF sweep mode dwell_max_s", + minimum=self.dwell_min_s, + ) + if self.dwell_min_s <= 0.0: + raise ValueError("RF sweep mode dwell_min_s must be positive") RfFeatureProfile: TypeAlias = ( @@ -966,6 +1072,105 @@ def __post_init__(self) -> None: raise ValueError("RF pulse snapshot state has an invalid type") +@dataclass(frozen=True, slots=True) +class RfSweepConfigureRequest: + """One RF-OFF frequency-only Step Sweep configuration for one RF port. + + The descriptor supplies the fixed Step/forward/ramp/linear profile. This + request deliberately has no level, trigger, arm, fire, or output field. + """ + + port_id: str + start_frequency_hz: float + stop_frequency_hz: float + points: int + dwell_s: float + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF sweep configure request port_id") + _require_finite( + self.start_frequency_hz, + "RF sweep configure request start_frequency_hz", + minimum=0.0, + ) + _require_finite( + self.stop_frequency_hz, + "RF sweep configure request stop_frequency_hz", + minimum=0.0, + ) + if self.start_frequency_hz >= self.stop_frequency_hz: + raise ValueError("RF sweep configure request start_frequency_hz must be less than stop_frequency_hz") + _require_integer(self.points, "RF sweep configure request points", minimum=2) + _require_finite(self.dwell_s, "RF sweep configure request dwell_s", minimum=0.0) + if self.dwell_s <= 0.0: + raise ValueError("RF sweep configure request dwell_s must be positive") + + +@dataclass(frozen=True, slots=True) +class RfSweepConfigureResult: + """A bounded Step Sweep configuration confirmed while Sweep remains disabled.""" + + port_id: str + start_frequency_hz: float + stop_frequency_hz: float + points: int + dwell_s: float + + def __post_init__(self) -> None: + RfSweepConfigureRequest( + port_id=self.port_id, + start_frequency_hz=self.start_frequency_hz, + stop_frequency_hz=self.stop_frequency_hz, + points=self.points, + dwell_s=self.dwell_s, + ) + + +@dataclass(frozen=True, slots=True) +class RfSweepSnapshot: + """Complete typed readback for one frequency-only Step Sweep profile.""" + + port_id: str + sweep_type: RfSweepType + direction: RfSweepDirection + shape: RfSweepShape + spacing: RfSweepSpacing + start_frequency_hz: float + stop_frequency_hz: float + points: int + dwell_s: float + state: RfSweepState + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF sweep snapshot port_id") + if not isinstance(self.sweep_type, RfSweepType): + raise ValueError("RF sweep snapshot sweep_type has an invalid type") + if not isinstance(self.direction, RfSweepDirection): + raise ValueError("RF sweep snapshot direction has an invalid type") + if not isinstance(self.shape, RfSweepShape): + raise ValueError("RF sweep snapshot shape has an invalid type") + if not isinstance(self.spacing, RfSweepSpacing): + raise ValueError("RF sweep snapshot spacing has an invalid type") + _require_finite( + self.start_frequency_hz, + "RF sweep snapshot start_frequency_hz", + minimum=0.0, + ) + _require_finite( + self.stop_frequency_hz, + "RF sweep snapshot stop_frequency_hz", + minimum=0.0, + ) + if self.start_frequency_hz >= self.stop_frequency_hz: + raise ValueError("RF sweep snapshot start_frequency_hz must be less than stop_frequency_hz") + _require_integer(self.points, "RF sweep snapshot points", minimum=2) + _require_finite(self.dwell_s, "RF sweep snapshot dwell_s", minimum=0.0) + if self.dwell_s <= 0.0: + raise ValueError("RF sweep snapshot dwell_s must be positive") + if not isinstance(self.state, RfSweepState): + raise ValueError("RF sweep snapshot state has an invalid type") + + @dataclass(frozen=True, slots=True) class RfOutputRequest: """One explicit RF output state request for one descriptor-defined port.""" @@ -1014,6 +1219,10 @@ def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: ... def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: ... + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: ... + + def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: ... + def set_rf_output(self, request: RfOutputRequest) -> None: ... @@ -1117,6 +1326,16 @@ def rf_pulse_snapshot_document(snapshot: RfPulseSnapshot) -> dict[str, object]: return {"schema": RF_SOURCE_PULSE_SNAPSHOT_SCHEMA, **data} +def rf_sweep_snapshot_document(snapshot: RfSweepSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF Step Sweep readback.""" + + if not isinstance(snapshot, RfSweepSnapshot): + raise TypeError("snapshot must be RfSweepSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA, **data} + + def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: """Build a read-only snapshot artifact without transport-private values.""" @@ -1285,6 +1504,45 @@ def rf_source_pulse_operation_artifact( } +def rf_source_sweep_operation_artifact( + request: RfSweepConfigureRequest, + result: RfSweepConfigureResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_sweep_snapshot: RfSweepSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one disabled Step Sweep configuration.""" + + if not isinstance(request, RfSweepConfigureRequest): + raise TypeError("request must be RfSweepConfigureRequest") + if not isinstance(result, RfSweepConfigureResult): + raise TypeError("result must be RfSweepConfigureResult") + if ( + request.port_id != result.port_id + or request.start_frequency_hz != result.start_frequency_hz + or request.stop_frequency_hz != result.stop_frequency_hz + or request.points != result.points + or request.dwell_s != result.dwell_s + ): + raise ValueError("RF sweep request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_sweep_snapshot, RfSweepSnapshot): + raise TypeError("postcondition_sweep_snapshot must be RfSweepSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.sweep_configure", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_sweep_snapshot": rf_sweep_snapshot_document(postcondition_sweep_snapshot), + } + + def rf_source_output_operation_artifact( request: RfOutputRequest, result: RfOutputResult, @@ -1322,6 +1580,7 @@ def rf_source_output_operation_artifact( "RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", "RF_SOURCE_PULSE_SNAPSHOT_SCHEMA", + "RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA", "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", "RF_SOURCE_SNAPSHOT_SCHEMA", "RfAvailability", @@ -1368,16 +1627,26 @@ def rf_source_output_operation_artifact( "RfSourceSnapshot", "RfSourceTopology", "RfSweepProfile", + "RfSweepConfigureRequest", + "RfSweepConfigureResult", + "RfSweepDirection", + "RfSweepModeProfile", + "RfSweepShape", + "RfSweepSnapshot", + "RfSweepSpacing", "RfSweepState", + "RfSweepType", "rf_source_canonical_json", "rf_source_cw_operation_artifact", "rf_source_digest", "rf_modulation_snapshot_document", "rf_modulation_state_snapshot_document", "rf_pulse_snapshot_document", + "rf_sweep_snapshot_document", "rf_source_modulation_disable_operation_artifact", "rf_source_modulation_operation_artifact", "rf_source_pulse_operation_artifact", + "rf_source_sweep_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", "rf_source_output_operation_artifact", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 196a0c9..e39b0d9 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1109,6 +1109,26 @@ def _spec( risk_flags=("rf_output_must_be_off", "pulse_state", "state_drift"), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.sweep_configure", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.sweep_configure"), + effect="write", + changed_fields=( + "rf_source.sweep.type", + "rf_source.sweep.direction", + "rf_source.sweep.shape", + "rf_source.sweep.spacing", + "rf_source.sweep.start_frequency_hz", + "rf_source.sweep.stop_frequency_hz", + "rf_source.sweep.points", + "rf_source.sweep.dwell_s", + "rf_source.sweep.state", + ), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "sweep_disabled", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec( "rf_source.output_enable", "rf_source", diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index d462349..3ff850b 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -137,6 +137,33 @@ def test_rf_source_pulse_configure_spec_keeps_rf_and_pulse_off() -> None: assert configure.safe_alternatives == ("rf_source.snapshot",) +def test_rf_source_sweep_configure_spec_keeps_rf_and_sweep_off() -> None: + configure = require_operation_spec("rf_source.sweep_configure") + + assert configure.instrument_kind == "rf_source" + assert configure.required_capabilities == ( + "rf_source.snapshot", + "rf_source.sweep_configure", + ) + assert configure.effect == "write" + assert configure.changed_fields == ( + "rf_source.sweep.type", + "rf_source.sweep.direction", + "rf_source.sweep.shape", + "rf_source.sweep.spacing", + "rf_source.sweep.start_frequency_hz", + "rf_source.sweep.stop_frequency_hz", + "rf_source.sweep.points", + "rf_source.sweep.dwell_s", + "rf_source.sweep.state", + ) + assert configure.restore_coverage == "none" + assert "rf_output_must_be_off" in configure.risk_flags + assert "sweep_disabled" in configure.risk_flags + assert "trigger" not in configure.risk_flags + assert configure.safe_alternatives == ("rf_source.snapshot",) + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_sweep_extensions.py b/tests/test_rf_source_sweep_extensions.py new file mode 100644 index 0000000..b39a924 --- /dev/null +++ b/tests/test_rf_source_sweep_extensions.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSweepConfigureRequest, + RfSweepConfigureResult, + RfSweepDirection, + RfSweepModeProfile, + RfSweepProfile, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, + RfSweepState, + RfSweepType, + RfSourceTopology, + rf_source_sweep_operation_artifact, + rf_sweep_snapshot_document, +) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _sweep_mode() -> RfSweepModeProfile: + return RfSweepModeProfile( + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + points_min=2, + points_max=65_535, + dwell_min_s=20e-3, + dwell_max_s=100.0, + ) + + +def _sweep_profile() -> RfSweepProfile: + return RfSweepProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_sweep_mode(),), + ) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _sweep_snapshot() -> RfSweepSnapshot: + return RfSweepSnapshot( + port_id="rf_out", + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=20e-3, + state=RfSweepState.DISABLED, + ) + + +def _descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.sweep", + kind="rf_source", + models=("RF-SWEEP",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.SWEEP, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_sweep_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-SWEEP,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot() + + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: + assert port_id == "rf_out" + return _sweep_snapshot() + + def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: + assert request.port_id == "rf_out" + + +def test_sweep_contract_rejects_unsafe_profiles_and_requests() -> None: + with pytest.raises(ValueError, match=">= 2"): + RfSweepModeProfile( + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + points_min=1, + points_max=65_535, + dwell_min_s=20e-3, + dwell_max_s=100.0, + ) + with pytest.raises(ValueError, match="less than stop"): + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=1_000_000.0, + points=11, + dwell_s=20e-3, + ) + with pytest.raises(ValueError, match="integer"): + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=True, + dwell_s=20e-3, + ) + + +def test_sweep_descriptor_requires_readable_bounded_profile_and_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.sweep_configure"] == ( + "get_rf_sweep_snapshot", + "configure_rf_sweep", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.SWEEP, + directions=(RfFeatureDirection.CONFIGURE,), + port_ids=("rf_out",), + profile=_sweep_profile(), + ), + ), + ) + with pytest.raises(Exception, match="configure and read"): + validate_rf_source_descriptor(invalid) + + +def test_sweep_snapshot_document_and_artifact_keep_typed_safe_evidence() -> None: + request = RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=20e-3, + ) + result = RfSweepConfigureResult( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=20e-3, + ) + sweep_snapshot = _sweep_snapshot() + + document = rf_sweep_snapshot_document(sweep_snapshot) + artifact = rf_source_sweep_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(), + postcondition_snapshot=_snapshot(), + postcondition_sweep_snapshot=sweep_snapshot, + ) + + assert document["schema"] == RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA + assert document["sweep_type"] == "step" + assert document["state"] == "disabled" + assert artifact["operation"] == "rf_source.sweep_configure" + assert artifact["request"]["points"] == 11 + assert artifact["postcondition_sweep_snapshot"]["dwell_s"] == 20e-3 + assert "resource" not in str(artifact) From 8ec1733d780d6923dbbe9da0aa0ce9e57a5f68f3 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:53:06 +0800 Subject: [PATCH 37/63] feat: add safe RF step sweep configuration service --- src/wavebench/services/rf_source_service.py | 237 +++++++++++++ tests/test_rf_source_sweep_service.py | 354 ++++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 tests/test_rf_source_sweep_service.py diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index aabc960..b80287c 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -49,12 +49,22 @@ RfSourceDescriptorExtensions, RfSourceDriver, RfSourceSnapshot, + RfSweepConfigureRequest, + RfSweepConfigureResult, + RfSweepDirection, + RfSweepModeProfile, + RfSweepProfile, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, RfSweepState, + RfSweepType, rf_source_cw_operation_artifact, rf_source_modulation_disable_operation_artifact, rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, rf_source_pulse_operation_artifact, + rf_source_sweep_operation_artifact, ) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy @@ -103,6 +113,14 @@ class _RfPulseTransaction: postcondition_pulse_snapshot: RfPulseSnapshot +@dataclass(frozen=True) +class _RfSweepTransaction: + result: RfSweepConfigureResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_sweep_snapshot: RfSweepSnapshot + + @dataclass(frozen=True) class _RfOutputTransaction: result: RfOutputResult @@ -549,6 +567,84 @@ def _configure_pulse_transaction( ) raise + def configure_sweep(self, request: RfSweepConfigureRequest) -> RfSweepConfigureResult: + return self._configure_sweep_transaction(request).result + + def configure_sweep_with_artifact( + self, + request: RfSweepConfigureRequest, + ) -> tuple[RfSweepConfigureResult, dict[str, object]]: + """Apply one RF-OFF Step Sweep profile while Sweep stays disabled.""" + + transaction = self._configure_sweep_transaction(request) + return ( + transaction.result, + rf_source_sweep_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_sweep_snapshot=transaction.postcondition_sweep_snapshot, + ), + ) + + def _configure_sweep_transaction( + self, + request: RfSweepConfigureRequest, + ) -> _RfSweepTransaction: + """Configure one disabled, frequency-only Step Sweep without triggering. + + This first Sweep slice cannot arm, trigger, fire, or select a Level + Sweep. It writes only the descriptor-bounded frequency profile and + explicitly requires RF output, modulation, Pulse, and Sweep to remain + disabled through the independent readback. + """ + + if not isinstance(request, RfSweepConfigureRequest): + raise ConfigError("rf_source Sweep configuration requires RfSweepConfigureRequest") + operation = "rf_source.sweep_configure" + self._require(operation, "rf_source.snapshot", "rf_source.sweep_configure") + mode_profile = self._validate_sweep_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + self._validate_sweep_preflight( + request, + preflight_snapshot, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_rf_sweep(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_sweep_snapshot = rf_source.get_rf_sweep_snapshot(request.port_id) + result = self._validate_sweep_postcondition( + request, + postcondition_snapshot, + postcondition_sweep_snapshot, + mode_profile, + operation=operation, + ) + return _RfSweepTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_sweep_snapshot=postcondition_sweep_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_sweep_postcondition_unverified", + ) + raise + def set_output(self, request: RfOutputRequest) -> RfOutputResult: return self._set_output_transaction(request).result @@ -1029,6 +1125,147 @@ def _validate_pulse_postcondition( polarity=request.polarity, ) + def _validate_sweep_descriptor( + self, + request: RfSweepConfigureRequest, + operation: str, + ) -> RfSweepModeProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.SWEEP), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfSweepProfile) + or not feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable Sweep profile for the target port" + ) + mode_profile = next( + ( + item + for item in feature.profile.mode_profiles + if ( + item.sweep_type is RfSweepType.STEP + and item.direction is RfSweepDirection.FORWARD + and item.shape is RfSweepShape.RAMP + and item.spacing is RfSweepSpacing.LINEAR + ) + ), + None, + ) + if mode_profile is None: + raise ConfigError( + f"{operation} requires a frequency-only forward linear Step Sweep profile" + ) + if not ( + mode_profile.frequency_min_hz + <= request.start_frequency_hz + <= mode_profile.frequency_max_hz + ): + raise ConfigError(f"{operation} request start_frequency_hz is outside the descriptor range") + if not ( + mode_profile.frequency_min_hz + <= request.stop_frequency_hz + <= mode_profile.frequency_max_hz + ): + raise ConfigError(f"{operation} request stop_frequency_hz is outside the descriptor range") + if not mode_profile.points_min <= request.points <= mode_profile.points_max: + raise ConfigError(f"{operation} request points is outside the descriptor range") + if not mode_profile.dwell_min_s <= request.dwell_s <= mode_profile.dwell_max_s: + raise ConfigError(f"{operation} request dwell_s is outside the descriptor range") + return mode_profile + + def _validate_sweep_preflight( + self, + request: RfSweepConfigureRequest, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> RfPortSnapshot: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return port + + def _validate_sweep_postcondition( + self, + request: RfSweepConfigureRequest, + snapshot: RfSourceSnapshot, + sweep_snapshot: RfSweepSnapshot, + mode_profile: RfSweepModeProfile, + *, + operation: str, + ) -> RfSweepConfigureResult: + self._validate_sweep_preflight(request, snapshot, operation=operation) + if sweep_snapshot.port_id != request.port_id: + raise ConfigError(f"{operation} Sweep snapshot does not match the requested port") + if ( + sweep_snapshot.sweep_type is not mode_profile.sweep_type + or sweep_snapshot.direction is not mode_profile.direction + or sweep_snapshot.shape is not mode_profile.shape + or sweep_snapshot.spacing is not mode_profile.spacing + ): + raise ConfigError( + f"{operation} postcondition requires the declared frequency-only Step Sweep profile" + ) + if sweep_snapshot.state is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} postcondition requires Sweep disabled") + if ( + sweep_snapshot.start_frequency_hz != request.start_frequency_hz + or sweep_snapshot.stop_frequency_hz != request.stop_frequency_hz + or sweep_snapshot.points != request.points + or sweep_snapshot.dwell_s != request.dwell_s + ): + raise ConfigError(f"{operation} Sweep readback does not match request") + return RfSweepConfigureResult( + port_id=request.port_id, + start_frequency_hz=request.start_frequency_hz, + stop_frequency_hz=request.stop_frequency_hz, + points=request.points, + dwell_s=request.dwell_s, + ) + def _validate_modulation_descriptor( self, request: RfModulationRequest, diff --git a/tests/test_rf_source_sweep_service.py b/tests/test_rf_source_sweep_service.py new file mode 100644 index 0000000..9808068 --- /dev/null +++ b/tests/test_rf_source_sweep_service.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepConfigureRequest, + RfSweepDirection, + RfSweepModeProfile, + RfSweepProfile, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, + RfSweepState, + RfSweepType, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf.sweep", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _sweep_profile() -> RfSweepProfile: + return RfSweepProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfSweepModeProfile( + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + points_min=2, + points_max=65_535, + dwell_min_s=20e-3, + dwell_max_s=100.0, + ), + ), + ) + + +def _descriptor(*capabilities: str, profile: RfSweepProfile | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.sweep", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.SWEEP, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=profile or _sweep_profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _sweep_snapshot( + *, + start_frequency_hz: float = 1_000_000.0, + stop_frequency_hz: float = 2_000_000.0, + points: int = 11, + dwell_s: float = 20e-3, + state: RfSweepState = RfSweepState.DISABLED, +) -> RfSweepSnapshot: + return RfSweepSnapshot( + port_id="rf_out", + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + start_frequency_hz=start_frequency_hz, + stop_frequency_hz=stop_frequency_hz, + points=points, + dwell_s=dwell_s, + state=state, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + sweep_snapshots: list[RfSweepSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.sweep_snapshots = list(sweep_snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfSweepConfigureRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: + self.calls.append("sweep_snapshot") + assert port_id == "rf_out" + if not self.sweep_snapshots: + raise AssertionError("unexpected Sweep snapshot") + return self.sweep_snapshots.pop(0) + + def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: + self.calls.append("configure_sweep") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake Sweep write failed after transmission") + + +def _request(*, dwell_s: float = 20e-3) -> RfSweepConfigureRequest: + return RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=dwell_s, + ) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + sweep_snapshots: list[RfSweepSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + sweep_snapshots, + raise_after_write=raise_after_write, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_sweep_configuration_uses_one_write_and_independent_readbacks() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_sweep_snapshot()], + ) + + result, artifact = service.configure_sweep_with_artifact(request) + + assert result.start_frequency_hz == request.start_frequency_hz + assert result.stop_frequency_hz == request.stop_frequency_hz + assert result.points == request.points + assert result.dwell_s == request.dwell_s + assert driver.requests == [request] + assert driver.calls == ["snapshot", "configure_sweep", "snapshot", "sweep_snapshot"] + assert artifact["operation"] == "rf_source.sweep_configure" + assert artifact["postcondition_sweep_snapshot"]["state"] == "disabled" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), "target RF output OFF"), + (_rf_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_rf_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_sweep_configuration_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _service([snapshot], []) + + with pytest.raises(ConfigError, match=message): + service.configure_sweep(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_sweep_configuration_checks_capability_access_and_static_profile_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.sweep_configure"): + missing.configure_sweep(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.sweep_configure"): + read_only.configure_sweep(_request()) + assert read_only_driver.calls == [] + + range_service, range_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_sweep( + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=65_536, + dwell_s=20e-3, + ) + ) + assert range_driver.calls == [] + + dwell_service, dwell_driver = _service([], []) + with pytest.raises(ConfigError, match="dwell_s is outside the descriptor range"): + dwell_service.configure_sweep(_request(dwell_s=10e-3)) + assert dwell_driver.calls == [] + + +@pytest.mark.parametrize( + "sweep_snapshot", + ( + _sweep_snapshot(stop_frequency_hz=2_100_000.0), + _sweep_snapshot(points=12), + _sweep_snapshot(dwell_s=30e-3), + _sweep_snapshot(state=RfSweepState.ENABLED), + ), +) +def test_sweep_configuration_mismatch_is_not_retried_and_degrades_session( + sweep_snapshot: RfSweepSnapshot, +) -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [sweep_snapshot], + ) + + with pytest.raises(ConfigError): + service.configure_sweep(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_sweep", "snapshot", "sweep_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_sweep_configuration_write_failure_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot()], + [], + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission"): + service.configure_sweep(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_sweep"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN From 93bc594aebae420db24470d2a3dbd4df66af6c84 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:02:08 +0800 Subject: [PATCH 38/63] test: cover RF step sweep capability mapping --- tests/test_rf_source_extensions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 5109c14..46e5825 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -351,6 +351,10 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "get_rf_pulse_snapshot", "configure_rf_pulse", ), + "rf_source.sweep_configure": ( + "get_rf_sweep_snapshot", + "configure_rf_sweep", + ), "rf_source.output": ("set_rf_output",), } assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( From e04ed6063444cb7dfe6b09925b4dcdb11fb424f3 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:02:19 +0800 Subject: [PATCH 39/63] feat: expose safe RF step sweep configuration --- src/wavebench/cli.py | 16 +++ src/wavebench/cli_parser.py | 19 +++ src/wavebench/services/run_plan.py | 31 +++++ src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 18 +++ tests/test_rf_source_cli.py | 80 +++++++++++- tests/test_rf_source_run.py | 174 ++++++++++++++++++++++++++ 7 files changed, 338 insertions(+), 1 deletion(-) diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index bea7345..efc5325 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -94,6 +94,7 @@ RfOutputRequest, RfPulseConfigureRequest, RfPulsePolarity, + RfSweepConfigureRequest, ) from .mcp_http import ( resolve_mcp_token, @@ -1603,6 +1604,21 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "sweep": + result = service.configure_sweep( + RfSweepConfigureRequest( + port_id=args.port, + start_frequency_hz=args.start_frequency_hz, + stop_frequency_hz=args.stop_frequency_hz, + points=args.points, + dwell_s=args.dwell_s, + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.command == "output": result = service.set_output( RfOutputRequest(port_id=args.port, enabled=args.state == "on") diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 2111664..ce85e1b 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -735,6 +735,25 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(rf_source_pulse_configure) + rf_source_sweep = rf_source_sub.add_parser( + "sweep", + help="Configure a bounded frequency-only Step Sweep while RF output is OFF", + ) + rf_source_sweep_sub = rf_source_sweep.add_subparsers( + dest="sweep_command", + required=True, + ) + rf_source_sweep_configure = rf_source_sweep_sub.add_parser( + "configure", + help="Configure a disabled Step Sweep without arming, firing, or triggering", + ) + rf_source_sweep_configure.add_argument("--port", required=True) + rf_source_sweep_configure.add_argument("--start-frequency-hz", type=float, required=True) + rf_source_sweep_configure.add_argument("--stop-frequency-hz", type=float, required=True) + rf_source_sweep_configure.add_argument("--points", type=int, required=True) + rf_source_sweep_configure.add_argument("--dwell-s", type=float, required=True) + add_runtime_options(rf_source_sweep_configure) + source_sub = source_parser.add_subparsers(dest="command", required=True) source_idn = source_sub.add_parser("idn", help="Query source *IDN?") diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 53a6f73..457eb16 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -26,6 +26,7 @@ "rf_source.set_power_dbm", "rf_source.modulation_configure", "rf_source.pulse_configure", + "rf_source.sweep_configure", "rf_source.output_enable", "rf_source.output_disable", "source.set_freq", @@ -79,6 +80,13 @@ "internal_frequency_hz", ), "rf_source.pulse_configure": ("port_id", "period_s", "width_s", "polarity"), + "rf_source.sweep_configure": ( + "port_id", + "start_frequency_hz", + "stop_frequency_hz", + "points", + "dwell_s", + ), "rf_source.output_enable": ("port_id",), "rf_source.output_disable": ("port_id",), "source.basic_configure_v2": ("channel",), @@ -197,6 +205,7 @@ "on_failure", }, "rf_source.pulse_configure": set(), + "rf_source.sweep_configure": set(), "rf_source.output_enable": {"on_failure"}, "rf_source.output_disable": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, @@ -262,6 +271,7 @@ "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", "rf_source.pulse_configure": "Configure one OFF RF port with a disabled internal single-pulse profile; it does not enable RF output or trigger a pulse.", + "rf_source.sweep_configure": "Configure one OFF RF port with a disabled frequency-only Step Sweep profile; it does not arm, fire, trigger, or enable RF output.", "rf_source.output_enable": "Enable one RF port only after a fresh safety snapshot confirms the configured load, frequency, power, and inactive modulation, Pulse, Sweep, and blocking protection conditions.", "rf_source.output_disable": "Disable one RF port and confirm OFF without requiring frequency, power, or protection readback.", "source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.", @@ -707,6 +717,27 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["period_s"] = period_s fields["width_s"] = width_s fields["polarity"] = polarity + elif kind == "rf_source.sweep_configure": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + start_frequency_hz = _positive_float( + fields["start_frequency_hz"], + f"{prefix}.start_frequency_hz", + ) + stop_frequency_hz = _positive_float( + fields["stop_frequency_hz"], + f"{prefix}.stop_frequency_hz", + ) + if start_frequency_hz >= stop_frequency_hz: + raise ConfigError( + f"{prefix}.start_frequency_hz must be less than stop_frequency_hz" + ) + points = fields["points"] + if isinstance(points, bool) or not isinstance(points, int) or points < 2: + raise ConfigError(f"{prefix}.points must be an integer >= 2") + fields["start_frequency_hz"] = start_frequency_hz + fields["stop_frequency_hz"] = stop_frequency_hz + fields["points"] = points + fields["dwell_s"] = _positive_float(fields["dwell_s"], f"{prefix}.dwell_s") elif kind in {"rf_source.output_enable", "rf_source.output_disable"}: fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") elif kind == "source.arb_load": diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 22b294d..feee6ae 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -26,6 +26,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.set_power_dbm", "rf_source.modulation_configure", "rf_source.pulse_configure", + "rf_source.sweep_configure", "rf_source.output_enable", "rf_source.output_disable", "source.set_freq", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 2d59100..57412f5 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -29,6 +29,7 @@ RfOutputRequest, RfPulseConfigureRequest, RfPulsePolarity, + RfSweepConfigureRequest, rf_source_snapshot_operation_artifact, ) from wavebench.instruments.source_extensions import ( @@ -307,6 +308,7 @@ def _check_rf_source_access(self, plan: RunPlan) -> None: "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", "rf_source.pulse_configure": "rf_source.pulse_configure", + "rf_source.sweep_configure": "rf_source.sweep_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", } @@ -458,6 +460,8 @@ def add_source_output_gate_capability() -> None: add("rf_source", "rf_source.snapshot", "rf_source.modulation_configure") elif step.kind == "rf_source.pulse_configure": add("rf_source", "rf_source.snapshot", "rf_source.pulse_configure") + elif step.kind == "rf_source.sweep_configure": + add("rf_source", "rf_source.snapshot", "rf_source.sweep_configure") elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: add("rf_source", "rf_source.snapshot", "rf_source.output") elif step.kind == "source.set_freq": @@ -1275,6 +1279,20 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.sweep_configure": + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_sweep_with_artifact( + RfSweepConfigureRequest( + port_id=fields["port_id"], + start_frequency_hz=fields["start_frequency_hz"], + stop_frequency_hz=fields["stop_frequency_hz"], + points=fields["points"], + dwell_s=fields["dwell_s"], + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: _, rf_source_operation = self._rf_source_service( services=services diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index 2e4b971..cb9f05f 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -18,10 +18,12 @@ RfPulseConfigureRequest, RfPulseConfigureResult, RfPulsePolarity, + RfSweepConfigureRequest, + RfSweepConfigureResult, ) -def test_rf_source_parser_accepts_cw_modulation_pulse_and_output_commands() -> None: +def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands() -> None: identity = build_parser().parse_args( ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] ) @@ -89,6 +91,23 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_and_output_commands() -> N "inverted", ] ) + sweep = build_parser().parse_args( + [ + "rf-source", + "sweep", + "configure", + "--port", + "rf_out", + "--start-frequency-hz", + "1000000", + "--stop-frequency-hz", + "2000000", + "--points", + "11", + "--dwell-s", + "0.02", + ] + ) assert (identity.domain, identity.command) == ("rf-source", "idn") assert identity.config == "rf.toml" @@ -117,6 +136,15 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_and_output_commands() -> N assert pulse.period_s == 0.001 assert pulse.width_s == 0.0001 assert pulse.polarity == "inverted" + assert (sweep.domain, sweep.command, sweep.sweep_command) == ( + "rf-source", + "sweep", + "configure", + ) + assert sweep.start_frequency_hz == 1_000_000.0 + assert sweep.stop_frequency_hz == 2_000_000.0 + assert sweep.points == 11 + assert sweep.dwell_s == 0.02 def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: @@ -353,6 +381,56 @@ def test_rf_source_cli_dispatches_disabled_internal_single_pulse_request() -> No ) +def test_rf_source_cli_dispatches_disabled_frequency_only_step_sweep_request() -> None: + service = Mock() + service.configure_sweep.return_value = RfSweepConfigureResult( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert main( + [ + "--json", + "rf-source", + "sweep", + "configure", + "--port", + "rf_out", + "--start-frequency-hz", + "1000000", + "--stop-frequency-hz", + "2000000", + "--points", + "11", + "--dwell-s", + "0.02", + ] + ) == 0 + + payload = json.loads(stdout.getvalue()) + assert payload["result"] == { + "port_id": "rf_out", + "start_frequency_hz": 1_000_000.0, + "stop_frequency_hz": 2_000_000.0, + "points": 11, + "dwell_s": 0.02, + } + service.configure_sweep.assert_called_once_with( + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + ) + + def test_rf_source_resource_override_does_not_touch_source_config() -> None: updated = object() config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index c7d762e..da2b88b 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -41,10 +41,18 @@ RfPulseSource, RfPulseState, RfSourceSnapshot, + RfSweepConfigureRequest, + RfSweepConfigureResult, + RfSweepDirection, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, RfSweepState, + RfSweepType, rf_source_cw_operation_artifact, rf_source_modulation_operation_artifact, rf_source_pulse_operation_artifact, + rf_source_sweep_operation_artifact, RfOutputRequest, RfOutputResult, rf_source_output_operation_artifact, @@ -139,6 +147,21 @@ def _pulse_plan(directory: str): return load_run_plan(path) +def _sweep_plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.sweep_configure"\n' + 'port_id = "rf_out"\n' + "start_frequency_hz = 1000000\n" + "stop_frequency_hz = 2000000\n" + "points = 11\n" + "dwell_s = 0.02\n", + encoding="utf-8", + ) + return load_run_plan(path) + + def _snapshot() -> RfSourceSnapshot: return RfSourceSnapshot( ports=( @@ -684,6 +707,157 @@ def _run_safety_guards(self, run_plan, *, services=None): assert run_data["rf_source_operations"] == [artifact] +def test_rf_source_sweep_plan_normalizes_the_disabled_frequency_only_subset() -> None: + with TemporaryDirectory() as directory: + plan = _sweep_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "start_frequency_hz": 1_000_000.0, + "stop_frequency_hz": 2_000_000.0, + "points": 11, + "dwell_s": 0.02, + } + + invalid_order_path = Path(directory) / "invalid-order.toml" + invalid_order_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.sweep_configure"\n' + 'port_id = "rf_out"\n' + "start_frequency_hz = 2000000\n" + "stop_frequency_hz = 1000000\n" + "points = 11\n" + "dwell_s = 0.02\n", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="less than stop_frequency_hz"): + load_run_plan(invalid_order_path) + + invalid_points_path = Path(directory) / "invalid-points.toml" + invalid_points_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.sweep_configure"\n' + 'port_id = "rf_out"\n' + "start_frequency_hz = 1000000\n" + "stop_frequency_hz = 2000000\n" + "points = 1\n" + "dwell_s = 0.02\n", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="points must be an integer >= 2"): + load_run_plan(invalid_points_path) + + +def test_rf_source_sweep_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.sweep_configure"): + service.run(_sweep_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_sweep_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_sweep_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_sweep_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _sweep_plan(directory) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.sweep_configure" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "start_frequency_hz": 1_000_000.0, + "stop_frequency_hz": 2_000_000.0, + "points": 11, + "dwell_s": 0.02, + } + + request = RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + result_value = RfSweepConfigureResult( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + preflight_snapshot = _snapshot() + postcondition_snapshot = _snapshot() + postcondition_sweep_snapshot = RfSweepSnapshot( + port_id="rf_out", + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + state=RfSweepState.DISABLED, + ) + artifact = rf_source_sweep_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_sweep_snapshot=postcondition_sweep_snapshot, + ) + rf_service = SimpleNamespace( + configure_sweep_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_sweep_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + def test_rf_source_output_step_requires_capability_before_opening_a_session() -> None: with TemporaryDirectory() as directory: service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) From ee84148d0edbfe0dd4853c145d09ebc3f0edb1ce Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:10:23 +0800 Subject: [PATCH 40/63] docs: record RF step sweep offline boundary --- README.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 15 ++++---- ...67\346\272\220\350\256\276\350\256\241.md" | 27 +++++++++------ ...77\347\224\250\346\214\207\345\215\227.md" | 34 +++++++++++++++++-- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index daa2918..0ee802b 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置,以及具有完整 safety 配置的 `rf_out` ON/OFF。内部正弦调制的 A4 验证已通过 AM、FM 的 RF-OFF 序列,但 PM 仍有严格读回不匹配;由于 capability 覆盖三种模式,调制、Pulse、Sweep 和触发继续保持关闭。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、具有完整 safety 配置的 `rf_out` ON/OFF,以及 RF-OFF internal/single Pulse 配置。frequency-only Step Sweep 的 Core 合同、CLI、run step 与 DSG830 离线映射已经完成,但 production descriptor 尚未声明 `rf_source.sweep_configure`;当前设备仍拒绝该写入,且不提供 arm、fire、trigger 或 Level Sweep。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index e725bd7..8baf880 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 已提升 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 的 A4 已通过;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 和 `rf_source.pulse_configure`。 | -| 真实仪器证据 | A1、A2、A3、A4 Pulse 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 Pulse 提升 OFF-only Pulse 配置。 | +| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 已提升,Step Sweep 离线完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 与 frequency-only Step Sweep 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 的 A4 已通过,Step Sweep 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 和 `rf_source.pulse_configure`。 | +| 真实仪器证据 | A1、A2、A3、A4 Pulse 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;Step Sweep 尚无专项证据,A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 Pulse 提升 OFF-only Pulse 配置。 | ## 双仓库交付规则 @@ -32,7 +32,7 @@ | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | -| M4(Step Sweep) | 未开始 | frequency-only Step Sweep 合同 | 待实现 | trigger/fire 只能由专项安全规则与实机证据提升。 | +| M4(Step Sweep) | 离线完成;专项 A4 证据未开始 | frequency-only Step Sweep 合同、CLI、run 与 artifact | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;production capability 仍关闭。 | ## Seed:历史种子包 @@ -59,7 +59,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor。M3/M4 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 Pulse 已将 `rf_source.pulse_configure` 提升到同一 descriptor。M3 与 Step Sweep capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 ### 离线完成条件 @@ -109,7 +109,9 @@ Pulse 只覆盖 `rf_out` 的 internal/single 子集。request 只包含 period 源码 checkout 的 `tools/a4_pulse_evidence.py` 与资源无关的 setup 模板完成了 A4 Pulse 受控验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确的 production descriptor 和 50 Ω 端接声明;显式 `--execute` 才在内存中建立临时 `read_write` descriptor。两种已声明 polarity 都通过初始 snapshot、一次 Pulse 配置、独立配置读回和最终 snapshot,均为 38 次 query、6 次配置 write,并确认 RF 与 Pulse 仍关闭。`--diagnose` 保持 `read_only`,固定 22 次 query、零 write。两种模式都不读取 scope、不使用 CH1/CH2、不发送 trigger,证据以 `0600` 保存且不包含资源或原始响应。证据复核后,DSG830 production descriptor 已声明 `rf_source.pulse_configure`;historical harness 现在会拒绝重跑。 -Step Sweep、Pulse trigger、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 +frequency-only Step Sweep 已完成离线合同。Core 的 request 只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`;Service 在写前和写后要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读取完整 Sweep profile 并要求状态仍为 disabled。DSG830 driver 只查询/写入 Step Sweep profile,并固定以 `:SWE:STAT OFF` 收尾;不发送 `:SWE:EXEC`、`*TRG`、任意 `:TRIG:*`、`:SWE:STAT FREQ`、Level Sweep、list、RF 输出或后面板接口命令。CLI 与 `rf_source.sweep_configure` run step 已接入,但当前 DSG830 production descriptor 不声明 capability,因此普通上机请求会在 transport I/O 前拒绝。 + +Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 ## A1–A5:实机证据门 @@ -177,3 +179,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成 A4 capability 提升证据。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 +7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 离线合同、固定 SCPI 映射、CLI、run、artifact 与 fake 回归;下一步是独立的零写诊断和受控证据工具。没有专项 evidence 前,不进行实机 Sweep 写入,也不提升 `rf_source.sweep_configure`。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 2947c96..a48a3d6 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 配置的合同与控制入口;DSG830 已凭 A1/A2/A3 和 A4 Pulse 证据开放 snapshot、OFF-only CW、受 safety 限制的 output 与 RF-OFF Pulse 配置。M3 仍须通过覆盖 PM 的实机证据门,不能由离线代码替代。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3 和 A4 Pulse 证据开放 snapshot、OFF-only CW、受 safety 限制的 output 与 RF-OFF Pulse 配置。Step Sweep 仅完成离线实现,M3 仍须通过覆盖 PM 的实机证据门,二者都不能由离线代码替代。 阅读顺序如下: @@ -15,9 +15,9 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM 和 internal/single Pulse 配置映射;A1/A2/A3/A4 Pulse 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output` 和 RF-OFF `rf_source.pulse_configure`;M3 与 Step Sweep 写 capability 仍关闭。 | -| 实机证据 | A1、A2、A3 和 A4 Pulse 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A5 未开始。 | M3 capability 覆盖三种模式;Step Sweep 仍等待实现与证据,DSG830 production descriptor 已开放 RF-OFF Pulse 配置。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 Pulse 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output` 和 RF-OFF `rf_source.pulse_configure`;M3 与 Step Sweep 写 capability 仍关闭。 | +| 实机证据 | A1、A2、A3 和 A4 Pulse 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;Step Sweep 尚无专项证据,A5 未开始。 | M3 capability 覆盖三种模式;DSG830 production descriptor 已开放 RF-OFF Pulse 配置,Step Sweep 仍等待专项证据。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -48,7 +48,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;DSG830 已由 A3 将其提升到 production。 - M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 -- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;其中 M4 当前只完成 internal/single Pulse 配置子集。 +- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;M4 当前完成 internal/single Pulse 与保持 Sweep disabled 的 frequency-only Step Sweep 配置子集。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 ### 明确不做 @@ -332,17 +332,22 @@ rf_source.modulation_configure wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted rf_source.pulse_configure -# Pulse trigger 与 Step Sweep 仍是目标合同,尚未进入当前 Core schema +# Pulse trigger、Sweep arm/fire/stop 与 Level Sweep 仍是目标合同,尚未进入当前 Core schema wavebench rf-source pulse trigger ... -wavebench rf-source sweep configure ... wavebench rf-source sweep arm ... wavebench rf-source sweep fire ... wavebench rf-source sweep stop ... + +# M4 Step Sweep:仅配置 frequency-only、forward、linear、ramp profile,配置后 Sweep 仍保持关闭 +wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS +rf_source.sweep_configure ``` M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 -`rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。Pulse trigger、Step Sweep 及其 arm/fire/stop 仍是目标合同,尚未进入当前 schema。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 +`rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 + +`rf_source.sweep_configure` 也已进入当前 Core schema,但仅表示离线合同:请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。当前 DSG830 production descriptor 不声明该 capability,因此普通 CLI 或 run plan 会在 transport I/O 前拒绝。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 ## M0–M4 里程碑 @@ -355,7 +360,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(离线完成;A4 的 AM、FM 已通过,PM 待定位) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待完整 A4。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | -| M4(Step Sweep) | frequency-only Step Sweep profile、configure/arm/fire/stop | 待实现 | 仅在固定 trigger、安全规则和实机证据具备后进入实现;fire/trigger 先只由 fake descriptor 覆盖。 | +| M4(Step Sweep;离线完成) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。production capability 等待专项 A4 证据。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 @@ -378,7 +383,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 与 `rf_source.pulse_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。历史 A4 Pulse harness 在 descriptor 提升后拒绝重跑,普通使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。它不读取 scope、不调用 RF output、不使用 Pulse I/O 或 trigger。A4 尚未提升 `rf_source.modulation_configure`、`rf_source.modulation_disable`,严格 parser 与既有证据也不开放 Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 与 `rf_source.pulse_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 已实现仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并固定以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出,且尚无 production capability。历史 A4 Pulse harness 在 descriptor 提升后拒绝重跑,普通 Pulse 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。A4 尚未提升 `rf_source.modulation_configure`、`rf_source.modulation_disable`,严格 parser 与既有证据也不开放 Step Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -395,5 +400,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。A4–A5 仍不能据此提升调制、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;production descriptor 未改变。A4–A5 仍不能据此提升调制、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index bfe65de..0476e64 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -24,7 +24,8 @@ | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | | 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无覆盖三种模式的完整合格证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | -| Sweep、trigger | 未完成 | 未开放 | 不应尝试调用或绕过。 | +| frequency-only Step Sweep | M4 离线合同、CLI、run step 与 artifact 已完成 | 未开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;当前 DSG830 会在 transport I/O 前拒绝该 capability。 | +| trigger、arm/fire、Level Sweep | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -124,6 +125,20 @@ width_s = 0.0001 polarity = "normal" ``` +`rf_source.sweep_configure` 已进入 schema,但当前只用于离线 fake descriptor、开发验证或未来取得专项证据的插件: + +```toml +[[steps]] +kind = "rf_source.sweep_configure" +port_id = "rf_out" +start_frequency_hz = 1000000 +stop_frequency_hz = 2000000 +points = 11 +dwell_s = 0.02 +``` + +它只配置 frequency-only Step Sweep,不会 arm、fire、触发、执行 `SWE:EXEC`、切换 RF 输出或配置 Level Sweep。当前 DSG830 production descriptor 未声明 `rf_source.sweep_configure`,因此不应将这段 plan 用于该设备的日常上机操作。 + 先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 ## M3:内部正弦调制合同 @@ -164,7 +179,7 @@ DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令 M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 -## M4:受控 Pulse 配置合同 +## M4:受控 Pulse 与 Step Sweep 配置合同 Core 已提供下列离线入口: @@ -177,12 +192,25 @@ DSG830 production descriptor 已声明 `rf_source.pulse_configure`。普通 CLI 源码 checkout 的 `tools/a4_pulse_evidence.py` 是已完成的受控验收工具。它只接受 internal/single、period、width 和 polarity,并在每次配置后保持 Pulse OFF;初始、写后和最终状态都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection。它不调用 RF output、不使用后面板 Pulse I/O、不发送 trigger、不读取 CH1/CH2。`--diagnose` 保持 `read_only` 且零写,`--execute` 才允许一次受审计的配置写入。两种 polarity 的证据均通过并经复核,DSG830 已开放该 capability;historical harness 在提升后会拒绝重跑。 +### frequency-only Step Sweep + +Core 已提供下列离线入口: + +```text +wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS +rf_source.sweep_configure +``` + +该子集固定为 `STEP`/`FWD`/`RAMP`/`LIN`,请求只包含起止频率、点数和驻留时间。写前与写后均要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;写后独立读回所有 profile 字段,并要求 Sweep 仍为 disabled。DSG830 driver 固定以 `:SWE:STAT OFF` 收尾,不会发送 `:SWE:EXEC`、任意 `:TRIG:*`、Level Sweep、RF 输出或后面板接口命令。 + +这不是 production 使用授权。DSG830 production descriptor 尚未声明 `rf_source.sweep_configure`,当前 CLI 和 run plan 对该设备会在打开 transport 前被 capability 门拒绝。专项 evidence harness、实机证据和后续 descriptor 提升完成前,Step Sweep 不上机;CH2 的 50 Ω 端接不改变这一边界。 + ## 上机前检查清单 1. 使用网络发现和只读身份查询确认候选设备,再在隔离配置中复核资源与型号。 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Sweep、trigger 或 scope 自动量程。A4 Pulse 仅可通过专用受控 harness 执行。 +5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、未获证据的 Step Sweep、trigger 或 scope 自动量程。A4 Pulse 仅可通过专用受控 harness 执行。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 From fafd93792c46c8ceab05ae6a883b5a0f50e056a0 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:21:23 +0800 Subject: [PATCH 41/63] docs: describe RF step sweep evidence boundary --- ...\200\345\217\221\351\207\214\347\250\213\347\242\221.md" | 6 ++++-- ...\241\345\217\267\346\272\220\350\256\276\350\256\241.md" | 2 +- ...\220\344\275\277\347\224\250\346\214\207\345\215\227.md" | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 8baf880..1b0e71a 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -32,7 +32,7 @@ | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | -| M4(Step Sweep) | 离线完成;专项 A4 证据未开始 | frequency-only Step Sweep 合同、CLI、run 与 artifact | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;production capability 仍关闭。 | +| M4(Step Sweep) | 离线完成;专项 harness 已完成,实机证据未开始 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;production capability 仍关闭。 | ## Seed:历史种子包 @@ -111,6 +111,8 @@ Pulse 只覆盖 `rf_out` 的 internal/single 子集。request 只包含 period frequency-only Step Sweep 已完成离线合同。Core 的 request 只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`;Service 在写前和写后要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读取完整 Sweep profile 并要求状态仍为 disabled。DSG830 driver 只查询/写入 Step Sweep profile,并固定以 `:SWE:STAT OFF` 收尾;不发送 `:SWE:EXEC`、`*TRG`、任意 `:TRIG:*`、`:SWE:STAT FREQ`、Level Sweep、list、RF 输出或后面板接口命令。CLI 与 `rf_source.sweep_configure` run step 已接入,但当前 DSG830 production descriptor 不声明 capability,因此普通上机请求会在 transport I/O 前拒绝。 +源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与资源无关 setup 模板已完成离线回归。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确 production descriptor 和人工确认的 50 Ω 端接。`--diagnose` 保持 `read_only`,读取初始/最终 RF snapshot 与完整 Step Sweep profile,成功路径固定为 25 次 query、零 write;显式 `--execute` 才在内存中建立受限 `read_write` descriptor,成功路径固定为初始 snapshot、一次配置、独立 profile readback、最终 snapshot,共 41 次 query、9 条配置 write。两条路径都不读取 scope、不调用 RF output、不执行 arm/fire/trigger,且证据以 `0600` 保存。工具尚未取得实机证据,不能提升 capability。 + Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 ## A1–A5:实机证据门 @@ -179,4 +181,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成 A4 capability 提升证据。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 -7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 离线合同、固定 SCPI 映射、CLI、run、artifact 与 fake 回归;下一步是独立的零写诊断和受控证据工具。没有专项 evidence 前,不进行实机 Sweep 写入,也不提升 `rf_source.sweep_configure`。 +7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 离线合同、固定 SCPI 映射、CLI、run、artifact、fake 回归与独立 evidence harness。下一步先执行零写诊断,再按专项授权决定是否进行受控配置;没有合格实机 evidence 前,不提升 `rf_source.sweep_configure`。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index a48a3d6..dcc2a22 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -400,5 +400,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;production descriptor 未改变。A4–A5 仍不能据此提升调制、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。Step Sweep 尚无实机证据,production descriptor 未改变。A4–A5 仍不能据此提升调制、Sweep 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 0476e64..f507562 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -203,7 +203,7 @@ rf_source.sweep_configure 该子集固定为 `STEP`/`FWD`/`RAMP`/`LIN`,请求只包含起止频率、点数和驻留时间。写前与写后均要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;写后独立读回所有 profile 字段,并要求 Sweep 仍为 disabled。DSG830 driver 固定以 `:SWE:STAT OFF` 收尾,不会发送 `:SWE:EXEC`、任意 `:TRIG:*`、Level Sweep、RF 输出或后面板接口命令。 -这不是 production 使用授权。DSG830 production descriptor 尚未声明 `rf_source.sweep_configure`,当前 CLI 和 run plan 对该设备会在打开 transport 前被 capability 门拒绝。专项 evidence harness、实机证据和后续 descriptor 提升完成前,Step Sweep 不上机;CH2 的 50 Ω 端接不改变这一边界。 +这不是 production 使用授权。DSG830 源码 checkout 已提供 `tools/a4_step_sweep_evidence.py` 和无资源 setup 模板:`--diagnose` 保持 `read_only`,固定 25 次查询、零写入;显式 `--execute` 才允许一次受审计的配置,成功路径固定为 41 次查询、9 条 Step Sweep 配置写入。两条路径都不读取 Scope、不操作 RF output、arm、fire 或 trigger。该工具已通过 fake 回归,但尚无实机证据。DSG830 production descriptor 仍未声明 `rf_source.sweep_configure`,当前 CLI 和 run plan 对该设备会在打开 transport 前被 capability 门拒绝;除专项 evidence harness 的明示授权外,Step Sweep 不通过普通 CLI 或 run plan 上机。CH2 的 50 Ω 端接不改变这一边界。 ## 上机前检查清单 From 54bcd53c6d4bbbc853fc054d39bd15509be68612 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:55:28 +0800 Subject: [PATCH 42/63] docs: promote DSG830 step sweep boundary --- README.md | 2 +- docs/README_EN.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 18 +++++++-------- ...67\346\272\220\350\256\276\350\256\241.md" | 22 +++++++++---------- ...01\347\250\213\350\256\276\350\256\241.md" | 6 ++++- ...07\346\212\275\350\261\241\345\261\202.md" | 6 ++--- ...71\347\233\256\350\276\271\347\225\214.md" | 4 ++-- .../WaveBench_CLI\345\275\242\346\200\201.md" | 9 ++++---- ...77\347\224\250\346\214\207\345\215\227.md" | 15 +++++++------ ...77\347\224\250\346\214\207\345\215\227.md" | 19 ++++++++++++++-- ...07\344\273\266\346\240\274\345\274\217.md" | 6 ++--- ...345\231\250\346\217\222\344\273\266API.md" | 14 +++++++----- 12 files changed, 73 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 0ee802b..fc9040a 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、具有完整 safety 配置的 `rf_out` ON/OFF,以及 RF-OFF internal/single Pulse 配置。frequency-only Step Sweep 的 Core 合同、CLI、run step 与 DSG830 离线映射已经完成,但 production descriptor 尚未声明 `rf_source.sweep_configure`;当前设备仍拒绝该写入,且不提供 arm、fire、trigger 或 Level Sweep。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled;它不提供 execute、arm、fire、trigger、Level Sweep 或 list。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git a/docs/README_EN.md b/docs/README_EN.md index ec29389..98e5442 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -59,7 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) -- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M2 contracts; DSG830 A1/A2/A3 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, and safety-gated `rf_source.output` ON/OFF, while later RF writes remain gated. +- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M4 contracts; DSG830 A1/A2/A3/A4 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, safety-gated `rf_source.output` ON/OFF, internal/single Pulse configuration, and frequency-only Step Sweep configuration that remains disabled. Modulation, triggers, Sweep execution, Level Sweep, and list control remain gated. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 1b0e71a..7711e56 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 已提升,Step Sweep 离线完成 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 与 frequency-only Step Sweep 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 的 A4 已通过,Step Sweep 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 和 `rf_source.pulse_configure`。 | -| 真实仪器证据 | A1、A2、A3、A4 Pulse 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;Step Sweep 尚无专项证据,A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 Pulse 提升 OFF-only Pulse 配置。 | +| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 与 Step Sweep 已提升 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 与 frequency-only Step Sweep 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | +| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 与 Step Sweep 的 A4 已通过;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`。 | +| 真实仪器证据 | A1、A2、A3、A4 Pulse、A4 Step Sweep 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。 | ## 双仓库交付规则 @@ -32,7 +32,7 @@ | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | -| M4(Step Sweep) | 离线完成;专项 harness 已完成,实机证据未开始 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;production capability 仍关闭。 | +| M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | ## Seed:历史种子包 @@ -59,7 +59,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 Pulse 已将 `rf_source.pulse_configure` 提升到同一 descriptor。M3 与 Step Sweep capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 Pulse 与 A4 Step Sweep 已分别将 `rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor。M3 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 ### 离线完成条件 @@ -71,7 +71,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收和 A3 受控实机证据均已完成;DSG830 production 已开放 CW capability。 -DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;调制、Pulse、Sweep 与 trigger 仍继续关闭。 +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;调制 capability、Sweep execute/fire、trigger 与 Level Sweep 仍继续关闭。 ## M2:RF 输出安全事务 @@ -109,9 +109,9 @@ Pulse 只覆盖 `rf_out` 的 internal/single 子集。request 只包含 period 源码 checkout 的 `tools/a4_pulse_evidence.py` 与资源无关的 setup 模板完成了 A4 Pulse 受控验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确的 production descriptor 和 50 Ω 端接声明;显式 `--execute` 才在内存中建立临时 `read_write` descriptor。两种已声明 polarity 都通过初始 snapshot、一次 Pulse 配置、独立配置读回和最终 snapshot,均为 38 次 query、6 次配置 write,并确认 RF 与 Pulse 仍关闭。`--diagnose` 保持 `read_only`,固定 22 次 query、零 write。两种模式都不读取 scope、不使用 CH1/CH2、不发送 trigger,证据以 `0600` 保存且不包含资源或原始响应。证据复核后,DSG830 production descriptor 已声明 `rf_source.pulse_configure`;historical harness 现在会拒绝重跑。 -frequency-only Step Sweep 已完成离线合同。Core 的 request 只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`;Service 在写前和写后要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读取完整 Sweep profile 并要求状态仍为 disabled。DSG830 driver 只查询/写入 Step Sweep profile,并固定以 `:SWE:STAT OFF` 收尾;不发送 `:SWE:EXEC`、`*TRG`、任意 `:TRIG:*`、`:SWE:STAT FREQ`、Level Sweep、list、RF 输出或后面板接口命令。CLI 与 `rf_source.sweep_configure` run step 已接入,但当前 DSG830 production descriptor 不声明 capability,因此普通上机请求会在 transport I/O 前拒绝。 +frequency-only Step Sweep 的生产子集只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`;Service 在写前和写后要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读取完整 Sweep profile 并要求状态仍为 disabled。DSG830 driver 只查询/写入 Step Sweep profile,并固定以 `:SWE:STAT OFF` 收尾;不发送 `:SWE:EXEC`、`*TRG`、任意 `:TRIG:*`、`:SWE:STAT FREQ`、Level Sweep、list、RF 输出或后面板接口命令。A4 Step Sweep 证据通过后,DSG830 production descriptor 声明 `rf_source.sweep_configure`;普通 CLI 和 run step 仍要求 `read_write`、profile 匹配和 fresh OFF-only preflight。 -源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与资源无关 setup 模板已完成离线回归。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确 production descriptor 和人工确认的 50 Ω 端接。`--diagnose` 保持 `read_only`,读取初始/最终 RF snapshot 与完整 Step Sweep profile,成功路径固定为 25 次 query、零 write;显式 `--execute` 才在内存中建立受限 `read_write` descriptor,成功路径固定为初始 snapshot、一次配置、独立 profile readback、最终 snapshot,共 41 次 query、9 条配置 write。两条路径都不读取 scope、不调用 RF output、不执行 arm/fire/trigger,且证据以 `0600` 保存。工具尚未取得实机证据,不能提升 capability。 +源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与资源无关 setup 模板已完成离线回归和实机验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确 production descriptor 和人工确认的 50 Ω 端接。`--diagnose` 保持 `read_only`,读取初始/最终 RF snapshot 与完整 Step Sweep profile,成功路径固定为 25 次 query、零 write;显式 `--execute` 才在内存中建立受限 `read_write` descriptor,成功路径固定为初始 snapshot、一次配置、独立 profile readback、最终 snapshot,共 41 次 query、9 条配置 write。两条路径都不读取 scope、不调用 RF output、不执行 arm/fire/trigger,且证据以 `0600` 保存。诊断与受控配置序列均通过,最终独立复核 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;historical harness 在 capability 提升后拒绝重跑。 Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 @@ -181,4 +181,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成 A4 capability 提升证据。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 -7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 离线合同、固定 SCPI 映射、CLI、run、artifact、fake 回归与独立 evidence harness。下一步先执行零写诊断,再按专项授权决定是否进行受控配置;没有合格实机 evidence 前,不提升 `rf_source.sweep_configure`。 +7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index dcc2a22..0e5da7f 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3 和 A4 Pulse 证据开放 snapshot、OFF-only CW、受 safety 限制的 output 与 RF-OFF Pulse 配置。Step Sweep 仅完成离线实现,M3 仍须通过覆盖 PM 的实机证据门,二者都不能由离线代码替代。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3、A4 Pulse 和 A4 Step Sweep 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3 仍须通过覆盖 PM 的实机证据门;离线代码不能替代相应 capability 的实机证据。 阅读顺序如下: @@ -16,12 +16,12 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 Pulse 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output` 和 RF-OFF `rf_source.pulse_configure`;M3 与 Step Sweep 写 capability 仍关闭。 | -| 实机证据 | A1、A2、A3 和 A4 Pulse 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;Step Sweep 尚无专项证据,A5 未开始。 | M3 capability 覆盖三种模式;DSG830 production descriptor 已开放 RF-OFF Pulse 配置,Step Sweep 仍等待专项证据。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 Pulse/A4 Step Sweep 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`;M3 仍关闭。 | +| 实机证据 | A1、A2、A3、A4 Pulse 和 A4 Step Sweep 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;M3 capability 仍须覆盖三种模式。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「A2 已提升」「A3 已提升」「A4 Pulse 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M4 Pulse 已由 A4 提升,M3 仍未覆盖 PM。 +除明确标为「生产只读」「A2 已提升」「A3 已提升」「A4 Pulse/Step Sweep 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M4 Pulse 和 Step Sweep 已由 A4 分别提升,M3 仍未覆盖 PM。 ## 术语与证据级别 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。M3 已完成 Core 与 DSG830 的离线实现;A4–A5 实机验收、其它 production 写 capability 声明和发行包推广仍另行处理,离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 Pulse/A4 Step Sweep 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。M3 已完成 Core 与 DSG830 的离线实现;其它 A4–A5 实机验收、production 写 capability 声明和发行包推广仍另行处理,离线代码不能替代这些证据。 ## 范围与非目标 @@ -57,7 +57,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 - 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 - 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 -- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 Pulse 或 Step Sweep 配置 capability。 ## 分层与职责 @@ -347,7 +347,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 `rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 -`rf_source.sweep_configure` 也已进入当前 Core schema,但仅表示离线合同:请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。当前 DSG830 production descriptor 不声明该 capability,因此普通 CLI 或 run plan 会在 transport I/O 前拒绝。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 +`rf_source.sweep_configure` 已进入当前 Core schema;DSG830 已在 A4 Step Sweep 证据复核后声明该 capability。请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 ## M0–M4 里程碑 @@ -360,11 +360,11 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(离线完成;A4 的 AM、FM 已通过,PM 待定位) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待完整 A4。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | -| M4(Step Sweep;离线完成) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。production capability 等待专项 A4 证据。 | +| M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`。A4 的 AM、FM RF-OFF 单模式验证已通过并完成关闭恢复;PM 仍有严格读回不匹配,因此尚未形成可提升整体调制 capability 的合格证据。A4、A5 仍分别是调制/Pulse/Sweep、外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。A4 的 AM、FM RF-OFF 单模式验证已通过并完成关闭恢复;PM 仍有严格读回不匹配,因此尚未形成可提升整体调制 capability 的合格证据。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -383,7 +383,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output` 与 `rf_source.pulse_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 已实现仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并固定以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出,且尚无 production capability。历史 A4 Pulse harness 在 descriptor 提升后拒绝重跑,普通 Pulse 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。A4 尚未提升 `rf_source.modulation_configure`、`rf_source.modulation_disable`,严格 parser 与既有证据也不开放 Step Sweep、fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse/A4 Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 Pulse 与 Step Sweep harness 在对应 descriptor 提升后拒绝重跑;普通 Pulse 与 Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。A4 尚未提升 `rf_source.modulation_configure`、`rf_source.modulation_disable`,既有证据也不开放 Sweep fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -400,5 +400,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse 完成后声明 - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。Step Sweep 尚无实机证据,production descriptor 未改变。A4–A5 仍不能据此提升调制、Sweep 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。该提升仅限保持 Sweep disabled 的配置,不提升调制、Sweep execute/fire 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index 6249d8e..d24034c 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -251,14 +251,18 @@ source.set_vpp source.set_duty source.output rf_source.status +rf_source.set_frequency +rf_source.set_power_dbm rf_source.output_enable rf_source.output_disable +rf_source.pulse_configure +rf_source.sweep_configure sleep ``` `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 -RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、CW 频率/功率步骤、`rf_source.output_enable`/`rf_source.output_disable` 和 M3 的 `rf_source.modulation_configure`;它们使用独立的类型化 RF artifact。DSG830 已完成 A1/A2/A3,production descriptor 声明 `rf_source.snapshot`、`rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`,因此 status 可在 `read_only` session 中读取快照,CW 与输出步骤仅在 `read_write`、相应 capability 和 fresh preflight 同时成立时执行。M3 已完成离线合同但 production capability 仍等待 A4;Pulse、Sweep 和 trigger 仍未开放。详见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md)、[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、CW 频率/功率步骤、`rf_source.output_enable`/`rf_source.output_disable`、M3 的 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;它们使用独立的类型化 RF artifact。DSG830 已完成 A1/A2/A3、A4 Pulse 和 A4 Step Sweep,production descriptor 声明 `rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,因此 status 可在 `read_only` session 中读取快照,生产写步骤仅在 `read_write`、相应 capability 和 fresh OFF-only preflight 同时成立时执行。Pulse 与 Step Sweep 只配置并保持 disabled;trigger、Sweep execute/fire、Level Sweep 与调制 capability 仍未开放。详见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md)、[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 `scope.capture` 可以额外声明: diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index cbdaf9e..36fe5ed 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -248,13 +248,13 @@ Service 层可以按以下顺序组合这些动作: 设置信号 → 等待稳定 → 采集波形 → 保存数据 → 计算指标 ``` -## RF 信号源:当前 M0–M3 与后续阶段 +## RF 信号源:当前 M0–M4 与后续阶段 上述 `SignalGenerator` 示例只描述普通函数/任意波形发生器。RF 信号源以频率、dBm 功率、RF 输出和稳定 `port_id` 为主,不能把它映射为普通 `SourceDriver` 的 Vpp、offset、数字 channel 或波形接口。 -当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、内部正弦 AM/FM/PM transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output`/`rf-source modulation configure-*` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 +当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、内部正弦 AM/FM/PM transaction、internal/single Pulse transaction、保持 Sweep disabled 的 Step Sweep transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output`/`rf-source modulation configure-*`/`rf-source pulse configure`/`rf-source sweep configure` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 -DSG830 已由 A1/A2/A3 将 snapshot、OFF-only `rf_source.cw_configure` 和受 safety 限制的 `rf_source.output` 提升到 production。调制已完成离线映射,但 `rf_source.modulation_configure` 仍等待 A4;Pulse、Sweep 与 trigger 仍待后续实现和对应证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +DSG830 已由 A1/A2/A3/A4 Pulse/A4 Step Sweep 将 snapshot、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure` 提升到 production。Pulse 与 Step Sweep 只允许已声明的配置 profile,且配置后保持 disabled;调制 capability、trigger、Sweep execute/fire 与 Level Sweep 仍待独立证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 早期目录示意 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index c163d44..40953a3 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,7 +19,7 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| RF 信号源 | M0–M3 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制合同、配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2/A3,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure` 和受 safety 限制的 `rf_source.output`;M3 capability 仍需 A4 证据 | +| RF 信号源 | M0–M4 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制合同、Pulse 与 Step Sweep 配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2/A3/A4 Pulse/A4 Step Sweep,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;M3 capability 仍需覆盖 PM 的 A4 证据 | | run plan | source、rf_source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样;RF 输出仍受 capability、access 和端口 safety 限制 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | @@ -29,7 +29,7 @@ WaveBench 优先解决以下问题: RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 -当前 Core 已提供 M0–M3 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`,并凭 A3 证据开放 OFF-only `rf_source.cw_configure`。M3 内部正弦调制已完成离线合同与 driver 映射,但 capability 仍等待 A4;Pulse、Sweep 与 trigger 继续等待对应 A4–A5 证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +当前 Core 已提供 M0–M4 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`、凭 A3 证据开放 OFF-only `rf_source.cw_configure`,并凭 A4 Pulse/Step Sweep 证据开放保持 disabled 的 `rf_source.pulse_configure` 与 `rf_source.sweep_configure`。M3 内部正弦调制已完成离线合同与 driver 映射,但 capability 仍等待覆盖 PM 的 A4 证据;trigger、Sweep execute/fire 与 Level Sweep 继续等待对应 A5 或独立证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 推荐工作顺序 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 1d281a9..3c6e685 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -87,18 +87,19 @@ wavebench rf-source status --config wavebench.toml wavebench rf-source set-frequency --port PORT_ID HZ --config wavebench.toml wavebench rf-source set-power --port PORT_ID DBM --config wavebench.toml wavebench rf-source output --port PORT_ID on|off --config wavebench.toml +wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted --config wavebench.toml +wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS --config wavebench.toml wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only wavebench capability explain rf_source.output_enable --driver rigol.dsg830 --kind rf_source --access read_write ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 -`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2/A3,并在 -production descriptor 中声明两个只读 capability、`rf_source.cw_configure` 与 `rf_source.output`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2/A3、A4 Pulse 和 A4 Step Sweep,并在 +production descriptor 中声明只读 capability、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw SCPI。M1 的 `set-frequency` 与 `set-power` 还要求 `rf_source.cw_configure`、`read_write` 访问、已声明的 CW profile 和完整的 OFF-only preflight。M2 的 `output` 要求 `rf_source.output`、 `read_write` 访问、可读 output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 -DSG830 对 OFF-only CW 与端口级 ON/OFF 路径开放 production 写入;缺少 `read_write`、安全配置或 fresh preflight 时仍在写入前拒绝。调制、 -Pulse 和 Sweep 写入命令仍不存在。 +DSG830 对 OFF-only CW、端口级 ON/OFF、internal/single Pulse 与固定 profile 的 Step Sweep 配置开放 production 写入;缺少 `read_write`、对应 profile 或 fresh preflight 时仍在写入前拒绝。Pulse 与 Step Sweep 配置完成后分别保持 Pulse/Sweep disabled;它们不提供 trigger、execute、arm、fire、Level Sweep 或 list。调制 capability 仍未开放。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index f507562..0097878 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -24,7 +24,7 @@ | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | | 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无覆盖三种模式的完整合格证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | -| frequency-only Step Sweep | M4 离线合同、CLI、run step 与 artifact 已完成 | 未开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;当前 DSG830 会在 transport I/O 前拒绝该 capability。 | +| frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | | trigger、arm/fire、Level Sweep | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -82,7 +82,7 @@ wavebench rf-source idn --config wavebench.toml wavebench rf-source status --config wavebench.toml ``` -在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW 和 RF 输出操作: +在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW、RF 输出、Pulse 和 Step Sweep 配置操作: ```bash wavebench rf-source set-frequency --config wavebench.toml --port rf_out 1000000 @@ -90,6 +90,7 @@ wavebench rf-source set-power --config wavebench.toml --port rf_out -40 wavebench rf-source output --config wavebench.toml --port rf_out on wavebench rf-source output --config wavebench.toml --port rf_out off wavebench rf-source pulse configure --config wavebench.toml --port rf_out --period-s 0.001 --width-s 0.0001 --polarity normal +wavebench rf-source sweep configure --config wavebench.toml --port rf_out --start-frequency-hz 1000000 --stop-frequency-hz 2000000 --points 11 --dwell-s 0.02 ``` `output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 @@ -125,7 +126,7 @@ width_s = 0.0001 polarity = "normal" ``` -`rf_source.sweep_configure` 已进入 schema,但当前只用于离线 fake descriptor、开发验证或未来取得专项证据的插件: +`rf_source.sweep_configure` 已进入 schema,并在 DSG830 的 A4 Step Sweep 证据复核后成为受限生产操作: ```toml [[steps]] @@ -137,7 +138,7 @@ points = 11 dwell_s = 0.02 ``` -它只配置 frequency-only Step Sweep,不会 arm、fire、触发、执行 `SWE:EXEC`、切换 RF 输出或配置 Level Sweep。当前 DSG830 production descriptor 未声明 `rf_source.sweep_configure`,因此不应将这段 plan 用于该设备的日常上机操作。 +它只配置 frequency-only Step Sweep,不会 arm、fire、触发、执行 `SWE:EXEC`、切换 RF 输出或配置 Level Sweep。DSG830 使用这段 plan 时仍须为 `read_write`,并通过 capability、profile 和 fresh OFF-only preflight;配置完成后必须读回 Sweep disabled。 先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 @@ -181,7 +182,7 @@ M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 ## M4:受控 Pulse 与 Step Sweep 配置合同 -Core 已提供下列离线入口: +Core 已提供下列生产入口;DSG830 已声明相应 capability: ```text wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted @@ -203,7 +204,7 @@ rf_source.sweep_configure 该子集固定为 `STEP`/`FWD`/`RAMP`/`LIN`,请求只包含起止频率、点数和驻留时间。写前与写后均要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;写后独立读回所有 profile 字段,并要求 Sweep 仍为 disabled。DSG830 driver 固定以 `:SWE:STAT OFF` 收尾,不会发送 `:SWE:EXEC`、任意 `:TRIG:*`、Level Sweep、RF 输出或后面板接口命令。 -这不是 production 使用授权。DSG830 源码 checkout 已提供 `tools/a4_step_sweep_evidence.py` 和无资源 setup 模板:`--diagnose` 保持 `read_only`,固定 25 次查询、零写入;显式 `--execute` 才允许一次受审计的配置,成功路径固定为 41 次查询、9 条 Step Sweep 配置写入。两条路径都不读取 Scope、不操作 RF output、arm、fire 或 trigger。该工具已通过 fake 回归,但尚无实机证据。DSG830 production descriptor 仍未声明 `rf_source.sweep_configure`,当前 CLI 和 run plan 对该设备会在打开 transport 前被 capability 门拒绝;除专项 evidence harness 的明示授权外,Step Sweep 不通过普通 CLI 或 run plan 上机。CH2 的 50 Ω 端接不改变这一边界。 +DSG830 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与无资源 setup 模板完成了专项实机验收:`--diagnose` 保持 `read_only`,固定 25 次查询、零写入;显式 `--execute` 才允许一次受审计的配置,成功路径固定为 41 次查询、9 条 Step Sweep 配置写入。两条路径都不读取 Scope、不操作 RF output、arm、fire 或 trigger。诊断与受控配置均通过,最终独立复核 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection,因此 DSG830 production descriptor 声明 `rf_source.sweep_configure`。该生产范围仍只限配置且保持 Sweep disabled;CH2 的 50 Ω 端接不改变 execute、trigger 或 RF 输出的边界。 ## 上机前检查清单 @@ -211,6 +212,6 @@ rf_source.sweep_configure 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、未获证据的 Step Sweep、trigger 或 scope 自动量程。A4 Pulse 仅可通过专用受控 harness 执行。 +5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 diff --git "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" index 8afcfc9..6b59edc 100644 --- "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -66,7 +66,7 @@ step 的 `OperationSpec`。`run plan --intent` 会在取得资源租约、打开 ## RF 信号源步骤 -RF 使用独立的 `rf_source.*` step,不使用普通 `source` 的 channel、Vpp、restore 或 safety 语义。当前 DSG830 production descriptor 已开放只读状态、OFF-only CW 和受 safety 限制的 RF 输出: +RF 使用独立的 `rf_source.*` step,不使用普通 `source` 的 channel、Vpp、restore 或 safety 语义。当前 DSG830 production descriptor 已开放只读状态、OFF-only CW、受 safety 限制的 RF 输出、internal/single Pulse 和保持 Sweep disabled 的 frequency-only Step Sweep 配置: ```toml [[steps]] @@ -86,9 +86,24 @@ port_id = "rf_out" [[steps]] kind = "rf_source.output_disable" port_id = "rf_out" + +[[steps]] +kind = "rf_source.pulse_configure" +port_id = "rf_out" +period_s = 0.001 +width_s = 0.0001 +polarity = "normal" + +[[steps]] +kind = "rf_source.sweep_configure" +port_id = "rf_out" +start_frequency_hz = 1000000 +stop_frequency_hz = 2000000 +points = 11 +dwell_s = 0.02 ``` -CW 步骤要求 RF 输出明确 OFF,且调制、Pulse、Sweep 与 protection 没有冲突。`rf_source.output_enable` 还会检查每端口安全配置、实际端接、频率、功率和 fresh snapshot;不满足时会在 ON 前拒绝。RF operation 的类型化 artifact 写入 `run.json.rf_source_operations`。 +CW、Pulse 和 Step Sweep 配置均要求 RF 输出明确 OFF,且调制、Pulse、Sweep 与 protection 没有冲突。Pulse 只支持 internal/single,配置后保持 disabled;Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,不 arm、fire、trigger、execute、配置 Level Sweep 或切换 RF 输出,配置后保持 disabled。`rf_source.output_enable` 还会检查每端口安全配置、实际端接、频率、功率和 fresh snapshot;不满足时会在 ON 前拒绝。RF operation 的类型化 artifact 写入 `run.json.rf_source_operations`。 Core 已在 schema 中提供 M3 的 `rf_source.modulation_configure`: diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index 980e274..0734088 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -449,10 +449,8 @@ actual_termination_ohm = 50 `[rf_source]` 是独立于普通 `[source]` 的 RF 信号源配置。它使用 plugin descriptor 的稳定 `port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。M0 提供 `wavebench rf-source idn` 与 `wavebench rf-source status`;后者要求 production descriptor 声明 -`rf_source.snapshot`。M1 的频率/功率 CLI 和 M2 的输出 CLI 都要求对应 capability、`read_write` 访问和 -fresh safety preflight。DSG830 已完成 A1/A2,声明 `rf_source.idn`、`rf_source.snapshot` 与 -`rf_source.output`:status 可在已配置的只读 session 中执行,端口 ON/OFF 还要求切换为 `read_write` 并提供 -完整安全配置。CW 与其它写 CLI 仍会在打开 transport 前被 capability 门禁拒绝,直到对应 A 级证据提升。 +`rf_source.snapshot`。M1 的频率/功率 CLI、M4 的 Pulse/Step Sweep 配置和 M2 的输出 CLI 都要求对应 capability、`read_write` 访问和 +fresh preflight;M2 的端口 ON/OFF 还要求完整安全配置。DSG830 已完成 A1/A2/A3/A4 Pulse/A4 Step Sweep,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`:status 可在已配置的只读 session 中执行,端口 ON/OFF 还要求切换为 `read_write` 并提供完整安全配置。Pulse 与 Step Sweep 配置分别保持 Pulse/Sweep disabled;调制、trigger、Sweep execute/fire 与 Level Sweep 仍由 capability 门禁拒绝。 字段说明: diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index 110ea2f..98a2ea0 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -584,19 +584,20 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 -### RF 信号源 M0–M3(DSG830 production 已含 A2 output 与 A3 CW) +### RF 信号源 M0–M4(DSG830 production 已含 A2 output、A3 CW、A4 Pulse 与 Step Sweep) `rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source 能力。descriptor 必须同时满足以下静态条件: - 只声明 `rf_source.*` capability,且至少包含 `rf_source.idn`;当前 Core 识别 - `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.output`。 + `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 和 `rf_source.output`。 - 提供 `rf_source_extensions`,其 contract version、拓扑、端口 ID、feature 和 protection policy 必须通过 Core 校验。 - 声明 `rf_source.cw_configure` 时,CW feature 必须有 `CONFIGURE` direction 和至少一个可配置字段;声明 `rf_source.output` 时,output feature 必须同时有 `ENABLE`/`DISABLE` direction 与可读 output state。 - 声明 `rf_source.modulation_configure` 时,Modulation feature 必须同时有 `CONFIGURE`/`READ` direction、 `configuration_readable = true`,并至少声明一个内部 Sine `RfModulationModeProfile`。profile 的模式、值单位、值范围和内部频率范围必须与 driver 的严格 readback 一致。 +- 声明 `rf_source.pulse_configure` 或 `rf_source.sweep_configure` 时,对应 feature 必须同时有 `CONFIGURE`/`READ` direction、可读 configuration state 和有界的 mode profile;Pulse 与 Sweep 的配置方法必须在独立 readback 后保持 disabled。 - `wavebench_min_version` 不低于 `0.8.25`,并且小于 `wavebench_max_version`。 - 打包检查时,wheel 必须有且仅有一条生效的 `wavebench` 依赖,并显式使用与 descriptor 相同的 `>=wavebench_min_version, Date: Thu, 27 Aug 2026 06:04:50 +0800 Subject: [PATCH 43/63] feat: retain RF modulation postcondition evidence --- src/wavebench/services/rf_source_service.py | 42 +++++++++++++++++---- tests/test_rf_source_modulation_service.py | 37 +++++++++++++++++- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index b80287c..ae3d17e 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -128,6 +128,27 @@ class _RfOutputTransaction: postcondition_snapshot: RfSourceSnapshot +class RfModulationPostconditionError(ConfigError): + """Strict M3 postcondition failure with already-read typed evidence. + + The exception never changes the fail-closed result. It only retains the + snapshots that were independently read before the strict comparison failed, + so a private evidence harness can record redacted field-level diagnostics + without issuing another query on an uncertain session. + """ + + def __init__( + self, + message: str, + *, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_snapshot: RfModulationSnapshot, + ) -> None: + super().__init__(message) + self.postcondition_snapshot = postcondition_snapshot + self.postcondition_modulation_snapshot = postcondition_modulation_snapshot + + @dataclass class RfSourceService(SessionStateAliasMixin): """Open one configured RF source session for a bounded RF operation.""" @@ -362,13 +383,20 @@ def _configure_modulation_transaction( request.port_id, request.kind, ) - result = self._validate_modulation_postcondition( - request, - postcondition_snapshot, - postcondition_modulation_snapshot, - mode_profile, - operation=operation, - ) + try: + result = self._validate_modulation_postcondition( + request, + postcondition_snapshot, + postcondition_modulation_snapshot, + mode_profile, + operation=operation, + ) + except ConfigError as exc: + raise RfModulationPostconditionError( + str(exc), + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) from exc return _RfModulationTransaction( result=result, preflight_snapshot=preflight_snapshot, diff --git a/tests/test_rf_source_modulation_service.py b/tests/test_rf_source_modulation_service.py index 0f66fb0..c5cdc52 100644 --- a/tests/test_rf_source_modulation_service.py +++ b/tests/test_rf_source_modulation_service.py @@ -42,7 +42,7 @@ RfSweepState, ) from wavebench.logging import CommandLogger -from wavebench.services.rf_source_service import RfSourceService +from wavebench.services.rf_source_service import RfModulationPostconditionError, RfSourceService from wavebench.transport.session import InstrumentSessionState, SessionHealth @@ -503,6 +503,41 @@ def test_modulation_mismatch_is_not_retried_and_degrades_session() -> None: assert service.session_state.health is SessionHealth.UNCERTAIN +def test_pm_postcondition_error_retains_typed_readback_without_relaxing_match() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + postcondition_rf = _rf_snapshot(modulation=RfModulationState.ENABLED) + postcondition_modulation = _modulation_snapshot( + kind=RfModulationKind.PM, + enabled=True, + value=1.25, + ) + service, driver = _service( + [ + _rf_snapshot(), + postcondition_rf, + ], + [ + _modulation_snapshot(kind=RfModulationKind.PM, value=2.0), + postcondition_modulation, + ], + ) + + with pytest.raises(RfModulationPostconditionError, match="readback does not match") as raised: + service.configure_modulation(request) + + assert raised.value.postcondition_snapshot is postcondition_rf + assert raised.value.postcondition_modulation_snapshot is postcondition_modulation + assert raised.value.postcondition_modulation_snapshot.phase_deviation_rad == 1.25 + assert driver.requests == [request] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + def _disable_descriptor() -> SimpleNamespace: return _descriptor( "rf_source.idn", From da476428f8d1b3743c1df938a91ed1b6d2a9b509 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:36:57 +0800 Subject: [PATCH 44/63] docs: clarify RF modulation production boundary --- README.md | 2 +- docs/README.md | 2 +- docs/README_EN.md | 2 +- docs/project/README.md | 12 ++++++--- ...21\351\207\214\347\250\213\347\242\221.md" | 26 +++++++++---------- ...67\346\272\220\350\256\276\350\256\241.md" | 26 +++++++++---------- ...01\347\250\213\350\256\276\350\256\241.md" | 2 +- ...07\346\212\275\350\261\241\345\261\202.md" | 2 +- ...71\347\233\256\350\276\271\347\225\214.md" | 4 +-- .../WaveBench_CLI\345\275\242\346\200\201.md" | 10 ++++--- ...77\347\224\250\346\214\207\345\215\227.md" | 13 ++++++---- ...77\347\224\250\346\214\207\345\215\227.md" | 4 +-- ...345\231\250\346\217\222\344\273\266API.md" | 4 +-- 13 files changed, 60 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index fc9040a..b0fa995 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled;它不提供 execute、arm、fire、trigger、Level Sweep 或 list。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`;调制配置不授权调制开启时的 RF 输出。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled;它不提供 `modulation_disable`、execute、arm、fire、trigger、Level Sweep 或 list。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git a/docs/README.md b/docs/README.md index 4499115..6e9c46f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ wavebench run check --plan /tmp/wavebench-demo.toml ### 使用 RF 信号源 -`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;需要实现新型号或查看证据门时,再阅读 [领域设计](project/design/WaveBench_RF信号源设计.md) 与 [开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 +`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;DSG830 的 RF-OFF 调制配置已开放,但调制开启时的 RF 输出仍未开放。需要实现新型号或查看证据门时,再阅读 [领域设计](project/design/WaveBench_RF信号源设计.md) 与 [开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 ### 执行实验 diff --git a/docs/README_EN.md b/docs/README_EN.md index 98e5442..f68e180 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -59,7 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) -- RF-source domain and milestones: [design](project/design/WaveBench_RF信号源设计.md) and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M4 contracts; DSG830 A1/A2/A3/A4 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, safety-gated `rf_source.output` ON/OFF, internal/single Pulse configuration, and frequency-only Step Sweep configuration that remains disabled. Modulation, triggers, Sweep execution, Level Sweep, and list control remain gated. +- RF-source domain and milestones: [guide](project/guides/WaveBench_RF信号源使用指南.md), [design](project/design/WaveBench_RF信号源设计.md), and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M4 contracts; DSG830 A1/A2/A3/A4 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, RF-OFF internal-sine `rf_source.modulation_configure`, safety-gated `rf_source.output` ON/OFF, internal/single Pulse configuration, and frequency-only Step Sweep configuration that remains disabled. PM is limited to the verified `1.25 rad` production profile. Modulated RF output, `rf_source.modulation_disable`, triggers, Sweep execution, Level Sweep, and list control remain gated. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git a/docs/project/README.md b/docs/project/README.md index 3f15e0f..c1063d6 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -4,9 +4,15 @@ ## RF 信号源 -- [使用指南](guides/WaveBench_RF信号源使用指南.md):配置、端接、当前 production capability 和上机前检查。 -- [领域设计](design/WaveBench_RF信号源设计.md):独立模型、OperationSpec、安全语义和 capability 边界。 -- [开发里程碑](design/WaveBench_RF信号源开发里程碑.md):Core/DSG830 双仓库分工、实机证据和下一阶段工作。 +`rf_source` 是与普通 `source` 平行的仪器领域:它使用 `port_id`、dBm、RF 输出、端接与 protection 状态,不能套用普通信号源的 Vpp、offset 或 channel 语义。 + +| 阅读目的 | 入口 | +| --- | --- | +| 配置仪器、声明端接和执行已开放操作 | [使用指南](guides/WaveBench_RF信号源使用指南.md) | +| 理解模型、事务与安全边界 | [领域设计](design/WaveBench_RF信号源设计.md) | +| 开发 Core/DSG830 或复核 capability 提升依据 | [开发里程碑](design/WaveBench_RF信号源开发里程碑.md) | + +DSG830 当前 production 范围包括只读状态、RF-OFF CW、RF-OFF 内部正弦调制、受 safety 限制的 RF ON/OFF、保持 disabled 的 internal/single Pulse 和 fixed Step Sweep 配置。调制配置不授权调制开启时的 RF 输出;`rf_source.modulation_disable`、trigger、Sweep execute/fire、Level Sweep 与 list 仍不在 production 范围内。PM 的 production profile 仅为已验证的 `1.25 rad`。 ## guides:使用指南 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 7711e56..1247876 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M3 合同完成;M4 Pulse 与 Step Sweep 已提升 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 与 frequency-only Step Sweep 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。production 能力由各插件证据逐项决定。 | -| DSG830 包 `0.2.0` | M0–M3 离线完成;M4 Pulse 与 Step Sweep 的 A4 已通过;A4 的 AM、FM 已通过,PM 待定位 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`。 | -| 真实仪器证据 | A1、A2、A3、A4 Pulse、A4 Step Sweep 已完成;A4 的 AM、FM 已通过,PM 尚无合格证据;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。 | +| Core `0.8.25` 开发线 | M0–M4 合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 与 frequency-only Step Sweep 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep 的 A4 均已通过并提升;A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。调制证据不提升调制开启时的 RF 输出。 | ## 双仓库交付规则 @@ -30,7 +30,7 @@ | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | -| M3 | 离线完成;A4 的 AM、FM 已通过,PM 待定位 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;production capability 等待完整 A4。 | +| M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;DSG830 production 已开放 `rf_source.modulation_configure`。PM 的 production profile 固定为 `1.25 rad`。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | @@ -59,7 +59,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 Pulse 与 A4 Step Sweep 已分别将 `rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor。M3 capability 仍只能存在于 fake descriptor 或离线 driver 测试中。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 调制、Pulse 与 Step Sweep 已分别将 `rf_source.modulation_configure`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor。`rf_source.modulation_disable` 仍只存在于私有证据与恢复路径。 ### 离线完成条件 @@ -71,7 +71,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收和 A3 受控实机证据均已完成;DSG830 production 已开放 CW capability。 -DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;调制 capability、Sweep execute/fire、trigger 与 Level Sweep 仍继续关闭。 +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;RF 调制输出、`rf_source.modulation_disable`、Sweep execute/fire、trigger 与 Level Sweep 仍继续关闭。 ## M2:RF 输出安全事务 @@ -79,7 +79,7 @@ Core 已在离线环境中完成 `RfOutputRequest`/result、`rf_source.output_ ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 使用单次 `:OUTP ON|OFF` 映射,Core 负责所有 snapshot readback 与 recovery;A2 已将 `rf_source.output` 加入 production descriptor,后续 A3 单独提升 CW,不提升 M3/M4 或其它 capability。 -## M3:内部正弦调制(离线完成) +## M3:内部正弦调制(A4 已通过并提升) Core 已冻结 `RfModulationModeProfile`、typed request/result、调制 snapshot、 `rf_source.modulation_configure` OperationSpec、Service、CLI、run step 与 artifact。M3 只描述内部 @@ -94,12 +94,10 @@ FM/PM 的共享 mode type 会与被查询 profile 分开记录。当前类型 `rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。该 operation 当前只供 A4 本地证据与恢复流程使用,不进入 DSG830 production descriptor。 -production descriptor 的调制 capability 仍需要 A4 证据;离线 driver、fake descriptor、CLI 或 run step 的完整测试均不能替代它。 -当前 M2 的 RF ON 合同要求调制 disabled,因此 A4 即使仅提升 M3 配置 capability,也不授权在调制开启时输出 RF;该能力需要后续专门的 -输出安全合同与实机证据。 +DSG830 的 A4 调制证据已将 `rf_source.modulation_configure` 加入 production descriptor。production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`,以及 PM 精确 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。driver 的离线 PM 映射范围不自动扩大 production profile。 +当前 M2 的 RF ON 合同仍要求调制 disabled,因此 M3 capability 不授权在调制开启时输出 RF;该能力仍需要专门的输出安全合同与实机证据。 -DSG830 源码 checkout 已提供 A4 的独立本地 harness 和资源无关 setup 模板,并已进入受控硬件验证。一次运行只配置一个内部 Sine -AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。AM、FM 的序列已通过;PM 目前在严格读回中不匹配请求值,每次异常后均通过独立恢复回到关闭基线。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是 A4 通过证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。当前尚未取得可提升 `rf_source.modulation_configure` 的完整 A4 证据,不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 +DSG830 源码 checkout 提供 A4 的独立本地 harness 和资源无关 setup 模板。一次运行只配置一个内部 Sine AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。三种模式的 RF-OFF 配置、严格读回与关闭恢复均已通过。为使生产 profile 与 PM 的严格读回证据完全一致,PM 仅开放 `1.25 rad`,不将离线映射的其它值外推到 production。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是新的 capability 提升证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。该证据不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 ## M4:Pulse 与 Step Sweep @@ -179,6 +177,6 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 -5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的离线合同、DSG830 映射、CLI、run 与 artifact 已完成,但 capability 继续等待 A4;M4 保持独立工作。 -6. A4 的 AM、FM RF-OFF 单模式配置、读回与关闭恢复证据已通过;PM 仍需定位严格读回不匹配的设备或固件条件。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成 A4 capability 提升证据。完成 PM 的合格证据前,不讨论任何允许调制开启时 RF 输出的专门安全合同,也不得把当前 CH2 可见信号证据外推为调制输出证据。 +5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的 Core 合同、DSG830 映射、CLI、run、artifact 与 A4 证据均已完成,`rf_source.modulation_configure` 已提升;M4 继续保持独立工作。 +6. A4 的 AM/FM/PM RF-OFF 单模式配置、严格读回与关闭恢复证据均已通过。PM production profile 固定为 `1.25 rad`,以避免将更宽的离线映射当作实机覆盖范围。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成新的 capability 提升证据;任何允许调制开启时 RF 输出的安全合同仍须单独设计和验证,CH2 可见信号也不能替代该证据。 7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 0e5da7f..76bbf6b 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3、A4 Pulse 和 A4 Step Sweep 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3 仍须通过覆盖 PM 的实机证据门;离线代码不能替代相应 capability 的实机证据。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3/A4 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3 的 PM production profile 仅为 `1.25 rad`;离线代码与宽于该 profile 的映射不能替代相应 capability 的实机证据。 阅读顺序如下: @@ -16,12 +16,12 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 Pulse/A4 Step Sweep 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`;M3 仍关闭。 | -| 实机证据 | A1、A2、A3、A4 Pulse 和 A4 Step Sweep 已完成;A4 的 AM、FM RF-OFF 序列已通过,PM 仍有严格读回不匹配;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;M3 capability 仍须覆盖三种模式。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 调制/Pulse/Step Sweep 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 限于 `1.25 rad`。 | +| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;调制 A4 只提升 RF-OFF 配置,不提升调制开启时的 RF 输出。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 -除明确标为「生产只读」「A2 已提升」「A3 已提升」「A4 Pulse/Step Sweep 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A4–A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;M4 Pulse 和 Step Sweep 已由 A4 分别提升,M3 仍未覆盖 PM。 +除明确标为「生产只读」「A2 已提升」「A3 已提升」「A4 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;DSG830 的 M3、M4 Pulse 和 M4 Step Sweep 已由 A4 分别提升,但各项均保持其声明的 profile 和输出边界。 ## 术语与证据级别 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 Pulse/A4 Step Sweep 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。M3 已完成 Core 与 DSG830 的离线实现;其它 A4–A5 实机验收、production 写 capability 声明和发行包推广仍另行处理,离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。DSG830 的 M3 已完成 Core 与实机验收并进入 production;其它 A5 实机验收、调制输出安全合同和发行包推广仍另行处理,离线代码不能替代这些证据。 ## 范围与非目标 @@ -57,7 +57,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 - 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 - 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 -- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 Pulse 或 Step Sweep 配置 capability。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 RF-OFF 调制、Pulse 或 Step Sweep 配置 capability。 ## 分层与职责 @@ -318,9 +318,9 @@ M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端 M1 已由 A3 在真实设备上完成受控频率/功率写入、独立 readback、低功率 RF ON/OFF 环回与最终 OFF 验收,因而将 `rf_source.cw_configure` 纳入 DSG830 production descriptor。M2 已由 A2 将 `rf_source.output` 纳入同一 descriptor;人工确认的实验室端接本身仍不构成调制、Pulse、Sweep、trigger 或其它额外写入授权。 -### M3 与 M4 的离线入口 +### M3 与 M4 的 production 入口 -M3 的写入 CLI 和 run step 已进入当前 Core schema,但其真实仪器使用仍由 production descriptor 的 A4 capability 门决定: +M3 的写入 CLI 和 run step 已进入当前 Core schema。DSG830 已由 A4 声明 `rf_source.modulation_configure`,但真实仪器使用仍由 production descriptor、`read_write`、匹配 profile 与 fresh RF-OFF preflight 共同门禁: ```text wavebench rf-source modulation configure-am ... @@ -343,7 +343,7 @@ wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START -- rf_source.sweep_configure ``` -M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。 +M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。M2 的 RF ON 仍要求调制关闭,因此这不是调制输出入口。 `rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 @@ -358,13 +358,13 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 | M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | -| M3(离线完成;A4 的 AM、FM 已通过,PM 待定位) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;production capability 等待完整 A4。 | +| M3(A4 已通过并提升) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;DSG830 已声明 `rf_source.modulation_configure`,其中 PM 固定为 `1.25 rad`。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | | M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。A4 的 AM、FM RF-OFF 单模式验证已通过并完成关闭恢复;PM 仍有严格读回不匹配,因此尚未形成可提升整体调制 capability 的合格证据。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -383,7 +383,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse/A4 Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。driver 已实现 M3 内部正弦 AM/FM/PM 的离线映射、严格 readback 和按模式关闭;A4 harness 在配置读回后执行受限调制关闭,或以显式恢复模式将一个已知模式还原为关闭状态。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 Pulse 与 Step Sweep harness 在对应 descriptor 提升后拒绝重跑;普通 Pulse 与 Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。A4 尚未提升 `rf_source.modulation_configure`、`rf_source.modulation_disable`,既有证据也不开放 Sweep fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM,严格 readback 后由 A4 harness 执行受限调制关闭;PM production profile 固定为 `1.25 rad`,`rf_source.modulation_disable` 仍不进入 descriptor。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 harness 在对应 descriptor 提升后拒绝重跑;普通 M3/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。既有证据不开放调制输出、Sweep fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -400,5 +400,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 Pulse/A4 Step Sweep - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出与 A3 CW 环回证据已通过,production 已提升 snapshot、`rf_source.output` 和 `rf_source.cw_configure`。Core `ab4de10` 与插件 `36e1e8e` 增加按模式调制关闭与私有恢复路径;A4 的 AM、FM RF-OFF 序列通过,PM 尚有严格读回不匹配,故整体调制 capability 仍关闭。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。该提升仅限保持 Sweep disabled 的配置,不提升调制、Sweep execute/fire 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure` 和 `rf_source.modulation_configure`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。该提升仅限保持 Sweep disabled 的配置,不提升调制输出、Sweep execute/fire 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index d24034c..bdc0349 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -262,7 +262,7 @@ sleep `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 -RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、CW 频率/功率步骤、`rf_source.output_enable`/`rf_source.output_disable`、M3 的 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;它们使用独立的类型化 RF artifact。DSG830 已完成 A1/A2/A3、A4 Pulse 和 A4 Step Sweep,production descriptor 声明 `rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,因此 status 可在 `read_only` session 中读取快照,生产写步骤仅在 `read_write`、相应 capability 和 fresh OFF-only preflight 同时成立时执行。Pulse 与 Step Sweep 只配置并保持 disabled;trigger、Sweep execute/fire、Level Sweep 与调制 capability 仍未开放。详见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md)、[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、CW 频率/功率步骤、`rf_source.output_enable`/`rf_source.output_disable`、M3 的 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;它们使用独立的类型化 RF artifact。DSG830 已完成 A1/A2/A3、A4 调制/Pulse/Step Sweep,production descriptor 声明 `rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,因此 status 可在 `read_only` session 中读取快照,生产写步骤仅在 `read_write`、相应 capability 和 fresh OFF-only preflight 同时成立时执行。PM production profile 仅为 `1.25 rad`;Pulse 与 Step Sweep 只配置并保持 disabled;调制开启时的 RF 输出、trigger、Sweep execute/fire 与 Level Sweep 仍未开放。详见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md)、[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 `scope.capture` 可以额外声明: diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index 36fe5ed..53f0a8a 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -254,7 +254,7 @@ Service 层可以按以下顺序组合这些动作: 当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、内部正弦 AM/FM/PM transaction、internal/single Pulse transaction、保持 Sweep disabled 的 Step Sweep transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output`/`rf-source modulation configure-*`/`rf-source pulse configure`/`rf-source sweep configure` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 -DSG830 已由 A1/A2/A3/A4 Pulse/A4 Step Sweep 将 snapshot、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure` 提升到 production。Pulse 与 Step Sweep 只允许已声明的配置 profile,且配置后保持 disabled;调制 capability、trigger、Sweep execute/fire 与 Level Sweep 仍待独立证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +DSG830 已由 A1/A2/A3/A4 调制/Pulse/Step Sweep 将 snapshot、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure` 提升到 production。PM production profile 固定为 `1.25 rad`;Pulse 与 Step Sweep 只允许已声明的配置 profile,且配置后保持 disabled。调制开启时的 RF 输出、trigger、Sweep execute/fire 与 Level Sweep 仍待独立证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 早期目录示意 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 40953a3..386e7f2 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,7 +19,7 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| RF 信号源 | M0–M4 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制合同、Pulse 与 Step Sweep 配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2/A3/A4 Pulse/A4 Step Sweep,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;M3 capability 仍需覆盖 PM 的 A4 证据 | +| RF 信号源 | M0–M4 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制、Pulse 与 Step Sweep 配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;PM production profile 仅为 `1.25 rad`,调制输出仍未开放 | | run plan | source、rf_source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样;RF 输出仍受 capability、access 和端口 safety 限制 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | @@ -29,7 +29,7 @@ WaveBench 优先解决以下问题: RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 -当前 Core 已提供 M0–M4 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`、凭 A3 证据开放 OFF-only `rf_source.cw_configure`,并凭 A4 Pulse/Step Sweep 证据开放保持 disabled 的 `rf_source.pulse_configure` 与 `rf_source.sweep_configure`。M3 内部正弦调制已完成离线合同与 driver 映射,但 capability 仍等待覆盖 PM 的 A4 证据;trigger、Sweep execute/fire 与 Level Sweep 继续等待对应 A5 或独立证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +当前 Core 已提供 M0–M4 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`、凭 A3 证据开放 OFF-only `rf_source.cw_configure`,并凭 A4 调制/Pulse/Step Sweep 证据开放 RF-OFF `rf_source.modulation_configure`、保持 disabled 的 `rf_source.pulse_configure` 与 `rf_source.sweep_configure`。M3 的 PM production profile 固定为 `1.25 rad`,而 M2 的 RF ON 合同仍要求调制 disabled;trigger、Sweep execute/fire 与 Level Sweep 继续等待对应 A5 或独立证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 推荐工作顺序 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 3c6e685..38bfac6 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -87,19 +87,23 @@ wavebench rf-source status --config wavebench.toml wavebench rf-source set-frequency --port PORT_ID HZ --config wavebench.toml wavebench rf-source set-power --port PORT_ID DBM --config wavebench.toml wavebench rf-source output --port PORT_ID on|off --config wavebench.toml +wavebench rf-source modulation configure-am --port PORT_ID --depth-percent PERCENT --internal-frequency-hz HZ --config wavebench.toml +wavebench rf-source modulation configure-fm --port PORT_ID --frequency-deviation-hz HZ --internal-frequency-hz HZ --config wavebench.toml +wavebench rf-source modulation configure-pm --port PORT_ID --phase-deviation-rad RAD --internal-frequency-hz HZ --config wavebench.toml wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted --config wavebench.toml wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS --config wavebench.toml wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only wavebench capability explain rf_source.output_enable --driver rigol.dsg830 --kind rf_source --access read_write +wavebench capability explain rf_source.modulation_configure --driver rigol.dsg830 --kind rf_source --access read_write ``` `rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 -`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2/A3、A4 Pulse 和 A4 Step Sweep,并在 -production descriptor 中声明只读 capability、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2/A3 与 A4 调制/Pulse/Step Sweep,并在 +production descriptor 中声明只读 capability、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw SCPI。M1 的 `set-frequency` 与 `set-power` 还要求 `rf_source.cw_configure`、`read_write` 访问、已声明的 CW profile 和完整的 OFF-only preflight。M2 的 `output` 要求 `rf_source.output`、 `read_write` 访问、可读 output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 -DSG830 对 OFF-only CW、端口级 ON/OFF、internal/single Pulse 与固定 profile 的 Step Sweep 配置开放 production 写入;缺少 `read_write`、对应 profile 或 fresh preflight 时仍在写入前拒绝。Pulse 与 Step Sweep 配置完成后分别保持 Pulse/Sweep disabled;它们不提供 trigger、execute、arm、fire、Level Sweep 或 list。调制 capability 仍未开放。 +DSG830 对 OFF-only CW、RF-OFF 内部正弦调制、端口级 ON/OFF、internal/single Pulse 与固定 profile 的 Step Sweep 配置开放 production 写入;缺少 `read_write`、对应 profile 或 fresh preflight 时仍在写入前拒绝。调制 profile 的 PM 固定为 `1.25 rad`,且当前 RF ON 合同要求调制 disabled。Pulse 与 Step Sweep 配置完成后分别保持 Pulse/Sweep disabled;它们不提供 `modulation_disable`、trigger、execute、arm、fire、Level Sweep 或 list。 已声明对应写 capability 的插件还可以配置跨通道关系: diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 0097878..aa76266 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -22,7 +22,7 @@ | 身份与状态 | 已开放 | 已开放 | `read_only` 可执行。 | | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | -| 内部正弦 AM/FM/PM | M3 离线合同与受限恢复路径已完成 | 未开放 | A4 尚无覆盖三种模式的完整合格证据前,DSG830 会在 transport I/O 前拒绝该 capability。 | +| 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。调制开启时的 RF 输出仍未开放。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | | trigger、arm/fire、Level Sweep | 未完成 | 未开放 | 不应尝试调用或绕过。 | @@ -82,11 +82,14 @@ wavebench rf-source idn --config wavebench.toml wavebench rf-source status --config wavebench.toml ``` -在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW、RF 输出、Pulse 和 Step Sweep 配置操作: +在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW、内部正弦调制、RF 输出、Pulse 和 Step Sweep 配置操作: ```bash wavebench rf-source set-frequency --config wavebench.toml --port rf_out 1000000 wavebench rf-source set-power --config wavebench.toml --port rf_out -40 +wavebench rf-source modulation configure-am --config wavebench.toml --port rf_out --depth-percent 25 --internal-frequency-hz 1000 +wavebench rf-source modulation configure-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 10000 --internal-frequency-hz 1000 +wavebench rf-source modulation configure-pm --config wavebench.toml --port rf_out --phase-deviation-rad 1.25 --internal-frequency-hz 1000 wavebench rf-source output --config wavebench.toml --port rf_out on wavebench rf-source output --config wavebench.toml --port rf_out off wavebench rf-source pulse configure --config wavebench.toml --port rf_out --period-s 0.001 --width-s 0.0001 --polarity normal @@ -174,11 +177,11 @@ internal_frequency_hz = 1000 M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disabled、Pulse/Sweep disabled,且没有活动 protection condition。FM 与 PM 共享设备的当前选择位:在三种模式均关闭时,preflight 可以观察到另一种 FM/PM 选择,固定写入会明确选择目标类型;postcondition 必须确认已切换到目标类型。Core 用独立调制 snapshot 验证目标模式、源、波形、数值、内部频率、全局调制开关和 RF 输出仍然 OFF。写入结果不明或 postcondition 不匹配时不重试,session 会降为不确定状态。 -截至当前,DSG830 production descriptor 不声明 `rf_source.modulation_configure`。因此上述命令和 step 仅用于离线 fake descriptor、开发验证或未来已取得 A4 证据的插件;对当前 production DSG830 会在打开 transport 前被 capability 门拒绝。 +DSG830 production descriptor 已声明 `rf_source.modulation_configure`。它只接受 descriptor 中的内部 Sine profile:AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。超出该 production profile 的请求会在仪器 I/O 前拒绝;不能把 driver 的离线映射范围当作当前设备的写入授权。 -DSG830 源码 checkout 的 A4 harness 是开发验证工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。显式 `--diagnose` 保留原始 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并要求 transport audit 为零写;它只生成私有诊断记录。A4 的 AM、FM RF-OFF 序列已通过;PM 仍有严格读回不匹配,故整体调制 capability 尚未提升。 +DSG830 源码 checkout 的 A4 harness 是已完成的开发验收工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。显式 `--diagnose` 保留原始 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并要求 transport audit 为零写;它只生成私有诊断记录。AM/FM/PM 的 RF-OFF 序列均已通过;PM 的 production profile 因严格读回证据而固定为 `1.25 rad`。 -M2 的 RF ON 合同目前要求调制 disabled。即使未来 A4 仅提升 M3 配置 capability,也不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 +M2 的 RF ON 合同目前要求调制 disabled。M3 已提升的配置 capability 不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 ## M4:受控 Pulse 与 Step Sweep 配置合同 diff --git "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" index 6b59edc..f3824b2 100644 --- "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -66,7 +66,7 @@ step 的 `OperationSpec`。`run plan --intent` 会在取得资源租约、打开 ## RF 信号源步骤 -RF 使用独立的 `rf_source.*` step,不使用普通 `source` 的 channel、Vpp、restore 或 safety 语义。当前 DSG830 production descriptor 已开放只读状态、OFF-only CW、受 safety 限制的 RF 输出、internal/single Pulse 和保持 Sweep disabled 的 frequency-only Step Sweep 配置: +RF 使用独立的 `rf_source.*` step,不使用普通 `source` 的 channel、Vpp、restore 或 safety 语义。当前 DSG830 production descriptor 已开放只读状态、OFF-only CW、RF-OFF 内部正弦调制、受 safety 限制的 RF 输出、internal/single Pulse 和保持 Sweep disabled 的 frequency-only Step Sweep 配置: ```toml [[steps]] @@ -116,7 +116,7 @@ frequency_deviation_hz = 10000 internal_frequency_hz = 1000 ``` -`modulation_kind` 为 `am`、`fm` 或 `pm`,分别只能使用 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad`。它只覆盖内部 Sine,要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。当前 DSG830 production descriptor 尚未声明调制 capability,因此该步骤会在 transport I/O 前拒绝;它仅用于离线或未来 A4 已提升的插件。不要把它与 `rf_source.output_enable` 拼成「调制输出」流程,当前 ON 合同要求调制 disabled。 +`modulation_kind` 为 `am`、`fm` 或 `pm`,分别只能使用 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad`。它只覆盖内部 Sine,要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production descriptor 已声明调制 capability,profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,且内部频率均为 `10 Hz–100 kHz`。不要把它与 `rf_source.output_enable` 拼成「调制输出」流程:当前 ON 合同要求调制 disabled。 RF 的配置、端接判断、CLI 与 A4 边界见 [RF 信号源使用指南](WaveBench_RF信号源使用指南.md)。 diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index 98a2ea0..8c0db84 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -584,7 +584,7 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 -### RF 信号源 M0–M4(DSG830 production 已含 A2 output、A3 CW、A4 Pulse 与 Step Sweep) +### RF 信号源 M0–M4(DSG830 production 已含 A2 output、A3 CW、A4 调制/Pulse/Step Sweep) `rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source 能力。descriptor 必须同时满足以下静态条件: @@ -607,7 +607,7 @@ capability 的高级配置保持 V1。插件不得把 capability 注册视为 | `rf_source.idn` | `idn` | `wavebench rf-source idn`、doctor IDN target | | `rf_source.snapshot` | `get_rf_snapshot` | `wavebench rf-source status`、`rf_source.status` run step | | `rf_source.cw_configure` | `configure_cw` | `rf-source set-frequency`/`set-power`、对应 run step;OFF-only 合同,DSG830 经 A3 复核后已提升 | -| `rf_source.modulation_configure` | `get_rf_modulation_snapshot`、`configure_rf_modulation` | `rf-source modulation configure-am`/`configure-fm`/`configure-pm`、`rf_source.modulation_configure` run step;内部 Sine、RF OFF、无重试合同,DSG830 仍等待 A4 | +| `rf_source.modulation_configure` | `get_rf_modulation_snapshot`、`configure_rf_modulation` | `rf-source modulation configure-am`/`configure-fm`/`configure-pm`、`rf_source.modulation_configure` run step;内部 Sine、RF OFF、无重试合同,DSG830 经 A4 复核后已提升。AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,均为 `10 Hz–100 kHz` 内部频率。 | | `rf_source.modulation_disable` | `get_rf_modulation_state`、`disable_rf_modulation` | 仅用于受控本地证据与恢复;DSG830 不声明 production capability | | `rf_source.pulse_configure` | `get_rf_pulse_snapshot`、`configure_rf_pulse` | `rf-source pulse configure`、`rf_source.pulse_configure` run step;internal/single、RF OFF,配置后保持 Pulse disabled,DSG830 经 A4 Pulse 复核后已提升 | | `rf_source.sweep_configure` | `get_rf_sweep_snapshot`、`configure_rf_sweep` | `rf-source sweep configure`、`rf_source.sweep_configure` run step;固定 `STEP`/`FWD`/`RAMP`/`LIN`、RF OFF,配置后保持 Sweep disabled,DSG830 经 A4 Step Sweep 复核后已提升 | From ab540b918c1728b4769d047a43d5cb97938f083f Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:45:16 +0800 Subject: [PATCH 45/63] docs: define RF trigger entry conditions --- ...217\221\351\207\214\347\250\213\347\242\221.md" | 14 ++++++++++++++ ...217\267\346\272\220\350\256\276\350\256\241.md" | 6 ++++++ 2 files changed, 20 insertions(+) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 1247876..860fa85 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -125,6 +125,20 @@ Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出 每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 +### A5:外部 trigger/同步的进入条件(未开始) + +A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out` 证据开始。一次验收只能覆盖一个明确目标,例如 Pulse 的 external trigger、Sweep period trigger 或 Sweep point trigger;不能把其中一项外推为其它 trigger、fire、同步或后面板辅助输出能力。 + +| 项目 | 进入前必须明确的事实 | +| --- | --- | +| 目标行为 | 本次只验证的 trigger/sync 模式、目标仪器状态和成功判据。 | +| 物理接线 | 每根线的源端设备/接口、目标设备/接口、线缆与转接件;必须明确是 trigger input、trigger output、sync/reference input、sync/reference output 还是 `rf_out`。 | +| 电气兼容 | 信号类型、方向、逻辑或模拟属性、幅度/阈值、极性、脉宽、频率/时序、源/负载阻抗和终端方式,均以已核对的设备资料和实际接线为准。 | +| 初始与恢复状态 | 初始 RF 输出、调制、Pulse、Sweep、protection 与后面板配置;失败后的恢复方式和最终 RF OFF 独立确认方式。 | +| 观察方式 | 如使用示波器,只能作为补充观察;必须核对其输入与接线,且不能替代仪器端读回。CH2 的 50 Ω 声明仅适用于已确认的 RF 路径。 | + +在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成单一路径的 Core typed contract、descriptor validator、fake driver 与零写拒绝测试;再实现私有 `read_only` 诊断;最后在已确认接线和电气边界下设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 + ### A1:已完成的只读 snapshot 验收 A1 已使用一次性、非 production 的本地 evidence harness 完成并经复核。当时没有临时将 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 76bbf6b..3f39ead 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -349,6 +349,12 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 `rf_source.sweep_configure` 已进入当前 Core schema;DSG830 已在 A4 Step Sweep 证据复核后声明该 capability。请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 +### A5 的离线合同边界 + +外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。新的 operation 必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 + +在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。它不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 + ## M0–M4 里程碑 下表同时标出当前进度和交付边界。Core 与 DSG830 插件的依赖、完成条件和状态见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 From 89fba16de8066db23dac465af93cc8e64b5d436f Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:05:48 +0800 Subject: [PATCH 46/63] feat: define read-only RF trigger snapshot contract --- .../instruments/rf_source_capabilities.py | 19 ++ .../instruments/rf_source_extensions.py | 165 +++++++++++++++++- tests/test_rf_source_extensions.py | 140 +++++++++++++++ 3 files changed, 323 insertions(+), 1 deletion(-) diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 15ee688..9aa49f8 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -23,6 +23,7 @@ RfPulseProfile, RfSourceDescriptorExtensions, RfSweepProfile, + RfTriggerProfile, ) @@ -30,6 +31,7 @@ { "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), + "rf_source.trigger_snapshot": ("get_rf_trigger_snapshot",), "rf_source.cw_configure": ("configure_cw",), "rf_source.modulation_configure": ( "get_rf_modulation_state", @@ -86,6 +88,8 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No raise ConfigError("rf_source descriptors require the rf_source.idn capability") if "rf_source.cw_configure" in rf_capabilities: _validate_cw_configure_feature(extensions) + if "rf_source.trigger_snapshot" in rf_capabilities: + _validate_trigger_snapshot_feature(extensions) if "rf_source.modulation_configure" in rf_capabilities: _validate_modulation_configure_feature(extensions) if "rf_source.modulation_disable" in rf_capabilities: @@ -122,7 +126,22 @@ def _validate_cw_configure_feature(extensions: RfSourceDescriptorExtensions) -> if not (feature.profile.frequency_configurable or feature.profile.power_configurable): raise ConfigError( "rf_source.cw_configure requires a configurable RF CW frequency or power field" + ) + + +def _validate_trigger_snapshot_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.TRIGGER), + None, + ) + if feature is None or RfFeatureDirection.READ not in feature.directions: + raise ConfigError( + "rf_source.trigger_snapshot requires an RF trigger feature with read direction" ) + if not isinstance(feature.profile, RfTriggerProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.trigger_snapshot requires an RF trigger profile") + if not feature.profile.state_readable: + raise ConfigError("rf_source.trigger_snapshot requires readable RF trigger state") def _validate_output_feature(extensions: RfSourceDescriptorExtensions) -> None: diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 88ad873..fe99f3c 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -25,6 +25,7 @@ RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" RF_SOURCE_PULSE_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_snapshot.v1" RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA = "wavebench.rf_source.sweep_snapshot.v1" +RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA = "wavebench.rf_source.trigger_snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" @@ -194,6 +195,26 @@ class RfPulsePolarity(StrEnum): INVERTED = "inverted" +class RfPulseTriggerMode(StrEnum): + """Logical Pulse trigger modes reported by a device configuration query.""" + + AUTOMATIC = "automatic" + BUS = "bus" + EXTERNAL = "external" + EXTERNAL_GATE = "external_gate" + KEY = "key" + + +class RfExternalTriggerEdge(StrEnum): + NEGATIVE = "negative" + POSITIVE = "positive" + + +class RfExternalGatePolarity(StrEnum): + INVERTED = "inverted" + NORMAL = "normal" + + class RfSweepState(StrEnum): DISABLED = "disabled" ENABLED = "enabled" @@ -215,12 +236,25 @@ class RfSweepSpacing(StrEnum): LINEAR = "linear" +class RfSweepMode(StrEnum): + CONTINUOUS = "continuous" + SINGLE = "single" + + +class RfSweepTriggerMode(StrEnum): + AUTOMATIC = "automatic" + BUS = "bus" + EXTERNAL = "external" + KEY = "key" + + class RfFeature(StrEnum): CW = "cw" MODULATION = "modulation" OUTPUT = "output" PULSE = "pulse" SWEEP = "sweep" + TRIGGER = "trigger" class RfFeatureDirection(StrEnum): @@ -625,8 +659,63 @@ def __post_init__(self) -> None: raise ValueError("RF sweep mode dwell_min_s must be positive") +@dataclass(frozen=True, slots=True) +class RfTriggerProfile: + """Complete read-only trigger-configuration profile for one RF output. + + The profile describes logical Pulse and Sweep trigger configuration that a + driver can read. It deliberately does not describe a physical trigger or + sync connector, its direction, or electrical characteristics; those need a + separate A5 physical-interface contract before any write or fire operation. + """ + + state_readable: bool + pulse_trigger_modes: tuple[RfPulseTriggerMode, ...] = () + pulse_external_trigger_edges: tuple[RfExternalTriggerEdge, ...] = () + pulse_external_gate_polarities: tuple[RfExternalGatePolarity, ...] = () + sweep_modes: tuple[RfSweepMode, ...] = () + sweep_period_trigger_modes: tuple[RfSweepTriggerMode, ...] = () + sweep_point_trigger_modes: tuple[RfSweepTriggerMode, ...] = () + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF trigger state_readable") + fields = ( + (self.pulse_trigger_modes, RfPulseTriggerMode, "RF trigger pulse_trigger_modes"), + ( + self.pulse_external_trigger_edges, + RfExternalTriggerEdge, + "RF trigger pulse_external_trigger_edges", + ), + ( + self.pulse_external_gate_polarities, + RfExternalGatePolarity, + "RF trigger pulse_external_gate_polarities", + ), + (self.sweep_modes, RfSweepMode, "RF trigger sweep_modes"), + ( + self.sweep_period_trigger_modes, + RfSweepTriggerMode, + "RF trigger sweep_period_trigger_modes", + ), + ( + self.sweep_point_trigger_modes, + RfSweepTriggerMode, + "RF trigger sweep_point_trigger_modes", + ), + ) + for values, enum_type, label in fields: + _require_enum_tuple(values, enum_type, label, allow_empty=not self.state_readable) + if not self.state_readable and any(values for values, _, _ in fields): + raise ValueError("RF unreadable trigger profile cannot declare trigger states") + + RfFeatureProfile: TypeAlias = ( - RfCwProfile | RfOutputProfile | RfModulationProfile | RfPulseProfile | RfSweepProfile + RfCwProfile + | RfOutputProfile + | RfModulationProfile + | RfPulseProfile + | RfSweepProfile + | RfTriggerProfile ) _FEATURE_PROFILE_TYPES: dict[RfFeature, type[RfFeatureProfile]] = { @@ -635,6 +724,7 @@ def __post_init__(self) -> None: RfFeature.OUTPUT: RfOutputProfile, RfFeature.PULSE: RfPulseProfile, RfFeature.SWEEP: RfSweepProfile, + RfFeature.TRIGGER: RfTriggerProfile, } @@ -1171,6 +1261,45 @@ def __post_init__(self) -> None: raise ValueError("RF sweep snapshot state has an invalid type") +@dataclass(frozen=True, slots=True) +class RfTriggerSnapshot: + """Complete readback of logical Pulse and Sweep trigger configuration. + + ``port_id`` identifies the RF output whose behavior the queried settings + govern. It is not a physical trigger/sync connector identifier. + """ + + port_id: str + pulse_trigger_mode: RfPulseTriggerMode + pulse_external_trigger_edge: RfExternalTriggerEdge + pulse_external_gate_polarity: RfExternalGatePolarity + sweep_mode: RfSweepMode + sweep_period_trigger_mode: RfSweepTriggerMode + sweep_point_trigger_mode: RfSweepTriggerMode + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF trigger snapshot port_id") + if not isinstance(self.pulse_trigger_mode, RfPulseTriggerMode): + raise ValueError("RF trigger snapshot pulse_trigger_mode has an invalid type") + if not isinstance(self.pulse_external_trigger_edge, RfExternalTriggerEdge): + raise ValueError( + "RF trigger snapshot pulse_external_trigger_edge has an invalid type" + ) + if not isinstance(self.pulse_external_gate_polarity, RfExternalGatePolarity): + raise ValueError( + "RF trigger snapshot pulse_external_gate_polarity has an invalid type" + ) + if not isinstance(self.sweep_mode, RfSweepMode): + raise ValueError("RF trigger snapshot sweep_mode has an invalid type") + if not isinstance(self.sweep_period_trigger_mode, RfSweepTriggerMode): + raise ValueError("RF trigger snapshot sweep_period_trigger_mode has an invalid type") + if not isinstance(self.sweep_point_trigger_mode, RfSweepTriggerMode): + raise ValueError("RF trigger snapshot sweep_point_trigger_mode has an invalid type") + + def as_dict(self) -> dict[str, object]: + return rf_trigger_snapshot_document(self) + + @dataclass(frozen=True, slots=True) class RfOutputRequest: """One explicit RF output state request for one descriptor-defined port.""" @@ -1201,6 +1330,8 @@ def __post_init__(self) -> None: class RfSourceDriver(InstrumentDriver, Protocol): def get_rf_snapshot(self) -> RfSourceSnapshot: ... + def get_rf_trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: ... + def configure_cw(self, request: RfCwRequest) -> None: ... def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: ... @@ -1336,6 +1467,16 @@ def rf_sweep_snapshot_document(snapshot: RfSweepSnapshot) -> dict[str, object]: return {"schema": RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA, **data} +def rf_trigger_snapshot_document(snapshot: RfTriggerSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF trigger configuration readback.""" + + if not isinstance(snapshot, RfTriggerSnapshot): + raise TypeError("snapshot must be RfTriggerSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA, **data} + + def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: """Build a read-only snapshot artifact without transport-private values.""" @@ -1346,6 +1487,18 @@ def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[st } +def rf_source_trigger_snapshot_operation_artifact( + snapshot: RfTriggerSnapshot, +) -> dict[str, object]: + """Build a read-only trigger-configuration artifact without transport values.""" + + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.trigger_snapshot", + "trigger_snapshot": rf_trigger_snapshot_document(snapshot), + } + + def rf_source_cw_operation_artifact( request: RfCwRequest, result: RfCwResult, @@ -1583,10 +1736,13 @@ def rf_source_output_operation_artifact( "RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA", "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", "RF_SOURCE_SNAPSHOT_SCHEMA", + "RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA", "RfAvailability", "RfCwProfile", "RfCwRequest", "RfCwResult", + "RfExternalGatePolarity", + "RfExternalTriggerEdge", "RfFeature", "RfFeatureCapability", "RfFeatureDirection", @@ -1621,6 +1777,7 @@ def rf_source_output_operation_artifact( "RfPulseSnapshot", "RfPulseSource", "RfPulseState", + "RfPulseTriggerMode", "RfReasonCode", "RfSourceDescriptorExtensions", "RfSourceDriver", @@ -1635,7 +1792,11 @@ def rf_source_output_operation_artifact( "RfSweepSnapshot", "RfSweepSpacing", "RfSweepState", + "RfSweepMode", + "RfSweepTriggerMode", "RfSweepType", + "RfTriggerProfile", + "RfTriggerSnapshot", "rf_source_canonical_json", "rf_source_cw_operation_artifact", "rf_source_digest", @@ -1643,12 +1804,14 @@ def rf_source_output_operation_artifact( "rf_modulation_state_snapshot_document", "rf_pulse_snapshot_document", "rf_sweep_snapshot_document", + "rf_trigger_snapshot_document", "rf_source_modulation_disable_operation_artifact", "rf_source_modulation_operation_artifact", "rf_source_pulse_operation_artifact", "rf_source_sweep_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", + "rf_source_trigger_snapshot_operation_artifact", "rf_source_output_operation_artifact", "rf_source_to_data", ] diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 46e5825..d17214a 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -16,10 +16,13 @@ RF_SOURCE_CONTRACT_VERSION, RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, RF_SOURCE_SNAPSHOT_SCHEMA, + RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA, RfAvailability, RfCwProfile, RfCwRequest, RfCwResult, + RfExternalGatePolarity, + RfExternalTriggerEdge, RfFeature, RfFeatureCapability, RfFeatureDirection, @@ -38,8 +41,15 @@ RfSourceSnapshot, RfSourceTopology, RfSweepState, + RfSweepMode, + RfSweepTriggerMode, + RfTriggerProfile, + RfTriggerSnapshot, + RfPulseTriggerMode, rf_source_snapshot_document, rf_source_snapshot_operation_artifact, + rf_source_trigger_snapshot_operation_artifact, + rf_trigger_snapshot_document, rf_source_cw_operation_artifact, rf_source_output_operation_artifact, ) @@ -55,6 +65,10 @@ def idn(self) -> str: def get_rf_snapshot(self) -> RfSourceSnapshot: return snapshot() + def get_rf_trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: + assert port_id == "rf_out" + return trigger_snapshot() + def configure_cw(self, request: RfCwRequest) -> None: del request @@ -140,6 +154,55 @@ def output_extensions() -> RfSourceDescriptorExtensions: ) +def trigger_profile() -> RfTriggerProfile: + return RfTriggerProfile( + state_readable=True, + pulse_trigger_modes=( + RfPulseTriggerMode.AUTOMATIC, + RfPulseTriggerMode.BUS, + RfPulseTriggerMode.EXTERNAL, + RfPulseTriggerMode.EXTERNAL_GATE, + RfPulseTriggerMode.KEY, + ), + pulse_external_trigger_edges=( + RfExternalTriggerEdge.NEGATIVE, + RfExternalTriggerEdge.POSITIVE, + ), + pulse_external_gate_polarities=( + RfExternalGatePolarity.INVERTED, + RfExternalGatePolarity.NORMAL, + ), + sweep_modes=(RfSweepMode.CONTINUOUS, RfSweepMode.SINGLE), + sweep_period_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + sweep_point_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + ) + + +def trigger_extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.TRIGGER, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=trigger_profile(), + ), + ), + ) + + def descriptor(**changes: object) -> InstrumentDescriptor: value = InstrumentDescriptor( driver_id="example.rf1", @@ -178,6 +241,18 @@ def snapshot() -> RfSourceSnapshot: ) +def trigger_snapshot() -> RfTriggerSnapshot: + return RfTriggerSnapshot( + port_id="rf_out", + pulse_trigger_mode=RfPulseTriggerMode.AUTOMATIC, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode=RfSweepMode.CONTINUOUS, + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + def test_rf_source_topology_and_features_are_strict() -> None: with pytest.raises(ValueError, match="finite"): RfOutputPortProfile("rf_out", float("nan"), 1.0, -1.0, 1.0, 50.0) @@ -256,6 +331,32 @@ def test_rf_output_request_and_result_require_explicit_boolean_state() -> None: RfOutputResult(port_id="rf_out", enabled=False, write_completed=0) # type: ignore[arg-type] +def test_rf_trigger_profile_and_snapshot_are_complete_and_typed() -> None: + profile = trigger_profile() + value = trigger_snapshot() + + assert profile.state_readable is True + assert value.pulse_trigger_mode is RfPulseTriggerMode.AUTOMATIC + assert value.sweep_mode is RfSweepMode.CONTINUOUS + with pytest.raises(ValueError, match="must not be empty"): + RfTriggerProfile(state_readable=True) + with pytest.raises(ValueError, match="cannot declare trigger states"): + RfTriggerProfile( + state_readable=False, + pulse_trigger_modes=(RfPulseTriggerMode.AUTOMATIC,), + ) + with pytest.raises(ValueError, match="invalid type"): + RfTriggerSnapshot( + port_id="rf_out", + pulse_trigger_mode=RfPulseTriggerMode.AUTOMATIC, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode="continuous", # type: ignore[arg-type] + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None: value = snapshot() document = rf_source_snapshot_document(value) @@ -270,6 +371,30 @@ def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None } +def test_rf_trigger_snapshot_document_and_artifact_are_structured_and_redacted() -> None: + value = trigger_snapshot() + document = rf_trigger_snapshot_document(value) + artifact = rf_source_trigger_snapshot_operation_artifact(value) + + assert document == { + "schema": RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA, + "type": "RfTriggerSnapshot", + "port_id": "rf_out", + "pulse_trigger_mode": "automatic", + "pulse_external_trigger_edge": "positive", + "pulse_external_gate_polarity": "normal", + "sweep_mode": "continuous", + "sweep_period_trigger_mode": "automatic", + "sweep_point_trigger_mode": "automatic", + } + assert artifact == { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.trigger_snapshot", + "trigger_snapshot": document, + } + assert "resource" not in str(artifact) + + def test_rf_cw_operation_artifact_uses_typed_pre_and_postcondition_evidence() -> None: request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) result = RfCwResult(port_id="rf_out", frequency_hz=2_000_000.0) @@ -337,6 +462,7 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: assert dict(RF_SOURCE_CAPABILITY_METHODS) == { "rf_source.idn": ("idn",), "rf_source.snapshot": ("get_rf_snapshot",), + "rf_source.trigger_snapshot": ("get_rf_trigger_snapshot",), "rf_source.cw_configure": ("configure_cw",), "rf_source.modulation_configure": ( "get_rf_modulation_state", @@ -384,6 +510,20 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: ) validate_rf_source_descriptor(cw_descriptor) validate_declared_capabilities(cw_descriptor, RfDriver()) + with pytest.raises(ConfigError, match="trigger feature with read direction"): + validate_rf_source_descriptor( + replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.trigger_snapshot"), + ) + ) + trigger_descriptor = replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.trigger_snapshot"), + rf_source_extensions=trigger_extensions(), + ) + validate_rf_source_descriptor(trigger_descriptor) + validate_declared_capabilities(trigger_descriptor, RfDriver()) with pytest.raises(ConfigError, match="output ENABLE and DISABLE"): validate_rf_source_descriptor( replace( From 9a19bfa476fbd0f238655d818ad897397be5928a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:10:22 +0800 Subject: [PATCH 47/63] feat: add read-only RF trigger status path --- src/wavebench/cli.py | 7 + src/wavebench/cli_parser.py | 14 ++ src/wavebench/services/execution_intent.py | 2 + src/wavebench/services/operation_specs.py | 10 + src/wavebench/services/rf_source_service.py | 91 +++++++ src/wavebench/services/run_plan.py | 6 + src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 7 + tests/test_rf_source_cli.py | 31 +++ tests/test_rf_source_run.py | 108 +++++++++ tests/test_rf_source_trigger_service.py | 251 ++++++++++++++++++++ 11 files changed, 528 insertions(+) create mode 100644 tests/test_rf_source_trigger_service.py diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index efc5325..d21a84c 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -1549,6 +1549,13 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "trigger": + result = service.trigger_snapshot(args.port) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.command == "set-frequency": result = service.configure_cw( RfCwRequest(port_id=args.port, frequency_hz=args.frequency_hz) diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index ce85e1b..5a93143 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -653,6 +653,20 @@ def build_parser() -> argparse.ArgumentParser: help="Query a typed, read-only RF source snapshot", ) add_runtime_options(rf_source_status) + rf_source_trigger = rf_source_sub.add_parser( + "trigger", + help="Read declared Pulse and Sweep trigger configuration without triggering", + ) + rf_source_trigger_sub = rf_source_trigger.add_subparsers( + dest="trigger_command", + required=True, + ) + rf_source_trigger_status = rf_source_trigger_sub.add_parser( + "status", + help="Read logical trigger configuration without changing RF or trigger state", + ) + rf_source_trigger_status.add_argument("--port", required=True) + add_runtime_options(rf_source_trigger_status) rf_source_set_frequency = rf_source_sub.add_parser( "set-frequency", help="Configure one RF port frequency while its RF output is OFF", diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index a3e0a1b..c0b4594 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -24,10 +24,12 @@ "sweep.frequency_response": "scope.capture_waveforms", "source.status": "source.status", "rf_source.status": "rf_source.snapshot", + "rf_source.trigger_status": "rf_source.trigger_snapshot", "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", "rf_source.pulse_configure": "rf_source.pulse_configure", + "rf_source.sweep_configure": "rf_source.sweep_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", "source.arb_load": "source.arbitrary_upload", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index e39b0d9..acaa074 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1037,6 +1037,16 @@ def _spec( error_check_minimum="disabled", risk_flags=("state_dependent_query",), ), + _spec( + "rf_source.trigger_snapshot", + "rf_source", + required_capabilities=("rf_source.trigger_snapshot",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + error_check_minimum="disabled", + risk_flags=("state_dependent_query", "trigger_configuration"), + ), _spec( "rf_source.set_frequency", "rf_source", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index ae3d17e..019d5ec 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -59,12 +59,15 @@ RfSweepSpacing, RfSweepState, RfSweepType, + RfTriggerProfile, + RfTriggerSnapshot, rf_source_cw_operation_artifact, rf_source_modulation_disable_operation_artifact, rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, rf_source_pulse_operation_artifact, rf_source_sweep_operation_artifact, + rf_source_trigger_snapshot_operation_artifact, ) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy @@ -242,6 +245,38 @@ def snapshot(self) -> RfSourceSnapshot: raise ConfigError("rf_source.snapshot requires a healthy session") return rf_source.get_rf_snapshot() + def trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: + """Read a declared logical trigger configuration without changing state.""" + + operation = "rf_source.trigger_snapshot" + self._require(operation, "rf_source.trigger_snapshot") + profile = self._validate_trigger_snapshot_descriptor(port_id, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + snapshot = rf_source.get_rf_trigger_snapshot(port_id) + else: + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + snapshot = rf_source.get_rf_trigger_snapshot(port_id) + self._validate_trigger_snapshot_readback( + port_id, + snapshot, + profile, + operation=operation, + ) + return snapshot + + def trigger_snapshot_with_artifact( + self, + port_id: str, + ) -> tuple[RfTriggerSnapshot, dict[str, object]]: + """Read a declared trigger configuration and retain redacted evidence.""" + + snapshot = self.trigger_snapshot(port_id) + return snapshot, rf_source_trigger_snapshot_operation_artifact(snapshot) + def configure_cw(self, request: RfCwRequest) -> RfCwResult: return self._configure_cw_transaction(request).result @@ -1036,6 +1071,62 @@ def _validate_cw_descriptor( raise ConfigError(f"{operation} request power_dbm is outside the descriptor range") return port_profile, profile + def _validate_trigger_snapshot_descriptor( + self, + port_id: str, + operation: str, + ) -> RfTriggerProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.TRIGGER), + None, + ) + if ( + feature is None + or RfFeatureDirection.READ not in feature.directions + or port_id not in feature.port_ids + or not isinstance(feature.profile, RfTriggerProfile) + or not feature.profile.state_readable + ): + raise ConfigError( + f"{operation} requires a readable trigger profile for the target port" + ) + return feature.profile + + @staticmethod + def _validate_trigger_snapshot_readback( + port_id: str, + snapshot: object, + profile: RfTriggerProfile, + *, + operation: str, + ) -> None: + if not isinstance(snapshot, RfTriggerSnapshot): + raise ConfigError(f"{operation} driver returned an invalid trigger snapshot") + if snapshot.port_id != port_id: + raise ConfigError(f"{operation} trigger snapshot does not match the requested port") + if snapshot.pulse_trigger_mode not in profile.pulse_trigger_modes: + raise ConfigError(f"{operation} readback pulse trigger mode is outside the descriptor profile") + if snapshot.pulse_external_trigger_edge not in profile.pulse_external_trigger_edges: + raise ConfigError(f"{operation} readback external trigger edge is outside the descriptor profile") + if snapshot.pulse_external_gate_polarity not in profile.pulse_external_gate_polarities: + raise ConfigError(f"{operation} readback external gate polarity is outside the descriptor profile") + if snapshot.sweep_mode not in profile.sweep_modes: + raise ConfigError(f"{operation} readback Sweep mode is outside the descriptor profile") + if snapshot.sweep_period_trigger_mode not in profile.sweep_period_trigger_modes: + raise ConfigError( + f"{operation} readback Sweep-period trigger mode is outside the descriptor profile" + ) + if snapshot.sweep_point_trigger_mode not in profile.sweep_point_trigger_modes: + raise ConfigError( + f"{operation} readback Sweep-point trigger mode is outside the descriptor profile" + ) + def _validate_pulse_descriptor( self, request: RfPulseConfigureRequest, diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 457eb16..c9d5883 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -22,6 +22,7 @@ "sweep.frequency_response", "source.status", "rf_source.status", + "rf_source.trigger_status", "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", @@ -72,6 +73,7 @@ "source.set_vpp": ("value_vpp",), "source.set_duty": ("duty_percent",), "source.output": ("state",), + "rf_source.trigger_status": ("port_id",), "rf_source.set_frequency": ("port_id", "frequency_hz"), "rf_source.set_power_dbm": ("port_id", "power_dbm"), "rf_source.modulation_configure": ( @@ -196,6 +198,7 @@ }, "source.status": {"channel", "on_failure"}, "rf_source.status": {"on_failure"}, + "rf_source.trigger_status": {"on_failure"}, "rf_source.set_frequency": {"on_failure"}, "rf_source.set_power_dbm": {"on_failure"}, "rf_source.modulation_configure": { @@ -267,6 +270,7 @@ "sweep.frequency_response": "Sweep a source through discrete frequencies, capture reference and response channels in one acquisition per point, and write a Bode response CSV.", "source.status": "Read signal-generator channel state without changing output.", "rf_source.status": "Read a typed RF-source snapshot without changing output.", + "rf_source.trigger_status": "Read declared logical Pulse and Sweep trigger configuration without changing RF or trigger state.", "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", @@ -671,6 +675,8 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["frequency_hz"], f"{prefix}.frequency_hz", ) + elif kind == "rf_source.trigger_status": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") elif kind == "rf_source.set_power_dbm": fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index feee6ae..52c7760 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -22,6 +22,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "sweep.frequency_response", "source.status", "rf_source.status", + "rf_source.trigger_status", "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 57412f5..eff548c 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -454,6 +454,8 @@ def add_source_output_gate_capability() -> None: add("source", "source.status") elif step.kind == "rf_source.status": add("rf_source", "rf_source.snapshot") + elif step.kind == "rf_source.trigger_status": + add("rf_source", "rf_source.trigger_snapshot") elif step.kind in {"rf_source.set_frequency", "rf_source.set_power_dbm"}: add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") elif step.kind == "rf_source.modulation_configure": @@ -1218,6 +1220,11 @@ def _run_step( elif step.kind == "rf_source.status": snapshot = self._rf_source_service(services=services).snapshot() artifact = {"rf_source_operation": rf_source_snapshot_operation_artifact(snapshot)} + elif step.kind == "rf_source.trigger_status": + _, rf_source_operation = self._rf_source_service( + services=services + ).trigger_snapshot_with_artifact(step.fields["port_id"]) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind == "rf_source.set_frequency": _, rf_source_operation = self._rf_source_service( services=services diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index cb9f05f..09fe305 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -28,6 +28,9 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands( ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] ) status = build_parser().parse_args(["rf-source", "status"]) + trigger_status = build_parser().parse_args( + ["rf-source", "trigger", "status", "--port", "rf_out"] + ) frequency = build_parser().parse_args( ["rf-source", "set-frequency", "--port", "rf_out", "4000000"] ) @@ -113,6 +116,12 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands( assert identity.config == "rf.toml" assert identity.resource == "TCPIP::rf::INSTR" assert (status.domain, status.command) == ("rf-source", "status") + assert (trigger_status.domain, trigger_status.command, trigger_status.trigger_command) == ( + "rf-source", + "trigger", + "status", + ) + assert trigger_status.port == "rf_out" assert (frequency.domain, frequency.command) == ("rf-source", "set-frequency") assert frequency.port == "rf_out" assert frequency.frequency_hz == 4_000_000.0 @@ -177,6 +186,28 @@ def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: service.snapshot.assert_called_once_with() +def test_rf_source_cli_dispatches_read_only_trigger_snapshot() -> None: + service = Mock() + service.trigger_snapshot.return_value = SimpleNamespace( + as_dict=lambda: { + "schema": "wavebench.rf_source.trigger_snapshot.v1", + "port_id": "rf_out", + "pulse_trigger_mode": "automatic", + "sweep_mode": "continuous", + } + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert main(["--json", "rf-source", "trigger", "status", "--port", "rf_out"]) == 0 + + payload = json.loads(stdout.getvalue()) + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"]["schema"] == "wavebench.rf_source.trigger_snapshot.v1" + assert payload["result"]["port_id"] == "rf_out" + service.trigger_snapshot.assert_called_once_with("rf_out") + + def test_rf_source_cli_dispatches_each_off_only_cw_request() -> None: service = Mock() service.configure_cw.side_effect = [ diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index da2b88b..ad4ebfa 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -49,6 +49,12 @@ RfSweepSpacing, RfSweepState, RfSweepType, + RfExternalGatePolarity, + RfExternalTriggerEdge, + RfPulseTriggerMode, + RfSweepMode, + RfSweepTriggerMode, + RfTriggerSnapshot, rf_source_cw_operation_artifact, rf_source_modulation_operation_artifact, rf_source_pulse_operation_artifact, @@ -57,6 +63,7 @@ RfOutputResult, rf_source_output_operation_artifact, rf_source_snapshot_operation_artifact, + rf_source_trigger_snapshot_operation_artifact, ) from wavebench.logging import CommandLogger from wavebench.services.execution_intent import build_execution_intent @@ -104,6 +111,15 @@ def _cw_plan(directory: str, *, kind: str, field: str, value: float): return load_run_plan(path) +def _trigger_plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + '[[steps]]\nkind = "rf_source.trigger_status"\nport_id = "rf_out"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + def _output_plan(directory: str, *, kind: str): path = Path(directory) / "plan.toml" path.write_text( @@ -179,6 +195,18 @@ def _snapshot() -> RfSourceSnapshot: ) +def _trigger_snapshot() -> RfTriggerSnapshot: + return RfTriggerSnapshot( + port_id="rf_out", + pulse_trigger_mode=RfPulseTriggerMode.AUTOMATIC, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode=RfSweepMode.CONTINUOUS, + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + def _descriptor(*capabilities: str) -> SimpleNamespace: return SimpleNamespace( driver_id="example.rf1", @@ -214,6 +242,86 @@ def test_rf_source_status_schema_and_intent_are_read_only() -> None: ) +def test_rf_trigger_status_schema_and_intent_are_read_only() -> None: + with TemporaryDirectory() as directory: + plan = _trigger_plan(directory) + intent = build_execution_intent(plan, _config(directory)) + + assert STEP_SCHEMAS["rf_source.trigger_status"].required == ("port_id",) + assert intent.operations == ( + { + "step_index": 0, + "step_kind": "rf_source.trigger_status", + "operation": "rf_source.trigger_snapshot", + "instrument_kind": "rf_source", + "effect": "stateful_read", + "lease_mode": "exclusive", + "changed_fields": [], + "restore_coverage": "none-read-only", + "session_purpose": "normal", + "required_verified_fields": [], + "verification_fields": [], + "timeout_source": "connection.timeout_ms", + "risk_flags": ["state_dependent_query", "trigger_configuration"], + "parameters": {"port_id": "rf_out"}, + "policy": {"on_failure": "stop", "safety_gate": {}}, + }, + ) + + +def test_rf_trigger_status_requires_capability_before_session_opens() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.trigger_snapshot"): + service.run(_trigger_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_trigger_status_run_uses_isolated_service_and_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _trigger_plan(directory) + snapshot = _trigger_snapshot() + artifact = rf_source_trigger_snapshot_operation_artifact(snapshot) + rf_service = SimpleNamespace( + trigger_snapshot_with_artifact=Mock(return_value=(snapshot, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.trigger_snapshot"), + ): + result = OfflineRfRunService(config=_config(directory), logger=CommandLogger()).run(plan) + + rf_service.trigger_snapshot_with_artifact.assert_called_once_with("rf_out") + assert result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + assert "source_operations" not in run_data + + +def test_rf_sweep_configure_execution_intent_uses_its_declared_operation() -> None: + with TemporaryDirectory() as directory: + intent = build_execution_intent(_sweep_plan(directory), _config(directory, access="read_write")) + + assert intent.operations[0]["operation"] == "rf_source.sweep_configure" + assert intent.operations[0]["effect"] == "write" + + def test_rf_source_status_requires_snapshot_capability_before_session_opens() -> None: with TemporaryDirectory() as directory: service = RunService(config=_config(directory), logger=CommandLogger()) diff --git a/tests/test_rf_source_trigger_service.py b/tests/test_rf_source_trigger_service.py new file mode 100644 index 0000000..224e3a1 --- /dev/null +++ b/tests/test_rf_source_trigger_service.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfExternalGatePolarity, + RfExternalTriggerEdge, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfOutputPortProfile, + RfPulseTriggerMode, + RfSourceDescriptorExtensions, + RfSourceTopology, + RfSweepMode, + RfSweepTriggerMode, + RfTriggerProfile, + RfTriggerSnapshot, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_only") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf.trigger", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _profile(*, state_readable: bool = True) -> RfTriggerProfile: + if not state_readable: + return RfTriggerProfile(state_readable=False) + return RfTriggerProfile( + state_readable=True, + pulse_trigger_modes=( + RfPulseTriggerMode.AUTOMATIC, + RfPulseTriggerMode.BUS, + RfPulseTriggerMode.EXTERNAL, + RfPulseTriggerMode.EXTERNAL_GATE, + RfPulseTriggerMode.KEY, + ), + pulse_external_trigger_edges=( + RfExternalTriggerEdge.NEGATIVE, + RfExternalTriggerEdge.POSITIVE, + ), + pulse_external_gate_polarities=( + RfExternalGatePolarity.INVERTED, + RfExternalGatePolarity.NORMAL, + ), + sweep_modes=(RfSweepMode.CONTINUOUS, RfSweepMode.SINGLE), + sweep_period_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + sweep_point_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + ) + + +def _descriptor( + *capabilities: str, + profile: RfTriggerProfile | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.trigger", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.TRIGGER, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=profile or _profile(), + ), + ), + ), + ) + + +def _snapshot( + *, + port_id: str = "rf_out", + pulse_trigger_mode: RfPulseTriggerMode = RfPulseTriggerMode.AUTOMATIC, +) -> RfTriggerSnapshot: + return RfTriggerSnapshot( + port_id=port_id, + pulse_trigger_mode=pulse_trigger_mode, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode=RfSweepMode.CONTINUOUS, + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + +class _Driver: + def __init__(self, snapshot: RfTriggerSnapshot) -> None: + self.snapshot = snapshot + self.calls: list[str] = [] + self.write_calls: list[str] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: + self.calls.append("trigger_snapshot") + assert port_id == "rf_out" + return self.snapshot + + +def _service( + snapshot: RfTriggerSnapshot, + *, + access: str = "read_only", + descriptor: SimpleNamespace | None = None, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver(snapshot) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor("rf_source.idn", "rf_source.trigger_snapshot"), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_trigger_snapshot_reads_declared_state_without_writes() -> None: + service, driver = _service(_snapshot()) + + result, artifact = service.trigger_snapshot_with_artifact("rf_out") + + assert result.pulse_trigger_mode is RfPulseTriggerMode.AUTOMATIC + assert driver.calls == ["trigger_snapshot"] + assert driver.write_calls == [] + assert artifact["operation"] == "rf_source.trigger_snapshot" + assert artifact["trigger_snapshot"]["port_id"] == "rf_out" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_trigger_snapshot_rejects_missing_capability_or_profile_before_driver_io() -> None: + missing_capability, missing_capability_driver = _service( + _snapshot(), + descriptor=_descriptor("rf_source.idn"), + ) + with pytest.raises(ConfigError, match="rf_source.trigger_snapshot"): + missing_capability.trigger_snapshot("rf_out") + assert missing_capability_driver.calls == [] + assert missing_capability_driver.write_calls == [] + + unreadable_profile, unreadable_profile_driver = _service( + _snapshot(), + descriptor=_descriptor( + "rf_source.idn", + "rf_source.trigger_snapshot", + profile=_profile(state_readable=False), + ), + ) + with pytest.raises(ConfigError, match="readable trigger profile"): + unreadable_profile.trigger_snapshot("rf_out") + assert unreadable_profile_driver.calls == [] + assert unreadable_profile_driver.write_calls == [] + + wrong_port, wrong_port_driver = _service(_snapshot()) + with pytest.raises(ConfigError, match="undeclared RF port"): + wrong_port.trigger_snapshot("not_a_port") + assert wrong_port_driver.calls == [] + assert wrong_port_driver.write_calls == [] + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_snapshot(port_id="other"), "does not match the requested port"), + ( + _snapshot(pulse_trigger_mode=RfPulseTriggerMode.BUS), + "pulse trigger mode is outside the descriptor profile", + ), + ), +) +def test_trigger_snapshot_rejects_unexpected_readback_without_writes( + snapshot: RfTriggerSnapshot, + message: str, +) -> None: + profile = _profile() + if snapshot.pulse_trigger_mode is RfPulseTriggerMode.BUS: + profile = RfTriggerProfile( + state_readable=True, + pulse_trigger_modes=(RfPulseTriggerMode.AUTOMATIC,), + pulse_external_trigger_edges=(RfExternalTriggerEdge.POSITIVE,), + pulse_external_gate_polarities=(RfExternalGatePolarity.NORMAL,), + sweep_modes=(RfSweepMode.CONTINUOUS,), + sweep_period_trigger_modes=(RfSweepTriggerMode.AUTOMATIC,), + sweep_point_trigger_modes=(RfSweepTriggerMode.AUTOMATIC,), + ) + service, driver = _service( + snapshot, + descriptor=_descriptor("rf_source.idn", "rf_source.trigger_snapshot", profile=profile), + ) + + with pytest.raises(ConfigError, match=message): + service.trigger_snapshot("rf_out") + + assert driver.calls == ["trigger_snapshot"] + assert driver.write_calls == [] From 802b632ec21e1627060a4a82ced4f71bdf25a134 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:20:52 +0800 Subject: [PATCH 48/63] docs: define A5 read-only trigger boundary --- README.md | 2 +- ...\221\351\207\214\347\250\213\347\242\221.md" | 17 ++++++++++++----- ...\267\346\272\220\350\256\276\350\256\241.md" | 14 ++++++++++---- ...\277\347\224\250\346\214\207\345\215\227.md" | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b0fa995..9a0d650 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`;调制配置不授权调制开启时的 RF 输出。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled;它不提供 `modulation_disable`、execute、arm、fire、trigger、Level Sweep 或 list。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`;调制配置不授权调制开启时的 RF 输出。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。A5-0 已具备逻辑 Pulse/Sweep trigger configuration 的只读代码合同,但 DSG830 production descriptor 未声明该 capability;它不代表后面板 trigger/sync 接口已定义或可操作。当前 production 范围仍不提供 `modulation_disable`、execute、arm、fire、trigger、Level Sweep 或 list。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 860fa85..03c1c51 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M4 合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM,以及 internal/single Pulse 与 frequency-only Step Sweep 的类型合同、Service、CLI、run 路径与 artifact;按模式调制关闭仅用于本地证据与私有恢复。 | -| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep 的 A4 均已通过并提升;A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`。 | -| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。调制证据不提升调制开启时的 RF 输出。 | +| Core `0.8.25` 开发线 | M0–M4 与 A5-0 离线只读合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep,以及逻辑 trigger configuration 的只读类型、Service、CLI、run 和 artifact;按模式调制关闭仅用于本地证据与私有恢复。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep 的 A4 均已通过并提升;A5-0 映射已完成,物理 A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 与六条固定 trigger configuration query 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;物理 A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A5-0 不产生 production capability,也不提升调制开启时的 RF 输出。 | ## 双仓库交付规则 @@ -33,6 +33,7 @@ | M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;DSG830 production 已开放 `rf_source.modulation_configure`。PM 的 production profile 固定为 `1.25 rad`。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | +| A5-0 | 离线完成;不属于物理 A5 证据 | `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、只读 Service/CLI/run/artifact | `:PULM:TRIG:MODE?`、external edge/gate query、Sweep mode/period/point trigger query 与严格 enum parser | 只使用 `TRIGGER / READ` profile 和非 production descriptor;固定 query 顺序、零 write、未知值失败关闭。它不定义物理 connector,不发送 trigger,也不提升 production capability。 | ## Seed:历史种子包 @@ -125,7 +126,13 @@ Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出 每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 -### A5:外部 trigger/同步的进入条件(未开始) +### A5-0:逻辑 trigger configuration 读取(离线完成) + +A5-0 是物理 A5 之前的只读基础,不验证外部 trigger/同步接线。Core 已增加 `RfTriggerProfile` 和 `RfTriggerSnapshot`,以封闭 enum 表示 Pulse trigger mode、external trigger edge、external gate polarity、Sweep mode、Sweep period trigger 与 Sweep point trigger。`rf_source.trigger_snapshot`、`wavebench rf-source trigger status --port PORT_ID` 与 `rf_source.trigger_status` 都要求目标端口的 `TRIGGER / READ` profile;操作为 `stateful_read`,不读取普通 RF snapshot、不写入、不触发且不做 recovery。 + +DSG830 driver 已用六条固定 query 读取该逻辑 configuration,并对别名和未知响应执行严格解析。production descriptor 仍不声明 `rf_source.trigger_snapshot` 或 `TRIGGER` feature;因此普通 DSG830 配置会在 session 建立前拒绝该入口。`port_id` 只表示这些设置影响的 RF 输出,不表示物理 trigger/sync connector,也不从 CH2 的 50 Ω 或 `rf_out` 的 dBm 参考推导电气条件。 + +### A5:外部 trigger/同步的进入条件(物理验收未开始) A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out` 证据开始。一次验收只能覆盖一个明确目标,例如 Pulse 的 external trigger、Sweep period trigger 或 Sweep point trigger;不能把其中一项外推为其它 trigger、fire、同步或后面板辅助输出能力。 @@ -137,7 +144,7 @@ A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out | 初始与恢复状态 | 初始 RF 输出、调制、Pulse、Sweep、protection 与后面板配置;失败后的恢复方式和最终 RF OFF 独立确认方式。 | | 观察方式 | 如使用示波器,只能作为补充观察;必须核对其输入与接线,且不能替代仪器端读回。CH2 的 50 Ω 声明仅适用于已确认的 RF 路径。 | -在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成单一路径的 Core typed contract、descriptor validator、fake driver 与零写拒绝测试;再实现私有 `read_only` 诊断;最后在已确认接线和电气边界下设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 +在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser 与 fake transport 零写回归;再实现保持原始 `read_only` 配置的私有零写诊断;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 ### A1:已完成的只读 snapshot 验收 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 3f39ead..2668cb5 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -169,6 +169,7 @@ class RfFeature(StrEnum): MODULATION = "modulation" PULSE = "pulse" SWEEP = "sweep" + TRIGGER = "trigger" class RfFeatureDirection(StrEnum): @@ -201,7 +202,7 @@ class RfSourceDescriptorExtensions: 每个 protection policy 的 `code` 必须非空且唯一。Core 以 policy 集合识别已知 condition;只有 `blocks_output_enable=False` 的已知 active code 可以不阻断 RF ON。不存在 policy 的 active code 必须拒绝 RF ON。 -`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfPulseProfile` 或 `RfSweepProfile` 的封闭联合。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 +`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfPulseProfile`、`RfSweepProfile` 或 `RfTriggerProfile` 的封闭联合。`RfTriggerProfile` 只描述可读取的逻辑 Pulse/Sweep trigger configuration 值;它不表示物理 trigger/sync 接口、方向、电平或端接。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 每个 `RfFeatureCapability` 必须指定 feature、direction、适用端口、静态限制和可读回字段。静态 profile 只能收紧设备支持范围,不能授权未声明的 operation。`rf_source.pulse_trigger` 对应 `PULSE / TRIGGER`;`rf_source.sweep_fire` 对应 `SWEEP / FIRE`;其他 operation 也必须在 M0–M4 的 descriptor validator 中有唯一映射。 @@ -213,6 +214,7 @@ Core 在调用目标 driver operation 前校验 request、access、descriptor | --- | --- | --- | | `rf_source.idn` | `idn()` | 身份查询 | | `rf_source.snapshot` | `get_rf_snapshot()` | 只读完整快照 | +| `rf_source.trigger_snapshot` | `get_rf_trigger_snapshot(port_id)` | 只读逻辑 Pulse/Sweep trigger configuration;不表示物理 trigger connector。 | | `rf_source.cw_configure` | `configure_cw(request)` | 端口频率与 dBm 功率配置 | | `rf_source.output` | `set_rf_output(request)` | 单端口 RF ON/OFF | | `rf_source.modulation_configure` | `configure_rf_modulation(request)` | 已声明的 AM/FM/PM 配置 | @@ -294,6 +296,10 @@ wavebench rf-source idn wavebench rf-source status rf_source.status +# A5-0 离线只读:需要声明 rf_source.trigger_snapshot 的非 production descriptor +wavebench rf-source trigger status --port PORT_ID +rf_source.trigger_status + # 生产 M1:仅在已完成 A3 的插件上,且必须同时具备 read_write、CW capability 和 OFF-only preflight wavebench rf-source set-frequency --port PORT_ID HZ wavebench rf-source set-power --port PORT_ID DBM @@ -306,7 +312,7 @@ rf_source.output_enable rf_source.output_disable ``` -`rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 +`rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`rf-source trigger status` 和 `rf_source.trigger_status` 要求独立的 `rf_source.trigger_snapshot` capability,以及目标 `port_id` 的 `TRIGGER / READ` profile;它们是 `stateful_read`,不读取普通 RF snapshot、不执行 recovery、不写入或触发。DSG830 当前 production descriptor 未声明该 capability,因此该命令会在打开 session 前拒绝。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 已由 A3 声明 `rf_source.cw_configure`,并由 A2 声明 `rf_source.output`;M1 仅在 `read_write`、目标端口明确 OFF 与完整 OFF-only preflight 同时成立时可执行,M2 还要求完整端口 safety 配置和 fresh preflight。 @@ -353,7 +359,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。新的 operation 必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 -在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。它不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 +在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。它不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 ## M0–M4 里程碑 @@ -389,7 +395,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM,严格 readback 后由 A4 harness 执行受限调制关闭;PM production profile 固定为 `1.25 rad`,`rf_source.modulation_disable` 仍不进入 descriptor。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 harness 在对应 descriptor 提升后拒绝重跑;普通 M3/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。既有证据不开放调制输出、Sweep fire 或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM,严格 readback 后由 A4 harness 执行受限调制关闭;PM production profile 固定为 `1.25 rad`,`rf_source.modulation_disable` 仍不进入 descriptor。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 harness 在对应 descriptor 提升后拒绝重跑;普通 M3/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。既有证据不开放调制输出、Sweep fire、后面板配置或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index aa76266..0d5e66b 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -25,6 +25,7 @@ | 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。调制开启时的 RF 输出仍未开放。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | +| 逻辑 trigger configuration 读取 | A5-0 离线合同完成 | 未开放 | `rf-source trigger status`/`rf_source.trigger_status` 需要独立 capability 和 `TRIGGER / READ` profile;当前 DSG830 production descriptor 会拒绝该请求。它不读取或配置物理 trigger/sync 接口。 | | trigger、arm/fire、Level Sweep | 未完成 | 未开放 | 不应尝试调用或绕过。 | 生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 @@ -98,6 +99,19 @@ wavebench rf-source sweep configure --config wavebench.toml --port rf_out --star `output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 +## A5-0:逻辑 trigger configuration 读取 + +Core 已提供下列只读入口: + +```text +wavebench rf-source trigger status --port PORT_ID +rf_source.trigger_status +``` + +它读取 Pulse trigger mode、external trigger edge、external gate polarity、Sweep mode、Sweep period trigger 与 Sweep point trigger 的封闭类型化状态。该操作是 `stateful_read`,不读取普通 RF snapshot,不发送 setter、RF 输出、`*TRG`、`:SWE:EXEC` 或后面板配置命令。 + +入口仍由 `rf_source.trigger_snapshot` capability 和目标端口的 `TRIGGER / READ` profile 门控。DSG830 当前 production descriptor 不声明该 capability,因此日常配置会在建立 session 前被拒绝。它只适用于非 production 的离线测试 descriptor 或后续私有零写诊断;`rf_out` 表示受这些配置影响的 RF 输出,不是物理 trigger/sync connector。外部 trigger、arm/fire、后面板接口和同步仍需明确物理接线、电气边界与 A5 证据。 + ## run plan 中的 RF 步骤 CW 和输出步骤使用独立的 `rf_source.*` kind,并把 evidence 写入 `run.json.rf_source_operations`: From 771b64ce8ac3c9b3c09b695a536810e6a1bd1759 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:34:22 +0800 Subject: [PATCH 49/63] docs: record A5 diagnostic harness boundary --- ...\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" | 2 +- ...\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" | 2 +- ...\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 03c1c51..acce5e9 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -144,7 +144,7 @@ A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out | 初始与恢复状态 | 初始 RF 输出、调制、Pulse、Sweep、protection 与后面板配置;失败后的恢复方式和最终 RF OFF 独立确认方式。 | | 观察方式 | 如使用示波器,只能作为补充观察;必须核对其输入与接线,且不能替代仪器端读回。CH2 的 50 Ω 声明仅适用于已确认的 RF 路径。 | -在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser 与 fake transport 零写回归;再实现保持原始 `read_only` 配置的私有零写诊断;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 +在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser、fake transport 零写回归,以及保持原始 `read_only` 配置的私有零写 harness;再在隔离配置中执行该 harness 的只读诊断并复核私有证据;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 ### A1:已完成的只读 snapshot 验收 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 2668cb5..907870b 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -359,7 +359,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。新的 operation 必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 -在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。它不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 +在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,成功时只执行 22 次 query 和零 write;它仍只通过 fake 回归,尚未构成 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 ## M0–M4 里程碑 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 0d5e66b..d1b7133 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -110,7 +110,7 @@ rf_source.trigger_status 它读取 Pulse trigger mode、external trigger edge、external gate polarity、Sweep mode、Sweep period trigger 与 Sweep point trigger 的封闭类型化状态。该操作是 `stateful_read`,不读取普通 RF snapshot,不发送 setter、RF 输出、`*TRG`、`:SWE:EXEC` 或后面板配置命令。 -入口仍由 `rf_source.trigger_snapshot` capability 和目标端口的 `TRIGGER / READ` profile 门控。DSG830 当前 production descriptor 不声明该 capability,因此日常配置会在建立 session 前被拒绝。它只适用于非 production 的离线测试 descriptor 或后续私有零写诊断;`rf_out` 表示受这些配置影响的 RF 输出,不是物理 trigger/sync connector。外部 trigger、arm/fire、后面板接口和同步仍需明确物理接线、电气边界与 A5 证据。 +入口仍由 `rf_source.trigger_snapshot` capability 和目标端口的 `TRIGGER / READ` profile 门控。DSG830 当前 production descriptor 不声明该 capability,因此日常配置会在建立 session 前被拒绝。它只适用于非 production 的离线测试 descriptor 或源码 checkout 中的私有零写诊断;后者保持 `read_only`、禁用读重试,成功预算为 22 次 query 与零 write。`rf_out` 表示受这些配置影响的 RF 输出,不是物理 trigger/sync connector。外部 trigger、arm/fire、后面板接口和同步仍需明确物理接线、电气边界与 A5 证据。 ## run plan 中的 RF 步骤 From 7e2f9be692107b805fe00d9f9d2f93913ca727b2 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:39:18 +0800 Subject: [PATCH 50/63] docs: record A5 zero-write diagnostic result --- ...\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" | 2 +- ...\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index acce5e9..022f349 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -144,7 +144,7 @@ A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out | 初始与恢复状态 | 初始 RF 输出、调制、Pulse、Sweep、protection 与后面板配置;失败后的恢复方式和最终 RF OFF 独立确认方式。 | | 观察方式 | 如使用示波器,只能作为补充观察;必须核对其输入与接线,且不能替代仪器端读回。CH2 的 50 Ω 声明仅适用于已确认的 RF 路径。 | -在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser、fake transport 零写回归,以及保持原始 `read_only` 配置的私有零写 harness;再在隔离配置中执行该 harness 的只读诊断并复核私有证据;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 +在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser、fake transport 零写回归,以及保持原始 `read_only` 配置的私有零写 harness;隔离诊断已完成 22 次 query、零 write、最终 RF OFF 和健康关闭复核;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 ### A1:已完成的只读 snapshot 验收 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index 907870b..c7512a9 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -359,7 +359,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。新的 operation 必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 -在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,成功时只执行 22 次 query 和零 write;它仍只通过 fake 回归,尚未构成 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 +在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成一次隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。该诊断不构成物理 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 ## M0–M4 里程碑 From f9c46e24bfe96fa8bdeea2ef9aac1f686b32ddc9 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:54:59 +0800 Subject: [PATCH 51/63] feat: define modulated RF output capability contract --- .../instruments/rf_source_capabilities.py | 95 ++++++ .../instruments/rf_source_extensions.py | 132 ++++++++ ...t_rf_source_modulated_output_extensions.py | 302 ++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 tests/test_rf_source_modulated_output_extensions.py diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 9aa49f8..7f7a09b 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -18,6 +18,7 @@ RfCwProfile, RfFeature, RfFeatureDirection, + RfModulatedOutputProfile, RfModulationProfile, RfOutputProfile, RfPulseProfile, @@ -42,6 +43,10 @@ "get_rf_modulation_state", "disable_rf_modulation", ), + "rf_source.modulated_output_enable": ( + "get_rf_modulation_snapshot", + "set_rf_output", + ), "rf_source.pulse_configure": ( "get_rf_pulse_snapshot", "configure_rf_pulse", @@ -94,6 +99,16 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No _validate_modulation_configure_feature(extensions) if "rf_source.modulation_disable" in rf_capabilities: _validate_modulation_disable_feature(extensions) + if "rf_source.modulated_output_enable" in rf_capabilities: + if "rf_source.output" not in rf_capabilities: + raise ConfigError( + "rf_source.modulated_output_enable requires the rf_source.output capability" + ) + if "rf_source.modulation_configure" not in rf_capabilities: + raise ConfigError( + "rf_source.modulated_output_enable requires the rf_source.modulation_configure capability" + ) + _validate_modulated_output_enable_feature(extensions) if "rf_source.pulse_configure" in rf_capabilities: _validate_pulse_configure_feature(extensions) if "rf_source.sweep_configure" in rf_capabilities: @@ -205,6 +220,86 @@ def _validate_modulation_disable_feature(extensions: RfSourceDescriptorExtension raise ConfigError("rf_source.modulation_disable requires readable RF modulation state") +def _validate_modulated_output_enable_feature(extensions: RfSourceDescriptorExtensions) -> None: + output_feature = next( + (item for item in extensions.features if item.feature is RfFeature.OUTPUT), + None, + ) + modulation_feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATED_OUTPUT), + None, + ) + if ( + output_feature is None + or RfFeatureDirection.ENABLE not in output_feature.directions + or RfFeatureDirection.DISABLE not in output_feature.directions + or not isinstance(output_feature.profile, RfOutputProfile) + or not output_feature.profile.output_readable + ): + raise ConfigError( + "rf_source.modulated_output_enable requires a readable base RF output profile" + ) + if ( + modulation_feature is None + or RfFeatureDirection.CONFIGURE not in modulation_feature.directions + or RfFeatureDirection.READ not in modulation_feature.directions + or not isinstance(modulation_feature.profile, RfModulationProfile) + or not modulation_feature.profile.configuration_readable + or not modulation_feature.profile.mode_profiles + ): + raise ConfigError( + "rf_source.modulated_output_enable requires a readable configurable modulation profile" + ) + if ( + feature is None + or RfFeatureDirection.ENABLE not in feature.directions + or not isinstance(feature.profile, RfModulatedOutputProfile) + ): + raise ConfigError( + "rf_source.modulated_output_enable requires a modulated-output feature with enable direction" + ) + if not set(feature.port_ids) <= set(output_feature.port_ids): + raise ConfigError( + "rf_source.modulated_output_enable ports must also declare the base output feature" + ) + if not set(feature.port_ids) <= set(modulation_feature.port_ids): + raise ConfigError( + "rf_source.modulated_output_enable ports must also declare the modulation feature" + ) + base_profiles = { + profile.kind: profile for profile in modulation_feature.profile.mode_profiles + } + for profile in feature.profile.mode_profiles: + base_profile = base_profiles.get(profile.kind) + if base_profile is None: + raise ConfigError( + "rf_source.modulated_output_enable profile must be declared by modulation" + ) + if ( + profile.source is not base_profile.source + or profile.waveform is not base_profile.waveform + or profile.value_unit is not base_profile.value_unit + or profile.value_min < base_profile.value_min + or profile.value_max > base_profile.value_max + or profile.internal_frequency_min_hz < base_profile.internal_frequency_min_hz + or profile.internal_frequency_max_hz > base_profile.internal_frequency_max_hz + ): + raise ConfigError( + "rf_source.modulated_output_enable profile must be a subset of modulation" + ) + topology = {port.port_id: port for port in extensions.topology.ports} + for port_id in feature.port_ids: + port = topology[port_id] + if not port.power_min_dbm <= feature.profile.maximum_power_dbm <= port.power_max_dbm: + raise ConfigError( + "rf_source.modulated_output_enable maximum_power_dbm must be within each port range" + ) + + def _validate_pulse_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: feature = next( (item for item in extensions.features if item.feature is RfFeature.PULSE), diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index fe99f3c..281efb7 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -251,6 +251,7 @@ class RfSweepTriggerMode(StrEnum): class RfFeature(StrEnum): CW = "cw" MODULATION = "modulation" + MODULATED_OUTPUT = "modulated_output" OUTPUT = "output" PULSE = "pulse" SWEEP = "sweep" @@ -511,6 +512,30 @@ def __post_init__(self) -> None: raise ValueError("RF modulation configuration readback requires readable state") +@dataclass(frozen=True, slots=True) +class RfModulatedOutputProfile: + """Bounded profiles that may enable an already modulated RF output. + + This is separate from :class:`RfOutputProfile`: ordinary RF output control + remains valid only while modulation is disabled. A descriptor must opt in + only after independent evidence covers the active modulation state, output + limits, and recovery behavior. + """ + + maximum_power_dbm: float + mode_profiles: tuple[RfModulationModeProfile, ...] + + def __post_init__(self) -> None: + _require_finite(self.maximum_power_dbm, "RF modulated-output maximum_power_dbm") + if not isinstance(self.mode_profiles, tuple) or not self.mode_profiles or any( + not isinstance(profile, RfModulationModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF modulated-output mode_profiles have an invalid type") + kinds = tuple(profile.kind for profile in self.mode_profiles) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds, key=lambda item: item.value)) != kinds: + raise ValueError("RF modulated-output mode_profiles must be sorted and unique") + + @dataclass(frozen=True, slots=True) class RfPulseProfile: state_readable: bool @@ -713,6 +738,7 @@ def __post_init__(self) -> None: RfCwProfile | RfOutputProfile | RfModulationProfile + | RfModulatedOutputProfile | RfPulseProfile | RfSweepProfile | RfTriggerProfile @@ -721,6 +747,7 @@ def __post_init__(self) -> None: _FEATURE_PROFILE_TYPES: dict[RfFeature, type[RfFeatureProfile]] = { RfFeature.CW: RfCwProfile, RfFeature.MODULATION: RfModulationProfile, + RfFeature.MODULATED_OUTPUT: RfModulatedOutputProfile, RfFeature.OUTPUT: RfOutputProfile, RfFeature.PULSE: RfPulseProfile, RfFeature.SWEEP: RfSweepProfile, @@ -936,6 +963,53 @@ def value_unit(self) -> RfModulationValueUnit: }[self.kind] +@dataclass(frozen=True, slots=True) +class RfModulatedOutputRequest: + """Enable one RF output only for an exactly read-back modulation profile. + + The operation never configures modulation. The embedded request is the + caller's explicit assertion of the already active internal-sine profile + that must be read back before and after the one RF-ON write. + """ + + modulation: RfModulationRequest + + def __post_init__(self) -> None: + if not isinstance(self.modulation, RfModulationRequest): + raise ValueError("RF modulated-output request requires RfModulationRequest") + + @property + def port_id(self) -> str: + return self.modulation.port_id + + @property + def kind(self) -> RfModulationKind: + return self.modulation.kind + + +@dataclass(frozen=True, slots=True) +class RfModulatedOutputResult: + """A modulated RF-output enable confirmed by independent readback.""" + + modulation: RfModulationResult + write_completed: bool + + def __post_init__(self) -> None: + if not isinstance(self.modulation, RfModulationResult): + raise ValueError("RF modulated-output result requires RfModulationResult") + _require_bool(self.write_completed, "RF modulated-output result write_completed") + if self.write_completed is not True: + raise ValueError("RF modulated-output enable must complete one RF-ON write") + + @property + def port_id(self) -> str: + return self.modulation.port_id + + @property + def kind(self) -> RfModulationKind: + return self.modulation.kind + + @dataclass(frozen=True, slots=True) class RfModulationDisableRequest: """Disable exactly one active modulation mode on one RF output port. @@ -1578,6 +1652,60 @@ def rf_source_modulation_operation_artifact( } +def rf_source_modulated_output_operation_artifact( + request: RfModulatedOutputRequest, + result: RfModulatedOutputResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_modulation_snapshot: RfModulationSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_snapshot: RfModulationSnapshot, +) -> dict[str, object]: + """Build typed evidence for one separate modulated RF-output enable. + + The artifact records the declared active profile both before and after the + one RF-ON write. It intentionally does not claim that RF OFF or modulation + disable was restored; those are separate operations and evidence. + """ + + if not isinstance(request, RfModulatedOutputRequest): + raise TypeError("request must be RfModulatedOutputRequest") + if not isinstance(result, RfModulatedOutputResult): + raise TypeError("result must be RfModulatedOutputResult") + modulation_request = request.modulation + modulation_result = result.modulation + if ( + modulation_request.port_id != modulation_result.port_id + or modulation_request.kind is not modulation_result.kind + or modulation_request.internal_frequency_hz != modulation_result.internal_frequency_hz + or modulation_request.value != modulation_result.value + or modulation_request.value_unit is not modulation_result.value_unit + ): + raise ValueError("RF modulated-output request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_modulation_snapshot, RfModulationSnapshot): + raise TypeError("preflight_modulation_snapshot must be RfModulationSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_modulation_snapshot, RfModulationSnapshot): + raise TypeError("postcondition_modulation_snapshot must be RfModulationSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.modulated_output_enable", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_modulation_snapshot": rf_modulation_snapshot_document( + preflight_modulation_snapshot + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_modulation_snapshot": rf_modulation_snapshot_document( + postcondition_modulation_snapshot + ), + } + + def rf_source_modulation_disable_operation_artifact( request: RfModulationDisableRequest, result: RfModulationDisableResult, @@ -1751,6 +1879,9 @@ def rf_source_output_operation_artifact( "RfModulationDisableRequest", "RfModulationDisableResult", "RfModulationModeProfile", + "RfModulatedOutputProfile", + "RfModulatedOutputRequest", + "RfModulatedOutputResult", "RfModulationProfile", "RfModulationRequest", "RfModulationResult", @@ -1806,6 +1937,7 @@ def rf_source_output_operation_artifact( "rf_sweep_snapshot_document", "rf_trigger_snapshot_document", "rf_source_modulation_disable_operation_artifact", + "rf_source_modulated_output_operation_artifact", "rf_source_modulation_operation_artifact", "rf_source_pulse_operation_artifact", "rf_source_sweep_operation_artifact", diff --git a/tests/test_rf_source_modulated_output_extensions.py b/tests/test_rf_source_modulated_output_extensions.py new file mode 100644 index 0000000..543ebd6 --- /dev/null +++ b/tests/test_rf_source_modulated_output_extensions.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulatedOutputProfile, + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationStateSnapshot, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_source_modulated_output_operation_artifact, +) + + +def _am_profile(*, maximum: float = 100.0) -> RfModulationModeProfile: + return RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=maximum, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ) + + +def _modulation_profile() -> RfModulationProfile: + return RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_am_profile(),), + ) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _extensions( + *, + modulated_profile: RfModulatedOutputProfile | None = None, +) -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATED_OUTPUT, + directions=(RfFeatureDirection.ENABLE,), + port_ids=("rf_out",), + profile=modulated_profile + or RfModulatedOutputProfile( + maximum_power_dbm=-30.0, + mode_profiles=(_am_profile(maximum=50.0),), + ), + ), + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_modulation_profile(), + ), + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.ENABLE), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + ) + + +def _descriptor( + *capabilities: str, + extensions: RfSourceDescriptorExtensions | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulated-output", + kind="rf_source", + models=("RF-MODULATED-OUTPUT",), + capabilities=capabilities, + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=extensions or _extensions(), + ) + + +def _request() -> RfModulatedOutputRequest: + return RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + ) + + +def _snapshot(*, output_enabled: bool) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _modulation_snapshot() -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-MODULATED-OUTPUT,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot(output_enabled=False) + + def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: + return RfModulationStateSnapshot(port_id=port_id) + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + assert port_id == "rf_out" + assert kind is RfModulationKind.AM + return _modulation_snapshot() + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: + del request + + def set_rf_output(self, request: RfOutputRequest) -> None: + del request + + +def test_modulated_output_contract_is_explicit_and_typed() -> None: + request = _request() + result = RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + + assert request.port_id == "rf_out" + assert request.kind is RfModulationKind.AM + assert result.port_id == "rf_out" + assert result.kind is RfModulationKind.AM + with pytest.raises(ValueError, match="requires RfModulationRequest"): + RfModulatedOutputRequest(modulation=object()) # type: ignore[arg-type] + with pytest.raises(ValueError, match="must complete one RF-ON write"): + RfModulatedOutputResult(modulation=result.modulation, write_completed=False) + + +def test_modulated_output_profile_requires_nonempty_sorted_modes() -> None: + with pytest.raises(ValueError, match="invalid type"): + RfModulatedOutputProfile(maximum_power_dbm=-30.0, mode_profiles=()) + with pytest.raises(ValueError, match="finite"): + RfModulatedOutputProfile( + maximum_power_dbm=float("nan"), + mode_profiles=(_am_profile(),), + ) + + +def test_modulated_output_descriptor_requires_base_capabilities_and_subset_profile() -> None: + capabilities = ( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + "rf_source.output", + "rf_source.modulated_output_enable", + ) + descriptor = _descriptor(*capabilities) + + assert CAPABILITY_METHODS["rf_source.modulated_output_enable"] == ( + "get_rf_modulation_snapshot", + "set_rf_output", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + missing_output = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ) + with pytest.raises(ConfigError, match="requires the rf_source.output capability"): + validate_rf_source_descriptor(missing_output) + + too_wide = _descriptor( + *capabilities, + extensions=_extensions( + modulated_profile=RfModulatedOutputProfile( + maximum_power_dbm=-30.0, + mode_profiles=(_am_profile(maximum=101.0),), + ) + ), + ) + with pytest.raises(ConfigError, match="must be a subset of modulation"): + validate_rf_source_descriptor(too_wide) + + excessive_power = _descriptor( + *capabilities, + extensions=_extensions( + modulated_profile=RfModulatedOutputProfile( + maximum_power_dbm=21.0, + mode_profiles=(_am_profile(maximum=50.0),), + ) + ), + ) + with pytest.raises(ConfigError, match="maximum_power_dbm"): + validate_rf_source_descriptor(excessive_power) + + +def test_modulated_output_artifact_keeps_exact_active_profile_before_and_after_on() -> None: + request = _request() + result = RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + artifact = rf_source_modulated_output_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(output_enabled=False), + preflight_modulation_snapshot=_modulation_snapshot(), + postcondition_snapshot=_snapshot(output_enabled=True), + postcondition_modulation_snapshot=_modulation_snapshot(), + ) + + assert artifact["operation"] == "rf_source.modulated_output_enable" + assert artifact["request"]["modulation"]["kind"] == "am" + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + assert artifact["preflight_modulation_snapshot"]["enabled_modes"] == ["am"] From 6d8d0afbb99fb9f4ea1c24e8cde079148df1e36f Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:00:14 +0800 Subject: [PATCH 52/63] feat: add guarded modulated RF output transaction --- src/wavebench/services/operation_specs.py | 20 + src/wavebench/services/rf_source_service.py | 307 ++++++++++++++ ...test_rf_source_modulated_output_service.py | 393 ++++++++++++++++++ 3 files changed, 720 insertions(+) create mode 100644 tests/test_rf_source_modulated_output_service.py diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index acaa074..80f2c5e 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1102,6 +1102,26 @@ def _spec( ), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.modulated_output_enable", + "rf_source", + required_capabilities=( + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ), + effect="write", + changed_fields=("rf_source.port.output_enabled",), + restore_coverage="none", + risk_flags=( + "dangerous_output", + "rf_output_enable", + "active_modulation", + "state_drift", + ), + safe_alternatives=("rf_source.snapshot",), + ), _spec( "rf_source.pulse_configure", "rf_source", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 019d5ec..6762860 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -20,6 +20,9 @@ RfCwResult, RfFeature, RfFeatureDirection, + RfModulatedOutputProfile, + RfModulatedOutputRequest, + RfModulatedOutputResult, RfModulationDisableRequest, RfModulationDisableResult, RfModulationKind, @@ -63,6 +66,7 @@ RfTriggerSnapshot, rf_source_cw_operation_artifact, rf_source_modulation_disable_operation_artifact, + rf_source_modulated_output_operation_artifact, rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, rf_source_pulse_operation_artifact, @@ -108,6 +112,15 @@ class _RfModulationDisableTransaction: postcondition_modulation_state: RfModulationStateSnapshot +@dataclass(frozen=True) +class _RfModulatedOutputTransaction: + result: RfModulatedOutputResult + preflight_snapshot: RfSourceSnapshot + preflight_modulation_snapshot: RfModulationSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_modulation_snapshot: RfModulationSnapshot + + @dataclass(frozen=True) class _RfPulseTransaction: result: RfPulseConfigureResult @@ -728,6 +741,128 @@ def set_output_with_artifact( ), ) + def enable_modulated_output( + self, + request: RfModulatedOutputRequest, + ) -> RfModulatedOutputResult: + return self._enable_modulated_output_transaction(request).result + + def enable_modulated_output_with_artifact( + self, + request: RfModulatedOutputRequest, + ) -> tuple[RfModulatedOutputResult, dict[str, object]]: + """Enable RF once for one exactly verified active modulation profile. + + This does not configure modulation, turn RF back off after success, or + disable modulation. Those actions remain explicit, separate operations. + On an uncertain RF-ON result it uses the existing one-shot guarded OFF + recovery, never retries RF ON, and keeps the session uncertain. + """ + + transaction = self._enable_modulated_output_transaction(request) + return ( + transaction.result, + rf_source_modulated_output_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_modulation_snapshot=transaction.preflight_modulation_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_modulation_snapshot=transaction.postcondition_modulation_snapshot, + ), + ) + + def _enable_modulated_output_transaction( + self, + request: RfModulatedOutputRequest, + ) -> _RfModulatedOutputTransaction: + """Run one explicit, profile-bound RF-ON transaction under active modulation.""" + + if not isinstance(request, RfModulatedOutputRequest): + raise ConfigError( + "rf_source modulated-output enable requires RfModulatedOutputRequest" + ) + operation = "rf_source.modulated_output_enable" + self._require( + operation, + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ) + ( + port_profile, + output_profile, + modulated_output_profile, + extensions, + ) = self._validate_modulated_output_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + self._validate_modulated_output_enable_snapshot( + request, + preflight_snapshot, + preflight_modulation_snapshot, + port_profile, + output_profile, + modulated_output_profile, + extensions, + expected_output_enabled=False, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.set_rf_output(RfOutputRequest(port_id=request.port_id, enabled=True)) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + modulation_result = self._validate_modulated_output_enable_snapshot( + request, + postcondition_snapshot, + postcondition_modulation_snapshot, + port_profile, + output_profile, + modulated_output_profile, + extensions, + expected_output_enabled=True, + operation=operation, + ) + return _RfModulatedOutputTransaction( + result=RfModulatedOutputResult( + modulation=modulation_result, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + preflight_modulation_snapshot=preflight_modulation_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) + except BaseException as exc: + if main_entered: + self._degrade_output_session_uncertain(session_state) + recovery = self._recover_rf_output_off( + rf_source, + request.port_id, + operation=operation, + ) + try: + setattr(exc, "rf_source_recovery", recovery) + except Exception: + pass + raise + def _set_output_transaction(self, request: RfOutputRequest) -> _RfOutputTransaction: """Execute one per-port M2 output transaction with bounded OFF recovery.""" @@ -855,6 +990,174 @@ def _validate_output_descriptor( raise ConfigError(f"{operation} requires a readable output profile for the target port") return port_profile, feature.profile, extensions + def _validate_modulated_output_descriptor( + self, + request: RfModulatedOutputRequest, + operation: str, + ) -> tuple[ + RfOutputPortProfile, + RfOutputProfile, + RfModulatedOutputProfile, + RfSourceDescriptorExtensions, + ]: + port_profile, output_profile, extensions = self._validate_output_descriptor( + RfOutputRequest(port_id=request.port_id, enabled=True), + operation, + ) + self._validate_modulation_descriptor(request.modulation, operation) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATED_OUTPUT), + None, + ) + if ( + feature is None + or RfFeatureDirection.ENABLE not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfModulatedOutputProfile) + ): + raise ConfigError( + f"{operation} requires a modulated-output enable profile for the target port" + ) + mode_profile = next( + (item for item in feature.profile.mode_profiles if item.kind is request.kind), + None, + ) + if mode_profile is None: + raise ConfigError(f"{operation} does not support the requested modulation kind") + if ( + mode_profile.source is not RfModulationSource.INTERNAL + or mode_profile.waveform is not RfModulationWaveform.SINE + or mode_profile.value_unit is not request.modulation.value_unit + ): + raise ConfigError(f"{operation} requires an internal-sine profile for the requested kind") + if not mode_profile.value_min <= request.modulation.value <= mode_profile.value_max: + raise ConfigError(f"{operation} request value is outside the descriptor range") + if not ( + mode_profile.internal_frequency_min_hz + <= request.modulation.internal_frequency_hz + <= mode_profile.internal_frequency_max_hz + ): + raise ConfigError( + f"{operation} request internal_frequency_hz is outside the descriptor range" + ) + return port_profile, output_profile, feature.profile, extensions + + def _validate_modulated_output_enable_snapshot( + self, + request: RfModulatedOutputRequest, + snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + port_profile: RfOutputPortProfile, + output_profile: RfOutputProfile, + modulated_output_profile: RfModulatedOutputProfile, + extensions: RfSourceDescriptorExtensions, + *, + expected_output_enabled: bool, + operation: str, + ) -> RfModulationResult: + del output_profile + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + safety_port = self._output_safety_port(request.port_id, operation=operation) + frequency_hz = self._observed_value( + port.frequency_hz, + f"{operation} requires a readable RF frequency", + ) + power_dbm = self._observed_value( + port.power_dbm, + f"{operation} requires a readable RF power", + ) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(frequency_hz, (int, float)) or isinstance(frequency_hz, bool): + raise ConfigError(f"{operation} requires a valid RF frequency") + if not isinstance(power_dbm, (int, float)) or isinstance(power_dbm, bool): + raise ConfigError(f"{operation} requires a valid RF power") + if not isinstance(output_enabled, bool): + raise ConfigError(f"{operation} requires a valid RF output state") + if output_enabled is not expected_output_enabled: + expected = "ON" if expected_output_enabled else "OFF" + raise ConfigError(f"{operation} requires target RF output {expected}") + if modulation is not RfModulationState.ENABLED: + raise ConfigError(f"{operation} requires modulation enabled") + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if not ( + port_profile.frequency_min_hz <= frequency_hz <= port_profile.frequency_max_hz + and safety_port.minimum_frequency_hz + <= frequency_hz + <= safety_port.maximum_frequency_hz + ): + raise ConfigError(f"{operation} requires RF frequency within descriptor and safety ranges") + if not ( + port_profile.power_min_dbm <= power_dbm <= port_profile.power_max_dbm + and power_dbm <= safety_port.maximum_power_dbm + and power_dbm <= modulated_output_profile.maximum_power_dbm + ): + raise ConfigError( + f"{operation} requires RF power within descriptor, modulated-output, and safety ranges" + ) + if safety_port.actual_termination_ohm != port_profile.power_reference_impedance_ohm: + raise ConfigError(f"{operation} requires actual termination to match RF power reference") + policies = {item.code: item for item in extensions.protection_conditions} + unknown = sorted(set(protection.active_codes) - set(policies)) + if unknown: + raise ConfigError(f"{operation} rejects an unknown active protection condition") + if any(policies[code].blocks_output_enable for code in protection.active_codes): + raise ConfigError(f"{operation} rejects an active blocking protection condition") + mode_profile = next( + ( + item + for item in modulated_output_profile.mode_profiles + if item.kind is request.kind + ), + None, + ) + if mode_profile is None: # defensive: descriptor validation already requires it. + raise ConfigError(f"{operation} does not support the requested modulation kind") + self._validate_modulation_snapshot_identity( + request.modulation, + modulation_snapshot, + mode_profile, + require_target_profile=True, + require_selected_fm_pm_kind=True, + operation=operation, + ) + if modulation_snapshot.enabled_modes != (request.kind,): + raise ConfigError(f"{operation} requires only the requested modulation mode") + if modulation_snapshot.global_enabled is not True: + raise ConfigError(f"{operation} requires global modulation enabled") + if modulation_snapshot.fault_codes: + raise ConfigError(f"{operation} rejects an active modulation fault condition") + if ( + modulation_snapshot.internal_frequency_hz != request.modulation.internal_frequency_hz + or modulation_snapshot.value != request.modulation.value + or modulation_snapshot.value_unit is not request.modulation.value_unit + ): + raise ConfigError(f"{operation} modulation readback does not match request") + return self._modulation_result(request.modulation) + def _validate_output_enable_snapshot( self, request: RfOutputRequest, @@ -1596,6 +1899,10 @@ def _validate_modulation_postcondition( or modulation_snapshot.value_unit is not request.value_unit ): raise ConfigError(f"{operation} modulation readback does not match request") + return self._modulation_result(request) + + @staticmethod + def _modulation_result(request: RfModulationRequest) -> RfModulationResult: if request.kind is RfModulationKind.AM: return RfModulationResult( port_id=request.port_id, diff --git a/tests/test_rf_source_modulated_output_service.py b/tests/test_rf_source_modulated_output_service.py new file mode 100644 index 0000000..266bffe --- /dev/null +++ b/tests/test_rf_source_modulated_output_service.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfPortSafetyConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulatedOutputProfile, + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfPortSnapshot, + RfProtectionConditionPolicy, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf.modulated-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + safety_ports=( + RfPortSafetyConfig( + port_id="rf_out", + minimum_frequency_hz=9_000.0, + maximum_frequency_hz=3_000_000_000.0, + maximum_power_dbm=-30.0, + actual_termination_ohm=50.0, + ), + ), + ), + ) + + +def _mode_profile(*, value_max: float = 100.0) -> RfModulationModeProfile: + return RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=value_max, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulated-output", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATED_OUTPUT, + directions=(RfFeatureDirection.ENABLE,), + port_ids=("rf_out",), + profile=RfModulatedOutputProfile( + maximum_power_dbm=-30.0, + mode_profiles=(_mode_profile(value_max=50.0),), + ), + ), + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_mode_profile(),), + ), + ), + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.ENABLE), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", True), + RfProtectionConditionPolicy("status_notice", False), + ), + ), + ) + + +def _request(*, depth_percent: float = 50.0) -> RfModulatedOutputRequest: + return RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=depth_percent, + ) + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.ENABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + power_dbm: float = -50.0, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(power_dbm), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _modulation_snapshot( + *, + enabled_modes: tuple[RfModulationKind, ...] = (RfModulationKind.AM,), + global_enabled: bool = True, + depth_percent: float = 50.0, + fault_codes: tuple[str, ...] = (), +) -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=depth_percent, + enabled_modes=enabled_modes, + global_enabled=global_enabled, + fault_codes=fault_codes, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + *, + raise_after_enable: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.modulation_snapshots = list(modulation_snapshots) + self.raise_after_enable = raise_after_enable + self.calls: list[str] = [] + self.output_requests: list[RfOutputRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + self.calls.append("modulation_snapshot") + assert port_id == "rf_out" + assert kind is RfModulationKind.AM + if not self.modulation_snapshots: + raise AssertionError("unexpected modulation snapshot") + return self.modulation_snapshots.pop(0) + + def set_rf_output(self, request: RfOutputRequest) -> None: + self.calls.append("set_rf_output") + self.output_requests.append(request) + if request.enabled and self.raise_after_enable: + raise ConfigError("RF ON failed after transmission") + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_enable: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + modulation_snapshots, + raise_after_enable=raise_after_enable, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + "rf_source.output", + "rf_source.modulated_output_enable", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_modulated_output_enable_uses_one_on_write_and_exact_pre_post_readback() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(output_enabled=False), _rf_snapshot(output_enabled=True)], + [_modulation_snapshot(), _modulation_snapshot()], + ) + + result, artifact = service.enable_modulated_output_with_artifact(request) + + assert result == RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + assert driver.output_requests == [RfOutputRequest(port_id="rf_out", enabled=True)] + assert driver.calls == [ + "snapshot", + "modulation_snapshot", + "set_rf_output", + "snapshot", + "modulation_snapshot", + ] + assert artifact["operation"] == "rf_source.modulated_output_enable" + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("rf_snapshot", "modulation_snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), _modulation_snapshot(), "target RF output OFF"), + (_rf_snapshot(), _modulation_snapshot(enabled_modes=()), "only the requested modulation mode"), + (_rf_snapshot(), _modulation_snapshot(global_enabled=False), "global modulation enabled"), + (_rf_snapshot(), _modulation_snapshot(fault_codes=("am_fault",)), "modulation fault"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), _modulation_snapshot(), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), _modulation_snapshot(), "Sweep disabled"), + (_rf_snapshot(power_dbm=-20.0), _modulation_snapshot(), "modulated-output"), + ), +) +def test_modulated_output_enable_rejects_unsafe_preflight_without_write( + rf_snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + message: str, +) -> None: + service, driver = _service([rf_snapshot], [modulation_snapshot]) + + with pytest.raises(ConfigError, match=message): + service.enable_modulated_output(_request()) + + assert driver.output_requests == [] + assert driver.calls == ["snapshot", "modulation_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulated_output_enable_checks_capability_access_and_profile_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + ) + with pytest.raises(ConfigError, match="rf_source.modulation_configure"): + missing.enable_modulated_output(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.modulated_output_enable"): + read_only.enable_modulated_output(_request()) + assert read_only_driver.calls == [] + + out_of_profile, out_of_profile_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + out_of_profile.enable_modulated_output(_request(depth_percent=51.0)) + assert out_of_profile_driver.calls == [] + + +def test_modulated_output_enable_mismatch_or_write_failure_runs_one_off_recovery() -> None: + request = _request() + mismatch_service, mismatch_driver = _service( + [ + _rf_snapshot(output_enabled=False), + _rf_snapshot(output_enabled=False), + _rf_snapshot(output_enabled=False), + ], + [_modulation_snapshot(), _modulation_snapshot()], + ) + + with pytest.raises(ConfigError, match="target RF output ON") as mismatch: + mismatch_service.enable_modulated_output(request) + + assert mismatch_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert mismatch.value.rf_source_recovery == { + "status": "off_verified", + "session_health": "uncertain", + } + assert mismatch_service.session_state is not None + assert mismatch_service.session_state.health is SessionHealth.UNCERTAIN + + failed_service, failed_driver = _service( + [_rf_snapshot(output_enabled=False), _rf_snapshot(output_enabled=False)], + [_modulation_snapshot()], + raise_after_enable=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission") as failed: + failed_service.enable_modulated_output(request) + + assert failed_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert failed.value.rf_source_recovery["status"] == "off_verified" + assert failed_service.session_state is not None + assert failed_service.session_state.health is SessionHealth.UNCERTAIN From 3ee26979dd709928b2bef1b7fee5b697c474a1f8 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:03:45 +0800 Subject: [PATCH 53/63] feat: expose profile-bound modulated RF output flow --- src/wavebench/cli.py | 12 +- src/wavebench/cli_parser.py | 17 +- src/wavebench/services/execution_intent.py | 1 + src/wavebench/services/run_plan.py | 17 +- src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 27 ++- tests/test_rf_source_cli.py | 63 +++++ tests/test_rf_source_modulated_output_run.py | 241 +++++++++++++++++++ 8 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 tests/test_rf_source_modulated_output_run.py diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index d21a84c..97d333a 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -89,6 +89,7 @@ ) from .instruments.rf_source_extensions import ( RfCwRequest, + RfModulatedOutputRequest, RfModulationKind, RfModulationRequest, RfOutputRequest, @@ -1579,6 +1580,9 @@ def _main(argv: list[str] | None = None) -> int: "configure-am": RfModulationKind.AM, "configure-fm": RfModulationKind.FM, "configure-pm": RfModulationKind.PM, + "enable-output-am": RfModulationKind.AM, + "enable-output-fm": RfModulationKind.FM, + "enable-output-pm": RfModulationKind.PM, }[args.modulation_command] request_fields = { "port_id": args.port, @@ -1591,7 +1595,13 @@ def _main(argv: list[str] | None = None) -> int: request_fields["frequency_deviation_hz"] = args.frequency_deviation_hz else: request_fields["phase_deviation_rad"] = args.phase_deviation_rad - result = service.configure_modulation(RfModulationRequest(**request_fields)) + request = RfModulationRequest(**request_fields) + if args.modulation_command.startswith("enable-output-"): + result = service.enable_modulated_output( + RfModulatedOutputRequest(modulation=request) + ) + else: + result = service.configure_modulation(request) if args.json: _emit_json_result(_json_payload(result)) else: diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 5a93143..c3552f3 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -691,7 +691,7 @@ def build_parser() -> argparse.ArgumentParser: rf_source_modulation = rf_source_sub.add_parser( "modulation", - help="Configure one OFF RF port with bounded internal-sine AM, FM, or PM", + help="Configure internal-sine modulation or enable RF for one exactly verified profile", ) rf_source_modulation_sub = rf_source_modulation.add_subparsers( dest="modulation_command", @@ -704,6 +704,21 @@ def build_parser() -> argparse.ArgumentParser: "frequency-deviation-hz", "Configure internal-sine FM while RF output is OFF", ), + ( + "enable-output-am", + "depth-percent", + "Enable RF only when the active internal-sine AM profile exactly matches", + ), + ( + "enable-output-fm", + "frequency-deviation-hz", + "Enable RF only when the active internal-sine FM profile exactly matches", + ), + ( + "enable-output-pm", + "phase-deviation-rad", + "Enable RF only when the active internal-sine PM profile exactly matches", + ), ( "configure-pm", "phase-deviation-rad", diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index c0b4594..d972b0b 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -28,6 +28,7 @@ "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", "rf_source.pulse_configure": "rf_source.pulse_configure", "rf_source.sweep_configure": "rf_source.sweep_configure", "rf_source.output_enable": "rf_source.output_enable", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index c9d5883..b157c3b 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -26,6 +26,7 @@ "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", + "rf_source.modulated_output_enable", "rf_source.pulse_configure", "rf_source.sweep_configure", "rf_source.output_enable", @@ -81,6 +82,11 @@ "modulation_kind", "internal_frequency_hz", ), + "rf_source.modulated_output_enable": ( + "port_id", + "modulation_kind", + "internal_frequency_hz", + ), "rf_source.pulse_configure": ("port_id", "period_s", "width_s", "polarity"), "rf_source.sweep_configure": ( "port_id", @@ -207,6 +213,12 @@ "phase_deviation_rad", "on_failure", }, + "rf_source.modulated_output_enable": { + "depth_percent", + "frequency_deviation_hz", + "phase_deviation_rad", + "on_failure", + }, "rf_source.pulse_configure": set(), "rf_source.sweep_configure": set(), "rf_source.output_enable": {"on_failure"}, @@ -274,6 +286,7 @@ "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", + "rf_source.modulated_output_enable": "Enable one RF port only when its active internal-sine AM, FM, or PM profile exactly matches the requested bounded profile; it does not configure modulation or restore RF OFF.", "rf_source.pulse_configure": "Configure one OFF RF port with a disabled internal single-pulse profile; it does not enable RF output or trigger a pulse.", "rf_source.sweep_configure": "Configure one OFF RF port with a disabled frequency-only Step Sweep profile; it does not arm, fire, trigger, or enable RF output.", "rf_source.output_enable": "Enable one RF port only after a fresh safety snapshot confirms the configured load, frequency, power, and inactive modulation, Pulse, Sweep, and blocking protection conditions.", @@ -680,7 +693,7 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non elif kind == "rf_source.set_power_dbm": fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") - elif kind == "rf_source.modulation_configure": + elif kind in {"rf_source.modulation_configure", "rf_source.modulated_output_enable"}: fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") modulation_kind = _non_empty_str( fields["modulation_kind"], @@ -697,7 +710,7 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non present_value_fields = [field for field in value_fields.values() if field in fields] if present_value_fields != [expected_value_field]: raise ConfigError( - f"{prefix} rf_source.modulation_configure requires only " + f"{prefix} {kind} requires only " f"{expected_value_field} for modulation_kind {modulation_kind}" ) fields[expected_value_field] = _finite_float( diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 52c7760..9a8e9ac 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -26,6 +26,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", + "rf_source.modulated_output_enable", "rf_source.pulse_configure", "rf_source.sweep_configure", "rf_source.output_enable", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index eff548c..bbbc738 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -24,6 +24,7 @@ from wavebench.instruments.registry import resolve_instrument_descriptor from wavebench.instruments.rf_source_extensions import ( RfCwRequest, + RfModulatedOutputRequest, RfModulationKind, RfModulationRequest, RfOutputRequest, @@ -307,6 +308,7 @@ def _check_rf_source_access(self, plan: RunPlan) -> None: "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", "rf_source.pulse_configure": "rf_source.pulse_configure", "rf_source.sweep_configure": "rf_source.sweep_configure", "rf_source.output_enable": "rf_source.output_enable", @@ -460,6 +462,14 @@ def add_source_output_gate_capability() -> None: add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") elif step.kind == "rf_source.modulation_configure": add("rf_source", "rf_source.snapshot", "rf_source.modulation_configure") + elif step.kind == "rf_source.modulated_output_enable": + add( + "rf_source", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ) elif step.kind == "rf_source.pulse_configure": add("rf_source", "rf_source.snapshot", "rf_source.pulse_configure") elif step.kind == "rf_source.sweep_configure": @@ -1245,7 +1255,10 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} - elif step.kind == "rf_source.modulation_configure": + elif step.kind in { + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + }: fields = step.fields modulation_kind = RfModulationKind(fields["modulation_kind"]) if modulation_kind is RfModulationKind.AM: @@ -1269,9 +1282,15 @@ def _run_step( phase_deviation_rad=fields["phase_deviation_rad"], internal_frequency_hz=fields["internal_frequency_hz"], ) - _, rf_source_operation = self._rf_source_service( - services=services - ).configure_modulation_with_artifact(request) + rf_source_service = self._rf_source_service(services=services) + if step.kind == "rf_source.modulated_output_enable": + _, rf_source_operation = rf_source_service.enable_modulated_output_with_artifact( + RfModulatedOutputRequest(modulation=request) + ) + else: + _, rf_source_operation = rf_source_service.configure_modulation_with_artifact( + request + ) artifact = {"rf_source_operation": rf_source_operation} elif step.kind == "rf_source.pulse_configure": fields = step.fields diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index 09fe305..6853032 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -10,6 +10,8 @@ from wavebench.instruments.rf_source_extensions import ( RfCwRequest, RfCwResult, + RfModulatedOutputRequest, + RfModulatedOutputResult, RfModulationKind, RfModulationRequest, RfModulationResult, @@ -79,6 +81,19 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands( "1000", ] ) + modulation_output_am = build_parser().parse_args( + [ + "rf-source", + "modulation", + "enable-output-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ] + ) pulse = build_parser().parse_args( [ "rf-source", @@ -137,6 +152,8 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands( assert modulation_fm.frequency_deviation_hz == 10_000.0 assert modulation_pm.modulation_command == "configure-pm" assert modulation_pm.phase_deviation_rad == 1.5 + assert modulation_output_am.modulation_command == "enable-output-am" + assert modulation_output_am.depth_percent == 50.0 assert (pulse.domain, pulse.command, pulse.pulse_command) == ( "rf-source", "pulse", @@ -367,6 +384,52 @@ def test_rf_source_cli_dispatches_each_internal_sine_modulation_request() -> Non ] +def test_rf_source_cli_dispatches_profile_bound_modulated_output_enable() -> None: + service = Mock() + service.enable_modulated_output.return_value = RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ), + write_completed=True, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert ( + main( + [ + "--json", + "rf-source", + "modulation", + "enable-output-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ] + ) + == 0 + ) + + payload = json.loads(stdout.getvalue()) + assert payload["result"]["write_completed"] is True + service.enable_modulated_output.assert_called_once_with( + RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ) + ) + ) + + def test_rf_source_cli_dispatches_disabled_internal_single_pulse_request() -> None: service = Mock() service.configure_pulse.return_value = RfPulseConfigureResult( diff --git a/tests/test_rf_source_modulated_output_run.py b/tests/test_rf_source_modulated_output_run.py new file mode 100644 index 0000000..d12b658 --- /dev/null +++ b/tests/test_rf_source_modulated_output_run.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationKind, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationWaveform, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceSnapshot, + RfSweepState, + rf_source_modulated_output_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.execution_intent import build_execution_intent +from wavebench.services.run_plan import STEP_SCHEMAS, load_run_plan +from wavebench.services.run_service import RunInstrumentServices, RunService + + +def _config(directory: str, *, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig( + Path(directory) / "data" / "raw", + "timestamp_label", + True, + True, + True, + True, + False, + ), + source_path=Path(directory) / "wavebench.toml", + rf_source=RfSourceConfig( + driver="example.rf.modulated-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulated_output_enable"\n' + 'port_id = "rf_out"\n' + 'modulation_kind = "am"\n' + "depth_percent = 50\n" + "internal_frequency_hz = 1000\n", + encoding="utf-8", + ) + return load_run_plan(path) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulated-output", + kind="rf_source", + capabilities=capabilities, + ) + + +def _snapshot(*, output_enabled: bool) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _modulation_snapshot() -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + + +def _request() -> RfModulatedOutputRequest: + return RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + ) + + +def _result() -> RfModulatedOutputResult: + return RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + + +def test_modulated_output_run_schema_and_intent_are_explicit_writes() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + intent = build_execution_intent(plan, _config(directory)) + + assert "rf_source.modulated_output_enable" in STEP_SCHEMAS + assert intent.operations[0]["operation"] == "rf_source.modulated_output_enable" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "depth_percent": 50.0, + "internal_frequency_hz": 1_000.0, + "modulation_kind": "am", + "port_id": "rf_out", + } + + +def test_modulated_output_run_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.modulated_output_enable"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_modulated_output_run_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_only"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_modulated_output_run_step_dispatches_exact_profile_and_artifact() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + config = _config(directory) + request = _request() + result = _result() + artifact = rf_source_modulated_output_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(output_enabled=False), + preflight_modulation_snapshot=_modulation_snapshot(), + postcondition_snapshot=_snapshot(output_enabled=True), + postcondition_modulation_snapshot=_modulation_snapshot(), + ) + rf_service = SimpleNamespace( + enable_modulated_output_with_artifact=Mock(return_value=(result, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ), + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.enable_modulated_output_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] From 964b95955fdfde868bc3e9f148b8b9ceacf9af5a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:31:14 +0800 Subject: [PATCH 54/63] docs: document bounded modulated RF output --- README.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 23 ++++++-- ...67\346\272\220\350\256\276\350\256\241.md" | 55 ++++++++++++++----- ...77\347\224\250\346\214\207\345\215\227.md" | 26 ++++++++- 4 files changed, 81 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9a0d650..6139b8d 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`;调制配置不授权调制开启时的 RF 输出。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。A5-0 已具备逻辑 Pulse/Sweep trigger configuration 的只读代码合同,但 DSG830 production descriptor 未声明该 capability;它不代表后面板 trigger/sync 接口已定义或可操作。当前 production 范围仍不提供 `modulation_disable`、execute、arm、fire、trigger、Level Sweep 或 list。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`;调制配置不授权调制开启时的 RF 输出。Core 已有受限 `rf_source.modulated_output_enable` 合同,但 DSG830 尚未取得该能力的实机证据,也未在 production descriptor 中声明它;普通 `rf_source.output` 仍要求调制关闭。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。A5-0 已具备逻辑 Pulse/Sweep trigger configuration 的只读代码合同,但 DSG830 production descriptor 未声明该 capability;它不代表后面板 trigger/sync 接口已定义或可操作。当前 production 范围仍不提供 `modulation_disable`、execute、arm、fire、trigger、Level Sweep 或 list。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 022f349..6547008 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M4 与 A5-0 离线只读合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep,以及逻辑 trigger configuration 的只读类型、Service、CLI、run 和 artifact;按模式调制关闭仅用于本地证据与私有恢复。 | -| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep 的 A4 均已通过并提升;A5-0 映射已完成,物理 A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 与六条固定 trigger configuration query 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | -| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;物理 A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A5-0 不产生 production capability,也不提升调制开启时的 RF 输出。 | +| Core `0.8.25` 开发线 | M0–M4、M3-MO 与 A5-0 的离线合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep,以及逻辑 trigger configuration 的只读类型、Service、CLI、run 和 artifact;按模式调制关闭仅用于本地证据与私有恢复。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep 的 A4 均已通过并提升;M3-MO 的私有 harness 与 fake 回归完成,实机验收待进行;A5-0 映射已完成,物理 A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 与六条固定 trigger configuration query 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,不声明 `rf_source.modulated_output_enable` 或 `rf_source.trigger_snapshot`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A4-MO 与物理 A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 是单独的调制输出证据。A5-0 不产生 production capability。 | ## 双仓库交付规则 @@ -31,6 +31,7 @@ | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;DSG830 production 已开放 `rf_source.modulation_configure`。PM 的 production profile 固定为 `1.25 rad`。 | +| M3-MO | 离线完成;A4-MO 实机待验收 | `RfModulatedOutputProfile`、严格 pre/post RF 与调制 snapshot、一次 RF ON、受 guard OFF recovery、CLI、run 与 artifact | 复用既有 `:OUTP`/调制 snapshot 映射;私有固定 AM descriptor、CH2-only evidence harness 与 fake 回归 | 只接受已激活且精确匹配的内部 Sine profile;不配置调制,不重试 ON。普通 `rf_source.output` 仍要求调制关闭。DSG830 production 不声明 `rf_source.modulated_output_enable`。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | | A5-0 | 离线完成;不属于物理 A5 证据 | `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、只读 Service/CLI/run/artifact | `:PULM:TRIG:MODE?`、external edge/gate query、Sweep mode/period/point trigger query 与严格 enum parser | 只使用 `TRIGGER / READ` profile 和非 production descriptor;固定 query 顺序、零 write、未知值失败关闭。它不定义物理 connector,不发送 trigger,也不提升 production capability。 | @@ -93,13 +94,21 @@ FM/PM 的共享 mode type 会与被查询 profile 分开记录。当前类型 均 disabled、Pulse/Sweep disabled 且无活动 protection condition;postcondition 要求 RF 仍 OFF、仅目标模式 enabled、全局调制 开启且所有目标字段精确匹配。写入或 postcondition 结果不明时不重试,session 降为不确定状态。 -`rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。该 operation 当前只供 A4 本地证据与恢复流程使用,不进入 DSG830 production descriptor。 +`rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。该 operation 当前只供 A4 本地证据、A4-MO 清理和私有恢复流程使用,不进入 DSG830 production descriptor。 DSG830 的 A4 调制证据已将 `rf_source.modulation_configure` 加入 production descriptor。production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`,以及 PM 精确 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。driver 的离线 PM 映射范围不自动扩大 production profile。 -当前 M2 的 RF ON 合同仍要求调制 disabled,因此 M3 capability 不授权在调制开启时输出 RF;该能力仍需要专门的输出安全合同与实机证据。 +当前 M2 的 RF ON 合同仍要求调制 disabled,因此 M3 capability 不授权在调制开启时输出 RF;M3-MO 为此提供了专门的输出安全合同,但仍需要独立的 A4-MO 实机证据。 DSG830 源码 checkout 提供 A4 的独立本地 harness 和资源无关 setup 模板。一次运行只配置一个内部 Sine AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。三种模式的 RF-OFF 配置、严格读回与关闭恢复均已通过。为使生产 profile 与 PM 的严格读回证据完全一致,PM 仅开放 `1.25 rad`,不将离线映射的其它值外推到 production。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是新的 capability 提升证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。该证据不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 +### M3-MO:受限调制输出(离线完成,A4-MO 待验收) + +Core 将调制开启时的 RF 输出建模为独立的 `rf_source.modulated_output_enable`,而不是放宽通用 `rf_source.output`。request 包含一个内部 Sine AM/FM/PM profile;preflight 要求 RF OFF、全局调制开启、唯一目标模式开启且完整 profile 与 request 精确相同,Pulse/Sweep disabled、protection 清晰、频率/功率/实际端接满足端口 safety 配置,并且 request 被 `RfModulatedOutputProfile` 的窄范围接受。它读取 RF snapshot 和完整调制 snapshot,单次 ON 后再次读取二者;不会配置调制,也不会在成功后自动 RF OFF 或关闭调制。 + +任何 ON 结果不明、RF readback 或调制 readback 不符时,都不重试 ON。只有 session health 允许时,Core 才复用 M2 的一次受 guard RF OFF recovery;恢复后不推断调制状态。普通 `rf_source.output` 的 ON preflight 不变,仍要求调制关闭。 + +DSG830 源码 checkout 提供 `tools/a4_modulated_output_evidence.py` 与无资源 setup 模板。它使用仅在内存中创建的 descriptor,固定为 AM `50 %`/内部 `1 kHz`、RF `1 MHz`/`-50 dBm`,并只读取 CH2 的当前 `DEF` 缓冲区。CH2 必须由 setup 显式确认 50 Ω;scope 只判定是否有可见信号,不计算 dBm、频率或调制深度。工具不读取或控制 CH1,不把 LF OUTPUT 解释为调制测量,不使用 trigger/sync/后面板 Pulse I/O。成功路径仍显式 RF OFF,再关闭 AM 和全局调制,最后独立确认安全基线。该 harness 目前只完成代码与 fake 回归;未取得私有实机通过记录并完成审查前,不得将 capability 加入 production descriptor。 + ## M4:Pulse 与 Step Sweep M4 当前先完成 Pulse,再处理 frequency-only Step Sweep。`RfPortSnapshot` 中的 Pulse、Sweep 状态必须可区分,不能将外部 trigger、后面板辅助输出或设备私有模式默认为安全。 @@ -121,7 +130,8 @@ Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出 | A1 | 只读 snapshot | `rf_source.snapshot` | | A2 | RF OFF/ON、readback 与最终 OFF | `rf_source.output` | | A3 | CW 环回、频率与 dBm 功率 | `rf_source.cw_configure` | -| A4 | 调制、Pulse、Step Sweep | 对应 M3/M4 capability | +| A4 | RF-OFF 调制、Pulse、Step Sweep | 对应 M3/M4 capability | +| A4-MO | 固定调制 profile 的 RF ON、CH2 存在性观察与最终清理 | `rf_source.modulated_output_enable` | | A5 | 外部 trigger 或同步接线 | trigger/fire/同步相关 capability | 每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 @@ -201,3 +211,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的 Core 合同、DSG830 映射、CLI、run、artifact 与 A4 证据均已完成,`rf_source.modulation_configure` 已提升;M4 继续保持独立工作。 6. A4 的 AM/FM/PM RF-OFF 单模式配置、严格读回与关闭恢复证据均已通过。PM production profile 固定为 `1.25 rad`,以避免将更宽的离线映射当作实机覆盖范围。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成新的 capability 提升证据;任何允许调制开启时 RF 输出的安全合同仍须单独设计和验证,CH2 可见信号也不能替代该证据。 7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 +8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、DSG830 固定 profile 私有 descriptor、CH2-only harness 与 fake 回归。下一步是先用网络发现与只读诊断确认隔离设备状态,再以固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 执行一次受控 A4-MO;只在 final RF OFF、调制关闭、健康关闭和脱敏证据均通过后,才评估 production descriptor 提升。CH1 的低频输出不属于该证据路径。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index c7512a9..df6f47d 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3/A4 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3 的 PM production profile 仅为 `1.25 rad`;离线代码与宽于该 profile 的映射不能替代相应 capability 的实机证据。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制、M3-MO 受限调制输出,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3/A4 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3-MO 仍只是非 production 的固定 profile 合同与私有证据脚本;M3 的 PM production profile 仅为 `1.25 rad`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 阅读顺序如下: @@ -15,9 +15,9 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、仅用于受控恢复的调制关闭事务,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 调制/Pulse/Step Sweep 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 限于 `1.25 rad`。 | -| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;调制 A4 只提升 RF-OFF 配置,不提升调制开启时的 RF 输出。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、M3-MO profile-bound 调制输出、仅用于受控恢复的调制关闭事务,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 调制/Pulse/Step Sweep 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。它不声明 `rf_source.modulated_output_enable`。PM 限于 `1.25 rad`。 | +| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A4-MO 的脚本和 fake 回归完成,实机验收待进行;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;M3 的 A4 只提升 RF-OFF 配置,不提升调制开启时的 RF 输出。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep 的提升边界,以及 M1、M3–M4 的离线开发和 fake transport 验证边界。DSG830 的 M3 已完成 Core 与实机验收并进入 production;其它 A5 实机验收、调制输出安全合同和发行包推广仍另行处理,离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep 的提升边界,以及 M1、M3、M3-MO 与 M4 的离线开发和 fake transport 验证边界。DSG830 的 M3 已完成 Core 与实机验收并进入 production;M3-MO 的安全合同已经落地,但实机证据与 production 推广仍另行处理,离线代码不能替代这些证据。 ## 范围与非目标 @@ -48,7 +48,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;DSG830 已由 A3 将其提升到 production。 - M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 -- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;M4 当前完成 internal/single Pulse 与保持 Sweep disabled 的 frequency-only Step Sweep 配置子集。 +- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、profile-bound 调制输出、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;M4 当前完成 internal/single Pulse 与保持 Sweep disabled 的 frequency-only Step Sweep 配置子集。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 ### 明确不做 @@ -167,6 +167,7 @@ class RfFeature(StrEnum): CW = "cw" OUTPUT = "output" MODULATION = "modulation" + MODULATED_OUTPUT = "modulated_output" PULSE = "pulse" SWEEP = "sweep" TRIGGER = "trigger" @@ -202,7 +203,7 @@ class RfSourceDescriptorExtensions: 每个 protection policy 的 `code` 必须非空且唯一。Core 以 policy 集合识别已知 condition;只有 `blocks_output_enable=False` 的已知 active code 可以不阻断 RF ON。不存在 policy 的 active code 必须拒绝 RF ON。 -`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfPulseProfile`、`RfSweepProfile` 或 `RfTriggerProfile` 的封闭联合。`RfTriggerProfile` 只描述可读取的逻辑 Pulse/Sweep trigger configuration 值;它不表示物理 trigger/sync 接口、方向、电平或端接。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 +`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfModulatedOutputProfile`、`RfPulseProfile`、`RfSweepProfile` 或 `RfTriggerProfile` 的封闭联合。`RfModulatedOutputProfile` 只列出已逐项证实允许在调制开启时启用 RF 的内部 Sine profile,并且必须是基础调制 profile 的子集,功率上限不得超过端口范围。`RfTriggerProfile` 只描述可读取的逻辑 Pulse/Sweep trigger configuration 值;它不表示物理 trigger/sync 接口、方向、电平或端接。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 每个 `RfFeatureCapability` 必须指定 feature、direction、适用端口、静态限制和可读回字段。静态 profile 只能收紧设备支持范围,不能授权未声明的 operation。`rf_source.pulse_trigger` 对应 `PULSE / TRIGGER`;`rf_source.sweep_fire` 对应 `SWEEP / FIRE`;其他 operation 也必须在 M0–M4 的 descriptor validator 中有唯一映射。 @@ -219,6 +220,7 @@ Core 在调用目标 driver operation 前校验 request、access、descriptor | `rf_source.output` | `set_rf_output(request)` | 单端口 RF ON/OFF | | `rf_source.modulation_configure` | `configure_rf_modulation(request)` | 已声明的 AM/FM/PM 配置 | | `rf_source.modulation_disable` | `disable_rf_modulation(request)` | 关闭一个已明确识别的调制模式与全局调制开关 | +| `rf_source.modulated_output_enable` | `get_rf_modulation_snapshot(port_id, kind)`、`set_rf_output(request)` | 只在已激活 profile 精确匹配时,单次启用 RF;不配置或关闭调制。 | | `rf_source.pulse_configure` | `configure_rf_pulse(request)` | 已声明的 Pulse 配置 | | `rf_source.pulse_trigger` | `trigger_rf_pulse(request)` | 已声明的 Pulse 触发 | | `rf_source.sweep_configure` | `configure_rf_sweep(request)` | 已声明的 Sweep 配置 | @@ -250,7 +252,7 @@ maximum_power_dbm = -20 actual_termination_ohm = 50 ``` -每个会执行 RF ON、fire 或其它可能增加 RF 端口能量的 operation 都要求目标端口拥有完整安全配置。配置范围只能收紧 descriptor 的设备范围。`actual_termination_ohm` 必须是有限正数,并绑定当前端口;M0–M4 仅在它与 descriptor 的 `power_reference_impedance_ohm` 精确相等时允许使用 dBm 输出安全判断,不进行阻抗或电压换算。 +每个会执行 RF ON、fire 或其它可能增加 RF 端口能量的 operation 都要求目标端口拥有完整安全配置。配置范围只能收紧 descriptor 的设备范围。`actual_termination_ohm` 必须是有限正数,并绑定当前端口;M0–M4 与 M3-MO 仅在它与 descriptor 的 `power_reference_impedance_ohm` 精确相等时允许使用 dBm 输出安全判断,不进行阻抗或电压换算。 ### Operation 顺序 @@ -263,7 +265,7 @@ CW、调制、Pulse 与 Sweep 配置必须按以下顺序执行: 5. 读取独立 postcondition,逐字段比较请求值、端口状态和隐式变化; 6. 成功后返回类型化结果与脱敏 artifact。 -主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。M1 CW 与 M3 调制配置不执行 RF OFF recovery,而是将 session 保持在更保守状态;只有 M2 的 RF ON 事务可在 session health 允许时最多执行一次目标端口 RF OFF 并独立回读。 +主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。M1 CW 与 M3 调制配置不执行 RF OFF recovery,而是将 session 保持在更保守状态;M2 与 M3-MO 的 RF ON 事务只在 session health 允许时最多执行一次目标端口 RF OFF 并独立回读。 ### 调制关闭与恢复 @@ -282,6 +284,14 @@ RF ON 是独立 operation。其 preflight 必须确认: RF OFF 不依赖频率、功率、端接或 protection readback;它仍受 access、session health 和单次写入规则限制。 +### M3-MO:受限调制输出 + +`rf_source.modulated_output_enable` 是与 `rf_source.output` 分开的特殊 capability。它只接受一个精确的、已经激活的内部 Sine AM/FM/PM request:不会配置调制,不会在成功后自动 RF OFF,也不会关闭调制。普通 `rf_source.output` 的 ON preflight 仍要求所有调制模式关闭,不能用这个 capability 放宽其边界。 + +特殊 operation 在一个独占 session 中依次读取 RF snapshot 和目标完整调制 snapshot,确认目标 RF 为 OFF、调制全局开关与唯一目标模式已开启、source/waveform/数值/内部频率精确匹配、Pulse/Sweep disabled、protection 清晰,以及频率、功率、50 Ω 实际端接和 descriptor 中更窄的调制输出 profile 均满足。随后只发送一次 RF ON,并再次读取两类 snapshot,确认 RF 为 ON 且调制 profile 未改变。 + +ON 写入、RF readback 或调制 readback 任何一项不确定时,绝不重试 ON;只允许沿用 M2 的一次受 guard RF OFF recovery。恢复后 session 保持不确定,调用方不得假设调制已关闭。`RfModulatedOutputProfile` 必须显式声明可用端口、精确 mode profile 与最大功率,不能从基础 `RfModulationProfile` 或普通 output capability 自动推导。 + Sweep arm 是 OFF-only 准备 operation,必须保持目标端口 RF OFF。Sweep fire 与 Pulse trigger 是潜在能量操作。它们必须使用独立、一次性安全决定,不能因先前的 configure、arm 或 output ON 成功而自动获得许可。core 不会隐式打开输出、触发外部端口或开启后面板辅助输出。 ## Service、CLI、doctor 与 run plan @@ -310,6 +320,12 @@ rf_source.set_power_dbm wavebench rf-source output --port PORT_ID on|off rf_source.output_enable rf_source.output_disable + +# M3-MO:只限声明 rf_source.modulated_output_enable 的非 production descriptor 或私有受控证据 +wavebench rf-source modulation enable-output-am ... +wavebench rf-source modulation enable-output-fm ... +wavebench rf-source modulation enable-output-pm ... +rf_source.modulated_output_enable ``` `rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`rf-source trigger status` 和 `rf_source.trigger_status` 要求独立的 `rf_source.trigger_snapshot` capability,以及目标 `port_id` 的 `TRIGGER / READ` profile;它们是 `stateful_read`,不读取普通 RF snapshot、不执行 recovery、不写入或触发。DSG830 当前 production descriptor 未声明该 capability,因此该命令会在打开 session 前拒绝。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 @@ -324,7 +340,7 @@ M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端 M1 已由 A3 在真实设备上完成受控频率/功率写入、独立 readback、低功率 RF ON/OFF 环回与最终 OFF 验收,因而将 `rf_source.cw_configure` 纳入 DSG830 production descriptor。M2 已由 A2 将 `rf_source.output` 纳入同一 descriptor;人工确认的实验室端接本身仍不构成调制、Pulse、Sweep、trigger 或其它额外写入授权。 -### M3 与 M4 的 production 入口 +### M3、M3-MO 与 M4 的 production 入口 M3 的写入 CLI 和 run step 已进入当前 Core schema。DSG830 已由 A4 声明 `rf_source.modulation_configure`,但真实仪器使用仍由 production descriptor、`read_write`、匹配 profile 与 fresh RF-OFF preflight 共同门禁: @@ -334,6 +350,12 @@ wavebench rf-source modulation configure-fm ... wavebench rf-source modulation configure-pm ... rf_source.modulation_configure +# M3-MO:仅当特殊 capability 已被实机证据提升时才是生产入口 +wavebench rf-source modulation enable-output-am ... +wavebench rf-source modulation enable-output-fm ... +wavebench rf-source modulation enable-output-pm ... +rf_source.modulated_output_enable + # M4 Pulse:只允许 internal/single 配置,配置后 Pulse 仍保持关闭 wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted rf_source.pulse_configure @@ -351,6 +373,8 @@ rf_source.sweep_configure M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。M2 的 RF ON 仍要求调制关闭,因此这不是调制输出入口。 +M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要求调制已经激活且完整 profile 精确匹配,不会配置该 profile。当前 Core schema 已有三个 `enable-output-*` CLI 和 `rf_source.modulated_output_enable` run step;DSG830 production descriptor 尚未声明该 capability。源码 checkout 的私有 A4-MO harness 只使用固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 和 CH2 的 50 Ω 可见信号观察来取得一次窄范围证据。它不读取或控制 CH1,不把 LF OUTPUT 当作 AM 测量,也不从 scope 推断 dBm、频率或调制深度。 + `rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 `rf_source.sweep_configure` 已进入当前 Core schema;DSG830 已在 A4 Step Sweep 证据复核后声明该 capability。请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 @@ -361,7 +385,7 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成一次隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。该诊断不构成物理 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 -## M0–M4 里程碑 +## M0–M4 与 M3-MO 里程碑 下表同时标出当前进度和交付边界。Core 与 DSG830 插件的依赖、完成条件和状态见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 @@ -371,12 +395,13 @@ M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式 | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(A4 已通过并提升) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;DSG830 已声明 `rf_source.modulation_configure`,其中 PM 固定为 `1.25 rad`。 | +| M3-MO(离线完成;A4-MO 实机待验收) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;私有固定 AM evidence descriptor 和 CH2-only harness | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production descriptor 尚未声明此 capability。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | | M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | -M0–M4 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 +M0–M4 与 M3-MO 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A4-MO 调制输出,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 的代码与 fake 回归已经具备,但未取得实机证据前,`rf_source.modulated_output_enable` 不能进入 production descriptor。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -395,7 +420,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM,严格 readback 后由 A4 harness 执行受限调制关闭;PM production profile 固定为 `1.25 rad`,`rf_source.modulation_disable` 仍不进入 descriptor。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 harness 在对应 descriptor 提升后拒绝重跑;普通 M3/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。既有证据不开放调制输出、Sweep fire、后面板配置或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM,严格 readback 后由 A4 harness 执行受限调制关闭;PM production profile 固定为 `1.25 rad`,`rf_source.modulation_disable` 仍不进入 descriptor。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但其 special capability 与固定 evidence descriptor 只存在于非 production 测试,未改变普通 `rf_source.output` 的调制关闭前置条件。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 harness 在对应 descriptor 提升后拒绝重跑;普通 M3/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。既有证据不开放调制输出、Sweep fire、后面板配置或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -412,5 +437,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure` 和 `rf_source.modulation_configure`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。该提升仅限保持 Sweep disabled 的配置,不提升调制输出、Sweep execute/fire 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure` 和 `rf_source.modulation_configure`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697` 与插件 `5394f15`/`3fb3778` 新增 M3-MO 的 special capability、严格 transaction、CLI/run、私有固定 AM 证据 harness 与 fake 回归。它尚无 DSG830 A4-MO 实机验收,不提升 production descriptor。该提升序列均不开放调制输出、Sweep execute/fire 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index d1b7133..64bcb3f 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -22,7 +22,8 @@ | 身份与状态 | 已开放 | 已开放 | `read_only` 可执行。 | | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | -| 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。调制开启时的 RF 输出仍未开放。 | +| 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。 | +| 受限调制输出 | Core 离线合同已完成 | 未开放 | `rf_source.modulated_output_enable` 只接受已激活且精确匹配的内部 Sine profile;它不配置或关闭调制。当前 DSG830 production descriptor 不声明该 capability,普通 `rf_source.output on` 仍要求调制关闭。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | | 逻辑 trigger configuration 读取 | A5-0 离线合同完成 | 未开放 | `rf-source trigger status`/`rf_source.trigger_status` 需要独立 capability 和 `TRIGGER / READ` profile;当前 DSG830 production descriptor 会拒绝该请求。它不读取或配置物理 trigger/sync 接口。 | @@ -99,6 +100,8 @@ wavebench rf-source sweep configure --config wavebench.toml --port rf_out --star `output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 +上述 production 操作不包括 `enable-output-am`、`enable-output-fm`、`enable-output-pm` 或 `rf_source.modulated_output_enable`。这些入口已经存在于 Core,用于有专门 capability 和实机证据的后续型号;当前 DSG830 descriptor 在连接前拒绝它们。不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 + ## A5-0:逻辑 trigger configuration 读取 Core 已提供下列只读入口: @@ -157,6 +160,8 @@ dwell_s = 0.02 它只配置 frequency-only Step Sweep,不会 arm、fire、触发、执行 `SWE:EXEC`、切换 RF 输出或配置 Level Sweep。DSG830 使用这段 plan 时仍须为 `read_write`,并通过 capability、profile 和 fresh OFF-only preflight;配置完成后必须读回 Sweep disabled。 +`rf_source.modulated_output_enable` 也已进入 run schema,但当前只是非 production 合同。它使用与 `rf_source.modulation_configure` 相同的 `port_id`、`modulation_kind`、内部频率和对应数值字段;不能把配置步骤和输出步骤合并,也不能假定 run plan 会在成功后自动关闭 RF 或调制。DSG830 的 production descriptor 会拒绝该 step,直到专门证据完成并显式提升 capability。 + 先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 ## M3:内部正弦调制合同 @@ -195,7 +200,22 @@ DSG830 production descriptor 已声明 `rf_source.modulation_configure`。它只 DSG830 源码 checkout 的 A4 harness 是已完成的开发验收工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。显式 `--diagnose` 保留原始 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并要求 transport audit 为零写;它只生成私有诊断记录。AM/FM/PM 的 RF-OFF 序列均已通过;PM 的 production profile 因严格读回证据而固定为 `1.25 rad`。 -M2 的 RF ON 合同目前要求调制 disabled。M3 已提升的配置 capability 不能据此推导「已可在调制开启时输出 RF」。允许调制输出需要单独调整输出 safety 合同并取得相应实机证据;不得通过关闭门禁或原始 SCPI 先行绕过。 +M2 的 RF ON 合同目前要求调制 disabled。M3 已提升的配置 capability 不能据此推导「已可在调制开启时输出 RF」。 + +### M3-MO:受限调制输出 + +Core 已提供下列非 production 入口: + +```text +wavebench rf-source modulation enable-output-am ... +wavebench rf-source modulation enable-output-fm ... +wavebench rf-source modulation enable-output-pm ... +rf_source.modulated_output_enable +``` + +它们复用 M3 的 `modulation_kind`、数值字段和内部频率字段,但不会配置调制:调用前目标 profile 必须已经完整激活并与 request 精确一致。Core 还要求 RF 当前为 OFF、Pulse/Sweep disabled、protection 清晰、端口 safety 配置完整、实际端接与 dBm 参考阻抗一致,以及特殊 `RfModulatedOutputProfile` 明确允许这一 profile 与功率。成功路径只启用一次 RF;不会自动 RF OFF 或关闭调制。任何写入或 readback 不确定时不重试 ON,只可能执行一次受 guard 的 RF OFF recovery。 + +当前 DSG830 production descriptor 不声明该 capability。源码 checkout 中的 A4-MO 私有 harness 只验证固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的单次循环:CH2 必须显式为 50 Ω,scope 只观察当前 `DEF` 缓冲区是否有可见信号,随后工具明确 RF OFF、关闭 AM 和全局调制并复核最终状态。CH1 的低频输出独立于 RF 调制路径,不被读取或当作证据;scope 也不用于推断 dBm、频率或调制深度。该实机证据尚未完成前,日常配置不能使用这些命令。 ## M4:受控 Pulse 与 Step Sweep 配置合同 @@ -229,6 +249,6 @@ DSG830 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与无资源 setup 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口。 +5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;M3-MO 在 DSG830 capability 提升前只可由私有受控证据使用。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 From 188292d2dfd5c6c2923bf3ed1ad6271af943d54a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:46:43 +0800 Subject: [PATCH 55/63] feat: expose RF modulation disable cleanup --- src/wavebench/cli.py | 13 ++ src/wavebench/cli_parser.py | 12 ++ src/wavebench/services/execution_intent.py | 1 + src/wavebench/services/run_plan.py | 13 ++ src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 15 +++ tests/test_rf_source_cli.py | 48 +++++++ tests/test_rf_source_run.py | 140 +++++++++++++++++++++ 8 files changed, 243 insertions(+) diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 97d333a..159f823 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -90,6 +90,7 @@ from .instruments.rf_source_extensions import ( RfCwRequest, RfModulatedOutputRequest, + RfModulationDisableRequest, RfModulationKind, RfModulationRequest, RfOutputRequest, @@ -1576,6 +1577,18 @@ def _main(argv: list[str] | None = None) -> int: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 if args.command == "modulation": + if args.modulation_command == "disable": + result = service.disable_modulation( + RfModulationDisableRequest( + port_id=args.port, + kind=RfModulationKind(args.modulation_kind), + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 modulation_kind = { "configure-am": RfModulationKind.AM, "configure-fm": RfModulationKind.FM, diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index c3552f3..9e02267 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -742,6 +742,18 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(rf_source_modulation_configure) + rf_source_modulation_disable = rf_source_modulation_sub.add_parser( + "disable", + help="Disable one known active internal-sine modulation mode while RF output is OFF", + ) + rf_source_modulation_disable.add_argument("--port", required=True) + rf_source_modulation_disable.add_argument( + "--modulation-kind", + choices=("am", "fm", "pm"), + required=True, + ) + add_runtime_options(rf_source_modulation_disable) + rf_source_pulse = rf_source_sub.add_parser( "pulse", help="Configure a bounded internal single-pulse profile while RF output is OFF", diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index d972b0b..cad40b2 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -28,6 +28,7 @@ "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.modulation_disable": "rf_source.modulation_disable", "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", "rf_source.pulse_configure": "rf_source.pulse_configure", "rf_source.sweep_configure": "rf_source.sweep_configure", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index b157c3b..d528efc 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -26,6 +26,7 @@ "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", + "rf_source.modulation_disable", "rf_source.modulated_output_enable", "rf_source.pulse_configure", "rf_source.sweep_configure", @@ -82,6 +83,7 @@ "modulation_kind", "internal_frequency_hz", ), + "rf_source.modulation_disable": ("port_id", "modulation_kind"), "rf_source.modulated_output_enable": ( "port_id", "modulation_kind", @@ -213,6 +215,7 @@ "phase_deviation_rad", "on_failure", }, + "rf_source.modulation_disable": {"on_failure"}, "rf_source.modulated_output_enable": { "depth_percent", "frequency_deviation_hz", @@ -286,6 +289,7 @@ "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", + "rf_source.modulation_disable": "Disable one known active internal-sine AM, FM, or PM mode while RF output is OFF; an already consistent disabled state makes no write.", "rf_source.modulated_output_enable": "Enable one RF port only when its active internal-sine AM, FM, or PM profile exactly matches the requested bounded profile; it does not configure modulation or restore RF OFF.", "rf_source.pulse_configure": "Configure one OFF RF port with a disabled internal single-pulse profile; it does not enable RF output or trigger a pulse.", "rf_source.sweep_configure": "Configure one OFF RF port with a disabled frequency-only Step Sweep profile; it does not arm, fire, trigger, or enable RF output.", @@ -693,6 +697,15 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non elif kind == "rf_source.set_power_dbm": fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") + elif kind == "rf_source.modulation_disable": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + modulation_kind = _non_empty_str( + fields["modulation_kind"], + f"{prefix}.modulation_kind", + ).lower() + if modulation_kind not in {"am", "fm", "pm"}: + raise ConfigError(f"{prefix}.modulation_kind must be one of am, fm, pm") + fields["modulation_kind"] = modulation_kind elif kind in {"rf_source.modulation_configure", "rf_source.modulated_output_enable"}: fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") modulation_kind = _non_empty_str( diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 9a8e9ac..4673ac0 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -26,6 +26,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.set_frequency", "rf_source.set_power_dbm", "rf_source.modulation_configure", + "rf_source.modulation_disable", "rf_source.modulated_output_enable", "rf_source.pulse_configure", "rf_source.sweep_configure", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index bbbc738..9355710 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -25,6 +25,7 @@ from wavebench.instruments.rf_source_extensions import ( RfCwRequest, RfModulatedOutputRequest, + RfModulationDisableRequest, RfModulationKind, RfModulationRequest, RfOutputRequest, @@ -308,6 +309,7 @@ def _check_rf_source_access(self, plan: RunPlan) -> None: "rf_source.set_frequency": "rf_source.set_frequency", "rf_source.set_power_dbm": "rf_source.set_power_dbm", "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.modulation_disable": "rf_source.modulation_disable", "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", "rf_source.pulse_configure": "rf_source.pulse_configure", "rf_source.sweep_configure": "rf_source.sweep_configure", @@ -462,6 +464,8 @@ def add_source_output_gate_capability() -> None: add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") elif step.kind == "rf_source.modulation_configure": add("rf_source", "rf_source.snapshot", "rf_source.modulation_configure") + elif step.kind == "rf_source.modulation_disable": + add("rf_source", "rf_source.snapshot", "rf_source.modulation_disable") elif step.kind == "rf_source.modulated_output_enable": add( "rf_source", @@ -1255,6 +1259,17 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.modulation_disable": + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).disable_modulation_with_artifact( + RfModulationDisableRequest( + port_id=fields["port_id"], + kind=RfModulationKind(fields["modulation_kind"]), + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind in { "rf_source.modulation_configure", "rf_source.modulated_output_enable", diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py index 6853032..711bbf2 100644 --- a/tests/test_rf_source_cli.py +++ b/tests/test_rf_source_cli.py @@ -12,6 +12,8 @@ RfCwResult, RfModulatedOutputRequest, RfModulatedOutputResult, + RfModulationDisableRequest, + RfModulationDisableResult, RfModulationKind, RfModulationRequest, RfModulationResult, @@ -94,6 +96,17 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands( "1000", ] ) + modulation_disable = build_parser().parse_args( + [ + "rf-source", + "modulation", + "disable", + "--port", + "rf_out", + "--modulation-kind", + "am", + ] + ) pulse = build_parser().parse_args( [ "rf-source", @@ -154,6 +167,8 @@ def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands( assert modulation_pm.phase_deviation_rad == 1.5 assert modulation_output_am.modulation_command == "enable-output-am" assert modulation_output_am.depth_percent == 50.0 + assert modulation_disable.modulation_command == "disable" + assert modulation_disable.modulation_kind == "am" assert (pulse.domain, pulse.command, pulse.pulse_command) == ( "rf-source", "pulse", @@ -430,6 +445,39 @@ def test_rf_source_cli_dispatches_profile_bound_modulated_output_enable() -> Non ) +def test_rf_source_cli_dispatches_mode_specific_modulation_disable() -> None: + service = Mock() + service.disable_modulation.return_value = RfModulationDisableResult( + port_id="rf_out", + kind=RfModulationKind.AM, + write_completed=True, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert ( + main( + [ + "--json", + "rf-source", + "modulation", + "disable", + "--port", + "rf_out", + "--modulation-kind", + "am", + ] + ) + == 0 + ) + + payload = json.loads(stdout.getvalue()) + assert payload["result"]["write_completed"] is True + service.disable_modulation.assert_called_once_with( + RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + ) + + def test_rf_source_cli_dispatches_disabled_internal_single_pulse_request() -> None: service = Mock() service.configure_pulse.return_value = RfPulseConfigureResult( diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py index ad4ebfa..bf918b5 100644 --- a/tests/test_rf_source_run.py +++ b/tests/test_rf_source_run.py @@ -22,6 +22,8 @@ from wavebench.instruments.rf_source_extensions import ( RfCwRequest, RfCwResult, + RfModulationDisableRequest, + RfModulationDisableResult, RfModulationKind, RfModulationRequest, RfModulationResult, @@ -56,6 +58,7 @@ RfSweepTriggerMode, RfTriggerSnapshot, rf_source_cw_operation_artifact, + rf_source_modulation_disable_operation_artifact, rf_source_modulation_operation_artifact, rf_source_pulse_operation_artifact, rf_source_sweep_operation_artifact, @@ -149,6 +152,18 @@ def _modulation_plan( return load_run_plan(path) +def _modulation_disable_plan(directory: str, *, modulation_kind: str = "am"): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_disable"\n' + 'port_id = "rf_out"\n' + f'modulation_kind = "{modulation_kind}"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + def _pulse_plan(directory: str): path = Path(directory) / "plan.toml" path.write_text( @@ -673,6 +688,131 @@ def _run_safety_guards(self, run_plan, *, services=None): assert run_data["rf_source_operations"] == [artifact] +def test_rf_source_modulation_disable_plan_has_explicit_mode_only_schema_and_intent() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_disable_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "modulation_kind": "am", + } + assert "rf_source.modulation_disable" in STEP_SCHEMAS + intent = build_execution_intent(plan, _config(directory, access="read_write")) + assert intent.operations[0]["operation"] == "rf_source.modulation_disable" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "modulation_kind": "am", + } + + invalid_path = Path(directory) / "invalid-modulation-disable.toml" + invalid_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_disable"\n' + 'port_id = "rf_out"\n' + 'modulation_kind = "unknown"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="must be one of am, fm, pm"): + load_run_plan(invalid_path) + + +def test_rf_source_modulation_disable_step_requires_capability_and_read_write_access() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_disable_plan(directory) + missing_capability = RunService( + config=_config(directory, access="read_write"), + logger=CommandLogger(), + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(missing_capability, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.modulation_disable"): + missing_capability.run(plan) + open_services.assert_not_called() + + read_only = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + ), + ), patch.object(read_only, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + read_only.run(plan) + open_services.assert_not_called() + + +def test_rf_source_modulation_disable_step_dispatches_typed_artifact() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_disable_plan(directory) + config = _config(directory, access="read_write") + request = RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + result = RfModulationDisableResult( + port_id="rf_out", + kind=RfModulationKind.AM, + write_completed=True, + ) + preflight_snapshot = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + preflight_modulation_state = RfModulationStateSnapshot( + port_id="rf_out", + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + postcondition_modulation_state = RfModulationStateSnapshot(port_id="rf_out") + artifact = rf_source_modulation_disable_operation_artifact( + request=request, + result=result, + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=_snapshot(), + postcondition_modulation_state=postcondition_modulation_state, + ) + rf_service = SimpleNamespace( + disable_modulation_with_artifact=Mock(return_value=(result, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.disable_modulation_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + def test_rf_source_pulse_plan_normalizes_the_disabled_internal_single_subset() -> None: with TemporaryDirectory() as directory: plan = _pulse_plan(directory) From 48b95501c5c2af1736cb7a293ff8cec23f4346da Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:15:39 +0800 Subject: [PATCH 56/63] test: cover modulated output driver contract --- tests/test_rf_source_extensions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index d17214a..6de9b31 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -473,6 +473,10 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "get_rf_modulation_state", "disable_rf_modulation", ), + "rf_source.modulated_output_enable": ( + "get_rf_modulation_snapshot", + "set_rf_output", + ), "rf_source.pulse_configure": ( "get_rf_pulse_snapshot", "configure_rf_pulse", From c99bf3cc1bfa6d59e18584249f94f517e246a1cc Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:16:25 +0800 Subject: [PATCH 57/63] docs: record DSG830 modulated output promotion --- README.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 24 +++++------ ...67\346\272\220\350\256\276\350\256\241.md" | 30 +++++++------- ...77\347\224\250\346\214\207\345\215\227.md" | 40 ++++++++++++++----- 4 files changed, 60 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6139b8d..e79e5e8 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`;调制配置不授权调制开启时的 RF 输出。Core 已有受限 `rf_source.modulated_output_enable` 合同,但 DSG830 尚未取得该能力的实机证据,也未在 production descriptor 中声明它;普通 `rf_source.output` 仍要求调制关闭。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。A5-0 已具备逻辑 Pulse/Sweep trigger configuration 的只读代码合同,但 DSG830 production descriptor 未声明该 capability;它不代表后面板 trigger/sync 接口已定义或可操作。当前 production 范围仍不提供 `modulation_disable`、execute、arm、fire、trigger、Level Sweep 或 list。 +`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置及按模式关闭、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`。A4-MO 已将受限 `rf_source.modulated_output_enable` 提升到 production:仅接受已激活且精确匹配的 AM `50 %`/`1 kHz` profile,最大功率 `-50 dBm`;普通 `rf_source.output` 仍要求调制关闭。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。A5-0 已具备逻辑 Pulse/Sweep trigger configuration 的只读代码合同,但 DSG830 production descriptor 未声明该 capability;它不代表后面板 trigger/sync 接口已定义或可操作。当前 production 范围仍不提供 execute、arm、fire、trigger、Level Sweep 或 list。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 6547008..7403a7a 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M4、M3-MO 与 A5-0 的离线合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep,以及逻辑 trigger configuration 的只读类型、Service、CLI、run 和 artifact;按模式调制关闭仅用于本地证据与私有恢复。 | -| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep 的 A4 均已通过并提升;M3-MO 的私有 harness 与 fake 回归完成,实机验收待进行;A5-0 映射已完成,物理 A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 与六条固定 trigger configuration query 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,不声明 `rf_source.modulated_output_enable` 或 `rf_source.trigger_snapshot`。 | -| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A4-MO 与物理 A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 是单独的调制输出证据。A5-0 不产生 production capability。 | +| Core `0.8.25` 开发线 | M0–M4、M3-MO 与 A5-0 的离线合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、按模式调制关闭、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep,以及逻辑 trigger configuration 的只读类型、Service、CLI、run 和 artifact。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep、M3-MO 的 A4/A4-MO 均已通过并提升;A5-0 映射已完成,物理 A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 与六条固定 trigger configuration query 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 和 A4-MO 已完成;物理 A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、调制关闭、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 只提升固定 AM 调制输出 profile。A5-0 不产生 production capability。 | ## 双仓库交付规则 @@ -30,8 +30,8 @@ | M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | -| M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置事务、CLI、run 与 artifact;按模式关闭仅用于本地证据与私有恢复 | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;DSG830 production 已开放 `rf_source.modulation_configure`。PM 的 production profile 固定为 `1.25 rad`。 | -| M3-MO | 离线完成;A4-MO 实机待验收 | `RfModulatedOutputProfile`、严格 pre/post RF 与调制 snapshot、一次 RF ON、受 guard OFF recovery、CLI、run 与 artifact | 复用既有 `:OUTP`/调制 snapshot 映射;私有固定 AM descriptor、CH2-only evidence harness 与 fake 回归 | 只接受已激活且精确匹配的内部 Sine profile;不配置调制,不重试 ON。普通 `rf_source.output` 仍要求调制关闭。DSG830 production 不声明 `rf_source.modulated_output_enable`。 | +| M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置与按模式关闭事务、CLI、run 与 artifact | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 配置只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;关闭只在 RF OFF、唯一目标模式活动时写入。DSG830 production 已开放 `rf_source.modulation_configure` 和 `rf_source.modulation_disable`。PM 的 production profile 固定为 `1.25 rad`。 | +| M3-MO | A4-MO 已通过并提升 | `RfModulatedOutputProfile`、严格 pre/post RF 与调制 snapshot、一次 RF ON、受 guard OFF recovery、CLI、run 与 artifact | 复用既有 `:OUTP`/调制 snapshot 映射;固定 AM descriptor、CH2-only evidence harness 与 fake 回归 | 只接受已激活且精确匹配的内部 Sine profile;不配置调制,不重试 ON。普通 `rf_source.output` 仍要求调制关闭。DSG830 production 仅开放 AM `50 %`/`1 kHz`、最大 `-50 dBm`。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | | A5-0 | 离线完成;不属于物理 A5 证据 | `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、只读 Service/CLI/run/artifact | `:PULM:TRIG:MODE?`、external edge/gate query、Sweep mode/period/point trigger query 与严格 enum parser | 只使用 `TRIGGER / READ` profile 和非 production descriptor;固定 query 顺序、零 write、未知值失败关闭。它不定义物理 connector,不发送 trigger,也不提升 production capability。 | @@ -61,7 +61,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 调制、Pulse 与 Step Sweep 已分别将 `rf_source.modulation_configure`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor。`rf_source.modulation_disable` 仍只存在于私有证据与恢复路径。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 调制、Pulse 与 Step Sweep 已分别将 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor;A4-MO 已将固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到同一 descriptor。 ### 离线完成条件 @@ -73,7 +73,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收和 A3 受控实机证据均已完成;DSG830 production 已开放 CW capability。 -DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;RF 调制输出、`rf_source.modulation_disable`、Sweep execute/fire、trigger 与 Level Sweep 仍继续关闭。 +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;Sweep execute/fire、trigger 与 Level Sweep 仍继续关闭。 ## M2:RF 输出安全事务 @@ -94,20 +94,20 @@ FM/PM 的共享 mode type 会与被查询 profile 分开记录。当前类型 均 disabled、Pulse/Sweep disabled 且无活动 protection condition;postcondition 要求 RF 仍 OFF、仅目标模式 enabled、全局调制 开启且所有目标字段精确匹配。写入或 postcondition 结果不明时不重试,session 降为不确定状态。 -`rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。该 operation 当前只供 A4 本地证据、A4-MO 清理和私有恢复流程使用,不进入 DSG830 production descriptor。 +`rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。A4 调制与 A4-MO 清理均已验证此事务,因此 DSG830 production descriptor 已开放它,并由 `wavebench rf-source modulation disable --port PORT_ID --modulation-kind am|fm|pm` 和 `rf_source.modulation_disable` 提供日常入口。 DSG830 的 A4 调制证据已将 `rf_source.modulation_configure` 加入 production descriptor。production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`,以及 PM 精确 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。driver 的离线 PM 映射范围不自动扩大 production profile。 -当前 M2 的 RF ON 合同仍要求调制 disabled,因此 M3 capability 不授权在调制开启时输出 RF;M3-MO 为此提供了专门的输出安全合同,但仍需要独立的 A4-MO 实机证据。 +当前 M2 的 RF ON 合同仍要求调制 disabled,因此 M3 capability 不授权在调制开启时输出 RF;M3-MO 为固定 profile 提供专门的输出安全合同,且已由独立 A4-MO 实机证据提升。 DSG830 源码 checkout 提供 A4 的独立本地 harness 和资源无关 setup 模板。一次运行只配置一个内部 Sine AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。三种模式的 RF-OFF 配置、严格读回与关闭恢复均已通过。为使生产 profile 与 PM 的严格读回证据完全一致,PM 仅开放 `1.25 rad`,不将离线映射的其它值外推到 production。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是新的 capability 提升证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。该证据不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 -### M3-MO:受限调制输出(离线完成,A4-MO 待验收) +### M3-MO:受限调制输出(A4-MO 已通过并提升) Core 将调制开启时的 RF 输出建模为独立的 `rf_source.modulated_output_enable`,而不是放宽通用 `rf_source.output`。request 包含一个内部 Sine AM/FM/PM profile;preflight 要求 RF OFF、全局调制开启、唯一目标模式开启且完整 profile 与 request 精确相同,Pulse/Sweep disabled、protection 清晰、频率/功率/实际端接满足端口 safety 配置,并且 request 被 `RfModulatedOutputProfile` 的窄范围接受。它读取 RF snapshot 和完整调制 snapshot,单次 ON 后再次读取二者;不会配置调制,也不会在成功后自动 RF OFF 或关闭调制。 任何 ON 结果不明、RF readback 或调制 readback 不符时,都不重试 ON。只有 session health 允许时,Core 才复用 M2 的一次受 guard RF OFF recovery;恢复后不推断调制状态。普通 `rf_source.output` 的 ON preflight 不变,仍要求调制关闭。 -DSG830 源码 checkout 提供 `tools/a4_modulated_output_evidence.py` 与无资源 setup 模板。它使用仅在内存中创建的 descriptor,固定为 AM `50 %`/内部 `1 kHz`、RF `1 MHz`/`-50 dBm`,并只读取 CH2 的当前 `DEF` 缓冲区。CH2 必须由 setup 显式确认 50 Ω;scope 只判定是否有可见信号,不计算 dBm、频率或调制深度。工具不读取或控制 CH1,不把 LF OUTPUT 解释为调制测量,不使用 trigger/sync/后面板 Pulse I/O。成功路径仍显式 RF OFF,再关闭 AM 和全局调制,最后独立确认安全基线。该 harness 目前只完成代码与 fake 回归;未取得私有实机通过记录并完成审查前,不得将 capability 加入 production descriptor。 +DSG830 源码 checkout 提供 `tools/a4_modulated_output_evidence.py` 与无资源 setup 模板。它使用仅在内存中创建的 descriptor,固定为 AM `50 %`/内部 `1 kHz`、RF `1 MHz`/`-50 dBm`,并只读取 CH2 的当前 `DEF` 缓冲区。CH2 必须由 setup 显式确认 50 Ω;scope 只判定是否有可见信号,不计算 dBm、频率或调制深度。工具不读取或控制 CH1,不把 LF OUTPUT 解释为调制测量,不使用 trigger/sync/后面板 Pulse I/O。受控序列已通过:154 次 RF query、12 次完成 write、CH2 信号存在、最终 RF OFF/调制关闭、两个 session 健康关闭,脱敏记录为 `0600`。因此 production descriptor 仅声明相同 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 capability;historical harness 会拒绝重跑。 ## M4:Pulse 与 Step Sweep @@ -211,4 +211,4 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的 Core 合同、DSG830 映射、CLI、run、artifact 与 A4 证据均已完成,`rf_source.modulation_configure` 已提升;M4 继续保持独立工作。 6. A4 的 AM/FM/PM RF-OFF 单模式配置、严格读回与关闭恢复证据均已通过。PM production profile 固定为 `1.25 rad`,以避免将更宽的离线映射当作实机覆盖范围。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成新的 capability 提升证据;任何允许调制开启时 RF 输出的安全合同仍须单独设计和验证,CH2 可见信号也不能替代该证据。 7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 -8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、DSG830 固定 profile 私有 descriptor、CH2-only harness 与 fake 回归。下一步是先用网络发现与只读诊断确认隔离设备状态,再以固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 执行一次受控 A4-MO;只在 final RF OFF、调制关闭、健康关闭和脱敏证据均通过后,才评估 production descriptor 提升。CH1 的低频输出不属于该证据路径。 +8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、公开按模式关闭入口、DSG830 固定 profile descriptor、CH2-only harness 与 fake 回归。已使用 WaveBench 有界网络发现确认候选设备,并完成只读诊断与固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的一次受控 A4-MO;CH2 信号存在、最终 RF OFF、调制关闭和健康关闭均已通过,production descriptor 已提升。CH1 的低频输出不属于该证据路径。后续进入 A5 前仍须确认唯一的物理接口、接线和电气边界。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index df6f47d..b66ac2f 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制、M3-MO 受限调制输出,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3/A4 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3-MO 仍只是非 production 的固定 profile 合同与私有证据脚本;M3 的 PM production profile 仅为 `1.25 rad`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制及按模式关闭、M3-MO 受限调制输出,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3/A4/A4-MO 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制及按模式关闭、固定 profile 调制输出、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3 的 PM production profile 仅为 `1.25 rad`;M3-MO 的 production profile 仅为 AM `50 %`/`1 kHz`、最大 `-50 dBm`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 阅读顺序如下: @@ -15,9 +15,9 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM、M3-MO profile-bound 调制输出、仅用于受控恢复的调制关闭事务,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4 调制/Pulse/Step Sweep 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。它不声明 `rf_source.modulated_output_enable`。PM 限于 `1.25 rad`。 | -| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 已完成;A4-MO 的脚本和 fake 回归完成,实机验收待进行;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;M3 的 A4 只提升 RF-OFF 配置,不提升调制开启时的 RF 输出。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM 及按模式关闭、M3-MO profile-bound 调制输出,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4/A4-MO 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 限于 `1.25 rad`。 | +| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 与 A4-MO 已完成;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;A4-MO 只提升已声明的固定 AM 调制输出 profile,不放宽普通 RF ON。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep 的提升边界,以及 M1、M3、M3-MO 与 M4 的离线开发和 fake transport 验证边界。DSG830 的 M3 已完成 Core 与实机验收并进入 production;M3-MO 的安全合同已经落地,但实机证据与 production 推广仍另行处理,离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep/A4-MO 的提升边界,以及 M1、M3、M3-MO 与 M4 的离线开发和 fake transport 验证边界。DSG830 的 M3 与 M3-MO 已完成 Core 与实机验收并进入 production;A5 仍另行处理,离线代码不能替代这些证据。 ## 范围与非目标 @@ -57,7 +57,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 - 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 - 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 -- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 RF-OFF 调制、Pulse 或 Step Sweep 配置 capability。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 RF-OFF 调制、调制关闭、Pulse 或 Step Sweep 配置 capability,A4-MO 只授权其精确声明的调制输出 profile。 ## 分层与职责 @@ -271,7 +271,7 @@ CW、调制、Pulse 与 Sweep 配置必须按以下顺序执行: `rf_source.modulation_disable` 是一个独立的、按模式寻址的写事务,不是 reset,也不等同于 RF 输出关闭。它只在目标 RF 输出为 OFF、Pulse/Sweep 已关闭、protection 清晰,并且状态明确表明仅请求的 AM、FM 或 PM 模式已启用时才发送关闭写入;随后必须用 RF snapshot 和调制状态回读确认全局调制及所有模式均已关闭。 -已一致关闭的状态以零写方式返回。混合模式、状态矛盾、未知状态或写后结果不明都会拒绝或使 session 降为不确定状态,不能改用宽泛关闭命令重试。该 operation 当前用于受控本地证据和恢复流程;生产 DSG830 descriptor 不因此新增 capability,也没有面向日常使用的 CLI 或 run step。 +已一致关闭的状态以零写方式返回。混合模式、状态矛盾、未知状态或写后结果不明都会拒绝或使 session 降为不确定状态,不能改用宽泛关闭命令重试。DSG830 的 A4 调制与 A4-MO 清理证据已经覆盖该事务,因此 production descriptor 声明 `rf_source.modulation_disable`,并提供 `wavebench rf-source modulation disable --port PORT_ID --modulation-kind am|fm|pm` 与 `rf_source.modulation_disable` run step。 RF ON 是独立 operation。其 preflight 必须确认: @@ -292,6 +292,8 @@ RF OFF 不依赖频率、功率、端接或 protection readback;它仍受 acce ON 写入、RF readback 或调制 readback 任何一项不确定时,绝不重试 ON;只允许沿用 M2 的一次受 guard RF OFF recovery。恢复后 session 保持不确定,调用方不得假设调制已关闭。`RfModulatedOutputProfile` 必须显式声明可用端口、精确 mode profile 与最大功率,不能从基础 `RfModulationProfile` 或普通 output capability 自动推导。 +DSG830 的 A4-MO 受控证据仅提升 AM `50 %`、内部 `1 kHz`、最大 `-50 dBm`。它必须在普通 M3 配置读回后通过 `enable-output-am` 启用;结束时先用普通 `output off`,再用按模式 `modulation disable` 清理。该 profile 不授权 FM/PM 调制输出,也不修改普通 `rf_source.output` 的调制关闭前置条件。 + Sweep arm 是 OFF-only 准备 operation,必须保持目标端口 RF OFF。Sweep fire 与 Pulse trigger 是潜在能量操作。它们必须使用独立、一次性安全决定,不能因先前的 configure、arm 或 output ON 成功而自动获得许可。core 不会隐式打开输出、触发外部端口或开启后面板辅助输出。 ## Service、CLI、doctor 与 run plan @@ -321,7 +323,7 @@ wavebench rf-source output --port PORT_ID on|off rf_source.output_enable rf_source.output_disable -# M3-MO:只限声明 rf_source.modulated_output_enable 的非 production descriptor 或私有受控证据 +# M3-MO:只限已完成对应实机证据的 production descriptor wavebench rf-source modulation enable-output-am ... wavebench rf-source modulation enable-output-fm ... wavebench rf-source modulation enable-output-pm ... @@ -350,7 +352,7 @@ wavebench rf-source modulation configure-fm ... wavebench rf-source modulation configure-pm ... rf_source.modulation_configure -# M3-MO:仅当特殊 capability 已被实机证据提升时才是生产入口 +# M3-MO:仅接受 descriptor 明确声明的 active profile wavebench rf-source modulation enable-output-am ... wavebench rf-source modulation enable-output-fm ... wavebench rf-source modulation enable-output-pm ... @@ -373,7 +375,7 @@ rf_source.sweep_configure M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。M2 的 RF ON 仍要求调制关闭,因此这不是调制输出入口。 -M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要求调制已经激活且完整 profile 精确匹配,不会配置该 profile。当前 Core schema 已有三个 `enable-output-*` CLI 和 `rf_source.modulated_output_enable` run step;DSG830 production descriptor 尚未声明该 capability。源码 checkout 的私有 A4-MO harness 只使用固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 和 CH2 的 50 Ω 可见信号观察来取得一次窄范围证据。它不读取或控制 CH1,不把 LF OUTPUT 当作 AM 测量,也不从 scope 推断 dBm、频率或调制深度。 +M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要求调制已经激活且完整 profile 精确匹配,不会配置该 profile。当前 Core schema 已有三个 `enable-output-*` CLI、`rf_source.modulated_output_enable` run step,以及显式 `rf_source.modulation_disable` 清理入口。DSG830 production descriptor 只声明 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 M3-MO profile;FM/PM special output 请求会在仪器 I/O 前拒绝。A4-MO 使用同一固定 AM profile、RF `1 MHz`/`-50 dBm` 和 CH2 的 50 Ω 可见信号观察完成受控验收。它不读取或控制 CH1,不把 LF OUTPUT 当作 AM 测量,也不从 scope 推断 dBm、频率或调制深度。 `rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 @@ -395,13 +397,13 @@ M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要 | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(A4 已通过并提升) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;DSG830 已声明 `rf_source.modulation_configure`,其中 PM 固定为 `1.25 rad`。 | -| M3-MO(离线完成;A4-MO 实机待验收) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;私有固定 AM evidence descriptor 和 CH2-only harness | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production descriptor 尚未声明此 capability。 | +| M3-MO(A4-MO 已通过并提升) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;固定 AM evidence descriptor 和 CH2-only harness | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production 仅声明 AM `50 %`/`1 kHz`、最大 `-50 dBm`。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | | M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | M0–M4 与 M3-MO 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A4-MO 调制输出,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 的代码与 fake 回归已经具备,但未取得实机证据前,`rf_source.modulated_output_enable` 不能进入 production descriptor。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的 50 Ω CH2 路径上通过,因此仅将同一 AM profile、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到 production。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -420,7 +422,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step Sweep 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM,严格 readback 后由 A4 harness 执行受限调制关闭;PM production profile 固定为 `1.25 rad`,`rf_source.modulation_disable` 仍不进入 descriptor。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但其 special capability 与固定 evidence descriptor 只存在于非 production 测试,未改变普通 `rf_source.output` 的调制关闭前置条件。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4 harness 在对应 descriptor 提升后拒绝重跑;普通 M3/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 OFF-only preflight。既有证据不开放调制输出、Sweep fire、后面板配置或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.modulated_output_enable`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM 与按模式关闭;PM production profile 固定为 `1.25 rad`。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但 production 只接受 AM `50 %`/`1 kHz`、最大 `-50 dBm`;它未改变普通 `rf_source.output` 的调制关闭前置条件。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4/A4-MO harness 在对应 descriptor 提升后拒绝重跑;普通 M3/M3-MO/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 preflight。既有证据不开放 Sweep fire、后面板配置或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -437,5 +439,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4 调制/Pulse/Step - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure` 和 `rf_source.modulation_configure`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697` 与插件 `5394f15`/`3fb3778` 新增 M3-MO 的 special capability、严格 transaction、CLI/run、私有固定 AM 证据 harness 与 fake 回归。它尚无 DSG830 A4-MO 实机验收,不提升 production descriptor。该提升序列均不开放调制输出、Sweep execute/fire 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.modulation_disable`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697`/`188292d` 与插件 `5394f15`/`3fb3778`/`65eb611` 新增 M3-MO special capability、严格 transaction、公开调制关闭入口、固定 AM 证据 harness 与 fake 回归。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm`、CH2 50 Ω 路径上通过,最终 RF OFF、调制关闭和健康关闭均经独立复核;production 仅提升该 AM profile、最大 `-50 dBm`。该提升不开放 Sweep execute/fire 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 64bcb3f..5bac0bb 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -23,7 +23,8 @@ | CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | | 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。 | -| 受限调制输出 | Core 离线合同已完成 | 未开放 | `rf_source.modulated_output_enable` 只接受已激活且精确匹配的内部 Sine profile;它不配置或关闭调制。当前 DSG830 production descriptor 不声明该 capability,普通 `rf_source.output on` 仍要求调制关闭。 | +| 按模式关闭调制 | 已开放 | A4 后已开放 | RF OFF、Pulse/Sweep disabled 且唯一目标模式活动时才写入;已一致关闭时零写返回。 | +| 受限调制输出 | A4-MO 后已开放 | A4-MO 后已开放 | 仅 AM `50 %`/内部 `1 kHz`、最大 `-50 dBm`。它要求 profile 已激活且精确匹配,不配置或关闭调制;普通 `rf_source.output on` 仍要求调制关闭。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | | 逻辑 trigger configuration 读取 | A5-0 离线合同完成 | 未开放 | `rf-source trigger status`/`rf_source.trigger_status` 需要独立 capability 和 `TRIGGER / READ` profile;当前 DSG830 production descriptor 会拒绝该请求。它不读取或配置物理 trigger/sync 接口。 | @@ -92,6 +93,7 @@ wavebench rf-source set-power --config wavebench.toml --port rf_out -40 wavebench rf-source modulation configure-am --config wavebench.toml --port rf_out --depth-percent 25 --internal-frequency-hz 1000 wavebench rf-source modulation configure-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 10000 --internal-frequency-hz 1000 wavebench rf-source modulation configure-pm --config wavebench.toml --port rf_out --phase-deviation-rad 1.25 --internal-frequency-hz 1000 +wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind am wavebench rf-source output --config wavebench.toml --port rf_out on wavebench rf-source output --config wavebench.toml --port rf_out off wavebench rf-source pulse configure --config wavebench.toml --port rf_out --period-s 0.001 --width-s 0.0001 --polarity normal @@ -100,7 +102,16 @@ wavebench rf-source sweep configure --config wavebench.toml --port rf_out --star `output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 -上述 production 操作不包括 `enable-output-am`、`enable-output-fm`、`enable-output-pm` 或 `rf_source.modulated_output_enable`。这些入口已经存在于 Core,用于有专门 capability 和实机证据的后续型号;当前 DSG830 descriptor 在连接前拒绝它们。不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 +调制输出必须使用单独的受限序列,不能将已激活调制交给普通 `output on`: + +```bash +wavebench rf-source modulation configure-am --config wavebench.toml --port rf_out --depth-percent 50 --internal-frequency-hz 1000 +wavebench rf-source modulation enable-output-am --config wavebench.toml --port rf_out --depth-percent 50 --internal-frequency-hz 1000 +wavebench rf-source output --config wavebench.toml --port rf_out off +wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind am +``` + +DSG830 目前只声明上述精确 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `enable-output-am`。`enable-output-fm`、`enable-output-pm` 会因 profile 不匹配而在仪器 I/O 前拒绝。成功的特殊 ON 不会自动 RF OFF 或关闭调制;结束时必须显式执行普通 `output off` 和按模式 `disable`。不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 ## A5-0:逻辑 trigger configuration 读取 @@ -160,19 +171,30 @@ dwell_s = 0.02 它只配置 frequency-only Step Sweep,不会 arm、fire、触发、执行 `SWE:EXEC`、切换 RF 输出或配置 Level Sweep。DSG830 使用这段 plan 时仍须为 `read_write`,并通过 capability、profile 和 fresh OFF-only preflight;配置完成后必须读回 Sweep disabled。 -`rf_source.modulated_output_enable` 也已进入 run schema,但当前只是非 production 合同。它使用与 `rf_source.modulation_configure` 相同的 `port_id`、`modulation_kind`、内部频率和对应数值字段;不能把配置步骤和输出步骤合并,也不能假定 run plan 会在成功后自动关闭 RF 或调制。DSG830 的 production descriptor 会拒绝该 step,直到专门证据完成并显式提升 capability。 +`rf_source.modulation_disable` 也已进入 run schema;它只需要 `port_id` 与 `modulation_kind`: + +```toml +[[steps]] +kind = "rf_source.modulation_disable" +port_id = "rf_out" +modulation_kind = "am" +``` + +`rf_source.modulated_output_enable` 同样已进入 production schema。它使用与 `rf_source.modulation_configure` 相同的 `port_id`、`modulation_kind`、内部频率和对应数值字段;不能把配置步骤和输出步骤合并,也不能假定 run plan 会在成功后自动关闭 RF 或调制。DSG830 的 production descriptor 只接受 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 profile。 先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 ## M3:内部正弦调制合同 -Core 已提供三条 M3 命令和一个 run step: +Core 已提供三条 M3 配置命令、一个按模式关闭命令和对应 run step: ```text wavebench rf-source modulation configure-am ... wavebench rf-source modulation configure-fm ... wavebench rf-source modulation configure-pm ... +wavebench rf-source modulation disable --port PORT_ID --modulation-kind am|fm|pm rf_source.modulation_configure +rf_source.modulation_disable ``` 该合同只覆盖内部 Sine: @@ -196,15 +218,15 @@ internal_frequency_hz = 1000 M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disabled、Pulse/Sweep disabled,且没有活动 protection condition。FM 与 PM 共享设备的当前选择位:在三种模式均关闭时,preflight 可以观察到另一种 FM/PM 选择,固定写入会明确选择目标类型;postcondition 必须确认已切换到目标类型。Core 用独立调制 snapshot 验证目标模式、源、波形、数值、内部频率、全局调制开关和 RF 输出仍然 OFF。写入结果不明或 postcondition 不匹配时不重试,session 会降为不确定状态。 -DSG830 production descriptor 已声明 `rf_source.modulation_configure`。它只接受 descriptor 中的内部 Sine profile:AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。超出该 production profile 的请求会在仪器 I/O 前拒绝;不能把 driver 的离线映射范围当作当前设备的写入授权。 +DSG830 production descriptor 已声明 `rf_source.modulation_configure` 与 `rf_source.modulation_disable`。它只接受 descriptor 中的内部 Sine profile:AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。关闭操作只在 RF OFF、Pulse/Sweep disabled、protection 清晰且请求模式是唯一活动模式时写入;已一致关闭时零写。超出 production profile 或状态不清晰的请求会在仪器 I/O 前拒绝;不能把 driver 的离线映射范围当作当前设备的写入授权。 DSG830 源码 checkout 的 A4 harness 是已完成的开发验收工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。显式 `--diagnose` 保留原始 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并要求 transport audit 为零写;它只生成私有诊断记录。AM/FM/PM 的 RF-OFF 序列均已通过;PM 的 production profile 因严格读回证据而固定为 `1.25 rad`。 M2 的 RF ON 合同目前要求调制 disabled。M3 已提升的配置 capability 不能据此推导「已可在调制开启时输出 RF」。 -### M3-MO:受限调制输出 +### M3-MO:受限调制输出(A4-MO 已通过并提升) -Core 已提供下列非 production 入口: +Core 已提供下列专用入口: ```text wavebench rf-source modulation enable-output-am ... @@ -215,7 +237,7 @@ rf_source.modulated_output_enable 它们复用 M3 的 `modulation_kind`、数值字段和内部频率字段,但不会配置调制:调用前目标 profile 必须已经完整激活并与 request 精确一致。Core 还要求 RF 当前为 OFF、Pulse/Sweep disabled、protection 清晰、端口 safety 配置完整、实际端接与 dBm 参考阻抗一致,以及特殊 `RfModulatedOutputProfile` 明确允许这一 profile 与功率。成功路径只启用一次 RF;不会自动 RF OFF 或关闭调制。任何写入或 readback 不确定时不重试 ON,只可能执行一次受 guard 的 RF OFF recovery。 -当前 DSG830 production descriptor 不声明该 capability。源码 checkout 中的 A4-MO 私有 harness 只验证固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的单次循环:CH2 必须显式为 50 Ω,scope 只观察当前 `DEF` 缓冲区是否有可见信号,随后工具明确 RF OFF、关闭 AM 和全局调制并复核最终状态。CH1 的低频输出独立于 RF 调制路径,不被读取或当作证据;scope 也不用于推断 dBm、频率或调制深度。该实机证据尚未完成前,日常配置不能使用这些命令。 +DSG830 production descriptor 仅声明 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`。A4-MO 使用同一固定 profile、RF `1 MHz`/`-50 dBm` 完成一次受控循环:CH2 显式为 50 Ω,scope 只观察当前 `DEF` 缓冲区是否有可见信号,随后工具明确 RF OFF、关闭 AM 和全局调制并复核最终状态。CH1 的低频输出独立于 RF 调制路径,不被读取或当作证据;scope 也不用于推断 dBm、频率或调制深度。历史 harness 在 capability 提升后拒绝重跑,日常操作只能使用 production descriptor、`read_write`、完整 safety 配置和显式清理步骤。 ## M4:受控 Pulse 与 Step Sweep 配置合同 @@ -249,6 +271,6 @@ DSG830 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与无资源 setup 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;M3-MO 在 DSG830 capability 提升前只可由私有受控证据使用。 +5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;M3-MO 只能使用 DSG830 已声明的固定 AM profile,并在结束时显式 RF OFF 与按模式关闭调制。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 From c978febe7ae2cd09059c6e0c78e620c4f9582c81 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:32:25 +0800 Subject: [PATCH 58/63] docs: describe A5 zero-write baseline review --- ...\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" | 2 +- ...\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 7403a7a..5e1f5a1 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -154,7 +154,7 @@ A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out | 初始与恢复状态 | 初始 RF 输出、调制、Pulse、Sweep、protection 与后面板配置;失败后的恢复方式和最终 RF OFF 独立确认方式。 | | 观察方式 | 如使用示波器,只能作为补充观察;必须核对其输入与接线,且不能替代仪器端读回。CH2 的 50 Ω 声明仅适用于已确认的 RF 路径。 | -在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser、fake transport 零写回归,以及保持原始 `read_only` 配置的私有零写 harness;隔离诊断已完成 22 次 query、零 write、最终 RF OFF 和健康关闭复核;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。production descriptor 仍须等待该证据逐项提升。 +在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser、fake transport 零写回归,以及保持原始 `read_only` 配置的私有零写 harness;隔离诊断已完成 22 次 query、零 write、最终 RF OFF 和健康关闭复核;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。A5-0 的静态预检精确绑定当前 production descriptor 的 capability 列表;后续 capability 变更必须先经代码审查、fake 回归和新的零写诊断更新该基线,否则工具在建立 session 前拒绝。这一维护不构成物理 A5 证据。production descriptor 仍须等待物理证据逐项提升。 ### A1:已完成的只读 snapshot 验收 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index b66ac2f..c250d1c 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -385,7 +385,7 @@ M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要 外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。新的 operation 必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 -在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成一次隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。该诊断不构成物理 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 +在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成一次隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。静态预检精确绑定当前 production descriptor 的 capability 列表;后续 capability 变更必须通过代码审查、fake 回归和新的零写诊断更新该基线,否则工具在建立 session 前拒绝。该诊断不构成物理 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 ## M0–M4 与 M3-MO 里程碑 From 877645d6bdbdf8b91315de112f10ff3337d68103 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:41:18 +0800 Subject: [PATCH 59/63] feat: add guarded RF pulse output contract --- src/wavebench/cli.py | 14 + src/wavebench/cli_parser.py | 9 + .../instruments/rf_source_capabilities.py | 79 ++++ .../instruments/rf_source_extensions.py | 240 +++++++++++ src/wavebench/services/operation_specs.py | 33 ++ src/wavebench/services/rf_source_service.py | 301 +++++++++++++ src/wavebench/services/run_plan.py | 17 + src/wavebench/services/run_safety.py | 2 + src/wavebench/services/run_service.py | 22 + tests/test_rf_source_extensions.py | 4 + tests/test_rf_source_pulse_output_cli.py | 74 ++++ .../test_rf_source_pulse_output_extensions.py | 282 ++++++++++++ tests/test_rf_source_pulse_output_run.py | 219 ++++++++++ tests/test_rf_source_pulse_output_service.py | 401 ++++++++++++++++++ 14 files changed, 1697 insertions(+) create mode 100644 tests/test_rf_source_pulse_output_cli.py create mode 100644 tests/test_rf_source_pulse_output_extensions.py create mode 100644 tests/test_rf_source_pulse_output_run.py create mode 100644 tests/test_rf_source_pulse_output_service.py diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 159f823..fd5abc5 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -95,6 +95,7 @@ RfModulationRequest, RfOutputRequest, RfPulseConfigureRequest, + RfPulseOutputRequest, RfPulsePolarity, RfSweepConfigureRequest, ) @@ -1634,6 +1635,19 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) return 0 + if args.command == "pulse-output": + result = service.set_pulse_output( + RfPulseOutputRequest( + port_id=args.port, + interface_id=args.interface_id, + enabled=args.state == "on", + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.command == "sweep": result = service.configure_sweep( RfSweepConfigureRequest( diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 9e02267..fcc59a8 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -776,6 +776,15 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(rf_source_pulse_configure) + rf_source_pulse_output = rf_source_sub.add_parser( + "pulse-output", + help="Set one declared physical Pulse-output interface without enabling RF output", + ) + rf_source_pulse_output.add_argument("--port", required=True) + rf_source_pulse_output.add_argument("--interface", dest="interface_id", required=True) + rf_source_pulse_output.add_argument("state", choices=["on", "off"]) + add_runtime_options(rf_source_pulse_output) + rf_source_sweep = rf_source_sub.add_parser( "sweep", help="Configure a bounded frequency-only Step Sweep while RF output is OFF", diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py index 7f7a09b..42f939c 100644 --- a/src/wavebench/instruments/rf_source_capabilities.py +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -22,6 +22,7 @@ RfModulationProfile, RfOutputProfile, RfPulseProfile, + RfPulseOutputProfile, RfSourceDescriptorExtensions, RfSweepProfile, RfTriggerProfile, @@ -51,6 +52,10 @@ "get_rf_pulse_snapshot", "configure_rf_pulse", ), + "rf_source.pulse_output": ( + "get_rf_pulse_output_snapshot", + "set_rf_pulse_output", + ), "rf_source.sweep_configure": ( "get_rf_sweep_snapshot", "configure_rf_sweep", @@ -111,6 +116,14 @@ def validate_rf_source_descriptor(descriptor: object, driver: object | None = No _validate_modulated_output_enable_feature(extensions) if "rf_source.pulse_configure" in rf_capabilities: _validate_pulse_configure_feature(extensions) + if "rf_source.pulse_output" in rf_capabilities: + if "rf_source.snapshot" not in rf_capabilities: + raise ConfigError("rf_source.pulse_output requires the rf_source.snapshot capability") + if "rf_source.pulse_configure" not in rf_capabilities: + raise ConfigError( + "rf_source.pulse_output requires the rf_source.pulse_configure capability" + ) + _validate_pulse_output_feature(extensions) if "rf_source.sweep_configure" in rf_capabilities: _validate_sweep_configure_feature(extensions) if "rf_source.output" in rf_capabilities: @@ -321,6 +334,72 @@ def _validate_pulse_configure_feature(extensions: RfSourceDescriptorExtensions) ) +def _validate_pulse_output_feature(extensions: RfSourceDescriptorExtensions) -> None: + pulse_feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE_OUTPUT), + None, + ) + if ( + pulse_feature is None + or RfFeatureDirection.CONFIGURE not in pulse_feature.directions + or RfFeatureDirection.READ not in pulse_feature.directions + or not isinstance(pulse_feature.profile, RfPulseProfile) + or not pulse_feature.profile.configuration_readable + ): + raise ConfigError( + "rf_source.pulse_output requires a readable configurable base pulse profile" + ) + if ( + feature is None + or RfFeatureDirection.READ not in feature.directions + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + or not isinstance(feature.profile, RfPulseOutputProfile) + or not feature.profile.output_readable + ): + raise ConfigError( + "rf_source.pulse_output requires a readable Pulse-output feature with " + "enable and disable directions" + ) + if not set(feature.port_ids) <= set(pulse_feature.port_ids): + raise ConfigError( + "rf_source.pulse_output ports must also declare the base pulse feature" + ) + profile = feature.profile + mode_profile = next( + ( + item + for item in pulse_feature.profile.mode_profiles + if item.source is profile.source and item.mode is profile.mode + ), + None, + ) + if mode_profile is None: + raise ConfigError( + "rf_source.pulse_output profile must reference a declared base pulse mode" + ) + if profile.polarity not in mode_profile.polarities: + raise ConfigError( + "rf_source.pulse_output profile polarity must be declared by the base pulse mode" + ) + if not mode_profile.period_min_s <= profile.period_s <= mode_profile.period_max_s: + raise ConfigError( + "rf_source.pulse_output profile period_s must be within the base pulse range" + ) + if not mode_profile.width_min_s <= profile.width_s <= mode_profile.width_max_s: + raise ConfigError( + "rf_source.pulse_output profile width_s must be within the base pulse range" + ) + if profile.width_s > profile.period_s - mode_profile.minimum_off_time_s: + raise ConfigError( + "rf_source.pulse_output profile violates the base pulse minimum off time" + ) + + def _validate_sweep_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: feature = next( (item for item in extensions.features if item.feature is RfFeature.SWEEP), diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py index 281efb7..7ccb499 100644 --- a/src/wavebench/instruments/rf_source_extensions.py +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -24,6 +24,7 @@ RF_SOURCE_MODULATION_STATE_SCHEMA = "wavebench.rf_source.modulation_state.v1" RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" RF_SOURCE_PULSE_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_snapshot.v1" +RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_output_snapshot.v1" RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA = "wavebench.rf_source.sweep_snapshot.v1" RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA = "wavebench.rf_source.trigger_snapshot.v1" RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" @@ -195,6 +196,12 @@ class RfPulsePolarity(StrEnum): INVERTED = "inverted" +class RfPulseOutputDirection(StrEnum): + """Physical direction declared by the bounded rear-panel Pulse contract.""" + + OUTPUT = "output" + + class RfPulseTriggerMode(StrEnum): """Logical Pulse trigger modes reported by a device configuration query.""" @@ -254,6 +261,7 @@ class RfFeature(StrEnum): MODULATED_OUTPUT = "modulated_output" OUTPUT = "output" PULSE = "pulse" + PULSE_OUTPUT = "pulse_output" SWEEP = "sweep" TRIGGER = "trigger" @@ -602,6 +610,68 @@ def __post_init__(self) -> None: raise ValueError("RF pulse mode ranges cannot satisfy the minimum off time") +@dataclass(frozen=True, slots=True) +class RfPulseOutputProfile: + """One bounded physical Pulse-output interface tied to an RF port. + + This contract intentionally models only a proven output direction. A + connector with an ``IN/OUT`` label does not imply that input behavior is + declared. The profile fixes both the documented electrical characteristics + and the exact internal Pulse profile that must be read back before the + physical output can be enabled. + """ + + interface_id: str + direction: RfPulseOutputDirection + output_readable: bool + low_level_v: float + high_level_v: float + output_impedance_ohm: float + source: RfPulseSource + mode: RfPulseMode + period_s: float + width_s: float + polarity: RfPulsePolarity + pulse_state: RfPulseState = RfPulseState.DISABLED + + def __post_init__(self) -> None: + _require_token(self.interface_id, "RF pulse-output interface_id") + if not isinstance(self.direction, RfPulseOutputDirection): + raise ValueError("RF pulse-output direction has an invalid type") + if self.direction is not RfPulseOutputDirection.OUTPUT: + raise ValueError("RF pulse-output profiles must declare output direction") + _require_bool(self.output_readable, "RF pulse-output output_readable") + _require_finite(self.low_level_v, "RF pulse-output low_level_v", minimum=0.0) + _require_finite( + self.high_level_v, + "RF pulse-output high_level_v", + minimum=self.low_level_v, + ) + if self.high_level_v <= self.low_level_v: + raise ValueError("RF pulse-output high_level_v must exceed low_level_v") + _require_finite( + self.output_impedance_ohm, + "RF pulse-output output_impedance_ohm", + minimum=0.0, + ) + if self.output_impedance_ohm <= 0.0: + raise ValueError("RF pulse-output output_impedance_ohm must be positive") + if self.source is not RfPulseSource.INTERNAL: + raise ValueError("RF pulse-output profiles must use the internal source") + if self.mode is not RfPulseMode.SINGLE: + raise ValueError("RF pulse-output profiles must use the single mode") + _require_finite(self.period_s, "RF pulse-output period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse-output width_s", minimum=0.0) + if self.period_s <= 0.0 or self.width_s <= 0.0: + raise ValueError("RF pulse-output period_s and width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse-output width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse-output polarity has an invalid type") + if self.pulse_state is not RfPulseState.DISABLED: + raise ValueError("RF pulse-output profiles must keep Pulse modulation disabled") + + @dataclass(frozen=True, slots=True) class RfSweepProfile: state_readable: bool @@ -740,6 +810,7 @@ def __post_init__(self) -> None: | RfModulationProfile | RfModulatedOutputProfile | RfPulseProfile + | RfPulseOutputProfile | RfSweepProfile | RfTriggerProfile ) @@ -750,6 +821,7 @@ def __post_init__(self) -> None: RfFeature.MODULATED_OUTPUT: RfModulatedOutputProfile, RfFeature.OUTPUT: RfOutputProfile, RfFeature.PULSE: RfPulseProfile, + RfFeature.PULSE_OUTPUT: RfPulseOutputProfile, RfFeature.SWEEP: RfSweepProfile, RfFeature.TRIGGER: RfTriggerProfile, } @@ -1204,6 +1276,41 @@ def __post_init__(self) -> None: raise ValueError("RF pulse configure result polarity has an invalid type") +@dataclass(frozen=True, slots=True) +class RfPulseOutputRequest: + """Set one declared physical Pulse-output interface on or off. + + Timing, polarity, electrical levels, and physical direction are all fixed + by the descriptor. This request cannot select an input direction, a + trigger source, a receiving instrument, or arbitrary output levels. + """ + + port_id: str + interface_id: str + enabled: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse-output request port_id") + _require_token(self.interface_id, "RF pulse-output request interface_id") + _require_bool(self.enabled, "RF pulse-output request enabled") + + +@dataclass(frozen=True, slots=True) +class RfPulseOutputResult: + """A physical Pulse-output target confirmed by independent readback.""" + + port_id: str + interface_id: str + enabled: bool + write_completed: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse-output result port_id") + _require_token(self.interface_id, "RF pulse-output result interface_id") + _require_bool(self.enabled, "RF pulse-output result enabled") + _require_bool(self.write_completed, "RF pulse-output result write_completed") + + @dataclass(frozen=True, slots=True) class RfPulseSnapshot: """Complete typed readback for one pulse profile on one RF port.""" @@ -1236,6 +1343,64 @@ def __post_init__(self) -> None: raise ValueError("RF pulse snapshot state has an invalid type") +@dataclass(frozen=True, slots=True) +class RfPulseOutputSnapshot: + """Typed readback of one declared physical Pulse-output interface.""" + + port_id: str + interface_id: str + direction: RfPulseOutputDirection + enabled: bool + low_level_v: float + high_level_v: float + output_impedance_ohm: float + source: RfPulseSource + mode: RfPulseMode + period_s: float + width_s: float + polarity: RfPulsePolarity + pulse_state: RfPulseState + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse-output snapshot port_id") + _require_token(self.interface_id, "RF pulse-output snapshot interface_id") + if self.direction is not RfPulseOutputDirection.OUTPUT: + raise ValueError("RF pulse-output snapshots must report output direction") + _require_bool(self.enabled, "RF pulse-output snapshot enabled") + _require_finite(self.low_level_v, "RF pulse-output snapshot low_level_v", minimum=0.0) + _require_finite( + self.high_level_v, + "RF pulse-output snapshot high_level_v", + minimum=self.low_level_v, + ) + if self.high_level_v <= self.low_level_v: + raise ValueError("RF pulse-output snapshot high_level_v must exceed low_level_v") + _require_finite( + self.output_impedance_ohm, + "RF pulse-output snapshot output_impedance_ohm", + minimum=0.0, + ) + if self.output_impedance_ohm <= 0.0: + raise ValueError("RF pulse-output snapshot output_impedance_ohm must be positive") + if not isinstance(self.source, RfPulseSource): + raise ValueError("RF pulse-output snapshot source has an invalid type") + if not isinstance(self.mode, RfPulseMode): + raise ValueError("RF pulse-output snapshot mode has an invalid type") + _require_finite(self.period_s, "RF pulse-output snapshot period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse-output snapshot width_s", minimum=0.0) + if self.period_s <= 0.0 or self.width_s <= 0.0: + raise ValueError("RF pulse-output snapshot period_s and width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse-output snapshot width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse-output snapshot polarity has an invalid type") + if not isinstance(self.pulse_state, RfPulseState): + raise ValueError("RF pulse-output snapshot pulse_state has an invalid type") + + def as_dict(self) -> dict[str, object]: + return rf_pulse_output_snapshot_document(self) + + @dataclass(frozen=True, slots=True) class RfSweepConfigureRequest: """One RF-OFF frequency-only Step Sweep configuration for one RF port. @@ -1424,6 +1589,14 @@ def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: ... def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: ... + def get_rf_pulse_output_snapshot( + self, + port_id: str, + interface_id: str, + ) -> RfPulseOutputSnapshot: ... + + def set_rf_pulse_output(self, request: RfPulseOutputRequest) -> None: ... + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: ... def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: ... @@ -1531,6 +1704,16 @@ def rf_pulse_snapshot_document(snapshot: RfPulseSnapshot) -> dict[str, object]: return {"schema": RF_SOURCE_PULSE_SNAPSHOT_SCHEMA, **data} +def rf_pulse_output_snapshot_document(snapshot: RfPulseOutputSnapshot) -> dict[str, object]: + """Build a redacted document for one physical RF Pulse-output readback.""" + + if not isinstance(snapshot, RfPulseOutputSnapshot): + raise TypeError("snapshot must be RfPulseOutputSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA, **data} + + def rf_sweep_snapshot_document(snapshot: RfSweepSnapshot) -> dict[str, object]: """Build a redacted document for one typed RF Step Sweep readback.""" @@ -1785,6 +1968,55 @@ def rf_source_pulse_operation_artifact( } +def rf_source_pulse_output_operation_artifact( + request: RfPulseOutputRequest, + result: RfPulseOutputResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_pulse_output_snapshot: RfPulseOutputSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_pulse_output_snapshot: RfPulseOutputSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one physical Pulse-output state change.""" + + if not isinstance(request, RfPulseOutputRequest): + raise TypeError("request must be RfPulseOutputRequest") + if not isinstance(result, RfPulseOutputResult): + raise TypeError("result must be RfPulseOutputResult") + if ( + request.port_id != result.port_id + or request.interface_id != result.interface_id + or request.enabled is not result.enabled + ): + raise ValueError("RF pulse-output request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_pulse_output_snapshot, RfPulseOutputSnapshot): + raise TypeError("preflight_pulse_output_snapshot must be RfPulseOutputSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_pulse_output_snapshot, RfPulseOutputSnapshot): + raise TypeError("postcondition_pulse_output_snapshot must be RfPulseOutputSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": ( + "rf_source.pulse_output_enable" + if request.enabled + else "rf_source.pulse_output_disable" + ), + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_pulse_output_snapshot": rf_pulse_output_snapshot_document( + preflight_pulse_output_snapshot + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_pulse_output_snapshot": rf_pulse_output_snapshot_document( + postcondition_pulse_output_snapshot + ), + } + + def rf_source_sweep_operation_artifact( request: RfSweepConfigureRequest, result: RfSweepConfigureResult, @@ -1861,6 +2093,7 @@ def rf_source_output_operation_artifact( "RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA", "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", "RF_SOURCE_PULSE_SNAPSHOT_SCHEMA", + "RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA", "RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA", "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", "RF_SOURCE_SNAPSHOT_SCHEMA", @@ -1904,6 +2137,11 @@ def rf_source_output_operation_artifact( "RfPulseConfigureResult", "RfPulseMode", "RfPulseModeProfile", + "RfPulseOutputDirection", + "RfPulseOutputProfile", + "RfPulseOutputRequest", + "RfPulseOutputResult", + "RfPulseOutputSnapshot", "RfPulsePolarity", "RfPulseSnapshot", "RfPulseSource", @@ -1934,12 +2172,14 @@ def rf_source_output_operation_artifact( "rf_modulation_snapshot_document", "rf_modulation_state_snapshot_document", "rf_pulse_snapshot_document", + "rf_pulse_output_snapshot_document", "rf_sweep_snapshot_document", "rf_trigger_snapshot_document", "rf_source_modulation_disable_operation_artifact", "rf_source_modulated_output_operation_artifact", "rf_source_modulation_operation_artifact", "rf_source_pulse_operation_artifact", + "rf_source_pulse_output_operation_artifact", "rf_source_sweep_operation_artifact", "rf_source_snapshot_document", "rf_source_snapshot_operation_artifact", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 80f2c5e..915d4a8 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1139,6 +1139,39 @@ def _spec( risk_flags=("rf_output_must_be_off", "pulse_state", "state_drift"), safe_alternatives=("rf_source.snapshot",), ), + _spec( + "rf_source.pulse_output_enable", + "rf_source", + required_capabilities=( + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + effect="write", + changed_fields=("rf_source.physical_interface.pulse_output.enabled",), + restore_coverage="none", + risk_flags=( + "rf_output_must_be_off", + "physical_interface_output", + "pulse_output_state", + "state_drift", + ), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.pulse_output_disable", + "rf_source", + required_capabilities=( + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + effect="write", + changed_fields=("rf_source.physical_interface.pulse_output.enabled",), + restore_coverage="none", + risk_flags=("physical_interface_output", "pulse_output_state", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec( "rf_source.sweep_configure", "rf_source", diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py index 6762860..e950cca 100644 --- a/src/wavebench/services/rf_source_service.py +++ b/src/wavebench/services/rf_source_service.py @@ -45,6 +45,10 @@ RfPulseConfigureResult, RfPulseMode, RfPulseModeProfile, + RfPulseOutputProfile, + RfPulseOutputRequest, + RfPulseOutputResult, + RfPulseOutputSnapshot, RfPulseProfile, RfPulseSnapshot, RfPulseSource, @@ -70,6 +74,7 @@ rf_source_modulation_operation_artifact, rf_source_output_operation_artifact, rf_source_pulse_operation_artifact, + rf_source_pulse_output_operation_artifact, rf_source_sweep_operation_artifact, rf_source_trigger_snapshot_operation_artifact, ) @@ -129,6 +134,15 @@ class _RfPulseTransaction: postcondition_pulse_snapshot: RfPulseSnapshot +@dataclass(frozen=True) +class _RfPulseOutputTransaction: + result: RfPulseOutputResult + preflight_snapshot: RfSourceSnapshot + preflight_pulse_output_snapshot: RfPulseOutputSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_pulse_output_snapshot: RfPulseOutputSnapshot + + @dataclass(frozen=True) class _RfSweepTransaction: result: RfSweepConfigureResult @@ -643,6 +657,152 @@ def _configure_pulse_transaction( ) raise + def set_pulse_output(self, request: RfPulseOutputRequest) -> RfPulseOutputResult: + """Set one declared physical Pulse-output interface without changing RF output.""" + + return self._set_pulse_output_transaction(request).result + + def set_pulse_output_with_artifact( + self, + request: RfPulseOutputRequest, + ) -> tuple[RfPulseOutputResult, dict[str, object]]: + """Set one declared physical Pulse-output interface with typed evidence. + + This operation is intentionally independent from ``configure_pulse``. + It never changes RF output, trigger configuration, Sweep, or a + receiving instrument. A failed write or readback is never retried and + leaves the session uncertain for an independently preflighted recovery. + """ + + transaction = self._set_pulse_output_transaction(request) + return ( + transaction.result, + rf_source_pulse_output_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_pulse_output_snapshot=transaction.preflight_pulse_output_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_pulse_output_snapshot=transaction.postcondition_pulse_output_snapshot, + ), + ) + + def _set_pulse_output_transaction( + self, + request: RfPulseOutputRequest, + ) -> _RfPulseOutputTransaction: + """Run one bounded physical Pulse-output state transaction. + + Enabling requires the exact descriptor-declared internal Pulse profile + and the same RF-OFF baseline as the M4 Pulse configurator. Disabling + is deliberately narrower: it requires a readable declared interface + state but does not reject a caller's attempt to remove an auxiliary + output merely because the RF configuration has drifted. + """ + + if not isinstance(request, RfPulseOutputRequest): + raise ConfigError("rf_source pulse-output control requires RfPulseOutputRequest") + operation = ( + "rf_source.pulse_output_enable" + if request.enabled + else "rf_source.pulse_output_disable" + ) + self._require( + operation, + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + profile = self._validate_pulse_output_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_pulse_output_snapshot = rf_source.get_rf_pulse_output_snapshot( + request.port_id, + request.interface_id, + ) + if request.enabled: + current_enabled = self._validate_pulse_output_enable_snapshot( + request, + preflight_snapshot, + preflight_pulse_output_snapshot, + profile, + operation=operation, + ) + else: + current_enabled = self._validate_pulse_output_disable_snapshot( + request, + preflight_pulse_output_snapshot, + profile, + operation=operation, + ) + if current_enabled is request.enabled: + return _RfPulseOutputTransaction( + result=RfPulseOutputResult( + port_id=request.port_id, + interface_id=request.interface_id, + enabled=request.enabled, + write_completed=False, + ), + preflight_snapshot=preflight_snapshot, + preflight_pulse_output_snapshot=preflight_pulse_output_snapshot, + postcondition_snapshot=preflight_snapshot, + postcondition_pulse_output_snapshot=preflight_pulse_output_snapshot, + ) + + main_entered = False + try: + main_entered = True + rf_source.set_rf_pulse_output(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_pulse_output_snapshot = rf_source.get_rf_pulse_output_snapshot( + request.port_id, + request.interface_id, + ) + if request.enabled: + postcondition_enabled = self._validate_pulse_output_enable_snapshot( + request, + postcondition_snapshot, + postcondition_pulse_output_snapshot, + profile, + operation=operation, + ) + else: + postcondition_enabled = self._validate_pulse_output_disable_snapshot( + request, + postcondition_pulse_output_snapshot, + profile, + operation=operation, + ) + if postcondition_enabled is not request.enabled: + raise ConfigError( + f"{operation} postcondition does not match requested Pulse-output state" + ) + return _RfPulseOutputTransaction( + result=RfPulseOutputResult( + port_id=request.port_id, + interface_id=request.interface_id, + enabled=request.enabled, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + preflight_pulse_output_snapshot=preflight_pulse_output_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_pulse_output_snapshot=postcondition_pulse_output_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_pulse_output_postcondition_unverified", + ) + raise + def configure_sweep(self, request: RfSweepConfigureRequest) -> RfSweepConfigureResult: return self._configure_sweep_transaction(request).result @@ -1547,6 +1707,147 @@ def _validate_pulse_postcondition( polarity=request.polarity, ) + def _validate_pulse_output_descriptor( + self, + request: RfPulseOutputRequest, + operation: str, + ) -> RfPulseOutputProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + pulse_feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE_OUTPUT), + None, + ) + if ( + pulse_feature is None + or RfFeatureDirection.CONFIGURE not in pulse_feature.directions + or RfFeatureDirection.READ not in pulse_feature.directions + or request.port_id not in pulse_feature.port_ids + or not isinstance(pulse_feature.profile, RfPulseProfile) + or not pulse_feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable base pulse profile for the target port" + ) + if ( + feature is None + or RfFeatureDirection.READ not in feature.directions + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfPulseOutputProfile) + or not feature.profile.output_readable + ): + raise ConfigError( + f"{operation} requires a readable Pulse-output profile for the target port" + ) + profile = feature.profile + if profile.interface_id != request.interface_id: + raise ConfigError(f"{operation} references an undeclared physical Pulse-output interface") + self._validate_pulse_descriptor( + RfPulseConfigureRequest( + port_id=request.port_id, + period_s=profile.period_s, + width_s=profile.width_s, + polarity=profile.polarity, + ), + operation, + ) + return profile + + def _validate_pulse_output_snapshot_target( + self, + request: RfPulseOutputRequest, + snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> None: + if not isinstance(snapshot, RfPulseOutputSnapshot): + raise ConfigError(f"{operation} driver returned an invalid Pulse-output snapshot") + if snapshot.port_id != request.port_id or snapshot.interface_id != request.interface_id: + raise ConfigError(f"{operation} Pulse-output snapshot does not match the requested interface") + if snapshot.direction is not profile.direction: + raise ConfigError(f"{operation} Pulse-output direction is outside the descriptor profile") + + def _validate_pulse_output_profile_readback( + self, + snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> None: + if ( + snapshot.low_level_v != profile.low_level_v + or snapshot.high_level_v != profile.high_level_v + or snapshot.output_impedance_ohm != profile.output_impedance_ohm + or snapshot.source is not profile.source + or snapshot.mode is not profile.mode + or snapshot.period_s != profile.period_s + or snapshot.width_s != profile.width_s + or snapshot.polarity is not profile.polarity + or snapshot.pulse_state is not profile.pulse_state + ): + raise ConfigError( + f"{operation} Pulse-output readback does not match the descriptor profile" + ) + + def _validate_pulse_output_enable_snapshot( + self, + request: RfPulseOutputRequest, + rf_snapshot: RfSourceSnapshot, + pulse_output_snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> bool: + self._validate_pulse_preflight( + RfPulseConfigureRequest( + port_id=request.port_id, + period_s=profile.period_s, + width_s=profile.width_s, + polarity=profile.polarity, + ), + rf_snapshot, + operation=operation, + ) + self._validate_pulse_output_snapshot_target( + request, + pulse_output_snapshot, + profile, + operation=operation, + ) + self._validate_pulse_output_profile_readback( + pulse_output_snapshot, + profile, + operation=operation, + ) + return pulse_output_snapshot.enabled + + def _validate_pulse_output_disable_snapshot( + self, + request: RfPulseOutputRequest, + pulse_output_snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> bool: + self._validate_pulse_output_snapshot_target( + request, + pulse_output_snapshot, + profile, + operation=operation, + ) + return pulse_output_snapshot.enabled + def _validate_sweep_descriptor( self, request: RfSweepConfigureRequest, diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index d528efc..4fdbd42 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -29,6 +29,8 @@ "rf_source.modulation_disable", "rf_source.modulated_output_enable", "rf_source.pulse_configure", + "rf_source.pulse_output_enable", + "rf_source.pulse_output_disable", "rf_source.sweep_configure", "rf_source.output_enable", "rf_source.output_disable", @@ -90,6 +92,8 @@ "internal_frequency_hz", ), "rf_source.pulse_configure": ("port_id", "period_s", "width_s", "polarity"), + "rf_source.pulse_output_enable": ("port_id", "interface_id"), + "rf_source.pulse_output_disable": ("port_id", "interface_id"), "rf_source.sweep_configure": ( "port_id", "start_frequency_hz", @@ -749,6 +753,12 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["period_s"] = period_s fields["width_s"] = width_s fields["polarity"] = polarity + elif kind in {"rf_source.pulse_output_enable", "rf_source.pulse_output_disable"}: + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + fields["interface_id"] = _rf_interface_id( + fields["interface_id"], + f"{prefix}.interface_id", + ) elif kind == "rf_source.sweep_configure": fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") start_frequency_hz = _positive_float( @@ -1289,6 +1299,13 @@ def _rf_port_id(value: Any, name: str) -> str: return token +def _rf_interface_id(value: Any, name: str) -> str: + token = _non_empty_str(value, name) + if _SOURCE_STORAGE_TOKEN.fullmatch(token) is None: + raise ConfigError(f"{name} must be a short safe RF physical interface ID") + return token + + def _table(raw: Any, name: str) -> dict[str, Any]: if raw is None: return {} diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 4673ac0..ad9e96a 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -29,6 +29,8 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "rf_source.modulation_disable", "rf_source.modulated_output_enable", "rf_source.pulse_configure", + "rf_source.pulse_output_enable", + "rf_source.pulse_output_disable", "rf_source.sweep_configure", "rf_source.output_enable", "rf_source.output_disable", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 9355710..1d98956 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -30,6 +30,7 @@ RfModulationRequest, RfOutputRequest, RfPulseConfigureRequest, + RfPulseOutputRequest, RfPulsePolarity, RfSweepConfigureRequest, rf_source_snapshot_operation_artifact, @@ -312,6 +313,8 @@ def _check_rf_source_access(self, plan: RunPlan) -> None: "rf_source.modulation_disable": "rf_source.modulation_disable", "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", "rf_source.pulse_configure": "rf_source.pulse_configure", + "rf_source.pulse_output_enable": "rf_source.pulse_output_enable", + "rf_source.pulse_output_disable": "rf_source.pulse_output_disable", "rf_source.sweep_configure": "rf_source.sweep_configure", "rf_source.output_enable": "rf_source.output_enable", "rf_source.output_disable": "rf_source.output_disable", @@ -476,6 +479,13 @@ def add_source_output_gate_capability() -> None: ) elif step.kind == "rf_source.pulse_configure": add("rf_source", "rf_source.snapshot", "rf_source.pulse_configure") + elif step.kind in {"rf_source.pulse_output_enable", "rf_source.pulse_output_disable"}: + add( + "rf_source", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) elif step.kind == "rf_source.sweep_configure": add("rf_source", "rf_source.snapshot", "rf_source.sweep_configure") elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: @@ -1320,6 +1330,18 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} + elif step.kind in {"rf_source.pulse_output_enable", "rf_source.pulse_output_disable"}: + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).set_pulse_output_with_artifact( + RfPulseOutputRequest( + port_id=fields["port_id"], + interface_id=fields["interface_id"], + enabled=step.kind == "rf_source.pulse_output_enable", + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind == "rf_source.sweep_configure": fields = step.fields _, rf_source_operation = self._rf_source_service( diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py index 6de9b31..226a1ae 100644 --- a/tests/test_rf_source_extensions.py +++ b/tests/test_rf_source_extensions.py @@ -481,6 +481,10 @@ def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: "get_rf_pulse_snapshot", "configure_rf_pulse", ), + "rf_source.pulse_output": ( + "get_rf_pulse_output_snapshot", + "set_rf_pulse_output", + ), "rf_source.sweep_configure": ( "get_rf_sweep_snapshot", "configure_rf_sweep", diff --git a/tests/test_rf_source_pulse_output_cli.py b/tests/test_rf_source_pulse_output_cli.py new file mode 100644 index 0000000..a6a0715 --- /dev/null +++ b/tests/test_rf_source_pulse_output_cli.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import io +import json +from contextlib import redirect_stdout +from unittest.mock import Mock, patch + +from wavebench.cli import build_parser, main +from wavebench.instruments.rf_source_extensions import ( + RfPulseOutputRequest, + RfPulseOutputResult, +) + + +def test_rf_source_parser_accepts_a_bounded_physical_pulse_output_command() -> None: + arguments = build_parser().parse_args( + [ + "rf-source", + "pulse-output", + "--port", + "rf_out", + "--interface", + "pulse_in_out", + "on", + ] + ) + + assert (arguments.domain, arguments.command) == ("rf-source", "pulse-output") + assert arguments.port == "rf_out" + assert arguments.interface_id == "pulse_in_out" + assert arguments.state == "on" + + +def test_rf_source_cli_dispatches_a_typed_physical_pulse_output_request() -> None: + service = Mock() + service.set_pulse_output.return_value = RfPulseOutputResult( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + write_completed=True, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert ( + main( + [ + "--json", + "rf-source", + "pulse-output", + "--port", + "rf_out", + "--interface", + "pulse_in_out", + "on", + ] + ) + == 0 + ) + + payload = json.loads(stdout.getvalue()) + assert payload["result"] == { + "port_id": "rf_out", + "interface_id": "pulse_in_out", + "enabled": True, + "write_completed": True, + } + service.set_pulse_output.assert_called_once_with( + RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + ) + ) diff --git a/tests/test_rf_source_pulse_output_extensions.py b/tests/test_rf_source_pulse_output_extensions.py new file mode 100644 index 0000000..8aad48e --- /dev/null +++ b/tests/test_rf_source_pulse_output_extensions.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseMode, + RfPulseModeProfile, + RfPulseOutputDirection, + RfPulseOutputProfile, + RfPulseOutputRequest, + RfPulseOutputResult, + RfPulseOutputSnapshot, + RfPulsePolarity, + RfPulseProfile, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_pulse_output_snapshot_document, + rf_source_pulse_output_operation_artifact, +) +from wavebench.services.operation_specs import require_operation_spec + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ), + ), + ) + + +def _pulse_output_profile() -> RfPulseOutputProfile: + return RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _pulse_output_snapshot(*, enabled: bool = False) -> RfPulseOutputSnapshot: + return RfPulseOutputSnapshot( + port_id="rf_out", + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + enabled=enabled, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.DISABLED, + ) + + +def _descriptor(*, directions: tuple[RfFeatureDirection, ...] | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse-output", + kind="rf_source", + models=("RF-PULSE-OUTPUT",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + RfFeatureCapability( + feature=RfFeature.PULSE_OUTPUT, + directions=directions + or ( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=_pulse_output_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-PULSE-OUTPUT,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot() + + def get_rf_pulse_snapshot(self, port_id: str): + assert port_id == "rf_out" + return None + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: + assert request.port_id == "rf_out" + + def get_rf_pulse_output_snapshot(self, port_id: str, interface_id: str) -> RfPulseOutputSnapshot: + assert (port_id, interface_id) == ("rf_out", "pulse_in_out") + return _pulse_output_snapshot() + + def set_rf_pulse_output(self, request: RfPulseOutputRequest) -> None: + assert request.port_id == "rf_out" + + +def test_pulse_output_contract_requires_a_bounded_output_only_profile() -> None: + with pytest.raises(ValueError, match="high_level_v must exceed"): + RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=3.3, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + with pytest.raises(ValueError, match="Pulse modulation disabled"): + RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.ENABLED, + ) + + +def test_pulse_output_descriptor_requires_read_enable_disable_and_driver_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.pulse_output"] == ( + "get_rf_pulse_output_snapshot", + "set_rf_pulse_output", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor(directions=(RfFeatureDirection.ENABLE, RfFeatureDirection.READ)) + with pytest.raises(Exception, match="enable and disable"): + validate_rf_source_descriptor(invalid) + + +def test_pulse_output_snapshot_document_and_artifact_keep_typed_evidence() -> None: + request = RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + ) + result = RfPulseOutputResult( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + write_completed=True, + ) + snapshot = _pulse_output_snapshot(enabled=True) + + document = rf_pulse_output_snapshot_document(snapshot) + artifact = rf_source_pulse_output_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(), + preflight_pulse_output_snapshot=_pulse_output_snapshot(), + postcondition_snapshot=_snapshot(), + postcondition_pulse_output_snapshot=snapshot, + ) + + assert document["schema"] == RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA + assert document["direction"] == "output" + assert document["high_level_v"] == 3.3 + assert artifact["operation"] == "rf_source.pulse_output_enable" + assert artifact["postcondition_pulse_output_snapshot"]["enabled"] is True + assert "resource" not in str(artifact) + + +def test_pulse_output_operation_specs_keep_enable_and_disable_separate() -> None: + enable = require_operation_spec("rf_source.pulse_output_enable") + disable = require_operation_spec("rf_source.pulse_output_disable") + + assert enable.required_capabilities == ( + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + assert enable.changed_fields == ("rf_source.physical_interface.pulse_output.enabled",) + assert enable.effect == "write" + assert "rf_output_must_be_off" in enable.risk_flags + assert "rf_output_must_be_off" not in disable.risk_flags + assert disable.changed_fields == enable.changed_fields diff --git a/tests/test_rf_source_pulse_output_run.py b/tests/test_rf_source_pulse_output_run.py new file mode 100644 index 0000000..4331e43 --- /dev/null +++ b/tests/test_rf_source_pulse_output_run.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfModulationState, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseMode, + RfPulseOutputDirection, + RfPulseOutputRequest, + RfPulseOutputResult, + RfPulseOutputSnapshot, + RfPulsePolarity, + RfPulseSource, + RfPulseState, + RfSourceSnapshot, + RfSweepState, + rf_source_pulse_output_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.execution_intent import build_execution_intent +from wavebench.services.run_plan import load_run_plan +from wavebench.services.run_service import RunInstrumentServices, RunService + + +def _config(directory: str, *, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig( + Path(directory) / "data" / "raw", + "timestamp_label", + True, + True, + True, + True, + False, + ), + source_path=Path(directory) / "wavebench.toml", + rf_source=RfSourceConfig( + driver="example.rf.pulse-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _plan(directory: str, *, kind: str = "rf_source.pulse_output_enable"): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + f'kind = "{kind}"\n' + 'port_id = "rf_out"\n' + 'interface_id = "pulse_in_out"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _rf_snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _pulse_output_snapshot(*, enabled: bool) -> RfPulseOutputSnapshot: + return RfPulseOutputSnapshot( + port_id="rf_out", + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + enabled=enabled, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.DISABLED, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse-output", + kind="rf_source", + capabilities=capabilities, + ) + + +def test_rf_source_pulse_output_plan_is_typed_and_rejects_invalid_interface_ids() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "interface_id": "pulse_in_out", + } + intent = build_execution_intent(plan, _config(directory)) + assert intent.operations[0]["operation"] == "rf_source.pulse_output_enable" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == plan.steps[0].fields + + invalid = Path(directory) / "invalid.toml" + invalid.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_output_enable"\n' + 'port_id = "rf_out"\n' + 'interface_id = "PULSE IN/OUT"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="interface_id"): + load_run_plan(invalid) + + +def test_rf_source_pulse_output_run_preflights_capabilities_before_opening_sessions() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.pulse_output"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_pulse_output_run_dispatches_a_typed_artifact() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + request = RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + ) + result = RfPulseOutputResult( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + write_completed=True, + ) + artifact = rf_source_pulse_output_operation_artifact( + request, + result, + preflight_snapshot=_rf_snapshot(), + preflight_pulse_output_snapshot=_pulse_output_snapshot(enabled=False), + postcondition_snapshot=_rf_snapshot(), + postcondition_pulse_output_snapshot=_pulse_output_snapshot(enabled=True), + ) + rf_service = SimpleNamespace( + set_pulse_output_with_artifact=Mock(return_value=(result, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService( + config=_config(directory), + logger=CommandLogger(), + ).run(plan) + + rf_service.set_pulse_output_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] diff --git a/tests/test_rf_source_pulse_output_service.py b/tests/test_rf_source_pulse_output_service.py new file mode 100644 index 0000000..d2c65b5 --- /dev/null +++ b/tests/test_rf_source_pulse_output_service.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseMode, + RfPulseModeProfile, + RfPulseOutputDirection, + RfPulseOutputProfile, + RfPulseOutputRequest, + RfPulseOutputSnapshot, + RfPulsePolarity, + RfPulseProfile, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + rf_source=RfSourceConfig( + driver="example.rf.pulse-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ), + ), + ) + + +def _pulse_output_profile() -> RfPulseOutputProfile: + return RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse-output", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + RfFeatureCapability( + feature=RfFeature.PULSE_OUTPUT, + directions=( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=_pulse_output_profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _pulse_output_snapshot( + *, + enabled: bool = False, + period_s: float = 1e-3, +) -> RfPulseOutputSnapshot: + return RfPulseOutputSnapshot( + port_id="rf_out", + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + enabled=enabled, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=period_s, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.DISABLED, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + pulse_output_snapshots: list[RfPulseOutputSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.pulse_output_snapshots = list(pulse_output_snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfPulseOutputRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_pulse_output_snapshot( + self, + port_id: str, + interface_id: str, + ) -> RfPulseOutputSnapshot: + self.calls.append("pulse_output_snapshot") + assert (port_id, interface_id) == ("rf_out", "pulse_in_out") + if not self.pulse_output_snapshots: + raise AssertionError("unexpected Pulse-output snapshot") + return self.pulse_output_snapshots.pop(0) + + def set_rf_pulse_output(self, request: RfPulseOutputRequest) -> None: + self.calls.append("set_pulse_output") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake Pulse-output write failed after transmission") + + +def _request(*, enabled: bool = True) -> RfPulseOutputRequest: + return RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=enabled, + ) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + pulse_output_snapshots: list[RfPulseOutputSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + pulse_output_snapshots, + raise_after_write=raise_after_write, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_pulse_output_enable_uses_one_write_and_independent_readbacks() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_pulse_output_snapshot(), _pulse_output_snapshot(enabled=True)], + ) + + result, artifact = service.set_pulse_output_with_artifact(request) + + assert result.enabled is True + assert result.write_completed is True + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "pulse_output_snapshot", + "set_pulse_output", + "snapshot", + "pulse_output_snapshot", + ] + assert artifact["operation"] == "rf_source.pulse_output_enable" + assert artifact["postcondition_pulse_output_snapshot"]["enabled"] is True + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), "target RF output OFF"), + (_rf_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_rf_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_pulse_output_enable_rejects_unsafe_rf_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _service([snapshot], [_pulse_output_snapshot()]) + + with pytest.raises(ConfigError, match=message): + service.set_pulse_output(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot", "pulse_output_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_pulse_output_enable_rejects_profile_mismatch_without_write() -> None: + service, driver = _service([_rf_snapshot()], [_pulse_output_snapshot(period_s=2e-3)]) + + with pytest.raises(ConfigError, match="does not match the descriptor profile"): + service.set_pulse_output(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot", "pulse_output_snapshot"] + + +def test_pulse_output_disable_remains_available_for_a_drifted_profile() -> None: + request = _request(enabled=False) + service, driver = _service( + [_rf_snapshot(output_enabled=True), _rf_snapshot(output_enabled=True)], + [_pulse_output_snapshot(enabled=True, period_s=2e-3), _pulse_output_snapshot(enabled=False, period_s=2e-3)], + ) + + result = service.set_pulse_output(request) + + assert result.enabled is False + assert result.write_completed is True + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "pulse_output_snapshot", + "set_pulse_output", + "snapshot", + "pulse_output_snapshot", + ] + + +def test_pulse_output_idempotence_preserves_the_readback_evidence_without_a_write() -> None: + request = _request() + service, driver = _service([_rf_snapshot()], [_pulse_output_snapshot(enabled=True)]) + + result, artifact = service.set_pulse_output_with_artifact(request) + + assert result.write_completed is False + assert driver.requests == [] + assert driver.calls == ["snapshot", "pulse_output_snapshot"] + assert artifact["preflight_pulse_output_snapshot"] == artifact[ + "postcondition_pulse_output_snapshot" + ] + + +def test_pulse_output_checks_capability_and_access_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot", "rf_source.pulse_configure"), + ) + with pytest.raises(ConfigError, match="rf_source.pulse_output"): + missing.set_pulse_output(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.pulse_output_enable"): + read_only.set_pulse_output(_request()) + assert read_only_driver.calls == [] + + +def test_pulse_output_write_failure_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot()], + [_pulse_output_snapshot()], + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission"): + service.set_pulse_output(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "pulse_output_snapshot", "set_pulse_output"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_pulse_output_postcondition_mismatch_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_pulse_output_snapshot(), _pulse_output_snapshot(enabled=False)], + ) + + with pytest.raises(ConfigError, match="does not match requested"): + service.set_pulse_output(_request()) + + assert driver.requests == [_request()] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN From 3bbb696ba7073807fc7a25f489a5bf12f7d0e193 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:46:10 +0800 Subject: [PATCH 60/63] docs: document bounded RF pulse output --- README.md | 4 +- docs/README.md | 2 +- docs/README_EN.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 34 ++++++----- ...67\346\272\220\350\256\276\350\256\241.md" | 58 ++++++++++++++----- ...71\347\233\256\350\276\271\347\225\214.md" | 4 +- ...77\347\224\250\346\214\207\345\215\227.md" | 25 +++++++- 7 files changed, 91 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index e79e5e8..06e26d4 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,9 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。当前 DSG830 已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置及按模式关闭、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`。A4-MO 已将受限 `rf_source.modulated_output_enable` 提升到 production:仅接受已激活且精确匹配的 AM `50 %`/`1 kHz` profile,最大功率 `-50 dBm`;普通 `rf_source.output` 仍要求调制关闭。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。A5-0 已具备逻辑 Pulse/Sweep trigger configuration 的只读代码合同,但 DSG830 production descriptor 未声明该 capability;它不代表后面板 trigger/sync 接口已定义或可操作。当前 production 范围仍不提供 execute、arm、fire、trigger、Level Sweep 或 list。 +`rf_source` 是独立于普通 `source` 的仪器领域。DSG830 当前已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置及按模式关闭、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`。A4-MO 已将受限 `rf_source.modulated_output_enable` 提升到 production:仅接受已激活且精确匹配的 AM `50 %`/`1 kHz` profile,最大功率 `-50 dBm`;普通 `rf_source.output` 仍要求调制关闭。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。 + +已完成的 A5 只覆盖一条后面板物理路径:DSG830 的「PULSE IN/OUT」按 output 方向、固定 internal/single/normal/`1 ms`/`100 μs` profile,提供 `rf_source.pulse_output` 与 `wavebench rf-source pulse-output`。它不启用 RF 输出,也不定义 Pulse input、`TRIGGER IN`、Sweep fire、sync/reference、Level Sweep 或 list。A5-0 仍仅是逻辑 Pulse/Sweep trigger configuration 的零写读取合同,DSG830 production descriptor 不声明 `rf_source.trigger_snapshot`。 - 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) - 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) diff --git a/docs/README.md b/docs/README.md index 6e9c46f..4437383 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ wavebench run check --plan /tmp/wavebench-demo.toml ### 使用 RF 信号源 -`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;DSG830 的 RF-OFF 调制配置已开放,但调制开启时的 RF 输出仍未开放。需要实现新型号或查看证据门时,再阅读 [领域设计](project/design/WaveBench_RF信号源设计.md) 与 [开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 +`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;DSG830 已开放固定 profile 的调制输出,以及唯一受验证的后面板 `pulse_in_out` output 路径。后者不代表 Pulse input、`TRIGGER IN` 或同步能力。需要实现新型号或查看证据门时,再阅读 [领域设计](project/design/WaveBench_RF信号源设计.md) 与 [开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 ### 执行实验 diff --git a/docs/README_EN.md b/docs/README_EN.md index f68e180..d607ec0 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -59,7 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) -- RF-source domain and milestones: [guide](project/guides/WaveBench_RF信号源使用指南.md), [design](project/design/WaveBench_RF信号源设计.md), and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M4 contracts; DSG830 A1/A2/A3/A4 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, RF-OFF internal-sine `rf_source.modulation_configure`, safety-gated `rf_source.output` ON/OFF, internal/single Pulse configuration, and frequency-only Step Sweep configuration that remains disabled. PM is limited to the verified `1.25 rad` production profile. Modulated RF output, `rf_source.modulation_disable`, triggers, Sweep execution, Level Sweep, and list control remain gated. +- RF-source domain and milestones: [guide](project/guides/WaveBench_RF信号源使用指南.md), [design](project/design/WaveBench_RF信号源设计.md), and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M4 and one bounded A5 Pulse Output contract; DSG830 A1/A2/A3/A4/A4-MO/A5 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, RF-OFF internal-sine `rf_source.modulation_configure`, `rf_source.modulation_disable`, safety-gated `rf_source.output` ON/OFF, fixed-profile modulated output, internal/single Pulse configuration, `rf_source.pulse_output`, and frequency-only Step Sweep configuration that remains disabled. A5 covers only the declared `pulse_in_out` output route, not Pulse input, `TRIGGER IN`, trigger, sync, or Sweep execution. PM is limited to the verified `1.25 rad` production profile. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index 5e1f5a1..cf1c8f6 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -8,9 +8,9 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | -| Core `0.8.25` 开发线 | M0–M4、M3-MO 与 A5-0 的离线合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、按模式调制关闭、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep,以及逻辑 trigger configuration 的只读类型、Service、CLI、run 和 artifact。 | -| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep、M3-MO 的 A4/A4-MO 均已通过并提升;A5-0 映射已完成,物理 A5 未开始 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 与六条固定 trigger configuration query 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | -| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 和 A4-MO 已完成;物理 A5 未开始 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、调制关闭、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 只提升固定 AM 调制输出 profile。A5-0 不产生 production capability。 | +| Core `0.8.25` 开发线 | M0–M4、M3-MO、A5-0 与 A5 Pulse Output 合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、按模式调制关闭、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep、逻辑 trigger configuration 的只读类型,以及物理 Pulse Output 的 Service、CLI、run 和 artifact。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep、M3-MO 的 A4/A4-MO 均已通过并提升;A5-0 映射已完成,A5 Pulse Output 已通过并提升 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep、六条固定 trigger configuration query,以及 `:PULM:OUT:STAT` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、调制关闭、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 只提升固定 AM 调制输出 profile。A5-0 不产生 production capability,A5 Pulse Output 只提升被验证的 output 路径。 | ## 双仓库交付规则 @@ -35,6 +35,7 @@ | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | | A5-0 | 离线完成;不属于物理 A5 证据 | `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、只读 Service/CLI/run/artifact | `:PULM:TRIG:MODE?`、external edge/gate query、Sweep mode/period/point trigger query 与严格 enum parser | 只使用 `TRIGGER / READ` profile 和非 production descriptor;固定 query 顺序、零 write、未知值失败关闭。它不定义物理 connector,不发送 trigger,也不提升 production capability。 | +| A5(Pulse Output) | 已通过并提升 | `RfPulseOutputProfile`、固定物理接口的 Service/CLI/run/artifact | 仅 `pulse_in_out` output 的 `:PULM:OUT:STAT?`/`:PULM:OUT:STAT ON|OFF` | 固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs` profile;最终 RF 输出和 Pulse Output 必须为 OFF。只提升 `rf_source.pulse_output`。 | ## Seed:历史种子包 @@ -121,7 +122,7 @@ frequency-only Step Sweep 的生产子集只接受起止频率、点数和驻留 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与资源无关 setup 模板已完成离线回归和实机验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确 production descriptor 和人工确认的 50 Ω 端接。`--diagnose` 保持 `read_only`,读取初始/最终 RF snapshot 与完整 Step Sweep profile,成功路径固定为 25 次 query、零 write;显式 `--execute` 才在内存中建立受限 `read_write` descriptor,成功路径固定为初始 snapshot、一次配置、独立 profile readback、最终 snapshot,共 41 次 query、9 条配置 write。两条路径都不读取 scope、不调用 RF output、不执行 arm/fire/trigger,且证据以 `0600` 保存。诊断与受控配置序列均通过,最终独立复核 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;historical harness 在 capability 提升后拒绝重跑。 -Pulse trigger、Sweep arm/fire/stop、外部 trigger、后面板辅助输出、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在 A4 或 A5 对应证据具备后才能声明相关 capability。 +除已验证的「PULSE IN/OUT」output 外,Pulse trigger、Sweep arm/fire/stop、外部 trigger、其它后面板辅助接口、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在对应 A5 证据具备后才能声明相关 capability。 ## A1–A5:实机证据门 @@ -142,19 +143,21 @@ A5-0 是物理 A5 之前的只读基础,不验证外部 trigger/同步接线 DSG830 driver 已用六条固定 query 读取该逻辑 configuration,并对别名和未知响应执行严格解析。production descriptor 仍不声明 `rf_source.trigger_snapshot` 或 `TRIGGER` feature;因此普通 DSG830 配置会在 session 建立前拒绝该入口。`port_id` 只表示这些设置影响的 RF 输出,不表示物理 trigger/sync connector,也不从 CH2 的 50 Ω 或 `rf_out` 的 dBm 参考推导电气条件。 -### A5:外部 trigger/同步的进入条件(物理验收未开始) +### A5:物理接口按路径提升 -A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out` 证据开始。一次验收只能覆盖一个明确目标,例如 Pulse 的 external trigger、Sweep period trigger 或 Sweep point trigger;不能把其中一项外推为其它 trigger、fire、同步或后面板辅助输出能力。 +A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out` 证据开始。一次验收只能覆盖一个明确目标;通过一条后面板路径不能外推为其它 trigger、fire、同步或辅助输出能力。 -| 项目 | 进入前必须明确的事实 | -| --- | --- | -| 目标行为 | 本次只验证的 trigger/sync 模式、目标仪器状态和成功判据。 | -| 物理接线 | 每根线的源端设备/接口、目标设备/接口、线缆与转接件;必须明确是 trigger input、trigger output、sync/reference input、sync/reference output 还是 `rf_out`。 | -| 电气兼容 | 信号类型、方向、逻辑或模拟属性、幅度/阈值、极性、脉宽、频率/时序、源/负载阻抗和终端方式,均以已核对的设备资料和实际接线为准。 | -| 初始与恢复状态 | 初始 RF 输出、调制、Pulse、Sweep、protection 与后面板配置;失败后的恢复方式和最终 RF OFF 独立确认方式。 | -| 观察方式 | 如使用示波器,只能作为补充观察;必须核对其输入与接线,且不能替代仪器端读回。CH2 的 50 Ω 声明仅适用于已确认的 RF 路径。 | +| 项目 | 已完成的 Pulse Output 路径 | 其它 A5 路径的进入条件 | +| --- | --- | --- | +| 目标行为 | DSG830「PULSE IN/OUT」output 的 ON/OFF,触发 RTM2032「EXT TRIGGER INPUT」上的一次 single acquisition。 | 只验证一个明确 trigger/sync 模式及其成功判据。 | +| 物理接线 | DSG830 `PULSE IN/OUT` output → RTM2032 `EXT TRIGGER INPUT`。 | 明确每根线的源端与目标端、线缆和转接件,区分 input/output/sync/reference/`rf_out`。 | +| 电气兼容 | DSG830 固定 `0 V`/`3.3 V`、约 `600 Ω`;RTM 输入为 `1 MΩ`/`12 pF`/`≤ 150 Vp`。 | 核对方向、逻辑或模拟属性、幅度/阈值、极性、脉宽、频率/时序、阻抗和终端。 | +| 初始与恢复状态 | RF 输出、调制、Pulse、Sweep 均 OFF,protection 为空;最终 RF 输出和 Pulse Output 独立确认 OFF。 | 预先定义初始状态、失败恢复和最终 RF OFF 独立确认方式。 | +| 观察方式 | scope 仅在隔离 harness 中临时采用 external/normal/single/auto 触发序列;不形成 RTM API 或 capability。 | 如使用示波器,只作补充观察,不能代替仪器端 readback。 | + +已完成的 A5 harness 固定 DSG830 为 internal/single/normal、period `1 ms`、width `100 μs`,在 RF 始终关闭时执行一次 Pulse Output ON、scope single、Pulse Output OFF。审计结果为 RF 主 session `97` 次 query/`8` 次完成 write、独立最终 RF 复核 `15` 次 query/零 write、scope `5` 次 query/`3` 次完成 write;最终两项输出均确认关闭。source 的既有 Pulse profile 与 scope acquisition state 不恢复,scope 可能停在 `Single`。历史 harness 在 production 提升后拒绝重跑,避免临时 descriptor 绕过 capability。 -在上述事实未明确前,不写入后面板配置,不发送 `*TRG`、`:TRIG:*`、`:SWE:EXEC` 或 `:PULM:OUT`,不切换 RF 输出,也不把外部接口视为安全。A5 的推荐开发顺序为:先完成 A5-0 的逻辑 configuration readback、Core profile/artifact、DSG830 严格 parser、fake transport 零写回归,以及保持原始 `read_only` 配置的私有零写 harness;隔离诊断已完成 22 次 query、零 write、最终 RF OFF 和健康关闭复核;最后在已确认接线和电气边界下,对一个明确的物理路径设计独立受控证据。A5-0 的静态预检精确绑定当前 production descriptor 的 capability 列表;后续 capability 变更必须先经代码审查、fake 回归和新的零写诊断更新该基线,否则工具在建立 session 前拒绝。这一维护不构成物理 A5 证据。production descriptor 仍须等待物理证据逐项提升。 +A5-0 的逻辑 configuration readback 仍保持独立:它固定读取六项逻辑 trigger 状态,私有零写诊断成功预算为 22 次 query、零 write;production descriptor 不声明 `rf_source.trigger_snapshot`。后续的 Pulse input、`TRIGGER IN`、Pulse trigger、Sweep fire、sync/reference、Level Sweep 与 list 必须分别定义接线、电气 profile 和恢复语义,并取得新的 A5 证据。没有这些事实时,不写入后面板配置,不发送 `*TRG`、`:TRIG:*` 或 `:SWE:EXEC`,也不把外部接口视为安全。 ### A1:已完成的只读 snapshot 验收 @@ -211,4 +214,5 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的 Core 合同、DSG830 映射、CLI、run、artifact 与 A4 证据均已完成,`rf_source.modulation_configure` 已提升;M4 继续保持独立工作。 6. A4 的 AM/FM/PM RF-OFF 单模式配置、严格读回与关闭恢复证据均已通过。PM production profile 固定为 `1.25 rad`,以避免将更宽的离线映射当作实机覆盖范围。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成新的 capability 提升证据;任何允许调制开启时 RF 输出的安全合同仍须单独设计和验证,CH2 可见信号也不能替代该证据。 7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 -8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、公开按模式关闭入口、DSG830 固定 profile descriptor、CH2-only harness 与 fake 回归。已使用 WaveBench 有界网络发现确认候选设备,并完成只读诊断与固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的一次受控 A4-MO;CH2 信号存在、最终 RF OFF、调制关闭和健康关闭均已通过,production descriptor 已提升。CH1 的低频输出不属于该证据路径。后续进入 A5 前仍须确认唯一的物理接口、接线和电气边界。 +8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、公开按模式关闭入口、DSG830 固定 profile descriptor、CH2-only harness 与 fake 回归。已使用 WaveBench 有界网络发现确认候选设备,并完成只读诊断与固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的一次受控 A4-MO;CH2 信号存在、最终 RF OFF、调制关闭和健康关闭均已通过,production descriptor 已提升。CH1 的低频输出不属于该证据路径。 +9. 已完成受限 A5 Pulse Output 的 Core 合同、DSG830 固定 `:PULM:OUT:STAT` 映射、CLI/run/artifact、fake 回归和隔离实机验收。唯一提升路径为「PULSE IN/OUT」output →「EXT TRIGGER INPUT」,固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs`;最终 RF 输出和 Pulse Output 均为 OFF。production descriptor 已提升 `rf_source.pulse_output`。后续 A5 工作必须从另一条物理接口、接线和电气边界重新开始,不能复用本条证据。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index c250d1c..a867e36 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制及按模式关闭、M3-MO 受限调制输出,以及 M4 Pulse 和 frequency-only Step Sweep 配置的合同与控制入口;DSG830 已凭 A1/A2/A3/A4/A4-MO 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制及按模式关闭、固定 profile 调制输出、RF-OFF Pulse 配置和保持 Sweep disabled 的 Step Sweep 配置。M3 的 PM production profile 仅为 `1.25 rad`;M3-MO 的 production profile 仅为 AM `50 %`/`1 kHz`、最大 `-50 dBm`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制及按模式关闭、M3-MO 受限调制输出、M4 Pulse 和 frequency-only Step Sweep 配置,以及受限的 A5 Pulse Output 合同与控制入口;DSG830 已凭 A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制及按模式关闭、固定 profile 调制输出、RF-OFF Pulse 配置、保持 Sweep disabled 的 Step Sweep 配置,以及一条后面板「PULSE IN/OUT」输出路径。M3 的 PM production profile 仅为 `1.25 rad`;M3-MO 的 production profile 仅为 AM `50 %`/`1 kHz`、最大 `-50 dBm`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 阅读顺序如下: @@ -15,9 +15,9 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | -| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM 及按模式关闭、M3-MO profile-bound 调制输出,以及 M4 Pulse/frequency-only Step Sweep 配置的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse 与 frequency-only Step Sweep 配置映射;A1/A2/A3/A4/A4-MO 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 限于 `1.25 rad`。 | -| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep 与 A4-MO 已完成;A5 未开始。 | Step Sweep 只提升固定 profile 的配置 capability,不提升 execute、trigger、Level Sweep 或 list;A4-MO 只提升已声明的固定 AM 调制输出 profile,不放宽普通 RF ON。 | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM 及按模式关闭、M3-MO profile-bound 调制输出、M4 Pulse/frequency-only Step Sweep 配置,以及 A5 Pulse Output 的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 和 `:PULM:OUT:STAT` 输出映射;A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、受限 `rf_source.pulse_output` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 限于 `1.25 rad`。 | +| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成。 | A5 只提升已验证的「PULSE IN/OUT」output 方向;不提升 Pulse input、`TRIGGER IN`、trigger、fire、sync/reference、Level Sweep 或 list。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -38,7 +38,7 @@ WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输 RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 -本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep/A4-MO 的提升边界,以及 M1、M3、M3-MO 与 M4 的离线开发和 fake transport 验证边界。DSG830 的 M3 与 M3-MO 已完成 Core 与实机验收并进入 production;A5 仍另行处理,离线代码不能替代这些证据。 +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep/A4-MO 和 A5 Pulse Output 的提升边界,以及 M1、M3、M3-MO 与 M4 的离线开发和 fake transport 验证边界。DSG830 的 M3、M3-MO 和受限 A5 Pulse Output 已完成 Core 与实机验收并进入 production;其它 A5 接口仍另行处理,离线代码不能替代这些证据。 ## 范围与非目标 @@ -48,7 +48,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;DSG830 已由 A3 将其提升到 production。 - M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 - 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 -- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、profile-bound 调制输出、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;M4 当前完成 internal/single Pulse 与保持 Sweep disabled 的 frequency-only Step Sweep 配置子集。 +- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、profile-bound 调制输出、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;M4 当前完成 internal/single Pulse 与保持 Sweep disabled 的 frequency-only Step Sweep 配置子集,A5 当前只完成一个固定后面板 Pulse 输出子集。 - 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 ### 明确不做 @@ -57,7 +57,7 @@ RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的 - 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 - 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 - 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 -- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 RF-OFF 调制、调制关闭、Pulse 或 Step Sweep 配置 capability,A4-MO 只授权其精确声明的调制输出 profile。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 RF-OFF 调制、调制关闭、Pulse 或 Step Sweep 配置 capability,A4-MO 只授权其精确声明的调制输出 profile,A5 只授权逐条确认的物理接口、方向和电气 profile。 ## 分层与职责 @@ -169,6 +169,7 @@ class RfFeature(StrEnum): MODULATION = "modulation" MODULATED_OUTPUT = "modulated_output" PULSE = "pulse" + PULSE_OUTPUT = "pulse_output" SWEEP = "sweep" TRIGGER = "trigger" @@ -203,7 +204,7 @@ class RfSourceDescriptorExtensions: 每个 protection policy 的 `code` 必须非空且唯一。Core 以 policy 集合识别已知 condition;只有 `blocks_output_enable=False` 的已知 active code 可以不阻断 RF ON。不存在 policy 的 active code 必须拒绝 RF ON。 -`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfModulatedOutputProfile`、`RfPulseProfile`、`RfSweepProfile` 或 `RfTriggerProfile` 的封闭联合。`RfModulatedOutputProfile` 只列出已逐项证实允许在调制开启时启用 RF 的内部 Sine profile,并且必须是基础调制 profile 的子集,功率上限不得超过端口范围。`RfTriggerProfile` 只描述可读取的逻辑 Pulse/Sweep trigger configuration 值;它不表示物理 trigger/sync 接口、方向、电平或端接。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 +`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfModulatedOutputProfile`、`RfPulseProfile`、`RfPulseOutputProfile`、`RfSweepProfile` 或 `RfTriggerProfile` 的封闭联合。`RfModulatedOutputProfile` 只列出已逐项证实允许在调制开启时启用 RF 的内部 Sine profile,并且必须是基础调制 profile 的子集,功率上限不得超过端口范围。`RfPulseOutputProfile` 明确一个物理接口 ID、唯一 output 方向、可读回状态、电平、源阻抗和固定的内部 Pulse profile;它不隐含同名接口的 input 方向。`RfTriggerProfile` 只描述可读取的逻辑 Pulse/Sweep trigger configuration 值;它不表示物理 trigger/sync 接口、方向、电平或端接。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 每个 `RfFeatureCapability` 必须指定 feature、direction、适用端口、静态限制和可读回字段。静态 profile 只能收紧设备支持范围,不能授权未声明的 operation。`rf_source.pulse_trigger` 对应 `PULSE / TRIGGER`;`rf_source.sweep_fire` 对应 `SWEEP / FIRE`;其他 operation 也必须在 M0–M4 的 descriptor validator 中有唯一映射。 @@ -222,6 +223,7 @@ Core 在调用目标 driver operation 前校验 request、access、descriptor | `rf_source.modulation_disable` | `disable_rf_modulation(request)` | 关闭一个已明确识别的调制模式与全局调制开关 | | `rf_source.modulated_output_enable` | `get_rf_modulation_snapshot(port_id, kind)`、`set_rf_output(request)` | 只在已激活 profile 精确匹配时,单次启用 RF;不配置或关闭调制。 | | `rf_source.pulse_configure` | `configure_rf_pulse(request)` | 已声明的 Pulse 配置 | +| `rf_source.pulse_output` | `get_rf_pulse_output_snapshot(port_id, interface_id)`、`set_rf_pulse_output(request)` | 已声明的物理 Pulse 输出接口;不控制 RF 输出或接收设备。 | | `rf_source.pulse_trigger` | `trigger_rf_pulse(request)` | 已声明的 Pulse 触发 | | `rf_source.sweep_configure` | `configure_rf_sweep(request)` | 已声明的 Sweep 配置 | | `rf_source.sweep_arm` | `arm_rf_sweep(request)` | 准备 Sweep | @@ -328,11 +330,16 @@ wavebench rf-source modulation enable-output-am ... wavebench rf-source modulation enable-output-fm ... wavebench rf-source modulation enable-output-pm ... rf_source.modulated_output_enable + +# A5 Pulse Output:只限声明的物理 output 接口与固定 profile +wavebench rf-source pulse-output --port PORT_ID --interface INTERFACE_ID on|off +rf_source.pulse_output_enable +rf_source.pulse_output_disable ``` `rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`rf-source trigger status` 和 `rf_source.trigger_status` 要求独立的 `rf_source.trigger_snapshot` capability,以及目标 `port_id` 的 `TRIGGER / READ` profile;它们是 `stateful_read`,不读取普通 RF snapshot、不执行 recovery、不写入或触发。DSG830 当前 production descriptor 未声明该 capability,因此该命令会在打开 session 前拒绝。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 -M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 的每个 run step 都要求 `port_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有三类 step 使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 已由 A3 声明 `rf_source.cw_configure`,并由 A2 声明 `rf_source.output`;M1 仅在 `read_write`、目标端口明确 OFF 与完整 OFF-only preflight 同时成立时可执行,M2 还要求完整端口 safety 配置和 fresh preflight。 +M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 与 A5 Pulse Output 的每个 run step 都要求 `port_id`,其中 A5 还要求显式 `interface_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有步骤使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 已由 A3 声明 `rf_source.cw_configure`、由 A2 声明 `rf_source.output`、由受限 A5 证据声明 `rf_source.pulse_output`;M1 仅在 `read_write`、目标端口明确 OFF 与完整 OFF-only preflight 同时成立时可执行,M2 还要求完整端口 safety 配置和 fresh preflight。 ### M1 的生产 CW 与 M2 的生产输出合同 @@ -362,6 +369,11 @@ rf_source.modulated_output_enable wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted rf_source.pulse_configure +# A5 Pulse Output:只切换已声明的物理 output 状态,不启用 RF 输出 +wavebench rf-source pulse-output --port PORT_ID --interface INTERFACE_ID on|off +rf_source.pulse_output_enable +rf_source.pulse_output_disable + # Pulse trigger、Sweep arm/fire/stop 与 Level Sweep 仍是目标合同,尚未进入当前 Core schema wavebench rf-source pulse trigger ... wavebench rf-source sweep arm ... @@ -379,13 +391,25 @@ M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要 `rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 +`rf_source.pulse_output` 是与 `rf_source.pulse_configure` 分开的受限物理接口 operation。DSG830 仅声明 `rf_out` 上的 `pulse_in_out`,方向固定为 output,电气 profile 固定为 `0 V`/`3.3 V`、约 `600 Ω`,Pulse profile 固定为 internal/single/normal/`1 ms`/`100 μs` 且 Pulse 调制保持关闭。启用前,Core 要求 RF 输出、调制、Pulse、Sweep 均关闭、protection 为空、接口与完整固定 profile 精确匹配;随后只执行一次 Pulse Output 状态写入,并分别回读 RF 与物理接口状态。关闭操作故意允许已知 profile 漂移,以保留关闭已启用输出的安全路径;它仍只操作该接口。任何写入或 readback 结果不明都会使 session 降为不确定状态,Core 不自动重试,也不配置接收设备、RF 输出、trigger 或示波器。 + `rf_source.sweep_configure` 已进入当前 Core schema;DSG830 已在 A4 Step Sweep 证据复核后声明该 capability。请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 -### A5 的离线合同边界 +### A5:物理接口按路径提升 + +外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。每条路径都必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 + +#### A5-0:逻辑 trigger configuration 读取 + +A5-0 已在离线范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 production descriptor 仍不声明该 capability。源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。静态预检绑定当前 production descriptor 的 capability 列表;后续 capability 变更必须通过代码审查、fake 回归和新的零写诊断更新该基线,否则工具在建立 session 前拒绝。该诊断不构成物理 A5 实机证据,也不创建外部接口默认值、不隐式触发设备,或把 `rf_out` 的端接和 CH2 输入设置用于推断 trigger/sync 端口。 + +#### A5 Pulse Output:已完成的单一路径 + +已验证的唯一路径是 DSG830「PULSE IN/OUT」的 output 方向接入 RTM2032「EXT TRIGGER INPUT」。DSG830 侧 profile 固定为 `0 V`/`3.3 V`、约 `600 Ω`,internal/single/normal、period `1 ms`、width `100 μs`;接收端仅以其 `1 MΩ`/`12 pF`/`≤ 150 Vp` 输入额定值作为本次接线的电气边界。验收 harness 在隔离 session 中先核对 scope trigger source 为 external、mode 为 auto,再短暂设为 normal、执行 single、复位为 auto;scope 只作为隔离的物理观察与单次采集执行者,不构成 RTM driver 或 production capability。 -外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。新的 operation 必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 +该路径在 RF 输出始终关闭的前提下完成一次「Pulse Output ON → scope single → Pulse Output OFF」序列。成功审计为 RF 主 session `97` 次 query/`8` 次完成 write、独立最终 RF 复核 session `15` 次 query/零 write、scope session `5` 次 query/`3` 次完成 write;最终 RF 输出和 Pulse Output 均独立确认关闭。source 的既有 Pulse profile 与 scope acquisition state 不属于恢复范围,scope 在完成后可能停在 `Single`。历史 harness 在 capability 提升后拒绝重跑,避免以临时 descriptor 绕过 production 边界。 -在没有已确认后面板接线和电气边界的情况下,Core 只允许离线 typed contract、descriptor validator、fake transport、零写拒绝和 artifact 测试。A5-0 已在该范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成一次隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。静态预检精确绑定当前 production descriptor 的 capability 列表;后续 capability 变更必须通过代码审查、fake 回归和新的零写诊断更新该基线,否则工具在建立 session 前拒绝。该诊断不构成物理 A5 实机证据。该范围不声明 production capability、不创建外部接口默认值、不隐式触发设备,也不把 `rf_out` 的端接或 CH2 的输入设置用于推断 trigger/sync 端口。任何会使设备开始 Pulse 或 Sweep 的 fire/trigger operation 仍需独立的、一次性 safety 决定和 A5 实机证据。 +因此 DSG830 production descriptor 只增加 `rf_source.pulse_output`。它不提升同一连接器的 input 方向、`TRIGGER IN`、Pulse trigger、Sweep arm/fire、sync/reference、Level Sweep 或 list;任何会使设备开始 Pulse 或 Sweep 的 trigger/fire operation 仍需独立、一次性的安全决定和另一条 A5 实机证据。 ## M0–M4 与 M3-MO 里程碑 @@ -400,10 +424,11 @@ M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要 | M3-MO(A4-MO 已通过并提升) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;固定 AM evidence descriptor 和 CH2-only harness | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production 仅声明 AM `50 %`/`1 kHz`、最大 `-50 dBm`。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | | M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | +| A5(Pulse Output;DSG830 已提升) | `RfPulseOutputProfile`、固定物理 output request/result/snapshot、Service、CLI、run step 与 artifact | `pulse_in_out` output 的 `:PULM:OUT:STAT?`/`:PULM:OUT:STAT ON|OFF` 映射 | 仅在 RF 输出、调制、Pulse、Sweep 都关闭、protection 为空、接口和固定 profile 精确匹配时启用;关闭允许 profile 漂移以保留安全关闭路径。仅提升 `rf_source.pulse_output`。 | -M0–M4 与 M3-MO 只证明代码合同和 SCPI 映射。后续证据顺序固定为:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A4-MO 调制输出,A5 外部触发或同步接线。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 +M0–M4、M3-MO 与 A5 Pulse Output 的每项提升都绑定代码合同、SCPI 映射和对应实机证据。证据顺序包含:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A4-MO 调制输出,以及按物理路径拆分的 A5。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的 50 Ω CH2 路径上通过,因此仅将同一 AM profile、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到 production。A5 仍是外部 trigger/同步 capability 的实机提升门槛。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的 50 Ω CH2 路径上通过,因此仅将同一 AM profile、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到 production。受限 A5 已将「PULSE IN/OUT」output 方向的 `rf_source.pulse_output` 提升到 production;剩余 A5 trigger/fire/sync 路径仍需独立实机证据。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -417,12 +442,13 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 | RF 输出 | `:OUTP ON|OFF` / `:OUTP?` | 单个 `rf_out` 端口。 | | 调制状态 | `:MOD:STAT?` | `0`/`1`;M3 还读取 AM/FM/PM enable 状态与目标内部 Sine 参数。 | | Pulse 状态 | `:PULM:STAT?` | `0`/`1`;配置和触发进入 M4。 | +| 后面板 Pulse Output | `:PULM:OUT:STAT?` / `:PULM:OUT:STAT ON|OFF` | 仅 `pulse_in_out` 的 output 方向;`0 V`/`3.3 V`、约 `600 Ω`,internal/single/normal/`1 ms`/`100 μs`。 | | Sweep 状态 | `:SWE:STAT?` | `OFF`、`FREQ`、`LEV` 或组合;frequency-only Step Sweep 子集进入 M4。 | | 保护状态 | `:STAT:QUES:POW:COND?` | 位 0 ALC unlocked、位 1 output power protection、位 2 heater detector;未知高位按阻断处理。 | 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.modulated_output_enable`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM 与按模式关闭;PM production profile 固定为 `1.25 rad`。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但 production 只接受 AM `50 %`/`1 kHz`、最大 `-50 dBm`;它未改变普通 `rf_source.output` 的调制关闭前置条件。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4/A4-MO harness 在对应 descriptor 提升后拒绝重跑;普通 M3/M3-MO/Pulse/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 preflight。既有证据不开放 Sweep fire、后面板配置或 trigger 控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO/A5 Pulse Output 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM 与按模式关闭;PM production profile 固定为 `1.25 rad`。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但 production 只接受 AM `50 %`/`1 kHz`、最大 `-50 dBm`;它未改变普通 `rf_source.output` 的调制关闭前置条件。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。A5 Pulse Output 只通过 `get_rf_pulse_output_snapshot()`/`set_rf_pulse_output()` 查询和切换 `pulse_in_out` 的 output 状态,不配置 Pulse profile、RF 输出、trigger 或接收设备。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4/A4-MO/A5 Pulse Output harness 在对应 descriptor 提升后拒绝重跑;普通 M3/M3-MO/Pulse/Pulse Output/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 preflight。既有证据不开放 Sweep fire、Pulse input、`TRIGGER IN` 或同步控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -439,5 +465,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO 完成后声 - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.modulation_disable`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697`/`188292d` 与插件 `5394f15`/`3fb3778`/`65eb611` 新增 M3-MO special capability、严格 transaction、公开调制关闭入口、固定 AM 证据 harness 与 fake 回归。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm`、CH2 50 Ω 路径上通过,最终 RF OFF、调制关闭和健康关闭均经独立复核;production 仅提升该 AM profile、最大 `-50 dBm`。该提升不开放 Sweep execute/fire 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.modulation_disable`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697`/`188292d` 与插件 `5394f15`/`3fb3778`/`65eb611` 新增 M3-MO special capability、严格 transaction、公开调制关闭入口、固定 AM 证据 harness 与 fake 回归。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm`、CH2 50 Ω 路径上通过,最终 RF OFF、调制关闭和健康关闭均经独立复核;production 仅提升该 AM profile、最大 `-50 dBm`。Core `877645d` 与插件 `1b08593`/`e32e335`/`e9c3502` 增加并完成 A5 Pulse Output 合同、固定 `:PULM:OUT:STAT` 映射、隔离实机验收和 production 提升。该提升不开放 Pulse input、`TRIGGER IN`、Sweep execute/fire、sync 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 386e7f2..89a5a60 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,7 +19,7 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| RF 信号源 | M0–M4 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制、Pulse 与 Step Sweep 配置、CLI 与对应 run step | 不复用普通 source;DSG830 已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;PM production profile 仅为 `1.25 rad`,调制输出仍未开放 | +| RF 信号源 | M0–M4 与受限 A5 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制、Pulse 与 Step Sweep 配置,以及一条后面板 Pulse 输出路径 | 不复用普通 source;DSG830 已完成 A1/A2/A3/A4 与 A5 Pulse Output,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 与受限的 `rf_source.pulse_output`;PM production profile 仅为 `1.25 rad`,普通调制输出只限固定 profile | | run plan | source、rf_source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样;RF 输出仍受 capability、access 和端口 safety 限制 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | @@ -29,7 +29,7 @@ WaveBench 优先解决以下问题: RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 -当前 Core 已提供 M0–M4 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`、凭 A3 证据开放 OFF-only `rf_source.cw_configure`,并凭 A4 调制/Pulse/Step Sweep 证据开放 RF-OFF `rf_source.modulation_configure`、保持 disabled 的 `rf_source.pulse_configure` 与 `rf_source.sweep_configure`。M3 的 PM production profile 固定为 `1.25 rad`,而 M2 的 RF ON 合同仍要求调制 disabled;trigger、Sweep execute/fire 与 Level Sweep 继续等待对应 A5 或独立证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 +当前 Core 已提供 M0–M4 合同,并增加受限的 A5 Pulse Output 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`、凭 A3 证据开放 OFF-only `rf_source.cw_configure`,并凭 A4 调制/Pulse/Step Sweep 证据开放 RF-OFF `rf_source.modulation_configure`、保持 disabled 的 `rf_source.pulse_configure` 与 `rf_source.sweep_configure`。A5 仅将「PULSE IN/OUT」的 output 方向、固定 internal/single/normal/`1 ms`/`100 μs` profile 提升为 `rf_source.pulse_output`;该操作不启用 RF 输出。M3 的 PM production profile 固定为 `1.25 rad`,而 M2 的 RF ON 合同仍要求调制 disabled;Pulse input、`TRIGGER IN`、Sweep execute/fire、同步与 Level Sweep 继续等待各自的独立证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 ## 推荐工作顺序 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 5bac0bb..12adf1e 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -27,6 +27,7 @@ | 受限调制输出 | A4-MO 后已开放 | A4-MO 后已开放 | 仅 AM `50 %`/内部 `1 kHz`、最大 `-50 dBm`。它要求 profile 已激活且精确匹配,不配置或关闭调制;普通 `rf_source.output on` 仍要求调制关闭。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | +| 后面板 Pulse Output | A5 合同、CLI、run step、artifact 与受控 evidence 已完成 | A5 Pulse Output 后已开放 | 仅 `rf_out` 的 `pulse_in_out` output 方向;固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs`。它不启用 RF 输出,也不配置接收设备。 | | 逻辑 trigger configuration 读取 | A5-0 离线合同完成 | 未开放 | `rf-source trigger status`/`rf_source.trigger_status` 需要独立 capability 和 `TRIGGER / READ` profile;当前 DSG830 production descriptor 会拒绝该请求。它不读取或配置物理 trigger/sync 接口。 | | trigger、arm/fire、Level Sweep | 未完成 | 未开放 | 不应尝试调用或绕过。 | @@ -85,7 +86,7 @@ wavebench rf-source idn --config wavebench.toml wavebench rf-source status --config wavebench.toml ``` -在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW、内部正弦调制、RF 输出、Pulse 和 Step Sweep 配置操作: +在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW、内部正弦调制、RF 输出、Pulse、Pulse Output 和 Step Sweep 操作: ```bash wavebench rf-source set-frequency --config wavebench.toml --port rf_out 1000000 @@ -97,6 +98,8 @@ wavebench rf-source modulation disable --config wavebench.toml --port rf_out --m wavebench rf-source output --config wavebench.toml --port rf_out on wavebench rf-source output --config wavebench.toml --port rf_out off wavebench rf-source pulse configure --config wavebench.toml --port rf_out --period-s 0.001 --width-s 0.0001 --polarity normal +wavebench rf-source pulse-output --config wavebench.toml --port rf_out --interface pulse_in_out on +wavebench rf-source pulse-output --config wavebench.toml --port rf_out --interface pulse_in_out off wavebench rf-source sweep configure --config wavebench.toml --port rf_out --start-frequency-hz 1000000 --stop-frequency-hz 2000000 --points 11 --dwell-s 0.02 ``` @@ -113,6 +116,14 @@ wavebench rf-source modulation disable --config wavebench.toml --port rf_out --m DSG830 目前只声明上述精确 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `enable-output-am`。`enable-output-fm`、`enable-output-pm` 会因 profile 不匹配而在仪器 I/O 前拒绝。成功的特殊 ON 不会自动 RF OFF 或关闭调制;结束时必须显式执行普通 `output off` 和按模式 `disable`。不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 +## A5:后面板 Pulse Output(受限生产操作) + +`rf_source.pulse_output` 只覆盖 DSG830 的 `pulse_in_out` output 方向。它不是 `rf_source.pulse_configure` 的别名,也不表示接口的 input 方向、`TRIGGER IN`、Pulse trigger、Sweep fire、sync/reference 或 RTM 控制能力。 + +固定 profile 为 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal、period `1 ms`、width `100 μs`。`on` 只在 RF 输出、调制、Pulse、Sweep 都关闭、protection 为空且 readback 与该 profile 精确匹配时执行一次状态写入;成功不会启用 RF 输出或改变示波器。`off` 仍只操作该接口,但故意允许已知 profile 漂移,以保留关闭已启用输出的路径。写入或读回结果不明时,不重试,session 会变为不确定状态。 + +已验证的接线仅为 DSG830「PULSE IN/OUT」output → RTM2032「EXT TRIGGER INPUT」。日常 CLI 或 run plan 不会检测接收设备、配置 scope trigger、发起 scope single 或恢复接收设备状态;使用前必须人工核对实际接线与接收端电气额定值。该操作不适用于从相似连接器名称推导出的其它路径。 + ## A5-0:逻辑 trigger configuration 读取 Core 已提供下列只读入口: @@ -155,6 +166,16 @@ port_id = "rf_out" period_s = 0.001 width_s = 0.0001 polarity = "normal" + +[[steps]] +kind = "rf_source.pulse_output_enable" +port_id = "rf_out" +interface_id = "pulse_in_out" + +[[steps]] +kind = "rf_source.pulse_output_disable" +port_id = "rf_out" +interface_id = "pulse_in_out" ``` `rf_source.sweep_configure` 已进入 schema,并在 DSG830 的 A4 Step Sweep 证据复核后成为受限生产操作: @@ -271,6 +292,6 @@ DSG830 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与无资源 setup 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. 日常 M3/M4 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、后面板 Pulse I/O、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;M3-MO 只能使用 DSG830 已声明的固定 AM profile,并在结束时显式 RF OFF 与按模式关闭调制。 +5. 日常 M3/M4/A5 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、未声明的后面板接口、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;A5 只使用已声明的 `pulse_in_out` output 路径;M3-MO 只能使用 DSG830 已声明的固定 AM profile,并在结束时显式 RF OFF 与按模式关闭调制。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 From 03cc9404c671e870cfc9f85a69508c822ec9bf17 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:59:29 +0800 Subject: [PATCH 61/63] docs: record DSG830 FM PM output profiles --- README.md | 2 +- ...21\351\207\214\347\250\213\347\242\221.md" | 14 ++++++++------ ...67\346\272\220\350\256\276\350\256\241.md" | 16 ++++++++-------- ...77\347\224\250\346\214\207\345\215\227.md" | 19 ++++++++++++++----- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 06e26d4..fa817cc 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ wavebench tui --fake ## RF 信号源 -`rf_source` 是独立于普通 `source` 的仪器领域。DSG830 当前已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置及按模式关闭、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`。A4-MO 已将受限 `rf_source.modulated_output_enable` 提升到 production:仅接受已激活且精确匹配的 AM `50 %`/`1 kHz` profile,最大功率 `-50 dBm`;普通 `rf_source.output` 仍要求调制关闭。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。 +`rf_source` 是独立于普通 `source` 的仪器领域。DSG830 当前已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置及按模式关闭、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`。A4-MO 已将受限 `rf_source.modulated_output_enable` 提升到 production:仅接受已激活且精确匹配的 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz` profile,最大功率均为 `-50 dBm`;普通 `rf_source.output` 仍要求调制关闭。FM/PM 的 WaveBench CH2 分析只记录波形质量,不计量频偏或相偏。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。 已完成的 A5 只覆盖一条后面板物理路径:DSG830 的「PULSE IN/OUT」按 output 方向、固定 internal/single/normal/`1 ms`/`100 μs` profile,提供 `rf_source.pulse_output` 与 `wavebench rf-source pulse-output`。它不启用 RF 输出,也不定义 Pulse input、`TRIGGER IN`、Sweep fire、sync/reference、Level Sweep 或 list。A5-0 仍仅是逻辑 Pulse/Sweep trigger configuration 的零写读取合同,DSG830 production descriptor 不声明 `rf_source.trigger_snapshot`。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" index cf1c8f6..95cc87c 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -9,8 +9,8 @@ | 范围 | 当前状态 | 说明 | | --- | --- | --- | | Core `0.8.25` 开发线 | M0–M4、M3-MO、A5-0 与 A5 Pulse Output 合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、按模式调制关闭、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep、逻辑 trigger configuration 的只读类型,以及物理 Pulse Output 的 Service、CLI、run 和 artifact。 | -| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep、M3-MO 的 A4/A4-MO 均已通过并提升;A5-0 映射已完成,A5 Pulse Output 已通过并提升 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep、六条固定 trigger configuration query,以及 `:PULM:OUT:STAT` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | -| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、调制关闭、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 只提升固定 AM 调制输出 profile。A5-0 不产生 production capability,A5 Pulse Output 只提升被验证的 output 路径。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep、M3-MO 的 A4/A4-MO 均已通过并提升;A5-0 映射已完成,A5 Pulse Output 已通过并提升 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep、六条固定 trigger configuration query,以及 `:PULM:OUT:STAT` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、调制关闭、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 提升三个固定调制输出 profile。A5-0 不产生 production capability,A5 Pulse Output 只提升被验证的 output 路径。 | ## 双仓库交付规则 @@ -31,7 +31,7 @@ | M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | | M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | | M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置与按模式关闭事务、CLI、run 与 artifact | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 配置只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;关闭只在 RF OFF、唯一目标模式活动时写入。DSG830 production 已开放 `rf_source.modulation_configure` 和 `rf_source.modulation_disable`。PM 的 production profile 固定为 `1.25 rad`。 | -| M3-MO | A4-MO 已通过并提升 | `RfModulatedOutputProfile`、严格 pre/post RF 与调制 snapshot、一次 RF ON、受 guard OFF recovery、CLI、run 与 artifact | 复用既有 `:OUTP`/调制 snapshot 映射;固定 AM descriptor、CH2-only evidence harness 与 fake 回归 | 只接受已激活且精确匹配的内部 Sine profile;不配置调制,不重试 ON。普通 `rf_source.output` 仍要求调制关闭。DSG830 production 仅开放 AM `50 %`/`1 kHz`、最大 `-50 dBm`。 | +| M3-MO | A4-MO 已通过并提升 | `RfModulatedOutputProfile`、严格 pre/post RF 与调制 snapshot、一次 RF ON、受 guard OFF recovery、CLI、run 与 artifact | 复用既有 `:OUTP`/调制 snapshot 映射;历史 AM 和独立 FM/PM descriptor、CH2 observation、WaveBench 波形摘要/FFT 与 fake 回归 | 只接受已激活且精确匹配的内部 Sine profile;不配置调制,不重试 ON。普通 `rf_source.output` 仍要求调制关闭。DSG830 production 开放 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`,最大 `-50 dBm`。scope 分析只记录波形质量,不能计量频偏或相偏。 | | M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | | M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | | A5-0 | 离线完成;不属于物理 A5 证据 | `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、只读 Service/CLI/run/artifact | `:PULM:TRIG:MODE?`、external edge/gate query、Sweep mode/period/point trigger query 与严格 enum parser | 只使用 `TRIGGER / READ` profile 和非 production descriptor;固定 query 顺序、零 write、未知值失败关闭。它不定义物理 connector,不发送 trigger,也不提升 production capability。 | @@ -62,7 +62,7 @@ DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包 - 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 - 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 - 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 -- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 调制、Pulse 与 Step Sweep 已分别将 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor;A4-MO 已将固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到同一 descriptor。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 调制、Pulse 与 Step Sweep 已分别将 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor;A4-MO 已将 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到同一 descriptor。 ### 离线完成条件 @@ -108,7 +108,9 @@ Core 将调制开启时的 RF 输出建模为独立的 `rf_source.modulated_outp 任何 ON 结果不明、RF readback 或调制 readback 不符时,都不重试 ON。只有 session health 允许时,Core 才复用 M2 的一次受 guard RF OFF recovery;恢复后不推断调制状态。普通 `rf_source.output` 的 ON preflight 不变,仍要求调制关闭。 -DSG830 源码 checkout 提供 `tools/a4_modulated_output_evidence.py` 与无资源 setup 模板。它使用仅在内存中创建的 descriptor,固定为 AM `50 %`/内部 `1 kHz`、RF `1 MHz`/`-50 dBm`,并只读取 CH2 的当前 `DEF` 缓冲区。CH2 必须由 setup 显式确认 50 Ω;scope 只判定是否有可见信号,不计算 dBm、频率或调制深度。工具不读取或控制 CH1,不把 LF OUTPUT 解释为调制测量,不使用 trigger/sync/后面板 Pulse I/O。受控序列已通过:154 次 RF query、12 次完成 write、CH2 信号存在、最终 RF OFF/调制关闭、两个 session 健康关闭,脱敏记录为 `0600`。因此 production descriptor 仅声明相同 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 capability;historical harness 会拒绝重跑。 +DSG830 源码 checkout 保留 `tools/a4_modulated_output_evidence.py` 与无资源 setup 模板,作为已完成的历史 AM 验收:它使用仅在内存中创建的 descriptor,固定为 AM `50 %`/内部 `1 kHz`、RF `1 MHz`/`-50 dBm`,并只读取 CH2 的当前 `DEF` 缓冲区。CH2 必须由 setup 显式确认 50 Ω;scope 只判定是否有可见信号,不计算 dBm、频率或调制深度。工具不读取或控制 CH1,不把 LF OUTPUT 解释为调制测量,不使用 trigger/sync/后面板 Pulse I/O。该受控序列已通过:154 次 RF query、12 次完成 write、CH2 信号存在、最终 RF OFF/调制关闭、两个 session 健康关闭,脱敏记录为 `0600`。提升后 historical harness 拒绝重跑。 + +独立的 `tools/a4_fm_pm_modulated_output_evidence.py` 使用同一安全合同验证 FM `20 kHz`/内部 `1 kHz` 和 PM `1.25 rad`/内部 `1 kHz`,两者同为 RF `1 MHz`/`-50 dBm`。它们在严格源端 profile readback 与最终关闭复核之外,使用 WaveBench 波形摘要和 FFT 记录 CH2 信号存在及质量告警;这些记录不测量频偏、相偏、调制准确度或频谱合规性。三个精确 profile 因此已进入 production descriptor。 ## M4:Pulse 与 Step Sweep @@ -214,5 +216,5 @@ CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的 Core 合同、DSG830 映射、CLI、run、artifact 与 A4 证据均已完成,`rf_source.modulation_configure` 已提升;M4 继续保持独立工作。 6. A4 的 AM/FM/PM RF-OFF 单模式配置、严格读回与关闭恢复证据均已通过。PM production profile 固定为 `1.25 rad`,以避免将更宽的离线映射当作实机覆盖范围。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成新的 capability 提升证据;任何允许调制开启时 RF 输出的安全合同仍须单独设计和验证,CH2 可见信号也不能替代该证据。 7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 -8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、公开按模式关闭入口、DSG830 固定 profile descriptor、CH2-only harness 与 fake 回归。已使用 WaveBench 有界网络发现确认候选设备,并完成只读诊断与固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的一次受控 A4-MO;CH2 信号存在、最终 RF OFF、调制关闭和健康关闭均已通过,production descriptor 已提升。CH1 的低频输出不属于该证据路径。 +8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、公开按模式关闭入口、DSG830 固定 profile descriptor、CH2 observation 与 fake 回归。已使用 WaveBench 有界网络发现确认候选设备,并完成只读诊断与 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的受控 A4-MO;CH2 信号存在、最终 RF OFF、调制关闭和健康关闭均已通过。FM/PM 记录通过 WaveBench 波形摘要与 FFT 保存质量告警,但不测量频偏或相偏;production descriptor 已提升三个精确 profile。CH1 的低频输出不属于该证据路径。 9. 已完成受限 A5 Pulse Output 的 Core 合同、DSG830 固定 `:PULM:OUT:STAT` 映射、CLI/run/artifact、fake 回归和隔离实机验收。唯一提升路径为「PULSE IN/OUT」output →「EXT TRIGGER INPUT」,固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs`;最终 RF 输出和 Pulse Output 均为 OFF。production descriptor 已提升 `rf_source.pulse_output`。后续 A5 工作必须从另一条物理接口、接线和电气边界重新开始,不能复用本条证据。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" index a867e36..caa28ea 100644 --- "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -2,7 +2,7 @@ ## 文档定位 -本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制及按模式关闭、M3-MO 受限调制输出、M4 Pulse 和 frequency-only Step Sweep 配置,以及受限的 A5 Pulse Output 合同与控制入口;DSG830 已凭 A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制及按模式关闭、固定 profile 调制输出、RF-OFF Pulse 配置、保持 Sweep disabled 的 Step Sweep 配置,以及一条后面板「PULSE IN/OUT」输出路径。M3 的 PM production profile 仅为 `1.25 rad`;M3-MO 的 production profile 仅为 AM `50 %`/`1 kHz`、最大 `-50 dBm`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制及按模式关闭、M3-MO 受限调制输出、M4 Pulse 和 frequency-only Step Sweep 配置,以及受限的 A5 Pulse Output 合同与控制入口;DSG830 已凭 A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制及按模式关闭、固定 profile 调制输出、RF-OFF Pulse 配置、保持 Sweep disabled 的 Step Sweep 配置,以及一条后面板「PULSE IN/OUT」输出路径。M3 的 PM production profile 仅为 `1.25 rad`;M3-MO 的 production profile 为 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`,最大 `-50 dBm`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 阅读顺序如下: @@ -16,7 +16,7 @@ | 范围 | 当前状态 | 边界 | | --- | --- | --- | | Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM 及按模式关闭、M3-MO profile-bound 调制输出、M4 Pulse/frequency-only Step Sweep 配置,以及 A5 Pulse Output 的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | -| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 和 `:PULM:OUT:STAT` 输出映射;A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、固定 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、受限 `rf_source.pulse_output` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 限于 `1.25 rad`。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 和 `:PULM:OUT:STAT` 输出映射;A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、受限 `rf_source.pulse_output` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 的 RF-OFF 配置范围仍限于 `1.25 rad`。 | | 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成。 | A5 只提升已验证的「PULSE IN/OUT」output 方向;不提升 Pulse input、`TRIGGER IN`、trigger、fire、sync/reference、Level Sweep 或 list。 | 普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 @@ -294,7 +294,7 @@ RF OFF 不依赖频率、功率、端接或 protection readback;它仍受 acce ON 写入、RF readback 或调制 readback 任何一项不确定时,绝不重试 ON;只允许沿用 M2 的一次受 guard RF OFF recovery。恢复后 session 保持不确定,调用方不得假设调制已关闭。`RfModulatedOutputProfile` 必须显式声明可用端口、精确 mode profile 与最大功率,不能从基础 `RfModulationProfile` 或普通 output capability 自动推导。 -DSG830 的 A4-MO 受控证据仅提升 AM `50 %`、内部 `1 kHz`、最大 `-50 dBm`。它必须在普通 M3 配置读回后通过 `enable-output-am` 启用;结束时先用普通 `output off`,再用按模式 `modulation disable` 清理。该 profile 不授权 FM/PM 调制输出,也不修改普通 `rf_source.output` 的调制关闭前置条件。 +DSG830 的 A4-MO 受控证据提升三个精确 profile:AM `50 %`/内部 `1 kHz`、FM `20 kHz` 频偏/内部 `1 kHz`、PM `1.25 rad` 相偏/内部 `1 kHz`,最大功率均为 `-50 dBm`。它们必须在普通 M3 配置读回后通过相应 `enable-output-am|fm|pm` 启用;结束时先用普通 `output off`,再用按模式 `modulation disable` 清理。FM/PM 的 WaveBench CH2 分析只记录信号存在和波形质量,不能独立测量频偏或相偏。该提升不修改普通 `rf_source.output` 的调制关闭前置条件。 Sweep arm 是 OFF-only 准备 operation,必须保持目标端口 RF OFF。Sweep fire 与 Pulse trigger 是潜在能量操作。它们必须使用独立、一次性安全决定,不能因先前的 configure、arm 或 output ON 成功而自动获得许可。core 不会隐式打开输出、触发外部端口或开启后面板辅助输出。 @@ -387,7 +387,7 @@ rf_source.sweep_configure M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。M2 的 RF ON 仍要求调制关闭,因此这不是调制输出入口。 -M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要求调制已经激活且完整 profile 精确匹配,不会配置该 profile。当前 Core schema 已有三个 `enable-output-*` CLI、`rf_source.modulated_output_enable` run step,以及显式 `rf_source.modulation_disable` 清理入口。DSG830 production descriptor 只声明 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 M3-MO profile;FM/PM special output 请求会在仪器 I/O 前拒绝。A4-MO 使用同一固定 AM profile、RF `1 MHz`/`-50 dBm` 和 CH2 的 50 Ω 可见信号观察完成受控验收。它不读取或控制 CH1,不把 LF OUTPUT 当作 AM 测量,也不从 scope 推断 dBm、频率或调制深度。 +M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要求调制已经激活且完整 profile 精确匹配,不会配置该 profile。当前 Core schema 已有三个 `enable-output-*` CLI、`rf_source.modulated_output_enable` run step,以及显式 `rf_source.modulation_disable` 清理入口。DSG830 production descriptor 声明 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 M3-MO profile;其它 special output 请求会在仪器 I/O 前拒绝。A4-MO 的三条固定 profile 均使用 RF `1 MHz`/`-50 dBm` 和 CH2 的显式 50 Ω 观察完成受控验收。FM/PM 额外保存 WaveBench 波形摘要和 FFT 质量记录;它们不读取或控制 CH1,不把 LF OUTPUT 当作调制测量,也不从 scope 推断 dBm、频偏、相偏、调制准确度或频谱合规性。 `rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 @@ -421,14 +421,14 @@ A5-0 已在离线范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_ | M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | | M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | | M3(A4 已通过并提升) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;DSG830 已声明 `rf_source.modulation_configure`,其中 PM 固定为 `1.25 rad`。 | -| M3-MO(A4-MO 已通过并提升) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;固定 AM evidence descriptor 和 CH2-only harness | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production 仅声明 AM `50 %`/`1 kHz`、最大 `-50 dBm`。 | +| M3-MO(A4-MO 已通过并提升) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;历史 AM 与独立 FM/PM evidence descriptor、CH2 observation、WaveBench 波形摘要/FFT 和 fake 回归 | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production 声明 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm`。scope 分析不计量频偏或相偏。 | | M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | | M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | | A5(Pulse Output;DSG830 已提升) | `RfPulseOutputProfile`、固定物理 output request/result/snapshot、Service、CLI、run step 与 artifact | `pulse_in_out` output 的 `:PULM:OUT:STAT?`/`:PULM:OUT:STAT ON|OFF` 映射 | 仅在 RF 输出、调制、Pulse、Sweep 都关闭、protection 为空、接口和固定 profile 精确匹配时启用;关闭允许 profile 漂移以保留安全关闭路径。仅提升 `rf_source.pulse_output`。 | M0–M4、M3-MO 与 A5 Pulse Output 的每项提升都绑定代码合同、SCPI 映射和对应实机证据。证据顺序包含:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A4-MO 调制输出,以及按物理路径拆分的 A5。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 -DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的 50 Ω CH2 路径上通过,因此仅将同一 AM profile、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到 production。受限 A5 已将「PULSE IN/OUT」output 方向的 `rf_source.pulse_output` 提升到 production;剩余 A5 trigger/fire/sync 路径仍需独立实机证据。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 已在 RF `1 MHz`/`-50 dBm`、50 Ω CH2 路径上完成 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz` 的固定 profile 验收,因此将这三个精确 profile 的 `rf_source.modulated_output_enable` 提升到 production。FM/PM 的 WaveBench 波形摘要与 FFT 只记录质量告警,不外推为偏差计量。受限 A5 已将「PULSE IN/OUT」output 方向的 `rf_source.pulse_output` 提升到 production;剩余 A5 trigger/fire/sync 路径仍需独立实机证据。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 ## 首个适配器:RIGOL DSG830 @@ -448,7 +448,7 @@ DSG830 只为通用合同提供第一组设备映射,不改变核心类型或 手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 -DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO/A5 Pulse Output 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM 与按模式关闭;PM production profile 固定为 `1.25 rad`。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但 production 只接受 AM `50 %`/`1 kHz`、最大 `-50 dBm`;它未改变普通 `rf_source.output` 的调制关闭前置条件。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。A5 Pulse Output 只通过 `get_rf_pulse_output_snapshot()`/`set_rf_pulse_output()` 查询和切换 `pulse_in_out` 的 output 状态,不配置 Pulse profile、RF 输出、trigger 或接收设备。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4/A4-MO/A5 Pulse Output harness 在对应 descriptor 提升后拒绝重跑;普通 M3/M3-MO/Pulse/Pulse Output/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 preflight。既有证据不开放 Sweep fire、Pulse input、`TRIGGER IN` 或同步控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO/A5 Pulse Output 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM 与按模式关闭;PM production profile 固定为 `1.25 rad`。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但 production 只接受 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm`;它未改变普通 `rf_source.output` 的调制关闭前置条件。FM/PM 的 scope 分析只记录波形质量,不能独立计量频偏或相偏。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。A5 Pulse Output 只通过 `get_rf_pulse_output_snapshot()`/`set_rf_pulse_output()` 查询和切换 `pulse_in_out` 的 output 状态,不配置 Pulse profile、RF 输出、trigger 或接收设备。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4/A4-MO/A5 Pulse Output harness 在对应 descriptor 提升后拒绝重跑;普通 M3/M3-MO/Pulse/Pulse Output/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 preflight。既有证据不开放 Sweep fire、Pulse input、`TRIGGER IN` 或同步控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 ## 测试与发布边界 @@ -465,5 +465,5 @@ DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO/A5 Pulse Ou - 核心开发分支:`Scaxlibur/feat/rf-source-core`。 - DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 - Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 -- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.modulation_disable`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697`/`188292d` 与插件 `5394f15`/`3fb3778`/`65eb611` 新增 M3-MO special capability、严格 transaction、公开调制关闭入口、固定 AM 证据 harness 与 fake 回归。A4-MO 已在固定 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm`、CH2 50 Ω 路径上通过,最终 RF OFF、调制关闭和健康关闭均经独立复核;production 仅提升该 AM profile、最大 `-50 dBm`。Core `877645d` 与插件 `1b08593`/`e32e335`/`e9c3502` 增加并完成 A5 Pulse Output 合同、固定 `:PULM:OUT:STAT` 映射、隔离实机验收和 production 提升。该提升不开放 Pulse input、`TRIGGER IN`、Sweep execute/fire、sync 或 trigger capability。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.modulation_disable`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697`/`188292d` 与插件 `5394f15`/`3fb3778`/`65eb611` 新增 M3-MO special capability、严格 transaction、公开调制关闭入口、历史 AM 证据 harness 与 fake 回归。A4-MO 先在 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm`、CH2 50 Ω 路径上通过;随后独立 FM `20 kHz`/`1 kHz` 与 PM `1.25 rad`/`1 kHz` 受控循环也完成同样的严格源端 readback、CH2 信号存在、最终 RF OFF、调制关闭和健康关闭复核。FM/PM 使用 WaveBench 波形摘要和 FFT 记录质量告警,但不把它们外推为偏差计量;production 已提升三个精确 profile,最大 `-50 dBm`。Core `877645d` 与插件 `1b08593`/`e32e335`/`e9c3502` 增加并完成 A5 Pulse Output 合同、固定 `:PULM:OUT:STAT` 映射、隔离实机验收和 production 提升。该提升不开放 Pulse input、`TRIGGER IN`、Sweep execute/fire、sync 或 trigger capability。 - `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" index 12adf1e..90216f1 100644 --- "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -24,7 +24,7 @@ | RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | | 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。 | | 按模式关闭调制 | 已开放 | A4 后已开放 | RF OFF、Pulse/Sweep disabled 且唯一目标模式活动时才写入;已一致关闭时零写返回。 | -| 受限调制输出 | A4-MO 后已开放 | A4-MO 后已开放 | 仅 AM `50 %`/内部 `1 kHz`、最大 `-50 dBm`。它要求 profile 已激活且精确匹配,不配置或关闭调制;普通 `rf_source.output on` 仍要求调制关闭。 | +| 受限调制输出 | A4-MO 后已开放 | A4-MO 后已开放 | AM `50 %`/内部 `1 kHz`、FM `20 kHz`/内部 `1 kHz`、PM `1.25 rad`/内部 `1 kHz`,最大 `-50 dBm`。它要求 profile 已激活且精确匹配,不配置或关闭调制;普通 `rf_source.output on` 仍要求调制关闭。 | | Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | | frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | | 后面板 Pulse Output | A5 合同、CLI、run step、artifact 与受控 evidence 已完成 | A5 Pulse Output 后已开放 | 仅 `rf_out` 的 `pulse_in_out` output 方向;固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs`。它不启用 RF 输出,也不配置接收设备。 | @@ -114,7 +114,16 @@ wavebench rf-source output --config wavebench.toml --port rf_out off wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind am ``` -DSG830 目前只声明上述精确 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `enable-output-am`。`enable-output-fm`、`enable-output-pm` 会因 profile 不匹配而在仪器 I/O 前拒绝。成功的特殊 ON 不会自动 RF OFF 或关闭调制;结束时必须显式执行普通 `output off` 和按模式 `disable`。不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 +DSG830 目前只声明三个精确 profile:AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`,最大功率均为 `-50 dBm`。例如 FM/PM 必须先在 RF OFF 下配置,再使用对应特殊入口: + +```bash +wavebench rf-source modulation configure-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 20000 --internal-frequency-hz 1000 +wavebench rf-source modulation enable-output-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 20000 --internal-frequency-hz 1000 +wavebench rf-source output --config wavebench.toml --port rf_out off +wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind fm +``` + +PM 使用 `configure-pm`/`enable-output-pm` 与 `--phase-deviation-rad 1.25`,其余字段和清理顺序相同。成功的特殊 ON 不会自动 RF OFF 或关闭调制;结束时必须显式执行普通 `output off` 和按模式 `disable`。超出三个精确 profile 的请求会在仪器 I/O 前拒绝;不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 ## A5:后面板 Pulse Output(受限生产操作) @@ -201,7 +210,7 @@ port_id = "rf_out" modulation_kind = "am" ``` -`rf_source.modulated_output_enable` 同样已进入 production schema。它使用与 `rf_source.modulation_configure` 相同的 `port_id`、`modulation_kind`、内部频率和对应数值字段;不能把配置步骤和输出步骤合并,也不能假定 run plan 会在成功后自动关闭 RF 或调制。DSG830 的 production descriptor 只接受 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 profile。 +`rf_source.modulated_output_enable` 同样已进入 production schema。它使用与 `rf_source.modulation_configure` 相同的 `port_id`、`modulation_kind`、内部频率和对应数值字段;不能把配置步骤和输出步骤合并,也不能假定 run plan 会在成功后自动关闭 RF 或调制。DSG830 的 production descriptor 只接受 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz` 三个精确 profile,最大功率均为 `-50 dBm`。 先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 @@ -258,7 +267,7 @@ rf_source.modulated_output_enable 它们复用 M3 的 `modulation_kind`、数值字段和内部频率字段,但不会配置调制:调用前目标 profile 必须已经完整激活并与 request 精确一致。Core 还要求 RF 当前为 OFF、Pulse/Sweep disabled、protection 清晰、端口 safety 配置完整、实际端接与 dBm 参考阻抗一致,以及特殊 `RfModulatedOutputProfile` 明确允许这一 profile 与功率。成功路径只启用一次 RF;不会自动 RF OFF 或关闭调制。任何写入或 readback 不确定时不重试 ON,只可能执行一次受 guard 的 RF OFF recovery。 -DSG830 production descriptor 仅声明 AM `50 %`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`。A4-MO 使用同一固定 profile、RF `1 MHz`/`-50 dBm` 完成一次受控循环:CH2 显式为 50 Ω,scope 只观察当前 `DEF` 缓冲区是否有可见信号,随后工具明确 RF OFF、关闭 AM 和全局调制并复核最终状态。CH1 的低频输出独立于 RF 调制路径,不被读取或当作证据;scope 也不用于推断 dBm、频率或调制深度。历史 harness 在 capability 提升后拒绝重跑,日常操作只能使用 production descriptor、`read_write`、完整 safety 配置和显式清理步骤。 +DSG830 production descriptor 声明 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`。A4-MO 的三条固定 profile 均使用 RF `1 MHz`/`-50 dBm` 完成受控循环:CH2 显式为 50 Ω,scope 观察当前 `DEF` 缓冲区的信号存在;FM/PM 额外保存 WaveBench 波形摘要和 FFT 质量记录。CH1 的低频输出独立于 RF 调制路径,不被读取或当作证据;scope 不用于推断 dBm、频偏、相偏、调制准确度或频谱合规性。历史 harness 在 capability 提升后拒绝重跑,日常操作只能使用 production descriptor、`read_write`、完整 safety 配置和显式清理步骤。 ## M4:受控 Pulse 与 Step Sweep 配置合同 @@ -292,6 +301,6 @@ DSG830 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与无资源 setup 2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 -5. 日常 M3/M4/A5 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、未声明的后面板接口、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;A5 只使用已声明的 `pulse_in_out` output 路径;M3-MO 只能使用 DSG830 已声明的固定 AM profile,并在结束时显式 RF OFF 与按模式关闭调制。 +5. 日常 M3/M4/A5 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、未声明的后面板接口、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;A5 只使用已声明的 `pulse_in_out` output 路径;M3-MO 只能使用 DSG830 已声明的固定 AM/FM/PM profile,并在结束时显式 RF OFF 与按模式关闭调制。 需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 From 9635ba18f1f5ed3c3c94f3e4c43f12a3d5fff5cc Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:14:33 +0800 Subject: [PATCH 62/63] test: remove trailing whitespace from RF pulse service test --- tests/test_rf_source_pulse_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_rf_source_pulse_service.py b/tests/test_rf_source_pulse_service.py index f326163..abab9ca 100644 --- a/tests/test_rf_source_pulse_service.py +++ b/tests/test_rf_source_pulse_service.py @@ -344,4 +344,3 @@ def test_pulse_configuration_write_failure_is_not_retried_and_degrades_session() assert driver.calls == ["snapshot", "configure_pulse"] assert service.session_state is not None assert service.session_state.health is SessionHealth.UNCERTAIN - From c62c26a96a049c2466d39526e4e558a271fbdce3 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:29:50 +0800 Subject: [PATCH 63/63] test: avoid payload assertion collision with temp paths --- tests/test_run_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_run_service.py b/tests/test_run_service.py index 2cd3109..56c86e5 100644 --- a/tests/test_run_service.py +++ b/tests/test_run_service.py @@ -2443,7 +2443,7 @@ def test_frequency_response_applies_referenced_software_baseline_without_rewriti def test_runs_source_v2_arbitrary_steps_without_putting_payload_in_artifacts(self): with TemporaryDirectory() as tmp: - payload = b"abc" + payload = b"wavebench-payload-artifact-sentinel-9d7cc41e" payload_path = Path(tmp) / "payload.bin" payload_path.write_bytes(payload) digest = "sha256:" + sha256(payload).hexdigest() @@ -2512,7 +2512,7 @@ def _run_safety_guards(self, plan, *, services=None): self.assertEqual(select_request.playback_mode.value, "dds") self.assertEqual(select_request.playback_frequency_hz, 1_000.0) self.assertEqual(run_data["source_operations"], artifacts) - self.assertNotIn("abc", json.dumps(run_data, ensure_ascii=False)) + self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False)) if __name__ == "__main__":