From 346fff21a3fa9f8f6de4a606572d91a903aebfd3 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:20:06 +0800
Subject: [PATCH 01/44] refactor: deduplicate config replacements
---
src/wavebench/config.py | 195 +++++-----------------------------------
1 file changed, 20 insertions(+), 175 deletions(-)
diff --git a/src/wavebench/config.py b/src/wavebench/config.py
index 9dae508..3b3ef8e 100644
--- a/src/wavebench/config.py
+++ b/src/wavebench/config.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from math import isfinite
from pathlib import Path
import re
@@ -384,52 +384,13 @@ class WaveBenchConfig:
def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig":
if timeout_ms <= 0:
raise ConfigError("connection timeout must be > 0")
- return WaveBenchConfig(
- connection=ConnectionConfig(
- backend=self.connection.backend,
- resource=self.connection.resource,
- timeout_ms=timeout_ms,
- opc_timeout_ms=self.connection.opc_timeout_ms,
- read_retry_attempts=self.connection.read_retry_attempts,
- read_retry_delay_ms=self.connection.read_retry_delay_ms,
- ),
- 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=self.rf_source,
+ return replace(
+ self,
+ connection=replace(self.connection, timeout_ms=timeout_ms),
)
def with_resource(self, resource: str) -> "WaveBenchConfig":
- return WaveBenchConfig(
- connection=ConnectionConfig(
- backend=self.connection.backend,
- resource=resource,
- timeout_ms=self.connection.timeout_ms,
- opc_timeout_ms=self.connection.opc_timeout_ms,
- read_retry_attempts=self.connection.read_retry_attempts,
- read_retry_delay_ms=self.connection.read_retry_delay_ms,
- ),
- 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=self.rf_source,
- )
+ return replace(self, connection=replace(self.connection, resource=resource))
def with_output_overrides(
self,
@@ -439,28 +400,15 @@ def with_output_overrides(
save_json: bool | None = None,
save_screenshot: bool | None = None,
) -> "WaveBenchConfig":
- return WaveBenchConfig(
- connection=self.connection,
- scope=self.scope,
- autoscale=self.autoscale,
- waveform=self.waveform,
- output=OutputConfig(
- directory=self.output.directory,
- package_naming=self.output.package_naming,
+ return replace(
+ self,
+ output=replace(
+ self.output,
save_csv=self.output.save_csv if save_csv is None else save_csv,
save_npy=self.output.save_npy if save_npy is None else save_npy,
save_json=self.output.save_json if save_json is None else save_json,
- save_commands_log=self.output.save_commands_log,
save_screenshot=self.output.save_screenshot if save_screenshot is None else save_screenshot,
),
- 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=self.rf_source,
)
def with_waveform_overrides(
@@ -476,13 +424,10 @@ def with_waveform_overrides(
target_vpp: float | None = None,
min_signal_vpp: float | None = None,
) -> "WaveBenchConfig":
- return WaveBenchConfig(
- connection=self.connection,
- scope=self.scope,
- autoscale=self.autoscale,
- waveform=WaveformConfig(
- format=self.waveform.format,
- byte_order=self.waveform.byte_order,
+ return replace(
+ self,
+ waveform=replace(
+ self.waveform,
points=self.waveform.points if points is None else normalize_waveform_points(points),
time_range_s=self.waveform.time_range_s if time_range_s is None else time_range_s,
expected_frequency_hz=(
@@ -503,15 +448,6 @@ def with_waveform_overrides(
target_vpp=self.waveform.target_vpp if target_vpp is None else target_vpp,
min_signal_vpp=self.waveform.min_signal_vpp if min_signal_vpp is None else min_signal_vpp,
),
- 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=self.rf_source,
)
def with_source_resource(self, resource: str) -> "WaveBenchConfig":
@@ -523,31 +459,7 @@ def with_source_resource(self, resource: str) -> "WaveBenchConfig":
ensure_fix_mode_on_set_frequency=True,
settle_ms_after_set_frequency=0,
)
- return WaveBenchConfig(
- connection=self.connection,
- scope=self.scope,
- autoscale=self.autoscale,
- waveform=self.waveform,
- output=self.output,
- source_path=self.source_path,
- source=SourceConfig(
- driver=source.driver,
- resource=resource,
- default_channel=source.default_channel,
- check_errors=source.check_errors,
- ensure_fix_mode_on_set_frequency=source.ensure_fix_mode_on_set_frequency,
- settle_ms_after_set_frequency=source.settle_ms_after_set_frequency,
- options=source.options,
- access=source.access,
- terminations=source.terminations,
- ),
- power=self.power,
- dmm=self.dmm,
- quality=self.quality,
- safety_limits=self.safety_limits,
- tui=self.tui,
- rf_source=self.rf_source,
- )
+ return replace(self, source=replace(source, resource=resource))
def with_power_resource(self, resource: str) -> "WaveBenchConfig":
power = self.power or PowerConfig(
@@ -558,30 +470,7 @@ def with_power_resource(self, resource: str) -> "WaveBenchConfig":
settle_ms_after_set=2000,
settle_ms_after_output=1000,
)
- 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=PowerConfig(
- driver=power.driver,
- resource=resource,
- default_channel=power.default_channel,
- check_errors=power.check_errors,
- settle_ms_after_set=power.settle_ms_after_set,
- settle_ms_after_output=power.settle_ms_after_output,
- options=power.options,
- access=power.access,
- ),
- dmm=self.dmm,
- quality=self.quality,
- safety_limits=self.safety_limits,
- tui=self.tui,
- rf_source=self.rf_source,
- )
+ return replace(self, power=replace(power, resource=resource))
def with_dmm_resource(self, resource: str) -> "WaveBenchConfig":
dmm = self.dmm or DmmConfig(
@@ -597,38 +486,14 @@ def with_dmm_resource(self, resource: str) -> "WaveBenchConfig":
settle_ms_after_function_change=500,
)
is_tcpip = resource.upper().startswith("TCPIP")
- 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=DmmConfig(
+ return replace(
+ self,
+ dmm=replace(
+ dmm,
driver="dm3058" if is_tcpip else dmm.driver,
resource=resource,
backend="lan" if is_tcpip else dmm.backend,
- baudrate=dmm.baudrate,
- bytesize=dmm.bytesize,
- parity=dmm.parity,
- stopbits=dmm.stopbits,
- timeout_ms=dmm.timeout_ms,
- settle_ms_before_read=dmm.settle_ms_before_read,
- settle_ms_after_function_change=dmm.settle_ms_after_function_change,
- options=dmm.options,
- write_termination=dmm.write_termination,
- read_termination=dmm.read_termination,
- xonxoff=dmm.xonxoff,
- rtscts=dmm.rtscts,
- dsrdtr=dmm.dsrdtr,
- access=dmm.access,
),
- 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":
@@ -636,27 +501,7 @@ def with_rf_source_resource(self, resource: str) -> "WaveBenchConfig":
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,
- ),
- )
+ return replace(self, rf_source=replace(rf_source, resource=resource))
def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig:
config_path = Path(path)
From 653f5e7546ac532657a3e61b69aaac644811768f Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:23:45 +0800
Subject: [PATCH 02/44] refactor: share scope JSON serialization
---
.../services/scope_average_capture_executor.py | 17 +++--------------
.../services/scope_waveform_executor.py | 16 ++--------------
2 files changed, 5 insertions(+), 28 deletions(-)
diff --git a/src/wavebench/services/scope_average_capture_executor.py b/src/wavebench/services/scope_average_capture_executor.py
index d0f7092..de7a551 100644
--- a/src/wavebench/services/scope_average_capture_executor.py
+++ b/src/wavebench/services/scope_average_capture_executor.py
@@ -2,10 +2,10 @@
from __future__ import annotations
-from dataclasses import asdict, dataclass, is_dataclass
+from dataclasses import dataclass
from hashlib import sha256
from types import MappingProxyType
-from typing import Any, Mapping
+from typing import Mapping
from uuid import uuid4
from wavebench.config import normalize_waveform_points
@@ -36,6 +36,7 @@
from .operation_specs import require_operation_spec
from .scope_error_policy import ScopeErrorPolicyExecutor
+from .scope_extension_service import _json_safe
from .scope_phase_coordinator import (
OperationPhase,
ScopeBaselineHandle,
@@ -54,18 +55,6 @@
}
-def _json_safe(value: Any) -> Any:
- if is_dataclass(value):
- return _json_safe(asdict(value))
- if isinstance(value, Mapping):
- return {str(key): _json_safe(item) for key, item in value.items()}
- if isinstance(value, (tuple, list)):
- return [_json_safe(item) for item in value]
- if isinstance(value, (str, int, float, bool, type(None))):
- return value
- return str(value)
-
-
@dataclass(frozen=True, slots=True)
class AverageCaptureV2ExecutionResult:
"""Private handoff preserving the public V2 result's stable shape."""
diff --git a/src/wavebench/services/scope_waveform_executor.py b/src/wavebench/services/scope_waveform_executor.py
index 55379c2..655c344 100644
--- a/src/wavebench/services/scope_waveform_executor.py
+++ b/src/wavebench/services/scope_waveform_executor.py
@@ -3,10 +3,9 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
-from dataclasses import asdict, dataclass, is_dataclass, replace
+from dataclasses import dataclass, replace
from hashlib import sha256
from types import MappingProxyType
-from typing import Any
import numpy as np
@@ -36,6 +35,7 @@
from .operation_specs import OperationSpec, require_operation_spec
from .scope_error_policy import ScopeErrorPolicyExecutor
+from .scope_extension_service import _json_safe
from .scope_phase_coordinator import (
OperationPhase,
ScopeBaselineHandle,
@@ -55,18 +55,6 @@
_WaveformCallbackEvidence = tuple[object, str, tuple[int, ...], bytes]
-def _json_safe(value: Any) -> Any:
- if is_dataclass(value):
- return _json_safe(asdict(value))
- if isinstance(value, Mapping):
- return {str(key): _json_safe(item) for key, item in value.items()}
- if isinstance(value, (tuple, list)):
- return [_json_safe(item) for item in value]
- if isinstance(value, (str, int, float, bool, type(None))):
- return value
- return str(value)
-
-
@dataclass(frozen=True, slots=True)
class BoundedWaveformExecutionResult:
"""Private handoff preserving the stable public waveform return values."""
From d7baf1c00b6d6cc4855d8a2b97b6b45624207214 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:27:19 +0800
Subject: [PATCH 03/44] refactor: share TUI command log formatting
---
src/wavebench/tui/dmm.py | 6 +-----
src/wavebench/tui/power.py | 6 +-----
src/wavebench/tui/source.py | 6 +-----
src/wavebench/tui/state.py | 5 +++++
4 files changed, 8 insertions(+), 15 deletions(-)
diff --git a/src/wavebench/tui/dmm.py b/src/wavebench/tui/dmm.py
index 2b42c80..365c280 100644
--- a/src/wavebench/tui/dmm.py
+++ b/src/wavebench/tui/dmm.py
@@ -11,7 +11,7 @@
from wavebench.instruments.models import DmmReading
from wavebench.logging import CommandLogger
from wavebench.services.dmm_service import DmmService
-from wavebench.tui.state import DmmPanelState, dmm_state_from_reading
+from wavebench.tui.state import DmmPanelState, _logger_lines, dmm_state_from_reading
class DmmPanelAdapter(Protocol):
@@ -193,7 +193,3 @@ def build_dmm_panel_state(
reading=reading,
log_lines=log_lines,
)
-
-
-def _logger_lines(logger: CommandLogger) -> list[str]:
- return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]]
diff --git a/src/wavebench/tui/power.py b/src/wavebench/tui/power.py
index f1fc523..754e70e 100644
--- a/src/wavebench/tui/power.py
+++ b/src/wavebench/tui/power.py
@@ -10,7 +10,7 @@
from wavebench.instruments.models import PowerMeasurement, PowerProtectionStatus, PowerStatus
from wavebench.logging import CommandLogger
from wavebench.services.power_service import PowerService
-from wavebench.tui.state import PowerPanelState, channel_state_from_status, config_status
+from wavebench.tui.state import PowerPanelState, _logger_lines, channel_state_from_status, config_status
class PowerPanelAdapter(Protocol):
@@ -425,7 +425,3 @@ def build_power_panel_state(
),
log_lines=tuple(log_lines),
)
-
-
-def _logger_lines(logger: CommandLogger) -> list[str]:
- return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]]
diff --git a/src/wavebench/tui/source.py b/src/wavebench/tui/source.py
index d187bd1..2edd50e 100644
--- a/src/wavebench/tui/source.py
+++ b/src/wavebench/tui/source.py
@@ -8,7 +8,7 @@
from wavebench.instruments.models import SourceStatus
from wavebench.logging import CommandLogger
from wavebench.services.source_service import SourceService
-from wavebench.tui.state import SourcePanelState, source_state_from_status
+from wavebench.tui.state import SourcePanelState, _logger_lines, source_state_from_status
class SourcePanelAdapter(Protocol):
@@ -169,7 +169,3 @@ def build_source_panel_state(
status=status,
log_lines=log_lines,
)
-
-
-def _logger_lines(logger: CommandLogger) -> list[str]:
- return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]]
diff --git a/src/wavebench/tui/state.py b/src/wavebench/tui/state.py
index bb40b51..04f5ee7 100644
--- a/src/wavebench/tui/state.py
+++ b/src/wavebench/tui/state.py
@@ -4,6 +4,7 @@
from wavebench.config import WaveBenchConfig
from wavebench.instruments.models import DmmReading, PowerProtectionStatus, PowerStatus, SourceStatus
+from wavebench.logging import CommandLogger
@dataclass(frozen=True)
@@ -77,6 +78,10 @@ class SourcePanelState:
)
+def _logger_lines(logger: CommandLogger) -> list[str]:
+ return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]]
+
+
def format_optional_number(value: float | None, unit: str = "", digits: int = 6) -> str:
if value is None:
return "未知 / N/A"
From fa924bf47897939bd232385af49d3aeb0e12eadb Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:31:12 +0800
Subject: [PATCH 04/44] refactor: call report artifact URLs directly
---
src/wavebench/report/html.py | 60 ++++++++++++++++-------------------
src/wavebench/report/index.py | 6 +---
2 files changed, 29 insertions(+), 37 deletions(-)
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 8021b81..0023b1d 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -29,7 +29,7 @@ def write_run_report_html(run: RunPackage, output_path: str | Path | None = None
for response in run.frequency_responses
)
plotly_asset = write_plotly_asset(path.parent) if has_surface else None
- plotly_url = _relative_url(plotly_asset, path.parent) if plotly_asset is not None else None
+ plotly_url = artifact_url(plotly_asset, path.parent) if plotly_asset is not None else None
path.write_text(
render_run_report_html(run, output_dir=path.parent, plotly_url=plotly_url), encoding="utf-8"
)
@@ -427,7 +427,7 @@ def _build_report_manifest(
package_record = {
"step_index": step_index,
"package": package,
- "path": _relative_url(package_dir, output_dir),
+ "path": artifact_url(package_dir, output_dir),
"exists": package_dir.exists(),
}
capture_packages.append(package_record)
@@ -438,7 +438,7 @@ def _build_report_manifest(
if npy_text:
npy_path = _resolve_capture_file_path(run.path, package_dir, str(npy_text))
npy_exists = npy_path.exists()
- source_npy = _relative_url(npy_path, output_dir)
+ source_npy = artifact_url(npy_path, output_dir)
if not npy_exists:
warnings.append(f"step {step_index} ch{channel}: waveform npy missing: {npy_text}")
else:
@@ -464,7 +464,7 @@ def _build_report_manifest(
{
"step_index": reference.step_index,
"package": reference.package,
- "path": _relative_url(package_dir, output_dir),
+ "path": artifact_url(package_dir, output_dir),
"exists": package_dir.exists(),
}
)
@@ -474,21 +474,21 @@ def _build_report_manifest(
)
return {
"schema": "wavebench.report_manifest.v1",
- "report": _relative_url(report_path, output_dir),
- "run_json": _relative_url(run.run_json_path, output_dir),
- "summary_csv": _relative_url(run.summary_csv_path, output_dir) if run.summary_csv_path is not None else None,
- "frequency_response_csv": _relative_url(run.frequency_response_csv_path, output_dir)
+ "report": artifact_url(report_path, output_dir),
+ "run_json": artifact_url(run.run_json_path, output_dir),
+ "summary_csv": artifact_url(run.summary_csv_path, output_dir) if run.summary_csv_path is not None else None,
+ "frequency_response_csv": artifact_url(run.frequency_response_csv_path, output_dir)
if run.frequency_response_csv_path is not None
else None,
- "frequency_response_fit_json": _relative_url(run.frequency_response_fit_path, output_dir)
+ "frequency_response_fit_json": artifact_url(run.frequency_response_fit_path, output_dir)
if run.frequency_response_fit_path is not None
else None,
- "frequency_response_calibration_csv": _relative_url(
+ "frequency_response_calibration_csv": artifact_url(
run.frequency_response_calibration_csv_path, output_dir
)
if run.frequency_response_calibration_csv_path is not None
else None,
- "frequency_response_calibration_json": _relative_url(
+ "frequency_response_calibration_json": artifact_url(
run.frequency_response_calibration_path, output_dir
)
if run.frequency_response_calibration_path is not None
@@ -496,14 +496,14 @@ def _build_report_manifest(
"frequency_responses": [
{
"label": response.label,
- "directory": _relative_url(response.directory, output_dir),
- "csv": _relative_url(response.csv_path, output_dir)
+ "directory": artifact_url(response.directory, output_dir),
+ "csv": artifact_url(response.csv_path, output_dir)
if response.csv_path is not None
else None,
- "baseline_json": _relative_url(response.baseline_path, output_dir)
+ "baseline_json": artifact_url(response.baseline_path, output_dir)
if response.baseline_path is not None
else None,
- "calibration_json": _relative_url(response.calibration_path, output_dir)
+ "calibration_json": artifact_url(response.calibration_path, output_dir)
if response.calibration_path is not None
else None,
"status": response.manifest_entry.get("status"),
@@ -517,7 +517,7 @@ def _build_report_manifest(
{
"step_index": item.step_index,
"package": item.package,
- "path": _relative_url(item.path, output_dir),
+ "path": artifact_url(item.path, output_dir),
}
for item in screenshots
],
@@ -526,7 +526,7 @@ def _build_report_manifest(
[
{
"kind": "plotly.js",
- "path": _relative_url(interactive_asset_path, output_dir),
+ "path": artifact_url(interactive_asset_path, output_dir),
"exists": interactive_asset_path.exists(),
}
]
@@ -1970,7 +1970,7 @@ def _collect_artifact_links(
step_index="-",
kind="运行记录 / Run JSON",
label="run.json",
- href=_relative_url(run.run_json_path, output_dir),
+ href=artifact_url(run.run_json_path, output_dir),
status=_availability_text(run.run_json_path.exists()),
)
]
@@ -1980,7 +1980,7 @@ def _collect_artifact_links(
step_index="-",
kind="摘要 CSV / Summary CSV",
label="summary.csv",
- href=_relative_url(run.summary_csv_path, output_dir),
+ href=artifact_url(run.summary_csv_path, output_dir),
status=_availability_text(run.summary_csv_path.exists()),
)
)
@@ -1990,7 +1990,7 @@ def _collect_artifact_links(
step_index="-",
kind="频率响应 CSV / Frequency response CSV",
label="frequency_response.csv",
- href=_relative_url(run.frequency_response_csv_path, output_dir),
+ href=artifact_url(run.frequency_response_csv_path, output_dir),
status=_availability_text(run.frequency_response_csv_path.exists()),
)
)
@@ -2000,7 +2000,7 @@ def _collect_artifact_links(
step_index="-",
kind="频响拟合 JSON / Frequency response fit JSON",
label="frequency_response_fit.json",
- href=_relative_url(run.frequency_response_fit_path, output_dir),
+ href=artifact_url(run.frequency_response_fit_path, output_dir),
status=_availability_text(run.frequency_response_fit_path.exists()),
)
)
@@ -2010,7 +2010,7 @@ def _collect_artifact_links(
step_index="-",
kind="二维校准 CSV / 2D calibration CSV",
label="frequency_response_calibration.csv",
- href=_relative_url(run.frequency_response_calibration_csv_path, output_dir),
+ href=artifact_url(run.frequency_response_calibration_csv_path, output_dir),
status=_availability_text(run.frequency_response_calibration_csv_path.exists()),
)
)
@@ -2020,7 +2020,7 @@ def _collect_artifact_links(
step_index="-",
kind="二维校准 JSON / 2D calibration JSON",
label="frequency_response_calibration.json",
- href=_relative_url(run.frequency_response_calibration_path, output_dir),
+ href=artifact_url(run.frequency_response_calibration_path, output_dir),
status=_availability_text(run.frequency_response_calibration_path.exists()),
)
)
@@ -2032,7 +2032,7 @@ def _collect_artifact_links(
step_index=str(response.step_index) if response.step_index is not None else "-",
kind=f"{prefix}软件基线 JSON / Software baseline JSON",
label="frequency_response_baseline.json",
- href=_relative_url(response.baseline_path, output_dir),
+ href=artifact_url(response.baseline_path, output_dir),
status=_availability_text(response.baseline_path.exists()),
)
)
@@ -2048,7 +2048,7 @@ def _collect_artifact_links(
step_index=str(response.step_index) if response.step_index is not None else "-",
kind=f"{prefix}定点 {name.upper()} / Fixed-point {name.upper()}",
label=path.name,
- href=_relative_url(path, output_dir),
+ href=artifact_url(path, output_dir),
status=_availability_text(path.exists()),
)
)
@@ -2060,7 +2060,7 @@ def _collect_artifact_links(
step_index=reference.step_index,
kind="采集包 / Capture package",
label=reference.package,
- href=_relative_url(package_dir, output_dir),
+ href=artifact_url(package_dir, output_dir),
status=_availability_text(package_dir.exists()),
)
)
@@ -2088,7 +2088,7 @@ def _collect_artifact_links(
step_index=reference.step_index,
kind="波形原始数据 / Waveform raw artifact",
label=f"ch{channel} {npy_name}",
- href=_relative_url(npy_path, output_dir),
+ href=artifact_url(npy_path, output_dir),
status=_availability_text(True),
)
)
@@ -2380,7 +2380,7 @@ def _collect_screenshots(run: RunPackage, output_dir: Path) -> list[ReportScreen
step_index=reference.step_index,
package=reference.package,
path=screenshot_path,
- src=_relative_url(screenshot_path, output_dir),
+ src=artifact_url(screenshot_path, output_dir),
)
)
return screenshots
@@ -2490,7 +2490,3 @@ def _project_root_from_run_path(run_path: Path) -> Path:
if len(parts) >= 3 and parts[-3:-1] == ("data", "runs"):
return Path(*parts[:-3]) if len(parts[:-3]) > 0 else Path(".")
return run_path.parent
-
-
-def _relative_url(path: Path, output_dir: Path) -> str:
- return artifact_url(path, output_dir)
diff --git a/src/wavebench/report/index.py b/src/wavebench/report/index.py
index df812e4..761c0cd 100644
--- a/src/wavebench/report/index.py
+++ b/src/wavebench/report/index.py
@@ -228,14 +228,10 @@ def _artifact(entry: dict[str, Any], key: str) -> str | None:
def _html_link(path_text: str | None, output_dir: Path, label: str) -> str:
if not path_text:
return f'{html.escape(label)}: missing'
- rel = _relative_path(Path(path_text), output_dir)
+ rel = artifact_url(Path(path_text), output_dir)
return f'{html.escape(label)}'
-def _relative_path(path: Path, output_dir: Path) -> str:
- return artifact_url(path, output_dir)
-
-
def _nested(obj: dict[str, Any], *keys: str) -> Any:
cur: Any = obj
for key in keys:
From 58daa144d32a6ea805207350f3104be31bb0164b Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:35:05 +0800
Subject: [PATCH 05/44] refactor: share run operation validation
---
src/wavebench/services/run_artifacts.py | 83 ++++++++++---------------
1 file changed, 34 insertions(+), 49 deletions(-)
diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py
index e60696d..acc7710 100644
--- a/src/wavebench/services/run_artifacts.py
+++ b/src/wavebench/services/run_artifacts.py
@@ -40,61 +40,34 @@ def write_step_record(steps_dir: Path, record: RunStepRecord) -> None:
)
-def _validated_source_operations(
- source_operations: list[dict[str, Any]] | None,
-) -> list[dict[str, Any]] | None:
- """Accept only real, schema-labelled Source V2 operation artifacts.
-
- The namespace stays absent for all V1 runs. Feature-specific V2 operation
- code owns the rest of each artifact's shape, but it cannot accidentally
- insert an arbitrary unlabelled dictionary at the run root.
- """
-
- if source_operations is None:
- return None
- if not isinstance(source_operations, list) or any(
- not isinstance(item, dict) for item in source_operations
- ):
- raise TypeError("source_operations must be a list of operation artifact objects")
- if not source_operations:
- return None
- for artifact in source_operations:
- if artifact.get("schema") != SOURCE_OPERATION_ARTIFACT_SCHEMA:
- raise ValueError("source operation artifact has an unsupported schema")
- operation = artifact.get("operation")
- if (
- not isinstance(operation, str)
- or not operation.startswith("source.")
- or operation.strip() != operation
- ):
- raise ValueError("source operation artifact must have a trimmed source.* operation")
- return source_operations
-
-
-def _validated_rf_source_operations(
- rf_source_operations: list[dict[str, Any]] | None,
+def _validated_operations(
+ operations: list[dict[str, Any]] | None,
+ *,
+ field: str,
+ label: str,
+ schema: str,
+ prefix: str,
) -> list[dict[str, Any]] | None:
- """Accept only schema-labelled RF-source operation artifacts."""
-
- if rf_source_operations is None:
+ """Accept only schema-labelled operation artifacts."""
+ if 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:
+ if not isinstance(operations, list) or any(not isinstance(item, dict) for item in operations):
+ raise TypeError(f"{field} must be a list of operation artifact objects")
+ if not 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")
+ for artifact in operations:
+ if artifact.get("schema") != schema:
+ raise ValueError(f"{label} operation artifact has an unsupported schema")
operation = artifact.get("operation")
if (
not isinstance(operation, str)
- or not operation.startswith("rf_source.")
+ or not operation.startswith(prefix)
or operation.strip() != operation
):
- raise ValueError("RF source operation artifact must have a trimmed rf_source.* operation")
- return rf_source_operations
+ raise ValueError(
+ f"{label} operation artifact must have a trimmed {prefix}* operation"
+ )
+ return operations
def write_run_files(
@@ -152,10 +125,22 @@ def write_run_files(
# Keep the V2 namespace absent until an actual Source V2 operation has a
# typed artifact to place in it. In particular, this must not alter the
# byte representation of existing V1 run artifacts.
- validated_source_operations = _validated_source_operations(source_operations)
+ validated_source_operations = _validated_operations(
+ source_operations,
+ field="source_operations",
+ label="source",
+ schema=SOURCE_OPERATION_ARTIFACT_SCHEMA,
+ prefix="source.",
+ )
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)
+ validated_rf_source_operations = _validated_operations(
+ rf_source_operations,
+ field="rf_source_operations",
+ label="RF source",
+ schema=RF_SOURCE_OPERATION_ARTIFACT_SCHEMA,
+ prefix="rf_source.",
+ )
if validated_rf_source_operations is not None:
run_data["rf_source_operations"] = validated_rf_source_operations
run_json_path.write_text(
From 69e17711a3251f220b4ce8dd0374b9231530d601 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:49:36 +0800
Subject: [PATCH 06/44] refactor: keep config import surface private
---
src/wavebench/config.py | 26 +++++++++++++-------------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/src/wavebench/config.py b/src/wavebench/config.py
index 3b3ef8e..9076ac5 100644
--- a/src/wavebench/config.py
+++ b/src/wavebench/config.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from dataclasses import dataclass, field, replace
+from dataclasses import dataclass, field, replace as _replace
from math import isfinite
from pathlib import Path
import re
@@ -384,13 +384,13 @@ class WaveBenchConfig:
def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig":
if timeout_ms <= 0:
raise ConfigError("connection timeout must be > 0")
- return replace(
+ return _replace(
self,
- connection=replace(self.connection, timeout_ms=timeout_ms),
+ connection=_replace(self.connection, timeout_ms=timeout_ms),
)
def with_resource(self, resource: str) -> "WaveBenchConfig":
- return replace(self, connection=replace(self.connection, resource=resource))
+ return _replace(self, connection=_replace(self.connection, resource=resource))
def with_output_overrides(
self,
@@ -400,9 +400,9 @@ def with_output_overrides(
save_json: bool | None = None,
save_screenshot: bool | None = None,
) -> "WaveBenchConfig":
- return replace(
+ return _replace(
self,
- output=replace(
+ output=_replace(
self.output,
save_csv=self.output.save_csv if save_csv is None else save_csv,
save_npy=self.output.save_npy if save_npy is None else save_npy,
@@ -424,9 +424,9 @@ def with_waveform_overrides(
target_vpp: float | None = None,
min_signal_vpp: float | None = None,
) -> "WaveBenchConfig":
- return replace(
+ return _replace(
self,
- waveform=replace(
+ waveform=_replace(
self.waveform,
points=self.waveform.points if points is None else normalize_waveform_points(points),
time_range_s=self.waveform.time_range_s if time_range_s is None else time_range_s,
@@ -459,7 +459,7 @@ def with_source_resource(self, resource: str) -> "WaveBenchConfig":
ensure_fix_mode_on_set_frequency=True,
settle_ms_after_set_frequency=0,
)
- return replace(self, source=replace(source, resource=resource))
+ return _replace(self, source=_replace(source, resource=resource))
def with_power_resource(self, resource: str) -> "WaveBenchConfig":
power = self.power or PowerConfig(
@@ -470,7 +470,7 @@ def with_power_resource(self, resource: str) -> "WaveBenchConfig":
settle_ms_after_set=2000,
settle_ms_after_output=1000,
)
- return replace(self, power=replace(power, resource=resource))
+ return _replace(self, power=_replace(power, resource=resource))
def with_dmm_resource(self, resource: str) -> "WaveBenchConfig":
dmm = self.dmm or DmmConfig(
@@ -486,9 +486,9 @@ def with_dmm_resource(self, resource: str) -> "WaveBenchConfig":
settle_ms_after_function_change=500,
)
is_tcpip = resource.upper().startswith("TCPIP")
- return replace(
+ return _replace(
self,
- dmm=replace(
+ dmm=_replace(
dmm,
driver="dm3058" if is_tcpip else dmm.driver,
resource=resource,
@@ -501,7 +501,7 @@ def with_rf_source_resource(self, resource: str) -> "WaveBenchConfig":
driver="rigol.dsg830",
resource=None,
)
- return replace(self, rf_source=replace(rf_source, resource=resource))
+ return _replace(self, rf_source=_replace(rf_source, resource=resource))
def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig:
config_path = Path(path)
From eb1d80a405766275f6c19f3dc16e421ab895d1a9 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:47:14 +0800
Subject: [PATCH 07/44] refactor: keep only execution intent aliases
---
src/wavebench/services/execution_intent.py | 33 ----------------------
1 file changed, 33 deletions(-)
diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py
index cad40b2..4bb07cf 100644
--- a/src/wavebench/services/execution_intent.py
+++ b/src/wavebench/services/execution_intent.py
@@ -20,48 +20,15 @@
_STEP_OPERATIONS = {
"scope.auto": "scope.autoscale",
- "scope.capture": "scope.capture",
"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.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",
- "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",
"source.set_vpp": "source.set_amplitude_vpp",
"source.set_duty": "source.set_square_duty_cycle",
- "source.output": "source.output",
- "source.basic_configure_v2": "source.basic_configure_v2",
- "source.output_enable_v2": "source.output_enable_v2",
- "source.output_disable_v2": "source.output_disable_v2",
- "source.harmonics_configure_v2": "source.harmonics_configure_v2",
- "source.harmonics_disable_v2": "source.harmonics_disable_v2",
- "source.modulation_configure_v2": "source.modulation_configure_v2",
- "source.modulation_pm_configure_v2": "source.modulation_pm_configure_v2",
- "source.modulation_fm_configure_v2": "source.modulation_fm_configure_v2",
- "source.modulation_pwm_configure_v2": "source.modulation_pwm_configure_v2",
- "source.sweep_configure_v2": "source.sweep_configure_v2",
- "source.burst_configure_v2": "source.burst_configure_v2",
- "source.pulse_configure_v2": "source.pulse_configure_v2",
- "source.arbitrary_storage_v2": "source.arbitrary_storage_v2",
- "source.arbitrary_select_v2": "source.arbitrary_select_v2",
- "source.combine_configure_v2": "source.combine_configure_v2",
- "source.coupling_configure_v2": "source.coupling_configure_v2",
- "source.tracking_configure_v2": "source.tracking_configure_v2",
- "source.phase_relation_configure_v2": "source.phase_relation_configure_v2",
- "power.status": "power.status",
"power.set": "power.set_voltage_current_limit",
- "power.output": "power.output",
- "dmm.read": "dmm.read",
"sleep": "run.sleep",
}
From fa49532a54e842d358a8893bc51b3447696f0ff0 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:47:38 +0800
Subject: [PATCH 08/44] refactor: remove unused binary validator wrapper
---
src/wavebench/instruments/factory.py | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/src/wavebench/instruments/factory.py b/src/wavebench/instruments/factory.py
index 4117db3..6cd26a2 100644
--- a/src/wavebench/instruments/factory.py
+++ b/src/wavebench/instruments/factory.py
@@ -342,13 +342,3 @@ def _validate_bounded_binary_transport(
f"instrument driver {descriptor.driver_id!r} bounded binary operation requires "
"a bounded PyVISA or RsInstrument INSTR resource"
)
-
-
-def _validate_waveform_binary_transport(
- *,
- descriptor: InstrumentDescriptor,
- transport: GuardedAuditedTransport,
-) -> None:
- """Compatibility wrapper for the former waveform-specific internal validator."""
-
- _validate_bounded_binary_transport(descriptor=descriptor, transport=transport)
From d17d44d6d992d2b5b3b508c785d09c58b3934f33 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:48:16 +0800
Subject: [PATCH 09/44] refactor: remove bounded waveform marker aliases
---
src/wavebench/transport/guarded.py | 10 ----------
tests/test_scope_waveform_executor.py | 13 -------------
tests/test_waveform_binary_factory.py | 1 -
3 files changed, 24 deletions(-)
diff --git a/src/wavebench/transport/guarded.py b/src/wavebench/transport/guarded.py
index 4b5cb88..f37d54d 100644
--- a/src/wavebench/transport/guarded.py
+++ b/src/wavebench/transport/guarded.py
@@ -441,16 +441,6 @@ def _has_verified_bounded_binary_backend(self) -> bool:
and self.session_state.health is SessionHealth.HEALTHY
)
- def _mark_bounded_waveform_backend_verified(self) -> None:
- """Compatibility alias for the former waveform-specific internal marker."""
-
- self._mark_bounded_binary_backend_verified()
-
- def _has_verified_bounded_waveform_backend(self) -> bool:
- """Compatibility alias for the former waveform-specific internal predicate."""
-
- return self._has_verified_bounded_binary_backend()
-
def _check_access(self, operation: str, *, write: bool = False) -> None:
if write and self.access != "read_write":
if operation == "write":
diff --git a/tests/test_scope_waveform_executor.py b/tests/test_scope_waveform_executor.py
index 786f674..0bd523e 100644
--- a/tests/test_scope_waveform_executor.py
+++ b/tests/test_scope_waveform_executor.py
@@ -182,19 +182,6 @@ def _bounded_transport(
return transport
-def test_waveform_backend_marker_compatibility_aliases_share_generic_state() -> None:
- transport = GuardedAuditedTransport(
- _Backend(),
- session_state=InstrumentSessionState(epoch_id="marker-alias"),
- )
-
- assert not transport._has_verified_bounded_binary_backend()
- assert not transport._has_verified_bounded_waveform_backend()
- transport._mark_bounded_waveform_backend_verified()
- assert transport._has_verified_bounded_binary_backend()
- assert transport._has_verified_bounded_waveform_backend()
-
-
class _Driver:
def __init__(self, transport: GuardedAuditedTransport) -> None:
self.transport = transport
diff --git a/tests/test_waveform_binary_factory.py b/tests/test_waveform_binary_factory.py
index 4b21003..d247de7 100644
--- a/tests/test_waveform_binary_factory.py
+++ b/tests/test_waveform_binary_factory.py
@@ -186,7 +186,6 @@ def factory(context):
assert inner.queries == []
assert inner.writes == []
assert opened.transport._has_verified_bounded_binary_backend()
- assert opened.transport._has_verified_bounded_waveform_backend()
assert opened.transport.query("*IDN?") == "ok"
assert inner.queries == ["*IDN?"]
From 7b231e94b00304509eb3ef9a61ce541798e32053 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:48:57 +0800
Subject: [PATCH 10/44] refactor: remove unused TUI DMM state cache
---
src/wavebench/tui/app.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/wavebench/tui/app.py b/src/wavebench/tui/app.py
index 729531e..b5c17e2 100644
--- a/src/wavebench/tui/app.py
+++ b/src/wavebench/tui/app.py
@@ -200,7 +200,6 @@ def __init__(
self._source_read_in_flight = False
self._source_write_in_flight = False
self._last_state: PowerPanelState | None = None
- self._last_dmm_state: DmmPanelState | None = None
self._last_source_state: SourcePanelState | None = None
self._power_log_lines: tuple[str, ...] = ()
self._dmm_log_lines: tuple[str, ...] = ()
@@ -812,7 +811,6 @@ def _render_state(self, state: PowerPanelState) -> None:
self._render_log()
def _render_dmm_state(self, state: DmmPanelState) -> None:
- self._last_dmm_state = state
status = self.query_one("#dmm-status", Static)
status.update(
f"万用表 / DMM\n"
From bbd97e9ac046f818e306069921ddb627872a94a3 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:51:21 +0800
Subject: [PATCH 11/44] refactor: share scope CLI value formatters
---
src/wavebench/cli_output.py | 90 +++++++++++++++++--------------------
1 file changed, 40 insertions(+), 50 deletions(-)
diff --git a/src/wavebench/cli_output.py b/src/wavebench/cli_output.py
index 38cc75d..ab7c920 100644
--- a/src/wavebench/cli_output.py
+++ b/src/wavebench/cli_output.py
@@ -388,6 +388,20 @@ def _print_dmm_dcv_impedance_configuration(
print(f"changed={'true' if result.changed else 'false'}")
+def _scalar(value: object) -> str:
+ if value is None:
+ return "n/a"
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, float):
+ return f"{value:.12g}"
+ return str(value)
+
+
+def _number(value: float | None) -> str:
+ return "n/a" if value is None else f"{value:.12g}"
+
+
def _print_scope_snapshot(snapshot: ScopeSnapshot | ScopeStatusSummary) -> None:
if isinstance(snapshot, ScopeStatusSummary):
print(f"status={snapshot.status}")
@@ -402,15 +416,6 @@ def _print_scope_snapshot(snapshot: ScopeSnapshot | ScopeStatusSummary) -> None:
return
snapshot = snapshot.snapshot
- def scalar(value: object) -> str:
- if value is None:
- return "n/a"
- if isinstance(value, bool):
- return "true" if value else "false"
- if isinstance(value, float):
- return f"{value:.12g}"
- return str(value)
-
sections = (
("identity", snapshot.identity),
("health", snapshot.health),
@@ -425,7 +430,7 @@ def scalar(value: object) -> str:
if isinstance(value, tuple):
print(f"{section_name}.{name}=" + ",".join(str(item) for item in value))
else:
- print(f"{section_name}.{name}={scalar(value)}")
+ print(f"{section_name}.{name}={_scalar(value)}")
def _print_scope_acquisition_status(status: ScopeAcquisitionStatus) -> None:
@@ -491,39 +496,30 @@ def _print_scope_digital_status(status: ScopeDigitalChannelStatus) -> None:
def _print_scope_digital_status_v2(status: ScopeDigitalChannelStatusV2) -> None:
- def scalar(value: object) -> str:
- if value is None:
- return "n/a"
- if isinstance(value, bool):
- return "true" if value else "false"
- if isinstance(value, float):
- return f"{value:.12g}"
- return str(value)
-
print(f"digital_v2.channel={status.channel}")
- print(f"digital_v2.displayed={scalar(status.displayed)}")
- print(f"digital_v2.position_div={scalar(status.position_div)}")
- print(f"digital_v2.label={scalar(status.label)}")
- print(f"digital_v2.label_enabled={scalar(status.label_enabled)}")
- print(f"digital_v2.activity={scalar(status.activity)}")
- print(f"digital_v2.technology={scalar(status.technology)}")
- print(f"digital_v2.hysteresis={scalar(status.hysteresis)}")
+ print(f"digital_v2.displayed={_scalar(status.displayed)}")
+ print(f"digital_v2.position_div={_scalar(status.position_div)}")
+ print(f"digital_v2.label={_scalar(status.label)}")
+ print(f"digital_v2.label_enabled={_scalar(status.label_enabled)}")
+ print(f"digital_v2.activity={_scalar(status.activity)}")
+ print(f"digital_v2.technology={_scalar(status.technology)}")
+ print(f"digital_v2.hysteresis={_scalar(status.hysteresis)}")
if status.pod is None:
print("digital_v2.pod=n/a")
else:
print(f"digital_v2.pod.start_channel={status.pod.start_channel}")
print(f"digital_v2.pod.stop_channel={status.pod.stop_channel}")
- print(f"digital_v2.pod.threshold_v={scalar(status.pod.threshold_v)}")
- print(f"digital_v2.pod.threshold_scope={scalar(status.pod.threshold_scope)}")
+ print(f"digital_v2.pod.threshold_v={_scalar(status.pod.threshold_v)}")
+ print(f"digital_v2.pod.threshold_scope={_scalar(status.pod.threshold_scope)}")
if status.shared is None:
print("digital_v2.shared=n/a")
else:
- print(f"digital_v2.shared.module_present={scalar(status.shared.module_present)}")
+ print(f"digital_v2.shared.module_present={_scalar(status.shared.module_present)}")
print(
"digital_v2.shared.timing_calibration_s="
- + scalar(status.shared.timing_calibration_s)
+ + _scalar(status.shared.timing_calibration_s)
)
- print(f"digital_v2.shared.size={scalar(status.shared.size)}")
+ print(f"digital_v2.shared.size={_scalar(status.shared.size)}")
print(
"digital_v2.unavailable_fields="
+ (",".join(status.unavailable_fields) or "none")
@@ -546,21 +542,18 @@ def _print_scope_digital_waveform(
def _print_scope_measurement_statistics(stats: ScopeMeasurementStatistics) -> None:
- def number(value: float | None) -> str:
- return "n/a" if value is None else f"{value:.12g}"
-
print(f"measurement.slot={stats.slot}")
print(f"measurement.category={stats.category}")
- print(f"measurement.actual={number(stats.actual)}")
- print(f"measurement.average={number(stats.average)}")
- print(f"measurement.standard_deviation={number(stats.standard_deviation)}")
- print(f"measurement.minimum={number(stats.minimum)}")
- print(f"measurement.maximum={number(stats.maximum)}")
+ print(f"measurement.actual={_number(stats.actual)}")
+ print(f"measurement.average={_number(stats.average)}")
+ print(f"measurement.standard_deviation={_number(stats.standard_deviation)}")
+ print(f"measurement.minimum={_number(stats.minimum)}")
+ print(f"measurement.maximum={_number(stats.maximum)}")
print(f"measurement.waveform_count={stats.waveform_count}")
if stats.buffered_values is None:
print("measurement.buffer=n/a")
else:
- print("measurement.buffer=" + ",".join(number(value) for value in stats.buffered_values))
+ print("measurement.buffer=" + ",".join(_number(value) for value in stats.buffered_values))
def _print_scope_derived_waveform_metadata(
@@ -591,19 +584,16 @@ def _print_scope_fft_status(status: ScopeFftStatus) -> None:
def _print_scope_cursor_readout(readout: ScopeCursorReadout) -> None:
- def number(value: float | None) -> str:
- return "n/a" if value is None else f"{value:.12g}"
-
print(f"cursor.index={readout.cursor_index}")
print(f"cursor.source={readout.source}")
print(f"cursor.function={readout.function}")
- print(f"cursor.result={number(readout.result)}")
- print(f"cursor.x_delta_s={number(readout.x_delta_s)}")
- print(f"cursor.inverse_x_delta_hz={number(readout.inverse_x_delta_hz)}")
- print(f"cursor.y_delta={number(readout.y_delta)}")
- print(f"cursor.inverse_y_delta={number(readout.inverse_y_delta)}")
- print(f"cursor.x_ratio={number(readout.x_ratio)}")
- print(f"cursor.y_ratio={number(readout.y_ratio)}")
+ print(f"cursor.result={_number(readout.result)}")
+ print(f"cursor.x_delta_s={_number(readout.x_delta_s)}")
+ print(f"cursor.inverse_x_delta_hz={_number(readout.inverse_x_delta_hz)}")
+ print(f"cursor.y_delta={_number(readout.y_delta)}")
+ print(f"cursor.inverse_y_delta={_number(readout.inverse_y_delta)}")
+ print(f"cursor.x_ratio={_number(readout.x_ratio)}")
+ print(f"cursor.y_ratio={_number(readout.y_ratio)}")
def _print_dmm_function_status(function: str) -> None:
print(f"功能 / Function: {function}")
From 55270eccec533fac99a20eb762af00c90eaa28cf Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:51:55 +0800
Subject: [PATCH 12/44] refactor: reuse finite report value parsing
---
src/wavebench/report/html.py | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 0023b1d..3593600 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -12,6 +12,7 @@
from wavebench.data.packages import FrequencyResponsePackage, RunPackage
from wavebench.errors import ConfigError
from wavebench.report.plot3d import (
+ _finite_float,
build_surface_payload,
plotly_head_tag,
plotly_initializer,
@@ -1648,14 +1649,6 @@ def _short_svg_legend_label(label: str, *, limit: int = 30) -> str:
return normalized if len(normalized) <= limit else normalized[: limit - 1] + "…"
-def _finite_float(value: Any) -> float | None:
- try:
- numeric = float(value)
- except (TypeError, ValueError):
- return None
- return numeric if np.isfinite(numeric) else None
-
-
def _metric_label(metric: str) -> str:
labels = {
"frequency_estimate_hz": "频率 / Frequency",
From f9e1d456d2f62aed9692d143095d7ba87be71d52 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 01:54:30 +0800
Subject: [PATCH 13/44] refactor: share scope V2 profile lookup
---
src/wavebench/services/scope_service.py | 88 +++++++++----------------
1 file changed, 32 insertions(+), 56 deletions(-)
diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py
index d9758a9..7df7d00 100644
--- a/src/wavebench/services/scope_service.py
+++ b/src/wavebench/services/scope_service.py
@@ -9,7 +9,7 @@
from contextlib import contextmanager
from dataclasses import asdict, dataclass
from pathlib import Path
-from typing import Any, cast
+from typing import Any, TypeVar, cast
from uuid import uuid4
import numpy as np
@@ -101,6 +101,8 @@
HIGH_IMPEDANCE_COUPLINGS = {"DCL", "DCLIMIT", "ACL", "ACLIMIT"}
LOW_IMPEDANCE_COUPLINGS = {"DC", "AC"}
+_ProfileT = TypeVar("_ProfileT")
+
@dataclass(frozen=True)
class ScopeStatusSummary:
@@ -407,7 +409,10 @@ def snapshot_v2(self, channel: int) -> ScopeSnapshotV2:
if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1:
raise ConfigError("scope snapshot V2 channel must be a positive integer")
spec = self._require("scope.snapshot_v2", "scope.snapshot_v2")
- profile = self._snapshot_v2_profile()
+ profile = self._v2_descriptor_profile(
+ "snapshot_profile_v2", ScopeSnapshotProfileV2,
+ "scope snapshot V2 descriptor profile has an invalid type",
+ )
if profile is None:
raise ConfigError("scope snapshot V2 requires scope_extensions.snapshot_profile_v2")
with self._scope_session() as scope:
@@ -602,7 +607,10 @@ def capture_average_v2(
) -> ScopeAverageCaptureResultV2:
if not isinstance(request, ScopeAverageCaptureRequestV2):
raise ConfigError("scope average capture V2 request has an invalid type")
- profile = self._average_capture_v2_profile()
+ profile = self._v2_descriptor_profile(
+ "average_capture_profile_v2", ScopeAverageCaptureProfileV2,
+ "scope average capture V2 descriptor profile has an invalid type",
+ )
if profile is None:
raise ConfigError(
"scope average capture V2 requires "
@@ -697,7 +705,10 @@ def measurement_statistics_v2(
"scope.measurement_statistics_v2",
"scope.measurement_statistics_v2",
)
- profile = self._measurement_statistics_v2_profile()
+ profile = self._v2_descriptor_profile(
+ "measurement_statistics_profile_v2", ScopeMeasurementStatisticsProfileV2,
+ "scope measurement statistics V2 descriptor profile has an invalid type",
+ )
if profile is None:
raise ConfigError(
"scope measurement statistics V2 requires "
@@ -787,7 +798,10 @@ def fft_status_v2(
if configured_fft is not True:
raise ConfigError("FFT status V2 requires configured_fft=True")
spec = self._require("scope.fft_status_v2", "scope.fft_status_v2")
- profile = self._fft_status_v2_profile()
+ profile = self._v2_descriptor_profile(
+ "fft_status_profile_v2", ScopeFftStatusProfileV2,
+ "scope FFT status V2 descriptor profile has an invalid type",
+ )
if profile is None:
raise ConfigError("scope FFT status V2 requires scope_extensions.fft_status_profile_v2")
with self._scope_session() as scope:
@@ -857,7 +871,10 @@ def cursor_readout_v2(
if configured_cursor is not True:
raise ConfigError("cursor readout V2 requires configured_cursor=True")
spec = self._require("scope.cursor_readout_v2", "scope.cursor_readout_v2")
- profile = self._cursor_readout_v2_profile()
+ profile = self._v2_descriptor_profile(
+ "cursor_readout_profile_v2", ScopeCursorReadoutProfileV2,
+ "scope cursor readout V2 descriptor profile has an invalid type",
+ )
if profile is None:
raise ConfigError(
"scope cursor readout V2 requires scope_extensions.cursor_readout_profile_v2"
@@ -1236,15 +1253,20 @@ def _waveform_binary_profile(self) -> ScopeWaveformBinaryProfile | None:
extensions = getattr(descriptor, "scope_extensions", None)
return getattr(extensions, "waveform_binary_profile", None)
- def _snapshot_v2_profile(self) -> ScopeSnapshotProfileV2 | None:
+ def _v2_descriptor_profile(
+ self,
+ field: str,
+ profile_type: type[_ProfileT],
+ invalid_type_message: str,
+ ) -> _ProfileT | None:
descriptor = self.descriptor or resolve_instrument_descriptor(
self.config.scope.driver,
expected_kind="scope",
)
extensions = getattr(descriptor, "scope_extensions", None)
- profile = getattr(extensions, "snapshot_profile_v2", None)
- if profile is not None and not isinstance(profile, ScopeSnapshotProfileV2):
- raise ConfigError("scope snapshot V2 descriptor profile has an invalid type")
+ profile = getattr(extensions, field, None)
+ if profile is not None and not isinstance(profile, profile_type):
+ raise ConfigError(invalid_type_message)
return profile
def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | None:
@@ -1267,52 +1289,6 @@ def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | No
)
return profile
- def _average_capture_v2_profile(self) -> ScopeAverageCaptureProfileV2 | None:
- descriptor = self.descriptor or resolve_instrument_descriptor(
- self.config.scope.driver,
- expected_kind="scope",
- )
- extensions = getattr(descriptor, "scope_extensions", None)
- profile = getattr(extensions, "average_capture_profile_v2", None)
- if profile is not None and not isinstance(profile, ScopeAverageCaptureProfileV2):
- raise ConfigError("scope average capture V2 descriptor profile has an invalid type")
- return profile
-
- def _measurement_statistics_v2_profile(
- self,
- ) -> ScopeMeasurementStatisticsProfileV2 | None:
- descriptor = self.descriptor or resolve_instrument_descriptor(
- self.config.scope.driver,
- expected_kind="scope",
- )
- extensions = getattr(descriptor, "scope_extensions", None)
- profile = getattr(extensions, "measurement_statistics_profile_v2", None)
- if profile is not None and not isinstance(profile, ScopeMeasurementStatisticsProfileV2):
- raise ConfigError("scope measurement statistics V2 descriptor profile has an invalid type")
- return profile
-
- def _fft_status_v2_profile(self) -> ScopeFftStatusProfileV2 | None:
- descriptor = self.descriptor or resolve_instrument_descriptor(
- self.config.scope.driver,
- expected_kind="scope",
- )
- extensions = getattr(descriptor, "scope_extensions", None)
- profile = getattr(extensions, "fft_status_profile_v2", None)
- if profile is not None and not isinstance(profile, ScopeFftStatusProfileV2):
- raise ConfigError("scope FFT status V2 descriptor profile has an invalid type")
- return profile
-
- def _cursor_readout_v2_profile(self) -> ScopeCursorReadoutProfileV2 | None:
- descriptor = self.descriptor or resolve_instrument_descriptor(
- self.config.scope.driver,
- expected_kind="scope",
- )
- extensions = getattr(descriptor, "scope_extensions", None)
- profile = getattr(extensions, "cursor_readout_profile_v2", None)
- if profile is not None and not isinstance(profile, ScopeCursorReadoutProfileV2):
- raise ConfigError("scope cursor readout V2 descriptor profile has an invalid type")
- return profile
-
def _bounded_waveform_executor(self, scope: object) -> BoundedWaveformExecutor:
if self.descriptor is None or self.session_state is None:
raise ConfigError(
From 49e8feba71c9fe3145c915e5522e207d6b0abda5 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 02:05:28 +0800
Subject: [PATCH 14/44] fix: retain waveform marker compatibility
---
src/wavebench/transport/guarded.py | 10 ++++++++++
tests/test_scope_waveform_executor.py | 13 +++++++++++++
tests/test_waveform_binary_factory.py | 1 +
3 files changed, 24 insertions(+)
diff --git a/src/wavebench/transport/guarded.py b/src/wavebench/transport/guarded.py
index f37d54d..4b5cb88 100644
--- a/src/wavebench/transport/guarded.py
+++ b/src/wavebench/transport/guarded.py
@@ -441,6 +441,16 @@ def _has_verified_bounded_binary_backend(self) -> bool:
and self.session_state.health is SessionHealth.HEALTHY
)
+ def _mark_bounded_waveform_backend_verified(self) -> None:
+ """Compatibility alias for the former waveform-specific internal marker."""
+
+ self._mark_bounded_binary_backend_verified()
+
+ def _has_verified_bounded_waveform_backend(self) -> bool:
+ """Compatibility alias for the former waveform-specific internal predicate."""
+
+ return self._has_verified_bounded_binary_backend()
+
def _check_access(self, operation: str, *, write: bool = False) -> None:
if write and self.access != "read_write":
if operation == "write":
diff --git a/tests/test_scope_waveform_executor.py b/tests/test_scope_waveform_executor.py
index 0bd523e..786f674 100644
--- a/tests/test_scope_waveform_executor.py
+++ b/tests/test_scope_waveform_executor.py
@@ -182,6 +182,19 @@ def _bounded_transport(
return transport
+def test_waveform_backend_marker_compatibility_aliases_share_generic_state() -> None:
+ transport = GuardedAuditedTransport(
+ _Backend(),
+ session_state=InstrumentSessionState(epoch_id="marker-alias"),
+ )
+
+ assert not transport._has_verified_bounded_binary_backend()
+ assert not transport._has_verified_bounded_waveform_backend()
+ transport._mark_bounded_waveform_backend_verified()
+ assert transport._has_verified_bounded_binary_backend()
+ assert transport._has_verified_bounded_waveform_backend()
+
+
class _Driver:
def __init__(self, transport: GuardedAuditedTransport) -> None:
self.transport = transport
diff --git a/tests/test_waveform_binary_factory.py b/tests/test_waveform_binary_factory.py
index d247de7..4b21003 100644
--- a/tests/test_waveform_binary_factory.py
+++ b/tests/test_waveform_binary_factory.py
@@ -186,6 +186,7 @@ def factory(context):
assert inner.queries == []
assert inner.writes == []
assert opened.transport._has_verified_bounded_binary_backend()
+ assert opened.transport._has_verified_bounded_waveform_backend()
assert opened.transport.query("*IDN?") == "ok"
assert inner.queries == ["*IDN?"]
From 58210c6218071c7327a8350a185239a784d21798 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 02:09:49 +0800
Subject: [PATCH 15/44] refactor: retire waveform marker compatibility aliases
---
src/wavebench/transport/guarded.py | 10 ----------
tests/test_scope_waveform_executor.py | 13 -------------
tests/test_waveform_binary_factory.py | 1 -
3 files changed, 24 deletions(-)
diff --git a/src/wavebench/transport/guarded.py b/src/wavebench/transport/guarded.py
index 4b5cb88..f37d54d 100644
--- a/src/wavebench/transport/guarded.py
+++ b/src/wavebench/transport/guarded.py
@@ -441,16 +441,6 @@ def _has_verified_bounded_binary_backend(self) -> bool:
and self.session_state.health is SessionHealth.HEALTHY
)
- def _mark_bounded_waveform_backend_verified(self) -> None:
- """Compatibility alias for the former waveform-specific internal marker."""
-
- self._mark_bounded_binary_backend_verified()
-
- def _has_verified_bounded_waveform_backend(self) -> bool:
- """Compatibility alias for the former waveform-specific internal predicate."""
-
- return self._has_verified_bounded_binary_backend()
-
def _check_access(self, operation: str, *, write: bool = False) -> None:
if write and self.access != "read_write":
if operation == "write":
diff --git a/tests/test_scope_waveform_executor.py b/tests/test_scope_waveform_executor.py
index 786f674..0bd523e 100644
--- a/tests/test_scope_waveform_executor.py
+++ b/tests/test_scope_waveform_executor.py
@@ -182,19 +182,6 @@ def _bounded_transport(
return transport
-def test_waveform_backend_marker_compatibility_aliases_share_generic_state() -> None:
- transport = GuardedAuditedTransport(
- _Backend(),
- session_state=InstrumentSessionState(epoch_id="marker-alias"),
- )
-
- assert not transport._has_verified_bounded_binary_backend()
- assert not transport._has_verified_bounded_waveform_backend()
- transport._mark_bounded_waveform_backend_verified()
- assert transport._has_verified_bounded_binary_backend()
- assert transport._has_verified_bounded_waveform_backend()
-
-
class _Driver:
def __init__(self, transport: GuardedAuditedTransport) -> None:
self.transport = transport
diff --git a/tests/test_waveform_binary_factory.py b/tests/test_waveform_binary_factory.py
index 4b21003..d247de7 100644
--- a/tests/test_waveform_binary_factory.py
+++ b/tests/test_waveform_binary_factory.py
@@ -186,7 +186,6 @@ def factory(context):
assert inner.queries == []
assert inner.writes == []
assert opened.transport._has_verified_bounded_binary_backend()
- assert opened.transport._has_verified_bounded_waveform_backend()
assert opened.transport.query("*IDN?") == "ok"
assert inner.queries == ["*IDN?"]
From a6550701eb88f34846da5424e2b85d089a0c4a31 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 29 Aug 2026 02:18:20 +0800
Subject: [PATCH 16/44] fix: retain scope V2 profile getter surface
---
src/wavebench/services/scope_service.py | 57 +++++++++++++++----------
1 file changed, 34 insertions(+), 23 deletions(-)
diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py
index 7df7d00..c3a6611 100644
--- a/src/wavebench/services/scope_service.py
+++ b/src/wavebench/services/scope_service.py
@@ -409,10 +409,7 @@ def snapshot_v2(self, channel: int) -> ScopeSnapshotV2:
if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1:
raise ConfigError("scope snapshot V2 channel must be a positive integer")
spec = self._require("scope.snapshot_v2", "scope.snapshot_v2")
- profile = self._v2_descriptor_profile(
- "snapshot_profile_v2", ScopeSnapshotProfileV2,
- "scope snapshot V2 descriptor profile has an invalid type",
- )
+ profile = self._snapshot_v2_profile()
if profile is None:
raise ConfigError("scope snapshot V2 requires scope_extensions.snapshot_profile_v2")
with self._scope_session() as scope:
@@ -607,10 +604,7 @@ def capture_average_v2(
) -> ScopeAverageCaptureResultV2:
if not isinstance(request, ScopeAverageCaptureRequestV2):
raise ConfigError("scope average capture V2 request has an invalid type")
- profile = self._v2_descriptor_profile(
- "average_capture_profile_v2", ScopeAverageCaptureProfileV2,
- "scope average capture V2 descriptor profile has an invalid type",
- )
+ profile = self._average_capture_v2_profile()
if profile is None:
raise ConfigError(
"scope average capture V2 requires "
@@ -705,10 +699,7 @@ def measurement_statistics_v2(
"scope.measurement_statistics_v2",
"scope.measurement_statistics_v2",
)
- profile = self._v2_descriptor_profile(
- "measurement_statistics_profile_v2", ScopeMeasurementStatisticsProfileV2,
- "scope measurement statistics V2 descriptor profile has an invalid type",
- )
+ profile = self._measurement_statistics_v2_profile()
if profile is None:
raise ConfigError(
"scope measurement statistics V2 requires "
@@ -798,10 +789,7 @@ def fft_status_v2(
if configured_fft is not True:
raise ConfigError("FFT status V2 requires configured_fft=True")
spec = self._require("scope.fft_status_v2", "scope.fft_status_v2")
- profile = self._v2_descriptor_profile(
- "fft_status_profile_v2", ScopeFftStatusProfileV2,
- "scope FFT status V2 descriptor profile has an invalid type",
- )
+ profile = self._fft_status_v2_profile()
if profile is None:
raise ConfigError("scope FFT status V2 requires scope_extensions.fft_status_profile_v2")
with self._scope_session() as scope:
@@ -871,10 +859,7 @@ def cursor_readout_v2(
if configured_cursor is not True:
raise ConfigError("cursor readout V2 requires configured_cursor=True")
spec = self._require("scope.cursor_readout_v2", "scope.cursor_readout_v2")
- profile = self._v2_descriptor_profile(
- "cursor_readout_profile_v2", ScopeCursorReadoutProfileV2,
- "scope cursor readout V2 descriptor profile has an invalid type",
- )
+ profile = self._cursor_readout_v2_profile()
if profile is None:
raise ConfigError(
"scope cursor readout V2 requires scope_extensions.cursor_readout_profile_v2"
@@ -1253,11 +1238,11 @@ def _waveform_binary_profile(self) -> ScopeWaveformBinaryProfile | None:
extensions = getattr(descriptor, "scope_extensions", None)
return getattr(extensions, "waveform_binary_profile", None)
- def _v2_descriptor_profile(
+ def _v2_profile(
self,
field: str,
profile_type: type[_ProfileT],
- invalid_type_message: str,
+ label: str,
) -> _ProfileT | None:
descriptor = self.descriptor or resolve_instrument_descriptor(
self.config.scope.driver,
@@ -1266,9 +1251,12 @@ def _v2_descriptor_profile(
extensions = getattr(descriptor, "scope_extensions", None)
profile = getattr(extensions, field, None)
if profile is not None and not isinstance(profile, profile_type):
- raise ConfigError(invalid_type_message)
+ raise ConfigError(f"scope {label} V2 descriptor profile has an invalid type")
return profile
+ def _snapshot_v2_profile(self) -> ScopeSnapshotProfileV2 | None:
+ return self._v2_profile("snapshot_profile_v2", ScopeSnapshotProfileV2, "snapshot")
+
def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | None:
descriptor = self.descriptor or resolve_instrument_descriptor(
self.config.scope.driver,
@@ -1289,6 +1277,29 @@ def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | No
)
return profile
+ def _average_capture_v2_profile(self) -> ScopeAverageCaptureProfileV2 | None:
+ return self._v2_profile(
+ "average_capture_profile_v2", ScopeAverageCaptureProfileV2,
+ "average capture",
+ )
+
+ def _measurement_statistics_v2_profile(
+ self,
+ ) -> ScopeMeasurementStatisticsProfileV2 | None:
+ return self._v2_profile(
+ "measurement_statistics_profile_v2", ScopeMeasurementStatisticsProfileV2,
+ "measurement statistics",
+ )
+
+ def _fft_status_v2_profile(self) -> ScopeFftStatusProfileV2 | None:
+ return self._v2_profile("fft_status_profile_v2", ScopeFftStatusProfileV2, "FFT status")
+
+ def _cursor_readout_v2_profile(self) -> ScopeCursorReadoutProfileV2 | None:
+ return self._v2_profile(
+ "cursor_readout_profile_v2", ScopeCursorReadoutProfileV2,
+ "cursor readout",
+ )
+
def _bounded_waveform_executor(self, scope: object) -> BoundedWaveformExecutor:
if self.descriptor is None or self.session_state is None:
raise ConfigError(
From 582fdeb9e8d3938b95f757482379b50f246c7976 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 03:26:50 +0800
Subject: [PATCH 17/44] feat(source): model parameterized coupling readback
---
...345\207\272\345\256\211\345\205\250RFC.md" | 11 ++
.../source_extension_capabilities.py | 28 ++-
.../instruments/source_extensions.py | 165 +++++++++++++++++-
src/wavebench/services/source_service.py | 33 +++-
src/wavebench/services/source_snapshot_v2.py | 29 ++-
tests/test_source_cross_channel_v2.py | 54 +++++-
tests/test_source_extensions.py | 114 +++++++++++-
7 files changed, 400 insertions(+), 34 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index acf1fe0..3363632 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -578,6 +578,17 @@ SourcePhaseRelationConfigureV2Driver
SOURCE_PHASE_RELATION_CONFIGURE_V2_OPERATION_CONTRACT
```
+首次稳定版 Coupling 只读模型修正在上述清单末尾追加以下精确条目:
+
+```text
+SourceCouplingCapabilityProfile
+SourceCouplingDimension
+SourceCouplingDimensionState
+SourceCouplingParameter
+SourceCouplingParameterKind
+SourceCouplingState
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 8c6c842..d299dfc 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -17,6 +17,7 @@
SOURCE_SNAPSHOT_MIN_CORE_VERSION,
SourceAmplitudeUnit,
SourceArbitraryCapabilityProfile,
+ SourceCouplingCapabilityProfile,
SourceCrossChannelCapabilityProfile,
SourceDescriptorExtensions,
SourceAnchorField,
@@ -641,6 +642,11 @@ def _validate_cross_channel_write_capability(
) -> None:
if capability not in capabilities:
return
+ profile_type = (
+ SourceCouplingCapabilityProfile
+ if feature_kind is SourceFeature.COUPLING
+ else SourceCrossChannelCapabilityProfile
+ )
configurable = tuple(
feature
for feature in extensions.features
@@ -650,7 +656,7 @@ def _validate_cross_channel_write_capability(
and feature.support is SupportState.SUPPORTED
and SourceFeatureDirection.CONFIGURE in feature.directions
and SourceFeatureDirection.READ in feature.directions
- and isinstance(feature.profile, SourceCrossChannelCapabilityProfile)
+ and isinstance(feature.profile, profile_type)
)
)
if not configurable:
@@ -659,11 +665,19 @@ def _validate_cross_channel_write_capability(
)
for feature in configurable:
profile = feature.profile
- if (
- feature_kind not in profile.relation_kinds
- or feature.channels not in profile.supported_channel_sets
- or not profile.configuration_readable
- ):
+ if isinstance(profile, SourceCouplingCapabilityProfile):
+ readable = (
+ feature.channels in profile.supported_channel_sets
+ and profile.global_state_readable
+ and profile.configuration_readable
+ )
+ else:
+ readable = (
+ feature_kind in profile.relation_kinds
+ and feature.channels in profile.supported_channel_sets
+ and profile.configuration_readable
+ )
+ if not readable:
raise ConfigError(
f"{capability} requires readable declared {feature_kind.value} relation state"
)
@@ -675,7 +689,7 @@ def _validate_cross_channel_write_capability(
and feature.scope is SourceFacetScope.INSTRUMENT
and feature.support is SupportState.SUPPORTED
and SourceFeatureDirection.READ in feature.directions
- and isinstance(feature.profile, SourceCrossChannelCapabilityProfile)
+ and isinstance(feature.profile, profile_type)
and feature.profile.relation_graph_readable
)
)
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index b57bc9b..e019c43 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -351,6 +351,31 @@ class SourceInputCoupling(StrEnum):
UNKNOWN = "unknown"
+class SourceCouplingDimension(StrEnum):
+ AMPLITUDE = "amplitude"
+ FREQUENCY = "frequency"
+ PHASE = "phase"
+
+
+class SourceCouplingParameterKind(StrEnum):
+ AMPLITUDE_DEVIATION_VPP = "amplitude_deviation_vpp"
+ AMPLITUDE_RATIO = "amplitude_ratio"
+ FREQUENCY_DEVIATION_HZ = "frequency_deviation_hz"
+ FREQUENCY_RATIO = "frequency_ratio"
+ PHASE_DEVIATION_DEG = "phase_deviation_deg"
+ PHASE_RATIO = "phase_ratio"
+
+
+_COUPLING_PARAMETER_DIMENSIONS = {
+ SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP: SourceCouplingDimension.AMPLITUDE,
+ SourceCouplingParameterKind.AMPLITUDE_RATIO: SourceCouplingDimension.AMPLITUDE,
+ SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ: SourceCouplingDimension.FREQUENCY,
+ SourceCouplingParameterKind.FREQUENCY_RATIO: SourceCouplingDimension.FREQUENCY,
+ SourceCouplingParameterKind.PHASE_DEVIATION_DEG: SourceCouplingDimension.PHASE,
+ SourceCouplingParameterKind.PHASE_RATIO: SourceCouplingDimension.PHASE,
+}
+
+
class SourceReferenceClockMode(StrEnum):
INTERNAL = "internal"
EXTERNAL = "external"
@@ -681,6 +706,52 @@ def __post_init__(self) -> None:
_require_bool(self.cascade_readable, "clock cascade_readable")
+@dataclass(frozen=True, slots=True)
+class SourceCouplingCapabilityProfile:
+ dimensions: tuple[SourceCouplingDimension, ...]
+ parameter_kinds: tuple[SourceCouplingParameterKind, ...]
+ supported_channel_sets: tuple[tuple[int, ...], ...]
+ global_state_readable: bool
+ reference_channel_readable: bool
+ relation_graph_readable: bool
+ configuration_readable: bool = False
+
+ def __post_init__(self) -> None:
+ _require_enum_tuple(
+ self.dimensions,
+ SourceCouplingDimension,
+ "coupling dimensions",
+ )
+ _require_enum_tuple(
+ self.parameter_kinds,
+ SourceCouplingParameterKind,
+ "coupling parameter_kinds",
+ )
+ if any(
+ _COUPLING_PARAMETER_DIMENSIONS[kind] not in self.dimensions
+ for kind in self.parameter_kinds
+ ):
+ raise ValueError("coupling parameter_kinds reference an unsupported dimension")
+ if not isinstance(self.supported_channel_sets, tuple):
+ raise ValueError("coupling supported_channel_sets must be a tuple")
+ for channel_set in self.supported_channel_sets:
+ _require_positive_channels(channel_set, "coupling supported channel set")
+ if len(channel_set) < 2:
+ raise ValueError("coupling channel sets require at least two channels")
+ if (
+ len(set(self.supported_channel_sets)) != len(self.supported_channel_sets)
+ or tuple(sorted(self.supported_channel_sets)) != self.supported_channel_sets
+ ):
+ raise ValueError("coupling supported_channel_sets must be sorted and unique")
+ _require_bool(self.global_state_readable, "coupling global_state_readable")
+ _require_bool(
+ self.reference_channel_readable,
+ "coupling reference_channel_readable",
+ )
+ _require_bool(self.relation_graph_readable, "coupling relation_graph_readable")
+ _require_bool(self.configuration_readable, "coupling configuration_readable")
+
+
@dataclass(frozen=True, slots=True)
class SourceCrossChannelCapabilityProfile:
relation_kinds: tuple[SourceFeature, ...]
@@ -694,7 +765,6 @@ def __post_init__(self) -> None:
allowed = {
SourceFeature.COMBINE,
SourceFeature.TRACKING,
- SourceFeature.COUPLING,
SourceFeature.COPY,
SourceFeature.PHASE_RELATION,
SourceFeature.SHARED_POWER,
@@ -734,6 +804,7 @@ def __post_init__(self) -> None:
| SourceArbitraryCapabilityProfile
| SourceCounterCapabilityProfile
| SourceClockSyncCapabilityProfile
+ | SourceCouplingCapabilityProfile
| SourceCrossChannelCapabilityProfile
)
@@ -1828,7 +1899,7 @@ def __post_init__(self) -> None:
SourceFeature.CASCADE: SourceClockSyncCapabilityProfile,
SourceFeature.COMBINE: SourceCrossChannelCapabilityProfile,
SourceFeature.TRACKING: SourceCrossChannelCapabilityProfile,
- SourceFeature.COUPLING: SourceCrossChannelCapabilityProfile,
+ SourceFeature.COUPLING: SourceCouplingCapabilityProfile,
SourceFeature.COPY: SourceCrossChannelCapabilityProfile,
SourceFeature.PHASE_RELATION: SourceCrossChannelCapabilityProfile,
SourceFeature.SHARED_POWER: SourceCrossChannelCapabilityProfile,
@@ -4079,6 +4150,72 @@ def __post_init__(self) -> None:
_require_int(self.source_channel.value, "sync source_channel value", minimum=1)
+@dataclass(frozen=True, slots=True)
+class SourceCouplingParameter:
+ kind: SourceCouplingParameterKind
+ value: float
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.kind, SourceCouplingParameterKind):
+ raise ValueError("source coupling parameter kind has an invalid type")
+ _require_finite(self.value, "source coupling parameter value")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCouplingDimensionState:
+ dimension: SourceCouplingDimension
+ enabled: Observed[bool]
+ parameter: Observed[SourceCouplingParameter]
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.dimension, SourceCouplingDimension):
+ raise ValueError("source coupling dimension has an invalid type")
+ _require_observed(self.enabled, "source coupling dimension enabled")
+ _require_observed(self.parameter, "source coupling dimension parameter")
+ if self.enabled.availability is Availability.VALUE:
+ _require_bool(self.enabled.value, "source coupling dimension enabled value")
+ if self.parameter.availability is Availability.VALUE:
+ if not isinstance(self.parameter.value, SourceCouplingParameter):
+ raise ValueError("source coupling dimension parameter has an invalid type")
+ if _COUPLING_PARAMETER_DIMENSIONS[self.parameter.value.kind] is not self.dimension:
+ raise ValueError("source coupling parameter does not match its dimension")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCouplingState:
+ feature: SourceFeature
+ channels: tuple[int, ...]
+ enabled: Observed[bool]
+ reference_channel: Observed[int]
+ dimensions: tuple[SourceCouplingDimensionState, ...]
+
+ def __post_init__(self) -> None:
+ if self.feature is not SourceFeature.COUPLING:
+ raise ValueError("source coupling state feature must be coupling")
+ _require_positive_channels(self.channels, "source coupling state channels")
+ if len(self.channels) < 2:
+ raise ValueError("source coupling state requires two or more channels")
+ _require_observed(self.enabled, "source coupling state enabled")
+ _require_observed(self.reference_channel, "source coupling state reference_channel")
+ if self.enabled.availability is Availability.VALUE:
+ _require_bool(self.enabled.value, "source coupling state enabled value")
+ if self.reference_channel.availability is Availability.VALUE:
+ _require_int(
+ self.reference_channel.value,
+ "source coupling state reference_channel value",
+ minimum=1,
+ )
+ if self.reference_channel.value not in self.channels:
+ raise ValueError("source coupling reference_channel must be a participant")
+ if not isinstance(self.dimensions, tuple) or not self.dimensions or any(
+ not isinstance(item, SourceCouplingDimensionState) for item in self.dimensions
+ ):
+ raise ValueError("source coupling dimensions have an invalid type")
+ keys = tuple(item.dimension.value for item in self.dimensions)
+ if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys:
+ raise ValueError("source coupling dimensions must be sorted and unique")
+
+
@dataclass(frozen=True, slots=True)
class SourceCascadeState:
enabled: Observed[bool]
@@ -4103,7 +4240,6 @@ def __post_init__(self) -> None:
if self.feature not in {
SourceFeature.COMBINE,
SourceFeature.TRACKING,
- SourceFeature.COUPLING,
SourceFeature.COPY,
SourceFeature.PHASE_RELATION,
}:
@@ -4137,7 +4273,7 @@ class SourceCrossChannelConfigureResult:
feature: SourceFeature
channels: tuple[int, ...]
enabled: bool
- relation: SourceRelationState
+ relation: SourceRelationState | SourceCouplingState
outputs: tuple[SourceRelationOutputState, ...]
def __post_init__(self) -> None:
@@ -4157,7 +4293,7 @@ def __post_init__(self) -> None:
"source cross-channel configure result requires two or more channels"
)
_require_bool(self.enabled, "source cross-channel configure result enabled")
- if not isinstance(self.relation, SourceRelationState):
+ if not isinstance(self.relation, (SourceRelationState, SourceCouplingState)):
raise ValueError(
"source cross-channel configure result relation has an invalid type"
)
@@ -4227,13 +4363,14 @@ def __post_init__(self) -> None:
@dataclass(frozen=True, slots=True)
class SourceCrossChannelStateV2:
- relations: tuple[SourceRelationState, ...]
+ relations: tuple[SourceRelationState | SourceCouplingState, ...]
relation_graph: Observed[SourceRelationGraph]
shared_power: Observed[SourceSharedPowerState]
def __post_init__(self) -> None:
if not isinstance(self.relations, tuple) or any(
- not isinstance(item, SourceRelationState) for item in self.relations
+ not isinstance(item, (SourceRelationState, SourceCouplingState))
+ for item in self.relations
):
raise ValueError("source cross-channel relations have an invalid type")
keys = tuple((item.feature.value, item.channels) for item in self.relations)
@@ -4455,6 +4592,7 @@ class SourceQueryItemOutcome(StrEnum):
| SourceCounterInputState
| SourceReferenceClockState
| SourceSyncState
+ | SourceCouplingState
| SourceCascadeState
| SourceRelationState
| SourceRelationGraph
@@ -4479,7 +4617,7 @@ class SourceQueryItemOutcome(StrEnum):
SourceFieldId.ARM_STATE: bool,
SourceFieldId.TRIGGER_STATE: bool,
SourceFieldId.COMBINE: SourceRelationState,
- SourceFieldId.COUPLING: SourceRelationState,
+ SourceFieldId.COUPLING: SourceCouplingState,
SourceFieldId.TRACKING: SourceRelationState,
SourceFieldId.COPY: SourceRelationState,
SourceFieldId.PHASE_RELATION: SourceRelationState,
@@ -4615,6 +4753,11 @@ def __post_init__(self) -> None:
raise ValueError(
"source cross-channel profile references an unknown channel"
)
+ if isinstance(feature.profile, SourceCouplingCapabilityProfile) and any(
+ not set(channel_set) <= set(self.topology.channels)
+ for channel_set in feature.profile.supported_channel_sets
+ ):
+ raise ValueError("source coupling profile references an unknown channel")
if not isinstance(self.query_contract, SourceQueryContract):
raise ValueError("source descriptor query_contract has an invalid type")
if not isinstance(self.safety_profile, SourceSafetyProfile):
@@ -5177,4 +5320,10 @@ def source_snapshot_timestamp_utc() -> str:
"SourcePhaseRelationConfigureRequest",
"SourcePhaseRelationConfigureV2Driver",
"SOURCE_PHASE_RELATION_CONFIGURE_V2_OPERATION_CONTRACT",
+ "SourceCouplingCapabilityProfile",
+ "SourceCouplingDimension",
+ "SourceCouplingDimensionState",
+ "SourceCouplingParameter",
+ "SourceCouplingParameterKind",
+ "SourceCouplingState",
]
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index d38eebc..1c1c35c 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -117,8 +117,10 @@
SourceBurstMode,
SourceCombineConfigureRequest,
SourceCombineConfigureV2Driver,
+ SourceCouplingCapabilityProfile,
SourceCouplingConfigureRequest,
SourceCouplingConfigureV2Driver,
+ SourceCouplingState,
SourceCrossChannelCapabilityProfile,
SourceCrossChannelConfigureResult,
SourceFacetScope,
@@ -346,7 +348,7 @@ class _SourceCrossChannelClosure:
feature: SourceFeature
relation_field: SourceFieldId
- relation: SourceRelationState
+ relation: SourceRelationState | SourceCouplingState
relation_graph: SourceRelationGraph
affected_channels: tuple[int, ...]
fields: tuple[SourceFieldRef, ...]
@@ -3838,6 +3840,11 @@ def _validate_source_cross_channel_runtime_profile(
feature: SourceFeature,
operation: str,
) -> None:
+ profile_type = (
+ SourceCouplingCapabilityProfile
+ if feature is SourceFeature.COUPLING
+ else SourceCrossChannelCapabilityProfile
+ )
configurable = next(
(
item
@@ -3848,18 +3855,26 @@ def _validate_source_cross_channel_runtime_profile(
and item.support is SupportState.SUPPORTED
and SourceFeatureDirection.READ in item.directions
and SourceFeatureDirection.CONFIGURE in item.directions
- and isinstance(item.profile, SourceCrossChannelCapabilityProfile)
+ and isinstance(item.profile, profile_type)
),
None,
)
if configurable is None:
raise ConfigError(f"{operation} is not available for the runtime channel set")
profile = configurable.profile
- if (
- feature not in profile.relation_kinds
- or channels not in profile.supported_channel_sets
- or not profile.configuration_readable
- ):
+ if isinstance(profile, SourceCouplingCapabilityProfile):
+ readable = (
+ channels in profile.supported_channel_sets
+ and profile.global_state_readable
+ and profile.configuration_readable
+ )
+ else:
+ readable = (
+ feature in profile.relation_kinds
+ and channels in profile.supported_channel_sets
+ and profile.configuration_readable
+ )
+ if not readable:
raise ConfigError(f"{operation} requires readable declared relation configuration")
graph = next(
(
@@ -3869,7 +3884,7 @@ def _validate_source_cross_channel_runtime_profile(
and item.scope is SourceFacetScope.INSTRUMENT
and item.support is SupportState.SUPPORTED
and SourceFeatureDirection.READ in item.directions
- and isinstance(item.profile, SourceCrossChannelCapabilityProfile)
+ and isinstance(item.profile, profile_type)
and item.profile.relation_graph_readable
),
None,
@@ -3879,7 +3894,7 @@ def _validate_source_cross_channel_runtime_profile(
def _source_cross_channel_fields(
self,
- relations: tuple[SourceRelationState, ...],
+ relations: tuple[SourceRelationState | SourceCouplingState, ...],
*,
feature: SourceFeature,
relation_field: SourceFieldId,
diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py
index 9e62893..7436a45 100644
--- a/src/wavebench/services/source_snapshot_v2.py
+++ b/src/wavebench/services/source_snapshot_v2.py
@@ -23,6 +23,9 @@
SourceAnchorField,
SourceChannelStateV2,
SourceCounterInputState,
+ SourceCouplingCapabilityProfile,
+ SourceCouplingDimensionState,
+ SourceCouplingState,
SourceCrossChannelStateV2,
SourceDescriptorExtensions,
SourceFacetQueryContract,
@@ -424,7 +427,7 @@ def _anchor_predicate_value(
and isinstance(value, ArbitraryFacet)
):
return _observed_value(value.playback_mode)
- if isinstance(value, SourceRelationState):
+ if isinstance(value, (SourceRelationState, SourceCouplingState)):
return _observed_value(value.enabled)
return None
@@ -650,7 +653,7 @@ def _cross_channel_state(
values: dict[SourceFieldRef, Observed[object]],
features: tuple[SourceFeatureCapability, ...],
) -> SourceCrossChannelStateV2:
- relations: list[SourceRelationState] = []
+ relations: list[SourceRelationState | SourceCouplingState] = []
relation_fields = {
SourceFeature.COMBINE: SourceFieldId.COMBINE,
SourceFeature.TRACKING: SourceFieldId.TRACKING,
@@ -671,6 +674,28 @@ def _cross_channel_state(
)
if observed.availability is Availability.VALUE:
relations.append(observed.value)
+ elif feature.feature is SourceFeature.COUPLING:
+ profile = feature.profile
+ if not isinstance(profile, SourceCouplingCapabilityProfile):
+ raise SourceSnapshotContractError(
+ "source coupling feature has an invalid runtime profile"
+ )
+ relations.append(
+ SourceCouplingState(
+ feature=SourceFeature.COUPLING,
+ channels=feature.channels,
+ enabled=observed,
+ reference_channel=observed,
+ dimensions=tuple(
+ SourceCouplingDimensionState(
+ dimension=dimension,
+ enabled=observed,
+ parameter=observed,
+ )
+ for dimension in profile.dimensions
+ ),
+ )
+ )
else:
relations.append(
SourceRelationState(
diff --git a/tests/test_source_cross_channel_v2.py b/tests/test_source_cross_channel_v2.py
index 449cdb8..ee78f00 100644
--- a/tests/test_source_cross_channel_v2.py
+++ b/tests/test_source_cross_channel_v2.py
@@ -29,7 +29,12 @@
OutputFacet,
SourceCombineConfigureRequest,
SourceConstraintApplicability,
+ SourceCouplingCapabilityProfile,
SourceCouplingConfigureRequest,
+ SourceCouplingDimension,
+ SourceCouplingDimensionState,
+ SourceCouplingParameterKind,
+ SourceCouplingState,
SourceCrossChannelCapabilityProfile,
SourceCrossChannelConfigureResult,
SourceFacetQueryContract,
@@ -239,7 +244,26 @@ def _output(self, channel: int) -> OutputFacet:
polarity=Observed.value_of(SourceOutputPolarity.NORMAL),
)
- def _relation(self, enabled: bool) -> SourceRelationState:
+ def _relation(self, enabled: bool) -> SourceRelationState | SourceCouplingState:
+ if self.feature is SourceFeature.COUPLING:
+ not_queried = Observed.missing(
+ Availability.NOT_QUERIED,
+ SourceReasonCode.NOT_REQUESTED,
+ )
+ return SourceCouplingState(
+ feature=SourceFeature.COUPLING,
+ channels=(1, 2),
+ enabled=Observed.value_of(enabled),
+ reference_channel=Observed.value_of(1),
+ dimensions=tuple(
+ SourceCouplingDimensionState(
+ dimension=dimension,
+ enabled=Observed.value_of(enabled),
+ parameter=not_queried,
+ )
+ for dimension in SourceCouplingDimension
+ ),
+ )
return SourceRelationState(
feature=self.feature,
channels=(1, 2),
@@ -296,12 +320,28 @@ def _extensions(
):
base = source_extensions()
basic, output = base.features
- profile = SourceCrossChannelCapabilityProfile(
- relation_kinds=(feature,),
- supported_channel_sets=((1, 2),),
- relation_graph_readable=True,
- shared_power_constraint_readable=False,
- configuration_readable=configuration_readable,
+ profile = (
+ SourceCouplingCapabilityProfile(
+ dimensions=tuple(SourceCouplingDimension),
+ parameter_kinds=(
+ SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,
+ SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ,
+ SourceCouplingParameterKind.PHASE_DEVIATION_DEG,
+ ),
+ supported_channel_sets=((1, 2),),
+ global_state_readable=True,
+ reference_channel_readable=True,
+ relation_graph_readable=True,
+ configuration_readable=configuration_readable,
+ )
+ if feature is SourceFeature.COUPLING
+ else SourceCrossChannelCapabilityProfile(
+ relation_kinds=(feature,),
+ supported_channel_sets=((1, 2),),
+ relation_graph_readable=True,
+ shared_power_constraint_readable=False,
+ configuration_readable=configuration_readable,
+ )
)
relation = SourceFeatureCapability(
feature=feature,
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index be45cd7..b6adbd7 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -117,7 +117,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[arbitrary_start + len(arbitrary_exports) :] == match.group(1).splitlines()
+ relation_exports = match.group(1).splitlines()
+ relation_start = arbitrary_start + len(arbitrary_exports)
+ assert module.__all__[relation_start : relation_start + len(relation_exports)] == relation_exports
+ match = re.search(
+ r"首次稳定版 Coupling 只读模型修正在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[relation_start + len(relation_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -215,6 +224,24 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"configuration_readable",
"query_effect",
),
+ "SourceCouplingCapabilityProfile": (
+ "dimensions",
+ "parameter_kinds",
+ "supported_channel_sets",
+ "global_state_readable",
+ "reference_channel_readable",
+ "relation_graph_readable",
+ "configuration_readable",
+ ),
+ "SourceCouplingParameter": ("kind", "value"),
+ "SourceCouplingDimensionState": ("dimension", "enabled", "parameter"),
+ "SourceCouplingState": (
+ "feature",
+ "channels",
+ "enabled",
+ "reference_channel",
+ "dimensions",
+ ),
"SourceClockSyncCapabilityProfile": (
"reference_clock_modes",
"sync_readable",
@@ -1346,6 +1373,91 @@ def test_source_v2_cross_channel_write_models_are_closed_and_serializable() -> N
)
+def test_source_v2_coupling_read_model_separates_dimensions_and_parameters() -> None:
+ profile = module.SourceCouplingCapabilityProfile(
+ dimensions=(
+ module.SourceCouplingDimension.AMPLITUDE,
+ module.SourceCouplingDimension.FREQUENCY,
+ module.SourceCouplingDimension.PHASE,
+ ),
+ parameter_kinds=(
+ module.SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,
+ module.SourceCouplingParameterKind.AMPLITUDE_RATIO,
+ module.SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ,
+ module.SourceCouplingParameterKind.FREQUENCY_RATIO,
+ module.SourceCouplingParameterKind.PHASE_DEVIATION_DEG,
+ module.SourceCouplingParameterKind.PHASE_RATIO,
+ ),
+ supported_channel_sets=((1, 2),),
+ global_state_readable=True,
+ reference_channel_readable=True,
+ relation_graph_readable=False,
+ )
+ missing = Observed.missing(
+ Availability.NOT_QUERIED,
+ SourceReasonCode.NOT_REQUESTED,
+ )
+ state = module.SourceCouplingState(
+ feature=module.SourceFeature.COUPLING,
+ channels=(1, 2),
+ enabled=missing,
+ reference_channel=Observed.value_of(1),
+ dimensions=(
+ module.SourceCouplingDimensionState(
+ module.SourceCouplingDimension.AMPLITUDE,
+ Observed.value_of(True),
+ Observed.value_of(
+ module.SourceCouplingParameter(
+ module.SourceCouplingParameterKind.AMPLITUDE_RATIO,
+ 0.5,
+ )
+ ),
+ ),
+ module.SourceCouplingDimensionState(
+ module.SourceCouplingDimension.FREQUENCY,
+ Observed.value_of(True),
+ Observed.value_of(
+ module.SourceCouplingParameter(
+ module.SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ,
+ 500.0,
+ )
+ ),
+ ),
+ module.SourceCouplingDimensionState(
+ module.SourceCouplingDimension.PHASE,
+ Observed.value_of(False),
+ missing,
+ ),
+ ),
+ )
+
+ assert profile.configuration_readable is False
+ assert module.source_v2_to_data(state)["dimensions"][0]["parameter"]["value"] == {
+ "type": "SourceCouplingParameter",
+ "kind": "amplitude_ratio",
+ "value": 0.5,
+ }
+ with pytest.raises(ValueError, match="does not match its dimension"):
+ module.SourceCouplingDimensionState(
+ module.SourceCouplingDimension.PHASE,
+ Observed.value_of(True),
+ Observed.value_of(
+ module.SourceCouplingParameter(
+ module.SourceCouplingParameterKind.FREQUENCY_RATIO,
+ 2.0,
+ )
+ ),
+ )
+ with pytest.raises(ValueError, match="must be a participant"):
+ replace(state, reference_channel=Observed.value_of(3))
+ with pytest.raises(ValueError, match="feature is not a relation"):
+ module.SourceRelationState(
+ feature=module.SourceFeature.COUPLING,
+ channels=(1, 2),
+ enabled=Observed.value_of(True),
+ )
+
+
def test_source_v2_arbitrary_write_capabilities_require_explicit_readback() -> None:
extensions = source_extensions()
basic, output = extensions.features
From 18a562e0c3d07ee5371b1c1885ca9b7fd95d1952 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 03:34:18 +0800
Subject: [PATCH 18/44] feat(source): make sync a channel read facet
---
...345\207\272\345\256\211\345\205\250RFC.md" | 68 ++++++++++---
.../instruments/source_extensions.py | 98 +++++++++++++------
src/wavebench/services/source_service.py | 4 +-
src/wavebench/services/source_snapshot_v2.py | 38 +++++--
tests/source_v2_fixtures.py | 7 ++
tests/test_source_budget.py | 1 +
tests/test_source_extensions.py | 78 +++++++++++++--
tests/test_source_snapshot_v2.py | 81 +++++++++++++++
8 files changed, 315 insertions(+), 60 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 3363632..cfd53e4 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -347,7 +347,6 @@ SourceBurstCapabilityProfile
SourceBurstMode
SourceCascadeState
SourceChannelStateV2
-SourceClockSyncCapabilityProfile
SourceComponentAmplitude
SourceConstraintApplicability
SourceCounterCapabilityProfile
@@ -589,6 +588,15 @@ SourceCouplingParameterKind
SourceCouplingState
```
+首次稳定版 Sync 只读模型修正在上述清单末尾追加以下精确条目:
+
+```text
+SourceReferenceClockCapabilityProfile
+SourceSyncCapabilityProfile
+SourceSyncPolarity
+SourceCascadeCapabilityProfile
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
@@ -1002,6 +1010,7 @@ facet 辅助 enum 也使用封闭 value 集:
- `SourceAmplitudeUnit`:`vpp`、`vrms`、`dbm`、`v`、`unknown`;
- `SourceOutputPolarity`:`normal`、`inverted`、`unknown`;
+- `SourceSyncPolarity`:`positive`、`negative`、`unknown`;
- `SourceLoadKind`:`high_impedance`、`resistive`、`unknown`;
- `SourceModulationKind`:`am`、`dsb_am`、`fm`、`pm`、`pwm`、`ask`、`fsk`、`psk`、`other`;
- `SourceModulationSource`:`internal`、`external`、`channel`、`unknown`;
@@ -1025,8 +1034,8 @@ facet 辅助 enum 也使用封闭 value 集:
机器可读 feature ID 按作用域分为:
-- channel:`basic`、`output`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`;
-- system:`counter`、`reference_clock`、`sync`、`cascade`;
+- channel:`basic`、`output`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`、`sync`;
+- system:`counter`、`reference_clock`、`cascade`;
- cross-channel:`combine`、`tracking`、`coupling`、`copy`、`phase_relation`、`shared_power`。
正文中的 Harmonic、Sync、Combine 等首字母大写名称只是展示术语;注册表、artifact 和
@@ -1035,7 +1044,7 @@ descriptor 一律使用上述小写 ID,不允许通过大小写或单复数增
feature 集合是核心注册表,不接受插件自定义任意字符串作为新安全语义。厂商专用功能可继续
使用独立 capability,但未经核心注册时不进入通用 Source V2 预算或恢复。
-R2 冻结以下 11 个只读 capability profile。布尔字段只声明该值能否读取,不提供写授权;tuple
+R2 冻结以下只读 capability profile。布尔字段只声明该值能否读取,不提供写授权;tuple
使用 enum value 或 ID 的升序并且不重复。
```python
@@ -1115,10 +1124,35 @@ class SourceCounterCapabilityProfile:
@dataclass(frozen=True, slots=True)
-class SourceClockSyncCapabilityProfile:
- reference_clock_modes: tuple[SourceReferenceClockMode, ...]
- sync_readable: bool
- cascade_readable: bool
+class SourceReferenceClockCapabilityProfile:
+ modes: tuple[SourceReferenceClockMode, ...]
+ frequency_readable: bool
+ lock_state_readable: bool
+
+
+@dataclass(frozen=True, slots=True)
+class SourceSyncCapabilityProfile:
+ enabled_readable: bool
+ polarity_readable: bool
+ source_channel_readable: bool
+ source_channels: tuple[int, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCascadeCapabilityProfile:
+ enabled_readable: bool
+ role_readable: bool
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCouplingCapabilityProfile:
+ dimensions: tuple[SourceCouplingDimension, ...]
+ parameter_kinds: tuple[SourceCouplingParameterKind, ...]
+ supported_channel_sets: tuple[tuple[int, ...], ...]
+ global_state_readable: bool
+ reference_channel_readable: bool
+ relation_graph_readable: bool
+ configuration_readable: bool = False
@dataclass(frozen=True, slots=True)
@@ -1143,7 +1177,10 @@ SourceFeatureProfile: TypeAlias = (
| SourcePulseCapabilityProfile
| SourceArbitraryCapabilityProfile
| SourceCounterCapabilityProfile
- | SourceClockSyncCapabilityProfile
+ | SourceReferenceClockCapabilityProfile
+ | SourceSyncCapabilityProfile
+ | SourceCascadeCapabilityProfile
+ | SourceCouplingCapabilityProfile
| SourceCrossChannelCapabilityProfile
)
```
@@ -1180,10 +1217,10 @@ class SourceTopologyContract:
格式的 `input_id`;`INSTRUMENT` 不携带这些字段。所有通道必须属于 topology。
`SourceTopologyContract.channels` 必须递增、唯一且非空;`input_ids` 必须排序稳定且不重复。
-- `basic`、`output`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst` 和 `arbitrary`
+- `basic`、`output`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst`、`arbitrary` 和 `sync`
通常属于 `CHANNEL`;
- `combine`、`coupling` 和 `tracking` 属于 `CHANNEL_SET`,并明确列出关系参与者;
-- `reference_clock`、`sync` 和 `cascade` 属于 `INSTRUMENT` 或 `CHANNEL_SET`;
+- `reference_clock` 和 `cascade` 属于 `INSTRUMENT`;
- `counter` 通常属于独立 `INPUT`,只有存在已声明路由关系时才参与输出预算。
descriptor 中的 topology 是静态上界。实际 capability 必须根据已验证的型号、固件、选件和
@@ -1216,7 +1253,7 @@ class SourceFieldId(StrEnum):
PHASE_RELATION = "source.cross_channel.phase_relation"
RELATION_GRAPH = "source.cross_channel.relation_graph"
REFERENCE_CLOCK = "source.instrument.reference_clock"
- SYNC = "source.instrument.sync"
+ SYNC = "source.channel.sync"
CASCADE = "source.instrument.cascade"
SHARED_POWER = "source.instrument.shared_power"
COUNTER = "source.input.counter"
@@ -1534,13 +1571,12 @@ class ArbitraryFacet:
class SourceSystemStateV2:
counters: tuple[SourceCounterInputState, ...]
reference_clock: Observed[SourceReferenceClockState]
- sync: Observed[SourceSyncState]
cascade: Observed[SourceCascadeState]
@dataclass(frozen=True, slots=True)
class SourceCrossChannelStateV2:
- relations: tuple[SourceRelationState, ...]
+ relations: tuple[SourceRelationState | SourceCouplingState, ...]
relation_graph: Observed[SourceRelationGraph]
shared_power: Observed[SourceSharedPowerState]
@@ -1556,6 +1592,7 @@ class SourceChannelStateV2:
burst: Observed[BurstFacet]
pulse: Observed[PulseFacet]
arbitrary: Observed[ArbitraryFacet]
+ sync: Observed[SourceSyncState]
@dataclass(frozen=True, slots=True)
@@ -1584,7 +1621,8 @@ class SourceSnapshotV2:
gate_time_s, trigger_level_v, statistics_enabled)`、
`SourceReferenceClockState(mode, frequency_hz, locked)`、
`SourceSyncState(enabled, polarity, source_channel)`、`SourceCascadeState(enabled, role)`、
-`SourceRelationState(feature, channels, enabled)` 和
+`SourceRelationState(feature, channels, enabled)`、
+`SourceCouplingState(feature, channels, enabled, reference_channel, dimensions)` 和
`SourceSharedPowerState(participants, active_power_upper_w, hard_limit_w)`。
每个数值必须有限;频率、时间、阻抗、点数、阶次和功率等非负量不得为负;百分比范围为
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index e019c43..bb762ee 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -235,6 +235,12 @@ class SourceOutputPolarity(StrEnum):
UNKNOWN = "unknown"
+class SourceSyncPolarity(StrEnum):
+ POSITIVE = "positive"
+ NEGATIVE = "negative"
+ UNKNOWN = "unknown"
+
+
class SourceLoadKind(StrEnum):
HIGH_IMPEDANCE = "high_impedance"
RESISTIVE = "resistive"
@@ -691,19 +697,43 @@ def __post_init__(self) -> None:
@dataclass(frozen=True, slots=True)
-class SourceClockSyncCapabilityProfile:
- reference_clock_modes: tuple[SourceReferenceClockMode, ...]
- sync_readable: bool
- cascade_readable: bool
+class SourceReferenceClockCapabilityProfile:
+ modes: tuple[SourceReferenceClockMode, ...]
+ frequency_readable: bool
+ lock_state_readable: bool
def __post_init__(self) -> None:
- _require_enum_tuple(
- self.reference_clock_modes,
- SourceReferenceClockMode,
- "clock reference_clock_modes",
+ _require_enum_tuple(self.modes, SourceReferenceClockMode, "reference clock modes")
+ _require_bool(self.frequency_readable, "reference clock frequency_readable")
+ _require_bool(self.lock_state_readable, "reference clock lock_state_readable")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceSyncCapabilityProfile:
+ enabled_readable: bool
+ polarity_readable: bool
+ source_channel_readable: bool
+ source_channels: tuple[int, ...] = ()
+
+ def __post_init__(self) -> None:
+ _require_bool(self.enabled_readable, "sync enabled_readable")
+ _require_bool(self.polarity_readable, "sync polarity_readable")
+ _require_bool(self.source_channel_readable, "sync source_channel_readable")
+ _require_positive_channels(
+ self.source_channels,
+ "sync source_channels",
+ allow_empty=not self.source_channel_readable,
)
- _require_bool(self.sync_readable, "clock sync_readable")
- _require_bool(self.cascade_readable, "clock cascade_readable")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCascadeCapabilityProfile:
+ enabled_readable: bool
+ role_readable: bool
+
+ def __post_init__(self) -> None:
+ _require_bool(self.enabled_readable, "cascade enabled_readable")
+ _require_bool(self.role_readable, "cascade role_readable")
@dataclass(frozen=True, slots=True)
@@ -803,7 +833,9 @@ def __post_init__(self) -> None:
| SourcePulseCapabilityProfile
| SourceArbitraryCapabilityProfile
| SourceCounterCapabilityProfile
- | SourceClockSyncCapabilityProfile
+ | SourceReferenceClockCapabilityProfile
+ | SourceSyncCapabilityProfile
+ | SourceCascadeCapabilityProfile
| SourceCouplingCapabilityProfile
| SourceCrossChannelCapabilityProfile
)
@@ -873,7 +905,7 @@ class SourceFieldId(StrEnum):
PHASE_RELATION = "source.cross_channel.phase_relation"
RELATION_GRAPH = "source.cross_channel.relation_graph"
REFERENCE_CLOCK = "source.instrument.reference_clock"
- SYNC = "source.instrument.sync"
+ SYNC = "source.channel.sync"
CASCADE = "source.instrument.cascade"
SHARED_POWER = "source.instrument.shared_power"
COUNTER = "source.input.counter"
@@ -900,12 +932,8 @@ class SourceFieldId(StrEnum):
SourceFieldId.PHASE_RELATION: frozenset({SourceFacetScope.CHANNEL_SET}),
SourceFieldId.RELATION_GRAPH: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFieldId.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}),
- SourceFieldId.SYNC: frozenset(
- {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET}
- ),
- SourceFieldId.CASCADE: frozenset(
- {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET}
- ),
+ SourceFieldId.SYNC: frozenset({SourceFacetScope.CHANNEL}),
+ SourceFieldId.CASCADE: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFieldId.SHARED_POWER: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFieldId.COUNTER: frozenset({SourceFacetScope.INPUT}),
}
@@ -1894,9 +1922,9 @@ def __post_init__(self) -> None:
SourceFeature.PULSE: SourcePulseCapabilityProfile,
SourceFeature.ARBITRARY: SourceArbitraryCapabilityProfile,
SourceFeature.COUNTER: SourceCounterCapabilityProfile,
- SourceFeature.REFERENCE_CLOCK: SourceClockSyncCapabilityProfile,
- SourceFeature.SYNC: SourceClockSyncCapabilityProfile,
- SourceFeature.CASCADE: SourceClockSyncCapabilityProfile,
+ SourceFeature.REFERENCE_CLOCK: SourceReferenceClockCapabilityProfile,
+ SourceFeature.SYNC: SourceSyncCapabilityProfile,
+ SourceFeature.CASCADE: SourceCascadeCapabilityProfile,
SourceFeature.COMBINE: SourceCrossChannelCapabilityProfile,
SourceFeature.TRACKING: SourceCrossChannelCapabilityProfile,
SourceFeature.COUPLING: SourceCouplingCapabilityProfile,
@@ -1916,12 +1944,8 @@ def __post_init__(self) -> None:
SourceFeature.ARBITRARY: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.COUNTER: frozenset({SourceFacetScope.INPUT}),
SourceFeature.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}),
- SourceFeature.SYNC: frozenset(
- {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET}
- ),
- SourceFeature.CASCADE: frozenset(
- {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET}
- ),
+ SourceFeature.SYNC: frozenset({SourceFacetScope.CHANNEL}),
+ SourceFeature.CASCADE: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFeature.COMBINE: frozenset(
{SourceFacetScope.CHANNEL_SET, SourceFacetScope.INSTRUMENT}
),
@@ -4137,7 +4161,7 @@ def __post_init__(self) -> None:
@dataclass(frozen=True, slots=True)
class SourceSyncState:
enabled: Observed[bool]
- polarity: Observed[SourceOutputPolarity]
+ polarity: Observed[SourceSyncPolarity]
source_channel: Observed[int]
def __post_init__(self) -> None:
@@ -4146,6 +4170,11 @@ def __post_init__(self) -> None:
_require_observed(self.source_channel, "sync source_channel")
if self.enabled.availability is Availability.VALUE:
_require_bool(self.enabled.value, "sync enabled value")
+ if self.polarity.availability is Availability.VALUE and not isinstance(
+ self.polarity.value,
+ SourceSyncPolarity,
+ ):
+ raise ValueError("sync polarity value has an invalid type")
if self.source_channel.availability is Availability.VALUE:
_require_int(self.source_channel.value, "sync source_channel value", minimum=1)
@@ -4345,7 +4374,6 @@ def __post_init__(self) -> None:
class SourceSystemStateV2:
counters: tuple[SourceCounterInputState, ...]
reference_clock: Observed[SourceReferenceClockState]
- sync: Observed[SourceSyncState]
cascade: Observed[SourceCascadeState]
def __post_init__(self) -> None:
@@ -4357,7 +4385,6 @@ def __post_init__(self) -> None:
if len(set(ids)) != len(ids) or tuple(sorted(ids)) != ids:
raise ValueError("source system counters must be sorted by input_id and unique")
_require_observed(self.reference_clock, "source system reference_clock")
- _require_observed(self.sync, "source system sync")
_require_observed(self.cascade, "source system cascade")
@@ -4391,6 +4418,7 @@ class SourceChannelStateV2:
burst: Observed[BurstFacet]
pulse: Observed[PulseFacet]
arbitrary: Observed[ArbitraryFacet]
+ sync: Observed[SourceSyncState]
def __post_init__(self) -> None:
_require_int(self.channel, "source channel state channel", minimum=1)
@@ -4403,6 +4431,7 @@ def __post_init__(self) -> None:
("burst", self.burst),
("pulse", self.pulse),
("arbitrary", self.arbitrary),
+ ("sync", self.sync),
):
_require_observed(value, f"source channel state {name}")
@@ -4746,6 +4775,10 @@ def __post_init__(self) -> None:
feature.profile.input_ids
) <= set(self.topology.input_ids):
raise ValueError("source counter profile references an unknown input_id")
+ if isinstance(feature.profile, SourceSyncCapabilityProfile) and not set(
+ feature.profile.source_channels
+ ) <= set(self.topology.channels):
+ raise ValueError("source sync profile references an unknown source channel")
if isinstance(feature.profile, SourceCrossChannelCapabilityProfile) and any(
not set(channel_set) <= set(self.topology.channels)
for channel_set in feature.profile.supported_channel_sets
@@ -5149,7 +5182,6 @@ def source_snapshot_timestamp_utc() -> str:
"SourceBurstMode",
"SourceCascadeState",
"SourceChannelStateV2",
- "SourceClockSyncCapabilityProfile",
"SourceComponentAmplitude",
"SourceConstraintApplicability",
"SourceCounterCapabilityProfile",
@@ -5326,4 +5358,8 @@ def source_snapshot_timestamp_utc() -> str:
"SourceCouplingParameter",
"SourceCouplingParameterKind",
"SourceCouplingState",
+ "SourceReferenceClockCapabilityProfile",
+ "SourceSyncCapabilityProfile",
+ "SourceSyncPolarity",
+ "SourceCascadeCapabilityProfile",
]
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 1c1c35c..95f9fe9 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -3929,6 +3929,7 @@ def _source_cross_channel_fields(
SourceFieldId.BURST,
SourceFieldId.PULSE,
SourceFieldId.ARBITRARY_SELECTION,
+ SourceFieldId.SYNC,
}
relation_fields = {
SourceFieldId.COMBINE,
@@ -3941,7 +3942,6 @@ def _source_cross_channel_fields(
SourceFieldId.IDENTITY,
SourceFieldId.RELATION_GRAPH,
SourceFieldId.REFERENCE_CLOCK,
- SourceFieldId.SYNC,
SourceFieldId.CASCADE,
SourceFieldId.SHARED_POWER,
}
@@ -4073,6 +4073,7 @@ def _source_cross_channel_snapshot_observation(
SourceFieldId.BURST: target.burst,
SourceFieldId.PULSE: target.pulse,
SourceFieldId.ARBITRARY_SELECTION: target.arbitrary,
+ SourceFieldId.SYNC: target.sync,
}
if field.field is SourceFieldId.DISPLAY_LOAD:
if target.output.availability is not Availability.VALUE or not isinstance(
@@ -4113,7 +4114,6 @@ def _source_cross_channel_snapshot_observation(
return snapshot.system
system_values = {
SourceFieldId.REFERENCE_CLOCK: snapshot.system.value.reference_clock,
- SourceFieldId.SYNC: snapshot.system.value.sync,
SourceFieldId.CASCADE: snapshot.system.value.cascade,
}
try:
diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py
index 7436a45..7dd2036 100644
--- a/src/wavebench/services/source_snapshot_v2.py
+++ b/src/wavebench/services/source_snapshot_v2.py
@@ -47,6 +47,8 @@
SourceSemanticQueryPlan,
SourceSnapshotConsistency,
SourceSnapshotV2,
+ SourceSyncCapabilityProfile,
+ SourceSyncState,
SourceSystemStateV2,
SourceTypedObservation,
SweepFacet,
@@ -553,6 +555,35 @@ def _channel_state(
features: tuple[SourceFeatureCapability, ...],
) -> SourceChannelStateV2:
target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel)
+ sync = _field_value(
+ values,
+ SourceFieldRef(SourceFieldId.SYNC, target),
+ features,
+ SourceFeature.SYNC,
+ )
+ if sync.availability is Availability.VALUE:
+ state = sync.value
+ assert isinstance(state, SourceSyncState)
+ profile = next(
+ (
+ feature.profile
+ for feature in features
+ if feature.feature is SourceFeature.SYNC
+ and feature.scope is SourceFacetScope.CHANNEL
+ and feature.channels == (channel,)
+ and isinstance(feature.profile, SourceSyncCapabilityProfile)
+ ),
+ None,
+ )
+ if profile is None:
+ raise SourceSnapshotContractError("source sync observation has no runtime profile")
+ if state.source_channel.availability is Availability.VALUE and (
+ not profile.source_channel_readable
+ or state.source_channel.value not in profile.source_channels
+ ):
+ raise SourceSnapshotContractError(
+ "source sync observation references an undeclared source channel"
+ )
return SourceChannelStateV2(
channel=channel,
basic=_field_value(
@@ -603,6 +634,7 @@ def _channel_state(
features,
SourceFeature.ARBITRARY,
),
+ sync=sync,
)
@@ -633,12 +665,6 @@ def _system_state(
features,
SourceFeature.REFERENCE_CLOCK,
),
- sync=_field_value(
- values,
- SourceFieldRef(SourceFieldId.SYNC, instrument),
- features,
- SourceFeature.SYNC,
- ),
cascade=_field_value(
values,
SourceFieldRef(SourceFieldId.CASCADE, instrument),
diff --git a/tests/source_v2_fixtures.py b/tests/source_v2_fixtures.py
index c9a338e..8beba4a 100644
--- a/tests/source_v2_fixtures.py
+++ b/tests/source_v2_fixtures.py
@@ -38,6 +38,7 @@
SourceRuntimeIdentity,
SourceSafetyProfile,
SourceTopologyContract,
+ SourceSyncState,
SourceTypedObservation,
SourceWaveformKind,
SupportState,
@@ -239,11 +240,13 @@ def __init__(
drift: bool = False,
harmonic_unavailable: bool = False,
anchor_unknown: bool = False,
+ sync_state: SourceSyncState | None = None,
) -> None:
self.combined = combined
self.drift = drift
self.harmonic_unavailable = harmonic_unavailable
self.anchor_unknown = anchor_unknown
+ self.sync_state = sync_state
self.plans = []
self.closed = False
@@ -287,6 +290,10 @@ def execute_source_query_plan_v2(self, plan):
value = output_facet(
enabled=(self.drift and item.phase.value == "anchor_after")
)
+ elif field.field is SourceFieldId.SYNC:
+ if self.sync_state is None:
+ raise AssertionError("sync state was not configured")
+ value = self.sync_state
else:
raise AssertionError(field)
observations.append(SourceTypedObservation(field, value))
diff --git a/tests/test_source_budget.py b/tests/test_source_budget.py
index 64cccad..be6879b 100644
--- a/tests/test_source_budget.py
+++ b/tests/test_source_budget.py
@@ -178,6 +178,7 @@ def _channel(
burst=_missing(),
pulse=_missing(),
arbitrary=arbitrary or _missing(),
+ sync=_missing(),
)
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index b6adbd7..9e079b2 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -126,7 +126,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[relation_start + len(relation_exports) :] == match.group(1).splitlines()
+ coupling_exports = match.group(1).splitlines()
+ coupling_start = relation_start + len(relation_exports)
+ assert module.__all__[coupling_start : coupling_start + len(coupling_exports)] == coupling_exports
+ match = re.search(
+ r"首次稳定版 Sync 只读模型修正在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[coupling_start + len(coupling_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -242,10 +251,20 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"reference_channel",
"dimensions",
),
- "SourceClockSyncCapabilityProfile": (
- "reference_clock_modes",
- "sync_readable",
- "cascade_readable",
+ "SourceReferenceClockCapabilityProfile": (
+ "modes",
+ "frequency_readable",
+ "lock_state_readable",
+ ),
+ "SourceSyncCapabilityProfile": (
+ "enabled_readable",
+ "polarity_readable",
+ "source_channel_readable",
+ "source_channels",
+ ),
+ "SourceCascadeCapabilityProfile": (
+ "enabled_readable",
+ "role_readable",
),
"SourceCrossChannelCapabilityProfile": (
"relation_kinds",
@@ -503,9 +522,20 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"SourceSystemStateV2": (
"counters",
"reference_clock",
- "sync",
"cascade",
),
+ "SourceChannelStateV2": (
+ "channel",
+ "basic",
+ "output",
+ "harmonics",
+ "modulation",
+ "sweep",
+ "burst",
+ "pulse",
+ "arbitrary",
+ "sync",
+ ),
"SourceCrossChannelStateV2": (
"relations",
"relation_graph",
@@ -1458,6 +1488,42 @@ def test_source_v2_coupling_read_model_separates_dimensions_and_parameters() ->
)
+def test_source_v2_sync_is_channel_scoped_and_uses_a_dedicated_profile() -> None:
+ profile = module.SourceSyncCapabilityProfile(
+ enabled_readable=True,
+ polarity_readable=True,
+ source_channel_readable=True,
+ source_channels=(1, 2),
+ )
+ feature = module.SourceFeatureCapability(
+ feature=module.SourceFeature.SYNC,
+ support=module.SupportState.SUPPORTED,
+ directions=(module.SourceFeatureDirection.READ,),
+ scope=module.SourceFacetScope.CHANNEL,
+ channels=(1,),
+ applicability=module.SourceConstraintApplicability(),
+ profile=profile,
+ )
+
+ assert feature.profile is profile
+ assert not hasattr(module, "SourceClockSyncCapabilityProfile")
+ with pytest.raises(ValueError, match="cannot use scope"):
+ module.SourceFieldRef(
+ module.SourceFieldId.SYNC,
+ module.SourceScopeRef(module.SourceFacetScope.INSTRUMENT),
+ )
+ with pytest.raises(ValueError, match="cannot use scope"):
+ replace(feature, scope=module.SourceFacetScope.INSTRUMENT, channels=())
+ with pytest.raises(ValueError, match="must not be empty"):
+ module.SourceSyncCapabilityProfile(True, False, True)
+ with pytest.raises(ValueError, match="polarity value has an invalid type"):
+ module.SourceSyncState(
+ enabled=Observed.value_of(False),
+ polarity=Observed.value_of(module.SourceOutputPolarity.NORMAL),
+ source_channel=Observed.value_of(1),
+ )
+
+
def test_source_v2_arbitrary_write_capabilities_require_explicit_readback() -> None:
extensions = source_extensions()
basic, output = extensions.features
diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py
index 64d1899..2eac495 100644
--- a/tests/test_source_snapshot_v2.py
+++ b/tests/test_source_snapshot_v2.py
@@ -28,13 +28,21 @@
from wavebench.instruments.source_extensions import (
SOURCE_OPERATION_ARTIFACT_SCHEMA,
SOURCE_SNAPSHOT_SCHEMA,
+ Observed,
SnapshotConsistencyState,
SourceQueryExecutionRecord,
SourceCrossChannelCapabilityProfile,
SourceFacetScope,
+ SourceFacetQueryContract,
SourceFeature,
SourceFeatureCapability,
+ SourceFeatureDirection,
+ SourceFieldId,
SourceHarmonicPreset,
+ SourceQueryEffect,
+ SourceSyncCapabilityProfile,
+ SourceSyncPolarity,
+ SourceSyncState,
SourceTopologyContract,
SupportState,
source_snapshot_v2_operation_artifact,
@@ -112,6 +120,79 @@ def test_snapshot_v2_accepts_combined_and_scalar_protocol_plans(
assert driver.plans[0].allowed_effects[0].value == "pure_read"
+def test_snapshot_v2_projects_channel_sync_and_validates_source_channel() -> None:
+ extensions = source_extensions_with_harmonics()
+ sync_profile = SourceSyncCapabilityProfile(
+ enabled_readable=True,
+ polarity_readable=True,
+ source_channel_readable=True,
+ source_channels=(1, 2),
+ )
+ sync_feature = SourceFeatureCapability(
+ feature=SourceFeature.SYNC,
+ support=SupportState.SUPPORTED,
+ directions=(SourceFeatureDirection.READ,),
+ scope=SourceFacetScope.CHANNEL,
+ channels=(1,),
+ applicability=SourceConstraintApplicability(),
+ profile=sync_profile,
+ )
+ sync_query = SourceFacetQueryContract(
+ feature=SourceFeature.SYNC,
+ scope=SourceFacetScope.CHANNEL,
+ fields=(SourceFieldId.SYNC,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ )
+ extensions = replace(
+ extensions,
+ topology=SourceTopologyContract((1, 2)),
+ features=(*extensions.features, sync_feature),
+ query_contract=replace(
+ extensions.query_contract,
+ facets=(*extensions.query_contract.facets, sync_query),
+ max_queries=extensions.query_contract.max_queries + 1,
+ ),
+ )
+ state = SourceSyncState(
+ enabled=Observed.value_of(False),
+ polarity=Observed.value_of(SourceSyncPolarity.NEGATIVE),
+ source_channel=Observed.value_of(2),
+ )
+ driver = SourceV2FakeDriver(combined=True, sync_state=state)
+ service = make_service(driver)
+ service.descriptor = source_descriptor(driver=driver, extensions=extensions)
+
+ snapshot = service.snapshot_v2()
+
+ assert snapshot.channels[0].sync.value == state
+ assert snapshot.channels[1].sync.availability.value == "unsupported"
+ sync_item = next(
+ item for item in driver.plans[0].items if item.feature is SourceFeature.SYNC
+ )
+ assert sync_item.target.scope is SourceFacetScope.CHANNEL
+ assert sync_item.target.channel == 1
+
+ invalid_sync = replace(
+ sync_feature,
+ profile=replace(sync_profile, source_channels=(1,)),
+ )
+ invalid_extensions = replace(
+ extensions,
+ features=(*extensions.features[:-1], invalid_sync),
+ )
+ invalid_driver = SourceV2FakeDriver(combined=True, sync_state=state)
+ invalid_service = make_service(invalid_driver)
+ invalid_service.descriptor = source_descriptor(
+ driver=invalid_driver,
+ extensions=invalid_extensions,
+ )
+ with pytest.raises(SourceSnapshotContractError, match="undeclared source channel"):
+ invalid_service.snapshot_v2()
+
+
def test_snapshot_v2_runtime_identity_can_only_narrow_descriptor_features() -> None:
extensions = source_extensions_with_harmonics()
narrowed_output = replace(
From 77a4bcbfb73f9f8f79f01a511182fbf632519b64 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 03:56:59 +0800
Subject: [PATCH 19/44] feat(source): model read-only noise overlay
---
...345\207\272\345\256\211\345\205\250RFC.md" | 39 +++++-
.../source_extension_capabilities.py | 1 +
.../instruments/source_extensions.py | 73 +++++++++++
src/wavebench/services/source_budget.py | 14 ++
src/wavebench/services/source_service.py | 2 +
src/wavebench/services/source_snapshot_v2.py | 37 ++++++
tests/source_v2_fixtures.py | 7 +
tests/test_source_budget.py | 122 ++++++++++++++++++
tests/test_source_extensions.py | 76 ++++++++++-
tests/test_source_snapshot_v2.py | 104 +++++++++++++++
10 files changed, 472 insertions(+), 3 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index cfd53e4..43e34e8 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -597,6 +597,15 @@ SourceSyncPolarity
SourceCascadeCapabilityProfile
```
+首次稳定版 Noise Overlay 只读模型在上述清单末尾追加以下精确条目:
+
+```text
+NoiseOverlayFacet
+SourceNoiseOverlayCapabilityProfile
+SourceNoiseOverlayScale
+SourceNoiseOverlayScaleKind
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
@@ -1011,6 +1020,7 @@ facet 辅助 enum 也使用封闭 value 集:
- `SourceAmplitudeUnit`:`vpp`、`vrms`、`dbm`、`v`、`unknown`;
- `SourceOutputPolarity`:`normal`、`inverted`、`unknown`;
- `SourceSyncPolarity`:`positive`、`negative`、`unknown`;
+- `SourceNoiseOverlayScaleKind`:`percent`、`ratio`、`ratio_db`;
- `SourceLoadKind`:`high_impedance`、`resistive`、`unknown`;
- `SourceModulationKind`:`am`、`dsb_am`、`fm`、`pm`、`pwm`、`ask`、`fsk`、`psk`、`other`;
- `SourceModulationSource`:`internal`、`external`、`channel`、`unknown`;
@@ -1034,7 +1044,7 @@ facet 辅助 enum 也使用封闭 value 集:
机器可读 feature ID 按作用域分为:
-- channel:`basic`、`output`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`、`sync`;
+- channel:`basic`、`output`、`noise_overlay`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`、`sync`;
- system:`counter`、`reference_clock`、`cascade`;
- cross-channel:`combine`、`tracking`、`coupling`、`copy`、`phase_relation`、`shared_power`。
@@ -1065,6 +1075,12 @@ class SourceOutputCapabilityProfile:
polarity_readable: bool
+@dataclass(frozen=True, slots=True)
+class SourceNoiseOverlayCapabilityProfile:
+ enabled_readable: bool
+ scale_kinds: tuple[SourceNoiseOverlayScaleKind, ...] = ()
+
+
@dataclass(frozen=True, slots=True)
class SourceHarmonicCapabilityProfile:
minimum_order: int
@@ -1170,6 +1186,7 @@ class SourceCrossChannelCapabilityProfile:
SourceFeatureProfile: TypeAlias = (
SourceBasicCapabilityProfile
| SourceOutputCapabilityProfile
+ | SourceNoiseOverlayCapabilityProfile
| SourceHarmonicCapabilityProfile
| SourceModulationCapabilityProfile
| SourceSweepCapabilityProfile
@@ -1188,6 +1205,10 @@ SourceFeatureProfile: TypeAlias = (
feature 与 profile 类型使用固定映射;例如 `HARMONICS` 只能使用
`SourceHarmonicCapabilityProfile`。union 新增成员属于公共合同扩展,必须由核心注册并补版本门。
+Noise Overlay 的 `enabled_readable=False` 时,snapshot 不得返回 `enabled=VALUE`。
+`scales=VALUE` 携带的 scale kind 必须与 `scale_kinds` 完全一致;查询失败仍使用
+对应的非 `VALUE` availability,不得删除已声明的 kind 或补造默认值。
+
### facet 作用域
```python
@@ -1217,7 +1238,7 @@ class SourceTopologyContract:
格式的 `input_id`;`INSTRUMENT` 不携带这些字段。所有通道必须属于 topology。
`SourceTopologyContract.channels` 必须递增、唯一且非空;`input_ids` 必须排序稳定且不重复。
-- `basic`、`output`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst`、`arbitrary` 和 `sync`
+- `basic`、`output`、`noise_overlay`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst`、`arbitrary` 和 `sync`
通常属于 `CHANNEL`;
- `combine`、`coupling` 和 `tracking` 属于 `CHANNEL_SET`,并明确列出关系参与者;
- `reference_clock` 和 `cascade` 属于 `INSTRUMENT`;
@@ -1236,6 +1257,7 @@ class SourceFieldId(StrEnum):
IDENTITY = "source.identity"
BASIC = "source.channel.basic"
OUTPUT = "source.channel.output"
+ NOISE_OVERLAY = "source.channel.noise_overlay"
DISPLAY_LOAD = "source.channel.display_load"
HARMONICS = "source.channel.harmonics"
MODULATION = "source.channel.modulation"
@@ -1567,6 +1589,12 @@ class ArbitraryFacet:
storage_digest: Observed[str]
+@dataclass(frozen=True, slots=True)
+class NoiseOverlayFacet:
+ enabled: Observed[bool]
+ scales: Observed[tuple[SourceNoiseOverlayScale, ...]]
+
+
@dataclass(frozen=True, slots=True)
class SourceSystemStateV2:
counters: tuple[SourceCounterInputState, ...]
@@ -1593,6 +1621,7 @@ class SourceChannelStateV2:
pulse: Observed[PulseFacet]
arbitrary: Observed[ArbitraryFacet]
sync: Observed[SourceSyncState]
+ noise_overlay: Observed[NoiseOverlayFacet]
@dataclass(frozen=True, slots=True)
@@ -1621,6 +1650,7 @@ class SourceSnapshotV2:
gate_time_s, trigger_level_v, statistics_enabled)`、
`SourceReferenceClockState(mode, frequency_hz, locked)`、
`SourceSyncState(enabled, polarity, source_channel)`、`SourceCascadeState(enabled, role)`、
+`SourceNoiseOverlayScale(kind, value)`、
`SourceRelationState(feature, channels, enabled)`、
`SourceCouplingState(feature, channels, enabled, reference_channel, dimensions)` 和
`SourceSharedPowerState(participants, active_power_upper_w, hard_limit_w)`。
@@ -2135,6 +2165,7 @@ class SourceBudgetBlockerCode(StrEnum):
MODULATION_CONSTRAINT_MISSING = "modulation_constraint_missing"
ARBITRARY_OVERSHOOT_MISSING = "arbitrary_overshoot_missing"
NOISE_PEAK_MISSING = "noise_peak_missing"
+ NOISE_OVERLAY_BOUND_MISSING = "noise_overlay_bound_missing"
SWEEP_DERATING_MISSING = "sweep_derating_missing"
ACTIVE_CHANNEL_UNKNOWN = "active_channel_unknown"
COMBINE_STATE_UNAVAILABLE = "combine_state_unavailable"
@@ -2303,6 +2334,10 @@ vpp_upper_v = maximum_v_upper - minimum_v_lower
- ARB 必须计入样本归一化、输出滤波或插值可能产生的 overshoot 边界;无法给出边界时为 `UNKNOWN`;
- Sweep 在完整频率范围内取最大边界并应用频率降额;
- Noise 只有在型号和固件范围内存在已审计的硬峰值上界时,才能进入 `HARD_CONSERVATIVE`;
+- Noise Overlay 与基本 Noise 波形是两个独立 contributor。当前只读模型没有冻结叠加噪声的硬电压
+ 边界;只有 runtime 明确为 `UNSUPPORTED`/`NOT_APPLICABLE`,或 `enabled=VALUE(False)` 时,
+ 才能沿用基本波形预算。已启用或启用状态不是 `VALUE` 时返回
+ `NOISE_OVERLAY_BOUND_MISSING`;`SourceNoisePeakConstraint` 不得替代该边界;
- RMS 无法保守计算时可以为 `None`,但若配置了 RMS 上限,该缺失会成为 blocker;
- 多通道共享功率限制必须由 typed constraint 表示或返回可审查的计算结果,不允许使用自由文本声明。
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index d299dfc..56dd1b7 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -255,6 +255,7 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None:
field_features = {
SourceFieldId.BASIC: frozenset({SourceFeature.BASIC}),
SourceFieldId.OUTPUT: frozenset({SourceFeature.OUTPUT}),
+ SourceFieldId.NOISE_OVERLAY: frozenset({SourceFeature.NOISE_OVERLAY}),
SourceFieldId.DISPLAY_LOAD: frozenset({SourceFeature.OUTPUT}),
SourceFieldId.HARMONICS: frozenset({SourceFeature.HARMONICS}),
SourceFieldId.MODULATION: frozenset({SourceFeature.MODULATION}),
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index bb762ee..b3cce49 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -165,6 +165,7 @@ def _contains_nonfinite(value: object) -> bool:
class SourceFeature(StrEnum):
BASIC = "basic"
OUTPUT = "output"
+ NOISE_OVERLAY = "noise_overlay"
HARMONICS = "harmonics"
MODULATION = "modulation"
SWEEP = "sweep"
@@ -241,6 +242,12 @@ class SourceSyncPolarity(StrEnum):
UNKNOWN = "unknown"
+class SourceNoiseOverlayScaleKind(StrEnum):
+ PERCENT = "percent"
+ RATIO = "ratio"
+ RATIO_DB = "ratio_db"
+
+
class SourceLoadKind(StrEnum):
HIGH_IMPEDANCE = "high_impedance"
RESISTIVE = "resistive"
@@ -521,6 +528,21 @@ def __post_init__(self) -> None:
_require_bool(self.polarity_readable, "output polarity_readable")
+@dataclass(frozen=True, slots=True)
+class SourceNoiseOverlayCapabilityProfile:
+ enabled_readable: bool
+ scale_kinds: tuple[SourceNoiseOverlayScaleKind, ...] = ()
+
+ def __post_init__(self) -> None:
+ _require_bool(self.enabled_readable, "noise overlay enabled_readable")
+ _require_enum_tuple(
+ self.scale_kinds,
+ SourceNoiseOverlayScaleKind,
+ "noise overlay scale_kinds",
+ allow_empty=True,
+ )
+
+
@dataclass(frozen=True, slots=True)
class SourceHarmonicCapabilityProfile:
minimum_order: int
@@ -826,6 +848,7 @@ def __post_init__(self) -> None:
SourceFeatureProfile: TypeAlias = (
SourceBasicCapabilityProfile
| SourceOutputCapabilityProfile
+ | SourceNoiseOverlayCapabilityProfile
| SourceHarmonicCapabilityProfile
| SourceModulationCapabilityProfile
| SourceSweepCapabilityProfile
@@ -888,6 +911,7 @@ class SourceFieldId(StrEnum):
IDENTITY = "source.identity"
BASIC = "source.channel.basic"
OUTPUT = "source.channel.output"
+ NOISE_OVERLAY = "source.channel.noise_overlay"
DISPLAY_LOAD = "source.channel.display_load"
HARMONICS = "source.channel.harmonics"
MODULATION = "source.channel.modulation"
@@ -915,6 +939,7 @@ class SourceFieldId(StrEnum):
SourceFieldId.IDENTITY: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFieldId.BASIC: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.OUTPUT: frozenset({SourceFacetScope.CHANNEL}),
+ SourceFieldId.NOISE_OVERLAY: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.DISPLAY_LOAD: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.HARMONICS: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.MODULATION: frozenset({SourceFacetScope.CHANNEL}),
@@ -1915,6 +1940,7 @@ def __post_init__(self) -> None:
_FEATURE_PROFILE_TYPES: dict[SourceFeature, type[object]] = {
SourceFeature.BASIC: SourceBasicCapabilityProfile,
SourceFeature.OUTPUT: SourceOutputCapabilityProfile,
+ SourceFeature.NOISE_OVERLAY: SourceNoiseOverlayCapabilityProfile,
SourceFeature.HARMONICS: SourceHarmonicCapabilityProfile,
SourceFeature.MODULATION: SourceModulationCapabilityProfile,
SourceFeature.SWEEP: SourceSweepCapabilityProfile,
@@ -1936,6 +1962,7 @@ def __post_init__(self) -> None:
_FEATURE_SCOPES: dict[SourceFeature, frozenset[SourceFacetScope]] = {
SourceFeature.BASIC: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.OUTPUT: frozenset({SourceFacetScope.CHANNEL}),
+ SourceFeature.NOISE_OVERLAY: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.HARMONICS: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.MODULATION: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.SWEEP: frozenset({SourceFacetScope.CHANNEL}),
@@ -2113,6 +2140,7 @@ class SourceBudgetBlockerCode(StrEnum):
MODULATION_CONSTRAINT_MISSING = "modulation_constraint_missing"
ARBITRARY_OVERSHOOT_MISSING = "arbitrary_overshoot_missing"
NOISE_PEAK_MISSING = "noise_peak_missing"
+ NOISE_OVERLAY_BOUND_MISSING = "noise_overlay_bound_missing"
SWEEP_DERATING_MISSING = "sweep_derating_missing"
ACTIVE_CHANNEL_UNKNOWN = "active_channel_unknown"
COMBINE_STATE_UNAVAILABLE = "combine_state_unavailable"
@@ -2660,6 +2688,43 @@ def __post_init__(self) -> None:
_require_bool(self.enabled.value, "output enabled value")
+@dataclass(frozen=True, slots=True)
+class SourceNoiseOverlayScale:
+ kind: SourceNoiseOverlayScaleKind
+ value: float
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.kind, SourceNoiseOverlayScaleKind):
+ raise ValueError("noise overlay scale kind has an invalid type")
+ _require_finite(
+ self.value,
+ "noise overlay scale value",
+ minimum=None if self.kind is SourceNoiseOverlayScaleKind.RATIO_DB else 0.0,
+ maximum=100.0 if self.kind is SourceNoiseOverlayScaleKind.PERCENT else None,
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class NoiseOverlayFacet:
+ enabled: Observed[bool]
+ scales: Observed[tuple[SourceNoiseOverlayScale, ...]]
+
+ def __post_init__(self) -> None:
+ _require_observed(self.enabled, "noise overlay enabled")
+ _require_observed(self.scales, "noise overlay scales")
+ if self.enabled.availability is Availability.VALUE:
+ _require_bool(self.enabled.value, "noise overlay enabled value")
+ if self.scales.availability is Availability.VALUE:
+ scales = self.scales.value
+ if not isinstance(scales, tuple) or any(
+ not isinstance(item, SourceNoiseOverlayScale) for item in scales
+ ):
+ raise ValueError("noise overlay scales value has an invalid type")
+ keys = tuple(item.kind.value for item in scales)
+ if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys:
+ raise ValueError("noise overlay scales must be sorted and unique")
+
+
@dataclass(frozen=True, slots=True)
class SourceBasicPatch:
waveform_kind: PatchValue[SourceWaveformKind] = PatchValue(PatchAction.KEEP)
@@ -4419,6 +4484,7 @@ class SourceChannelStateV2:
pulse: Observed[PulseFacet]
arbitrary: Observed[ArbitraryFacet]
sync: Observed[SourceSyncState]
+ noise_overlay: Observed[NoiseOverlayFacet]
def __post_init__(self) -> None:
_require_int(self.channel, "source channel state channel", minimum=1)
@@ -4432,6 +4498,7 @@ def __post_init__(self) -> None:
("pulse", self.pulse),
("arbitrary", self.arbitrary),
("sync", self.sync),
+ ("noise_overlay", self.noise_overlay),
):
_require_observed(value, f"source channel state {name}")
@@ -4611,6 +4678,7 @@ class SourceQueryItemOutcome(StrEnum):
SourceRuntimeIdentity
| BasicWaveFacet
| OutputFacet
+ | NoiseOverlayFacet
| SourceDisplayLoad
| HarmonicFacet
| ModulationFacet
@@ -4635,6 +4703,7 @@ class SourceQueryItemOutcome(StrEnum):
SourceFieldId.IDENTITY: SourceRuntimeIdentity,
SourceFieldId.BASIC: BasicWaveFacet,
SourceFieldId.OUTPUT: OutputFacet,
+ SourceFieldId.NOISE_OVERLAY: NoiseOverlayFacet,
SourceFieldId.DISPLAY_LOAD: SourceDisplayLoad,
SourceFieldId.HARMONICS: HarmonicFacet,
SourceFieldId.MODULATION: ModulationFacet,
@@ -5362,4 +5431,8 @@ def source_snapshot_timestamp_utc() -> str:
"SourceSyncCapabilityProfile",
"SourceSyncPolarity",
"SourceCascadeCapabilityProfile",
+ "NoiseOverlayFacet",
+ "SourceNoiseOverlayCapabilityProfile",
+ "SourceNoiseOverlayScale",
+ "SourceNoiseOverlayScaleKind",
]
diff --git a/src/wavebench/services/source_budget.py b/src/wavebench/services/source_budget.py
index 92b6955..dd31c9d 100644
--- a/src/wavebench/services/source_budget.py
+++ b/src/wavebench/services/source_budget.py
@@ -541,6 +541,20 @@ def _reference_bounds(
proof = BudgetProofStrength.HARD_CONSERVATIVE
constraint_ids: set[str] = set()
+ if channel.noise_overlay.availability not in {
+ Availability.UNSUPPORTED,
+ Availability.NOT_APPLICABLE,
+ }:
+ if channel.noise_overlay.availability is not Availability.VALUE:
+ blockers.add(SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING)
+ else:
+ noise_overlay = channel.noise_overlay.value
+ if (
+ noise_overlay.enabled.availability is not Availability.VALUE
+ or noise_overlay.enabled.value
+ ):
+ blockers.add(SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING)
+
if facts.waveform_kind is SourceWaveformKind.NOISE:
constraints, noise_proof = _constraints(
request,
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 95f9fe9..933e3c9 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -3930,6 +3930,7 @@ def _source_cross_channel_fields(
SourceFieldId.PULSE,
SourceFieldId.ARBITRARY_SELECTION,
SourceFieldId.SYNC,
+ SourceFieldId.NOISE_OVERLAY,
}
relation_fields = {
SourceFieldId.COMBINE,
@@ -4074,6 +4075,7 @@ def _source_cross_channel_snapshot_observation(
SourceFieldId.PULSE: target.pulse,
SourceFieldId.ARBITRARY_SELECTION: target.arbitrary,
SourceFieldId.SYNC: target.sync,
+ SourceFieldId.NOISE_OVERLAY: target.noise_overlay,
}
if field.field is SourceFieldId.DISPLAY_LOAD:
if target.output.availability is not Availability.VALUE or not isinstance(
diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py
index 7dd2036..d9cead2 100644
--- a/src/wavebench/services/source_snapshot_v2.py
+++ b/src/wavebench/services/source_snapshot_v2.py
@@ -15,6 +15,7 @@
BurstFacet,
HarmonicFacet,
ModulationFacet,
+ NoiseOverlayFacet,
Observed,
OutputFacet,
SnapshotConsistencyState,
@@ -34,6 +35,7 @@
SourceFeatureCapability,
SourceFieldId,
SourceFieldRef,
+ SourceNoiseOverlayCapabilityProfile,
SourceQueryExecutionRecord,
SourceQueryEffect,
SourceQueryItemOutcome,
@@ -584,6 +586,40 @@ def _channel_state(
raise SourceSnapshotContractError(
"source sync observation references an undeclared source channel"
)
+ noise_overlay = _field_value(
+ values,
+ SourceFieldRef(SourceFieldId.NOISE_OVERLAY, target),
+ features,
+ SourceFeature.NOISE_OVERLAY,
+ )
+ if noise_overlay.availability is Availability.VALUE:
+ state = noise_overlay.value
+ assert isinstance(state, NoiseOverlayFacet)
+ profile = next(
+ (
+ feature.profile
+ for feature in features
+ if feature.feature is SourceFeature.NOISE_OVERLAY
+ and feature.scope is SourceFacetScope.CHANNEL
+ and feature.channels == (channel,)
+ and isinstance(feature.profile, SourceNoiseOverlayCapabilityProfile)
+ ),
+ None,
+ )
+ if profile is None:
+ raise SourceSnapshotContractError(
+ "source noise overlay observation has no runtime profile"
+ )
+ if state.enabled.availability is Availability.VALUE and not profile.enabled_readable:
+ raise SourceSnapshotContractError(
+ "source noise overlay observation reports unreadable enabled state"
+ )
+ if state.scales.availability is Availability.VALUE and tuple(
+ scale.kind for scale in state.scales.value
+ ) != profile.scale_kinds:
+ raise SourceSnapshotContractError(
+ "source noise overlay observation scale kinds do not match runtime profile"
+ )
return SourceChannelStateV2(
channel=channel,
basic=_field_value(
@@ -635,6 +671,7 @@ def _channel_state(
SourceFeature.ARBITRARY,
),
sync=sync,
+ noise_overlay=noise_overlay,
)
diff --git a/tests/source_v2_fixtures.py b/tests/source_v2_fixtures.py
index 8beba4a..0d4580f 100644
--- a/tests/source_v2_fixtures.py
+++ b/tests/source_v2_fixtures.py
@@ -7,6 +7,7 @@
SOURCE_CONTRACT_VERSION,
Availability,
BasicWaveFacet,
+ NoiseOverlayFacet,
Observed,
OutputFacet,
SourceAmplitude,
@@ -241,12 +242,14 @@ def __init__(
harmonic_unavailable: bool = False,
anchor_unknown: bool = False,
sync_state: SourceSyncState | None = None,
+ noise_overlay: NoiseOverlayFacet | None = None,
) -> None:
self.combined = combined
self.drift = drift
self.harmonic_unavailable = harmonic_unavailable
self.anchor_unknown = anchor_unknown
self.sync_state = sync_state
+ self.noise_overlay = noise_overlay
self.plans = []
self.closed = False
@@ -294,6 +297,10 @@ def execute_source_query_plan_v2(self, plan):
if self.sync_state is None:
raise AssertionError("sync state was not configured")
value = self.sync_state
+ elif field.field is SourceFieldId.NOISE_OVERLAY:
+ if self.noise_overlay is None:
+ raise AssertionError("noise overlay was not configured")
+ value = self.noise_overlay
else:
raise AssertionError(field)
observations.append(SourceTypedObservation(field, value))
diff --git a/tests/test_source_budget.py b/tests/test_source_budget.py
index be6879b..b5fa4ee 100644
--- a/tests/test_source_budget.py
+++ b/tests/test_source_budget.py
@@ -14,6 +14,7 @@
HarmonicCompleteness,
HarmonicFacet,
ModulationFacet,
+ NoiseOverlayFacet,
Observed,
OutputFacet,
ResistanceBounds,
@@ -22,6 +23,7 @@
SourceAmplitudeUnit,
SourceArbitraryOvershootConstraint,
SourceArbitraryPlaybackMode,
+ SourceBudgetBlockerCode,
SourceChannelStateV2,
SourceComponentAmplitude,
SourceConstraintApplicability,
@@ -42,6 +44,8 @@
SourceModulationEnvelopeConstraint,
SourceModulationKind,
SourceModulationSource,
+ SourceNoiseOverlayScale,
+ SourceNoiseOverlayScaleKind,
SourceOutputPolarity,
SourceNoisePeakConstraint,
SourceReasonCode,
@@ -145,6 +149,7 @@ def _channel(
modulation: Observed[ModulationFacet] | None = None,
sweep: Observed[SweepFacet] | None = None,
arbitrary: Observed[ArbitraryFacet] | None = None,
+ noise_overlay: Observed[NoiseOverlayFacet] | None = None,
) -> SourceChannelStateV2:
return SourceChannelStateV2(
channel=channel,
@@ -179,6 +184,7 @@ def _channel(
pulse=_missing(),
arbitrary=arbitrary or _missing(),
sync=_missing(),
+ noise_overlay=noise_overlay or _missing(),
)
@@ -354,6 +360,122 @@ def test_basic_open_circuit_budget_is_pure_and_authorizable() -> None:
assert budget.shared_power.availability is Availability.NOT_APPLICABLE
+def test_disabled_noise_overlay_preserves_the_basic_waveform_budget() -> None:
+ extensions = _extensions(safety_profile=_constraints())
+ without_overlay = _snapshot(extensions, (_channel(1),))
+ with_overlay = _snapshot(
+ extensions,
+ (
+ _channel(
+ 1,
+ noise_overlay=Observed.value_of(
+ NoiseOverlayFacet(
+ enabled=Observed.value_of(False),
+ scales=Observed.value_of(
+ (
+ SourceNoiseOverlayScale(
+ SourceNoiseOverlayScaleKind.PERCENT,
+ 10.0,
+ ),
+ )
+ ),
+ )
+ ),
+ ),
+ ),
+ )
+
+ assert evaluate_source_output_budget(
+ _request(
+ with_overlay,
+ extensions,
+ terminations=(_termination(with_overlay, 1),),
+ )
+ ) == evaluate_source_output_budget(
+ _request(
+ without_overlay,
+ extensions,
+ terminations=(_termination(without_overlay, 1),),
+ )
+ )
+
+
+def test_enabled_noise_overlay_does_not_reuse_standalone_noise_peak_constraint() -> None:
+ extensions = _extensions(
+ safety_profile=_constraints(
+ _constraint(
+ "safety.noise",
+ SourceSafetyConstraintKind.NOISE_PEAK,
+ SourceNoisePeakConstraint(0.25),
+ )
+ )
+ )
+ snapshot = _snapshot(
+ extensions,
+ (
+ _channel(
+ 1,
+ noise_overlay=Observed.value_of(
+ NoiseOverlayFacet(
+ enabled=Observed.value_of(True),
+ scales=Observed.value_of(
+ (
+ SourceNoiseOverlayScale(
+ SourceNoiseOverlayScaleKind.PERCENT,
+ 10.0,
+ ),
+ )
+ ),
+ )
+ ),
+ ),
+ ),
+ )
+
+ budget = evaluate_source_output_budget(
+ _request(snapshot, extensions, terminations=(_termination(snapshot, 1),))
+ )
+
+ assert not budget.can_authorize_energy
+ assert budget.bounds.availability is Availability.NOT_QUERIED
+ assert budget.blockers == (SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING,)
+
+
+@pytest.mark.parametrize(
+ "noise_overlay",
+ (
+ pytest.param(
+ _missing(Availability.NOT_QUERIED),
+ id="facet-not-queried",
+ ),
+ pytest.param(
+ Observed.value_of(
+ NoiseOverlayFacet(
+ enabled=_missing(Availability.NOT_QUERIED),
+ scales=_missing(Availability.NOT_QUERIED),
+ )
+ ),
+ id="enabled-not-queried",
+ ),
+ ),
+)
+def test_unknown_noise_overlay_state_fails_closed(
+ noise_overlay: Observed[NoiseOverlayFacet],
+) -> None:
+ extensions = _extensions(safety_profile=_constraints())
+ snapshot = _snapshot(
+ extensions,
+ (_channel(1, noise_overlay=noise_overlay),),
+ )
+
+ budget = evaluate_source_output_budget(
+ _request(snapshot, extensions, terminations=(_termination(snapshot, 1),))
+ )
+
+ assert not budget.can_authorize_energy
+ assert SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING in budget.blockers
+
+
def test_missing_or_non_resistive_termination_fails_closed() -> None:
extensions = _extensions(safety_profile=_constraints())
snapshot = _snapshot(extensions, (_channel(1),))
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 9e079b2..2276bee 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -135,7 +135,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[coupling_start + len(coupling_exports) :] == match.group(1).splitlines()
+ sync_exports = match.group(1).splitlines()
+ sync_start = coupling_start + len(coupling_exports)
+ assert module.__all__[sync_start : sync_start + len(sync_exports)] == sync_exports
+ match = re.search(
+ r"首次稳定版 Noise Overlay 只读模型在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[sync_start + len(sync_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -182,6 +191,12 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"display_load_readable",
"polarity_readable",
),
+ "SourceNoiseOverlayCapabilityProfile": (
+ "enabled_readable",
+ "scale_kinds",
+ ),
+ "SourceNoiseOverlayScale": ("kind", "value"),
+ "NoiseOverlayFacet": ("enabled", "scales"),
"SourceHarmonicCapabilityProfile": (
"minimum_order",
"maximum_order",
@@ -535,6 +550,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"pulse",
"arbitrary",
"sync",
+ "noise_overlay",
),
"SourceCrossChannelStateV2": (
"relations",
@@ -1524,6 +1540,64 @@ def test_source_v2_sync_is_channel_scoped_and_uses_a_dedicated_profile() -> None
)
+def test_source_v2_noise_overlay_is_distinct_from_the_noise_waveform() -> None:
+ profile = module.SourceNoiseOverlayCapabilityProfile(
+ enabled_readable=True,
+ scale_kinds=(
+ module.SourceNoiseOverlayScaleKind.PERCENT,
+ module.SourceNoiseOverlayScaleKind.RATIO,
+ module.SourceNoiseOverlayScaleKind.RATIO_DB,
+ ),
+ )
+ facet = module.NoiseOverlayFacet(
+ enabled=Observed.value_of(True),
+ scales=Observed.value_of(
+ (
+ module.SourceNoiseOverlayScale(
+ module.SourceNoiseOverlayScaleKind.PERCENT,
+ 25.0,
+ ),
+ module.SourceNoiseOverlayScale(
+ module.SourceNoiseOverlayScaleKind.RATIO,
+ 0.25,
+ ),
+ module.SourceNoiseOverlayScale(
+ module.SourceNoiseOverlayScaleKind.RATIO_DB,
+ -12.0412,
+ ),
+ )
+ ),
+ )
+
+ assert profile.scale_kinds[-1] is module.SourceNoiseOverlayScaleKind.RATIO_DB
+ assert module.source_v2_to_data(facet)["scales"]["value"][0] == {
+ "type": "SourceNoiseOverlayScale",
+ "kind": "percent",
+ "value": 25.0,
+ }
+ assert module.SourceFeature.NOISE_OVERLAY is not module.SourceFeature.BASIC
+ assert module.SourceFieldId.NOISE_OVERLAY.value == "source.channel.noise_overlay"
+ assert module.SourceNoiseOverlayScale(
+ module.SourceNoiseOverlayScaleKind.PERCENT,
+ 100.0,
+ ).value == 100.0
+ with pytest.raises(ValueError, match="must be >= 0"):
+ module.SourceNoiseOverlayScale(
+ module.SourceNoiseOverlayScaleKind.RATIO,
+ -0.1,
+ )
+ with pytest.raises(ValueError, match="must be <= 100"):
+ module.SourceNoiseOverlayScale(
+ module.SourceNoiseOverlayScaleKind.PERCENT,
+ 100.1,
+ )
+ with pytest.raises(ValueError, match="sorted and unique"):
+ replace(
+ facet,
+ scales=Observed.value_of(tuple(reversed(facet.scales.value))),
+ )
+
+
def test_source_v2_arbitrary_write_capabilities_require_explicit_readback() -> None:
extensions = source_extensions()
basic, output = extensions.features
diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py
index 2eac495..deda348 100644
--- a/tests/test_source_snapshot_v2.py
+++ b/tests/test_source_snapshot_v2.py
@@ -28,6 +28,7 @@
from wavebench.instruments.source_extensions import (
SOURCE_OPERATION_ARTIFACT_SCHEMA,
SOURCE_SNAPSHOT_SCHEMA,
+ NoiseOverlayFacet,
Observed,
SnapshotConsistencyState,
SourceQueryExecutionRecord,
@@ -39,6 +40,9 @@
SourceFeatureDirection,
SourceFieldId,
SourceHarmonicPreset,
+ SourceNoiseOverlayCapabilityProfile,
+ SourceNoiseOverlayScale,
+ SourceNoiseOverlayScaleKind,
SourceQueryEffect,
SourceSyncCapabilityProfile,
SourceSyncPolarity,
@@ -61,6 +65,7 @@
from tests.source_v2_fixtures import (
SourceV2FakeDriver,
source_descriptor,
+ source_extensions,
source_extensions_with_harmonics,
)
from wavebench.instruments.source_extensions import SourceConstraintApplicability
@@ -193,6 +198,105 @@ def test_snapshot_v2_projects_channel_sync_and_validates_source_channel() -> Non
invalid_service.snapshot_v2()
+def test_snapshot_v2_projects_noise_overlay_without_changing_basic_noise_kind() -> None:
+ extensions = source_extensions()
+ noise_feature = SourceFeatureCapability(
+ feature=SourceFeature.NOISE_OVERLAY,
+ support=SupportState.SUPPORTED,
+ directions=(SourceFeatureDirection.READ,),
+ scope=SourceFacetScope.CHANNEL,
+ channels=(1,),
+ applicability=SourceConstraintApplicability(),
+ profile=SourceNoiseOverlayCapabilityProfile(
+ enabled_readable=True,
+ scale_kinds=(SourceNoiseOverlayScaleKind.PERCENT,),
+ ),
+ )
+ noise_query = SourceFacetQueryContract(
+ feature=SourceFeature.NOISE_OVERLAY,
+ scope=SourceFacetScope.CHANNEL,
+ fields=(SourceFieldId.NOISE_OVERLAY,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=2,
+ required=True,
+ )
+ extensions = replace(
+ extensions,
+ features=(extensions.features[0], noise_feature, extensions.features[1]),
+ query_contract=replace(
+ extensions.query_contract,
+ facets=(
+ extensions.query_contract.facets[0],
+ extensions.query_contract.facets[1],
+ noise_query,
+ extensions.query_contract.facets[2],
+ ),
+ max_queries=extensions.query_contract.max_queries + 2,
+ ),
+ )
+ noise = NoiseOverlayFacet(
+ enabled=Observed.value_of(False),
+ scales=Observed.value_of(
+ (
+ SourceNoiseOverlayScale(
+ SourceNoiseOverlayScaleKind.PERCENT,
+ 10.0,
+ ),
+ )
+ ),
+ )
+ driver = SourceV2FakeDriver(combined=True, noise_overlay=noise)
+ service = make_service(driver)
+ service.descriptor = source_descriptor(driver=driver, extensions=extensions)
+
+ snapshot = service.snapshot_v2()
+
+ assert snapshot.channels[0].noise_overlay.value == noise
+ assert snapshot.channels[0].basic.value.waveform_kind.value.value == "sine"
+ noise_item = next(
+ item
+ for item in driver.plans[0].items
+ if item.feature is SourceFeature.NOISE_OVERLAY
+ )
+ assert noise_item.target.scope is SourceFacetScope.CHANNEL
+ assert noise_item.max_queries == 2
+
+ invalid_profiles = (
+ (
+ SourceNoiseOverlayCapabilityProfile(
+ enabled_readable=False,
+ scale_kinds=(SourceNoiseOverlayScaleKind.PERCENT,),
+ ),
+ "unreadable enabled state",
+ ),
+ (
+ SourceNoiseOverlayCapabilityProfile(
+ enabled_readable=True,
+ scale_kinds=(SourceNoiseOverlayScaleKind.RATIO,),
+ ),
+ "scale kinds do not match",
+ ),
+ )
+ for profile, message in invalid_profiles:
+ invalid_extensions = replace(
+ extensions,
+ features=(
+ extensions.features[0],
+ replace(noise_feature, profile=profile),
+ extensions.features[2],
+ ),
+ )
+ invalid_driver = SourceV2FakeDriver(combined=True, noise_overlay=noise)
+ invalid_service = make_service(invalid_driver)
+ invalid_service.descriptor = source_descriptor(
+ driver=invalid_driver,
+ extensions=invalid_extensions,
+ )
+ with pytest.raises(SourceSnapshotContractError, match=message):
+ invalid_service.snapshot_v2()
+
+
def test_snapshot_v2_runtime_identity_can_only_narrow_descriptor_features() -> None:
extensions = source_extensions_with_harmonics()
narrowed_output = replace(
From e5a9c8e9d6552b78c2e0126f54565da6f3fe5f68 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 05:36:49 +0800
Subject: [PATCH 20/44] fix(source): validate extended readback profiles
---
...345\207\272\345\256\211\345\205\250RFC.md" | 8 +
.../instruments/source_extensions.py | 8 +
src/wavebench/services/source_snapshot_v2.py | 49 ++++-
tests/source_v2_fixtures.py | 7 +
tests/test_source_extensions.py | 16 ++
tests/test_source_snapshot_v2.py | 176 ++++++++++++++++++
6 files changed, 263 insertions(+), 1 deletion(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 43e34e8..5953b0f 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -1209,6 +1209,14 @@ Noise Overlay 的 `enabled_readable=False` 时,snapshot 不得返回 `enabled=
`scales=VALUE` 携带的 scale kind 必须与 `scale_kinds` 完全一致;查询失败仍使用
对应的非 `VALUE` availability,不得删除已声明的 kind 或补造默认值。
+Sync 的 `enabled_readable`、`polarity_readable` 和 `source_channel_readable` 也使用同样的
+单向诚实声明:未声明可读的字段不得返回 `VALUE`,运行时暂时无法取得时可以返回
+合适的非 `VALUE` availability。Coupling 的 `CHANNEL_SET` 必须存在于
+`supported_channel_sets`;snapshot 中的 dimensions 必须与 profile 完全一致,已返回的
+parameter kind 必须属于 `parameter_kinds`。`global_state_readable=False` 或
+`reference_channel_readable=False` 时,对应字段不得返回 `VALUE`。全局开关与各
+dimension 开关可以表示主开关和保留配置,核心不强制两者必须相等。
+
### facet 作用域
```python
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index b3cce49..aaf9f06 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -4860,6 +4860,14 @@ def __post_init__(self) -> None:
for channel_set in feature.profile.supported_channel_sets
):
raise ValueError("source coupling profile references an unknown channel")
+ if (
+ isinstance(feature.profile, SourceCouplingCapabilityProfile)
+ and feature.scope is SourceFacetScope.CHANNEL_SET
+ and feature.channels not in feature.profile.supported_channel_sets
+ ):
+ raise ValueError(
+ "source coupling feature channel set is not declared by its profile"
+ )
if not isinstance(self.query_contract, SourceQueryContract):
raise ValueError("source descriptor query_contract has an invalid type")
if not isinstance(self.safety_profile, SourceSafetyProfile):
diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py
index d9cead2..2180eb6 100644
--- a/src/wavebench/services/source_snapshot_v2.py
+++ b/src/wavebench/services/source_snapshot_v2.py
@@ -579,6 +579,14 @@ def _channel_state(
)
if profile is None:
raise SourceSnapshotContractError("source sync observation has no runtime profile")
+ if state.enabled.availability is Availability.VALUE and not profile.enabled_readable:
+ raise SourceSnapshotContractError(
+ "source sync observation reports unreadable enabled state"
+ )
+ if state.polarity.availability is Availability.VALUE and not profile.polarity_readable:
+ raise SourceSnapshotContractError(
+ "source sync observation reports unreadable polarity"
+ )
if state.source_channel.availability is Availability.VALUE and (
not profile.source_channel_readable
or state.source_channel.value not in profile.source_channels
@@ -736,7 +744,46 @@ def _cross_channel_state(
feature.feature,
)
if observed.availability is Availability.VALUE:
- relations.append(observed.value)
+ if feature.feature is not SourceFeature.COUPLING:
+ relations.append(observed.value)
+ continue
+ profile = feature.profile
+ state = observed.value
+ if not isinstance(profile, SourceCouplingCapabilityProfile) or not isinstance(
+ state,
+ SourceCouplingState,
+ ):
+ raise SourceSnapshotContractError(
+ "source coupling observation has an invalid runtime profile"
+ )
+ if state.channels != feature.channels:
+ raise SourceSnapshotContractError(
+ "source coupling observation does not match its channel set"
+ )
+ if state.enabled.availability is Availability.VALUE and not profile.global_state_readable:
+ raise SourceSnapshotContractError(
+ "source coupling observation reports unreadable global state"
+ )
+ if (
+ state.reference_channel.availability is Availability.VALUE
+ and not profile.reference_channel_readable
+ ):
+ raise SourceSnapshotContractError(
+ "source coupling observation reports unreadable reference channel"
+ )
+ if tuple(item.dimension for item in state.dimensions) != profile.dimensions:
+ raise SourceSnapshotContractError(
+ "source coupling observation dimensions do not match runtime profile"
+ )
+ if any(
+ item.parameter.availability is Availability.VALUE
+ and item.parameter.value.kind not in profile.parameter_kinds
+ for item in state.dimensions
+ ):
+ raise SourceSnapshotContractError(
+ "source coupling observation uses an undeclared parameter kind"
+ )
+ relations.append(state)
elif feature.feature is SourceFeature.COUPLING:
profile = feature.profile
if not isinstance(profile, SourceCouplingCapabilityProfile):
diff --git a/tests/source_v2_fixtures.py b/tests/source_v2_fixtures.py
index 0d4580f..9056e22 100644
--- a/tests/source_v2_fixtures.py
+++ b/tests/source_v2_fixtures.py
@@ -14,6 +14,7 @@
SourceAmplitudeUnit,
SourceBasicCapabilityProfile,
SourceConstraintApplicability,
+ SourceCouplingState,
SourceDescriptorExtensions,
SourceFacetQueryContract,
SourceFacetScope,
@@ -243,6 +244,7 @@ def __init__(
anchor_unknown: bool = False,
sync_state: SourceSyncState | None = None,
noise_overlay: NoiseOverlayFacet | None = None,
+ coupling_state: SourceCouplingState | None = None,
) -> None:
self.combined = combined
self.drift = drift
@@ -250,6 +252,7 @@ def __init__(
self.anchor_unknown = anchor_unknown
self.sync_state = sync_state
self.noise_overlay = noise_overlay
+ self.coupling_state = coupling_state
self.plans = []
self.closed = False
@@ -301,6 +304,10 @@ def execute_source_query_plan_v2(self, plan):
if self.noise_overlay is None:
raise AssertionError("noise overlay was not configured")
value = self.noise_overlay
+ elif field.field is SourceFieldId.COUPLING:
+ if self.coupling_state is None:
+ raise AssertionError("coupling state was not configured")
+ value = self.coupling_state
else:
raise AssertionError(field)
observations.append(SourceTypedObservation(field, value))
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 2276bee..7c51a4c 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -1483,6 +1483,22 @@ def test_source_v2_coupling_read_model_separates_dimensions_and_parameters() ->
"kind": "amplitude_ratio",
"value": 0.5,
}
+ base = source_extensions()
+ mismatched_feature = module.SourceFeatureCapability(
+ feature=module.SourceFeature.COUPLING,
+ support=module.SupportState.SUPPORTED,
+ directions=(module.SourceFeatureDirection.READ,),
+ scope=module.SourceFacetScope.CHANNEL_SET,
+ channels=(1, 2),
+ applicability=module.SourceConstraintApplicability(),
+ profile=replace(profile, supported_channel_sets=((1, 3),)),
+ )
+ with pytest.raises(ValueError, match="channel set is not declared"):
+ replace(
+ base,
+ topology=module.SourceTopologyContract((1, 2, 3)),
+ features=(base.features[0], mismatched_feature, base.features[1]),
+ )
with pytest.raises(ValueError, match="does not match its dimension"):
module.SourceCouplingDimensionState(
module.SourceCouplingDimension.PHASE,
diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py
index deda348..f13edcf 100644
--- a/tests/test_source_snapshot_v2.py
+++ b/tests/test_source_snapshot_v2.py
@@ -33,6 +33,12 @@
SnapshotConsistencyState,
SourceQueryExecutionRecord,
SourceCrossChannelCapabilityProfile,
+ SourceCouplingCapabilityProfile,
+ SourceCouplingDimension,
+ SourceCouplingDimensionState,
+ SourceCouplingParameter,
+ SourceCouplingParameterKind,
+ SourceCouplingState,
SourceFacetScope,
SourceFacetQueryContract,
SourceFeature,
@@ -197,6 +203,30 @@ def test_snapshot_v2_projects_channel_sync_and_validates_source_channel() -> Non
with pytest.raises(SourceSnapshotContractError, match="undeclared source channel"):
invalid_service.snapshot_v2()
+ invalid_profiles = (
+ (
+ replace(sync_profile, enabled_readable=False),
+ "unreadable enabled state",
+ ),
+ (
+ replace(sync_profile, polarity_readable=False),
+ "unreadable polarity",
+ ),
+ )
+ for profile, message in invalid_profiles:
+ invalid_extensions = replace(
+ extensions,
+ features=(*extensions.features[:-1], replace(sync_feature, profile=profile)),
+ )
+ invalid_driver = SourceV2FakeDriver(combined=True, sync_state=state)
+ invalid_service = make_service(invalid_driver)
+ invalid_service.descriptor = source_descriptor(
+ driver=invalid_driver,
+ extensions=invalid_extensions,
+ )
+ with pytest.raises(SourceSnapshotContractError, match=message):
+ invalid_service.snapshot_v2()
+
def test_snapshot_v2_projects_noise_overlay_without_changing_basic_noise_kind() -> None:
extensions = source_extensions()
@@ -297,6 +327,152 @@ def test_snapshot_v2_projects_noise_overlay_without_changing_basic_noise_kind()
invalid_service.snapshot_v2()
+def test_snapshot_v2_validates_coupling_readback_against_runtime_profile() -> None:
+ extensions = source_extensions()
+ profile = SourceCouplingCapabilityProfile(
+ dimensions=(
+ SourceCouplingDimension.AMPLITUDE,
+ SourceCouplingDimension.FREQUENCY,
+ SourceCouplingDimension.PHASE,
+ ),
+ parameter_kinds=(
+ SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,
+ SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ,
+ SourceCouplingParameterKind.PHASE_DEVIATION_DEG,
+ ),
+ supported_channel_sets=((1, 2),),
+ global_state_readable=True,
+ reference_channel_readable=True,
+ relation_graph_readable=False,
+ )
+ coupling_feature = SourceFeatureCapability(
+ feature=SourceFeature.COUPLING,
+ support=SupportState.SUPPORTED,
+ directions=(SourceFeatureDirection.READ,),
+ scope=SourceFacetScope.CHANNEL_SET,
+ channels=(1, 2),
+ applicability=SourceConstraintApplicability(),
+ profile=profile,
+ )
+ coupling_query = SourceFacetQueryContract(
+ feature=SourceFeature.COUPLING,
+ scope=SourceFacetScope.CHANNEL_SET,
+ fields=(SourceFieldId.COUPLING,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ )
+ extensions = replace(
+ extensions,
+ topology=SourceTopologyContract((1, 2)),
+ features=(extensions.features[0], coupling_feature, extensions.features[1]),
+ query_contract=replace(
+ extensions.query_contract,
+ facets=(
+ extensions.query_contract.facets[0],
+ extensions.query_contract.facets[1],
+ coupling_query,
+ extensions.query_contract.facets[2],
+ ),
+ max_queries=extensions.query_contract.max_queries + 1,
+ ),
+ )
+
+ def dimension(
+ kind: SourceCouplingDimension,
+ parameter_kind: SourceCouplingParameterKind,
+ ) -> SourceCouplingDimensionState:
+ return SourceCouplingDimensionState(
+ dimension=kind,
+ enabled=Observed.value_of(kind is not SourceCouplingDimension.PHASE),
+ parameter=Observed.value_of(SourceCouplingParameter(parameter_kind, 1.0)),
+ )
+
+ state = SourceCouplingState(
+ feature=SourceFeature.COUPLING,
+ channels=(1, 2),
+ enabled=Observed.value_of(True),
+ reference_channel=Observed.value_of(1),
+ dimensions=(
+ dimension(
+ SourceCouplingDimension.AMPLITUDE,
+ SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,
+ ),
+ dimension(
+ SourceCouplingDimension.FREQUENCY,
+ SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ,
+ ),
+ dimension(
+ SourceCouplingDimension.PHASE,
+ SourceCouplingParameterKind.PHASE_DEVIATION_DEG,
+ ),
+ ),
+ )
+ driver = SourceV2FakeDriver(combined=True, coupling_state=state)
+ service = make_service(driver)
+ service.descriptor = source_descriptor(driver=driver, extensions=extensions)
+
+ assert service.snapshot_v2().cross_channel.value.relations == (state,)
+
+ invalid_cases = (
+ (
+ replace(profile, global_state_readable=False),
+ state,
+ "unreadable global state",
+ ),
+ (
+ replace(profile, reference_channel_readable=False),
+ state,
+ "unreadable reference channel",
+ ),
+ (
+ replace(
+ profile,
+ dimensions=(SourceCouplingDimension.AMPLITUDE,),
+ parameter_kinds=(
+ SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,
+ ),
+ ),
+ state,
+ "dimensions do not match",
+ ),
+ (
+ replace(
+ profile,
+ parameter_kinds=(SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,),
+ ),
+ state,
+ "undeclared parameter kind",
+ ),
+ (
+ profile,
+ replace(state, channels=(1, 3)),
+ "does not match its channel set",
+ ),
+ )
+ for invalid_profile, invalid_state, message in invalid_cases:
+ invalid_extensions = replace(
+ extensions,
+ features=(
+ extensions.features[0],
+ replace(coupling_feature, profile=invalid_profile),
+ extensions.features[2],
+ ),
+ )
+ invalid_driver = SourceV2FakeDriver(
+ combined=True,
+ coupling_state=invalid_state,
+ )
+ invalid_service = make_service(invalid_driver)
+ invalid_service.descriptor = source_descriptor(
+ driver=invalid_driver,
+ extensions=invalid_extensions,
+ )
+ with pytest.raises(SourceSnapshotContractError, match=message):
+ invalid_service.snapshot_v2()
+
+
def test_snapshot_v2_runtime_identity_can_only_narrow_descriptor_features() -> None:
extensions = source_extensions_with_harmonics()
narrowed_output = replace(
From e3e4bbbb7a3cd9e1480e3c4e8ce6d2cc5b6bbe17 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 06:18:41 +0800
Subject: [PATCH 21/44] docs(source): design coupling noise and sync writes
---
...345\207\272\345\256\211\345\205\250RFC.md" | 167 ++++++++++++++++++
1 file changed, 167 insertions(+)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 5953b0f..04b2ff4 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -10,6 +10,9 @@
> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座,
> M5-D 已开放受限的 Source V2 写入口,C2 已补齐候选发布的核心兼容与离线发布物门。M6-A 已完成;
> 在该里程碑范围内,Harmonic、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM 与内部 Sweep 子项均具备公开 Service、CLI 与 run plan 入口。
+> 本分支另记录 R8 候选设计:修正 Coupling 写合同,并拆分 Noise Overlay 与 Sync 写事务。
+> 该候选设计尚未接受或实现;它不新增 Noise/Sync capability,也不改变已注册的 M6-C
+> 布尔 Coupling capability,不影响 R7 的公开行为。
> [!IMPORTANT]
> `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve
@@ -53,6 +56,7 @@ Harmonic、Modulation、Sweep、Burst、Pulse、Noise、DC、ARB、Counter、Com
| R5 | Accepted | 冻结 M4.5 的 V1 写路由清单和 additive artifact 边界,并实现 C1 的受管 wheel/descriptor PEP 440 交叉门与 V1/V2 兼容 fixture;不注册任何 V2 写 capability |
| R6 | Accepted | 冻结基本写入安全、核心接口归属和兼容边界;授权按 M5-A → M5-B → M5-C → M5-D → C2 → M6-A → M6-B → M6-C → M7 → C3 实施 |
| R7 | Accepted | 为已关闭输出的 Harmonic 状态增加独立关闭 capability;不改变 basic 写入、V1 签名或输出 ON 准入 |
+| R8 候选 | Proposed | 为参数化 Coupling、Noise Overlay 和独立 Sync 物理端口设计写 capability 与恢复事务;不改变当前 capability 注册表 |
## Accepted R5 范围
@@ -3682,6 +3686,169 @@ relation graph 摘要、阶段和可选 recovery;不记录 raw SCPI、授权 t
声明任一 M6-C capability 后,V1 restore 也在 I/O 前拒绝。没有声明对应 capability 的 V1-only 或双合同插件继续走
既有 V1 route。当前只有核心 A0 离线 fixture,未声明真实插件 capability,也没有执行实机验收。
+## R8 候选:Coupling、Noise Overlay 与 Sync 写事务
+
+本节是首次稳定版前的候选设计,不是 R7 的实施授权。当前 Core 已有 M6-C 的布尔
+`source.coupling_configure_v2` Service/CLI/run plan 骨架,但没有生产插件声明该 capability;
+Noise Overlay 与 Sync 仍只有类型化读取。R8 不改变这些当前事实,任何插件都不得依据本节自行
+声明参数化 Coupling、Noise Overlay 或 Sync 写能力。
+
+### 跨设备交集
+
+当前普通信号发生器证据来自 DG4000 与 SDG2000X,两者不能共用厂商命令模型:
+
+- DG4000 的 Coupling 使用 CH1/CH2 基准通道,以及 amplitude、frequency、phase 三个独立
+ deviation;SDG2000X 还存在 ratio/deviation 选择、方向和只在活动状态返回的字段。
+- DG4000 的 Noise Overlay 提供每通道 enabled 与 percent scale;SDG2000X 的 Noise Add
+ 只证明 enabled 状态,不能假设存在相同比例语义。
+- DG4000 的 Sync 提供每通道 enabled 与 polarity;SDG2000X 提供 enabled 与 routing type,
+ 但没有同构的 polarity。
+
+因此 Core 只保留已类型化的交集:Coupling 使用带 dimension 与 parameter kind 的目标;Noise
+Overlay 使用可为空的 typed scale tuple;Sync 的共同字段只有 enabled。DG 的基准通道、SDG 的
+ratio/direction 与 routing type 只有在公共类型能够无损表达时才进入 Core,不使用自由 mapping
+或厂商字符串补齐。
+
+### Coupling 合同修正
+
+现有、尚未稳定发布的 `source.coupling_configure_v2` 只接收 `channels + enabled`,与已经稳定的
+参数化 `SourceCouplingState` 不等价。首次稳定版前直接修正该 capability,不新增第二个
+`source.coupling_parameters_configure_v2`。当前没有生产插件声明该写 capability,因此无需保留两套
+重叠 API。
+
+候选公共类型为完整目标,不使用 partial patch:
+
+```python
+@dataclass(frozen=True, slots=True)
+class SourceCouplingDimensionTarget:
+ dimension: SourceCouplingDimension
+ enabled: bool
+ parameter: SourceCouplingParameter
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCouplingConfigureRequest:
+ channels: tuple[int, ...]
+ enabled: bool
+ reference_channel: int
+ dimensions: tuple[SourceCouplingDimensionTarget, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCouplingConfigureResult:
+ coupling: SourceCouplingState
+ outputs: tuple[SourceRelationOutputState, ...]
+```
+
+request 的 channel set 必须由 profile 声明;dimensions 必须与 profile 完全一致;parameter kind
+必须属于对应 dimension 和 profile。`configuration_readable`、`global_state_readable`、
+`reference_channel_readable` 与 `relation_graph_readable` 必须全部为真。无法完整读取配置、关系图或
+受影响端口时,Core 在 MAIN 前拒绝,不退回旧布尔关系写入。
+
+事务继续使用 `source.coupling_configure_v2`,direction 为 `CONFIGURE`,energy effect 为
+`POTENTIAL_WHILE_OFF`。Core 先冻结 relation graph closure,要求 closure 内全部主输出为 OFF,
+再调用一次 driver 方法。driver 可按设备协议执行多字段写入,但每个目标字段在 MAIN 中最多写一次;
+写后必须返回完整 Coupling state,Core 再用 fresh snapshot 独立验证 target、graph 和全部 OFF 状态。
+
+### Noise Overlay 合同
+
+新增候选 capability `source.noise_overlay_configure_v2`,required method 为
+`configure_source_noise_overlay_v2`。它只在目标主输出已经 OFF 时配置,不打开输出:
+
+```python
+@dataclass(frozen=True, slots=True)
+class SourceNoiseOverlayConfigureRequest:
+ channel: int
+ enabled: bool
+ scales: tuple[SourceNoiseOverlayScale, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class SourceNoiseOverlayConfigureResult:
+ channel: int
+ noise_overlay: NoiseOverlayFacet
+ output_enabled: bool
+```
+
+request 的 scale kind tuple 必须与 runtime profile 完全一致;只支持 enabled 的设备使用空 tuple,
+不能伪造 percent 或默认 scale。descriptor 必须声明 Noise Overlay `READ`/`CONFIGURE`、
+`enabled_readable = true` 和完整配置回读;同一通道的 Output 必须支持 READ。
+
+该 operation 的 direction 为 `CONFIGURE`,energy effect 为 `POTENTIAL_WHILE_OFF`,字段闭包至少包含
+Identity、目标 Output 与 Noise Overlay。MAIN 只调用一次 driver 方法;postcondition 必须逐项确认
+enabled、scales 和 Output OFF。
+
+配置成功不构成后续输出 ON 授权。Noise Overlay 启用后,只有设备在当前 scale、型号、固件和基础
+波形范围内提供确定性硬边界时,严格复合预算才可能放行主输出。当前 `SourceNoisePeakConstraint`
+只描述基本 Noise 波形,不能替代 Noise Overlay 的独立边界;缺少该边界时继续返回
+`noise_overlay_bound_missing`。DG4000 手册只给出 `0–50 %` 的比例,没有确定性峰值保证,因此
+DG4202 即使未来声明配置 capability,也不能据此声明带 Noise Overlay 的输出 ON 已获准入。
+
+### Sync 配置与物理端口输出
+
+Sync 必须拆成配置和物理端口开关,不能把 polarity 写入与开始发出同步信号混为一个 operation:
+
+```text
+source.sync_configure_v2 capability -> configure_source_sync_v2
+source.sync_output_v2 capability -> set_source_sync_output_v2
+```
+
+两个 capability 都要求 Core 先建立 Sync 物理端口 topology:稳定 port ID、逻辑通道到物理端口的
+绑定,以及共享端口的 closure 规则。没有该绑定时,`SourceSyncState.enabled = false` 只能说明逻辑
+状态,不能证明目标物理端口已经 OFF,也不能确定 recovery OFF 的完整范围,因此 configure、enable
+和 disable 均不得注册。
+
+端口 topology 完成后,`source.sync_configure_v2` 只允许在对应 Sync 物理端口已证明为 OFF 时设置
+Core 已建模且 profile 声明可写的 polarity 或 source channel。它使用 feature-specific
+`PatchValue`,至少一个字段为 SET;driver result 和 fresh postcondition 必须返回完整、仍为 disabled
+的 `SourceSyncState`。SDG2000X 的 routing type 在当前 Core 模型中不可表达,因此不能通过该
+capability 写入。
+
+`source.sync_output_v2` 是一个 capability,并要求同一个 driver method。descriptor 的 `ENABLE`/
+`DISABLE` direction 分别映射到两个独立的 `SourceOperationContract` 与 `OperationSpec`:
+
+| operation | direction | energy effect | 最小门槛 |
+| --- | --- | --- | --- |
+| `source.sync_output_enable_v2` | `ENABLE` | `EMIT` | 明确物理端口、端接/电气上界、A5 接线证据和 fresh Sync state |
+| `source.sync_output_disable_v2` | `DISABLE` | `DECREASE_ONLY` | Sync enabled 可读、session 允许正常或 recovery I/O |
+
+当前 `SourceSyncState` 只描述逻辑通道,不足以证明物理端口。物理 topology 还必须为 enable 增加
+该端口的电压边界和端接要求。共享一个物理 Sync/Aux 端口的多个逻辑通道必须进入同一 closure。
+不得从主 Output 的 Vpp、显示负载或
+`max_source_vpp` 推断数字 Sync 端口安全,也不得将「主输出 OFF」解释为「Sync 端口 OFF」。
+
+在物理 port ID/binding/closure 进入 Core 前,任何通用 Sync 写 capability 都不得注册。该模型完成
+并通过 A0 后,可以先评审 configure 与 disable;enable 还必须等待电气 profile 和 A5 证据。
+
+### 恢复顺序与失败状态
+
+三个功能都使用 core-owned 完整 baseline;任何 baseline 字段不是 `VALUE`、snapshot 不一致或
+relation/port closure 不完整时,MAIN 前零写拒绝。恢复不会重新开启主输出,也不会自动重新开启
+Sync 物理端口。
+
+| 功能 | FAILURE_SAFE_STATE | FAILURE_RESTORE | CLEANUP_VERIFICATION |
+| --- | --- | --- | --- |
+| Coupling | 对冻结 closure 内每个主输出至多发送一次 V2 OFF | 仅在 session 仍允许 recovery 且 baseline 完整时,按一次完整 target 恢复 Coupling;设备适配器应先解除 Coupling,再恢复基准、参数和各维状态 | 完整 Coupling、relation graph 与全部主输出 OFF |
+| Noise Overlay | 目标主输出至多发送一次 V2 OFF | 先保持 Noise disabled,再恢复 scale,最后按 baseline 恢复 enabled;整个阶段主输出保持 OFF | Noise Overlay 完整 target 与主输出 OFF |
+| Sync 配置 | 在物理 port binding 已冻结后,对 closure 中的 Sync 端口至多发送一次 OFF;只在 descriptor 声明副作用时关闭相关主输出 | 在 Sync 端口保持 OFF 时恢复 polarity/source channel;不恢复原 Sync ON | Sync 完整配置与全部绑定物理端口 OFF |
+| Sync enable | 对目标 Sync 物理端口至多发送一次 OFF | 不执行重新供能的 restore | Sync 端口 OFF readback 与 session health |
+
+每个 MAIN 字段和每个恢复字段都至多尝试一次;结果未知不重试。`poisoned` session 只关闭连接,
+不发送 OFF 或验证查询。任一恢复或回读失败时,artifact 必须记录 partial/unknown 状态,并把后续
+输出 ON、Sync enable 和相关配置写入保持为失败关闭。
+
+### 实施顺序与退出门
+
+1. 先用 Core fake driver 替换尚未发布的布尔 Coupling request,并补完整 target、graph drift、
+ MAIN/restore 每步故障注入;此阶段不修改真实插件 descriptor。
+2. 增加 Noise Overlay configure 的类型、静态门和 OFF-only transaction;输出 ON 继续受独立硬边界阻断。
+3. 先增加 Sync port ID/binding/closure topology,再设计 configure 与 disable;enable 只有在至少
+ 两种普通信号发生器协议形态完成 A0,并由具体型号完成电气 profile 与 A5 物理端口证据后才能评审。
+4. 每项分别增加 Service、CLI、run plan、V1 route 分类和 operation artifact;不得以一个通用 advanced
+ configure 入口合并。
+5. 完成 Core 全量回归、真实 wheel/sdist、插件包检查和双合同兼容矩阵后,才可将 R8 候选改为
+ Accepted。当前分支不执行这些写入,也不提升任何生产 descriptor。
+
### R6 延后事项
RMS、统计 Noise、反应性/非线性负载、ARB 插值过冲、共享热功率、manifest 签名和信任根不阻塞
From 1af0f235cb5690eb25858c1bff91a1870fd9e57a Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 17:27:59 +0800
Subject: [PATCH 22/44] feat(source): restore basic state through v2
transactions
---
src/wavebench/services/run_service.py | 37 +++++--
src/wavebench/services/source_service.py | 48 +++++++++
tests/test_run_service.py | 32 ++++++
tests/test_source_basic_configure_v2.py | 125 ++++++++++++++++++++---
4 files changed, 218 insertions(+), 24 deletions(-)
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index 1d98956..26091a2 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -426,6 +426,33 @@ def add_source_output_gate_capability() -> None:
else:
add("source", "source.output")
+ def add_source_restore_capabilities() -> None:
+ source = self.config.source
+ if source is None or not source.resource:
+ add("source", "source.status")
+ return
+ descriptor = resolve_instrument_descriptor(
+ source.driver,
+ expected_kind="source",
+ )
+ v2_restore = {
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.output_v2",
+ }
+ if v2_restore.issubset(descriptor.capabilities):
+ add("source", *v2_restore)
+ return
+ add(
+ "source",
+ "source.status",
+ "source.set_function",
+ "source.set_amplitude_vpp",
+ "source.set_frequency",
+ "source.set_square_duty_cycle",
+ "source.output",
+ )
+
for step in plan.steps:
if step.kind == "scope.auto":
add("scope", "scope.autoscale")
@@ -567,15 +594,7 @@ def add_source_output_gate_capability() -> None:
add("power", "power.output")
if plan.restore.source_state:
- add(
- "source",
- "source.status",
- "source.set_function",
- "source.set_amplitude_vpp",
- "source.set_frequency",
- "source.set_square_duty_cycle",
- "source.output",
- )
+ add_source_restore_capabilities()
if plan.safety.safety_gate:
if plan.safety.off_source_channels or any(
item.kind.startswith("source.") or item.kind == "sweep.frequency_response"
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 933e3c9..1383413 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -406,6 +406,14 @@ def _declared_source_capabilities(self) -> tuple[str, ...]:
def _declares_source_v2_capability(self, capability: str) -> bool:
return capability in self._declared_source_capabilities()
+ def _declares_source_v2_basic_restore(self) -> bool:
+ capabilities = set(self._declared_source_capabilities())
+ return {
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.output_v2",
+ }.issubset(capabilities)
+
def _reject_v1_route_for_source_v2(
self,
route: SourceV1WriteRouteId,
@@ -8129,9 +8137,49 @@ def trigger_sweep(self, channel: int | None = None) -> None:
)
def snapshot_restorable_state(self, channel: int | None = None) -> RestorableSourceState:
+ if self._declares_source_v2_basic_restore():
+ source_cfg = self._source_config()
+ target_channel = source_cfg.default_channel if channel is None else channel
+ status = self._source_status_from_v2_snapshot(
+ self.snapshot_v2(),
+ target_channel,
+ )
+ if self.state_guard is not None:
+ self.state_guard.observe(status)
+ return RestorableSourceState.from_status(status)
return RestorableSourceState.from_status(self.status(channel=channel))
def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus:
+ if self._declares_source_v2_basic_restore():
+ request = SourceBasicConfigureRequest(
+ channel=state.channel,
+ patch=SourceBasicPatch(
+ waveform_kind=PatchValue(
+ PatchAction.SET,
+ self._source_v2_waveform_from_v1(state.function),
+ ),
+ frequency_hz=PatchValue(PatchAction.SET, state.frequency_hz),
+ amplitude_vpp=PatchValue(PatchAction.SET, state.amplitude_vpp),
+ square_duty_cycle_percent=(
+ PatchValue(PatchAction.SET, state.square_duty_cycle_percent)
+ if state.square_duty_cycle_percent is not None
+ else PatchValue(PatchAction.KEEP)
+ ),
+ ),
+ )
+ self._set_output_v2_transaction(
+ SourceOutputRequest(channel=state.channel, enabled=False),
+ )
+ basic = self._configure_basic_v2_transaction(request)
+ final_snapshot = basic.snapshot
+ if state.output == "ON":
+ output = self._set_output_v2_transaction(
+ SourceOutputRequest(channel=state.channel, enabled=True),
+ )
+ final_snapshot = output.snapshot
+ status = self._source_status_from_v2_snapshot(final_snapshot, state.channel)
+ self._state_guard_after_write(status)
+ return status
self._reject_v1_route_for_source_v2(
SourceV1WriteRouteId.RESTORE,
"source.basic_configure_v2",
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 56c86e5..81716e3 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -725,6 +725,38 @@ def test_check_accepts_source_v2_steps_without_v1_source_write_capabilities(self
):
service.check(plan)
+ def test_check_accepts_v2_restore_without_v1_source_write_capabilities(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "sleep"
+duration_s = 0.001
+""",
+ )
+ )
+ descriptor = SimpleNamespace(
+ driver_id="minimal.source-v2",
+ capabilities=(
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.output_v2",
+ ),
+ )
+ service = RunService(config=make_config(tmp), logger=CommandLogger())
+
+ with patch(
+ "wavebench.services.run_service.resolve_instrument_descriptor",
+ return_value=descriptor,
+ ):
+ service.check(plan)
+
def test_check_requires_protection_capability_for_power_output_on(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py
index 397ae9f..fb76717 100644
--- a/tests/test_source_basic_configure_v2.py
+++ b/tests/test_source_basic_configure_v2.py
@@ -37,6 +37,7 @@
SourceRuntimeIdentity,
SourceTypedObservation,
SourceV1WriteRouteId,
+ SourceWaveformKind,
PatchAction,
PatchValue,
)
@@ -173,7 +174,14 @@ def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResu
self.transport.write("SOURCE:OUTPUT OFF")
self.output_requests.append(request)
self.output_enabled = request.enabled
- return SourceOutputResult(channel=request.channel, enabled=request.enabled)
+ if not request.enabled:
+ return SourceOutputResult(channel=request.channel, enabled=False)
+ return SourceOutputResult(
+ channel=request.channel,
+ enabled=True,
+ final_amplitude=self.basic.amplitude.value,
+ final_offset_v=self.basic.offset_v.value,
+ )
def set_output(self, *args, **kwargs):
del args, kwargs
@@ -590,9 +598,98 @@ def test_v1_function_outside_the_v2_profile_keeps_its_legacy_route(
assert driver.transport.counters.write_completed == 1
-def test_v1_restore_route_rejects_before_io_for_a_dual_contract_driver() -> None:
+def test_v1_restore_route_uses_v2_basic_and_output_transactions() -> None:
service, driver = _service()
+ status = service.restore_restorable_state(
+ RestorableSourceState(
+ channel=1,
+ output="OFF",
+ function="SIN",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ )
+ )
+
+ assert status.output == "OFF"
+ assert driver.basic_requests == [
+ SourceBasicConfigureRequest(
+ channel=1,
+ patch=SourceBasicPatch(
+ waveform_kind=PatchValue(PatchAction.SET, SourceWaveformKind.SINE),
+ frequency_hz=PatchValue(PatchAction.SET, 1_000.0),
+ amplitude_vpp=PatchValue(PatchAction.SET, 1.0),
+ ),
+ )
+ ]
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_requests == 1
+
+
+def test_restorable_snapshot_uses_v2_when_the_full_restore_route_is_declared() -> None:
+ service, driver = _service()
+
+ state = service.snapshot_restorable_state(channel=1)
+
+ assert state == RestorableSourceState(
+ channel=1,
+ output="OFF",
+ function="SIN",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ )
+ assert driver.transport.counters.query_calls > 0
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_v2_restore_rejects_unmappable_waveform_before_turning_output_off() -> None:
+ service, driver = _service(output_enabled=True)
+
+ with pytest.raises(ConfigError, match="cannot map this waveform"):
+ service.restore_restorable_state(
+ RestorableSourceState(
+ channel=1,
+ output="ON",
+ function="USER",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ )
+ )
+
+ assert driver.basic_requests == []
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_v1_restore_route_restores_original_on_state_through_v2_output() -> None:
+ service, driver = _service(output_enabled=True)
+
+ status = service.restore_restorable_state(
+ RestorableSourceState(
+ channel=1,
+ output="ON",
+ function="SIN",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ )
+ )
+
+ assert status.output == "ON"
+ assert driver.output_requests == [
+ SourceOutputRequest(channel=1, enabled=False),
+ SourceOutputRequest(channel=1, enabled=True),
+ ]
+ assert len(driver.basic_requests) == 1
+ assert driver.transport.counters.write_requests == 3
+
+
+def test_v1_restore_route_rejects_partial_v2_restore_before_io() -> None:
+ service, driver = _service(include_output=False)
+
with pytest.raises(ConfigError, match="restore_restorable_state cannot run"):
service.restore_restorable_state(
RestorableSourceState(
@@ -645,6 +742,7 @@ def test_dual_contract_driver_classifies_every_v1_write_route() -> None:
SourceV1WriteRouteId.SET_AMPLITUDE_VPP,
SourceV1WriteRouteId.SET_SQUARE_DUTY_CYCLE,
SourceV1WriteRouteId.SET_OUTPUT,
+ SourceV1WriteRouteId.RESTORE,
}
assert len(driver.basic_requests) == 4
@@ -743,6 +841,16 @@ def test_dual_contract_driver_classifies_every_v1_write_route() -> None:
"configure_sweep",
]
+ service.restore_restorable_state(
+ RestorableSourceState(
+ channel=1,
+ output="OFF",
+ function="SIN",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ )
+ )
writes_before_rejections = driver.transport.counters.write_requests
with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"):
service.trigger_burst(channel=1)
@@ -755,23 +863,10 @@ def test_dual_contract_driver_classifies_every_v1_write_route() -> None:
playback_frequency_hz=1_000.0,
amplitude_vpp=1.0,
)
- with pytest.raises(ConfigError, match="restore_restorable_state cannot run"):
- service.restore_restorable_state(
- RestorableSourceState(
- channel=1,
- output="OFF",
- function="SIN",
- frequency_hz=1_000.0,
- amplitude_vpp=1.0,
- amplitude_unit="VPP",
- )
- )
-
rejected_routes = {
SourceV1WriteRouteId.TRIGGER_BURST,
SourceV1WriteRouteId.TRIGGER_SWEEP,
SourceV1WriteRouteId.UPLOAD_ARBITRARY,
- SourceV1WriteRouteId.RESTORE,
}
assert driver.transport.counters.write_requests == writes_before_rejections
assert mapped_routes | disjoint_routes | rejected_routes == set(SourceV1WriteRouteId)
From f77efc071f09298081b84674c9a039c56b94f45a Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 17:52:49 +0800
Subject: [PATCH 23/44] feat(source): support restricted live basic updates
---
...345\207\272\345\256\211\345\205\250RFC.md" | 58 ++-
.../source_extension_capabilities.py | 54 ++-
.../instruments/source_extensions.py | 96 +++++
src/wavebench/services/operation_specs.py | 32 ++
src/wavebench/services/source_service.py | 370 +++++++++++++++---
tests/test_source_basic_configure_v2.py | 271 ++++++++++++-
tests/test_source_extensions.py | 18 +-
tests/test_source_v1_routes.py | 2 +
8 files changed, 817 insertions(+), 84 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 04b2ff4..772f91d 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -216,8 +216,9 @@ mutation 或恢复写入的规则仍作为对应写 capability 的后续准入
15. artifact 不记录授权 token、baseline nonce、完整仪器响应、真实资源串、序列号或凭据。
16. 插件未声明 Source V2 时,核心不会从型号、方法存在或 V1 profile 自动推导 V2 写能力。
17. Source V2 能量增加操作必须显式配置 Vpp 与端口绝对电压上下限;缺失不表示无限制。
-18. Source V2 首个可写修订只允许相关输出 OFF 时配置;该限制不追溯改变 V1 行为,也不表示
- 仪器硬件不支持 live mutation。
+18. `source.basic_configure_v2` 与高级配置仍只允许相关输出 OFF 时执行。只有独立声明
+ `source.basic_live_configure_v2` 的设备,才允许在输出已证明为 ON 时执行受限单字段修改;该能力
+ 不追溯改变未声明能力的 V1 行为。
19. storage mutation、波形选择/配置和输出 ON 是三个独立 operation,不共享一次准入决定。
## R2 公共集成合同
@@ -610,6 +611,14 @@ SourceNoiseOverlayScale
SourceNoiseOverlayScaleKind
```
+D1-2/输出开启时的受限 Basic 修改在上述清单末尾追加以下精确条目:
+
+```text
+SourceBasicLiveConfigureResult
+SourceBasicLiveConfigureV2Driver
+SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
@@ -639,6 +648,7 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。
| capability | required method | 范围 |
| --- | --- | --- |
| `source.basic_configure_v2` | `configure_source_basic_v2` | 基础函数、频率、Vpp、偏置和方波参数 |
+| `source.basic_live_configure_v2` | `configure_source_basic_live_v2` | 输出 ON 时单独修改频率或 Vpp |
| `source.output_v2` | `set_source_output_v2` | 单独的 ON/OFF 转换 |
| `source.harmonics_configure_v2` | `configure_source_harmonics_v2` | 谐波配置 |
| `source.harmonics_disable_v2` | `disable_source_harmonics_v2` | 关闭 Harmonic 状态 |
@@ -868,6 +878,8 @@ epoch 和 baseline。R4 已把这些两个 model、固定 phase 和 core-only co
`UNKNOWN` energy/storage effect 在 I/O 前拒绝。`POTENTIAL_WHILE_OFF` 要求全部
`required_off_outputs` 已通过 fresh readback 证明 OFF,并且 operation 本身不能打开输出。
+`LIVE_MUTATION` 要求目标输出已通过 fresh readback 证明 ON,并且只能执行 capability 明确声明的
+单字段修改;失败恢复只能关闭输出。
`MAY_INCREASE` 与 `EMIT` 必须通过统一预算门。`DECREASE_ONLY` 不需要预算,但仍需 access、
session、operation context 和 postcondition。
@@ -878,13 +890,13 @@ session、operation context 和 postcondition。
| V2 输出 ON | fresh 一致 snapshot + 预算 + 写后回读 |
| V1 同义写入口调用双合同驱动 | 在 Service 边界映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝 |
| `source.arb_load output_on=true` | 先在输出 OFF 的配置 phase 完成上传或选择,再用 fresh snapshot 签发只授权下一次 ON 的新决定 |
-| 输出 ON 时的 Source V2 setter/patch | 首版在 I/O 前拒绝;未来若允许 live mutation,必须对完整目标状态使用专项预算合同 |
+| 输出 ON 时的 Source V2 setter/patch | 仅 `source.basic_live_configure_v2` 可单独修改频率或 Vpp;其它 patch 在 I/O 前拒绝 |
| arm/fire/trigger | 在可能发出信号前完成预算与接线证据检查 |
| 恢复为 ON | 作为独立、显式授权的 ON 操作重新计算预算 |
V1 驱动未 opt in 时继续使用现有 V1 路径,不伪装成已获得 Source V2 复合安全保证。
-Source V2 的首版 live-mutation 禁令不追溯改变 V1 驱动的既有行为,也不表示硬件本身不支持
-ON 状态写入。
+未声明 `source.basic_live_configure_v2` 的 V2 驱动继续拒绝 ON 状态配置。V1-only 驱动保持既有
+行为,但不因此获得 Source V2 的 live mutation 安全保证。
每个 V2 写 capability 必须在 `SourceOperationContract` 中登记其 V1 等价入口、重叠字段和可能发出
信号的间接入口。双合同驱动声明该 capability 后,只有落入这些集合的 V1 路径必须映射到 V2
@@ -2398,8 +2410,9 @@ class SafetyLimitsConfig:
V1 CLI 或未 opt in 的 V1 driver。
缺少显式安全轴仍允许 `source.snapshot_v2`、正常 OFF、disable,以及已经证明不会发出信号的
-输出 OFF 配置或独立 storage mutation。ON、fire、恢复 ON、可能发出信号的 trigger、live mutation,
-以及能量影响为 unknown 的 operation 必须零仪器 I/O 拒绝。
+输出 OFF 配置或独立 storage mutation。ON、fire、恢复 ON、可能发出信号的 trigger、严格预算型
+live mutation,以及能量影响为 unknown 的 operation 必须零仪器 I/O 拒绝。D1-2 的受限 Basic
+live mutation 使用 R6 数值门,不要求 R3 的完整复合预算模型。
示例配置中的「缺失表示不限」只能继续描述 V1。Source V2 文案必须明确:缺失表示没有能量转换
授权,而不是无限制。
@@ -2513,9 +2526,9 @@ topology 或 profile 只要声明共享功率关系,就必须提供完整 type
context 结束都会使它失效;获准的 ON/fire/trigger action 会一次性消费它。首版禁止用一个决定
同时授权 ARB 上传和 ON,也禁止输出 ON 时执行多字段 live patch。
-R2 规定未来 Source V2 首个可写修订中,高级配置只允许在所有相关输出 OFF 时执行,不自动执行
-「关闭 → 配置 → 重新开启」。未来若允许 live mutation,必须通过本 RFC 修订并复用同一
-写后预算门。该规则不追溯改变未 opt in 的 V1 驱动行为。
+高级配置只允许在所有相关输出 OFF 时执行,不自动执行「关闭 → 配置 → 重新开启」。D1-2 仅为
+Basic 的频率与 Vpp 增加独立单字段 live operation,不授权波形、Offset、占空比、高级功能、ARB
+或跨通道关系的 ON 状态修改。该规则不追溯改变未 opt in 的 V1 驱动行为。
OFF 不需要复合预算,但仍需要正常 OFF 权限或核心签发的 recovery 授权。`poisoned` 连接不得
为了 OFF 再发送协议 I/O。
@@ -2829,7 +2842,7 @@ status、output 和基础 setter,也包括已经发布的 profile、configure
双合同驱动声明某项 V2 写 capability 后,同义或副作用闭包重叠的 V1 写入口必须在 Service 边界
映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝;经审计确认字段闭包和发信号路径均不相交
的 V1 operation 可以继续保持原行为。未声明 V2 写 capability 的 V1-only 驱动保持原行为;
-Source V2 首版禁止 live mutation 不追溯改变该路径。
+未声明 `source.basic_live_configure_v2` 的 V2 驱动仍不得经 V1 setter 绕过 ON 状态门。
### 独立 P0 缺陷修复
@@ -3066,7 +3079,8 @@ R2 的本段只约束 R2–R5 的 snapshot-only 阶段。R6 已为后续基础
- 复合预算由核心统一消费;插件提供厂商状态、typed constraint 和协议回读。
- `max_source_vpp` 与端口绝对电压上下限必须显式配置,不能互相推导;缺失时 V2 能量操作失败关闭。
- 只有 `HARD_CONSERVATIVE` 可以支持输出 ON 准入。
-- Source V2 首个可写修订禁止 live mutation;未 opt in 的 V1 行为不变。
+- Basic live mutation 是独立 capability,只允许输出 ON 时单独修改频率或 Vpp;未 opt in 的 V1
+ 行为不变。
- ARB storage、selection/configuration 和 ON 使用三个独立 operation。
- Source 事务复用核心 session health 和授权底座,不新增平行 `state_uncertain` 布尔值。
- 失败恢复默认以 OFF 结束;重新 ON 是新的授权操作。
@@ -3099,8 +3113,8 @@ Vpp、Offset 和输出状态的设备正常使用信号发生器功能,而不
基础 Source V2 功能。
3. 若同时配置了 `min_source_port_voltage_v` 和 `max_source_port_voltage_v`,核心以
`offset ± Vpp / 2` 检查该端口区间;两个配置均缺失时,不增加额外端口电压限制。
-4. 基础配置要求目标通道在写前为 OFF;不支持 V2 live mutation。独立端口可以同时保持 ON,核心
- 不为缺少共享功率或热模型而全局拒绝。
+4. `source.basic_configure_v2` 要求目标通道在写前为 OFF。独立端口可以同时保持 ON,核心不为
+ 缺少共享功率或热模型而全局拒绝。
5. 每个目标字段最多写入一次;写后必须独立回读。结果不明时不得重试;在 session 仍允许 recovery
I/O 时请求受影响端口 OFF,`poisoned` session 仍遵守 transport RFC 的 close-only 规则。
6. `source.output_v2` 的 OFF 不因 Vpp、Offset、端接或预算信息缺失而拒绝;ON 使用 fresh snapshot
@@ -3110,6 +3124,22 @@ Vpp、Offset 和输出状态的设备正常使用信号发生器功能,而不
插值上界、复杂负载或共享热功率模型。R3 的严格预算模型继续保留给明确选择它的后续 capability,
不能反向限制 R6 的 basic/output 正常路径。
+### D1-2 受限 Basic live mutation
+
+`source.basic_live_configure_v2` 是独立 capability,不改变 `source.basic_configure_v2` 的 OFF-only
+语义。descriptor 必须同时声明 `source.snapshot_v2`、`source.basic_configure_v2`、
+`source.output_v2`,并在每个 Basic profile 中逐字段声明是否允许在线修改频率和 Vpp。
+
+每次请求只允许 `frequency_hz` 或 `amplitude_vpp` 中一个字段为 `SET`;waveform、Offset、占空比和
+多字段 patch 在 MAIN 前拒绝。preflight 必须证明 snapshot 一致、目标输出为 ON、频率模式为 FIXED,
+并取得最终 Vpp 与 Offset。目标和回读值继续使用 R6 的 `max_source_vpp` 与端口绝对电压检查。
+
+MAIN 只调用一次 `configure_source_basic_live_v2()`。结果与 fresh postcondition 必须逐项证明请求值、
+最终 Vpp、Offset 和输出仍为 ON。结果未知、driver 异常或后置条件失败时不重试,也不恢复 ON;Core
+只允许一次 `source.output_v2` OFF recovery 与独立回读。V1 `set_frequency()` 和
+`set_amplitude_vpp()` 可按已证明的输出状态选择 OFF-only 或 live operation。D1-2 不增加 CLI 命令
+或 run plan step,频响、离散扫频与 TUI 继续复用既有 setter。
+
Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使用 `offset ± Vpp / 2`;不要求独立
`SourceNoisePeakConstraint`。若设备只能提供标称值、RMS 或载波幅度,插件不得为该模式声明
`source.output_v2`,直到能够返回最终 Vpp 或定义专项 capability。
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 56dd1b7..32154ff 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -50,6 +50,9 @@
{
"source.snapshot_v2": ("execute_source_query_plan_v2",),
"source.basic_configure_v2": ("configure_source_basic_v2",),
+ "source.basic_live_configure_v2": (
+ "configure_source_basic_live_v2",
+ ),
"source.harmonics_configure_v2": ("configure_source_harmonics_v2",),
"source.harmonics_disable_v2": ("disable_source_harmonics_v2",),
"source.modulation_configure_v2": ("configure_source_modulation_v2",),
@@ -77,6 +80,7 @@
_SOURCE_WRITE_CAPABILITIES = frozenset(
{
"source.basic_configure_v2",
+ "source.basic_live_configure_v2",
"source.harmonics_configure_v2",
"source.harmonics_disable_v2",
"source.modulation_configure_v2",
@@ -352,6 +356,51 @@ def _validate_write_contract(
"source.basic_configure_v2 requires readable output state on every channel"
)
+ if "source.basic_live_configure_v2" in capabilities:
+ required = {"source.basic_configure_v2", "source.output_v2"}
+ missing = required - capabilities
+ if missing:
+ raise ConfigError(
+ "source.basic_live_configure_v2 requires source.basic_configure_v2 "
+ "and source.output_v2"
+ )
+ configurable = _channels_with_direction(
+ extensions,
+ SourceFeature.BASIC,
+ SourceFeatureDirection.CONFIGURE,
+ )
+ live_configurable = frozenset(
+ channel
+ for feature in extensions.features
+ if (
+ feature.feature is SourceFeature.BASIC
+ and feature.scope is SourceFacetScope.CHANNEL
+ and feature.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.CONFIGURE in feature.directions
+ and isinstance(feature.profile, SourceBasicCapabilityProfile)
+ and SourceFrequencyMode.FIXED in feature.profile.frequency_modes
+ and (
+ feature.profile.live_frequency_configurable
+ or feature.profile.live_amplitude_vpp_configurable
+ )
+ )
+ for channel in feature.channels
+ )
+ if not configurable or not configurable <= live_configurable:
+ raise ConfigError(
+ "source.basic_live_configure_v2 requires per-channel fixed-mode live "
+ "frequency or Vpp declarations"
+ )
+ if not configurable <= basic_readable:
+ raise ConfigError(
+ "source.basic_live_configure_v2 requires readable final VPP and Offset "
+ "on every channel"
+ )
+ if not configurable <= output_readable:
+ raise ConfigError(
+ "source.basic_live_configure_v2 requires readable output state on every channel"
+ )
+
if "source.harmonics_configure_v2" in capabilities:
configurable = _channels_with_direction(
extensions,
@@ -1040,7 +1089,10 @@ def _validate_declared_write_directions(
) -> None:
capabilities_by_direction = {
(SourceFeature.BASIC, SourceFeatureDirection.CONFIGURE): frozenset(
- {"source.basic_configure_v2"}
+ {
+ "source.basic_configure_v2",
+ "source.basic_live_configure_v2",
+ }
),
(SourceFeature.HARMONICS, SourceFeatureDirection.CONFIGURE): frozenset(
{"source.harmonics_configure_v2"}
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index aaf9f06..2e3b924 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -506,6 +506,8 @@ class SourceBasicCapabilityProfile:
offset_readable: bool
phase_readable: bool
square_duty_readable: bool
+ live_frequency_configurable: bool = False
+ live_amplitude_vpp_configurable: bool = False
def __post_init__(self) -> None:
_require_enum_tuple(self.waveform_kinds, SourceWaveformKind, "basic waveform_kinds")
@@ -514,6 +516,14 @@ def __post_init__(self) -> None:
_require_bool(self.offset_readable, "basic offset_readable")
_require_bool(self.phase_readable, "basic phase_readable")
_require_bool(self.square_duty_readable, "basic square_duty_readable")
+ _require_bool(
+ self.live_frequency_configurable,
+ "basic live_frequency_configurable",
+ )
+ _require_bool(
+ self.live_amplitude_vpp_configurable,
+ "basic live_amplitude_vpp_configurable",
+ )
@dataclass(frozen=True, slots=True)
@@ -984,6 +994,7 @@ class SourceEnergyEffect(StrEnum):
NONE = "none"
DECREASE_ONLY = "decrease_only"
POTENTIAL_WHILE_OFF = "potential_while_off"
+ LIVE_MUTATION = "live_mutation"
MAY_INCREASE = "may_increase"
EMIT = "emit"
UNKNOWN = "unknown"
@@ -1190,6 +1201,39 @@ def __post_init__(self) -> None:
)
+SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.basic_live_configure_v2",
+ capability="source.basic_live_configure_v2",
+ feature=SourceFeature.BASIC,
+ direction=SourceFeatureDirection.CONFIGURE,
+ energy_effect=SourceEnergyEffect.LIVE_MUTATION,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ SourceFieldId.IDENTITY,
+ ),
+ changed_fields=(SourceFieldId.BASIC,),
+ postcondition_fields=(
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ ),
+ cleanup_verification_fields=(SourceFieldId.OUTPUT,),
+ v1_equivalent_routes=(
+ SourceV1WriteRouteId.SET_AMPLITUDE_VPP,
+ SourceV1WriteRouteId.SET_FREQUENCY,
+ ),
+ v1_overlapping_routes=(
+ SourceV1WriteRouteId.RESTORE,
+ SourceV1WriteRouteId.UPLOAD_ARBITRARY,
+ ),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=2,
+ verification_max_steps=2,
+)
+
+
SOURCE_HARMONICS_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract(
operation="source.harmonics_configure_v2",
capability="source.harmonics_configure_v2",
@@ -3260,6 +3304,47 @@ def __post_init__(self) -> None:
)
+@dataclass(frozen=True, slots=True)
+class SourceBasicLiveConfigureResult:
+ channel: int
+ basic: BasicWaveFacet
+ output_enabled: bool
+
+ def __post_init__(self) -> None:
+ _require_int(self.channel, "source basic live configure result channel", minimum=1)
+ if not isinstance(self.basic, BasicWaveFacet):
+ raise ValueError("source basic live configure result basic has an invalid type")
+ _require_bool(
+ self.output_enabled,
+ "source basic live configure result output_enabled",
+ )
+ if not self.output_enabled:
+ raise ValueError(
+ "source basic live configure result requires output_enabled=True"
+ )
+ if (
+ self.basic.amplitude.availability is not Availability.VALUE
+ or not isinstance(self.basic.amplitude.value, SourceAmplitude)
+ or self.basic.amplitude.value.unit is not SourceAmplitudeUnit.VPP
+ ):
+ raise ValueError(
+ "source basic live configure result requires a final VPP amplitude readback"
+ )
+ _require_finite(
+ self.basic.amplitude.value.value,
+ "source basic live configure result final_amplitude",
+ minimum=0.0,
+ )
+ if self.basic.offset_v.availability is not Availability.VALUE:
+ raise ValueError(
+ "source basic live configure result requires a final offset readback"
+ )
+ _require_finite(
+ self.basic.offset_v.value,
+ "source basic live configure result final_offset_v",
+ )
+
+
@dataclass(frozen=True, slots=True)
class SourceOutputResult:
channel: int
@@ -4992,6 +5077,14 @@ def configure_source_basic_v2(
) -> SourceBasicConfigureResult: ...
+@runtime_checkable
+class SourceBasicLiveConfigureV2Driver(InstrumentDriver, Protocol):
+ def configure_source_basic_live_v2(
+ self,
+ request: SourceBasicConfigureRequest,
+ ) -> SourceBasicLiveConfigureResult: ...
+
+
@runtime_checkable
class SourceHarmonicConfigureV2Driver(InstrumentDriver, Protocol):
def configure_source_harmonics_v2(
@@ -5443,4 +5536,7 @@ def source_snapshot_timestamp_utc() -> str:
"SourceNoiseOverlayCapabilityProfile",
"SourceNoiseOverlayScale",
"SourceNoiseOverlayScaleKind",
+ "SourceBasicLiveConfigureResult",
+ "SourceBasicLiveConfigureV2Driver",
+ "SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT",
]
diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py
index 915d4a8..aeddc94 100644
--- a/src/wavebench/services/operation_specs.py
+++ b/src/wavebench/services/operation_specs.py
@@ -496,6 +496,38 @@ def _spec(
error_check_minimum="disabled",
risk_flags=("source_v2", "output_must_be_off"),
),
+ _spec(
+ "source.basic_live_configure_v2",
+ "source",
+ required_capabilities=(
+ "source.basic_live_configure_v2",
+ "source.basic_configure_v2",
+ "source.output_v2",
+ ),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.channel.basic",),
+ restore_coverage="source-v2-live-basic",
+ required_verified_fields=(
+ "source.identity",
+ "source.channel.basic",
+ "source.channel.output",
+ ),
+ verification_fields=(
+ "source.identity",
+ "source.channel.basic",
+ "source.channel.output",
+ ),
+ postcondition_fields=(
+ "source.channel.basic",
+ "source.channel.output",
+ ),
+ cleanup_verification_fields=("source.channel.output",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=("source_v2", "output_must_be_on", "live_signal_mutation"),
+ ),
_spec(
"source.harmonics_configure_v2",
"source",
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 1383413..49625e0 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -76,6 +76,7 @@
SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT,
SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_COMBINE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_COUPLING_CONFIGURE_V2_OPERATION_CONTRACT,
@@ -109,6 +110,8 @@
SourceBasicConfigureRequest,
SourceBasicConfigureResult,
SourceBasicConfigureV2Driver,
+ SourceBasicLiveConfigureResult,
+ SourceBasicLiveConfigureV2Driver,
SourceBasicPatch,
SourceBurstCapabilityProfile,
SourceBurstConfigureRequest,
@@ -216,7 +219,7 @@
class _SourceBasicConfigureV2Transaction:
"""Core transaction result shared by public and V1-adapter routes."""
- result: SourceBasicConfigureResult
+ result: SourceBasicConfigureResult | SourceBasicLiveConfigureResult
artifact: dict[str, object]
snapshot: SourceSnapshotV2
@@ -225,6 +228,14 @@ class _SourceV2BasicLegacyFallback(ConfigError):
"""A V1 setter has no lossless representation in the active V2 basic profile."""
+class _SourceV2BasicRequiresLiveMutation(ConfigError):
+ """An OFF-only basic transaction found the target output enabled."""
+
+
+class _SourceV2BasicRequiresOffMutation(ConfigError):
+ """A live basic transaction found the target output disabled."""
+
+
@dataclass(frozen=True, slots=True)
class _SourceHarmonicConfigureV2Transaction:
"""Core transaction result shared by the Harmonic public route."""
@@ -545,6 +556,22 @@ def configure_basic_v2(
request,
correlation_id=correlation_id,
)
+ assert isinstance(transaction.result, SourceBasicConfigureResult)
+ return transaction.result, transaction.artifact
+
+ def configure_basic_live_v2(
+ self,
+ request: SourceBasicConfigureRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceBasicLiveConfigureResult, dict[str, object]]:
+ """Change one declared frequency or Vpp field while output remains enabled."""
+
+ transaction = self._configure_basic_live_v2_transaction(
+ request,
+ correlation_id=correlation_id,
+ )
+ assert isinstance(transaction.result, SourceBasicLiveConfigureResult)
return transaction.result, transaction.artifact
def configure_harmonics_v2(
@@ -850,46 +877,54 @@ def _configure_basic_v2_transaction(
request: SourceBasicConfigureRequest,
*,
correlation_id: str | None = None,
+ _live: bool = False,
) -> _SourceBasicConfigureV2Transaction:
- """Execute the private M5-B basic-write transaction.
-
- This method deliberately remains private until M5-D owns the public
- Service, CLI, run-plan and V1 dual-contract routes. It is the single
- core path that M5-B tests use to prove the write/recovery contract.
- """
+ """Execute the shared OFF-only or restricted live basic transaction."""
- if not isinstance(request, SourceBasicConfigureRequest):
- raise ConfigError("source.basic_configure_v2 requires SourceBasicConfigureRequest")
- self._require(
- "source.basic_configure_v2",
- "source.snapshot_v2",
- "source.basic_configure_v2",
+ operation = (
+ "source.basic_live_configure_v2"
+ if _live
+ else "source.basic_configure_v2"
+ )
+ contract = (
+ SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT
+ if _live
+ else SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT
)
+ if not isinstance(request, SourceBasicConfigureRequest):
+ raise ConfigError(f"{operation} requires SourceBasicConfigureRequest")
+ capabilities = ["source.snapshot_v2", contract.capability]
+ if _live:
+ capabilities.extend(("source.basic_configure_v2", "source.output_v2"))
+ self._require(operation, *capabilities)
with self._source_session() as source:
descriptor = self.descriptor
extensions = None if descriptor is None else descriptor.source_extensions
session_state = self.session_state
if not isinstance(extensions, SourceDescriptorExtensions):
- raise ConfigError(
- "source.basic_configure_v2 requires validated source_extensions"
- )
+ raise ConfigError(f"{operation} requires validated source_extensions")
if session_state is None:
- raise ConfigError(
- "source.basic_configure_v2 requires a connection-bound session state"
- )
+ raise ConfigError(f"{operation} requires a connection-bound session state")
fields = self._source_basic_v2_fields(request.channel)
output_field = next(
field for field in fields if field.field is SourceFieldId.OUTPUT
)
context = SourceOperationContextCoordinator(
session_state=session_state,
- operation_spec=require_operation_spec("source.basic_configure_v2"),
- operation_contract=SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
+ operation_spec=require_operation_spec(operation),
+ operation_contract=contract,
connection_timeout_ms=self.config.connection.timeout_ms,
baseline_snapshot_digest=None,
fields=fields,
required_off_outputs=(
- SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel),
+ ()
+ if _live
+ else (
+ SourceScopeRef(
+ SourceFacetScope.CHANNEL,
+ channel=request.channel,
+ ),
+ )
),
emergency_off_outputs=(
SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel),
@@ -903,7 +938,7 @@ def _configure_basic_v2_transaction(
)
preflight_snapshot: SourceSnapshotV2 | None = None
postcondition_snapshot: SourceSnapshotV2 | None = None
- result: SourceBasicConfigureResult | None = None
+ result: SourceBasicConfigureResult | SourceBasicLiveConfigureResult | None = None
main_entered = False
failure: BaseException | None = None
recovery: dict[str, object] | None = None
@@ -924,14 +959,22 @@ def _configure_basic_v2_transaction(
preflight_basic, preflight_output = self._source_v2_target(
preflight_snapshot,
request.channel,
- operation="source.basic_configure_v2",
- )
- self._validate_source_basic_v2_preflight(
- request,
- preflight_snapshot,
- preflight_basic,
- preflight_output,
+ operation=operation,
)
+ if _live:
+ self._validate_source_basic_live_v2_preflight(
+ request,
+ preflight_snapshot,
+ preflight_basic,
+ preflight_output,
+ )
+ else:
+ self._validate_source_basic_v2_preflight(
+ request,
+ preflight_snapshot,
+ preflight_basic,
+ preflight_output,
+ )
context.bind_baseline_snapshot_digest(
source_v2_digest((request.channel, preflight_basic, preflight_output))
)
@@ -947,15 +990,23 @@ def _configure_basic_v2_transaction(
fields=(
next(field for field in fields if field.field is SourceFieldId.BASIC),
),
- max_steps=SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT.main_max_steps,
+ max_steps=contract.main_max_steps,
)
try:
with context.authorize_phase(main):
main_entered = True
- result = cast(SourceBasicConfigureV2Driver, source).configure_source_basic_v2(
- request
- )
- self._validate_source_basic_v2_result(request, result)
+ if _live:
+ result = cast(
+ SourceBasicLiveConfigureV2Driver,
+ source,
+ ).configure_source_basic_live_v2(request)
+ self._validate_source_basic_live_v2_result(request, result)
+ else:
+ result = cast(
+ SourceBasicConfigureV2Driver,
+ source,
+ ).configure_source_basic_v2(request)
+ self._validate_source_basic_v2_result(request, result)
except BaseException as exc:
failure = exc
@@ -984,17 +1035,28 @@ def _configure_basic_v2_transaction(
self._source_v2_target(
postcondition_snapshot,
request.channel,
- operation="source.basic_configure_v2",
+ operation=operation,
)
)
assert result is not None
- self._validate_source_basic_v2_postcondition(
- request,
- result,
- postcondition_snapshot,
- postcondition_basic,
- postcondition_output,
- )
+ if _live:
+ assert isinstance(result, SourceBasicLiveConfigureResult)
+ self._validate_source_basic_live_v2_postcondition(
+ request,
+ result,
+ postcondition_snapshot,
+ postcondition_basic,
+ postcondition_output,
+ )
+ else:
+ assert isinstance(result, SourceBasicConfigureResult)
+ self._validate_source_basic_v2_postcondition(
+ request,
+ result,
+ postcondition_snapshot,
+ postcondition_basic,
+ postcondition_output,
+ )
context.complete_phase_verification(
authorization,
io_kind="query",
@@ -1020,7 +1082,7 @@ def _configure_basic_v2_transaction(
request.channel,
extensions,
output_field,
- operation="source.basic_configure_v2",
+ operation=operation,
)
except BaseException:
recovery = {
@@ -1037,6 +1099,8 @@ def _configure_basic_v2_transaction(
postcondition_snapshot=postcondition_snapshot,
result=result,
recovery=recovery,
+ capability=contract.capability,
+ output_expected=("on" if _live else "off"),
)
raise failure
@@ -1052,6 +1116,8 @@ def _configure_basic_v2_transaction(
preflight_snapshot=preflight_snapshot,
postcondition_snapshot=postcondition_snapshot,
result=result,
+ capability=contract.capability,
+ output_expected=("on" if _live else "off"),
),
snapshot=postcondition_snapshot,
)
@@ -1060,6 +1126,18 @@ def _configure_basic_v2_transaction(
context.complete()
raise
+ def _configure_basic_live_v2_transaction(
+ self,
+ request: SourceBasicConfigureRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> _SourceBasicConfigureV2Transaction:
+ return self._configure_basic_v2_transaction(
+ request,
+ correlation_id=correlation_id,
+ _live=True,
+ )
+
def _set_output_v2_transaction(
self,
request: SourceOutputRequest,
@@ -4782,8 +4860,12 @@ def _validate_source_basic_v2_preflight(
) -> None:
if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
raise ConfigError("source.basic_configure_v2 requires a fresh consistent snapshot")
- if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False:
+ if output.enabled.availability is not Availability.VALUE:
raise ConfigError("source.basic_configure_v2 requires target output OFF")
+ if output.enabled.value is not False:
+ raise _SourceV2BasicRequiresLiveMutation(
+ "source.basic_configure_v2 requires target output OFF"
+ )
if not any(
feature.feature is SourceFeature.BASIC
and feature.scope is SourceFacetScope.CHANNEL
@@ -4841,6 +4923,88 @@ def _validate_source_basic_v2_preflight(
operation="source.basic_configure_v2",
)
+ def _validate_source_basic_live_v2_preflight(
+ self,
+ request: SourceBasicConfigureRequest,
+ snapshot: SourceSnapshotV2,
+ basic: BasicWaveFacet,
+ output: OutputFacet,
+ ) -> None:
+ operation = "source.basic_live_configure_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if output.enabled.availability is not Availability.VALUE:
+ raise ConfigError(f"{operation} requires target output ON")
+ if output.enabled.value is not True:
+ raise _SourceV2BasicRequiresOffMutation(
+ f"{operation} requires target output ON"
+ )
+ runtime_basic = next(
+ (
+ feature
+ for feature in snapshot.runtime_profile.features
+ if feature.feature is SourceFeature.BASIC
+ and feature.scope is SourceFacetScope.CHANNEL
+ and request.channel in feature.channels
+ and feature.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.CONFIGURE in feature.directions
+ ),
+ None,
+ )
+ if runtime_basic is None or not isinstance(
+ runtime_basic.profile,
+ SourceBasicCapabilityProfile,
+ ):
+ raise ConfigError(
+ f"{operation} is not available for the runtime target channel"
+ )
+ patch = request.patch
+ set_fields = tuple(
+ name
+ for name, value in (
+ ("waveform_kind", patch.waveform_kind),
+ ("frequency_hz", patch.frequency_hz),
+ ("amplitude_vpp", patch.amplitude_vpp),
+ ("offset_v", patch.offset_v),
+ ("square_duty_cycle_percent", patch.square_duty_cycle_percent),
+ )
+ if value.action is PatchAction.SET
+ )
+ if len(set_fields) != 1 or set_fields[0] not in {
+ "frequency_hz",
+ "amplitude_vpp",
+ }:
+ raise ConfigError(
+ f"{operation} requires exactly one frequency_hz or amplitude_vpp SET"
+ )
+ profile = runtime_basic.profile
+ if set_fields[0] == "frequency_hz" and not profile.live_frequency_configurable:
+ raise ConfigError(f"{operation} frequency_hz is not declared live-configurable")
+ if (
+ set_fields[0] == "amplitude_vpp"
+ and not profile.live_amplitude_vpp_configurable
+ ):
+ raise ConfigError(f"{operation} amplitude_vpp is not declared live-configurable")
+ if (
+ basic.frequency_mode.availability is not Availability.VALUE
+ or basic.frequency_mode.value is not SourceFrequencyMode.FIXED
+ ):
+ raise ConfigError(f"{operation} requires fixed frequency mode")
+ current_vpp, current_offset = self._source_v2_amplitude_offset(
+ basic,
+ operation=operation,
+ )
+ requested_vpp = (
+ float(patch.amplitude_vpp.value)
+ if patch.amplitude_vpp.action is PatchAction.SET
+ else current_vpp
+ )
+ self._check_source_v2_final_output_limits(
+ requested_vpp,
+ current_offset,
+ operation=operation,
+ )
+
@staticmethod
def _source_harmonic_runtime_profile(
snapshot: SourceSnapshotV2,
@@ -6286,6 +6450,32 @@ def _validate_source_basic_v2_result(
)
self._validate_source_basic_v2_patch_readback(request, result.basic)
+ def _validate_source_basic_live_v2_result(
+ self,
+ request: SourceBasicConfigureRequest,
+ result: object,
+ ) -> None:
+ operation = "source.basic_live_configure_v2"
+ if not isinstance(result, SourceBasicLiveConfigureResult):
+ raise ConfigError(
+ "configure_source_basic_live_v2() returned an invalid "
+ "SourceBasicLiveConfigureResult"
+ )
+ if result.channel != request.channel:
+ raise ConfigError(f"{operation} result channel does not match request")
+ if not result.output_enabled:
+ raise ConfigError(f"{operation} result reports output OFF")
+ vpp, offset = self._source_v2_amplitude_offset(
+ result.basic,
+ operation=operation,
+ )
+ self._check_source_v2_final_output_limits(vpp, offset, operation=operation)
+ self._validate_source_basic_v2_patch_readback(
+ request,
+ result.basic,
+ operation=operation,
+ )
+
def _validate_source_basic_v2_postcondition(
self,
request: SourceBasicConfigureRequest,
@@ -6317,6 +6507,42 @@ def _validate_source_basic_v2_postcondition(
operation="source.basic_configure_v2",
)
+ def _validate_source_basic_live_v2_postcondition(
+ self,
+ request: SourceBasicConfigureRequest,
+ result: SourceBasicLiveConfigureResult,
+ snapshot: SourceSnapshotV2,
+ basic: BasicWaveFacet,
+ output: OutputFacet,
+ ) -> None:
+ operation = "source.basic_live_configure_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} postcondition snapshot is inconsistent")
+ if output.enabled.availability is not Availability.VALUE or output.enabled.value is not True:
+ raise ConfigError(f"{operation} postcondition reports output OFF")
+ self._validate_source_basic_v2_patch_readback(
+ request,
+ basic,
+ operation=operation,
+ )
+ result_vpp, result_offset = self._source_v2_amplitude_offset(
+ result.basic,
+ operation=operation,
+ )
+ post_vpp, post_offset = self._source_v2_amplitude_offset(
+ basic,
+ operation=operation,
+ )
+ if (result_vpp, result_offset) != (post_vpp, post_offset):
+ raise ConfigError(
+ f"{operation} final amplitude or offset readback does not match"
+ )
+ self._check_source_v2_final_output_limits(
+ post_vpp,
+ post_offset,
+ operation=operation,
+ )
+
@staticmethod
def _source_v2_amplitude_offset(
basic: BasicWaveFacet,
@@ -6340,6 +6566,8 @@ def _source_v2_amplitude_offset(
def _validate_source_basic_v2_patch_readback(
request: SourceBasicConfigureRequest,
basic: BasicWaveFacet,
+ *,
+ operation: str = "source.basic_configure_v2",
) -> None:
patch = request.patch
values = (
@@ -6357,16 +6585,16 @@ def _validate_source_basic_v2_patch_readback(
continue
if observed.availability is not Availability.VALUE or observed.value != patch_value.value:
raise ConfigError(
- f"source.basic_configure_v2 {name} readback does not match request"
+ f"{operation} {name} readback does not match request"
)
if patch.amplitude_vpp.action is PatchAction.SET:
actual_vpp, _ = SourceService._source_v2_amplitude_offset(
basic,
- operation="source.basic_configure_v2",
+ operation=operation,
)
if actual_vpp != patch.amplitude_vpp.value:
raise ConfigError(
- "source.basic_configure_v2 amplitude_vpp readback does not match request"
+ f"{operation} amplitude_vpp readback does not match request"
)
def _check_source_v2_final_output_limits(
@@ -6719,8 +6947,10 @@ def _source_basic_v2_artifact(
request: SourceBasicConfigureRequest,
preflight_snapshot: SourceSnapshotV2 | None,
postcondition_snapshot: SourceSnapshotV2 | None,
- result: SourceBasicConfigureResult | None,
+ result: SourceBasicConfigureResult | SourceBasicLiveConfigureResult | None,
recovery: dict[str, object] | None = None,
+ capability: str = "source.basic_configure_v2",
+ output_expected: str = "off",
) -> dict[str, object]:
artifact = context.artifact()
descriptor_digest = (
@@ -6729,7 +6959,7 @@ def _source_basic_v2_artifact(
else preflight_snapshot.runtime_profile.descriptor_digest
)
artifact["capability_decision"] = {
- "capability": "source.basic_configure_v2",
+ "capability": capability,
"contract_version": SOURCE_CONTRACT_VERSION,
"descriptor_digest": descriptor_digest,
}
@@ -6751,7 +6981,7 @@ def _source_basic_v2_artifact(
artifact["recovery"] = dict(recovery)
artifact["final_state"] = {
"session_health": context.session_state.health.value,
- "output_expected": "off",
+ "output_expected": output_expected,
}
artifact["evidence_refs"] = sorted(
{
@@ -8213,14 +8443,19 @@ def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
if self._declares_source_v2_capability("source.basic_configure_v2"):
- transaction = self._configure_basic_v2_transaction(
- SourceBasicConfigureRequest(
- channel=channel,
- patch=SourceBasicPatch(
- frequency_hz=PatchValue(PatchAction.SET, value_hz),
- ),
- )
+ request = SourceBasicConfigureRequest(
+ channel=channel,
+ patch=SourceBasicPatch(
+ frequency_hz=PatchValue(PatchAction.SET, value_hz),
+ ),
)
+ if self._declares_source_v2_capability("source.basic_live_configure_v2"):
+ try:
+ transaction = self._configure_basic_live_v2_transaction(request)
+ except _SourceV2BasicRequiresOffMutation:
+ transaction = self._configure_basic_v2_transaction(request)
+ else:
+ transaction = self._configure_basic_v2_transaction(request)
status = self._source_status_from_v2_snapshot(transaction.snapshot, channel)
self._state_guard_after_write(status)
return status
@@ -8389,14 +8624,19 @@ def set_amplitude_vpp(self, channel: int | None, value_vpp: float) -> SourceStat
self._check_source_vpp(value_vpp, field="source amplitude / 信号源幅度")
channel = source_cfg.default_channel if channel is None else channel
if self._declares_source_v2_capability("source.basic_configure_v2"):
- transaction = self._configure_basic_v2_transaction(
- SourceBasicConfigureRequest(
- channel=channel,
- patch=SourceBasicPatch(
- amplitude_vpp=PatchValue(PatchAction.SET, value_vpp),
- ),
- )
+ request = SourceBasicConfigureRequest(
+ channel=channel,
+ patch=SourceBasicPatch(
+ amplitude_vpp=PatchValue(PatchAction.SET, value_vpp),
+ ),
)
+ if self._declares_source_v2_capability("source.basic_live_configure_v2"):
+ try:
+ transaction = self._configure_basic_live_v2_transaction(request)
+ except _SourceV2BasicRequiresOffMutation:
+ transaction = self._configure_basic_v2_transaction(request)
+ else:
+ transaction = self._configure_basic_v2_transaction(request)
status = self._source_status_from_v2_snapshot(transaction.snapshot, channel)
self._state_guard_after_write(status)
return status
diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py
index fb76717..668e4f6 100644
--- a/tests/test_source_basic_configure_v2.py
+++ b/tests/test_source_basic_configure_v2.py
@@ -26,6 +26,7 @@
SourceAmplitudeUnit,
SourceBasicConfigureRequest,
SourceBasicConfigureResult,
+ SourceBasicLiveConfigureResult,
SourceBasicPatch,
SourceFeatureDirection,
SourceFieldId,
@@ -109,6 +110,7 @@ def __init__(
self.raise_after_write = raise_after_write
self.basic = basic_facet()
self.basic_requests: list[SourceBasicConfigureRequest] = []
+ self.live_basic_requests: list[SourceBasicConfigureRequest] = []
self.output_requests: list[SourceOutputRequest] = []
self.v1_output_calls = 0
self.closed = False
@@ -170,6 +172,21 @@ def configure_source_basic_v2(
output_enabled=False,
)
+ def configure_source_basic_live_v2(
+ self,
+ request: SourceBasicConfigureRequest,
+ ) -> SourceBasicLiveConfigureResult:
+ self.transport.write("SOURCE:LIVE CONFIGURE")
+ self.live_basic_requests.append(request)
+ self.basic = self._apply_patch(request)
+ if self.raise_after_write:
+ raise ConfigError("fake live basic configure failed after write")
+ return SourceBasicLiveConfigureResult(
+ channel=request.channel,
+ basic=self.basic,
+ output_enabled=True,
+ )
+
def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult:
self.transport.write("SOURCE:OUTPUT OFF")
self.output_requests.append(request)
@@ -189,7 +206,9 @@ def set_output(self, *args, **kwargs):
raise AssertionError("M5-B recovery must not fall back to the V1 output route")
def _readback_basic(self):
- if self.postcondition_frequency_hz is None or not self.basic_requests:
+ if self.postcondition_frequency_hz is None or not (
+ self.basic_requests or self.live_basic_requests
+ ):
return self.basic
return replace(
self.basic,
@@ -336,7 +355,12 @@ def _config(*, limits: SafetyLimitsConfig = SafetyLimitsConfig()) -> WaveBenchCo
)
-def _write_extensions(*, include_output: bool):
+def _write_extensions(
+ *,
+ include_output: bool,
+ live_frequency: bool = False,
+ live_amplitude_vpp: bool = False,
+):
extensions = source_extensions()
basic, output = extensions.features
return replace(
@@ -348,6 +372,11 @@ def _write_extensions(*, include_output: bool):
SourceFeatureDirection.CONFIGURE,
SourceFeatureDirection.READ,
),
+ profile=replace(
+ basic.profile,
+ live_frequency_configurable=live_frequency,
+ live_amplitude_vpp_configurable=live_amplitude_vpp,
+ ),
),
replace(
output,
@@ -369,6 +398,9 @@ def _service(
*,
combined: bool = True,
include_output: bool = True,
+ include_live: bool = False,
+ live_frequency: bool = True,
+ live_amplitude_vpp: bool = True,
output_enabled: bool = False,
postcondition_frequency_hz: float | None = None,
raise_after_write: bool = False,
@@ -383,10 +415,16 @@ def _service(
raise_after_write=raise_after_write,
)
- extensions = _write_extensions(include_output=include_output)
+ extensions = _write_extensions(
+ include_output=include_output,
+ live_frequency=(include_live and live_frequency),
+ live_amplitude_vpp=(include_live and live_amplitude_vpp),
+ )
capabilities = ["source.snapshot_v2", "source.basic_configure_v2"]
if include_output:
capabilities.append("source.output_v2")
+ if include_live:
+ capabilities.append("source.basic_live_configure_v2")
descriptor = replace(
source_descriptor(driver=driver, extensions=extensions),
capabilities=tuple(capabilities),
@@ -497,6 +535,48 @@ def _frequency_request(value_hz: float = 2_000.0) -> SourceBasicConfigureRequest
)
+def test_basic_live_capability_requires_off_basic_and_output_transactions() -> None:
+ driver = _BasicWriteDriver(
+ session_state=InstrumentSessionState(epoch_id="source-live-dependencies"),
+ combined=True,
+ )
+ descriptor = replace(
+ source_descriptor(
+ driver=driver,
+ extensions=_write_extensions(
+ include_output=False,
+ live_frequency=True,
+ ),
+ ),
+ capabilities=("source.snapshot_v2", "source.basic_live_configure_v2"),
+ )
+
+ with pytest.raises(ConfigError, match="requires source.basic_configure_v2"):
+ validate_source_descriptor(descriptor)
+
+
+def test_basic_live_capability_requires_explicit_per_field_profile() -> None:
+ driver = _BasicWriteDriver(
+ session_state=InstrumentSessionState(epoch_id="source-live-profile"),
+ combined=True,
+ )
+ descriptor = replace(
+ source_descriptor(
+ driver=driver,
+ extensions=_write_extensions(include_output=True),
+ ),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.output_v2",
+ "source.basic_live_configure_v2",
+ ),
+ )
+
+ with pytest.raises(ConfigError, match="per-channel fixed-mode live"):
+ validate_source_descriptor(descriptor)
+
+
@pytest.mark.parametrize("combined", (True, False))
def test_basic_configure_v2_public_service_supports_combined_and_scalar_queries(
combined: bool,
@@ -525,6 +605,150 @@ def test_basic_configure_v2_public_service_supports_combined_and_scalar_queries(
assert "SOURCE:STATE?" not in repr(artifact)
+def test_basic_live_configure_v2_public_service_keeps_output_on() -> None:
+ service, driver = _service(output_enabled=True, include_live=True)
+ request = _frequency_request()
+
+ result, artifact = service.configure_basic_live_v2(
+ request,
+ correlation_id="basic-live-write",
+ )
+
+ assert result.output_enabled is True
+ assert result.basic.frequency_hz.value == 2_000.0
+ assert driver.basic_requests == []
+ assert driver.live_basic_requests == [request]
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_completed == 1
+ assert driver.transport.counters.query_calls == 2
+ assert artifact["operation"] == "source.basic_live_configure_v2"
+ assert artifact["capability_decision"]["capability"] == (
+ "source.basic_live_configure_v2"
+ )
+ assert artifact["final_state"] == {
+ "session_health": "healthy",
+ "output_expected": "on",
+ }
+
+
+def test_v1_live_capable_basic_route_uses_off_transaction_when_output_is_off() -> None:
+ service, driver = _service(include_live=True)
+
+ status = service.set_frequency(channel=1, value_hz=2_000.0)
+
+ assert status.output == "OFF"
+ assert driver.basic_requests == [_frequency_request()]
+ assert driver.live_basic_requests == []
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_completed == 1
+
+
+@pytest.mark.parametrize(
+ ("method", "value", "expected_field"),
+ (
+ ("set_frequency", 2_000.0, "frequency_hz"),
+ ("set_amplitude_vpp", 1.5, "amplitude_vpp"),
+ ),
+)
+def test_v1_live_basic_routes_do_not_cycle_output(
+ method: str,
+ value: float,
+ expected_field: str,
+) -> None:
+ service, driver = _service(output_enabled=True, include_live=True)
+
+ status = getattr(service, method)(channel=1, **{
+ "value_hz" if method == "set_frequency" else "value_vpp": value,
+ })
+
+ assert status.output == "ON"
+ assert driver.basic_requests == []
+ assert len(driver.live_basic_requests) == 1
+ assert getattr(driver.live_basic_requests[0].patch, expected_field).value == value
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_completed == 1
+ assert driver.transport.counters.query_calls == 2
+
+
+def test_frequency_response_style_live_sequence_never_cycles_output() -> None:
+ service, driver = _service(output_enabled=True, include_live=True)
+
+ for amplitude_vpp in (0.5, 1.0):
+ service.set_amplitude_vpp(channel=1, value_vpp=amplitude_vpp)
+ for frequency_hz in (100.0, 1_000.0):
+ status = service.set_frequency(channel=1, value_hz=frequency_hz)
+ assert status.output == "ON"
+
+ assert len(driver.live_basic_requests) == 6
+ assert driver.basic_requests == []
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_completed == 6
+ assert driver.transport.counters.query_calls == 12
+
+
+def test_basic_live_configure_v2_rejects_output_off_before_write() -> None:
+ service, driver = _service(include_live=True)
+
+ with pytest.raises(ConfigError, match="target output ON"):
+ service.configure_basic_live_v2(_frequency_request())
+
+ assert driver.live_basic_requests == []
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_basic_live_configure_v2_rejects_multiple_fields_before_write() -> None:
+ service, driver = _service(output_enabled=True, include_live=True)
+ request = SourceBasicConfigureRequest(
+ channel=1,
+ patch=SourceBasicPatch(
+ frequency_hz=PatchValue(PatchAction.SET, 2_000.0),
+ amplitude_vpp=PatchValue(PatchAction.SET, 1.5),
+ ),
+ )
+
+ with pytest.raises(ConfigError, match="exactly one"):
+ service.configure_basic_live_v2(request)
+
+ assert driver.live_basic_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_basic_live_configure_v2_enforces_per_field_profile() -> None:
+ service, driver = _service(
+ output_enabled=True,
+ include_live=True,
+ live_frequency=False,
+ live_amplitude_vpp=True,
+ )
+
+ with pytest.raises(ConfigError, match="frequency_hz is not declared"):
+ service.configure_basic_live_v2(_frequency_request())
+
+ assert driver.live_basic_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_basic_live_configure_v2_rejects_safety_limit_before_write() -> None:
+ service, driver = _service(
+ output_enabled=True,
+ include_live=True,
+ limits=SafetyLimitsConfig(max_source_vpp=2.0),
+ )
+ request = SourceBasicConfigureRequest(
+ channel=1,
+ patch=SourceBasicPatch(
+ amplitude_vpp=PatchValue(PatchAction.SET, 2.5),
+ ),
+ )
+
+ with pytest.raises(ConfigError, match="max_source_vpp"):
+ service.configure_basic_live_v2(request)
+
+ assert driver.live_basic_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
def test_v1_frequency_route_maps_to_v2_for_a_dual_contract_driver() -> None:
service, driver = _service()
assert service.descriptor is not None
@@ -940,6 +1164,47 @@ def test_basic_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> No
assert service.session_state.health is SessionHealth.UNCERTAIN
+def test_basic_live_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> None:
+ service, driver = _service(
+ output_enabled=True,
+ include_live=True,
+ postcondition_frequency_hz=2_001.0,
+ )
+
+ with pytest.raises(ConfigError, match="frequency_hz readback") as raised:
+ service.configure_basic_live_v2(_frequency_request())
+
+ artifact = raised.value.source_operation_artifact
+ assert driver.basic_requests == []
+ assert driver.live_basic_requests == [_frequency_request()]
+ assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)]
+ assert driver.transport.counters.write_completed == 2
+ assert artifact["operation"] == "source.basic_live_configure_v2"
+ assert artifact["recovery"] == {
+ "status": "off_verified",
+ "session_health": "uncertain",
+ }
+ assert artifact["safe_state_verified"] is True
+ assert artifact["final_state"]["session_health"] == "uncertain"
+ assert service.session_state is not None
+ assert service.session_state.health is SessionHealth.UNCERTAIN
+
+
+def test_basic_live_configure_v2_failure_is_not_retried() -> None:
+ service, driver = _service(
+ output_enabled=True,
+ include_live=True,
+ raise_after_write=True,
+ )
+
+ with pytest.raises(ConfigError, match="failed after write"):
+ service.configure_basic_live_v2(_frequency_request())
+
+ assert driver.live_basic_requests == [_frequency_request()]
+ assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)]
+ assert driver.transport.counters.write_requests == 2
+
+
def test_basic_configure_v2_never_falls_back_to_v1_output_for_recovery() -> None:
service, driver = _service(include_output=False, raise_after_write=True)
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 7c51a4c..6198736 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -144,7 +144,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[sync_start + len(sync_exports) :] == match.group(1).splitlines()
+ noise_exports = match.group(1).splitlines()
+ noise_start = sync_start + len(sync_exports)
+ assert module.__all__[noise_start : noise_start + len(noise_exports)] == noise_exports
+ match = re.search(
+ r"D1-2/输出开启时的受限 Basic 修改在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[noise_start + len(noise_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -185,6 +194,8 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"offset_readable",
"phase_readable",
"square_duty_readable",
+ "live_frequency_configurable",
+ "live_amplitude_vpp_configurable",
),
"SourceOutputCapabilityProfile": (
"output_readable",
@@ -354,6 +365,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
),
"SourceBasicConfigureRequest": ("channel", "patch", "mode"),
"SourceBasicConfigureResult": ("channel", "basic", "output_enabled"),
+ "SourceBasicLiveConfigureResult": ("channel", "basic", "output_enabled"),
"SourceOutputRequest": ("channel", "enabled"),
"SourceOutputResult": (
"channel",
@@ -603,6 +615,7 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None:
expected = {
"source.snapshot_v2": ("execute_source_query_plan_v2",),
"source.basic_configure_v2": ("configure_source_basic_v2",),
+ "source.basic_live_configure_v2": ("configure_source_basic_live_v2",),
"source.harmonics_configure_v2": ("configure_source_harmonics_v2",),
"source.harmonics_disable_v2": ("disable_source_harmonics_v2",),
"source.modulation_configure_v2": ("configure_source_modulation_v2",),
@@ -659,6 +672,7 @@ def test_source_v2_basic_write_models_are_closed_and_serializable() -> None:
}
assert keep.action is module.PatchAction.KEEP
assert module.SourceBasicConfigureResult(1, basic_facet(), False).output_enabled is False
+ assert module.SourceBasicLiveConfigureResult(1, basic_facet(), True).output_enabled is True
assert module.SourceOutputResult(1, False) == module.SourceOutputResult(1, False)
with pytest.raises(ValueError, match="SET patch values"):
@@ -684,6 +698,8 @@ def test_source_v2_basic_write_models_are_closed_and_serializable() -> None:
)
with pytest.raises(ValueError, match="output_enabled=False"):
module.SourceBasicConfigureResult(1, basic_facet(), True)
+ with pytest.raises(ValueError, match="output_enabled=True"):
+ module.SourceBasicLiveConfigureResult(1, basic_facet(), False)
with pytest.raises(ValueError, match="final VPP amplitude"):
module.SourceBasicConfigureResult(
1,
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index 4556ceb..53da0c1 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -31,6 +31,7 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs
assert inventoried_operations <= source_write_operations
assert source_write_operations - inventoried_operations == {
"source.basic_configure_v2",
+ "source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
"source.harmonics_configure_v2",
@@ -94,6 +95,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
} == expected_v1_run_steps | expected_v2_run_steps
expected_v2_operations = {
"source.basic_configure_v2",
+ "source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
"source.harmonics_configure_v2",
From e418c0169db98df7419bf832c41bb40ef40534e7 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 18:29:01 +0800
Subject: [PATCH 24/44] feat(source): add guarded burst and sweep fire
---
...345\207\272\345\256\211\345\205\250RFC.md" | 89 ++-
.../source_extension_capabilities.py | 101 ++-
.../instruments/source_extensions.py | 169 ++++-
src/wavebench/services/operation_specs.py | 74 ++
.../services/source_operation_context.py | 17 +-
src/wavebench/services/source_service.py | 658 +++++++++++++++++-
tests/test_operation_specs.py | 19 +
tests/test_source_burst_v2.py | 212 +++++-
tests/test_source_extensions.py | 54 +-
tests/test_source_operation_context.py | 53 ++
tests/test_source_sweep_v2.py | 196 +++++-
tests/test_source_v1_routes.py | 4 +
12 files changed, 1557 insertions(+), 89 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 772f91d..5afb4f9 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -619,6 +619,17 @@ SourceBasicLiveConfigureV2Driver
SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT
```
+D1-3/Burst 与 Sweep fire 在上述清单末尾追加以下精确条目:
+
+```text
+SourceFireRequest
+SourceFireResult
+SourceBurstFireV2Driver
+SourceSweepFireV2Driver
+SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT
+SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
@@ -676,8 +687,8 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。
R6/M6-A 将 `source.modulation_pm_configure_v2`、`source.burst_configure_v2` 与
`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2` 明确加入已授权的窄 capability。
现有 `source.modulation_configure_v2` 保持内部 AM 的首版语义,不因 PM 子项扩张;分离 capability 使 PM-only
-插件不会改变 V1 AM route,也使 AM-only 插件无需提供 PM 入口。Burst capability 也不授权 arm、fire 或任何
-输出开启路径。
+插件不会改变 V1 AM route,也使 AM-only 插件无需提供 PM 入口。配置 capability 不授权 arm、fire 或任何
+输出开启路径。D1-3 另行注册 `source.burst_fire_v2` 与 `source.sweep_fire_v2`;两者仍不授权 arm。
Source V2 驱动不接收 `SessionAuthorization`、`InstrumentSessionState` 或 raw transport handle。
核心在授权 phase 中调用已冻结的 driver 方法,driver 只返回公共类型化 model。
@@ -3471,27 +3482,29 @@ descriptor 必须同时声明 Modulation `READ`/`CONFIGURE`、`pm`、`internal
`steps[].artifact.source_operation` 与非空的 `run.json.source_operations`。这些入口不构成任何真实插件的写
capability 声明或实机验收。
-### M6-A 已实现子能力:内部 Triggered Burst
+### M6-A 已实现子能力:受控 Triggered Burst
M6-A 的后续单通道能力按以下顺序开发:内部 FM、内部 PWM、Sweep。每个子项各自
拥有 capability、typed request/result、descriptor readback 条件、OFF-only 事务、V1 路由审计、CLI 与 run plan
step;不得借已完成子项扩大其它高级功能的范围。该顺序只决定核心开发节奏,不要求插件同时声明全部能力。
-`source.burst_configure_v2` 覆盖单通道、输出 OFF 时的内部 Triggered Burst。
-typed request 固定为 `channel`、`cycles`、`phase_deg`、`internal_period_s` 与 `delay_s`。配置完成后 Burst 必为
-enabled、mode 必为 `triggered`、trigger source 必为 `internal`、trigger slope 必为 `positive`、trigger output
+`source.burst_configure_v2` 覆盖单通道、输出 OFF 时的受控 Triggered Burst。
+typed request 包含 `channel`、`cycles`、`phase_deg`、`internal_period_s`、`delay_s` 与末尾默认值为
+`internal` 的 `trigger_source`;触发源只允许 `internal` 或 `manual`。配置完成后 Burst 必为
+enabled、mode 必为 `triggered`、trigger source 必须与请求一致、trigger slope 必为 `positive`、trigger output
必为 `off`。`cycles` 为 `[1, 500000]` 的整数,`phase_deg` 为 `[0, 360]` 的有限值,
-`internal_period_s` 为有限正值,`delay_s` 为 `[0, 85]` 的有限值。这些是内部 Triggered 配置的类型边界,
+`internal_period_s` 为有限正值,`delay_s` 为 `[0, 85]` 的有限值。这些是受控 Triggered 配置的类型边界,
不是额外的负载、RMS、热或共享功率安全门。
-首版不包含 Gated、Infinity、外部/手动 trigger、Gate polarity、trigger slope/output 自定义、disable、
+配置 capability 不包含 Gated、Infinity、外部 trigger、Gate polarity、trigger slope/output 自定义、disable、
partial patch、arm、fire、同步、自动波形切换、输出 ON 或输出恢复。配置不会发出 Burst,也不会为后续输出 ON
提供额外授权;`source.output_v2` 仍按 R6 的基础 Vpp/Offset 规则独立决策。
`SourceBurstCapabilityProfile` 在现有字段末尾追加
-`triggered_internal_configuration_readable: bool = False`。该字段为真时,表示 enabled、mode、cycles、phase、
-internal period、delay 和完整 trigger state 都能以纯读 snapshot 独立回读。声明本 capability 的 descriptor
-还必须在同一 channel 声明 Burst `READ`/`CONFIGURE`、`triggered` mode、`internal` trigger source、
+`triggered_internal_configuration_readable: bool = False` 和
+`triggered_manual_configuration_readable: bool = False`。两个字段分别表示对应触发源下的 enabled、mode、cycles、
+phase、internal period、delay 和完整 trigger state 都能以纯读 snapshot 独立回读。声明本 capability 的 descriptor
+还必须在同一 channel 声明 Burst `READ`/`CONFIGURE`、`triggered` mode、至少一个受支持的 trigger source、
`timing_readable = true`,以及 Output `READ` 与 output state readback。
该 operation 使用 `POTENTIAL_WHILE_OFF`,静态字段闭包为同一 channel 的 Burst/Output 和仪器 Identity。
@@ -3499,9 +3512,10 @@ internal period、delay 和完整 trigger state 都能以纯读 snapshot 独立
V2 OFF recovery;postcondition 必须逐项确认 request 值、固定 Triggered 语义及 output 仍为 OFF。它不恢复先前
Burst state。
-双合同插件声明 `source.burst_configure_v2` 后,V1 `configure_burst`、`trigger_burst` 与 restore 路径必须在
-仪器 I/O 前拒绝:V1 route 可表示 Gated、Infinity、手动/外部 trigger 和实际 fire,不能无损映射到这个
-仅配置的 V2 子集。V1-only 插件及未声明该 capability 的双合同插件继续走 V1 路径。
+双合同插件只声明 `source.burst_configure_v2` 时,V1 `configure_burst`、`trigger_burst` 与 restore 路径必须在
+仪器 I/O 前拒绝:V1 route 可表示 Gated、Infinity、外部 trigger 和实际 fire,不能无损映射到这个仅配置的
+V2 子集。插件另行声明 `source.burst_fire_v2` 后,V1 `trigger_burst` 可映射到独立 fire operation;V1-only
+插件及未声明相关 capability 的双合同插件继续走 V1 路径。
当前核心开发线已提供 `SourceService.configure_burst_v2()`、
`wavebench source burst-configure-v2 --channel N --cycles N --phase-deg DEG --internal-period-s S --delay-s S` 和
@@ -3578,19 +3592,21 @@ capability 独立决定。
Sweep 的窄 capability、typed model 与 descriptor readback 条件已冻结,并由下列公开事务完成接口收口。
-### M6-A 已实现子能力:内部 Sweep
+### M6-A 已实现子能力:受控 Sweep
-`source.sweep_configure_v2` 的首个范围只覆盖单通道、输出 OFF 时的内建 Sweep 配置。typed request 固定为
-`channel`、`start_hz`、`stop_hz`、`spacing`、`steps` 和 `sweep_time_s`:频率必须为有限正值且
+`source.sweep_configure_v2` 的首个范围只覆盖单通道、输出 OFF 时的内建 Sweep 配置。typed request 包含
+`channel`、`start_hz`、`stop_hz`、`spacing`、`steps`、`sweep_time_s` 与末尾默认值为 `internal` 的
+`trigger_source`;触发源只允许 `internal` 或 `manual`。频率必须为有限正值且
`start_hz <= stop_hz`;`spacing` 只能是 `linear`、`logarithmic` 或 `step`;steps 位于 `[2, 2048]`,
sweep time 位于 `[0.001, 300]` 秒。这些值域沿用既有 Source V1 的配置合同,不作为新的电气安全预算。
-配置完成后 Sweep 必为 enabled,三个 hold/return 时间均为零;trigger 固定为 internal、positive、trigger output OFF,
+配置完成后 Sweep 必为 enabled,三个 hold/return 时间均为零;trigger source 必须与请求一致,slope 固定为
+positive,trigger output 固定为 OFF,
marker 固定为 disabled。Basic 的 `frequency_mode` 必须独立回读为 `sweep`,因此该 operation 的字段闭包包含同一
-channel 的 Basic/Sweep/Output 和仪器 Identity。它不提供 center/span、partial patch、hold/return 自定义、marker、
-外部/手动/BUS trigger、arm、fire、trigger output、隐式输出 ON 或从 Sweep 回到固定频率。
+channel 的 Basic/Sweep/Output 和仪器 Identity。配置 capability 不提供 center/span、partial patch、
+hold/return 自定义、marker、外部/BUS trigger、arm、fire、trigger output、隐式输出 ON 或从 Sweep 回到固定频率。
-descriptor 必须声明 Sweep `READ`/`CONFIGURE`、至少一个 spacing、internal trigger、timing/marker 与完整配置
+descriptor 必须声明 Sweep `READ`/`CONFIGURE`、至少一个 spacing、internal 或 manual trigger、timing/marker 与完整配置
readback;同一 channel 的 Basic `READ` 必须声明 `sweep` frequency mode,Output `READ` 与 output state readback
也为必需。核心在运行时按 request 检查所选 spacing,不要求每个设备支持全部三种 spacing。
@@ -3598,9 +3614,10 @@ readback;同一 channel 的 Basic `READ` 必须声明 `sweep` frequency mode
postcondition 和主写入后的最多一次 V2 OFF recovery;没有额外 RMS、端接、热、共享功率或 trigger 接线门。本子项只
授权配置,不构成任何 fire 或输出 ON 授权。
-双合同插件声明 `source.sweep_configure_v2` 后,V1 `configure_sweep`、`trigger_sweep` 与 restore 必须在仪器
-I/O 前拒绝,不能把 V1 的 center/span、外部/手动 trigger、marker 或 fire 语义部分映射进本范围。V1-only 插件和
-未声明该 capability 的双合同插件继续使用既有 V1 Sweep 路径。
+双合同插件只声明 `source.sweep_configure_v2` 时,V1 `configure_sweep`、`trigger_sweep` 与 restore 必须在仪器
+I/O 前拒绝,不能把 V1 的 center/span、外部 trigger、marker 或 fire 语义部分映射进本范围。插件另行声明
+`source.sweep_fire_v2` 后,V1 `trigger_sweep` 可映射到独立 fire operation;V1-only 插件和未声明相关
+capability 的双合同插件继续使用既有 V1 Sweep 路径。
当前核心开发线已提供 `SourceService.configure_sweep_v2()`、
`wavebench source sweep-configure-v2 --channel N --start-hz F --stop-hz F --spacing linear|logarithmic|step --steps N --sweep-time-s S` 和
@@ -3608,6 +3625,30 @@ I/O 前拒绝,不能把 V1 的 center/span、外部/手动 trigger、marker
独立回读、写后失败的一次 V2 OFF recovery 与 `wavebench.source.operation.v1` artifact;run step 的 artifact 同时写入
`steps[].artifact.source_operation` 与非空的 `run.json.source_operations`。这些入口不构成任何真实插件的写 capability 声明或实机验收。
+### D1-3 已实现子能力:Burst 与 Sweep fire
+
+`source.burst_fire_v2` 与 `source.sweep_fire_v2` 使用共享的 `SourceFireRequest(channel)` 和
+`SourceFireResult(channel)`,但保留独立 capability、driver Protocol、operation contract 和 artifact。fire
+capability 分别依赖对应 configure capability 与 `source.output_v2`;descriptor 必须在同一 channel 声明
+`FIRE` direction、manual trigger、manual 模式下的完整配置回读、最终 Vpp/Offset 和输出状态回读。
+
+Core 只接受由同一个 `SourceService` 和 session epoch 成功完成的对应 V2 配置。配置成功后形成绑定已验证
+Burst/Sweep facet 摘要的内存 receipt;重新配置开始前清除旧 receipt,连接代次改变或 fire 主写入失败后也
+不再接受旧 receipt。receipt 不写入插件,也不替代 fire 前的 fresh snapshot。
+
+fire preflight 必须证明 snapshot 一致、目标输出为 ON、Vpp/Offset 满足 R6 数值门,并且当前 Burst 或 Sweep
+配置仍使用 manual trigger,且与同 session receipt 的 facet 摘要一致。MAIN 只调用一次
+`fire_source_burst_v2()` 或
+`fire_source_sweep_v2()`;结果未知不得重试。postcondition 只证明输出仍为 ON、配置未改变和 session 健康,
+不能证明物理端口已经产生 Burst 或 Sweep。artifact 固定记录 `emission_verified = false` 与
+`external_measurement_required = true`。
+
+driver 异常、结果类型错误或后置条件失败时,Core 清除 receipt,只允许一次 V2 output OFF recovery 与独立回读,
+不会重新 fire 或恢复 ON。D1-3 不增加 CLI 命令或 run plan step;现有 V1 trigger 仅在对应 fire capability 已声明且
+同 session receipt 有效时映射到本 operation。现有 CLI 与 run step 仍构造默认 internal 请求;为保持已有
+operation artifact 字节形状,默认 `trigger_source=internal` 不写入 request payload,manual 请求则显式记录该字段。
+物理发出能力必须在具体插件的 A4 实机验收中由外部测量证明。
+
### M6-B 已实现:ARB storage 与 selection
M6-B 使用两个独立 capability,不把上传、选择、基本幅度配置或输出 ON 合并为一个 driver 调用:
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 32154ff..1b83461 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -59,9 +59,11 @@
"source.pulse_configure_v2": ("configure_source_pulse_v2",),
"source.modulation_pm_configure_v2": ("configure_source_pm_modulation_v2",),
"source.burst_configure_v2": ("configure_source_burst_v2",),
+ "source.burst_fire_v2": ("fire_source_burst_v2",),
"source.modulation_fm_configure_v2": ("configure_source_fm_modulation_v2",),
"source.modulation_pwm_configure_v2": ("configure_source_pwm_modulation_v2",),
"source.sweep_configure_v2": ("configure_source_sweep_v2",),
+ "source.sweep_fire_v2": ("fire_source_sweep_v2",),
"source.output_v2": ("set_source_output_v2",),
"source.arbitrary_storage_v2": (
"read_source_arbitrary_storage_v2",
@@ -87,9 +89,11 @@
"source.pulse_configure_v2",
"source.modulation_pm_configure_v2",
"source.burst_configure_v2",
+ "source.burst_fire_v2",
"source.modulation_fm_configure_v2",
"source.modulation_pwm_configure_v2",
"source.sweep_configure_v2",
+ "source.sweep_fire_v2",
"source.output_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
@@ -557,10 +561,10 @@ def _validate_write_contract(
raise ConfigError(
"source.burst_configure_v2 requires burst feature CONFIGURE directions"
)
- readable = _channels_with_burst_triggered_internal_configuration_readback(extensions)
+ readable = _channels_with_burst_triggered_configuration_readback(extensions)
if not configurable <= readable:
raise ConfigError(
- "source.burst_configure_v2 requires readable internal triggered burst "
+ "source.burst_configure_v2 requires readable internal or manual triggered burst "
"configuration on every channel"
)
if not configurable <= output_readable:
@@ -568,6 +572,31 @@ def _validate_write_contract(
"source.burst_configure_v2 requires readable output state on every channel"
)
+ if "source.burst_fire_v2" in capabilities:
+ required = {"source.burst_configure_v2", "source.output_v2"}
+ if not required <= capabilities:
+ raise ConfigError(
+ "source.burst_fire_v2 requires source.burst_configure_v2 and source.output_v2"
+ )
+ fireable = _channels_with_direction(
+ extensions,
+ SourceFeature.BURST,
+ SourceFeatureDirection.FIRE,
+ )
+ readable = _channels_with_burst_triggered_configuration_readback(
+ extensions,
+ trigger_source=SourceTriggerSource.MANUAL,
+ )
+ if not fireable or not fireable <= readable:
+ raise ConfigError(
+ "source.burst_fire_v2 requires readable manual triggered burst "
+ "configuration on every FIRE channel"
+ )
+ if not fireable <= basic_readable or not fireable <= output_readable:
+ raise ConfigError(
+ "source.burst_fire_v2 requires readable final VPP, Offset and output state"
+ )
+
if "source.sweep_configure_v2" in capabilities:
configurable = _channels_with_direction(
extensions,
@@ -581,7 +610,7 @@ def _validate_write_contract(
readable = _channels_with_sweep_configuration_readback(extensions)
if not configurable <= readable:
raise ConfigError(
- "source.sweep_configure_v2 requires readable internal sweep configuration "
+ "source.sweep_configure_v2 requires readable internal or manual sweep configuration "
"and sweep frequency mode on every channel"
)
if not configurable <= output_readable:
@@ -589,6 +618,31 @@ def _validate_write_contract(
"source.sweep_configure_v2 requires readable output state on every channel"
)
+ if "source.sweep_fire_v2" in capabilities:
+ required = {"source.sweep_configure_v2", "source.output_v2"}
+ if not required <= capabilities:
+ raise ConfigError(
+ "source.sweep_fire_v2 requires source.sweep_configure_v2 and source.output_v2"
+ )
+ fireable = _channels_with_direction(
+ extensions,
+ SourceFeature.SWEEP,
+ SourceFeatureDirection.FIRE,
+ )
+ readable = _channels_with_sweep_configuration_readback(
+ extensions,
+ trigger_source=SourceTriggerSource.MANUAL,
+ )
+ if not fireable or not fireable <= readable:
+ raise ConfigError(
+ "source.sweep_fire_v2 requires readable manual sweep configuration "
+ "on every FIRE channel"
+ )
+ if not fireable <= basic_readable or not fireable <= output_readable:
+ raise ConfigError(
+ "source.sweep_fire_v2 requires readable final VPP, Offset and output state"
+ )
+
if "source.arbitrary_storage_v2" in capabilities:
configurable = _channels_with_direction(
extensions,
@@ -962,9 +1016,16 @@ def _channels_with_pulse_width_configuration_readback(
)
-def _channels_with_burst_triggered_internal_configuration_readback(
+def _channels_with_burst_triggered_configuration_readback(
extensions: SourceDescriptorExtensions,
+ *,
+ trigger_source: SourceTriggerSource | None = None,
) -> frozenset[int]:
+ allowed_sources = (
+ {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL}
+ if trigger_source is None
+ else {trigger_source}
+ )
return frozenset(
channel
for feature in extensions.features
@@ -975,9 +1036,19 @@ def _channels_with_burst_triggered_internal_configuration_readback(
and SourceFeatureDirection.READ in feature.directions
and isinstance(feature.profile, SourceBurstCapabilityProfile)
and SourceBurstMode.TRIGGERED in feature.profile.modes
- and SourceTriggerSource.INTERNAL in feature.profile.trigger_sources
and feature.profile.timing_readable
- and feature.profile.triggered_internal_configuration_readable
+ and (
+ (
+ SourceTriggerSource.INTERNAL in allowed_sources
+ and SourceTriggerSource.INTERNAL in feature.profile.trigger_sources
+ and feature.profile.triggered_internal_configuration_readable
+ )
+ or (
+ SourceTriggerSource.MANUAL in allowed_sources
+ and SourceTriggerSource.MANUAL in feature.profile.trigger_sources
+ and feature.profile.triggered_manual_configuration_readable
+ )
+ )
)
for channel in feature.channels
)
@@ -985,7 +1056,14 @@ def _channels_with_burst_triggered_internal_configuration_readback(
def _channels_with_sweep_configuration_readback(
extensions: SourceDescriptorExtensions,
+ *,
+ trigger_source: SourceTriggerSource | None = None,
) -> frozenset[int]:
+ allowed_sources = (
+ {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL}
+ if trigger_source is None
+ else {trigger_source}
+ )
sweep_channels = frozenset(
channel
for feature in extensions.features
@@ -996,7 +1074,10 @@ def _channels_with_sweep_configuration_readback(
and SourceFeatureDirection.READ in feature.directions
and isinstance(feature.profile, SourceSweepCapabilityProfile)
and bool(feature.profile.spacing_modes)
- and SourceTriggerSource.INTERNAL in feature.profile.trigger_sources
+ and any(
+ source in feature.profile.trigger_sources
+ for source in allowed_sources
+ )
and feature.profile.timing_readable
and feature.profile.marker_readable
and feature.profile.configuration_readable
@@ -1114,9 +1195,15 @@ def _validate_declared_write_directions(
(SourceFeature.BURST, SourceFeatureDirection.CONFIGURE): frozenset(
{"source.burst_configure_v2"}
),
+ (SourceFeature.BURST, SourceFeatureDirection.FIRE): frozenset(
+ {"source.burst_fire_v2"}
+ ),
(SourceFeature.SWEEP, SourceFeatureDirection.CONFIGURE): frozenset(
{"source.sweep_configure_v2"}
),
+ (SourceFeature.SWEEP, SourceFeatureDirection.FIRE): frozenset(
+ {"source.sweep_fire_v2"}
+ ),
(SourceFeature.ARBITRARY, SourceFeatureDirection.CONFIGURE): frozenset(
{
"source.arbitrary_storage_v2",
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index 2e3b924..b378e72 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -629,6 +629,7 @@ class SourceBurstCapabilityProfile:
timing_readable: bool
gate_readable: bool
triggered_internal_configuration_readable: bool = False
+ triggered_manual_configuration_readable: bool = False
def __post_init__(self) -> None:
_require_enum_tuple(self.modes, SourceBurstMode, "burst modes")
@@ -639,6 +640,10 @@ def __post_init__(self) -> None:
self.triggered_internal_configuration_readable,
"burst triggered_internal_configuration_readable",
)
+ _require_bool(
+ self.triggered_manual_configuration_readable,
+ "burst triggered_manual_configuration_readable",
+ )
@dataclass(frozen=True, slots=True)
@@ -1385,6 +1390,38 @@ def __post_init__(self) -> None:
)
+SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.burst_fire_v2",
+ capability="source.burst_fire_v2",
+ feature=SourceFeature.BURST,
+ direction=SourceFeatureDirection.FIRE,
+ energy_effect=SourceEnergyEffect.EMIT,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(
+ SourceFieldId.BASIC,
+ SourceFieldId.BURST,
+ SourceFieldId.OUTPUT,
+ SourceFieldId.IDENTITY,
+ ),
+ changed_fields=(SourceFieldId.BURST,),
+ postcondition_fields=(
+ SourceFieldId.BURST,
+ SourceFieldId.OUTPUT,
+ ),
+ cleanup_verification_fields=(SourceFieldId.OUTPUT,),
+ v1_equivalent_routes=(SourceV1WriteRouteId.TRIGGER_BURST,),
+ v1_overlapping_routes=(
+ SourceV1WriteRouteId.CONFIGURE_BURST,
+ SourceV1WriteRouteId.RESTORE,
+ SourceV1WriteRouteId.SET_OUTPUT,
+ ),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+)
+
+
SOURCE_SWEEP_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract(
operation="source.sweep_configure_v2",
capability="source.sweep_configure_v2",
@@ -1421,6 +1458,38 @@ def __post_init__(self) -> None:
)
+SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.sweep_fire_v2",
+ capability="source.sweep_fire_v2",
+ feature=SourceFeature.SWEEP,
+ direction=SourceFeatureDirection.FIRE,
+ energy_effect=SourceEnergyEffect.EMIT,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ SourceFieldId.SWEEP,
+ SourceFieldId.IDENTITY,
+ ),
+ changed_fields=(SourceFieldId.SWEEP,),
+ postcondition_fields=(
+ SourceFieldId.OUTPUT,
+ SourceFieldId.SWEEP,
+ ),
+ cleanup_verification_fields=(SourceFieldId.OUTPUT,),
+ v1_equivalent_routes=(SourceV1WriteRouteId.TRIGGER_SWEEP,),
+ v1_overlapping_routes=(
+ SourceV1WriteRouteId.CONFIGURE_SWEEP,
+ SourceV1WriteRouteId.RESTORE,
+ SourceV1WriteRouteId.SET_OUTPUT,
+ ),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+)
+
+
SOURCE_FM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract(
operation="source.modulation_fm_configure_v2",
capability="source.modulation_fm_configure_v2",
@@ -3088,6 +3157,7 @@ class SourceBurstConfigureRequest:
phase_deg: float
internal_period_s: float
delay_s: float
+ trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL
def __post_init__(self) -> None:
_require_int(self.channel, "source burst configure channel", minimum=1)
@@ -3113,6 +3183,13 @@ def __post_init__(self) -> None:
minimum=0.0,
maximum=85.0,
)
+ if not isinstance(self.trigger_source, SourceTriggerSource) or self.trigger_source not in {
+ SourceTriggerSource.INTERNAL,
+ SourceTriggerSource.MANUAL,
+ }:
+ raise ValueError(
+ "source burst configure trigger_source must be internal or manual"
+ )
@dataclass(frozen=True, slots=True)
@@ -3123,6 +3200,7 @@ class SourceSweepConfigureRequest:
spacing: SourceSweepSpacing
steps: int
sweep_time_s: float
+ trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL
def __post_init__(self) -> None:
_require_int(self.channel, "source sweep configure channel", minimum=1)
@@ -3150,6 +3228,29 @@ def __post_init__(self) -> None:
minimum=0.001,
maximum=300.0,
)
+ if not isinstance(self.trigger_source, SourceTriggerSource) or self.trigger_source not in {
+ SourceTriggerSource.INTERNAL,
+ SourceTriggerSource.MANUAL,
+ }:
+ raise ValueError(
+ "source sweep configure trigger_source must be internal or manual"
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class SourceFireRequest:
+ channel: int
+
+ def __post_init__(self) -> None:
+ _require_int(self.channel, "source fire channel", minimum=1)
+
+
+@dataclass(frozen=True, slots=True)
+class SourceFireResult:
+ channel: int
+
+ def __post_init__(self) -> None:
+ _require_int(self.channel, "source fire result channel", minimum=1)
@dataclass(frozen=True, slots=True)
@@ -3718,13 +3819,6 @@ def __post_init__(self) -> None:
if observed.availability is not Availability.VALUE:
raise ValueError(f"source burst configure result requires {label} readback")
values[label] = observed.value
- SourceBurstConfigureRequest(
- channel=self.channel,
- cycles=values["cycles"], # type: ignore[arg-type]
- phase_deg=values["phase_deg"], # type: ignore[arg-type]
- internal_period_s=values["internal_period_s"], # type: ignore[arg-type]
- delay_s=values["delay_s"], # type: ignore[arg-type]
- )
if self.burst.trigger.availability is not Availability.VALUE or not isinstance(
self.burst.trigger.value,
SourceTriggerState,
@@ -3733,9 +3827,20 @@ def __post_init__(self) -> None:
trigger = self.burst.trigger.value
if (
trigger.source.availability is not Availability.VALUE
- or trigger.source.value is not SourceTriggerSource.INTERNAL
+ or trigger.source.value
+ not in {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL}
):
- raise ValueError("source burst configure result requires internal trigger readback")
+ raise ValueError(
+ "source burst configure result requires internal or manual trigger readback"
+ )
+ SourceBurstConfigureRequest(
+ channel=self.channel,
+ cycles=values["cycles"], # type: ignore[arg-type]
+ phase_deg=values["phase_deg"], # type: ignore[arg-type]
+ internal_period_s=values["internal_period_s"], # type: ignore[arg-type]
+ delay_s=values["delay_s"], # type: ignore[arg-type]
+ trigger_source=trigger.source.value,
+ )
if (
trigger.slope.availability is not Availability.VALUE
or trigger.slope.value is not SourceTriggerSlope.POSITIVE
@@ -3994,14 +4099,6 @@ def __post_init__(self) -> None:
if observed.availability is not Availability.VALUE:
raise ValueError(f"source sweep configure result requires {label} readback")
values[label] = observed.value
- SourceSweepConfigureRequest(
- channel=self.channel,
- start_hz=values["start_hz"], # type: ignore[arg-type]
- stop_hz=values["stop_hz"], # type: ignore[arg-type]
- spacing=values["spacing"], # type: ignore[arg-type]
- steps=values["steps"], # type: ignore[arg-type]
- sweep_time_s=values["sweep_time_s"], # type: ignore[arg-type]
- )
for label, observed in (
("start_hold_s", self.sweep.start_hold_s),
("stop_hold_s", self.sweep.stop_hold_s),
@@ -4019,9 +4116,21 @@ def __post_init__(self) -> None:
trigger = self.sweep.trigger.value
if (
trigger.source.availability is not Availability.VALUE
- or trigger.source.value is not SourceTriggerSource.INTERNAL
+ or trigger.source.value
+ not in {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL}
):
- raise ValueError("source sweep configure result requires internal trigger readback")
+ raise ValueError(
+ "source sweep configure result requires internal or manual trigger readback"
+ )
+ SourceSweepConfigureRequest(
+ channel=self.channel,
+ start_hz=values["start_hz"], # type: ignore[arg-type]
+ stop_hz=values["stop_hz"], # type: ignore[arg-type]
+ spacing=values["spacing"], # type: ignore[arg-type]
+ steps=values["steps"], # type: ignore[arg-type]
+ sweep_time_s=values["sweep_time_s"], # type: ignore[arg-type]
+ trigger_source=trigger.source.value,
+ )
if (
trigger.slope.availability is not Availability.VALUE
or trigger.slope.value is not SourceTriggerSlope.POSITIVE
@@ -5125,6 +5234,14 @@ def configure_source_burst_v2(
) -> SourceBurstConfigureResult: ...
+@runtime_checkable
+class SourceBurstFireV2Driver(InstrumentDriver, Protocol):
+ def fire_source_burst_v2(
+ self,
+ request: SourceFireRequest,
+ ) -> SourceFireResult: ...
+
+
@runtime_checkable
class SourceFmModulationConfigureV2Driver(InstrumentDriver, Protocol):
def configure_source_fm_modulation_v2(
@@ -5149,6 +5266,14 @@ def configure_source_sweep_v2(
) -> SourceSweepConfigureResult: ...
+@runtime_checkable
+class SourceSweepFireV2Driver(InstrumentDriver, Protocol):
+ def fire_source_sweep_v2(
+ self,
+ request: SourceFireRequest,
+ ) -> SourceFireResult: ...
+
+
@runtime_checkable
class SourcePulseConfigureV2Driver(InstrumentDriver, Protocol):
def configure_source_pulse_v2(
@@ -5539,4 +5664,10 @@ def source_snapshot_timestamp_utc() -> str:
"SourceBasicLiveConfigureResult",
"SourceBasicLiveConfigureV2Driver",
"SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT",
+ "SourceFireRequest",
+ "SourceFireResult",
+ "SourceBurstFireV2Driver",
+ "SourceSweepFireV2Driver",
+ "SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT",
+ "SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT",
]
diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py
index aeddc94..edd4fb8 100644
--- a/src/wavebench/services/operation_specs.py
+++ b/src/wavebench/services/operation_specs.py
@@ -709,6 +709,43 @@ def _spec(
error_check_minimum="disabled",
risk_flags=("source_v2", "output_must_be_off", "sweep_internal_no_fire"),
),
+ _spec(
+ "source.sweep_fire_v2",
+ "source",
+ required_capabilities=(
+ "source.sweep_fire_v2",
+ "source.sweep_configure_v2",
+ "source.output_v2",
+ ),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.channel.sweep",),
+ restore_coverage="source-v2-sweep-fire",
+ required_verified_fields=(
+ "source.identity",
+ "source.channel.basic",
+ "source.channel.output",
+ "source.channel.sweep",
+ ),
+ verification_fields=(
+ "source.identity",
+ "source.channel.basic",
+ "source.channel.output",
+ "source.channel.sweep",
+ ),
+ postcondition_fields=("source.channel.output", "source.channel.sweep"),
+ cleanup_verification_fields=("source.channel.output",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=(
+ "source_v2",
+ "persistent_session_required",
+ "output_must_be_on",
+ "emits_signal",
+ "no_retry",
+ ),
+ ),
_spec(
"source.burst_configure_v2",
"source",
@@ -734,6 +771,43 @@ def _spec(
error_check_minimum="disabled",
risk_flags=("source_v2", "output_must_be_off", "burst_internal_triggered_only"),
),
+ _spec(
+ "source.burst_fire_v2",
+ "source",
+ required_capabilities=(
+ "source.burst_fire_v2",
+ "source.burst_configure_v2",
+ "source.output_v2",
+ ),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.channel.burst",),
+ restore_coverage="source-v2-burst-fire",
+ required_verified_fields=(
+ "source.identity",
+ "source.channel.basic",
+ "source.channel.burst",
+ "source.channel.output",
+ ),
+ verification_fields=(
+ "source.identity",
+ "source.channel.basic",
+ "source.channel.burst",
+ "source.channel.output",
+ ),
+ postcondition_fields=("source.channel.burst", "source.channel.output"),
+ cleanup_verification_fields=("source.channel.output",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=(
+ "source_v2",
+ "persistent_session_required",
+ "output_must_be_on",
+ "emits_signal",
+ "no_retry",
+ ),
+ ),
_spec(
"source.pulse_configure_v2",
"source",
diff --git a/src/wavebench/services/source_operation_context.py b/src/wavebench/services/source_operation_context.py
index 8027a27..69dd147 100644
--- a/src/wavebench/services/source_operation_context.py
+++ b/src/wavebench/services/source_operation_context.py
@@ -26,6 +26,7 @@
SourceEnergyEffect,
SourceFieldId,
SourceFieldRef,
+ SourceFeatureDirection,
SourceOperationContract,
SourceScopeRef,
SourceStorageEffect,
@@ -853,11 +854,19 @@ def _validate_closure_inputs(
raise ValueError("source restore order and non-restorable fields overlap")
if any(item.field in _NON_REENERGIZING_RESTORE_FIELDS for item in restore_order):
raise ValueError("source failure restore cannot re-enable output, arm, or trigger fields")
- if self.operation_contract.energy_effect in {
- SourceEnergyEffect.MAY_INCREASE,
- SourceEnergyEffect.EMIT,
- } and not required_off_outputs:
+ if (
+ self.operation_contract.energy_effect is SourceEnergyEffect.MAY_INCREASE
+ or (
+ self.operation_contract.energy_effect is SourceEnergyEffect.EMIT
+ and self.operation_contract.direction is not SourceFeatureDirection.FIRE
+ )
+ ) and not required_off_outputs:
raise ValueError("energy-increasing Source operations require explicit OFF outputs")
+ if (
+ self.operation_contract.direction is SourceFeatureDirection.FIRE
+ and not emergency_off_outputs
+ ):
+ raise ValueError("Source fire operations require explicit emergency OFF outputs")
def _build_closure(
self,
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 49625e0..ce6a94f 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -2,7 +2,7 @@
from collections.abc import Iterator
from contextlib import contextmanager
-from dataclasses import dataclass
+from dataclasses import dataclass, field as dataclass_field
from hashlib import sha256
from math import isfinite
import time
@@ -78,6 +78,7 @@
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT,
SOURCE_COMBINE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_COUPLING_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_CONTRACT_VERSION,
@@ -92,6 +93,7 @@
SOURCE_PWM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_PHASE_RELATION_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_SWEEP_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT,
SOURCE_TRACKING_CONFIGURE_V2_OPERATION_CONTRACT,
SnapshotConsistencyState,
SourceDescriptorExtensions,
@@ -117,6 +119,7 @@
SourceBurstConfigureRequest,
SourceBurstConfigureResult,
SourceBurstConfigureV2Driver,
+ SourceBurstFireV2Driver,
SourceBurstMode,
SourceCombineConfigureRequest,
SourceCombineConfigureV2Driver,
@@ -135,6 +138,8 @@
SourceFmModulationConfigureRequest,
SourceFmModulationConfigureResult,
SourceFmModulationConfigureV2Driver,
+ SourceFireRequest,
+ SourceFireResult,
SourceHarmonicCapabilityProfile,
SourceHarmonicDisableRequest,
SourceHarmonicDisableResult,
@@ -173,6 +178,7 @@
SourceSweepConfigureRequest,
SourceSweepConfigureResult,
SourceSweepConfigureV2Driver,
+ SourceSweepFireV2Driver,
SourceSweepMarker,
SourceSnapshotV2,
SourceSnapshotV2Driver,
@@ -308,6 +314,15 @@ class _SourceBurstConfigureV2Transaction:
snapshot: SourceSnapshotV2
+@dataclass(frozen=True, slots=True)
+class _SourceFireV2Transaction:
+ """Core transaction result shared by Burst and Sweep fire routes."""
+
+ result: SourceFireResult
+ artifact: dict[str, object]
+ snapshot: SourceSnapshotV2
+
+
@dataclass(frozen=True, slots=True)
class _SourcePulseConfigureV2Transaction:
"""Core transaction result shared by the WIDTH Pulse public route."""
@@ -376,6 +391,12 @@ class SourceService(SessionStateAliasMixin):
session_state: InstrumentSessionState | None = None
lease: ResourceLease | None = None
state_guard: SourceStateGuard | None = None
+ _v2_fire_receipts: dict[tuple[SourceFeature, int, str], str] = dataclass_field(
+ default_factory=dict,
+ init=False,
+ repr=False,
+ compare=False,
+ )
def _require(self, operation: str, *capabilities: str) -> None:
source = self._source_config()
@@ -425,6 +446,49 @@ def _declares_source_v2_basic_restore(self) -> bool:
"source.output_v2",
}.issubset(capabilities)
+ def _clear_source_v2_fire_receipt(
+ self,
+ feature: SourceFeature,
+ channel: int,
+ ) -> None:
+ for receipt in tuple(self._v2_fire_receipts):
+ if receipt[:2] == (feature, channel):
+ del self._v2_fire_receipts[receipt]
+
+ def _record_source_v2_fire_receipt(
+ self,
+ feature: SourceFeature,
+ channel: int,
+ feature_state: BurstFacet | SweepFacet,
+ ) -> None:
+ session_state = self.session_state
+ if self.session is None or session_state is None:
+ return
+ self._v2_fire_receipts[(feature, channel, session_state.epoch_id)] = (
+ source_v2_digest(feature_state)
+ )
+
+ def _require_source_v2_fire_receipt(
+ self,
+ feature: SourceFeature,
+ channel: int,
+ *,
+ operation: str,
+ ) -> str:
+ if self.session is None:
+ raise ConfigError(f"{operation} requires a persistent source session")
+ session_state = self.session_state
+ if session_state is None:
+ raise ConfigError(f"{operation} requires a connection-bound session state")
+ receipt = self._v2_fire_receipts.get(
+ (feature, channel, session_state.epoch_id)
+ )
+ if receipt is None:
+ raise ConfigError(
+ f"{operation} requires {feature.value} configuration from the same session"
+ )
+ return receipt
+
def _reject_v1_route_for_source_v2(
self,
route: SourceV1WriteRouteId,
@@ -666,8 +730,32 @@ def configure_sweep_v2(
) -> tuple[SourceSweepConfigureResult, dict[str, object]]:
"""Configure one OFF source channel with the declared internal Sweep scope."""
- transaction = self._configure_sweep_v2_transaction(
+ if isinstance(request, SourceSweepConfigureRequest):
+ self._clear_source_v2_fire_receipt(SourceFeature.SWEEP, request.channel)
+ transaction = self._configure_sweep_v2_transaction(request, correlation_id=correlation_id)
+ _, configured_sweep, _ = self._source_v2_sweep_target(
+ transaction.snapshot,
+ transaction.result.channel,
+ operation="source.sweep_configure_v2",
+ )
+ self._record_source_v2_fire_receipt(
+ SourceFeature.SWEEP,
+ transaction.result.channel,
+ configured_sweep,
+ )
+ return transaction.result, transaction.artifact
+
+ def fire_sweep_v2(
+ self,
+ request: SourceFireRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceFireResult, dict[str, object]]:
+ """Fire one configured internal Sweep on its persistent source session."""
+
+ transaction = self._fire_source_v2_transaction(
request,
+ feature=SourceFeature.SWEEP,
correlation_id=correlation_id,
)
return transaction.result, transaction.artifact
@@ -680,8 +768,32 @@ def configure_burst_v2(
) -> tuple[SourceBurstConfigureResult, dict[str, object]]:
"""Configure one OFF source channel with the internal Triggered Burst scope."""
- transaction = self._configure_burst_v2_transaction(
+ if isinstance(request, SourceBurstConfigureRequest):
+ self._clear_source_v2_fire_receipt(SourceFeature.BURST, request.channel)
+ transaction = self._configure_burst_v2_transaction(request, correlation_id=correlation_id)
+ configured_burst, _ = self._source_v2_burst_target(
+ transaction.snapshot,
+ transaction.result.channel,
+ operation="source.burst_configure_v2",
+ )
+ self._record_source_v2_fire_receipt(
+ SourceFeature.BURST,
+ transaction.result.channel,
+ configured_burst,
+ )
+ return transaction.result, transaction.artifact
+
+ def fire_burst_v2(
+ self,
+ request: SourceFireRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceFireResult, dict[str, object]]:
+ """Fire one configured internal Burst on its persistent source session."""
+
+ transaction = self._fire_source_v2_transaction(
request,
+ feature=SourceFeature.BURST,
correlation_id=correlation_id,
)
return transaction.result, transaction.artifact
@@ -2942,6 +3054,251 @@ def _configure_burst_v2_transaction(
context.complete()
raise
+ def _fire_source_v2_transaction(
+ self,
+ request: SourceFireRequest,
+ *,
+ feature: SourceFeature,
+ correlation_id: str | None = None,
+ ) -> _SourceFireV2Transaction:
+ """Fire one configured Burst or Sweep without retrying the command."""
+
+ if feature is SourceFeature.BURST:
+ operation = "source.burst_fire_v2"
+ capability = "source.burst_fire_v2"
+ configure_capability = "source.burst_configure_v2"
+ contract = SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT
+ feature_field_id = SourceFieldId.BURST
+ elif feature is SourceFeature.SWEEP:
+ operation = "source.sweep_fire_v2"
+ capability = "source.sweep_fire_v2"
+ configure_capability = "source.sweep_configure_v2"
+ contract = SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT
+ feature_field_id = SourceFieldId.SWEEP
+ else: # pragma: no cover - private callers pass one of the two declared features.
+ raise ValueError("source fire feature must be Burst or Sweep")
+ if not isinstance(request, SourceFireRequest):
+ raise ConfigError(f"{operation} requires SourceFireRequest")
+ self._require(
+ operation,
+ "source.snapshot_v2",
+ capability,
+ configure_capability,
+ "source.output_v2",
+ )
+ configuration_digest = self._require_source_v2_fire_receipt(
+ feature,
+ request.channel,
+ operation=operation,
+ )
+ with self._source_session() as source:
+ descriptor = self.descriptor
+ extensions = None if descriptor is None else descriptor.source_extensions
+ session_state = self.session_state
+ if not isinstance(extensions, SourceDescriptorExtensions):
+ raise ConfigError(f"{operation} requires validated source_extensions")
+ if session_state is None:
+ raise ConfigError(f"{operation} requires a connection-bound session state")
+ fields = self._source_fire_v2_fields(request.channel, feature_field_id)
+ feature_field = next(field for field in fields if field.field is feature_field_id)
+ output_field = next(
+ field for field in fields if field.field is SourceFieldId.OUTPUT
+ )
+ target_scope = SourceScopeRef(
+ SourceFacetScope.CHANNEL,
+ channel=request.channel,
+ )
+ context = SourceOperationContextCoordinator(
+ session_state=session_state,
+ operation_spec=require_operation_spec(operation),
+ operation_contract=contract,
+ connection_timeout_ms=self.config.connection.timeout_ms,
+ baseline_snapshot_digest=None,
+ fields=fields,
+ required_off_outputs=(),
+ emergency_off_outputs=(target_scope,),
+ restore_order=(),
+ non_restorable_fields=tuple(
+ item
+ for item in fields
+ if item.field in {feature_field_id, SourceFieldId.OUTPUT}
+ ),
+ correlation_id=correlation_id,
+ )
+ preflight_snapshot: SourceSnapshotV2 | None = None
+ postcondition_snapshot: SourceSnapshotV2 | None = None
+ result: SourceFireResult | None = None
+ main_entered = False
+ failure: BaseException | None = None
+ recovery: dict[str, object] | None = None
+
+ try:
+ preflight = context.make_phase_spec(
+ SourceOperationPhase.PREFLIGHT,
+ allowed_io={"query"},
+ fields=fields,
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(preflight) as authorization:
+ preflight_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ preflight_basic, preflight_feature, preflight_output = (
+ self._source_v2_fire_target(
+ preflight_snapshot,
+ request.channel,
+ feature=feature,
+ operation=operation,
+ )
+ )
+ self._validate_source_fire_v2_preflight(
+ request,
+ feature=feature,
+ snapshot=preflight_snapshot,
+ basic=preflight_basic,
+ feature_state=preflight_feature,
+ output=preflight_output,
+ configuration_digest=configuration_digest,
+ operation=operation,
+ )
+ context.bind_baseline_snapshot_digest(
+ source_v2_digest(
+ (
+ request.channel,
+ preflight_basic,
+ preflight_feature,
+ preflight_output,
+ )
+ )
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=fields,
+ )
+
+ main = context.make_phase_spec(
+ SourceOperationPhase.MAIN,
+ allowed_io={"write"},
+ fields=(feature_field,),
+ max_steps=contract.main_max_steps,
+ )
+ try:
+ with context.authorize_phase(main):
+ main_entered = True
+ if feature is SourceFeature.BURST:
+ result = cast(
+ SourceBurstFireV2Driver,
+ source,
+ ).fire_source_burst_v2(request)
+ else:
+ result = cast(
+ SourceSweepFireV2Driver,
+ source,
+ ).fire_source_sweep_v2(request)
+ self._validate_source_fire_v2_result(
+ request,
+ result,
+ operation=operation,
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is None:
+ try:
+ postcondition = context.make_phase_spec(
+ SourceOperationPhase.POSTCONDITION,
+ allowed_io={"query"},
+ fields=fields,
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(postcondition) as authorization:
+ postcondition_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ post_basic, post_feature, post_output = (
+ self._source_v2_fire_target(
+ postcondition_snapshot,
+ request.channel,
+ feature=feature,
+ operation=operation,
+ )
+ )
+ self._validate_source_fire_v2_postcondition(
+ request,
+ feature=feature,
+ snapshot=postcondition_snapshot,
+ basic=post_basic,
+ feature_state=post_feature,
+ output=post_output,
+ configuration_digest=configuration_digest,
+ operation=operation,
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=fields,
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is not None:
+ if main_entered:
+ self._clear_source_v2_fire_receipt(feature, request.channel)
+ try:
+ context.mark_failure_required()
+ recovery = self._recover_source_v2_output_off(
+ context,
+ source,
+ request.channel,
+ extensions,
+ output_field,
+ operation=operation,
+ )
+ except BaseException:
+ recovery = {
+ "status": "recovery_setup_failed",
+ "session_health": session_state.health.value,
+ }
+ context.complete()
+ if main_entered:
+ self._attach_source_fire_v2_diagnostics(
+ failure,
+ context=context,
+ request=request,
+ feature=feature,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ recovery=recovery,
+ )
+ raise failure
+
+ context.complete()
+ assert result is not None
+ assert preflight_snapshot is not None
+ assert postcondition_snapshot is not None
+ return _SourceFireV2Transaction(
+ result=result,
+ artifact=self._source_fire_v2_artifact(
+ context=context,
+ request=request,
+ feature=feature,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ ),
+ snapshot=postcondition_snapshot,
+ )
+ except BaseException:
+ if not context.terminal:
+ context.complete()
+ raise
+
def _configure_pulse_v2_transaction(
self,
request: SourcePulseConfigureRequest,
@@ -4423,6 +4780,36 @@ def _source_burst_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]:
)
)
+ @staticmethod
+ def _source_fire_v2_fields(
+ channel: int,
+ feature_field: SourceFieldId,
+ ) -> tuple[SourceFieldRef, ...]:
+ if feature_field not in {SourceFieldId.BURST, SourceFieldId.SWEEP}:
+ raise ValueError("source fire field must be Burst or Sweep")
+ target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel)
+ fields = (
+ SourceFieldRef(SourceFieldId.BASIC, target),
+ SourceFieldRef(feature_field, target),
+ SourceFieldRef(SourceFieldId.OUTPUT, target),
+ SourceFieldRef(
+ SourceFieldId.IDENTITY,
+ SourceScopeRef(SourceFacetScope.INSTRUMENT),
+ ),
+ )
+ return tuple(
+ sorted(
+ fields,
+ key=lambda item: (
+ item.field.value,
+ item.target.scope.value,
+ -1 if item.target.channel is None else item.target.channel,
+ item.target.channels,
+ "" if item.target.input_id is None else item.target.input_id,
+ ),
+ )
+ )
+
@staticmethod
def _source_pulse_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]:
target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel)
@@ -4675,6 +5062,34 @@ def _source_v2_sweep_target(
raise ConfigError(f"{operation} requires readable output state")
return target.basic.value, target.sweep.value, target.output.value
+ def _source_v2_fire_target(
+ self,
+ snapshot: SourceSnapshotV2,
+ channel: int,
+ *,
+ feature: SourceFeature,
+ operation: str,
+ ) -> tuple[BasicWaveFacet, BurstFacet | SweepFacet, OutputFacet]:
+ if feature is SourceFeature.SWEEP:
+ return self._source_v2_sweep_target(
+ snapshot,
+ channel,
+ operation=operation,
+ )
+ if feature is SourceFeature.BURST:
+ basic, output = self._source_v2_target(
+ snapshot,
+ channel,
+ operation=operation,
+ )
+ burst, _ = self._source_v2_burst_target(
+ snapshot,
+ channel,
+ operation=operation,
+ )
+ return basic, burst, output
+ raise ValueError("source fire feature must be Burst or Sweep")
+
@staticmethod
def _source_v2_arbitrary_target(
snapshot: SourceSnapshotV2,
@@ -5722,6 +6137,7 @@ def _source_sweep_runtime_profile(
*,
channel: int,
operation: str,
+ direction: SourceFeatureDirection = SourceFeatureDirection.CONFIGURE,
) -> SourceSweepCapabilityProfile:
feature = next(
(
@@ -5731,7 +6147,7 @@ def _source_sweep_runtime_profile(
and candidate.scope is SourceFacetScope.CHANNEL
and channel in candidate.channels
and candidate.support is SupportState.SUPPORTED
- and SourceFeatureDirection.CONFIGURE in candidate.directions
+ and direction in candidate.directions
),
None,
)
@@ -5783,8 +6199,10 @@ def _validate_source_sweep_v2_preflight(
)
if request.spacing not in profile.spacing_modes:
raise ConfigError(f"{operation} spacing is not supported by the runtime profile")
- if SourceTriggerSource.INTERNAL not in profile.trigger_sources:
- raise ConfigError(f"{operation} internal trigger is not supported by the runtime profile")
+ if request.trigger_source not in profile.trigger_sources:
+ raise ConfigError(
+ f"{operation} requested trigger source is not supported by the runtime profile"
+ )
if not profile.timing_readable or not profile.marker_readable:
raise ConfigError(f"{operation} requires sweep timing and marker readback")
if not profile.configuration_readable:
@@ -5836,9 +6254,9 @@ def _validate_source_sweep_v2_readback(
trigger = sweep.trigger.value
if (
trigger.source.availability is not Availability.VALUE
- or trigger.source.value is not SourceTriggerSource.INTERNAL
+ or trigger.source.value is not request.trigger_source
):
- raise ConfigError(f"{operation} trigger source does not match scope")
+ raise ConfigError(f"{operation} trigger source does not match request")
if (
trigger.slope.availability is not Availability.VALUE
or trigger.slope.value is not SourceTriggerSlope.POSITIVE
@@ -6197,6 +6615,7 @@ def _source_burst_runtime_profile(
*,
channel: int,
operation: str,
+ direction: SourceFeatureDirection = SourceFeatureDirection.CONFIGURE,
) -> SourceBurstCapabilityProfile:
feature = next(
(
@@ -6206,7 +6625,7 @@ def _source_burst_runtime_profile(
and candidate.scope is SourceFacetScope.CHANNEL
and channel in candidate.channels
and candidate.support is SupportState.SUPPORTED
- and SourceFeatureDirection.CONFIGURE in candidate.directions
+ and direction in candidate.directions
),
None,
)
@@ -6234,12 +6653,22 @@ def _validate_source_burst_v2_preflight(
)
if SourceBurstMode.TRIGGERED not in profile.modes:
raise ConfigError(f"{operation} triggered mode is not supported by the runtime profile")
- if SourceTriggerSource.INTERNAL not in profile.trigger_sources:
- raise ConfigError(f"{operation} internal trigger is not supported by the runtime profile")
+ if request.trigger_source not in profile.trigger_sources:
+ raise ConfigError(
+ f"{operation} requested trigger source is not supported by the runtime profile"
+ )
if not profile.timing_readable:
raise ConfigError(f"{operation} requires burst timing readback")
- if not profile.triggered_internal_configuration_readable:
- raise ConfigError(f"{operation} requires configured internal triggered burst readback")
+ configuration_readable = (
+ profile.triggered_internal_configuration_readable
+ if request.trigger_source is SourceTriggerSource.INTERNAL
+ else profile.triggered_manual_configuration_readable
+ )
+ if not configuration_readable:
+ raise ConfigError(
+ f"{operation} requires configured {request.trigger_source.value} "
+ "triggered burst readback"
+ )
@staticmethod
def _validate_source_burst_v2_readback(
@@ -6271,9 +6700,9 @@ def _validate_source_burst_v2_readback(
trigger = burst.trigger.value
if (
trigger.source.availability is not Availability.VALUE
- or trigger.source.value is not SourceTriggerSource.INTERNAL
+ or trigger.source.value is not request.trigger_source
):
- raise ConfigError(f"{operation} trigger source does not match scope")
+ raise ConfigError(f"{operation} trigger source does not match request")
if (
trigger.slope.availability is not Availability.VALUE
or trigger.slope.value is not SourceTriggerSlope.POSITIVE
@@ -6321,6 +6750,111 @@ def _validate_source_burst_v2_postcondition(
raise ConfigError(f"{operation} postcondition reports output ON")
self._validate_source_burst_v2_readback(request, burst, operation=operation)
+ def _validate_source_fire_v2_preflight(
+ self,
+ request: SourceFireRequest,
+ *,
+ feature: SourceFeature,
+ snapshot: SourceSnapshotV2,
+ basic: BasicWaveFacet,
+ feature_state: BurstFacet | SweepFacet,
+ output: OutputFacet,
+ configuration_digest: str,
+ operation: str,
+ ) -> None:
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if output.enabled.availability is not Availability.VALUE or output.enabled.value is not True:
+ raise ConfigError(f"{operation} requires target output ON")
+ if feature is SourceFeature.BURST:
+ if not isinstance(feature_state, BurstFacet):
+ raise ConfigError(f"{operation} requires readable burst state")
+ profile = self._source_burst_runtime_profile(
+ snapshot,
+ channel=request.channel,
+ operation=operation,
+ direction=SourceFeatureDirection.FIRE,
+ )
+ if (
+ SourceBurstMode.TRIGGERED not in profile.modes
+ or SourceTriggerSource.MANUAL not in profile.trigger_sources
+ or not profile.timing_readable
+ or not profile.triggered_manual_configuration_readable
+ ):
+ raise ConfigError(f"{operation} runtime Burst profile cannot prove fire scope")
+ elif feature is SourceFeature.SWEEP:
+ if not isinstance(feature_state, SweepFacet):
+ raise ConfigError(f"{operation} requires readable sweep state")
+ profile = self._source_sweep_runtime_profile(
+ snapshot,
+ channel=request.channel,
+ operation=operation,
+ direction=SourceFeatureDirection.FIRE,
+ )
+ if (
+ SourceTriggerSource.MANUAL not in profile.trigger_sources
+ or not profile.timing_readable
+ or not profile.marker_readable
+ or not profile.configuration_readable
+ ):
+ raise ConfigError(f"{operation} runtime Sweep profile cannot prove fire scope")
+ if (
+ basic.frequency_mode.availability is not Availability.VALUE
+ or basic.frequency_mode.value is not SourceFrequencyMode.SWEEP
+ ):
+ raise ConfigError(f"{operation} requires sweep frequency mode")
+ else: # pragma: no cover - private callers pass one of the two declared features.
+ raise ValueError("source fire feature must be Burst or Sweep")
+ trigger = feature_state.trigger
+ if (
+ trigger.availability is not Availability.VALUE
+ or not isinstance(trigger.value, SourceTriggerState)
+ or trigger.value.source.availability is not Availability.VALUE
+ or trigger.value.source.value is not SourceTriggerSource.MANUAL
+ ):
+ raise ConfigError(f"{operation} requires manual trigger source")
+ if source_v2_digest(feature_state) != configuration_digest:
+ raise ConfigError(
+ f"{operation} configured feature state no longer matches the same-session receipt"
+ )
+ vpp, offset = self._source_v2_amplitude_offset(basic, operation=operation)
+ self._check_source_v2_final_output_limits(vpp, offset, operation=operation)
+
+ @staticmethod
+ def _validate_source_fire_v2_result(
+ request: SourceFireRequest,
+ result: object,
+ *,
+ operation: str,
+ ) -> None:
+ if not isinstance(result, SourceFireResult):
+ raise ConfigError(f"{operation} driver returned an invalid SourceFireResult")
+ if result.channel != request.channel:
+ raise ConfigError(f"{operation} result channel does not match request")
+
+ def _validate_source_fire_v2_postcondition(
+ self,
+ request: SourceFireRequest,
+ *,
+ feature: SourceFeature,
+ snapshot: SourceSnapshotV2,
+ basic: BasicWaveFacet,
+ feature_state: BurstFacet | SweepFacet,
+ output: OutputFacet,
+ configuration_digest: str,
+ operation: str,
+ ) -> None:
+ self._validate_source_fire_v2_preflight(
+ request,
+ feature=feature,
+ snapshot=snapshot,
+ basic=basic,
+ feature_state=feature_state,
+ output=output,
+ configuration_digest=configuration_digest,
+ operation=operation,
+ )
+
@staticmethod
def _source_pulse_runtime_profile(
snapshot: SourceSnapshotV2,
@@ -7345,7 +7879,11 @@ def _source_sweep_v2_artifact(
"contract_version": SOURCE_CONTRACT_VERSION,
"descriptor_digest": descriptor_digest,
}
- artifact["request"] = source_v2_to_data(request)
+ request_data = cast(dict[str, object], source_v2_to_data(request))
+ if request.trigger_source is SourceTriggerSource.INTERNAL:
+ request_data = dict(request_data)
+ request_data.pop("trigger_source", None)
+ artifact["request"] = request_data
if preflight_snapshot is not None:
artifact["preflight"] = {
"target_channel": request.channel,
@@ -7518,7 +8056,11 @@ def _source_burst_v2_artifact(
"contract_version": SOURCE_CONTRACT_VERSION,
"descriptor_digest": descriptor_digest,
}
- artifact["request"] = source_v2_to_data(request)
+ request_data = cast(dict[str, object], source_v2_to_data(request))
+ if request.trigger_source is SourceTriggerSource.INTERNAL:
+ request_data = dict(request_data)
+ request_data.pop("trigger_source", None)
+ artifact["request"] = request_data
if preflight_snapshot is not None:
artifact["preflight"] = {
"target_channel": request.channel,
@@ -7551,6 +8093,72 @@ def _source_burst_v2_artifact(
)
return artifact
+ def _source_fire_v2_artifact(
+ self,
+ *,
+ context: SourceOperationContextCoordinator,
+ request: SourceFireRequest,
+ feature: SourceFeature,
+ preflight_snapshot: SourceSnapshotV2 | None,
+ postcondition_snapshot: SourceSnapshotV2 | None,
+ result: SourceFireResult | None,
+ recovery: dict[str, object] | None = None,
+ ) -> dict[str, object]:
+ capability = (
+ "source.burst_fire_v2"
+ if feature is SourceFeature.BURST
+ else "source.sweep_fire_v2"
+ )
+ artifact = context.artifact()
+ descriptor_digest = (
+ None
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.descriptor_digest
+ )
+ artifact["capability_decision"] = {
+ "capability": capability,
+ "contract_version": SOURCE_CONTRACT_VERSION,
+ "descriptor_digest": descriptor_digest,
+ }
+ artifact["request"] = source_v2_to_data(request)
+ artifact["persistent_session_verified"] = True
+ if preflight_snapshot is not None:
+ artifact["preflight"] = {
+ "target_channel": request.channel,
+ "snapshot_digest": source_v2_digest(preflight_snapshot),
+ "consistency": preflight_snapshot.consistency.state.value,
+ }
+ if result is not None:
+ artifact["mutation"] = {
+ "result": source_v2_to_data(result),
+ "command_completed": True,
+ }
+ if postcondition_snapshot is not None:
+ artifact["postcondition"] = {
+ "snapshot_digest": source_v2_digest(postcondition_snapshot),
+ "consistency": postcondition_snapshot.consistency.state.value,
+ "emission_verified": False,
+ "external_measurement_required": True,
+ }
+ if recovery is not None:
+ artifact["recovery"] = dict(recovery)
+ artifact["final_state"] = {
+ "session_health": context.session_state.health.value,
+ "output_expected": "off" if recovery is not None else "on",
+ }
+ artifact["evidence_refs"] = sorted(
+ {
+ evidence_ref
+ for declared_feature in (
+ ()
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.features
+ )
+ for evidence_ref in declared_feature.evidence_refs
+ }
+ )
+ return artifact
+
def _source_pulse_v2_artifact(
self,
*,
@@ -7859,6 +8467,16 @@ def _attach_source_burst_v2_diagnostics(
except Exception:
pass
+ def _attach_source_fire_v2_diagnostics(
+ self,
+ exc: BaseException,
+ **kwargs: object,
+ ) -> None:
+ try:
+ setattr(exc, "source_operation_artifact", self._source_fire_v2_artifact(**kwargs))
+ except Exception:
+ pass
+
def _attach_source_pulse_v2_diagnostics(
self,
exc: BaseException,
@@ -8281,6 +8899,9 @@ def configure_burst(
def trigger_burst(self, channel: int | None = None) -> None:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
+ if self._declares_source_v2_capability("source.burst_fire_v2"):
+ self.fire_burst_v2(SourceFireRequest(channel=channel))
+ return
self._reject_v1_route_for_source_v2(
SourceV1WriteRouteId.TRIGGER_BURST,
"source.output_v2",
@@ -8347,6 +8968,9 @@ def configure_sweep(
def trigger_sweep(self, channel: int | None = None) -> None:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
+ if self._declares_source_v2_capability("source.sweep_fire_v2"):
+ self.fire_sweep_v2(SourceFireRequest(channel=channel))
+ return
self._reject_v1_route_for_source_v2(
SourceV1WriteRouteId.TRIGGER_SWEEP,
"source.output_v2",
diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py
index 3ff850b..1c80150 100644
--- a/tests/test_operation_specs.py
+++ b/tests/test_operation_specs.py
@@ -8,6 +8,7 @@
SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT,
SOURCE_FM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_HARMONICS_DISABLE_V2_OPERATION_CONTRACT,
SOURCE_HARMONICS_CONFIGURE_V2_OPERATION_CONTRACT,
@@ -18,6 +19,7 @@
SOURCE_PULSE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_PWM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_SWEEP_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT,
SourceEnergyEffect,
)
from wavebench.services.operation_specs import (
@@ -298,6 +300,23 @@ def test_source_v2_write_specs_match_their_static_operation_contracts() -> None:
SourceEnergyEffect.DECREASE_ONLY
)
+ for contract, configure_capability in (
+ (SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT, "source.burst_configure_v2"),
+ (SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT, "source.sweep_configure_v2"),
+ ):
+ spec = require_operation_spec(contract.operation)
+ assert contract.energy_effect is SourceEnergyEffect.EMIT
+ assert spec.required_capabilities == (
+ contract.capability,
+ configure_capability,
+ "source.output_v2",
+ )
+ assert spec.postcondition_fields == tuple(
+ field.value for field in contract.postcondition_fields
+ )
+ assert "persistent_session_required" in spec.risk_flags
+ assert "no_retry" in spec.risk_flags
+
def test_registry_is_read_only_and_filters_by_instrument_kind() -> None:
assert get_operation_spec("run.check") is not None
diff --git a/tests/test_source_burst_v2.py b/tests/test_source_burst_v2.py
index 8d4ea26..09d7ff5 100644
--- a/tests/test_source_burst_v2.py
+++ b/tests/test_source_burst_v2.py
@@ -36,6 +36,8 @@
SourceFeatureCapability,
SourceFeatureDirection,
SourceFieldId,
+ SourceFireRequest,
+ SourceFireResult,
SourceGatePolarity,
SourceOutputPolarity,
SourceOutputRequest,
@@ -89,6 +91,7 @@ def _burst(
phase_deg: float = 30.0,
internal_period_s: float = 0.25,
delay_s: float = 0.5,
+ trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL,
) -> BurstFacet:
return BurstFacet(
enabled=Observed.value_of(True),
@@ -100,7 +103,7 @@ def _burst(
gate_polarity=Observed.value_of(SourceGatePolarity.NORMAL),
trigger=Observed.value_of(
SourceTriggerState(
- source=Observed.value_of(SourceTriggerSource.INTERNAL),
+ source=Observed.value_of(trigger_source),
slope=Observed.value_of(SourceTriggerSlope.POSITIVE),
output=Observed.value_of(SourceTriggerOutput.OFF),
)
@@ -115,6 +118,8 @@ def __init__(
session_state: InstrumentSessionState,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ post_fire_mismatch: bool = False,
+ raise_after_fire: bool = False,
) -> None:
self.transport = GuardedAuditedTransport(
_TextTransport(),
@@ -122,8 +127,11 @@ def __init__(
)
self.output_enabled = output_enabled
self.postcondition_mismatch = postcondition_mismatch
+ self.post_fire_mismatch = post_fire_mismatch
+ self.raise_after_fire = raise_after_fire
self.burst = _burst()
self.burst_requests: list[SourceBurstConfigureRequest] = []
+ self.fire_requests: list[SourceFireRequest] = []
self.output_requests: list[SourceOutputRequest] = []
self.v1_burst_calls = 0
self.v1_trigger_calls = 0
@@ -182,6 +190,7 @@ def configure_source_burst_v2(
phase_deg=request.phase_deg,
internal_period_s=request.internal_period_s,
delay_s=request.delay_s,
+ trigger_source=request.trigger_source,
)
return SourceBurstConfigureResult(
channel=request.channel,
@@ -189,12 +198,25 @@ def configure_source_burst_v2(
output_enabled=False,
)
+ def fire_source_burst_v2(self, request: SourceFireRequest) -> SourceFireResult:
+ self.transport.write("SOURCE:BURST:FIRE")
+ self.fire_requests.append(request)
+ if self.raise_after_fire:
+ raise ConfigError("fake Burst fire failed after write")
+ return SourceFireResult(channel=request.channel)
+
def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult:
self.transport.write("SOURCE:OUTPUT")
self.output_requests.append(request)
self.output_enabled = request.enabled
if request.enabled:
- raise AssertionError("the Burst fixture only uses recovery OFF")
+ basic = basic_facet()
+ return SourceOutputResult(
+ channel=request.channel,
+ enabled=True,
+ final_amplitude=basic.amplitude.value,
+ final_offset_v=basic.offset_v.value,
+ )
return SourceOutputResult(channel=request.channel, enabled=False)
def configure_burst(self, *args: object, **kwargs: object) -> object:
@@ -215,6 +237,11 @@ def _output(self) -> OutputFacet:
)
def _readback_burst(self) -> BurstFacet:
+ if self.post_fire_mismatch and self.fire_requests:
+ return _burst(
+ delay_s=self.burst.delay_s.value * 2.0,
+ trigger_source=self.burst.trigger.value.source.value,
+ )
if not self.postcondition_mismatch or not self.burst_requests:
return self.burst
request = self.burst_requests[-1]
@@ -226,22 +253,30 @@ def _readback_burst(self) -> BurstFacet:
)
-def _extensions():
+def _extensions(*, include_fire: bool = False):
base = source_extensions()
basic, output = base.features
burst = SourceFeatureCapability(
feature=SourceFeature.BURST,
support=SupportState.SUPPORTED,
- directions=(SourceFeatureDirection.CONFIGURE, SourceFeatureDirection.READ),
+ directions=(
+ SourceFeatureDirection.CONFIGURE,
+ *((SourceFeatureDirection.FIRE,) if include_fire else ()),
+ SourceFeatureDirection.READ,
+ ),
scope=SourceFacetScope.CHANNEL,
channels=(1,),
applicability=SourceConstraintApplicability(),
profile=SourceBurstCapabilityProfile(
modes=(SourceBurstMode.TRIGGERED,),
- trigger_sources=(SourceTriggerSource.INTERNAL,),
+ trigger_sources=(
+ SourceTriggerSource.INTERNAL,
+ *((SourceTriggerSource.MANUAL,) if include_fire else ()),
+ ),
timing_readable=True,
gate_readable=False,
triggered_internal_configuration_readable=True,
+ triggered_manual_configuration_readable=include_fire,
),
)
burst_query = SourceFacetQueryContract(
@@ -304,23 +339,30 @@ def _service(
*,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ post_fire_mismatch: bool = False,
+ raise_after_fire: bool = False,
dual_contract: bool = False,
+ include_fire: bool = False,
) -> tuple[SourceService, _BurstWriteDriver]:
session_state = InstrumentSessionState(epoch_id="source-burst-v2")
driver = _BurstWriteDriver(
session_state=session_state,
output_enabled=output_enabled,
postcondition_mismatch=postcondition_mismatch,
+ post_fire_mismatch=post_fire_mismatch,
+ raise_after_fire=raise_after_fire,
)
capabilities = [
"source.snapshot_v2",
"source.burst_configure_v2",
"source.output_v2",
]
+ if include_fire:
+ capabilities.append("source.burst_fire_v2")
if dual_contract:
capabilities.extend(("source.burst_configure", "source.burst_trigger"))
descriptor = replace(
- source_descriptor(driver=driver, extensions=_extensions()),
+ source_descriptor(driver=driver, extensions=_extensions(include_fire=include_fire)),
capabilities=tuple(capabilities),
)
validate_source_descriptor(descriptor)
@@ -338,15 +380,54 @@ def _service(
)
-def _request() -> SourceBurstConfigureRequest:
+def _request(
+ *,
+ trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL,
+) -> SourceBurstConfigureRequest:
return SourceBurstConfigureRequest(
channel=1,
cycles=12,
phase_deg=30.0,
internal_period_s=0.25,
delay_s=0.5,
+ trigger_source=trigger_source,
+ )
+
+
+def test_burst_fire_capability_requires_manual_configuration_readback() -> None:
+ session_state = InstrumentSessionState(epoch_id="source-burst-fire-profile")
+ driver = _BurstWriteDriver(session_state=session_state)
+ extensions = _extensions(include_fire=True)
+ basic, burst, output = extensions.features
+ descriptor = replace(
+ source_descriptor(
+ driver=driver,
+ extensions=replace(
+ extensions,
+ features=(
+ basic,
+ replace(
+ burst,
+ profile=replace(
+ burst.profile,
+ triggered_manual_configuration_readable=False,
+ ),
+ ),
+ output,
+ ),
+ ),
+ ),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.burst_configure_v2",
+ "source.burst_fire_v2",
+ "source.output_v2",
+ ),
)
+ with pytest.raises(ConfigError, match="readable manual triggered burst"):
+ validate_source_descriptor(descriptor)
+
def test_burst_configure_v2_writes_once_and_keeps_output_off() -> None:
service, driver = _service()
@@ -440,3 +521,120 @@ def test_v1_restore_rejects_before_io_for_a_burst_v2_driver() -> None:
assert driver.transport.counters.write_requests == 0
assert driver.transport.counters.query_calls == 0
+
+
+def test_burst_fire_v2_reuses_configuring_session_and_keeps_output_on() -> None:
+ service, driver = _service(include_fire=True)
+ configured, configure_artifact = service.configure_burst_v2(
+ _request(trigger_source=SourceTriggerSource.MANUAL)
+ )
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ result, artifact = service.fire_burst_v2(
+ SourceFireRequest(channel=1),
+ correlation_id="burst-fire",
+ )
+
+ assert result == SourceFireResult(channel=1)
+ assert configured.burst.trigger.value.source.value is SourceTriggerSource.MANUAL
+ assert configure_artifact["request"]["trigger_source"] == "manual"
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.output_enabled is True
+ assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=True)]
+ assert artifact["operation"] == "source.burst_fire_v2"
+ assert artifact["persistent_session_verified"] is True
+ assert artifact["postcondition"]["emission_verified"] is False
+ assert artifact["postcondition"]["external_measurement_required"] is True
+ assert artifact["final_state"] == {
+ "session_health": "healthy",
+ "output_expected": "on",
+ }
+
+
+def test_burst_fire_v2_requires_same_session_configuration_before_io() -> None:
+ service, driver = _service(include_fire=True)
+
+ with pytest.raises(ConfigError, match="configuration from the same session"):
+ service.fire_burst_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.transport.counters.query_calls == 0
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_burst_fire_v2_requires_persistent_session_before_io() -> None:
+ service, driver = _service(include_fire=True)
+ service.session = None
+
+ with pytest.raises(ConfigError, match="persistent source session"):
+ service.fire_burst_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.transport.counters.query_calls == 0
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_burst_fire_v2_requires_output_on_before_fire_write() -> None:
+ service, driver = _service(include_fire=True)
+ service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+
+ with pytest.raises(ConfigError, match="target output ON"):
+ service.fire_burst_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.output_requests == []
+
+
+def test_burst_fire_v2_rejects_internal_trigger_configuration() -> None:
+ service, driver = _service(include_fire=True)
+ service.configure_burst_v2(_request())
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ with pytest.raises(ConfigError, match="manual trigger source"):
+ service.fire_burst_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.output_enabled is True
+
+
+def test_burst_fire_v2_failure_is_not_retried_and_recovers_off() -> None:
+ service, driver = _service(include_fire=True, raise_after_fire=True)
+ service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ with pytest.raises(ConfigError, match="failed after write") as raised:
+ service.fire_burst_v2(SourceFireRequest(channel=1))
+
+ artifact = raised.value.source_operation_artifact
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.output_requests == [
+ SourceOutputRequest(channel=1, enabled=True),
+ SourceOutputRequest(channel=1, enabled=False),
+ ]
+ assert driver.output_enabled is False
+ assert artifact["recovery"]["status"] == "off_verified"
+ assert artifact["final_state"]["output_expected"] == "off"
+
+
+def test_burst_fire_v2_postcondition_mismatch_recovers_off() -> None:
+ service, driver = _service(include_fire=True, post_fire_mismatch=True)
+ service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ with pytest.raises(ConfigError, match="same-session receipt"):
+ service.fire_burst_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.output_requests[-1] == SourceOutputRequest(channel=1, enabled=False)
+ assert driver.output_enabled is False
+
+
+def test_v1_burst_trigger_maps_to_fire_v2_when_declared() -> None:
+ service, driver = _service(include_fire=True, dual_contract=True)
+ service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ service.trigger_burst(channel=1)
+
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.v1_trigger_calls == 0
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 6198736..827fad1 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -153,7 +153,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[noise_start + len(noise_exports) :] == match.group(1).splitlines()
+ live_exports = match.group(1).splitlines()
+ live_start = noise_start + len(noise_exports)
+ assert module.__all__[live_start : live_start + len(live_exports)] == live_exports
+ match = re.search(
+ r"D1-3/Burst 与 Sweep fire 在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[live_start + len(live_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -237,6 +246,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"timing_readable",
"gate_readable",
"triggered_internal_configuration_readable",
+ "triggered_manual_configuration_readable",
),
"SourcePulseCapabilityProfile": (
"hold_modes",
@@ -366,6 +376,8 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"SourceBasicConfigureRequest": ("channel", "patch", "mode"),
"SourceBasicConfigureResult": ("channel", "basic", "output_enabled"),
"SourceBasicLiveConfigureResult": ("channel", "basic", "output_enabled"),
+ "SourceFireRequest": ("channel",),
+ "SourceFireResult": ("channel",),
"SourceOutputRequest": ("channel", "enabled"),
"SourceOutputResult": (
"channel",
@@ -395,6 +407,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"phase_deg",
"internal_period_s",
"delay_s",
+ "trigger_source",
),
"SourceBurstConfigureResult": ("channel", "burst", "output_enabled"),
"SourceFmModulationConfigureRequest": (
@@ -417,6 +430,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"spacing",
"steps",
"sweep_time_s",
+ "trigger_source",
),
"SourceSweepConfigureResult": ("channel", "basic", "sweep", "output_enabled"),
"SourcePulseConfigureRequest": (
@@ -622,9 +636,11 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None:
"source.pulse_configure_v2": ("configure_source_pulse_v2",),
"source.modulation_pm_configure_v2": ("configure_source_pm_modulation_v2",),
"source.burst_configure_v2": ("configure_source_burst_v2",),
+ "source.burst_fire_v2": ("fire_source_burst_v2",),
"source.modulation_fm_configure_v2": ("configure_source_fm_modulation_v2",),
"source.modulation_pwm_configure_v2": ("configure_source_pwm_modulation_v2",),
"source.sweep_configure_v2": ("configure_source_sweep_v2",),
+ "source.sweep_fire_v2": ("fire_source_sweep_v2",),
"source.output_v2": ("set_source_output_v2",),
"source.arbitrary_storage_v2": (
"read_source_arbitrary_storage_v2",
@@ -673,6 +689,11 @@ def test_source_v2_basic_write_models_are_closed_and_serializable() -> None:
assert keep.action is module.PatchAction.KEEP
assert module.SourceBasicConfigureResult(1, basic_facet(), False).output_enabled is False
assert module.SourceBasicLiveConfigureResult(1, basic_facet(), True).output_enabled is True
+ assert module.source_v2_to_data(module.SourceFireRequest(1)) == {
+ "type": "SourceFireRequest",
+ "channel": 1,
+ }
+ assert module.SourceFireResult(1).channel == 1
assert module.SourceOutputResult(1, False) == module.SourceOutputResult(1, False)
with pytest.raises(ValueError, match="SET patch values"):
@@ -958,15 +979,25 @@ def test_source_v2_burst_write_models_are_closed_and_serializable() -> None:
"phase_deg": 30.0,
"internal_period_s": 0.25,
"delay_s": 0.5,
+ "trigger_source": "internal",
}
assert result.burst.mode.value is module.SourceBurstMode.TRIGGERED
with pytest.raises(ValueError, match="must be <= 500000"):
module.SourceBurstConfigureRequest(1, 500_001, 30.0, 0.25, 0.5)
with pytest.raises(ValueError, match="must be > 0"):
module.SourceBurstConfigureRequest(1, 12, 30.0, 0.0, 0.5)
+ with pytest.raises(ValueError, match="internal or manual"):
+ module.SourceBurstConfigureRequest(
+ 1,
+ 12,
+ 30.0,
+ 0.25,
+ 0.5,
+ module.SourceTriggerSource.EXTERNAL,
+ )
with pytest.raises(ValueError, match="output_enabled=False"):
module.SourceBurstConfigureResult(1, burst, True)
- with pytest.raises(ValueError, match="internal trigger readback"):
+ with pytest.raises(ValueError, match="internal or manual trigger readback"):
module.SourceBurstConfigureResult(
1,
replace(
@@ -1161,6 +1192,7 @@ def test_source_v2_sweep_write_models_are_closed_and_serializable() -> None:
"spacing": "linear",
"steps": 101,
"sweep_time_s": 1.0,
+ "trigger_source": "internal",
}
assert result.sweep.spacing.value is module.SourceSweepSpacing.LINEAR
with pytest.raises(ValueError, match="start_hz must be > 0"):
@@ -1190,6 +1222,16 @@ def test_source_v2_sweep_write_models_are_closed_and_serializable() -> None:
2_049,
1.0,
)
+ with pytest.raises(ValueError, match="internal or manual"):
+ module.SourceSweepConfigureRequest(
+ 1,
+ 100.0,
+ 1_000.0,
+ module.SourceSweepSpacing.LINEAR,
+ 101,
+ 1.0,
+ module.SourceTriggerSource.BUS,
+ )
with pytest.raises(ValueError, match="output_enabled=False"):
module.SourceSweepConfigureResult(1, basic, sweep, True)
with pytest.raises(ValueError, match="sweep frequency mode"):
@@ -2614,7 +2656,7 @@ def configure_source_pulse_v2(self, request):
)
-def test_source_v2_burst_write_requires_triggered_internal_direction_and_readback() -> None:
+def test_source_v2_burst_write_requires_supported_trigger_direction_and_readback() -> None:
extensions = source_extensions()
basic, output = extensions.features
burst = module.SourceFeatureCapability(
@@ -2681,7 +2723,7 @@ def configure_source_burst_v2(self, request):
),
)
)
- with pytest.raises(ConfigError, match="readable internal triggered burst configuration"):
+ with pytest.raises(ConfigError, match="readable internal or manual triggered burst"):
validate_source_descriptor(
replace(
descriptor,
@@ -2729,7 +2771,7 @@ def configure_source_burst_v2(self, request):
)
-def test_source_v2_sweep_write_requires_internal_direction_and_readback() -> None:
+def test_source_v2_sweep_write_requires_supported_trigger_direction_and_readback() -> None:
extensions = source_extensions()
basic, output = extensions.features
basic = replace(
@@ -2810,7 +2852,7 @@ def configure_source_sweep_v2(self, request):
),
)
)
- with pytest.raises(ConfigError, match="readable internal sweep configuration"):
+ with pytest.raises(ConfigError, match="readable internal or manual sweep configuration"):
validate_source_descriptor(
replace(
descriptor,
diff --git a/tests/test_source_operation_context.py b/tests/test_source_operation_context.py
index 4ddc210..d514c3c 100644
--- a/tests/test_source_operation_context.py
+++ b/tests/test_source_operation_context.py
@@ -443,6 +443,59 @@ def test_unknown_effect_and_reenergizing_restore_are_rejected_without_io() -> No
assert transport.audit_snapshot()["counters"]["query_calls"] == 0
+def test_fire_effect_allows_output_on_baseline_but_requires_emergency_off() -> None:
+ transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type]
+ contract = SourceOperationContract(
+ operation="source.test_fire_v2",
+ capability="source.test_fire_v2",
+ feature=SourceFeature.BASIC,
+ direction=SourceFeatureDirection.FIRE,
+ energy_effect=SourceEnergyEffect.EMIT,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ SourceFieldId.IDENTITY,
+ ),
+ changed_fields=(SourceFieldId.BASIC,),
+ postcondition_fields=(SourceFieldId.BASIC,),
+ cleanup_verification_fields=(SourceFieldId.OUTPUT,),
+ v1_equivalent_routes=(SourceV1WriteRouteId.SET_FREQUENCY,),
+ v1_overlapping_routes=(),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+ )
+ context = SourceOperationContextCoordinator(
+ session_state=transport.session_state,
+ operation_spec=_spec(contract),
+ operation_contract=contract,
+ connection_timeout_ms=1_000,
+ baseline_snapshot_digest="sha256:" + "1" * 64,
+ fields=FIELDS,
+ required_off_outputs=(),
+ emergency_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),),
+ restore_order=(),
+ non_restorable_fields=(BASIC, OUTPUT),
+ )
+ context.complete()
+
+ with pytest.raises(ValueError, match="fire operations require explicit emergency OFF"):
+ SourceOperationContextCoordinator(
+ session_state=transport.session_state,
+ operation_spec=_spec(contract),
+ operation_contract=contract,
+ connection_timeout_ms=1_000,
+ baseline_snapshot_digest="sha256:" + "1" * 64,
+ fields=FIELDS,
+ required_off_outputs=(),
+ emergency_off_outputs=(),
+ restore_order=(),
+ non_restorable_fields=(BASIC, OUTPUT),
+ )
+
+
def test_affected_closure_digest_rejects_tampering() -> None:
transport, context = _context()
closure = context.closure
diff --git a/tests/test_source_sweep_v2.py b/tests/test_source_sweep_v2.py
index d63728e..b8993c3 100644
--- a/tests/test_source_sweep_v2.py
+++ b/tests/test_source_sweep_v2.py
@@ -30,6 +30,8 @@
SourceFeatureCapability,
SourceFeatureDirection,
SourceFieldId,
+ SourceFireRequest,
+ SourceFireResult,
SourceFrequencyMode,
SourceOutputPolarity,
SourceOutputRequest,
@@ -91,6 +93,7 @@ def _sweep(
spacing: SourceSweepSpacing = SourceSweepSpacing.LINEAR,
steps: int = 101,
sweep_time_s: float = 1.0,
+ trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL,
) -> SweepFacet:
return SweepFacet(
enabled=Observed.value_of(enabled),
@@ -104,7 +107,7 @@ def _sweep(
return_time_s=Observed.value_of(0.0),
trigger=Observed.value_of(
SourceTriggerState(
- source=Observed.value_of(SourceTriggerSource.INTERNAL),
+ source=Observed.value_of(trigger_source),
slope=Observed.value_of(SourceTriggerSlope.POSITIVE),
output=Observed.value_of(SourceTriggerOutput.OFF),
)
@@ -128,6 +131,8 @@ def __init__(
session_state: InstrumentSessionState,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ post_fire_mismatch: bool = False,
+ raise_after_fire: bool = False,
) -> None:
self.transport = GuardedAuditedTransport(
_TextTransport(),
@@ -135,9 +140,12 @@ def __init__(
)
self.output_enabled = output_enabled
self.postcondition_mismatch = postcondition_mismatch
+ self.post_fire_mismatch = post_fire_mismatch
+ self.raise_after_fire = raise_after_fire
self.basic = basic_facet()
self.sweep = _sweep()
self.sweep_requests: list[SourceSweepConfigureRequest] = []
+ self.fire_requests: list[SourceFireRequest] = []
self.output_requests: list[SourceOutputRequest] = []
self.v1_sweep_configure_calls = 0
self.v1_sweep_trigger_calls = 0
@@ -202,6 +210,7 @@ def configure_source_sweep_v2(
spacing=request.spacing,
steps=request.steps,
sweep_time_s=request.sweep_time_s,
+ trigger_source=request.trigger_source,
)
return SourceSweepConfigureResult(
channel=request.channel,
@@ -210,12 +219,24 @@ def configure_source_sweep_v2(
output_enabled=False,
)
+ def fire_source_sweep_v2(self, request: SourceFireRequest) -> SourceFireResult:
+ self.transport.write("SOURCE:SWEEP:FIRE")
+ self.fire_requests.append(request)
+ if self.raise_after_fire:
+ raise ConfigError("fake Sweep fire failed after write")
+ return SourceFireResult(channel=request.channel)
+
def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult:
self.transport.write("SOURCE:OUTPUT")
self.output_requests.append(request)
self.output_enabled = request.enabled
if request.enabled:
- raise AssertionError("the Sweep fixture only uses recovery OFF")
+ return SourceOutputResult(
+ channel=request.channel,
+ enabled=True,
+ final_amplitude=self.basic.amplitude.value,
+ final_offset_v=self.basic.offset_v.value,
+ )
return SourceOutputResult(channel=request.channel, enabled=False)
def configure_sweep(self, *args: object, **kwargs: object) -> object:
@@ -244,6 +265,8 @@ def _readback_basic(self):
)
def _readback_sweep(self) -> SweepFacet:
+ if self.post_fire_mismatch and self.fire_requests:
+ return replace(self.sweep, sweep_time_s=Observed.value_of(2.0))
if not self.postcondition_mismatch or not self.sweep_requests:
return self.sweep
return replace(self.sweep, sweep_time_s=Observed.value_of(2.0))
@@ -256,6 +279,7 @@ def _extensions(
SourceSweepSpacing.LOGARITHMIC,
SourceSweepSpacing.STEP,
),
+ include_fire: bool = False,
):
base = source_extensions()
basic, output = base.features
@@ -272,13 +296,20 @@ def _extensions(
sweep = SourceFeatureCapability(
feature=SourceFeature.SWEEP,
support=SupportState.SUPPORTED,
- directions=(SourceFeatureDirection.CONFIGURE, SourceFeatureDirection.READ),
+ directions=(
+ SourceFeatureDirection.CONFIGURE,
+ *((SourceFeatureDirection.FIRE,) if include_fire else ()),
+ SourceFeatureDirection.READ,
+ ),
scope=SourceFacetScope.CHANNEL,
channels=(1,),
applicability=SourceConstraintApplicability(),
profile=SourceSweepCapabilityProfile(
spacing_modes=spacing_modes,
- trigger_sources=(SourceTriggerSource.INTERNAL,),
+ trigger_sources=(
+ SourceTriggerSource.INTERNAL,
+ *((SourceTriggerSource.MANUAL,) if include_fire else ()),
+ ),
timing_readable=True,
marker_readable=True,
configuration_readable=True,
@@ -344,7 +375,10 @@ def _service(
*,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ post_fire_mismatch: bool = False,
+ raise_after_fire: bool = False,
dual_contract: bool = False,
+ include_fire: bool = False,
spacing_modes: tuple[SourceSweepSpacing, ...] = (
SourceSweepSpacing.LINEAR,
SourceSweepSpacing.LOGARITHMIC,
@@ -356,16 +390,26 @@ def _service(
session_state=session_state,
output_enabled=output_enabled,
postcondition_mismatch=postcondition_mismatch,
+ post_fire_mismatch=post_fire_mismatch,
+ raise_after_fire=raise_after_fire,
)
capabilities = [
"source.snapshot_v2",
"source.sweep_configure_v2",
"source.output_v2",
]
+ if include_fire:
+ capabilities.append("source.sweep_fire_v2")
if dual_contract:
capabilities.extend(("source.sweep_configure", "source.sweep_trigger"))
descriptor = replace(
- source_descriptor(driver=driver, extensions=_extensions(spacing_modes=spacing_modes)),
+ source_descriptor(
+ driver=driver,
+ extensions=_extensions(
+ spacing_modes=spacing_modes,
+ include_fire=include_fire,
+ ),
+ ),
capabilities=tuple(capabilities),
)
validate_source_descriptor(descriptor)
@@ -386,6 +430,7 @@ def _service(
def _request(
*,
spacing: SourceSweepSpacing = SourceSweepSpacing.LINEAR,
+ trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL,
) -> SourceSweepConfigureRequest:
return SourceSweepConfigureRequest(
channel=1,
@@ -394,8 +439,44 @@ def _request(
spacing=spacing,
steps=101,
sweep_time_s=1.0,
+ trigger_source=trigger_source,
+ )
+
+
+def test_sweep_fire_capability_requires_manual_configuration_readback() -> None:
+ session_state = InstrumentSessionState(epoch_id="source-sweep-fire-profile")
+ driver = _SweepWriteDriver(session_state=session_state)
+ extensions = _extensions(include_fire=True)
+ basic, output, sweep = extensions.features
+ descriptor = replace(
+ source_descriptor(
+ driver=driver,
+ extensions=replace(
+ extensions,
+ features=(
+ basic,
+ output,
+ replace(
+ sweep,
+ profile=replace(
+ sweep.profile,
+ trigger_sources=(SourceTriggerSource.INTERNAL,),
+ ),
+ ),
+ ),
+ ),
+ ),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.sweep_configure_v2",
+ "source.sweep_fire_v2",
+ "source.output_v2",
+ ),
)
+ with pytest.raises(ConfigError, match="readable manual sweep"):
+ validate_source_descriptor(descriptor)
+
def test_sweep_configure_v2_writes_once_and_keeps_output_off() -> None:
service, driver = _service()
@@ -491,3 +572,108 @@ def test_v1_restore_rejects_before_io_for_a_sweep_v2_driver() -> None:
assert driver.transport.counters.write_requests == 0
assert driver.transport.counters.query_calls == 0
+
+
+def test_sweep_fire_v2_reuses_configuring_session_and_keeps_output_on() -> None:
+ service, driver = _service(include_fire=True)
+ configured, configure_artifact = service.configure_sweep_v2(
+ _request(trigger_source=SourceTriggerSource.MANUAL)
+ )
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ result, artifact = service.fire_sweep_v2(
+ SourceFireRequest(channel=1),
+ correlation_id="sweep-fire",
+ )
+
+ assert result == SourceFireResult(channel=1)
+ assert configured.sweep.trigger.value.source.value is SourceTriggerSource.MANUAL
+ assert configure_artifact["request"]["trigger_source"] == "manual"
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.output_enabled is True
+ assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=True)]
+ assert artifact["operation"] == "source.sweep_fire_v2"
+ assert artifact["persistent_session_verified"] is True
+ assert artifact["postcondition"]["emission_verified"] is False
+ assert artifact["postcondition"]["external_measurement_required"] is True
+ assert artifact["final_state"] == {
+ "session_health": "healthy",
+ "output_expected": "on",
+ }
+
+
+def test_sweep_fire_v2_requires_same_session_configuration_before_io() -> None:
+ service, driver = _service(include_fire=True)
+
+ with pytest.raises(ConfigError, match="configuration from the same session"):
+ service.fire_sweep_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.transport.counters.query_calls == 0
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_sweep_fire_v2_requires_output_on_before_fire_write() -> None:
+ service, driver = _service(include_fire=True)
+ service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+
+ with pytest.raises(ConfigError, match="target output ON"):
+ service.fire_sweep_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.output_requests == []
+
+
+def test_sweep_fire_v2_rejects_internal_trigger_configuration() -> None:
+ service, driver = _service(include_fire=True)
+ service.configure_sweep_v2(_request())
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ with pytest.raises(ConfigError, match="manual trigger source"):
+ service.fire_sweep_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == []
+ assert driver.output_enabled is True
+
+
+def test_sweep_fire_v2_failure_is_not_retried_and_recovers_off() -> None:
+ service, driver = _service(include_fire=True, raise_after_fire=True)
+ service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ with pytest.raises(ConfigError, match="failed after write") as raised:
+ service.fire_sweep_v2(SourceFireRequest(channel=1))
+
+ artifact = raised.value.source_operation_artifact
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.output_requests == [
+ SourceOutputRequest(channel=1, enabled=True),
+ SourceOutputRequest(channel=1, enabled=False),
+ ]
+ assert driver.output_enabled is False
+ assert artifact["recovery"]["status"] == "off_verified"
+ assert artifact["final_state"]["output_expected"] == "off"
+
+
+def test_sweep_fire_v2_postcondition_mismatch_recovers_off() -> None:
+ service, driver = _service(include_fire=True, post_fire_mismatch=True)
+ service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ with pytest.raises(ConfigError, match="same-session receipt"):
+ service.fire_sweep_v2(SourceFireRequest(channel=1))
+
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.output_requests[-1] == SourceOutputRequest(channel=1, enabled=False)
+ assert driver.output_enabled is False
+
+
+def test_v1_sweep_trigger_maps_to_fire_v2_when_declared() -> None:
+ service, driver = _service(include_fire=True, dual_contract=True)
+ service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL))
+ service.set_output_v2(SourceOutputRequest(channel=1, enabled=True))
+
+ service.trigger_sweep(channel=1)
+
+ assert driver.fire_requests == [SourceFireRequest(channel=1)]
+ assert driver.v1_sweep_trigger_calls == 0
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index 53da0c1..e8e5893 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -41,7 +41,9 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs
"source.modulation_fm_configure_v2",
"source.modulation_pwm_configure_v2",
"source.sweep_configure_v2",
+ "source.sweep_fire_v2",
"source.burst_configure_v2",
+ "source.burst_fire_v2",
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
@@ -105,7 +107,9 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.modulation_fm_configure_v2",
"source.modulation_pwm_configure_v2",
"source.sweep_configure_v2",
+ "source.sweep_fire_v2",
"source.burst_configure_v2",
+ "source.burst_fire_v2",
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
From 58790388a2cb3ab7073603446e864d2fac1c84fc Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 18:47:53 +0800
Subject: [PATCH 25/44] feat(source): freeze volatile arb and counter contracts
---
...345\207\272\345\256\211\345\205\250RFC.md" | 57 +++
.../instruments/source_extensions.py | 433 ++++++++++++++++++
tests/test_source_extensions.py | 162 ++++++-
3 files changed, 651 insertions(+), 1 deletion(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 5afb4f9..ead528c 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -630,6 +630,29 @@ SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT
SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT
```
+D1-4/volatile ARB 与 Counter 收口在上述清单末尾追加以下精确条目:
+
+```text
+SourceArbitraryVolatileReplaceRequest
+SourceArbitraryVolatileReplaceResult
+SourceArbitraryVolatileReplaceV2Driver
+SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT
+SourceCounterConfigurationField
+SourceCounterConfigurationPatch
+SourceCounterConfigureRequest
+SourceCounterConfigureResult
+SourceCounterConfigureV2Driver
+SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT
+SourceCounterEnableRequest
+SourceCounterEnableResult
+SourceCounterEnableV2Driver
+SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT
+SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT
+SourceCounterMeasureRequest
+SourceCounterMeasureResult
+SourceCounterMeasureV2Driver
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
@@ -672,6 +695,10 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。
| `source.burst_configure_v2` | `configure_source_burst_v2` | Burst 配置 |
| `source.arbitrary_storage_v2` | `mutate_source_arbitrary_storage_v2` | 创建或覆盖 ARB 存储槽位 |
| `source.arbitrary_select_v2` | `select_source_arbitrary_v2` | 选择并配置已存在的 ARB |
+| `source.arbitrary_volatile_replace_v2` | `replace_source_arbitrary_volatile_v2` | 替换通道唯一的易失 ARB 工作区;上传会选择该工作区 |
+| `source.counter_configure_v2` | `configure_source_counter_v2` | 单字段 Counter 输入配置 |
+| `source.counter_enable_v2` | `set_source_counter_enabled_v2` | 单独启用或关闭 Counter |
+| `source.counter_measure_v2` | `measure_source_counter_v2` | 对已启用 Counter 的只读测量 |
| `source.combine_configure_v2` | `configure_source_combine_v2` | Combine 关系 |
| `source.coupling_configure_v2` | `configure_source_coupling_v2` | Coupling 关系 |
| `source.tracking_configure_v2` | `configure_source_tracking_v2` | Tracking 关系 |
@@ -3649,6 +3676,36 @@ driver 异常、结果类型错误或后置条件失败时,Core 清除 receipt
operation artifact 字节形状,默认 `trigger_source=internal` 不写入 request payload,manual 请求则显式记录该字段。
物理发出能力必须在具体插件的 A4 实机验收中由外部测量证明。
+### D1-4 合同冻结:volatile ARB 与 Counter
+
+`source.arbitrary_storage_v2` 继续只表示可命名、可读回摘要和大小、可在写前执行
+CAS 的存储槽位。它的写入后置条件要求选中状态和输出状态保持不变。单一的易失
+ARB 工作区不满足这些前提,必须使用独立的
+`source.arbitrary_volatile_replace_v2`。其 request 只含 channel、传入精确 bytes 的
+SHA-256、字节数和点数;实际 bytes 不进入 request、artifact 或 run JSON。
+
+volatile replace 明确承认上传会选择该工作区,并可能改变与波形长度相关的设备状态。
+前置条件为目标输出 OFF;主写只能调用一次;后置条件独立确认 ARB waveform 已选中、
+basic waveform 为 arbitrary、输出仍 OFF。结果必须分别记录 host bytes 的身份、当前
+selected waveform ID、内容是否能由设备读回验证、以及旧 volatile 内容是否可恢复。
+不能读取摘要或原始 bytes 的设备不得把 host digest 填为设备 readback,也不得声称
+可恢复旧内容。二进制写一旦尝试且后续失败,Core 只可尝试一次输出 OFF 收敛;旧内容
+保持 `unrecoverable`,不得重传或 rollback。
+
+Counter 按副作用拆开,而不是继续沿用 V1 的“完整 profile 一次设置”模型:
+
+- `source.counter_configure_v2` 只允许一个显式字段:AC/DC coupling、输入阻抗、衰减、
+ trigger level 或 statistics enable。每个字段均需独立回读;不会暗中写 50 Ω、默认
+ gate time 或其它默认值。
+- `source.counter_enable_v2` 只改变 counter ON/OFF;它不会配置输入、清零统计或取得测量。
+- `source.counter_measure_v2` 仅对已启用的 counter 做读取;它不会 enable、调用 AUTO、
+ 写 gate time 或统计 clear。
+
+`AUTO` gate-time、无法精确表达的厂商 preset、HF rejection、sensitivity、statistics display
+和 statistics clear 不进入这组首版合同。前四项需要各自可读的通用状态模型;clear 是
+破坏性动作,必须以后续单独 capability 明确授权。D1-4 只冻结 model、Protocol 和操作
+元数据,不注册 capability、不改变 CLI 或 run schema,也不授权任何真实插件写入。
+
### M6-B 已实现:ARB storage 与 selection
M6-B 使用两个独立 capability,不把上传、选择、基本幅度配置或输出 ON 合并为一个 driver 调用:
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index b378e72..861cbf3 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -358,6 +358,22 @@ class SourceCounterMeasurementKind(StrEnum):
UNKNOWN = "unknown"
+class SourceCounterConfigurationField(StrEnum):
+ """A counter setting with a portable, independently readable meaning.
+
+ The deliberately short set excludes vendor presets (for example an
+ ``AUTO`` gate-time action), display preferences, and destructive
+ statistics clears. Those need their own contract instead of becoming
+ incidental side effects of a measurement request.
+ """
+
+ COUPLING = "coupling"
+ IMPEDANCE_OHM = "impedance_ohm"
+ ATTENUATION = "attenuation"
+ TRIGGER_LEVEL_V = "trigger_level_v"
+ STATISTICS_ENABLED = "statistics_enabled"
+
+
class SourceInputCoupling(StrEnum):
AC = "ac"
DC = "dc"
@@ -672,6 +688,9 @@ class SourceArbitraryCapabilityProfile:
storage_slot_metadata_readable: bool = False
storage_write_modes: tuple[SourceStorageWriteMode, ...] = ()
storage_max_payload_bytes: int | None = None
+ volatile_replace_min_points: int | None = None
+ volatile_replace_max_points: int | None = None
+ volatile_replace_max_payload_bytes: int | None = None
def __post_init__(self) -> None:
_require_enum_tuple(
@@ -705,6 +724,38 @@ def __post_init__(self) -> None:
raise ValueError(
"arbitrary storage_max_payload_bytes requires storage_write_modes"
)
+ volatile_limits = (
+ self.volatile_replace_min_points,
+ self.volatile_replace_max_points,
+ self.volatile_replace_max_payload_bytes,
+ )
+ if any(value is not None for value in volatile_limits):
+ if any(value is None for value in volatile_limits):
+ raise ValueError(
+ "arbitrary volatile replace limits must be provided together"
+ )
+ _require_int(
+ self.volatile_replace_min_points,
+ "arbitrary volatile_replace_min_points",
+ minimum=1,
+ )
+ _require_int(
+ self.volatile_replace_max_points,
+ "arbitrary volatile_replace_max_points",
+ minimum=1,
+ )
+ _require_int(
+ self.volatile_replace_max_payload_bytes,
+ "arbitrary volatile_replace_max_payload_bytes",
+ minimum=1,
+ )
+ assert self.volatile_replace_min_points is not None
+ assert self.volatile_replace_max_points is not None
+ if self.volatile_replace_min_points > self.volatile_replace_max_points:
+ raise ValueError(
+ "arbitrary volatile_replace_min_points must not exceed "
+ "volatile_replace_max_points"
+ )
class SourceQueryEffect(StrEnum):
@@ -720,6 +771,9 @@ class SourceCounterCapabilityProfile:
measurement_kinds: tuple[SourceCounterMeasurementKind, ...]
configuration_readable: bool
query_effect: SourceQueryEffect
+ readable_configuration_fields: tuple[SourceCounterConfigurationField, ...] = ()
+ configurable_fields: tuple[SourceCounterConfigurationField, ...] = ()
+ enabled_configurable: bool = False
def __post_init__(self) -> None:
_require_token_tuple(self.input_ids, "counter input_ids", allow_empty=False)
@@ -731,6 +785,27 @@ def __post_init__(self) -> None:
_require_bool(self.configuration_readable, "counter configuration_readable")
if not isinstance(self.query_effect, SourceQueryEffect):
raise ValueError("counter query_effect has an invalid type")
+ _require_enum_tuple(
+ self.readable_configuration_fields,
+ SourceCounterConfigurationField,
+ "counter readable_configuration_fields",
+ allow_empty=True,
+ )
+ _require_enum_tuple(
+ self.configurable_fields,
+ SourceCounterConfigurationField,
+ "counter configurable_fields",
+ allow_empty=True,
+ )
+ _require_bool(self.enabled_configurable, "counter enabled_configurable")
+ if self.readable_configuration_fields and not self.configuration_readable:
+ raise ValueError(
+ "counter readable_configuration_fields require configuration_readable"
+ )
+ if not set(self.configurable_fields) <= set(self.readable_configuration_fields):
+ raise ValueError(
+ "counter configurable_fields require matching readable_configuration_fields"
+ )
@dataclass(frozen=True, slots=True)
@@ -1693,6 +1768,99 @@ def __post_init__(self) -> None:
)
+SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.arbitrary_volatile_replace_v2",
+ capability="source.arbitrary_volatile_replace_v2",
+ feature=SourceFeature.ARBITRARY,
+ direction=SourceFeatureDirection.CONFIGURE,
+ energy_effect=SourceEnergyEffect.POTENTIAL_WHILE_OFF,
+ storage_effect=SourceStorageEffect.REPLACE,
+ required_fields=(
+ SourceFieldId.ARBITRARY_SELECTION,
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ SourceFieldId.IDENTITY,
+ ),
+ changed_fields=(
+ SourceFieldId.ARBITRARY_SELECTION,
+ SourceFieldId.ARBITRARY_STORAGE,
+ SourceFieldId.BASIC,
+ ),
+ postcondition_fields=(
+ SourceFieldId.ARBITRARY_SELECTION,
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ ),
+ cleanup_verification_fields=(SourceFieldId.OUTPUT,),
+ v1_equivalent_routes=(),
+ v1_overlapping_routes=(SourceV1WriteRouteId.UPLOAD_ARBITRARY,),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+)
+
+
+SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.counter_configure_v2",
+ capability="source.counter_configure_v2",
+ feature=SourceFeature.COUNTER,
+ direction=SourceFeatureDirection.CONFIGURE,
+ energy_effect=SourceEnergyEffect.NONE,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(SourceFieldId.IDENTITY, SourceFieldId.COUNTER),
+ changed_fields=(SourceFieldId.COUNTER,),
+ postcondition_fields=(SourceFieldId.COUNTER,),
+ cleanup_verification_fields=(),
+ v1_equivalent_routes=(),
+ v1_overlapping_routes=(),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+)
+
+
+SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.counter_enable_v2",
+ capability="source.counter_enable_v2",
+ feature=SourceFeature.COUNTER,
+ direction=SourceFeatureDirection.ENABLE,
+ energy_effect=SourceEnergyEffect.NONE,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(SourceFieldId.IDENTITY, SourceFieldId.COUNTER),
+ changed_fields=(SourceFieldId.COUNTER,),
+ postcondition_fields=(SourceFieldId.COUNTER,),
+ cleanup_verification_fields=(),
+ v1_equivalent_routes=(),
+ v1_overlapping_routes=(),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+)
+
+
+SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.counter_disable_v2",
+ capability="source.counter_enable_v2",
+ feature=SourceFeature.COUNTER,
+ direction=SourceFeatureDirection.DISABLE,
+ energy_effect=SourceEnergyEffect.DECREASE_ONLY,
+ storage_effect=SourceStorageEffect.NONE,
+ required_fields=(SourceFieldId.IDENTITY, SourceFieldId.COUNTER),
+ changed_fields=(SourceFieldId.COUNTER,),
+ postcondition_fields=(SourceFieldId.COUNTER,),
+ cleanup_verification_fields=(),
+ v1_equivalent_routes=(),
+ v1_overlapping_routes=(),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=1,
+ verification_max_steps=2,
+)
+
+
def _source_cross_channel_configure_operation_contract(
*,
operation: str,
@@ -2948,6 +3116,40 @@ def __post_init__(self) -> None:
)
+@dataclass(frozen=True, slots=True)
+class SourceArbitraryVolatileReplaceRequest:
+ """Replace one channel's un-named, volatile arbitrary-waveform workspace.
+
+ ``payload`` is intentionally passed separately to the driver. The request
+ carries only a host-computed identity for those exact bytes; it does not
+ pretend that a device can expose a named slot, CAS token, or authoritative
+ payload digest.
+ """
+
+ channel: int
+ payload_sha256: str
+ payload_size_bytes: int
+ point_count: int
+
+ def __post_init__(self) -> None:
+ _require_int(self.channel, "source arbitrary volatile replace channel", minimum=1)
+ if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None:
+ raise ValueError(
+ "source arbitrary volatile replace payload_sha256 must be "
+ "sha256:<64 lowercase hex>"
+ )
+ _require_int(
+ self.payload_size_bytes,
+ "source arbitrary volatile replace payload_size_bytes",
+ minimum=1,
+ )
+ _require_int(
+ self.point_count,
+ "source arbitrary volatile replace point_count",
+ minimum=1,
+ )
+
+
@dataclass(frozen=True, slots=True)
class SourceArbitraryStorageSlot:
channel: int
@@ -3017,6 +3219,89 @@ def __post_init__(self) -> None:
raise ValueError("source arbitrary true-ARB sample_rate_hz must be > 0")
+@dataclass(frozen=True, slots=True)
+class SourceCounterConfigurationPatch:
+ """One explicit, independently verified counter configuration change."""
+
+ coupling: PatchValue[SourceInputCoupling] = PatchValue(PatchAction.KEEP)
+ impedance_ohm: PatchValue[float] = PatchValue(PatchAction.KEEP)
+ attenuation: PatchValue[int] = PatchValue(PatchAction.KEEP)
+ trigger_level_v: PatchValue[float] = PatchValue(PatchAction.KEEP)
+ statistics_enabled: PatchValue[bool] = PatchValue(PatchAction.KEEP)
+
+ def __post_init__(self) -> None:
+ values = (
+ ("coupling", self.coupling),
+ ("impedance_ohm", self.impedance_ohm),
+ ("attenuation", self.attenuation),
+ ("trigger_level_v", self.trigger_level_v),
+ ("statistics_enabled", self.statistics_enabled),
+ )
+ if any(not isinstance(value, PatchValue) for _, value in values):
+ raise ValueError("counter configuration patch values must be PatchValue")
+ if sum(value.action is PatchAction.SET for _, value in values) != 1:
+ raise ValueError("counter configuration patch requires exactly one SET value")
+ if self.coupling.action is PatchAction.SET and self.coupling.value not in {
+ SourceInputCoupling.AC,
+ SourceInputCoupling.DC,
+ }:
+ raise ValueError("counter configuration patch coupling must be AC or DC")
+ if self.impedance_ohm.action is PatchAction.SET:
+ _require_finite(
+ self.impedance_ohm.value,
+ "counter configuration patch impedance_ohm",
+ minimum=0.0,
+ )
+ assert self.impedance_ohm.value is not None
+ if self.impedance_ohm.value <= 0:
+ raise ValueError("counter configuration patch impedance_ohm must be > 0")
+ if self.attenuation.action is PatchAction.SET:
+ _require_int(
+ self.attenuation.value,
+ "counter configuration patch attenuation",
+ minimum=1,
+ )
+ if self.trigger_level_v.action is PatchAction.SET:
+ _require_finite(
+ self.trigger_level_v.value,
+ "counter configuration patch trigger_level_v",
+ )
+ if self.statistics_enabled.action is PatchAction.SET:
+ _require_bool(
+ self.statistics_enabled.value,
+ "counter configuration patch statistics_enabled",
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCounterConfigureRequest:
+ input_id: str
+ patch: SourceCounterConfigurationPatch
+
+ def __post_init__(self) -> None:
+ _require_token(self.input_id, "source counter configure input_id")
+ if not isinstance(self.patch, SourceCounterConfigurationPatch):
+ raise ValueError("source counter configure patch has an invalid type")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCounterEnableRequest:
+ input_id: str
+ enabled: bool
+
+ def __post_init__(self) -> None:
+ _require_token(self.input_id, "source counter enable input_id")
+ _require_bool(self.enabled, "source counter enable enabled")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCounterMeasureRequest:
+ input_id: str
+
+ def __post_init__(self) -> None:
+ _require_token(self.input_id, "source counter measure input_id")
+
+
def _validate_cross_channel_configure_request(
*,
channels: object,
@@ -3514,6 +3799,60 @@ def __post_init__(self) -> None:
raise ValueError("source arbitrary storage result requires readback_verified=True")
+@dataclass(frozen=True, slots=True)
+class SourceArbitraryVolatileReplaceResult:
+ channel: int
+ payload_sha256: str
+ payload_size_bytes: int
+ point_count: int
+ selected_waveform_id: str
+ write_completed: bool
+ content_readback_verified: bool
+ previous_content_restorable: bool
+
+ def __post_init__(self) -> None:
+ _require_int(
+ self.channel,
+ "source arbitrary volatile replace result channel",
+ minimum=1,
+ )
+ if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None:
+ raise ValueError(
+ "source arbitrary volatile replace result payload_sha256 must be "
+ "sha256:<64 lowercase hex>"
+ )
+ _require_int(
+ self.payload_size_bytes,
+ "source arbitrary volatile replace result payload_size_bytes",
+ minimum=1,
+ )
+ _require_int(
+ self.point_count,
+ "source arbitrary volatile replace result point_count",
+ minimum=1,
+ )
+ _require_token(
+ self.selected_waveform_id,
+ "source arbitrary volatile replace result selected_waveform_id",
+ )
+ _require_bool(
+ self.write_completed,
+ "source arbitrary volatile replace result write_completed",
+ )
+ _require_bool(
+ self.content_readback_verified,
+ "source arbitrary volatile replace result content_readback_verified",
+ )
+ _require_bool(
+ self.previous_content_restorable,
+ "source arbitrary volatile replace result previous_content_restorable",
+ )
+ if not self.write_completed:
+ raise ValueError(
+ "source arbitrary volatile replace result requires write_completed=True"
+ )
+
+
@dataclass(frozen=True, slots=True)
class HarmonicFacet:
enabled: Observed[bool]
@@ -4401,6 +4740,49 @@ def __post_init__(self) -> None:
_require_finite(self.trigger_level_v.value, "counter trigger_level_v")
+@dataclass(frozen=True, slots=True)
+class SourceCounterConfigureResult:
+ input_id: str
+ state: SourceCounterInputState
+
+ def __post_init__(self) -> None:
+ _require_token(self.input_id, "source counter configure result input_id")
+ if not isinstance(self.state, SourceCounterInputState):
+ raise ValueError("source counter configure result state has an invalid type")
+ if self.state.input_id != self.input_id:
+ raise ValueError("source counter configure result input_id does not match state")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCounterEnableResult:
+ input_id: str
+ enabled: bool
+
+ def __post_init__(self) -> None:
+ _require_token(self.input_id, "source counter enable result input_id")
+ _require_bool(self.enabled, "source counter enable result enabled")
+
+
+@dataclass(frozen=True, slots=True)
+class SourceCounterMeasureResult:
+ input_id: str
+ measurements: tuple[SourceCounterMeasurementV2, ...]
+
+ def __post_init__(self) -> None:
+ _require_token(self.input_id, "source counter measure result input_id")
+ if not isinstance(self.measurements, tuple) or not self.measurements:
+ raise ValueError("source counter measure result requires measurements")
+ if any(not isinstance(item, SourceCounterMeasurementV2) for item in self.measurements):
+ raise ValueError(
+ "source counter measure result measurements have an invalid type"
+ )
+ kinds = tuple(item.kind.value for item in self.measurements)
+ if len(set(kinds)) != len(kinds) or tuple(sorted(kinds)) != kinds:
+ raise ValueError(
+ "source counter measure result measurements must be sorted by kind and unique"
+ )
+
+
@dataclass(frozen=True, slots=True)
class SourceReferenceClockState:
mode: Observed[SourceReferenceClockMode]
@@ -5305,6 +5687,15 @@ def mutate_source_arbitrary_storage_v2(
) -> SourceArbitraryStorageResult: ...
+@runtime_checkable
+class SourceArbitraryVolatileReplaceV2Driver(InstrumentDriver, Protocol):
+ def replace_source_arbitrary_volatile_v2(
+ self,
+ request: SourceArbitraryVolatileReplaceRequest,
+ payload: bytes,
+ ) -> SourceArbitraryVolatileReplaceResult: ...
+
+
@runtime_checkable
class SourceArbitrarySelectV2Driver(InstrumentDriver, Protocol):
def select_source_arbitrary_v2(
@@ -5313,6 +5704,30 @@ def select_source_arbitrary_v2(
) -> SourceArbitrarySelectResult: ...
+@runtime_checkable
+class SourceCounterConfigureV2Driver(InstrumentDriver, Protocol):
+ def configure_source_counter_v2(
+ self,
+ request: SourceCounterConfigureRequest,
+ ) -> SourceCounterConfigureResult: ...
+
+
+@runtime_checkable
+class SourceCounterEnableV2Driver(InstrumentDriver, Protocol):
+ def set_source_counter_enabled_v2(
+ self,
+ request: SourceCounterEnableRequest,
+ ) -> SourceCounterEnableResult: ...
+
+
+@runtime_checkable
+class SourceCounterMeasureV2Driver(InstrumentDriver, Protocol):
+ def measure_source_counter_v2(
+ self,
+ request: SourceCounterMeasureRequest,
+ ) -> SourceCounterMeasureResult: ...
+
+
@runtime_checkable
class SourceCombineConfigureV2Driver(InstrumentDriver, Protocol):
def configure_source_combine_v2(
@@ -5670,4 +6085,22 @@ def source_snapshot_timestamp_utc() -> str:
"SourceSweepFireV2Driver",
"SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT",
"SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT",
+ "SourceArbitraryVolatileReplaceRequest",
+ "SourceArbitraryVolatileReplaceResult",
+ "SourceArbitraryVolatileReplaceV2Driver",
+ "SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT",
+ "SourceCounterConfigurationField",
+ "SourceCounterConfigurationPatch",
+ "SourceCounterConfigureRequest",
+ "SourceCounterConfigureResult",
+ "SourceCounterConfigureV2Driver",
+ "SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT",
+ "SourceCounterEnableRequest",
+ "SourceCounterEnableResult",
+ "SourceCounterEnableV2Driver",
+ "SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT",
+ "SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT",
+ "SourceCounterMeasureRequest",
+ "SourceCounterMeasureResult",
+ "SourceCounterMeasureV2Driver",
]
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 827fad1..c6929c7 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -162,7 +162,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[live_start + len(live_exports) :] == match.group(1).splitlines()
+ fire_exports = match.group(1).splitlines()
+ fire_start = live_start + len(live_exports)
+ assert module.__all__[fire_start : fire_start + len(fire_exports)] == fire_exports
+ match = re.search(
+ r"D1-4/volatile ARB 与 Counter 收口在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[fire_start + len(fire_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -262,12 +271,18 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"storage_slot_metadata_readable",
"storage_write_modes",
"storage_max_payload_bytes",
+ "volatile_replace_min_points",
+ "volatile_replace_max_points",
+ "volatile_replace_max_payload_bytes",
),
"SourceCounterCapabilityProfile": (
"input_ids",
"measurement_kinds",
"configuration_readable",
"query_effect",
+ "readable_configuration_fields",
+ "configurable_fields",
+ "enabled_configurable",
),
"SourceCouplingCapabilityProfile": (
"dimensions",
@@ -1410,6 +1425,151 @@ def test_source_v2_arbitrary_write_models_keep_payload_out_of_public_data() -> N
module.SourceArbitrarySelectResult(1, basic, arbitrary, True)
+def test_source_v2_volatile_arb_and_counter_contract_models_are_explicit() -> None:
+ digest = "sha256:" + "c" * 64
+ request = module.SourceArbitraryVolatileReplaceRequest(
+ channel=1,
+ payload_sha256=digest,
+ payload_size_bytes=128,
+ point_count=64,
+ )
+ assert module.source_v2_to_data(request) == {
+ "type": "SourceArbitraryVolatileReplaceRequest",
+ "channel": 1,
+ "payload_sha256": digest,
+ "payload_size_bytes": 128,
+ "point_count": 64,
+ }
+ assert (
+ module.SourceArbitraryVolatileReplaceResult(
+ 1,
+ digest,
+ 128,
+ 64,
+ "USER",
+ True,
+ False,
+ False,
+ ).previous_content_restorable
+ is False
+ )
+ with pytest.raises(ValueError, match="point_count"):
+ module.SourceArbitraryVolatileReplaceRequest(1, digest, 128, 0)
+ with pytest.raises(ValueError, match="write_completed=True"):
+ module.SourceArbitraryVolatileReplaceResult(
+ 1,
+ digest,
+ 128,
+ 64,
+ "USER",
+ False,
+ False,
+ False,
+ )
+
+ profile = module.SourceArbitraryCapabilityProfile(
+ playback_modes=(module.SourceArbitraryPlaybackMode.DDS,),
+ selection_readable=True,
+ storage_metadata_readable=False,
+ sample_rate_readable=False,
+ volatile_replace_min_points=2,
+ volatile_replace_max_points=16_384,
+ volatile_replace_max_payload_bytes=32_768,
+ )
+ assert profile.volatile_replace_max_points == 16_384
+ with pytest.raises(ValueError, match="provided together"):
+ module.SourceArbitraryCapabilityProfile(
+ playback_modes=(module.SourceArbitraryPlaybackMode.DDS,),
+ selection_readable=True,
+ storage_metadata_readable=False,
+ sample_rate_readable=False,
+ volatile_replace_min_points=2,
+ )
+
+ counter_profile = module.SourceCounterCapabilityProfile(
+ input_ids=("counter",),
+ measurement_kinds=(module.SourceCounterMeasurementKind.FREQUENCY_HZ,),
+ configuration_readable=True,
+ query_effect=module.SourceQueryEffect.PURE_READ,
+ readable_configuration_fields=(
+ module.SourceCounterConfigurationField.ATTENUATION,
+ module.SourceCounterConfigurationField.COUPLING,
+ module.SourceCounterConfigurationField.IMPEDANCE_OHM,
+ module.SourceCounterConfigurationField.STATISTICS_ENABLED,
+ module.SourceCounterConfigurationField.TRIGGER_LEVEL_V,
+ ),
+ configurable_fields=(
+ module.SourceCounterConfigurationField.COUPLING,
+ module.SourceCounterConfigurationField.IMPEDANCE_OHM,
+ ),
+ enabled_configurable=True,
+ )
+ assert counter_profile.enabled_configurable is True
+ with pytest.raises(ValueError, match="matching readable"):
+ module.SourceCounterCapabilityProfile(
+ input_ids=("counter",),
+ measurement_kinds=(module.SourceCounterMeasurementKind.FREQUENCY_HZ,),
+ configuration_readable=True,
+ query_effect=module.SourceQueryEffect.PURE_READ,
+ configurable_fields=(module.SourceCounterConfigurationField.COUPLING,),
+ )
+
+ patch = module.SourceCounterConfigurationPatch(
+ coupling=module.PatchValue(
+ module.PatchAction.SET,
+ module.SourceInputCoupling.AC,
+ )
+ )
+ configured = module.SourceCounterConfigureRequest("counter", patch)
+ assert module.source_v2_to_data(configured)["patch"]["coupling"]["value"] == "ac"
+ with pytest.raises(ValueError, match="exactly one SET"):
+ module.SourceCounterConfigurationPatch()
+ with pytest.raises(ValueError, match="exactly one SET"):
+ module.SourceCounterConfigurationPatch(
+ coupling=module.PatchValue(
+ module.PatchAction.SET,
+ module.SourceInputCoupling.AC,
+ ),
+ impedance_ohm=module.PatchValue(module.PatchAction.SET, 1_000_000.0),
+ )
+ with pytest.raises(ValueError, match="AC or DC"):
+ module.SourceCounterConfigurationPatch(
+ coupling=module.PatchValue(
+ module.PatchAction.SET,
+ module.SourceInputCoupling.UNKNOWN,
+ )
+ )
+
+ state = module.SourceCounterInputState(
+ input_id="counter",
+ enabled=Observed.value_of(False),
+ measurements=Observed.value_of(()),
+ coupling=Observed.value_of(module.SourceInputCoupling.AC),
+ impedance_ohm=Observed.value_of(1_000_000.0),
+ attenuation=Observed.value_of(1),
+ gate_time_s=Observed.missing(
+ Availability.UNSUPPORTED,
+ module.SourceReasonCode.DESCRIPTOR_UNSUPPORTED,
+ ),
+ trigger_level_v=Observed.value_of(0.0),
+ statistics_enabled=Observed.value_of(False),
+ )
+ assert module.SourceCounterConfigureResult("counter", state).state is state
+ assert module.SourceCounterEnableRequest("counter", True).enabled is True
+ measured = module.SourceCounterMeasureResult(
+ "counter",
+ (
+ module.SourceCounterMeasurementV2(
+ module.SourceCounterMeasurementKind.FREQUENCY_HZ,
+ 1_000.0,
+ ),
+ ),
+ )
+ assert measured.measurements[0].value == 1_000.0
+ with pytest.raises(ValueError, match="requires measurements"):
+ module.SourceCounterMeasureResult("counter", ())
+
+
def test_source_v2_cross_channel_write_models_are_closed_and_serializable() -> None:
relation = module.SourceRelationState(
feature=module.SourceFeature.COMBINE,
From a1d639cc2f8f965ee79c1d1a82064efc2e156257 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 19:11:08 +0800
Subject: [PATCH 26/44] fix(source): restore basic v2 fields one at a time
---
src/wavebench/services/source_service.py | 61 +++++++++++++++++-----
tests/test_source_basic_configure_v2.py | 66 ++++++++++++++++++++++--
2 files changed, 108 insertions(+), 19 deletions(-)
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index ce6a94f..da530ce 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -9005,27 +9005,60 @@ def snapshot_restorable_state(self, channel: int | None = None) -> RestorableSou
def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus:
if self._declares_source_v2_basic_restore():
- request = SourceBasicConfigureRequest(
- channel=state.channel,
- patch=SourceBasicPatch(
- waveform_kind=PatchValue(
- PatchAction.SET,
- self._source_v2_waveform_from_v1(state.function),
+ # Basic V2 MAIN phases permit exactly one bounded driver write.
+ # Build every request before turning output OFF, then preserve the
+ # legacy restore order with separate transactions.
+ requests = (
+ SourceBasicConfigureRequest(
+ channel=state.channel,
+ patch=SourceBasicPatch(
+ waveform_kind=PatchValue(
+ PatchAction.SET,
+ self._source_v2_waveform_from_v1(state.function),
+ ),
),
- frequency_hz=PatchValue(PatchAction.SET, state.frequency_hz),
- amplitude_vpp=PatchValue(PatchAction.SET, state.amplitude_vpp),
- square_duty_cycle_percent=(
- PatchValue(PatchAction.SET, state.square_duty_cycle_percent)
- if state.square_duty_cycle_percent is not None
- else PatchValue(PatchAction.KEEP)
+ ),
+ SourceBasicConfigureRequest(
+ channel=state.channel,
+ patch=SourceBasicPatch(
+ amplitude_vpp=PatchValue(
+ PatchAction.SET,
+ state.amplitude_vpp,
+ ),
),
),
+ SourceBasicConfigureRequest(
+ channel=state.channel,
+ patch=SourceBasicPatch(
+ frequency_hz=PatchValue(
+ PatchAction.SET,
+ state.frequency_hz,
+ ),
+ ),
+ ),
+ *(
+ (
+ SourceBasicConfigureRequest(
+ channel=state.channel,
+ patch=SourceBasicPatch(
+ square_duty_cycle_percent=PatchValue(
+ PatchAction.SET,
+ state.square_duty_cycle_percent,
+ ),
+ ),
+ ),
+ )
+ if state.square_duty_cycle_percent is not None
+ else ()
+ ),
)
self._set_output_v2_transaction(
SourceOutputRequest(channel=state.channel, enabled=False),
)
- basic = self._configure_basic_v2_transaction(request)
- final_snapshot = basic.snapshot
+ final_snapshot = None
+ for request in requests:
+ final_snapshot = self._configure_basic_v2_transaction(request).snapshot
+ assert final_snapshot is not None
if state.output == "ON":
output = self._set_output_v2_transaction(
SourceOutputRequest(channel=state.channel, enabled=True),
diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py
index 668e4f6..9e1ead7 100644
--- a/tests/test_source_basic_configure_v2.py
+++ b/tests/test_source_basic_configure_v2.py
@@ -360,6 +360,8 @@ def _write_extensions(
include_output: bool,
live_frequency: bool = False,
live_amplitude_vpp: bool = False,
+ waveform_kinds: tuple[SourceWaveformKind, ...] = (SourceWaveformKind.SINE,),
+ square_duty_readable: bool = False,
):
extensions = source_extensions()
basic, output = extensions.features
@@ -376,6 +378,8 @@ def _write_extensions(
basic.profile,
live_frequency_configurable=live_frequency,
live_amplitude_vpp_configurable=live_amplitude_vpp,
+ waveform_kinds=waveform_kinds,
+ square_duty_readable=square_duty_readable,
),
),
replace(
@@ -405,6 +409,8 @@ def _service(
postcondition_frequency_hz: float | None = None,
raise_after_write: bool = False,
limits: SafetyLimitsConfig = SafetyLimitsConfig(),
+ waveform_kinds: tuple[SourceWaveformKind, ...] = (SourceWaveformKind.SINE,),
+ square_duty_readable: bool = False,
) -> tuple[SourceService, _BasicWriteDriver]:
session_state = InstrumentSessionState(epoch_id="source-basic-v2")
driver = _BasicWriteDriver(
@@ -419,6 +425,8 @@ def _service(
include_output=include_output,
live_frequency=(include_live and live_frequency),
live_amplitude_vpp=(include_live and live_amplitude_vpp),
+ waveform_kinds=waveform_kinds,
+ square_duty_readable=square_duty_readable,
)
capabilities = ["source.snapshot_v2", "source.basic_configure_v2"]
if include_output:
@@ -842,13 +850,61 @@ def test_v1_restore_route_uses_v2_basic_and_output_transactions() -> None:
channel=1,
patch=SourceBasicPatch(
waveform_kind=PatchValue(PatchAction.SET, SourceWaveformKind.SINE),
- frequency_hz=PatchValue(PatchAction.SET, 1_000.0),
+ ),
+ ),
+ SourceBasicConfigureRequest(
+ channel=1,
+ patch=SourceBasicPatch(
amplitude_vpp=PatchValue(PatchAction.SET, 1.0),
),
- )
+ ),
+ SourceBasicConfigureRequest(
+ channel=1,
+ patch=SourceBasicPatch(
+ frequency_hz=PatchValue(PatchAction.SET, 1_000.0),
+ ),
+ ),
]
assert driver.output_requests == []
- assert driver.transport.counters.write_requests == 1
+ assert driver.transport.counters.write_requests == 3
+
+
+def test_v1_restore_v2_splits_square_duty_after_waveform_restore() -> None:
+ service, driver = _service(
+ waveform_kinds=(SourceWaveformKind.SINE, SourceWaveformKind.SQUARE),
+ square_duty_readable=True,
+ )
+
+ service.restore_restorable_state(
+ RestorableSourceState(
+ channel=1,
+ output="OFF",
+ function="SQU",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ square_duty_cycle_percent=25.0,
+ )
+ )
+
+ assert [
+ next(
+ name
+ for name, value in (
+ ("waveform_kind", request.patch.waveform_kind),
+ ("amplitude_vpp", request.patch.amplitude_vpp),
+ ("frequency_hz", request.patch.frequency_hz),
+ ("square_duty_cycle_percent", request.patch.square_duty_cycle_percent),
+ )
+ if value.action is PatchAction.SET
+ )
+ for request in driver.basic_requests
+ ] == [
+ "waveform_kind",
+ "amplitude_vpp",
+ "frequency_hz",
+ "square_duty_cycle_percent",
+ ]
def test_restorable_snapshot_uses_v2_when_the_full_restore_route_is_declared() -> None:
@@ -907,8 +963,8 @@ def test_v1_restore_route_restores_original_on_state_through_v2_output() -> None
SourceOutputRequest(channel=1, enabled=False),
SourceOutputRequest(channel=1, enabled=True),
]
- assert len(driver.basic_requests) == 1
- assert driver.transport.counters.write_requests == 3
+ assert len(driver.basic_requests) == 3
+ assert driver.transport.counters.write_requests == 5
def test_v1_restore_route_rejects_partial_v2_restore_before_io() -> None:
From 759421b7905140a9885883f1cab3445ea5d44bea Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 19:53:13 +0800
Subject: [PATCH 27/44] feat(source): add volatile arbitrary replacement
transaction
---
...00\345\217\221\346\214\207\345\215\227.md" | 10 +
.../instruments/source_conformance.py | 4 +
.../source_extension_capabilities.py | 58 +++
src/wavebench/services/operation_specs.py | 41 ++
src/wavebench/services/source_service.py | 458 ++++++++++++++++++
tests/test_operation_specs.py | 15 +
tests/test_source_arbitrary_v2.py | 160 +++++-
tests/test_source_extensions.py | 148 ++++++
tests/test_source_v1_routes.py | 3 +-
9 files changed, 895 insertions(+), 2 deletions(-)
diff --git "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md"
index bd95255..1c14b50 100644
--- "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md"
+++ "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md"
@@ -246,6 +246,16 @@ selection 与 storage digest、允许 playback mode;true-ARB 还要求 sample
selection 只允许目标输出 OFF,完成后仍为 OFF,不会隐式 ON。声明任一 ARB V2 capability 后,V1
`upload_arbitrary_waveform` 会在本地文件读取和仪器 I/O 前被拒绝,不能把混合 upload/selection/ON 的旧 route 部分映射。
+单一、无命名的易失 ARB 工作区使用 `source.arbitrary_volatile_replace_v2`。driver 实现
+`replace_source_arbitrary_volatile_v2(request, payload)`;request 只记录精确 payload 的主机 SHA-256、字节数和点数,
+不包含 payload。ARB profile 必须同时声明 `volatile_replace_min_points`、`volatile_replace_max_points` 与
+`volatile_replace_max_payload_bytes`;descriptor 还必须声明 `source.output_v2`,以便二进制写入后发生异常时由 Core
+只尝试一次 OFF 收敛。上传后必须独立确认当前 basic waveform 为 `arbitrary`、已选择 driver 返回的工作区 ID,且目标输出仍为 OFF。
+
+该 capability 不表示具名 storage,不得填造设备侧 digest、内容读回或旧内容可恢复性。它也不等价于旧
+`upload_arbitrary_waveform`:旧 route 还包含播放频率、Vpp/offset、可选输出 ON 和旧 artifact 语义。不得将旧 route
+部分改写为 volatile replace;需要公开该组合时,应另行定义完整的复合 capability、artifact 与验收。
+
跨通道 Combine、Coupling、Tracking 和相位关系分别使用 `source.combine_configure_v2`、
`source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2`。每项都使用
独立 driver method,request 只包含递增且唯一的 channel set 与 enabled state。descriptor 必须为该 relation 的
diff --git a/src/wavebench/instruments/source_conformance.py b/src/wavebench/instruments/source_conformance.py
index 9487ae9..7d74ecf 100644
--- a/src/wavebench/instruments/source_conformance.py
+++ b/src/wavebench/instruments/source_conformance.py
@@ -118,6 +118,10 @@
SourceFeature.ARBITRARY,
frozenset({SourceFeatureDirection.CONFIGURE}),
),
+ "source.arbitrary_volatile_replace_v2": (
+ SourceFeature.ARBITRARY,
+ frozenset({SourceFeatureDirection.CONFIGURE}),
+ ),
"source.combine_configure_v2": (
SourceFeature.COMBINE,
frozenset({SourceFeatureDirection.CONFIGURE}),
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 1b83461..6250e01 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -70,6 +70,9 @@
"mutate_source_arbitrary_storage_v2",
),
"source.arbitrary_select_v2": ("select_source_arbitrary_v2",),
+ "source.arbitrary_volatile_replace_v2": (
+ "replace_source_arbitrary_volatile_v2",
+ ),
"source.combine_configure_v2": ("configure_source_combine_v2",),
"source.coupling_configure_v2": ("configure_source_coupling_v2",),
"source.tracking_configure_v2": ("configure_source_tracking_v2",),
@@ -97,6 +100,7 @@
"source.output_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
+ "source.arbitrary_volatile_replace_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
@@ -691,6 +695,38 @@ def _validate_write_contract(
"source.arbitrary_select_v2 requires readable output state on every channel"
)
+ if "source.arbitrary_volatile_replace_v2" in capabilities:
+ if "source.output_v2" not in capabilities:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 requires source.output_v2"
+ )
+ configurable = _channels_with_direction(
+ extensions,
+ SourceFeature.ARBITRARY,
+ SourceFeatureDirection.CONFIGURE,
+ )
+ if not configurable:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 requires arbitrary feature CONFIGURE directions"
+ )
+ readable = _channels_with_arbitrary_volatile_replace_readback(extensions)
+ if not configurable <= readable:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 requires readable selected state and "
+ "volatile replace limits on every channel"
+ )
+ arbitrary_basic = _channels_with_arbitrary_basic_readback(extensions)
+ if not configurable <= arbitrary_basic:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 requires readable arbitrary basic waveform "
+ "state on every channel"
+ )
+ if not configurable <= output_readable:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 requires readable output state on every "
+ "channel"
+ )
+
_validate_cross_channel_write_capability(
extensions,
capabilities,
@@ -1146,6 +1182,27 @@ def _channels_with_arbitrary_selection_readback(
)
+def _channels_with_arbitrary_volatile_replace_readback(
+ extensions: SourceDescriptorExtensions,
+) -> frozenset[int]:
+ return frozenset(
+ channel
+ for feature in extensions.features
+ if (
+ feature.feature is SourceFeature.ARBITRARY
+ and feature.scope is SourceFacetScope.CHANNEL
+ and feature.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.READ in feature.directions
+ and isinstance(feature.profile, SourceArbitraryCapabilityProfile)
+ and feature.profile.selection_readable
+ and feature.profile.volatile_replace_min_points is not None
+ and feature.profile.volatile_replace_max_points is not None
+ and feature.profile.volatile_replace_max_payload_bytes is not None
+ )
+ for channel in feature.channels
+ )
+
+
def _channels_with_arbitrary_basic_readback(
extensions: SourceDescriptorExtensions,
) -> frozenset[int]:
@@ -1208,6 +1265,7 @@ def _validate_declared_write_directions(
{
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
+ "source.arbitrary_volatile_replace_v2",
}
),
(SourceFeature.COMBINE, SourceFeatureDirection.CONFIGURE): frozenset(
diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py
index edd4fb8..a71ce59 100644
--- a/src/wavebench/services/operation_specs.py
+++ b/src/wavebench/services/operation_specs.py
@@ -897,6 +897,47 @@ def _spec(
error_check_minimum="disabled",
risk_flags=("source_v2", "output_must_be_off", "arbitrary_selection"),
),
+ _spec(
+ "source.arbitrary_volatile_replace_v2",
+ "source",
+ required_capabilities=("source.arbitrary_volatile_replace_v2",),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=(
+ "source.channel.arbitrary_selection",
+ "source.channel.arbitrary_storage",
+ "source.channel.basic",
+ ),
+ restore_coverage="source-v2-arbitrary-volatile",
+ required_verified_fields=(
+ "source.identity",
+ "source.channel.arbitrary_selection",
+ "source.channel.basic",
+ "source.channel.output",
+ ),
+ verification_fields=(
+ "source.identity",
+ "source.channel.arbitrary_selection",
+ "source.channel.basic",
+ "source.channel.output",
+ ),
+ postcondition_fields=(
+ "source.channel.arbitrary_selection",
+ "source.channel.basic",
+ "source.channel.output",
+ ),
+ cleanup_verification_fields=("source.channel.output",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=(
+ "source_v2",
+ "output_must_be_off",
+ "arbitrary_volatile_replace",
+ "payload_not_artifact",
+ "no_retry",
+ ),
+ ),
_spec(
"source.combine_configure_v2",
"source",
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index da530ce..a99c4c0 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -75,6 +75,7 @@
PatchValue,
SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT,
SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT,
+ SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
@@ -108,6 +109,9 @@
SourceArbitraryStorageResult,
SourceArbitraryStorageSlot,
SourceArbitraryStorageV2Driver,
+ SourceArbitraryVolatileReplaceRequest,
+ SourceArbitraryVolatileReplaceResult,
+ SourceArbitraryVolatileReplaceV2Driver,
SourceBasicCapabilityProfile,
SourceBasicConfigureRequest,
SourceBasicConfigureResult,
@@ -359,6 +363,15 @@ class _SourceArbitrarySelectV2Transaction:
snapshot: SourceSnapshotV2
+@dataclass(frozen=True, slots=True)
+class _SourceArbitraryVolatileReplaceV2Transaction:
+ """Core transaction result for one unnamed, volatile ARB workspace replacement."""
+
+ result: SourceArbitraryVolatileReplaceResult
+ artifact: dict[str, object]
+ snapshot: SourceSnapshotV2
+
+
@dataclass(frozen=True, slots=True)
class _SourceCrossChannelConfigureV2Transaction:
"""Core transaction result shared by the four M6-C relation routes."""
@@ -856,6 +869,22 @@ def select_arbitrary_v2(
)
return transaction.result, transaction.artifact
+ def replace_arbitrary_volatile_v2(
+ self,
+ request: SourceArbitraryVolatileReplaceRequest,
+ *,
+ payload: bytes,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceArbitraryVolatileReplaceResult, dict[str, object]]:
+ """Replace one unnamed volatile ARB workspace while its target output is OFF."""
+
+ transaction = self._replace_arbitrary_volatile_v2_transaction(
+ request,
+ payload=payload,
+ correlation_id=correlation_id,
+ )
+ return transaction.result, transaction.artifact
+
def configure_combine_v2(
self,
request: SourceCombineConfigureRequest,
@@ -3905,6 +3934,229 @@ def _select_arbitrary_v2_transaction(
context.complete()
raise
+ def _replace_arbitrary_volatile_v2_transaction(
+ self,
+ request: SourceArbitraryVolatileReplaceRequest,
+ *,
+ payload: bytes,
+ correlation_id: str | None = None,
+ ) -> _SourceArbitraryVolatileReplaceV2Transaction:
+ """Replace one unnamed volatile ARB workspace without claiming content readback."""
+
+ operation = "source.arbitrary_volatile_replace_v2"
+ if not isinstance(request, SourceArbitraryVolatileReplaceRequest):
+ raise ConfigError(f"{operation} requires SourceArbitraryVolatileReplaceRequest")
+ self._validate_source_arbitrary_volatile_replace_v2_payload(request, payload)
+ self._require(
+ operation,
+ "source.snapshot_v2",
+ "source.arbitrary_volatile_replace_v2",
+ )
+ with self._source_session() as source:
+ descriptor = self.descriptor
+ extensions = None if descriptor is None else descriptor.source_extensions
+ session_state = self.session_state
+ if not isinstance(extensions, SourceDescriptorExtensions):
+ raise ConfigError(f"{operation} requires validated source_extensions")
+ if session_state is None:
+ raise ConfigError(f"{operation} requires a connection-bound session state")
+ fields = self._source_arbitrary_volatile_replace_v2_fields(request.channel)
+ selection_field = next(
+ field
+ for field in fields
+ if field.field is SourceFieldId.ARBITRARY_SELECTION
+ )
+ storage_field = next(
+ field
+ for field in fields
+ if field.field is SourceFieldId.ARBITRARY_STORAGE
+ )
+ basic_field = next(field for field in fields if field.field is SourceFieldId.BASIC)
+ output_field = next(
+ field for field in fields if field.field is SourceFieldId.OUTPUT
+ )
+ target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel)
+ context = SourceOperationContextCoordinator(
+ session_state=session_state,
+ operation_spec=require_operation_spec(operation),
+ operation_contract=SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
+ connection_timeout_ms=self.config.connection.timeout_ms,
+ baseline_snapshot_digest=None,
+ fields=fields,
+ required_off_outputs=(target_scope,),
+ emergency_off_outputs=(target_scope,),
+ restore_order=(),
+ non_restorable_fields=tuple(
+ field
+ for field in fields
+ if field.field
+ in {
+ SourceFieldId.ARBITRARY_SELECTION,
+ SourceFieldId.ARBITRARY_STORAGE,
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ }
+ ),
+ correlation_id=correlation_id,
+ )
+ preflight_snapshot: SourceSnapshotV2 | None = None
+ postcondition_snapshot: SourceSnapshotV2 | None = None
+ result: SourceArbitraryVolatileReplaceResult | None = None
+ main_entered = False
+ failure: BaseException | None = None
+ recovery: dict[str, object] | None = None
+
+ try:
+ preflight = context.make_phase_spec(
+ SourceOperationPhase.PREFLIGHT,
+ allowed_io={"query"},
+ fields=fields,
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(preflight) as authorization:
+ preflight_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ (
+ preflight_basic,
+ preflight_arbitrary,
+ preflight_output,
+ ) = self._source_v2_arbitrary_select_target(
+ preflight_snapshot,
+ request.channel,
+ operation=operation,
+ )
+ self._validate_source_arbitrary_volatile_replace_v2_preflight(
+ request,
+ preflight_snapshot,
+ preflight_basic,
+ preflight_arbitrary,
+ preflight_output,
+ )
+ context.bind_baseline_snapshot_digest(
+ source_v2_digest(
+ (
+ request.channel,
+ preflight_basic,
+ preflight_arbitrary,
+ preflight_output,
+ )
+ )
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=fields,
+ )
+
+ main = context.make_phase_spec(
+ SourceOperationPhase.MAIN,
+ allowed_io={"write_bytes"},
+ fields=(selection_field, storage_field, basic_field),
+ max_steps=SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT.main_max_steps,
+ )
+ try:
+ with context.authorize_phase(main):
+ main_entered = True
+ result = cast(
+ SourceArbitraryVolatileReplaceV2Driver,
+ source,
+ ).replace_source_arbitrary_volatile_v2(request, payload)
+ self._validate_source_arbitrary_volatile_replace_v2_result(request, result)
+ except BaseException as exc:
+ failure = exc
+
+ if failure is None:
+ try:
+ postcondition = context.make_phase_spec(
+ SourceOperationPhase.POSTCONDITION,
+ allowed_io={"query"},
+ fields=fields,
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(postcondition) as authorization:
+ postcondition_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ (
+ postcondition_basic,
+ postcondition_arbitrary,
+ postcondition_output,
+ ) = self._source_v2_arbitrary_select_target(
+ postcondition_snapshot,
+ request.channel,
+ operation=operation,
+ )
+ assert result is not None
+ self._validate_source_arbitrary_volatile_replace_v2_postcondition(
+ result,
+ postcondition_snapshot,
+ postcondition_basic,
+ postcondition_arbitrary,
+ postcondition_output,
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=fields,
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is not None:
+ if main_entered:
+ try:
+ context.mark_failure_required()
+ recovery = self._recover_source_v2_output_off(
+ context,
+ source,
+ request.channel,
+ extensions,
+ output_field,
+ operation=operation,
+ )
+ except BaseException:
+ recovery = {
+ "status": "recovery_setup_failed",
+ "session_health": session_state.health.value,
+ }
+ context.complete()
+ if main_entered:
+ self._attach_source_arbitrary_volatile_replace_v2_diagnostics(
+ failure,
+ context=context,
+ request=request,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ recovery=recovery,
+ )
+ raise failure
+
+ context.complete()
+ assert result is not None
+ assert preflight_snapshot is not None
+ assert postcondition_snapshot is not None
+ return _SourceArbitraryVolatileReplaceV2Transaction(
+ result=result,
+ artifact=self._source_arbitrary_volatile_replace_v2_artifact(
+ context=context,
+ request=request,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ ),
+ snapshot=postcondition_snapshot,
+ )
+ except BaseException:
+ if not context.terminal:
+ context.complete()
+ raise
+
def _configure_cross_channel_v2_transaction(
self,
request: object,
@@ -4909,6 +5161,34 @@ def _source_arbitrary_select_v2_fields(channel: int) -> tuple[SourceFieldRef, ..
)
)
+ @staticmethod
+ def _source_arbitrary_volatile_replace_v2_fields(
+ channel: int,
+ ) -> tuple[SourceFieldRef, ...]:
+ target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel)
+ fields = (
+ SourceFieldRef(SourceFieldId.ARBITRARY_SELECTION, target),
+ SourceFieldRef(SourceFieldId.ARBITRARY_STORAGE, target),
+ SourceFieldRef(SourceFieldId.BASIC, target),
+ SourceFieldRef(SourceFieldId.OUTPUT, target),
+ SourceFieldRef(
+ SourceFieldId.IDENTITY,
+ SourceScopeRef(SourceFacetScope.INSTRUMENT),
+ ),
+ )
+ return tuple(
+ sorted(
+ fields,
+ key=lambda field: (
+ field.field.value,
+ field.target.scope.value,
+ -1 if field.target.channel is None else field.target.channel,
+ field.target.channels,
+ "" if field.target.input_id is None else field.target.input_id,
+ ),
+ )
+ )
+
@classmethod
def _source_output_v2_fields(
cls,
@@ -6609,6 +6889,106 @@ def _validate_source_arbitrary_select_v2_postcondition(
if result.basic != basic or result.arbitrary != arbitrary:
raise ConfigError(f"{operation} result readback does not match postcondition")
+ @staticmethod
+ def _validate_source_arbitrary_volatile_replace_v2_payload(
+ request: SourceArbitraryVolatileReplaceRequest,
+ payload: object,
+ ) -> None:
+ if not isinstance(payload, bytes):
+ raise ConfigError("source.arbitrary_volatile_replace_v2 payload must be bytes")
+ if len(payload) != request.payload_size_bytes:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 payload size does not match the request"
+ )
+ digest = "sha256:" + sha256(payload).hexdigest()
+ if digest != request.payload_sha256:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 payload SHA-256 does not match the request"
+ )
+
+ def _validate_source_arbitrary_volatile_replace_v2_preflight(
+ self,
+ request: SourceArbitraryVolatileReplaceRequest,
+ snapshot: SourceSnapshotV2,
+ basic: BasicWaveFacet,
+ arbitrary: ArbitraryFacet,
+ output: OutputFacet,
+ ) -> None:
+ del basic, arbitrary
+ operation = "source.arbitrary_volatile_replace_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False:
+ raise ConfigError(f"{operation} requires target output OFF")
+ profile = self._source_arbitrary_runtime_profile(
+ snapshot,
+ channel=request.channel,
+ operation=operation,
+ )
+ if not profile.selection_readable:
+ raise ConfigError(f"{operation} requires readable selected ARB state")
+ minimum = profile.volatile_replace_min_points
+ maximum = profile.volatile_replace_max_points
+ payload_maximum = profile.volatile_replace_max_payload_bytes
+ if minimum is None or maximum is None or payload_maximum is None:
+ raise ConfigError(f"{operation} requires volatile replace limits in the runtime profile")
+ if request.point_count < minimum or request.point_count > maximum:
+ raise ConfigError(f"{operation} point count exceeds the runtime profile")
+ if request.payload_size_bytes > payload_maximum:
+ raise ConfigError(f"{operation} payload size exceeds the runtime profile")
+ basic_profile = self._source_arbitrary_basic_runtime_profile(
+ snapshot,
+ channel=request.channel,
+ operation=operation,
+ )
+ if SourceWaveformKind.ARBITRARY not in basic_profile.waveform_kinds:
+ raise ConfigError(f"{operation} requires arbitrary basic waveform support")
+
+ @staticmethod
+ def _validate_source_arbitrary_volatile_replace_v2_result(
+ request: SourceArbitraryVolatileReplaceRequest,
+ result: object,
+ ) -> None:
+ operation = "source.arbitrary_volatile_replace_v2"
+ if not isinstance(result, SourceArbitraryVolatileReplaceResult):
+ raise ConfigError(
+ "replace_source_arbitrary_volatile_v2() returned an invalid "
+ "SourceArbitraryVolatileReplaceResult"
+ )
+ if (
+ result.channel != request.channel
+ or result.payload_sha256 != request.payload_sha256
+ or result.payload_size_bytes != request.payload_size_bytes
+ or result.point_count != request.point_count
+ ):
+ raise ConfigError(f"{operation} result does not match the request")
+ if not result.write_completed:
+ raise ConfigError(f"{operation} result does not prove the write")
+
+ @staticmethod
+ def _validate_source_arbitrary_volatile_replace_v2_postcondition(
+ result: SourceArbitraryVolatileReplaceResult,
+ snapshot: SourceSnapshotV2,
+ basic: BasicWaveFacet,
+ arbitrary: ArbitraryFacet,
+ output: OutputFacet,
+ ) -> None:
+ operation = "source.arbitrary_volatile_replace_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} postcondition snapshot is inconsistent")
+ if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False:
+ raise ConfigError(f"{operation} postcondition reports output ON")
+ if (
+ basic.waveform_kind.availability is not Availability.VALUE
+ or basic.waveform_kind.value is not SourceWaveformKind.ARBITRARY
+ ):
+ raise ConfigError(f"{operation} basic waveform readback is not arbitrary")
+ if (
+ arbitrary.selected_waveform_id.availability is not Availability.VALUE
+ or arbitrary.selected_waveform_id.value != result.selected_waveform_id
+ ):
+ raise ConfigError(f"{operation} selected waveform readback does not match result")
+
@staticmethod
def _source_burst_runtime_profile(
snapshot: SourceSnapshotV2,
@@ -8035,6 +8415,69 @@ def _source_arbitrary_select_v2_artifact(
)
return artifact
+ def _source_arbitrary_volatile_replace_v2_artifact(
+ self,
+ *,
+ context: SourceOperationContextCoordinator,
+ request: SourceArbitraryVolatileReplaceRequest,
+ preflight_snapshot: SourceSnapshotV2 | None,
+ postcondition_snapshot: SourceSnapshotV2 | None,
+ result: SourceArbitraryVolatileReplaceResult | None,
+ recovery: dict[str, object] | None = None,
+ ) -> dict[str, object]:
+ artifact = context.artifact()
+ descriptor_digest = (
+ None
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.descriptor_digest
+ )
+ artifact["capability_decision"] = {
+ "capability": "source.arbitrary_volatile_replace_v2",
+ "contract_version": SOURCE_CONTRACT_VERSION,
+ "descriptor_digest": descriptor_digest,
+ }
+ artifact["request"] = source_v2_to_data(request)
+ if preflight_snapshot is not None:
+ artifact["preflight"] = {
+ "target_channel": request.channel,
+ "snapshot_digest": source_v2_digest(preflight_snapshot),
+ "consistency": preflight_snapshot.consistency.state.value,
+ }
+ if result is not None:
+ artifact["mutation"] = {"result": source_v2_to_data(result)}
+ if postcondition_snapshot is not None:
+ artifact["postcondition"] = {
+ "snapshot_digest": source_v2_digest(postcondition_snapshot),
+ "consistency": postcondition_snapshot.consistency.state.value,
+ }
+ if recovery is not None:
+ artifact["recovery"] = dict(recovery)
+ artifact["final_state"] = {
+ "session_health": context.session_state.health.value,
+ "output_expected": "off",
+ "selection_expected": None if result is None else result.selected_waveform_id,
+ "content_readback_verified": (
+ None if result is None else result.content_readback_verified
+ ),
+ "previous_content": (
+ "restorable"
+ if result is not None and result.previous_content_restorable
+ else "unrecoverable"
+ ),
+ }
+ artifact["evidence_refs"] = sorted(
+ {
+ evidence_ref
+ for feature in (
+ ()
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.features
+ )
+ for evidence_ref in feature.evidence_refs
+ }
+ )
+ return artifact
+
def _source_burst_v2_artifact(
self,
*,
@@ -8457,6 +8900,20 @@ def _attach_source_arbitrary_select_v2_diagnostics(
except Exception:
pass
+ def _attach_source_arbitrary_volatile_replace_v2_diagnostics(
+ self,
+ exc: BaseException,
+ **kwargs: object,
+ ) -> None:
+ try:
+ setattr(
+ exc,
+ "source_operation_artifact",
+ self._source_arbitrary_volatile_replace_v2_artifact(**kwargs),
+ )
+ except Exception:
+ pass
+
def _attach_source_burst_v2_diagnostics(
self,
exc: BaseException,
@@ -9332,6 +9789,7 @@ def upload_arbitrary_waveform(
"source.output_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
+ "source.arbitrary_volatile_replace_v2",
)
self._require_finite(
playback_frequency_hz,
diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py
index 1c80150..9823ec2 100644
--- a/tests/test_operation_specs.py
+++ b/tests/test_operation_specs.py
@@ -6,6 +6,7 @@
from wavebench.instruments.source_extensions import (
SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT,
SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT,
+ SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT,
@@ -228,6 +229,17 @@ def test_source_v2_write_specs_match_their_static_operation_contracts() -> None:
"source-v2-arbitrary-selection",
("source_v2", "output_must_be_off", "arbitrary_selection"),
),
+ (
+ SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
+ "source-v2-arbitrary-volatile",
+ (
+ "source_v2",
+ "output_must_be_off",
+ "arbitrary_volatile_replace",
+ "payload_not_artifact",
+ "no_retry",
+ ),
+ ),
(
SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT,
"source-v2-output",
@@ -295,6 +307,9 @@ def test_source_v2_write_specs_match_their_static_operation_contracts() -> None:
assert SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT.energy_effect is (
SourceEnergyEffect.POTENTIAL_WHILE_OFF
)
+ assert SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT.energy_effect is (
+ SourceEnergyEffect.POTENTIAL_WHILE_OFF
+ )
assert SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT.energy_effect is SourceEnergyEffect.EMIT
assert SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT.energy_effect is (
SourceEnergyEffect.DECREASE_ONLY
diff --git a/tests/test_source_arbitrary_v2.py b/tests/test_source_arbitrary_v2.py
index 5d804a1..d3641fa 100644
--- a/tests/test_source_arbitrary_v2.py
+++ b/tests/test_source_arbitrary_v2.py
@@ -32,6 +32,8 @@
SourceArbitraryStorageRequest,
SourceArbitraryStorageResult,
SourceArbitraryStorageSlot,
+ SourceArbitraryVolatileReplaceRequest,
+ SourceArbitraryVolatileReplaceResult,
SourceConstraintApplicability,
SourceFacetQueryContract,
SourceFacetScope,
@@ -130,6 +132,7 @@ def __init__(
session_state: InstrumentSessionState,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ volatile_write_error: bool = False,
) -> None:
self.transport = GuardedAuditedTransport(
_TextTransport(),
@@ -137,11 +140,13 @@ def __init__(
)
self.output_enabled = output_enabled
self.postcondition_mismatch = postcondition_mismatch
+ self.volatile_write_error = volatile_write_error
self.basic = basic_facet()
self.arbitrary = _arbitrary()
self.slots: dict[str, bytes] = {}
self.storage_requests: list[tuple[SourceArbitraryStorageRequest, bytes]] = []
self.select_requests: list[SourceArbitrarySelectRequest] = []
+ self.volatile_requests: list[tuple[SourceArbitraryVolatileReplaceRequest, bytes]] = []
self.output_requests: list[SourceOutputRequest] = []
self.v1_upload_calls = 0
@@ -268,6 +273,32 @@ def select_source_arbitrary_v2(
False,
)
+ def replace_source_arbitrary_volatile_v2(
+ self,
+ request: SourceArbitraryVolatileReplaceRequest,
+ payload: bytes,
+ ) -> SourceArbitraryVolatileReplaceResult:
+ self.transport.write_bytes(payload)
+ self.volatile_requests.append((request, payload))
+ if self.volatile_write_error:
+ raise ConfigError("fake volatile binary write result is unknown")
+ self.basic = replace(
+ self.basic,
+ waveform_kind=Observed.value_of(SourceWaveformKind.ARBITRARY),
+ waveform_id=Observed.value_of("volatile"),
+ )
+ self.arbitrary = _arbitrary(slot_id="volatile")
+ return SourceArbitraryVolatileReplaceResult(
+ request.channel,
+ request.payload_sha256,
+ request.payload_size_bytes,
+ request.point_count,
+ "volatile",
+ True,
+ False,
+ False,
+ )
+
def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult:
self.transport.write("SOURCE:OUTPUT")
self.output_requests.append(request)
@@ -289,7 +320,9 @@ def _output(self) -> OutputFacet:
)
def _readback_arbitrary(self) -> ArbitraryFacet:
- if not self.postcondition_mismatch or not self.select_requests:
+ if not self.postcondition_mismatch or not (
+ self.select_requests or self.volatile_requests
+ ):
return self.arbitrary
return replace(
self.arbitrary,
@@ -324,6 +357,9 @@ def _extensions(
SourceStorageWriteMode.REPLACE_IF_DIGEST_MATCHES,
),
storage_max_payload_bytes=4096,
+ volatile_replace_min_points=2,
+ volatile_replace_max_points=16_384,
+ volatile_replace_max_payload_bytes=32_768,
),
)
arbitrary_query = SourceFacetQueryContract(
@@ -391,6 +427,8 @@ def _service(
output_enabled: bool = False,
postcondition_mismatch: bool = False,
dual_contract: bool = False,
+ volatile: bool = False,
+ volatile_write_error: bool = False,
playback_modes: tuple[SourceArbitraryPlaybackMode, ...] = (
SourceArbitraryPlaybackMode.DDS,
SourceArbitraryPlaybackMode.TRUE_ARB,
@@ -401,6 +439,7 @@ def _service(
session_state=session_state,
output_enabled=output_enabled,
postcondition_mismatch=postcondition_mismatch,
+ volatile_write_error=volatile_write_error,
)
capabilities = [
"source.snapshot_v2",
@@ -410,6 +449,8 @@ def _service(
]
if dual_contract:
capabilities.append("source.arbitrary_upload")
+ if volatile:
+ capabilities.append("source.arbitrary_volatile_replace_v2")
descriptor = replace(
source_descriptor(driver=driver, extensions=_extensions(playback_modes=playback_modes)),
capabilities=tuple(capabilities),
@@ -445,6 +486,15 @@ def _storage_request(
)
+def _volatile_request(payload: bytes) -> SourceArbitraryVolatileReplaceRequest:
+ return SourceArbitraryVolatileReplaceRequest(
+ channel=1,
+ payload_sha256=_digest(payload),
+ payload_size_bytes=len(payload),
+ point_count=len(payload) // 2,
+ )
+
+
def test_arbitrary_storage_v2_writes_once_without_selecting_or_disabling_output() -> None:
service, driver = _service(output_enabled=True)
payload = b"raw-arbitrary-storage-payload-must-not-leak"
@@ -592,6 +642,114 @@ def test_arbitrary_select_v2_postcondition_mismatch_runs_one_off_recovery() -> N
}
+def test_arbitrary_volatile_replace_v2_writes_once_and_keeps_output_off() -> None:
+ service, driver = _service(volatile=True)
+ payload = b"\x00\x00\xff\x3f"
+ request = _volatile_request(payload)
+
+ result, artifact = service.replace_arbitrary_volatile_v2(
+ request,
+ payload=payload,
+ correlation_id="arb-volatile",
+ )
+
+ assert driver.volatile_requests == [(request, payload)]
+ assert driver.output_requests == []
+ assert driver.output_enabled is False
+ assert driver.transport.counters.binary_write_completed == 1
+ assert result.content_readback_verified is False
+ assert result.previous_content_restorable is False
+ assert artifact["operation"] == "source.arbitrary_volatile_replace_v2"
+ assert artifact["request"] == {
+ "type": "SourceArbitraryVolatileReplaceRequest",
+ "channel": 1,
+ "payload_sha256": _digest(payload),
+ "payload_size_bytes": len(payload),
+ "point_count": 2,
+ }
+ assert artifact["final_state"] == {
+ "session_health": "healthy",
+ "output_expected": "off",
+ "selection_expected": "volatile",
+ "content_readback_verified": False,
+ "previous_content": "unrecoverable",
+ }
+ assert payload.hex() not in repr(artifact)
+ assert [item["phase"] for item in artifact["phases"]] == [
+ "preflight",
+ "main",
+ "postcondition",
+ ]
+
+
+def test_arbitrary_volatile_replace_v2_rejects_invalid_payload_and_preflight_before_write() -> None:
+ payload = b"\x00\x00\xff\x3f"
+ request = _volatile_request(payload)
+ service, driver = _service(volatile=True)
+
+ with pytest.raises(ConfigError, match="SHA-256"):
+ service.replace_arbitrary_volatile_v2(request, payload=b"\x00\x00\x00\x00")
+ with pytest.raises(ConfigError, match="must be bytes"):
+ service.replace_arbitrary_volatile_v2(
+ request,
+ payload=bytearray(payload), # type: ignore[arg-type]
+ )
+ assert driver.transport.counters.binary_write_requests == 0
+ assert driver.transport.counters.query_calls == 0
+
+ output_on, output_on_driver = _service(output_enabled=True, volatile=True)
+ with pytest.raises(ConfigError, match="target output OFF"):
+ output_on.replace_arbitrary_volatile_v2(request, payload=payload)
+
+ below_minimum = SourceArbitraryVolatileReplaceRequest(
+ 1,
+ _digest(b"\x00\x00"),
+ 2,
+ 1,
+ )
+ with pytest.raises(ConfigError, match="point count exceeds"):
+ service.replace_arbitrary_volatile_v2(below_minimum, payload=b"\x00\x00")
+
+ assert driver.volatile_requests == []
+ assert driver.transport.counters.binary_write_requests == 0
+ assert output_on_driver.volatile_requests == []
+ assert output_on_driver.transport.counters.binary_write_requests == 0
+
+
+@pytest.mark.parametrize(
+ ("postcondition_mismatch", "volatile_write_error", "message"),
+ (
+ (True, False, "selected waveform readback"),
+ (False, True, "volatile binary write result is unknown"),
+ ),
+)
+def test_arbitrary_volatile_replace_v2_failure_runs_one_off_recovery(
+ postcondition_mismatch: bool,
+ volatile_write_error: bool,
+ message: str,
+) -> None:
+ service, driver = _service(
+ volatile=True,
+ postcondition_mismatch=postcondition_mismatch,
+ volatile_write_error=volatile_write_error,
+ )
+ payload = b"\x00\x00\xff\x3f"
+ request = _volatile_request(payload)
+
+ with pytest.raises(ConfigError, match=message) as raised:
+ service.replace_arbitrary_volatile_v2(request, payload=payload)
+
+ artifact = raised.value.source_operation_artifact
+ assert driver.volatile_requests == [(request, payload)]
+ assert driver.transport.counters.binary_write_requests == 1
+ assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)]
+ assert artifact["recovery"] == {
+ "status": "off_verified",
+ "session_health": "uncertain",
+ }
+ assert artifact["final_state"]["previous_content"] == "unrecoverable"
+
+
def test_v1_arbitrary_upload_rejects_before_loading_file_or_io_for_dual_contract_driver() -> None:
service, driver = _service(dual_contract=True)
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index c6929c7..5e81b39 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -662,6 +662,9 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None:
"mutate_source_arbitrary_storage_v2",
),
"source.arbitrary_select_v2": ("select_source_arbitrary_v2",),
+ "source.arbitrary_volatile_replace_v2": (
+ "replace_source_arbitrary_volatile_v2",
+ ),
"source.combine_configure_v2": ("configure_source_combine_v2",),
"source.coupling_configure_v2": ("configure_source_coupling_v2",),
"source.tracking_configure_v2": ("configure_source_tracking_v2",),
@@ -1953,6 +1956,151 @@ def select_source_arbitrary_v2(self, request):
"read_source_arbitrary_storage_v2": lambda self, channel, slot_id: None,
"select_source_arbitrary_v2": lambda self, request: None,
},
+ )(),
+ )
+
+
+def test_source_v2_volatile_arb_replace_requires_limits_and_off_recovery() -> None:
+ extensions = source_extensions()
+ basic, output = extensions.features
+ arbitrary = module.SourceFeatureCapability(
+ feature=module.SourceFeature.ARBITRARY,
+ support=module.SupportState.SUPPORTED,
+ directions=(module.SourceFeatureDirection.CONFIGURE, module.SourceFeatureDirection.READ),
+ scope=module.SourceFacetScope.CHANNEL,
+ channels=(1,),
+ applicability=module.SourceConstraintApplicability(),
+ profile=module.SourceArbitraryCapabilityProfile(
+ playback_modes=(module.SourceArbitraryPlaybackMode.DDS,),
+ selection_readable=True,
+ storage_metadata_readable=False,
+ sample_rate_readable=False,
+ volatile_replace_min_points=2,
+ volatile_replace_max_points=16_384,
+ volatile_replace_max_payload_bytes=32_768,
+ ),
+ )
+ arbitrary_query = module.SourceFacetQueryContract(
+ feature=module.SourceFeature.ARBITRARY,
+ scope=module.SourceFacetScope.CHANNEL,
+ fields=(module.SourceFieldId.ARBITRARY_SELECTION,),
+ activation_any=(),
+ effect=module.SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ )
+ configured_extensions = replace(
+ extensions,
+ features=(
+ arbitrary,
+ replace(
+ basic,
+ profile=replace(
+ basic.profile,
+ waveform_kinds=(
+ module.SourceWaveformKind.ARBITRARY,
+ module.SourceWaveformKind.SINE,
+ ),
+ ),
+ ),
+ replace(
+ output,
+ directions=(
+ module.SourceFeatureDirection.DISABLE,
+ module.SourceFeatureDirection.ENABLE,
+ module.SourceFeatureDirection.READ,
+ ),
+ ),
+ ),
+ query_contract=replace(
+ extensions.query_contract,
+ facets=(arbitrary_query, *extensions.query_contract.facets),
+ max_queries=7,
+ ),
+ )
+ descriptor = replace(
+ source_descriptor(extensions=configured_extensions),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.output_v2",
+ "source.arbitrary_volatile_replace_v2",
+ ),
+ )
+
+ class VolatileArbitraryWriteDriver(SourceV2FakeDriver):
+ def set_source_output_v2(self, request):
+ raise AssertionError(request)
+
+ def replace_source_arbitrary_volatile_v2(self, request, payload):
+ raise AssertionError((request, payload))
+
+ validate_source_descriptor(descriptor)
+ validate_declared_capabilities(descriptor, VolatileArbitraryWriteDriver(combined=True))
+
+ with pytest.raises(ConfigError, match="requires source.output_v2"):
+ validate_source_descriptor(
+ replace(
+ descriptor,
+ capabilities=(
+ "source.snapshot_v2",
+ "source.arbitrary_volatile_replace_v2",
+ ),
+ source_extensions=replace(
+ configured_extensions,
+ features=(
+ arbitrary,
+ configured_extensions.features[1],
+ replace(
+ configured_extensions.features[2],
+ directions=(module.SourceFeatureDirection.READ,),
+ ),
+ ),
+ ),
+ )
+ )
+ with pytest.raises(ConfigError, match="volatile replace limits"):
+ validate_source_descriptor(
+ replace(
+ descriptor,
+ source_extensions=replace(
+ configured_extensions,
+ features=(
+ replace(
+ arbitrary,
+ profile=replace(
+ arbitrary.profile,
+ volatile_replace_min_points=None,
+ volatile_replace_max_points=None,
+ volatile_replace_max_payload_bytes=None,
+ ),
+ ),
+ configured_extensions.features[1],
+ configured_extensions.features[2],
+ ),
+ ),
+ )
+ )
+ with pytest.raises(ConfigError, match="arbitrary basic waveform"):
+ validate_source_descriptor(
+ replace(
+ descriptor,
+ source_extensions=replace(
+ configured_extensions,
+ features=(arbitrary, basic, configured_extensions.features[2]),
+ ),
+ )
+ )
+ with pytest.raises(TypeError, match="replace_source_arbitrary_volatile_v2"):
+ validate_declared_capabilities(
+ descriptor,
+ type(
+ "MissingVolatileArbitraryDriver",
+ (),
+ {
+ "close": lambda self: None,
+ "execute_source_query_plan_v2": lambda self, plan: None,
+ "set_source_output_v2": lambda self, request: None,
+ },
)(),
)
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index e8e5893..115c232 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -47,6 +47,7 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
+ "source.arbitrary_volatile_replace_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
@@ -122,7 +123,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
spec.operation
for spec in list_operation_specs(instrument_kind="source")
if "_v2" in spec.operation and spec.effect == "write"
- } == expected_v2_operations
+ } == expected_v2_operations | {"source.arbitrary_volatile_replace_v2"}
with TemporaryDirectory() as tmp:
valid_steps = {
From 4100f0057897d1d86d366ac2c64e300e65cef77e Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:39:45 +0800
Subject: [PATCH 28/44] feat(source): add counter v2 candidate transactions
---
...00\345\217\221\346\214\207\345\215\227.md" | 8 +
.../instruments/source_conformance.py | 12 +
.../source_extension_capabilities.py | 87 +++
src/wavebench/services/operation_specs.py | 60 ++
src/wavebench/services/source_service.py | 709 +++++++++++++++++-
tests/test_operation_specs.py | 24 +
tests/test_source_counter_v2.py | 455 +++++++++++
tests/test_source_extensions.py | 3 +
tests/test_source_v1_routes.py | 6 +
9 files changed, 1362 insertions(+), 2 deletions(-)
create mode 100644 tests/test_source_counter_v2.py
diff --git "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md"
index 1c14b50..356f594 100644
--- "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md"
+++ "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md"
@@ -256,6 +256,14 @@ selection 只允许目标输出 OFF,完成后仍为 OFF,不会隐式 ON。
`upload_arbitrary_waveform`:旧 route 还包含播放频率、Vpp/offset、可选输出 ON 和旧 artifact 语义。不得将旧 route
部分改写为 volatile replace;需要公开该组合时,应另行定义完整的复合 capability、artifact 与验收。
+Counter 使用独立的 `source.counter_configure_v2`、`source.counter_enable_v2` 与
+`source.counter_measure_v2`。descriptor 必须声明 Counter `READ`,配置还需 `CONFIGURE` 与可读的
+`configurable_fields`,启停还需成对的 `ENABLE`/`DISABLE` 与 `enabled_configurable`。每次配置 request
+只能设置 coupling、input impedance、attenuation、trigger level 或 statistics enable 中的一项;Core 不会因配置或
+测量自动启用 Counter,也不会写 `AUTO`、gate、HF、sensitivity、display 或 statistics clear。配置和启停在写后必须独立
+回读;结果不明时不自动回滚或 disable,连接保守失效。测量只允许已启用的输入,并以单条受授权查询进入 driver。当前
+CLI 与 run schema 不提供这三项入口;production descriptor 必须另有对应的实机证据才能声明 capability。
+
跨通道 Combine、Coupling、Tracking 和相位关系分别使用 `source.combine_configure_v2`、
`source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2`。每项都使用
独立 driver method,request 只包含递增且唯一的 channel set 与 enabled state。descriptor 必须为该 relation 的
diff --git a/src/wavebench/instruments/source_conformance.py b/src/wavebench/instruments/source_conformance.py
index 7d74ecf..4344c0a 100644
--- a/src/wavebench/instruments/source_conformance.py
+++ b/src/wavebench/instruments/source_conformance.py
@@ -122,6 +122,18 @@
SourceFeature.ARBITRARY,
frozenset({SourceFeatureDirection.CONFIGURE}),
),
+ "source.counter_configure_v2": (
+ SourceFeature.COUNTER,
+ frozenset({SourceFeatureDirection.CONFIGURE}),
+ ),
+ "source.counter_enable_v2": (
+ SourceFeature.COUNTER,
+ frozenset({SourceFeatureDirection.ENABLE, SourceFeatureDirection.DISABLE}),
+ ),
+ "source.counter_measure_v2": (
+ SourceFeature.COUNTER,
+ frozenset({SourceFeatureDirection.READ}),
+ ),
"source.combine_configure_v2": (
SourceFeature.COMBINE,
frozenset({SourceFeatureDirection.CONFIGURE}),
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 6250e01..14828bf 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -18,6 +18,7 @@
SourceAmplitudeUnit,
SourceArbitraryCapabilityProfile,
SourceCouplingCapabilityProfile,
+ SourceCounterCapabilityProfile,
SourceCrossChannelCapabilityProfile,
SourceDescriptorExtensions,
SourceAnchorField,
@@ -73,6 +74,9 @@
"source.arbitrary_volatile_replace_v2": (
"replace_source_arbitrary_volatile_v2",
),
+ "source.counter_configure_v2": ("configure_source_counter_v2",),
+ "source.counter_enable_v2": ("set_source_counter_enabled_v2",),
+ "source.counter_measure_v2": ("measure_source_counter_v2",),
"source.combine_configure_v2": ("configure_source_combine_v2",),
"source.coupling_configure_v2": ("configure_source_coupling_v2",),
"source.tracking_configure_v2": ("configure_source_tracking_v2",),
@@ -101,6 +105,8 @@
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
@@ -133,6 +139,7 @@ def validate_source_descriptor(descriptor: object, driver: object | None = None)
)
_validate_source_version_range(descriptor)
_validate_read_contract(extensions)
+ _validate_counter_capabilities(extensions, frozenset(declared))
_validate_write_contract(extensions, frozenset(declared) & _SOURCE_WRITE_CAPABILITIES)
if driver is not None:
for capability in declared:
@@ -334,6 +341,77 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None:
)
+def _validate_counter_capabilities(
+ extensions: SourceDescriptorExtensions,
+ capabilities: frozenset[str],
+) -> None:
+ counter_capabilities = {
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_measure_v2",
+ }
+ if not counter_capabilities & capabilities:
+ return
+ features = tuple(
+ feature
+ for feature in extensions.features
+ if (
+ feature.feature is SourceFeature.COUNTER
+ and feature.scope is SourceFacetScope.INPUT
+ and feature.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.READ in feature.directions
+ and isinstance(feature.profile, SourceCounterCapabilityProfile)
+ )
+ )
+ if len(features) != 1:
+ raise ConfigError(
+ "Counter V2 capabilities require one readable INPUT counter feature"
+ )
+ feature = features[0]
+ profile = feature.profile
+ assert isinstance(profile, SourceCounterCapabilityProfile)
+ if not any(
+ facet.feature is SourceFeature.COUNTER
+ and facet.scope is SourceFacetScope.INPUT
+ and facet.fields == (SourceFieldId.COUNTER,)
+ for facet in extensions.query_contract.facets
+ ):
+ raise ConfigError("Counter V2 capabilities require a readable counter query facet")
+ if "source.counter_configure_v2" in capabilities:
+ if SourceFeatureDirection.CONFIGURE not in feature.directions:
+ raise ConfigError(
+ "source.counter_configure_v2 requires counter CONFIGURE direction"
+ )
+ if not profile.configuration_readable or not profile.configurable_fields:
+ raise ConfigError(
+ "source.counter_configure_v2 requires readable configurable counter fields"
+ )
+ if "source.counter_enable_v2" in capabilities:
+ if not {
+ SourceFeatureDirection.ENABLE,
+ SourceFeatureDirection.DISABLE,
+ } <= set(feature.directions):
+ raise ConfigError(
+ "source.counter_enable_v2 requires counter ENABLE and DISABLE directions"
+ )
+ if not profile.enabled_configurable:
+ raise ConfigError(
+ "source.counter_enable_v2 requires an enabled_configurable counter profile"
+ )
+ if "source.counter_measure_v2" in capabilities:
+ if not profile.measurement_kinds:
+ raise ConfigError(
+ "source.counter_measure_v2 requires declared counter measurement kinds"
+ )
+ if profile.query_effect not in {
+ SourceQueryEffect.PURE_READ,
+ SourceQueryEffect.STATEFUL_CONSUMING_READ,
+ }:
+ raise ConfigError(
+ "source.counter_measure_v2 requires a known read-only counter query effect"
+ )
+
+
def _validate_write_contract(
extensions: SourceDescriptorExtensions,
capabilities: frozenset[str],
@@ -1268,6 +1346,15 @@ def _validate_declared_write_directions(
"source.arbitrary_volatile_replace_v2",
}
),
+ (SourceFeature.COUNTER, SourceFeatureDirection.CONFIGURE): frozenset(
+ {"source.counter_configure_v2"}
+ ),
+ (SourceFeature.COUNTER, SourceFeatureDirection.ENABLE): frozenset(
+ {"source.counter_enable_v2"}
+ ),
+ (SourceFeature.COUNTER, SourceFeatureDirection.DISABLE): frozenset(
+ {"source.counter_enable_v2"}
+ ),
(SourceFeature.COMBINE, SourceFeatureDirection.CONFIGURE): frozenset(
{"source.combine_configure_v2"}
),
diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py
index a71ce59..0756bd4 100644
--- a/src/wavebench/services/operation_specs.py
+++ b/src/wavebench/services/operation_specs.py
@@ -938,6 +938,66 @@ def _spec(
"no_retry",
),
),
+ _spec(
+ "source.counter_configure_v2",
+ "source",
+ required_capabilities=("source.counter_configure_v2",),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.input.counter",),
+ restore_coverage="source-v2-counter-no-rollback",
+ required_verified_fields=("source.identity", "source.input.counter"),
+ verification_fields=("source.identity", "source.input.counter"),
+ postcondition_fields=("source.input.counter",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=("source_v2", "counter_input_configuration", "no_automatic_rollback"),
+ ),
+ _spec(
+ "source.counter_enable_v2",
+ "source",
+ required_capabilities=("source.counter_enable_v2",),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.input.counter",),
+ restore_coverage="source-v2-counter-no-rollback",
+ required_verified_fields=("source.identity", "source.input.counter"),
+ verification_fields=("source.identity", "source.input.counter"),
+ postcondition_fields=("source.input.counter",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=("source_v2", "counter_enable", "no_automatic_rollback"),
+ ),
+ _spec(
+ "source.counter_disable_v2",
+ "source",
+ required_capabilities=("source.counter_enable_v2",),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.input.counter",),
+ restore_coverage="source-v2-counter-no-rollback",
+ required_verified_fields=("source.identity", "source.input.counter"),
+ verification_fields=("source.identity", "source.input.counter"),
+ postcondition_fields=("source.input.counter",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=("source_v2", "counter_disable", "no_automatic_rollback"),
+ ),
+ _spec(
+ "source.counter_measure_v2",
+ "source",
+ required_capabilities=("source.counter_measure_v2",),
+ effect="stateful_read",
+ lease_mode="exclusive",
+ restore_coverage="none-read-only",
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=("counter_measurement_query",),
+ ),
_spec(
"source.combine_configure_v2",
"source",
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index a99c4c0..1c15b2b 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -7,6 +7,7 @@
from math import isfinite
import time
from typing import cast
+from uuid import uuid4
from wavebench.arbitrary import build_dg4000_dac14_binary_block, load_arbitrary_waveform
from wavebench.config import SourceConfig, WaveBenchConfig
@@ -80,6 +81,9 @@
SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT,
+ SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT,
+ SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT,
SOURCE_COMBINE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_COUPLING_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_CONTRACT_VERSION,
@@ -131,6 +135,18 @@
SourceCouplingConfigureRequest,
SourceCouplingConfigureV2Driver,
SourceCouplingState,
+ SourceCounterCapabilityProfile,
+ SourceCounterConfigurationField,
+ SourceCounterConfigureRequest,
+ SourceCounterConfigureResult,
+ SourceCounterConfigureV2Driver,
+ SourceCounterEnableRequest,
+ SourceCounterEnableResult,
+ SourceCounterEnableV2Driver,
+ SourceCounterInputState,
+ SourceCounterMeasureRequest,
+ SourceCounterMeasureResult,
+ SourceCounterMeasureV2Driver,
SourceCrossChannelCapabilityProfile,
SourceCrossChannelConfigureResult,
SourceFacetScope,
@@ -186,6 +202,7 @@
SourceSweepMarker,
SourceSnapshotV2,
SourceSnapshotV2Driver,
+ SourceSystemStateV2,
SourceTriggerOutput,
SourceTriggerSlope,
SourceTriggerSource,
@@ -197,6 +214,7 @@
SourceTrackingConfigureV2Driver,
SourceV1WriteRouteId,
SourceWaveformKind,
+ SourceQueryEffect,
SupportState,
source_v2_digest,
source_v2_to_data,
@@ -210,7 +228,11 @@
from wavebench.services.session_alias import SessionStateAliasMixin
from wavebench.services.state_guard import SourceStateGuard
from wavebench.transport.base import InstrumentTransport
-from wavebench.transport.session import InstrumentSessionState
+from wavebench.transport.session import (
+ InstrumentSessionState,
+ SessionHealth,
+ SessionTransactionCoordinator,
+)
from wavebench.services.source_snapshot_v2 import (
SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS,
SourceSnapshotContractError,
@@ -222,7 +244,6 @@
SourceOperationContextCoordinator,
SourceOperationPhase,
)
-from wavebench.transport.session import SessionHealth
@dataclass(frozen=True, slots=True)
@@ -372,6 +393,15 @@ class _SourceArbitraryVolatileReplaceV2Transaction:
snapshot: SourceSnapshotV2
+@dataclass(frozen=True, slots=True)
+class _SourceCounterV2Transaction:
+ """Core transaction result for one independently verified Counter mutation."""
+
+ result: SourceCounterConfigureResult | SourceCounterEnableResult
+ artifact: dict[str, object]
+ snapshot: SourceSnapshotV2
+
+
@dataclass(frozen=True, slots=True)
class _SourceCrossChannelConfigureV2Transaction:
"""Core transaction result shared by the four M6-C relation routes."""
@@ -885,6 +915,57 @@ def replace_arbitrary_volatile_v2(
)
return transaction.result, transaction.artifact
+ def configure_counter_v2(
+ self,
+ request: SourceCounterConfigureRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceCounterConfigureResult, dict[str, object]]:
+ """Apply one independently readable Counter input setting."""
+
+ transaction = self._mutate_counter_v2_transaction(
+ request,
+ operation="source.counter_configure_v2",
+ operation_contract=SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT,
+ correlation_id=correlation_id,
+ )
+ assert isinstance(transaction.result, SourceCounterConfigureResult)
+ return transaction.result, transaction.artifact
+
+ def set_counter_enabled_v2(
+ self,
+ request: SourceCounterEnableRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceCounterEnableResult, dict[str, object]]:
+ """Enable or disable Counter without changing its input configuration."""
+
+ if not isinstance(request, SourceCounterEnableRequest):
+ raise ConfigError("source.counter_enable_v2 requires SourceCounterEnableRequest")
+ operation = "source.counter_enable_v2" if request.enabled else "source.counter_disable_v2"
+ transaction = self._mutate_counter_v2_transaction(
+ request,
+ operation=operation,
+ operation_contract=(
+ SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT
+ if request.enabled
+ else SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT
+ ),
+ correlation_id=correlation_id,
+ )
+ assert isinstance(transaction.result, SourceCounterEnableResult)
+ return transaction.result, transaction.artifact
+
+ def measure_counter_v2(
+ self,
+ request: SourceCounterMeasureRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> SourceCounterMeasureResult:
+ """Read one already-enabled Counter input without changing any Counter setting."""
+
+ return self._measure_counter_v2(request, correlation_id=correlation_id)
+
def configure_combine_v2(
self,
request: SourceCombineConfigureRequest,
@@ -4157,6 +4238,291 @@ def _replace_arbitrary_volatile_v2_transaction(
context.complete()
raise
+ def _mutate_counter_v2_transaction(
+ self,
+ request: SourceCounterConfigureRequest | SourceCounterEnableRequest,
+ *,
+ operation: str,
+ operation_contract: SourceOperationContract,
+ correlation_id: str | None = None,
+ ) -> _SourceCounterV2Transaction:
+ """Run one Counter configuration or enable-state mutation with no rollback."""
+
+ if operation == "source.counter_configure_v2":
+ if not isinstance(request, SourceCounterConfigureRequest):
+ raise ConfigError(f"{operation} requires SourceCounterConfigureRequest")
+ elif operation in {"source.counter_enable_v2", "source.counter_disable_v2"}:
+ if not isinstance(request, SourceCounterEnableRequest):
+ raise ConfigError(f"{operation} requires SourceCounterEnableRequest")
+ else: # pragma: no cover - private callers fix the operation set above.
+ raise ValueError("unsupported Counter V2 mutation operation")
+ self._require(operation, "source.snapshot_v2", operation_contract.capability)
+ with self._source_session() as source:
+ descriptor = self.descriptor
+ extensions = None if descriptor is None else descriptor.source_extensions
+ session_state = self.session_state
+ if not isinstance(extensions, SourceDescriptorExtensions):
+ raise ConfigError(f"{operation} requires validated source_extensions")
+ if session_state is None:
+ raise ConfigError(f"{operation} requires a connection-bound session state")
+ fields = self._source_counter_v2_fields(request.input_id)
+ counter_field = next(
+ field for field in fields if field.field is SourceFieldId.COUNTER
+ )
+ context = SourceOperationContextCoordinator(
+ session_state=session_state,
+ operation_spec=require_operation_spec(operation),
+ operation_contract=operation_contract,
+ connection_timeout_ms=self.config.connection.timeout_ms,
+ baseline_snapshot_digest=None,
+ fields=fields,
+ required_off_outputs=(),
+ emergency_off_outputs=(),
+ restore_order=(),
+ non_restorable_fields=(counter_field,),
+ correlation_id=correlation_id,
+ )
+ preflight_snapshot: SourceSnapshotV2 | None = None
+ postcondition_snapshot: SourceSnapshotV2 | None = None
+ result: SourceCounterConfigureResult | SourceCounterEnableResult | None = None
+ wrote_main = False
+ main_entered = False
+ failure: BaseException | None = None
+ recovery: dict[str, object] | None = None
+
+ try:
+ preflight = context.make_phase_spec(
+ SourceOperationPhase.PREFLIGHT,
+ allowed_io={"query"},
+ fields=fields,
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(preflight) as authorization:
+ preflight_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ counter, profile = self._source_v2_counter_target(
+ preflight_snapshot,
+ request.input_id,
+ direction=operation_contract.direction,
+ operation=operation,
+ )
+ if isinstance(request, SourceCounterConfigureRequest):
+ wrote_main = self._validate_source_counter_configure_v2_preflight(
+ request,
+ preflight_snapshot,
+ counter,
+ profile,
+ )
+ if not wrote_main:
+ result = SourceCounterConfigureResult(request.input_id, counter)
+ else:
+ wrote_main = self._validate_source_counter_enable_v2_preflight(
+ request,
+ preflight_snapshot,
+ counter,
+ profile,
+ operation=operation,
+ )
+ if not wrote_main:
+ result = SourceCounterEnableResult(request.input_id, request.enabled)
+ context.bind_baseline_snapshot_digest(
+ source_v2_digest((request.input_id, counter))
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=fields,
+ )
+
+ if wrote_main:
+ main = context.make_phase_spec(
+ SourceOperationPhase.MAIN,
+ allowed_io={"write"},
+ fields=(counter_field,),
+ max_steps=operation_contract.main_max_steps,
+ )
+ try:
+ with context.authorize_phase(main):
+ main_entered = True
+ if isinstance(request, SourceCounterConfigureRequest):
+ result = cast(
+ SourceCounterConfigureV2Driver,
+ source,
+ ).configure_source_counter_v2(request)
+ self._validate_source_counter_configure_v2_result(request, result)
+ else:
+ result = cast(
+ SourceCounterEnableV2Driver,
+ source,
+ ).set_source_counter_enabled_v2(request)
+ self._validate_source_counter_enable_v2_result(
+ request,
+ result,
+ operation=operation,
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is None and wrote_main:
+ try:
+ postcondition = context.make_phase_spec(
+ SourceOperationPhase.POSTCONDITION,
+ allowed_io={"query"},
+ fields=(counter_field,),
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(postcondition) as authorization:
+ postcondition_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ counter, profile = self._source_v2_counter_target(
+ postcondition_snapshot,
+ request.input_id,
+ direction=operation_contract.direction,
+ operation=operation,
+ )
+ assert result is not None
+ if isinstance(request, SourceCounterConfigureRequest):
+ self._validate_source_counter_configure_v2_postcondition(
+ request,
+ result,
+ postcondition_snapshot,
+ counter,
+ profile,
+ )
+ else:
+ self._validate_source_counter_enable_v2_postcondition(
+ request,
+ result,
+ postcondition_snapshot,
+ counter,
+ profile,
+ operation=operation,
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=(counter_field,),
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is not None:
+ if main_entered:
+ context.mark_failure_required()
+ recovery = {
+ "status": "not_attempted",
+ "reason": "counter_state_not_rollback_safe",
+ }
+ context.complete()
+ if main_entered:
+ self._attach_source_counter_v2_diagnostics(
+ failure,
+ context=context,
+ request=request,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ wrote_main=wrote_main,
+ recovery=recovery,
+ capability=operation_contract.capability,
+ )
+ raise failure
+
+ context.complete()
+ assert result is not None
+ assert preflight_snapshot is not None
+ return _SourceCounterV2Transaction(
+ result=result,
+ artifact=self._source_counter_v2_artifact(
+ context=context,
+ request=request,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ wrote_main=wrote_main,
+ capability=operation_contract.capability,
+ ),
+ snapshot=(
+ postcondition_snapshot
+ if postcondition_snapshot is not None
+ else preflight_snapshot
+ ),
+ )
+ except BaseException:
+ if not context.terminal:
+ context.complete()
+ raise
+
+ def _measure_counter_v2(
+ self,
+ request: SourceCounterMeasureRequest,
+ *,
+ correlation_id: str | None = None,
+ ) -> SourceCounterMeasureResult:
+ operation = "source.counter_measure_v2"
+ if not isinstance(request, SourceCounterMeasureRequest):
+ raise ConfigError(f"{operation} requires SourceCounterMeasureRequest")
+ self._require(operation, "source.snapshot_v2", "source.counter_measure_v2")
+ spec = require_operation_spec(operation)
+ with self._source_session() as source:
+ descriptor = self.descriptor
+ extensions = None if descriptor is None else descriptor.source_extensions
+ session_state = self.session_state
+ if not isinstance(extensions, SourceDescriptorExtensions):
+ raise ConfigError(f"{operation} requires validated source_extensions")
+ if session_state is None:
+ raise ConfigError(f"{operation} requires a connection-bound session state")
+ timeout_ms = spec.operation_timeout_ms
+ if timeout_ms is None: # pragma: no cover - registry invariant.
+ raise ConfigError(f"{operation} requires an operation timeout")
+ timeout_ms = min(timeout_ms, self.config.connection.timeout_ms)
+ with session_state.transaction_lock:
+ snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=correlation_id,
+ )
+ counter, profile = self._source_v2_counter_target(
+ snapshot,
+ request.input_id,
+ direction=SourceFeatureDirection.READ,
+ operation=operation,
+ )
+ self._validate_source_counter_measure_v2_preflight(
+ snapshot,
+ counter,
+ profile,
+ operation=operation,
+ )
+ coordinator = SessionTransactionCoordinator(session_state)
+ with coordinator.authorize_normal(
+ operation_id=operation,
+ allowed_io=("query",),
+ fields=(SourceFieldId.COUNTER.value,),
+ timeout_ms=timeout_ms,
+ max_steps=1,
+ context_id="source_counter_measure_v2",
+ correlation_id=uuid4().hex,
+ phase="main",
+ absolute_deadline=time.monotonic() + (timeout_ms / 1000.0),
+ ):
+ result = cast(
+ SourceCounterMeasureV2Driver,
+ source,
+ ).measure_source_counter_v2(request)
+ self._validate_source_counter_measure_v2_result(
+ request,
+ result,
+ profile,
+ operation=operation,
+ )
+ return result
+
def _configure_cross_channel_v2_transaction(
self,
request: object,
@@ -5189,6 +5555,29 @@ def _source_arbitrary_volatile_replace_v2_fields(
)
)
+ @staticmethod
+ def _source_counter_v2_fields(input_id: str) -> tuple[SourceFieldRef, ...]:
+ target = SourceScopeRef(SourceFacetScope.INPUT, input_id=input_id)
+ fields = (
+ SourceFieldRef(SourceFieldId.COUNTER, target),
+ SourceFieldRef(
+ SourceFieldId.IDENTITY,
+ SourceScopeRef(SourceFacetScope.INSTRUMENT),
+ ),
+ )
+ return tuple(
+ sorted(
+ fields,
+ key=lambda field: (
+ field.field.value,
+ field.target.scope.value,
+ -1 if field.target.channel is None else field.target.channel,
+ field.target.channels,
+ "" if field.target.input_id is None else field.target.input_id,
+ ),
+ )
+ )
+
@classmethod
def _source_output_v2_fields(
cls,
@@ -5205,6 +5594,44 @@ def _source_output_v2_fields(
),
)
+ @staticmethod
+ def _source_v2_counter_target(
+ snapshot: SourceSnapshotV2,
+ input_id: str,
+ *,
+ direction: SourceFeatureDirection,
+ operation: str,
+ ) -> tuple[SourceCounterInputState, SourceCounterCapabilityProfile]:
+ features = tuple(
+ feature
+ for feature in snapshot.runtime_profile.features
+ if (
+ feature.feature is SourceFeature.COUNTER
+ and feature.scope is SourceFacetScope.INPUT
+ and feature.support is SupportState.SUPPORTED
+ and direction in feature.directions
+ and isinstance(feature.profile, SourceCounterCapabilityProfile)
+ )
+ )
+ if len(features) != 1:
+ raise ConfigError(f"{operation} requires a runtime Counter {direction.value} profile")
+ profile = features[0].profile
+ assert isinstance(profile, SourceCounterCapabilityProfile)
+ if input_id not in profile.input_ids:
+ raise ConfigError(f"{operation} input_id is unsupported by the runtime profile")
+ if (
+ snapshot.system.availability is not Availability.VALUE
+ or not isinstance(snapshot.system.value, SourceSystemStateV2)
+ ):
+ raise ConfigError(f"{operation} requires readable Counter system state")
+ counter = next(
+ (item for item in snapshot.system.value.counters if item.input_id == input_id),
+ None,
+ )
+ if counter is None:
+ raise ConfigError(f"{operation} input_id is absent from snapshot")
+ return counter, profile
+
@staticmethod
def _source_v2_target(
snapshot: SourceSnapshotV2,
@@ -6989,6 +7416,214 @@ def _validate_source_arbitrary_volatile_replace_v2_postcondition(
):
raise ConfigError(f"{operation} selected waveform readback does not match result")
+ @staticmethod
+ def _source_counter_configuration_field(
+ request: SourceCounterConfigureRequest,
+ ) -> SourceCounterConfigurationField:
+ fields = tuple(
+ field
+ for field, patch_value in (
+ (SourceCounterConfigurationField.COUPLING, request.patch.coupling),
+ (SourceCounterConfigurationField.IMPEDANCE_OHM, request.patch.impedance_ohm),
+ (SourceCounterConfigurationField.ATTENUATION, request.patch.attenuation),
+ (SourceCounterConfigurationField.TRIGGER_LEVEL_V, request.patch.trigger_level_v),
+ (SourceCounterConfigurationField.STATISTICS_ENABLED, request.patch.statistics_enabled),
+ )
+ if patch_value.action is PatchAction.SET
+ )
+ if len(fields) != 1: # pragma: no cover - request model already enforces this.
+ raise ConfigError("source.counter_configure_v2 requires exactly one Counter field")
+ return fields[0]
+
+ @staticmethod
+ def _source_counter_configuration_expected_value(
+ request: SourceCounterConfigureRequest,
+ field: SourceCounterConfigurationField,
+ ) -> object:
+ values = {
+ SourceCounterConfigurationField.COUPLING: request.patch.coupling.value,
+ SourceCounterConfigurationField.IMPEDANCE_OHM: request.patch.impedance_ohm.value,
+ SourceCounterConfigurationField.ATTENUATION: request.patch.attenuation.value,
+ SourceCounterConfigurationField.TRIGGER_LEVEL_V: request.patch.trigger_level_v.value,
+ SourceCounterConfigurationField.STATISTICS_ENABLED: request.patch.statistics_enabled.value,
+ }
+ value = values[field]
+ if value is None: # pragma: no cover - request model already enforces SET values.
+ raise ConfigError("source.counter_configure_v2 Counter value is missing")
+ return value
+
+ @staticmethod
+ def _source_counter_configuration_observed_value(
+ state: SourceCounterInputState,
+ field: SourceCounterConfigurationField,
+ *,
+ operation: str,
+ ) -> object:
+ observed = {
+ SourceCounterConfigurationField.COUPLING: state.coupling,
+ SourceCounterConfigurationField.IMPEDANCE_OHM: state.impedance_ohm,
+ SourceCounterConfigurationField.ATTENUATION: state.attenuation,
+ SourceCounterConfigurationField.TRIGGER_LEVEL_V: state.trigger_level_v,
+ SourceCounterConfigurationField.STATISTICS_ENABLED: state.statistics_enabled,
+ }[field]
+ if observed.availability is not Availability.VALUE:
+ raise ConfigError(
+ f"{operation} requires readable {field.value} Counter configuration"
+ )
+ return observed.value
+
+ def _validate_source_counter_configure_v2_preflight(
+ self,
+ request: SourceCounterConfigureRequest,
+ snapshot: SourceSnapshotV2,
+ counter: SourceCounterInputState,
+ profile: SourceCounterCapabilityProfile,
+ ) -> bool:
+ operation = "source.counter_configure_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if not profile.configuration_readable:
+ raise ConfigError(f"{operation} requires readable Counter configuration")
+ field = self._source_counter_configuration_field(request)
+ if field not in profile.configurable_fields:
+ raise ConfigError(f"{operation} field is not configurable in the runtime profile")
+ current = self._source_counter_configuration_observed_value(
+ counter,
+ field,
+ operation=operation,
+ )
+ return current != self._source_counter_configuration_expected_value(request, field)
+
+ def _validate_source_counter_configure_v2_result(
+ self,
+ request: SourceCounterConfigureRequest,
+ result: object,
+ ) -> None:
+ operation = "source.counter_configure_v2"
+ if not isinstance(result, SourceCounterConfigureResult):
+ raise ConfigError(
+ "configure_source_counter_v2() returned an invalid SourceCounterConfigureResult"
+ )
+ if result.input_id != request.input_id:
+ raise ConfigError(f"{operation} result input_id does not match request")
+ field = self._source_counter_configuration_field(request)
+ if self._source_counter_configuration_observed_value(
+ result.state,
+ field,
+ operation=operation,
+ ) != self._source_counter_configuration_expected_value(request, field):
+ raise ConfigError(f"{operation} result does not match the request")
+
+ def _validate_source_counter_configure_v2_postcondition(
+ self,
+ request: SourceCounterConfigureRequest,
+ result: SourceCounterConfigureResult,
+ snapshot: SourceSnapshotV2,
+ counter: SourceCounterInputState,
+ profile: SourceCounterCapabilityProfile,
+ ) -> None:
+ operation = "source.counter_configure_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} postcondition snapshot is inconsistent")
+ if not profile.configuration_readable:
+ raise ConfigError(f"{operation} postcondition lacks readable Counter configuration")
+ self._validate_source_counter_configure_v2_result(request, result)
+ field = self._source_counter_configuration_field(request)
+ if self._source_counter_configuration_observed_value(
+ counter,
+ field,
+ operation=operation,
+ ) != self._source_counter_configuration_expected_value(request, field):
+ raise ConfigError(f"{operation} readback does not match the request")
+
+ @staticmethod
+ def _validate_source_counter_enable_v2_preflight(
+ request: SourceCounterEnableRequest,
+ snapshot: SourceSnapshotV2,
+ counter: SourceCounterInputState,
+ profile: SourceCounterCapabilityProfile,
+ *,
+ operation: str,
+ ) -> bool:
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if not profile.enabled_configurable:
+ raise ConfigError(f"{operation} is not configurable in the runtime profile")
+ if counter.enabled.availability is not Availability.VALUE:
+ raise ConfigError(f"{operation} requires readable Counter enabled state")
+ return counter.enabled.value is not request.enabled
+
+ @staticmethod
+ def _validate_source_counter_enable_v2_result(
+ request: SourceCounterEnableRequest,
+ result: object,
+ *,
+ operation: str,
+ ) -> None:
+ if not isinstance(result, SourceCounterEnableResult):
+ raise ConfigError(
+ "set_source_counter_enabled_v2() returned an invalid SourceCounterEnableResult"
+ )
+ if result.input_id != request.input_id or result.enabled is not request.enabled:
+ raise ConfigError(f"{operation} result does not match the request")
+
+ def _validate_source_counter_enable_v2_postcondition(
+ self,
+ request: SourceCounterEnableRequest,
+ result: SourceCounterEnableResult,
+ snapshot: SourceSnapshotV2,
+ counter: SourceCounterInputState,
+ profile: SourceCounterCapabilityProfile,
+ *,
+ operation: str,
+ ) -> None:
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} postcondition snapshot is inconsistent")
+ if not profile.enabled_configurable:
+ raise ConfigError(f"{operation} postcondition lacks runtime enable support")
+ self._validate_source_counter_enable_v2_result(request, result, operation=operation)
+ if counter.enabled.availability is not Availability.VALUE or (
+ counter.enabled.value is not request.enabled
+ ):
+ raise ConfigError(f"{operation} enabled readback does not match the request")
+
+ @staticmethod
+ def _validate_source_counter_measure_v2_preflight(
+ snapshot: SourceSnapshotV2,
+ counter: SourceCounterInputState,
+ profile: SourceCounterCapabilityProfile,
+ *,
+ operation: str,
+ ) -> None:
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if counter.enabled.availability is not Availability.VALUE or counter.enabled.value is not True:
+ raise ConfigError(f"{operation} requires Counter enabled")
+ if profile.query_effect not in {
+ SourceQueryEffect.PURE_READ,
+ SourceQueryEffect.STATEFUL_CONSUMING_READ,
+ }:
+ raise ConfigError(f"{operation} requires a known read-only Counter query effect")
+
+ @staticmethod
+ def _validate_source_counter_measure_v2_result(
+ request: SourceCounterMeasureRequest,
+ result: object,
+ profile: SourceCounterCapabilityProfile,
+ *,
+ operation: str,
+ ) -> None:
+ if not isinstance(result, SourceCounterMeasureResult):
+ raise ConfigError(
+ "measure_source_counter_v2() returned an invalid SourceCounterMeasureResult"
+ )
+ if result.input_id != request.input_id:
+ raise ConfigError(f"{operation} result input_id does not match request")
+ if not {
+ measurement.kind for measurement in result.measurements
+ } <= set(profile.measurement_kinds):
+ raise ConfigError(f"{operation} result includes an unsupported measurement kind")
+
@staticmethod
def _source_burst_runtime_profile(
snapshot: SourceSnapshotV2,
@@ -8478,6 +9113,66 @@ def _source_arbitrary_volatile_replace_v2_artifact(
)
return artifact
+ def _source_counter_v2_artifact(
+ self,
+ *,
+ context: SourceOperationContextCoordinator,
+ request: SourceCounterConfigureRequest | SourceCounterEnableRequest,
+ preflight_snapshot: SourceSnapshotV2 | None,
+ postcondition_snapshot: SourceSnapshotV2 | None,
+ result: SourceCounterConfigureResult | SourceCounterEnableResult | None,
+ wrote_main: bool,
+ capability: str,
+ recovery: dict[str, object] | None = None,
+ ) -> dict[str, object]:
+ artifact = context.artifact()
+ descriptor_digest = (
+ None
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.descriptor_digest
+ )
+ artifact["capability_decision"] = {
+ "capability": capability,
+ "contract_version": SOURCE_CONTRACT_VERSION,
+ "descriptor_digest": descriptor_digest,
+ }
+ artifact["request"] = source_v2_to_data(request)
+ if preflight_snapshot is not None:
+ artifact["preflight"] = {
+ "target_input_id": request.input_id,
+ "snapshot_digest": source_v2_digest(preflight_snapshot),
+ "consistency": preflight_snapshot.consistency.state.value,
+ }
+ if result is not None:
+ artifact["mutation"] = {
+ "status": "written" if wrote_main else "already_at_target",
+ "result": source_v2_to_data(result),
+ }
+ if postcondition_snapshot is not None:
+ artifact["postcondition"] = {
+ "snapshot_digest": source_v2_digest(postcondition_snapshot),
+ "consistency": postcondition_snapshot.consistency.state.value,
+ }
+ if recovery is not None:
+ artifact["recovery"] = dict(recovery)
+ artifact["final_state"] = {
+ "session_health": context.session_state.health.value,
+ "counter_input_id": request.input_id,
+ "automatic_rollback": "not_available",
+ }
+ artifact["evidence_refs"] = sorted(
+ {
+ evidence_ref
+ for feature in (
+ ()
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.features
+ )
+ for evidence_ref in feature.evidence_refs
+ }
+ )
+ return artifact
+
def _source_burst_v2_artifact(
self,
*,
@@ -8914,6 +9609,16 @@ def _attach_source_arbitrary_volatile_replace_v2_diagnostics(
except Exception:
pass
+ def _attach_source_counter_v2_diagnostics(
+ self,
+ exc: BaseException,
+ **kwargs: object,
+ ) -> None:
+ try:
+ setattr(exc, "source_operation_artifact", self._source_counter_v2_artifact(**kwargs))
+ except Exception:
+ pass
+
def _attach_source_burst_v2_diagnostics(
self,
exc: BaseException,
diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py
index 9823ec2..6db30c7 100644
--- a/tests/test_operation_specs.py
+++ b/tests/test_operation_specs.py
@@ -10,6 +10,9 @@
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT,
+ SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT,
+ SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT,
+ SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT,
SOURCE_FM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_HARMONICS_DISABLE_V2_OPERATION_CONTRACT,
SOURCE_HARMONICS_CONFIGURE_V2_OPERATION_CONTRACT,
@@ -46,6 +49,27 @@ def test_source_output_spec_describes_mutation_and_restore_boundary() -> None:
assert spec.as_dict()["required_capabilities"] == ["source.output"]
+def test_source_counter_v2_specs_keep_configuration_enable_and_measure_separate() -> None:
+ configure = require_operation_spec("source.counter_configure_v2")
+ enable = require_operation_spec("source.counter_enable_v2")
+ disable = require_operation_spec("source.counter_disable_v2")
+ measure = require_operation_spec("source.counter_measure_v2")
+
+ for spec, contract in (
+ (configure, SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT),
+ (enable, SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT),
+ (disable, SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT),
+ ):
+ assert spec.effect == "write"
+ assert spec.required_capabilities == (contract.capability,)
+ assert spec.changed_fields == ("source.input.counter",)
+ assert spec.restore_coverage == "source-v2-counter-no-rollback"
+ assert "no_automatic_rollback" in spec.risk_flags
+ assert measure.effect == "stateful_read"
+ assert measure.required_capabilities == ("source.counter_measure_v2",)
+ assert measure.restore_coverage == "none-read-only"
+
+
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")
diff --git a/tests/test_source_counter_v2.py b/tests/test_source_counter_v2.py
new file mode 100644
index 0000000..589c08c
--- /dev/null
+++ b/tests/test_source_counter_v2.py
@@ -0,0 +1,455 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+
+from wavebench.config import (
+ AutoscaleConfig,
+ ConnectionConfig,
+ OutputConfig,
+ SafetyLimitsConfig,
+ ScopeConfig,
+ SourceConfig,
+ WaveBenchConfig,
+ WaveformConfig,
+)
+from wavebench.errors import ConfigError
+from wavebench.instruments.capabilities import validate_declared_capabilities
+from wavebench.instruments.source_extension_capabilities import validate_source_descriptor
+from wavebench.instruments.source_extensions import (
+ SOURCE_CONTRACT_VERSION,
+ Availability,
+ Observed,
+ PatchAction,
+ PatchValue,
+ SourceConstraintApplicability,
+ SourceCounterCapabilityProfile,
+ SourceCounterConfigurationField,
+ SourceCounterConfigurationPatch,
+ SourceCounterConfigureRequest,
+ SourceCounterConfigureResult,
+ SourceCounterEnableRequest,
+ SourceCounterEnableResult,
+ SourceCounterInputState,
+ SourceCounterMeasureRequest,
+ SourceCounterMeasurementKind,
+ SourceCounterMeasurementV2,
+ SourceCounterMeasureResult,
+ SourceFacetQueryContract,
+ SourceFacetScope,
+ SourceFeature,
+ SourceFeatureCapability,
+ SourceFeatureDirection,
+ SourceFieldId,
+ SourceInputCoupling,
+ SourceProtocolQueryRecord,
+ SourceQueryEffect,
+ SourceQueryExecutionRecord,
+ SourceQueryItemOutcome,
+ SourceReasonCode,
+ SourceRuntimeIdentity,
+ SourceTopologyContract,
+ SourceTypedObservation,
+ SupportState,
+)
+from wavebench.logging import CommandLogger
+from wavebench.services.source_service import SourceService
+from wavebench.transport.contracts import ReplayPolicy
+from wavebench.transport.guarded import GuardedAuditedTransport
+from wavebench.transport.session import InstrumentSessionState
+
+from tests.source_v2_fixtures import source_descriptor, source_extensions
+
+
+class _TextTransport:
+ resource = "fake-source-counter-v2"
+
+ def record_event(self, direction: str, text: str) -> None:
+ del direction, text
+
+ def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str:
+ del command, replay
+ return "ok"
+
+ def write(self, command: str) -> None:
+ del command
+
+ def close(self) -> None:
+ pass
+
+
+def _missing() -> Observed[object]:
+ return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED)
+
+
+def _counter(
+ *,
+ enabled: bool = False,
+ coupling: SourceInputCoupling = SourceInputCoupling.DC,
+ impedance_ohm: float = 1_000_000.0,
+ attenuation: int = 1,
+ trigger_level_v: float = 0.0,
+ statistics_enabled: bool = False,
+) -> SourceCounterInputState:
+ return SourceCounterInputState(
+ input_id="counter",
+ enabled=Observed.value_of(enabled),
+ measurements=(
+ Observed.missing(
+ Availability.NOT_APPLICABLE,
+ SourceReasonCode.INACTIVE_BY_ANCHOR,
+ )
+ if not enabled
+ else _missing()
+ ),
+ coupling=Observed.value_of(coupling),
+ impedance_ohm=Observed.value_of(impedance_ohm),
+ attenuation=Observed.value_of(attenuation),
+ gate_time_s=Observed.missing(
+ Availability.UNSUPPORTED,
+ SourceReasonCode.DESCRIPTOR_UNSUPPORTED,
+ ),
+ trigger_level_v=Observed.value_of(trigger_level_v),
+ statistics_enabled=Observed.value_of(statistics_enabled),
+ )
+
+
+class _CounterDriver:
+ def __init__(
+ self,
+ *,
+ session_state: InstrumentSessionState,
+ enabled: bool = False,
+ postcondition_mismatch: bool = False,
+ ) -> None:
+ self.transport = GuardedAuditedTransport(_TextTransport(), session_state=session_state)
+ self.counter = _counter(enabled=enabled)
+ self.postcondition_mismatch = postcondition_mismatch
+ self.configure_requests: list[SourceCounterConfigureRequest] = []
+ self.enable_requests: list[SourceCounterEnableRequest] = []
+ self.measure_requests: list[SourceCounterMeasureRequest] = []
+
+ def close(self) -> None:
+ self.transport.close()
+
+ def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord:
+ records = []
+ for index, item in enumerate(plan.items):
+ if index == 0:
+ self.transport.query("SOURCE:STATE?")
+ observations = []
+ for field in item.fields:
+ if field.field is SourceFieldId.IDENTITY:
+ value = SourceRuntimeIdentity(
+ manufacturer="Example",
+ model="EX1",
+ firmware_id="1.0",
+ )
+ elif field.field is SourceFieldId.BASIC:
+ from tests.source_v2_fixtures import basic_facet
+
+ value = basic_facet()
+ elif field.field is SourceFieldId.OUTPUT:
+ from tests.source_v2_fixtures import output_facet
+
+ value = output_facet()
+ else:
+ assert field.field is SourceFieldId.COUNTER
+ value = self._snapshot_counter()
+ observations.append(SourceTypedObservation(field, value))
+ records.append(
+ SourceProtocolQueryRecord(
+ item_id=item.item_id,
+ effect=item.effect,
+ outcome=SourceQueryItemOutcome.OBSERVED,
+ query_count=(1 if index == 0 else 0),
+ observations=tuple(observations),
+ )
+ )
+ return SourceQueryExecutionRecord(
+ contract_version=SOURCE_CONTRACT_VERSION,
+ plan_id=plan.plan_id,
+ items=tuple(records),
+ query_count=1,
+ device_revision_token_before="revision-1",
+ device_revision_token_after="revision-1",
+ )
+
+ def configure_source_counter_v2(
+ self,
+ request: SourceCounterConfigureRequest,
+ ) -> SourceCounterConfigureResult:
+ self.transport.write("SOURCE:COUNTER:CONFIGURE")
+ self.configure_requests.append(request)
+ values = {
+ SourceCounterConfigurationField.COUPLING: ("coupling", request.patch.coupling.value),
+ SourceCounterConfigurationField.IMPEDANCE_OHM: (
+ "impedance_ohm",
+ request.patch.impedance_ohm.value,
+ ),
+ SourceCounterConfigurationField.ATTENUATION: (
+ "attenuation",
+ request.patch.attenuation.value,
+ ),
+ SourceCounterConfigurationField.TRIGGER_LEVEL_V: (
+ "trigger_level_v",
+ request.patch.trigger_level_v.value,
+ ),
+ SourceCounterConfigurationField.STATISTICS_ENABLED: (
+ "statistics_enabled",
+ request.patch.statistics_enabled.value,
+ ),
+ }
+ field = next(
+ key
+ for key, patch_value in (
+ (SourceCounterConfigurationField.COUPLING, request.patch.coupling),
+ (SourceCounterConfigurationField.IMPEDANCE_OHM, request.patch.impedance_ohm),
+ (SourceCounterConfigurationField.ATTENUATION, request.patch.attenuation),
+ (SourceCounterConfigurationField.TRIGGER_LEVEL_V, request.patch.trigger_level_v),
+ (SourceCounterConfigurationField.STATISTICS_ENABLED, request.patch.statistics_enabled),
+ )
+ if patch_value.action is PatchAction.SET
+ )
+ name, value = values[field]
+ self.counter = replace(self.counter, **{name: Observed.value_of(value)})
+ return SourceCounterConfigureResult(request.input_id, self.counter)
+
+ def set_source_counter_enabled_v2(
+ self,
+ request: SourceCounterEnableRequest,
+ ) -> SourceCounterEnableResult:
+ self.transport.write("SOURCE:COUNTER:ENABLE")
+ self.enable_requests.append(request)
+ self.counter = replace(self.counter, enabled=Observed.value_of(request.enabled))
+ return SourceCounterEnableResult(request.input_id, request.enabled)
+
+ def measure_source_counter_v2(
+ self,
+ request: SourceCounterMeasureRequest,
+ ) -> SourceCounterMeasureResult:
+ self.transport.query("SOURCE:COUNTER:MEASURE?")
+ self.measure_requests.append(request)
+ return SourceCounterMeasureResult(
+ request.input_id,
+ (
+ SourceCounterMeasurementV2(SourceCounterMeasurementKind.DUTY_PERCENT, 40.0),
+ SourceCounterMeasurementV2(SourceCounterMeasurementKind.FREQUENCY_HZ, 1_000.0),
+ ),
+ )
+
+ def _snapshot_counter(self) -> SourceCounterInputState:
+ if not self.postcondition_mismatch or not self.configure_requests:
+ return self.counter
+ return replace(self.counter, coupling=Observed.value_of(SourceInputCoupling.DC))
+
+
+def _extensions():
+ base = source_extensions()
+ counter = SourceFeatureCapability(
+ feature=SourceFeature.COUNTER,
+ support=SupportState.SUPPORTED,
+ directions=(
+ SourceFeatureDirection.CONFIGURE,
+ SourceFeatureDirection.DISABLE,
+ SourceFeatureDirection.ENABLE,
+ SourceFeatureDirection.READ,
+ ),
+ scope=SourceFacetScope.INPUT,
+ channels=(),
+ applicability=SourceConstraintApplicability(),
+ profile=SourceCounterCapabilityProfile(
+ input_ids=("counter",),
+ measurement_kinds=(
+ SourceCounterMeasurementKind.DUTY_PERCENT,
+ SourceCounterMeasurementKind.FREQUENCY_HZ,
+ ),
+ configuration_readable=True,
+ query_effect=SourceQueryEffect.PURE_READ,
+ readable_configuration_fields=(
+ SourceCounterConfigurationField.ATTENUATION,
+ SourceCounterConfigurationField.COUPLING,
+ SourceCounterConfigurationField.IMPEDANCE_OHM,
+ SourceCounterConfigurationField.STATISTICS_ENABLED,
+ SourceCounterConfigurationField.TRIGGER_LEVEL_V,
+ ),
+ configurable_fields=(
+ SourceCounterConfigurationField.ATTENUATION,
+ SourceCounterConfigurationField.COUPLING,
+ SourceCounterConfigurationField.IMPEDANCE_OHM,
+ SourceCounterConfigurationField.STATISTICS_ENABLED,
+ SourceCounterConfigurationField.TRIGGER_LEVEL_V,
+ ),
+ enabled_configurable=True,
+ ),
+ )
+ counter_query = SourceFacetQueryContract(
+ feature=SourceFeature.COUNTER,
+ scope=SourceFacetScope.INPUT,
+ fields=(SourceFieldId.COUNTER,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ )
+ return replace(
+ base,
+ topology=SourceTopologyContract((1,), input_ids=("counter",)),
+ features=tuple(
+ sorted(
+ (*base.features, counter),
+ key=lambda item: (item.feature.value, item.scope.value, item.channels),
+ )
+ ),
+ query_contract=replace(
+ base.query_contract,
+ facets=tuple(
+ sorted(
+ (*base.query_contract.facets, counter_query),
+ key=lambda item: (
+ item.feature.value,
+ item.scope.value,
+ tuple(field.value for field in item.fields),
+ ),
+ )
+ ),
+ max_queries=7,
+ ),
+ )
+
+
+def _config() -> 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("wavebench.toml"),
+ source=SourceConfig(
+ "example.source-v2",
+ "TCPIP::source::INSTR",
+ 1,
+ False,
+ True,
+ 0,
+ ),
+ safety_limits=SafetyLimitsConfig(),
+ )
+
+
+def _service(
+ *,
+ enabled: bool = False,
+ postcondition_mismatch: bool = False,
+) -> tuple[SourceService, _CounterDriver]:
+ session_state = InstrumentSessionState(epoch_id="source-counter-v2")
+ driver = _CounterDriver(
+ session_state=session_state,
+ enabled=enabled,
+ postcondition_mismatch=postcondition_mismatch,
+ )
+ descriptor = replace(
+ source_descriptor(extensions=_extensions()),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_measure_v2",
+ ),
+ )
+ validate_source_descriptor(descriptor)
+ validate_declared_capabilities(descriptor, driver)
+ return (
+ SourceService(
+ config=_config(),
+ logger=CommandLogger(),
+ session=driver, # type: ignore[arg-type]
+ descriptor=descriptor,
+ transport=driver.transport,
+ session_state=session_state,
+ ),
+ driver,
+ )
+
+
+def _configure_request() -> SourceCounterConfigureRequest:
+ return SourceCounterConfigureRequest(
+ "counter",
+ SourceCounterConfigurationPatch(
+ coupling=PatchValue(PatchAction.SET, SourceInputCoupling.AC)
+ ),
+ )
+
+
+def test_counter_configure_v2_writes_once_and_records_no_rollback_boundary() -> None:
+ service, driver = _service()
+
+ result, artifact = service.configure_counter_v2(_configure_request())
+
+ assert result.state.coupling.value is SourceInputCoupling.AC
+ assert driver.configure_requests == [_configure_request()]
+ assert driver.enable_requests == []
+ assert driver.transport.counters.write_completed == 1
+ assert artifact["mutation"]["status"] == "written"
+ assert artifact["final_state"] == {
+ "session_health": "healthy",
+ "counter_input_id": "counter",
+ "automatic_rollback": "not_available",
+ }
+
+
+def test_counter_enable_v2_is_independent_from_configuration() -> None:
+ service, driver = _service()
+ request = SourceCounterEnableRequest("counter", True)
+
+ result, artifact = service.set_counter_enabled_v2(request)
+
+ assert result.enabled is True
+ assert driver.configure_requests == []
+ assert driver.enable_requests == [request]
+ assert driver.transport.counters.write_completed == 1
+ assert artifact["operation"] == "source.counter_enable_v2"
+
+
+def test_counter_measure_v2_queries_once_after_enabled_preflight_without_writes() -> None:
+ service, driver = _service(enabled=True)
+ request = SourceCounterMeasureRequest("counter")
+
+ result = service.measure_counter_v2(request)
+
+ assert result.input_id == "counter"
+ assert driver.measure_requests == [request]
+ assert driver.configure_requests == []
+ assert driver.enable_requests == []
+ assert driver.transport.counters.write_requests == 0
+ assert driver.transport.counters.query_calls == 2
+
+
+def test_counter_measure_v2_refuses_disabled_counter_before_measurement_query() -> None:
+ service, driver = _service(enabled=False)
+
+ with pytest.raises(ConfigError, match="requires Counter enabled"):
+ service.measure_counter_v2(SourceCounterMeasureRequest("counter"))
+
+ assert driver.measure_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
+def test_counter_postcondition_failure_does_not_write_rollback_or_disable() -> None:
+ service, driver = _service(postcondition_mismatch=True)
+
+ with pytest.raises(ConfigError, match="readback does not match") as raised:
+ service.configure_counter_v2(_configure_request())
+
+ artifact = raised.value.source_operation_artifact
+ assert driver.configure_requests == [_configure_request()]
+ assert driver.enable_requests == []
+ assert driver.transport.counters.write_completed == 1
+ assert artifact["recovery"] == {
+ "status": "not_attempted",
+ "reason": "counter_state_not_rollback_safe",
+ }
+ assert artifact["final_state"]["session_health"] == "poisoned"
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 5e81b39..e583d67 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -665,6 +665,9 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None:
"source.arbitrary_volatile_replace_v2": (
"replace_source_arbitrary_volatile_v2",
),
+ "source.counter_configure_v2": ("configure_source_counter_v2",),
+ "source.counter_enable_v2": ("set_source_counter_enabled_v2",),
+ "source.counter_measure_v2": ("measure_source_counter_v2",),
"source.combine_configure_v2": ("configure_source_combine_v2",),
"source.coupling_configure_v2": ("configure_source_coupling_v2",),
"source.tracking_configure_v2": ("configure_source_tracking_v2",),
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index 115c232..e1ab871 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -48,6 +48,9 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_disable_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
@@ -114,6 +117,9 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_disable_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
From 4395f36f49b40c20a2218fbe9a7a6e5a398b6f64 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 21:30:09 +0800
Subject: [PATCH 29/44] chore: ignore local codegraph index
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 4e9f5cb..19f8666 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Rei scratchpad / local helper workspace
tool-of-rei/
+.codegraph/
# Local agent/Codex metadata
/.agents/*
From 1f67f16523432398978fdf40feab4b3ce8c4c032 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sun, 30 Aug 2026 22:46:06 +0800
Subject: [PATCH 30/44] feat(source): add explicit additive V2 routes
---
...345\207\272\345\256\211\345\205\250RFC.md" | 65 ++++--
src/wavebench/cli.py | 42 ++--
src/wavebench/cli_parser.py | 9 +
.../instruments/source_extensions.py | 5 +
src/wavebench/services/run_plan.py | 20 ++
src/wavebench/services/run_safety.py | 6 +-
src/wavebench/services/run_service.py | 18 +-
src/wavebench/services/source_service.py | 74 +++++--
tests/test_cli.py | 41 ++++
tests/test_run_plan.py | 32 +++
tests/test_run_service.py | 73 ++++++
tests/test_source_basic_configure_v2.py | 209 ++++++++++++++++++
tests/test_source_extensions.py | 8 +
tests/test_source_v1_routes.py | 2 +
14 files changed, 542 insertions(+), 62 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index ead528c..75ece30 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -250,6 +250,7 @@ class SourceDescriptorExtensions:
features: tuple[SourceFeatureCapability, ...]
query_contract: SourceQueryContract
safety_profile: SourceSafetyProfile = SourceSafetyProfile()
+ v1_route_migration_enabled: bool = True
@dataclass(frozen=True, slots=True)
@@ -278,6 +279,12 @@ class SourceFeatureCapability:
- profile 只能收紧核心数值上限、deadline 和恢复步骤,不能放宽;
- 使用 Source V2 的插件必须提高 wheel 与 descriptor 的最低核心版本。
+`v1_route_migration_enabled` 是 append-only 的 descriptor 迁移策略,默认值为 `true`。默认策略下,
+核心按已注册 V2 capability 接管可无损映射的 V1 route,或在无法无损映射时于 I/O 前拒绝。
+设为 `false` 时,显式 V2 Service、CLI 与 run step 仍可用,但 Basic/Output 的既有 V1 setter、
+restore、ARB upload 与 trigger 不因这几个 V2 capability 自动换路。该开关不取消其它已单独声明
+V2 capability 的重叠 route 安全门。
+
R2 不改变现有 eager factory 合同:factory 可以调用 `DriverContext.open_transport()`。因此
Protocol 方法缺失能够保证「零 Source operation 命令」,不能保证「零连接建立」。离线 A0 与插件
发布检查必须在真实资源使用前发现这类声明错误。插件绕过 `DriverContext` 自行访问设备或网络
@@ -926,7 +933,7 @@ session、operation context 和 postcondition。
| 路径 | 统一要求 |
| --- | --- |
| V2 输出 ON | fresh 一致 snapshot + 预算 + 写后回读 |
-| V1 同义写入口调用双合同驱动 | 在 Service 边界映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝 |
+| V1 同义写入口调用双合同驱动 | 当 descriptor 未关闭 V1 route migration 时,在 Service 边界映射到对应 V2 operation;无法无损映射时在 I/O 前拒绝 |
| `source.arb_load output_on=true` | 先在输出 OFF 的配置 phase 完成上传或选择,再用 fresh snapshot 签发只授权下一次 ON 的新决定 |
| 输出 ON 时的 Source V2 setter/patch | 仅 `source.basic_live_configure_v2` 可单独修改频率或 Vpp;其它 patch 在 I/O 前拒绝 |
| arm/fire/trigger | 在可能发出信号前完成预算与接线证据检查 |
@@ -937,10 +944,11 @@ V1 驱动未 opt in 时继续使用现有 V1 路径,不伪装成已获得 Sour
行为,但不因此获得 Source V2 的 live mutation 安全保证。
每个 V2 写 capability 必须在 `SourceOperationContract` 中登记其 V1 等价入口、重叠字段和可能发出
-信号的间接入口。双合同驱动声明该 capability 后,只有落入这些集合的 V1 路径必须映射到 V2
-operation 或在 I/O 前拒绝;字段闭包完全不相交的 V1 operation 可以继续走 V1。核心仍需审计完整
-V1 写表面,防止遗漏隐式副作用。OFF 不需要复合预算,但不能绕过 access、session health、
-operation context 和必要回读。插件不能通过保留旧方法名重新引入同字段或同发信号路径的旁路。
+信号的间接入口。双合同驱动默认对落入这些集合的 V1 路径映射到 V2 operation 或在 I/O 前拒绝;
+若 descriptor 明确关闭 V1 route migration,则保留完整 V1 合同,只有显式 V2 入口调用新 operation。
+字段闭包完全不相交的 V1 operation 可以继续走 V1。核心仍需审计完整 V1 写表面,防止遗漏隐式副作用。
+OFF 不需要复合预算,但不能绕过 access、session health、operation context 和必要回读。插件不能通过
+保留旧方法名重新引入同字段或同发信号路径的旁路。
`source.output_v2` 的等价集合至少包括 V1 `set_output(ON)`、ARB 的 `output_on=True`、会发出信号的
trigger/fire 和恢复 ON;`source.arbitrary_storage_v2` 至少接管 V1 上传入口,但不因此接管无关的
@@ -2754,7 +2762,8 @@ scheme、原始等级、按 `wavebench.source.a0-a5.v1` 重新评定的等级和
| 新核心 + 旧插件 | `source_extensions=None`,保持 V1 路径,不推导 V2 写能力 |
| 旧核心 + 新插件 | 受管安装由 wheel `Requires-Dist` 在 entry point import 前拒绝;绕过 package inspection 的直接 `pip --no-deps` 或手工安装不承诺零导入,且不属于支持组合 |
| 新核心 + 新插件 | 只对明确声明并通过验证的 Source V2 capability 使用新合同 |
-| 新核心 + 同时声明 V1/V2 的新插件 | 新 operation 只使用 V2;同义或副作用重叠的旧写入口映射/拒绝,不相交的旧 operation 保持 V1;单次事务不混用两套安全视图 |
+| 新核心 + 同时声明 V1/V2 的新插件 | 新 operation 只使用 V2;默认策略下,同义或副作用重叠的旧写入口映射/拒绝,不相交的旧 operation 保持 V1;单次事务不混用两套安全视图 |
+| 新核心 + `v1_route_migration_enabled=false` 的双合同插件 | 只有显式 V2 operation 使用 V2;既有 V1 route 保持原合同,不能用不完整的 V2 组合替换 legacy composite transaction |
R2 决定保持 `wavebench.instrument.v2`。`source_extensions` 是带默认值的末尾扩展,新 Protocol
不改变现有 `SourceDriver`,新 capability 通过最低核心版本门显式 opt in。只有删除 Source V1、
@@ -3175,8 +3184,9 @@ Vpp、Offset 和输出状态的设备正常使用信号发生器功能,而不
MAIN 只调用一次 `configure_source_basic_live_v2()`。结果与 fresh postcondition 必须逐项证明请求值、
最终 Vpp、Offset 和输出仍为 ON。结果未知、driver 异常或后置条件失败时不重试,也不恢复 ON;Core
只允许一次 `source.output_v2` OFF recovery 与独立回读。V1 `set_frequency()` 和
-`set_amplitude_vpp()` 可按已证明的输出状态选择 OFF-only 或 live operation。D1-2 不增加 CLI 命令
-或 run plan step,频响、离散扫频与 TUI 继续复用既有 setter。
+`set_amplitude_vpp()` 只在 descriptor 保持默认 migration 策略时,才按已证明的输出状态选择
+OFF-only 或 live operation。显式 P1 入口为 `basic-live-configure-v2` CLI 与
+`source.basic_live_configure_v2` run step;频响、离散扫频与 TUI 继续复用既有 setter。
Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使用 `offset ± Vpp / 2`;不要求独立
`SourceNoisePeakConstraint`。若设备只能提供标称值、RMS 或载波幅度,插件不得为该模式声明
@@ -3198,10 +3208,11 @@ Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使
artifact 和错误路径,不能用一个方向不明确的 contract 混合表示。
- 新类型、descriptor 字段和 artifact 键必须 append-only;既有 `SourceDriver`、`SourceStatus`、
V1 CLI、V1 run step、V1 JSON 和 V1 artifact 不改变语义。
-- V1-only 插件继续执行 V1 路径。双合同插件声明某项 V2 capability 后,核心在 M5-D 将同义或副作用
- 重叠的 V1 route 映射到 V2。`set_function` 有一项兼容例外:目标波形未在当前 V2 Basic profile 声明,
- 或 V2 preflight 无法为当前旧状态提供最终 Vpp/Offset 时,核心继续调用既有 V1 setter,不进入 V2 MAIN
- 写入;其余无法无损映射的重叠 route 在仪器 I/O 前拒绝。不相交的 V1 route 保持原行为。
+- V1-only 插件继续执行 V1 路径。双合同插件默认在 M5-D 将同义或副作用重叠的 V1 route 映射到 V2;
+ `v1_route_migration_enabled=false` 可使显式 V2 surface 与完整 legacy V1 transaction 并存。
+ `set_function` 有一项兼容例外:目标波形未在当前 V2 Basic profile 声明,或 V2 preflight 无法为当前
+ 旧状态提供最终 Vpp/Offset 时,核心继续调用既有 V1 setter,不进入 V2 MAIN 写入;其余无法无损映射
+ 的重叠 route 在仪器 I/O 前拒绝。不相交的 V1 route 保持原行为。
- M5-D 在同一开发线内先完成 Service/CLI,再增加 V2 run plan step、intent 和 artifact;中间不发布
稳定写接口。
@@ -3297,33 +3308,38 @@ M5-D 将 M5-B/M5-C 的唯一核心事务开放为以下 Service 方法:
```python
SourceService.configure_basic_v2(request, *, correlation_id=None)
+SourceService.configure_basic_live_v2(request, *, correlation_id=None)
SourceService.set_output_v2(request, *, correlation_id=None)
```
-两者分别返回 `(typed_result, operation_artifact)`。`typed_result` 是已冻结的
+三者分别返回 `(typed_result, operation_artifact)`。`typed_result` 是已冻结的
`SourceBasicConfigureResult` 或 `SourceOutputResult`;`operation_artifact` 使用
`wavebench.source.operation.v1`。Service 不重新实现 preflight、写入、回读、recovery 或 session health
-逻辑,所有入口继续复用 M5-B/M5-C 事务。
+逻辑,所有入口继续复用 M5-B/M5-C 与 D1-2 事务。
-CLI 是 additive 的两个新子命令:
+CLI 是 additive 的三个新子命令:
```text
wavebench source basic-configure-v2 --channel N \
[--waveform sine|square|ramp|pulse|noise|dc] \
[--frequency-hz HZ] [--amplitude-vpp VPP] [--offset-v V] \
[--square-duty-cycle-percent PERCENT]
+wavebench source basic-live-configure-v2 --channel N \
+ [--frequency-hz HZ | --amplitude-vpp VPP]
wavebench source output-v2 --channel N on|off
```
-`basic-configure-v2` 至少需要一个 basic 字段。普通模式直接输出 operation artifact;`--json` 将它置于
-`wavebench.cli.result.v1.result`。写后失败时,CLI 的 `wavebench.error.v1` 会附加脱敏的
-`source_operation_artifact`。两个新命令不改变既有 V1 CLI 参数或成功 JSON。
+`basic-configure-v2` 至少需要一个 basic 字段。`basic-live-configure-v2` 只接受一个 frequency 或
+Vpp 字段,且由 Service 证明输出为 ON、模式为 FIX。普通模式直接输出 operation artifact;`--json` 将它
+置于 `wavebench.cli.result.v1.result`。写后失败时,CLI 的 `wavebench.error.v1` 会附加脱敏的
+`source_operation_artifact`。三个新命令不改变既有 V1 CLI 参数或成功 JSON。
-run plan 使用三个有方向 step,避免在 intent 中把 ON/OFF 混为同一 operation:
+run plan 使用四个有方向 step,避免在 intent 中把 ON/OFF 混为同一 operation:
| step kind | 必填字段 | 允许的额外字段 | 对应 operation |
| --- | --- | --- | --- |
| `source.basic_configure_v2` | `channel` | `waveform_kind`、`frequency_hz`、`amplitude_vpp`、`offset_v`、`square_duty_cycle_percent`;至少一个 | `source.basic_configure_v2` |
+| `source.basic_live_configure_v2` | `channel` | `frequency_hz` 或 `amplitude_vpp`;恰好一个 | `source.basic_live_configure_v2` |
| `source.output_enable_v2` | `channel` | 无 | `source.output_enable_v2` |
| `source.output_disable_v2` | `channel` | 无 | `source.output_disable_v2` |
@@ -3333,7 +3349,8 @@ run plan 使用三个有方向 step,避免在 intent 中把 ON/OFF 混为同
operation artifact 同时保存到 step 的 `artifact.source_operation` 和非空根键
`run.json.source_operations`;写后失败带来的 artifact 也会保存。没有 V2 step 的 V1 run 保持没有该根键。
-双合同插件按当前已注册 V2 contract 路由,不混用 V1 状态视图来做 V2 安全决策:
+`v1_route_migration_enabled=true` 的双合同插件按当前已注册 V2 contract 路由,不混用 V1 状态视图
+来做 V2 安全决策:
| 已声明 V2 capability | V1 route | M5-D 行为 |
| --- | --- | --- |
@@ -3343,8 +3360,10 @@ operation artifact 同时保存到 step 的 `artifact.source_operation` 和非
| `source.output_v2` | `trigger_burst`、`trigger_sweep` | 属于可能发信号的重叠 route,在仪器 I/O 前拒绝。 |
| 当前两个写 capability 均未覆盖 | `configure_coupling`、`configure_harmonics`、AM/FM/PM/PWM、`configure_pulse`、`configure_burst`、`configure_sweep` | 保持 V1 路径,等待对应 feature 的 V2 capability。 |
-V1-only 插件继续使用原 V1 route。双合同 V1 setter 的返回值仅为兼容显示而从 V2 postcondition
-flatten 为 `SourceStatus`;该 adapter 不参与 V2 preflight、预算、恢复或 capability 决策。
+设为 `false` 的双合同插件保留上述所有 V1 route;只有显式 V2 Service、CLI 或 run step 调用 V2
+transaction。该选择适用于 V1 composite transaction 无法由当前的窄 V2 operation 等价表示的设备。
+V1-only 插件继续使用原 V1 route。默认迁移的双合同 V1 setter 返回值仅为兼容显示而从 V2
+postcondition flatten 为 `SourceStatus`;该 adapter 不参与 V2 preflight、预算、恢复或 capability 决策。
### C2 核心兼容与候选发布门
@@ -3692,7 +3711,7 @@ selected waveform ID、内容是否能由设备读回验证、以及旧 volatile
可恢复旧内容。二进制写一旦尝试且后续失败,Core 只可尝试一次输出 OFF 收敛;旧内容
保持 `unrecoverable`,不得重传或 rollback。
-Counter 按副作用拆开,而不是继续沿用 V1 的“完整 profile 一次设置”模型:
+Counter 按副作用拆开,而不是继续沿用 V1 的「完整 profile 一次设置」模型:
- `source.counter_configure_v2` 只允许一个显式字段:AC/DC coupling、输入阻抗、衰减、
trigger level 或 statistics enable。每个字段均需独立回读;不会暗中写 50 Ω、默认
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index fd5abc5..c645dde 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -279,7 +279,11 @@ def _json_payload(value: object) -> object:
return value
-def _source_basic_configure_v2_request(args: argparse.Namespace):
+def _source_basic_configure_v2_request(
+ args: argparse.Namespace,
+ *,
+ command: str = "basic-configure-v2",
+):
from .instruments.source_extensions import (
PatchAction,
PatchValue,
@@ -289,18 +293,18 @@ def _source_basic_configure_v2_request(args: argparse.Namespace):
)
values = {
- "waveform_kind": args.waveform,
- "frequency_hz": args.frequency_hz,
- "amplitude_vpp": args.amplitude_vpp,
- "offset_v": args.offset_v,
- "square_duty_cycle_percent": args.square_duty_cycle_percent,
+ "waveform_kind": getattr(args, "waveform", None),
+ "frequency_hz": getattr(args, "frequency_hz", None),
+ "amplitude_vpp": getattr(args, "amplitude_vpp", None),
+ "offset_v": getattr(args, "offset_v", None),
+ "square_duty_cycle_percent": getattr(args, "square_duty_cycle_percent", None),
}
if all(value is None for value in values.values()):
- raise ConfigError("source basic-configure-v2 requires at least one basic field")
+ raise ConfigError(f"source {command} requires at least one basic field")
waveform = (
- PatchValue(PatchAction.SET, SourceWaveformKind(args.waveform))
- if args.waveform is not None
+ PatchValue(PatchAction.SET, SourceWaveformKind(values["waveform_kind"]))
+ if values["waveform_kind"] is not None
else PatchValue(PatchAction.KEEP)
)
@@ -315,10 +319,10 @@ def patch_value(value: object):
channel=args.channel,
patch=SourceBasicPatch(
waveform_kind=waveform,
- frequency_hz=patch_value(args.frequency_hz),
- amplitude_vpp=patch_value(args.amplitude_vpp),
- offset_v=patch_value(args.offset_v),
- square_duty_cycle_percent=patch_value(args.square_duty_cycle_percent),
+ frequency_hz=patch_value(values["frequency_hz"]),
+ amplitude_vpp=patch_value(values["amplitude_vpp"]),
+ offset_v=patch_value(values["offset_v"]),
+ square_duty_cycle_percent=patch_value(values["square_duty_cycle_percent"]),
),
)
@@ -1404,6 +1408,18 @@ def _main(argv: list[str] | None = None) -> int:
else:
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
+ if args.command == "basic-live-configure-v2":
+ _, payload = service.configure_basic_live_v2(
+ _source_basic_configure_v2_request(
+ args,
+ command="basic-live-configure-v2",
+ )
+ )
+ if args.json:
+ _emit_json_result(payload)
+ else:
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
+ return 0
if args.command == "output-v2":
from wavebench.instruments.source_extensions import SourceOutputRequest
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index fcc59a8..e1a5ed6 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -842,6 +842,15 @@ def build_parser() -> argparse.ArgumentParser:
)
add_runtime_options(source_basic_configure_v2)
+ source_basic_live_configure_v2 = source_sub.add_parser(
+ "basic-live-configure-v2",
+ help="Change one enabled Source V2 channel frequency or Vpp without output cycling",
+ )
+ source_basic_live_configure_v2.add_argument("--channel", type=int, required=True)
+ source_basic_live_configure_v2.add_argument("--frequency-hz", type=float, default=None)
+ source_basic_live_configure_v2.add_argument("--amplitude-vpp", type=float, default=None)
+ add_runtime_options(source_basic_live_configure_v2)
+
source_output_v2 = source_sub.add_parser(
"output-v2",
help="Turn one Source V2 channel output on or off",
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index 861cbf3..a40fe02 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -5398,6 +5398,7 @@ class SourceDescriptorExtensions:
features: tuple[SourceFeatureCapability, ...]
query_contract: SourceQueryContract
safety_profile: SourceSafetyProfile = SourceSafetyProfile()
+ v1_route_migration_enabled: bool = True
def __post_init__(self) -> None:
if self.contract_version != SOURCE_CONTRACT_VERSION:
@@ -5448,6 +5449,10 @@ def __post_init__(self) -> None:
raise ValueError("source descriptor query_contract has an invalid type")
if not isinstance(self.safety_profile, SourceSafetyProfile):
raise ValueError("source descriptor safety_profile has an invalid type")
+ _require_bool(
+ self.v1_route_migration_enabled,
+ "source descriptor v1_route_migration_enabled",
+ )
class SnapshotConsistencyState(StrEnum):
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 4fdbd42..e524c5c 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -41,6 +41,7 @@
"source.set_duty",
"source.output",
"source.basic_configure_v2",
+ "source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
"source.harmonics_configure_v2",
@@ -104,6 +105,7 @@
"rf_source.output_enable": ("port_id",),
"rf_source.output_disable": ("port_id",),
"source.basic_configure_v2": ("channel",),
+ "source.basic_live_configure_v2": ("channel",),
"source.output_enable_v2": ("channel",),
"source.output_disable_v2": ("channel",),
"source.harmonics_configure_v2": ("channel", "order", "preset"),
@@ -244,6 +246,11 @@
"square_duty_cycle_percent",
"on_failure",
},
+ "source.basic_live_configure_v2": {
+ "frequency_hz",
+ "amplitude_vpp",
+ "on_failure",
+ },
"source.output_enable_v2": {"on_failure"},
"source.output_disable_v2": {"on_failure"},
"source.harmonics_configure_v2": {"on_failure"},
@@ -306,6 +313,7 @@
"source.set_duty": "Set square-wave duty cycle in percent; valid range is 0 < duty_percent < 100.",
"source.output": "Turn source channel output on or off.",
"source.basic_configure_v2": "Configure one Source V2 channel while its output is OFF. At least one basic field is required.",
+ "source.basic_live_configure_v2": "Change exactly one declared frequency or Vpp field while one Source V2 channel remains enabled.",
"source.output_enable_v2": "Turn one Source V2 channel output on after a fresh V2 readback.",
"source.output_disable_v2": "Turn one Source V2 channel output off without requiring Vpp or offset readback.",
"source.harmonics_configure_v2": "Configure one OFF Source V2 channel with a declared Harmonic preset; it does not enable output.",
@@ -841,6 +849,18 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
if not 0 <= duty <= 100:
raise ConfigError(f"{prefix}.square_duty_cycle_percent must be in [0, 100]")
fields["square_duty_cycle_percent"] = duty
+ elif kind == "source.basic_live_configure_v2":
+ live_fields = {"frequency_hz", "amplitude_vpp"}
+ selected = live_fields & fields.keys()
+ if len(selected) != 1:
+ raise ConfigError(
+ f"{prefix} source.basic_live_configure_v2 requires exactly one frequency_hz or amplitude_vpp"
+ )
+ field = next(iter(selected))
+ value = _finite_float(fields[field], f"{prefix}.{field}")
+ if value < 0:
+ raise ConfigError(f"{prefix}.{field} must be >= 0")
+ fields[field] = value
elif kind == "source.harmonics_configure_v2":
order = fields["order"]
if isinstance(order, bool) or not isinstance(order, int):
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index ad9e96a..f7315df 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -41,6 +41,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) ->
"source.set_duty",
"source.output",
"source.basic_configure_v2",
+ "source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
"source.harmonics_configure_v2",
@@ -73,7 +74,10 @@ def check_run_plan_safety_limits(plan: RunPlan, limits: SafetyLimitsConfig) -> N
config_key="max_source_vpp",
unit="Vpp",
)
- elif step.kind == "source.basic_configure_v2" and "amplitude_vpp" in step.fields:
+ elif step.kind in {
+ "source.basic_configure_v2",
+ "source.basic_live_configure_v2",
+ } and "amplitude_vpp" in step.fields:
_check_limit(
step.fields["amplitude_vpp"],
limits.max_source_vpp,
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index 26091a2..9068479 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -538,6 +538,14 @@ def add_source_restore_capabilities() -> None:
add("source", "source.status")
elif step.kind == "source.basic_configure_v2":
add("source", "source.snapshot_v2", "source.basic_configure_v2")
+ elif step.kind == "source.basic_live_configure_v2":
+ add(
+ "source",
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.basic_live_configure_v2",
+ "source.output_v2",
+ )
elif step.kind in {"source.output_enable_v2", "source.output_disable_v2"}:
add("source", "source.snapshot_v2", "source.output_v2")
elif step.kind == "source.harmonics_configure_v2":
@@ -1385,9 +1393,15 @@ def _run_step(
)
)
artifact = {"rf_source_operation": rf_source_operation}
- elif step.kind == "source.basic_configure_v2":
+ elif step.kind in {"source.basic_configure_v2", "source.basic_live_configure_v2"}:
fields = step.fields
- _, source_operation = self._source_service(services=services).configure_basic_v2(
+ source_service = self._source_service(services=services)
+ configure = (
+ source_service.configure_basic_live_v2
+ if step.kind == "source.basic_live_configure_v2"
+ else source_service.configure_basic_v2
+ )
+ _, source_operation = configure(
SourceBasicConfigureRequest(
channel=fields["channel"],
patch=SourceBasicPatch(
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 1c15b2b..de0b9c3 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -481,9 +481,26 @@ def _declared_source_capabilities(self) -> tuple[str, ...]:
def _declares_source_v2_capability(self, capability: str) -> bool:
return capability in self._declared_source_capabilities()
+ def _maps_v1_routes_to_source_v2(self, capability: str) -> bool:
+ """Return whether a declared V2 capability owns its legacy V1 routes."""
+
+ self._declared_source_capabilities()
+ extensions = None if self.descriptor is None else getattr(
+ self.descriptor,
+ "source_extensions",
+ None,
+ )
+ return (
+ self._declares_source_v2_capability(capability)
+ and (
+ not isinstance(extensions, SourceDescriptorExtensions)
+ or extensions.v1_route_migration_enabled
+ )
+ )
+
def _declares_source_v2_basic_restore(self) -> bool:
capabilities = set(self._declared_source_capabilities())
- return {
+ return self._maps_v1_routes_to_source_v2("source.basic_configure_v2") and {
"source.snapshot_v2",
"source.basic_configure_v2",
"source.output_v2",
@@ -10061,13 +10078,15 @@ def configure_burst(
def trigger_burst(self, channel: int | None = None) -> None:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.burst_fire_v2"):
+ if self._maps_v1_routes_to_source_v2("source.burst_fire_v2"):
self.fire_burst_v2(SourceFireRequest(channel=channel))
return
+ overlapping = ["source.burst_configure_v2"]
+ if self._maps_v1_routes_to_source_v2("source.output_v2"):
+ overlapping.append("source.output_v2")
self._reject_v1_route_for_source_v2(
SourceV1WriteRouteId.TRIGGER_BURST,
- "source.output_v2",
- "source.burst_configure_v2",
+ *overlapping,
)
required = ["source.burst_trigger"]
if source_cfg.check_errors:
@@ -10130,13 +10149,15 @@ def configure_sweep(
def trigger_sweep(self, channel: int | None = None) -> None:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.sweep_fire_v2"):
+ if self._maps_v1_routes_to_source_v2("source.sweep_fire_v2"):
self.fire_sweep_v2(SourceFireRequest(channel=channel))
return
+ overlapping = ["source.sweep_configure_v2"]
+ if self._maps_v1_routes_to_source_v2("source.output_v2"):
+ overlapping.append("source.output_v2")
self._reject_v1_route_for_source_v2(
SourceV1WriteRouteId.TRIGGER_SWEEP,
- "source.output_v2",
- "source.sweep_configure_v2",
+ *overlapping,
)
required = ["source.sweep_trigger"]
if source_cfg.check_errors:
@@ -10229,9 +10250,7 @@ def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus
status = self._source_status_from_v2_snapshot(final_snapshot, state.channel)
self._state_guard_after_write(status)
return status
- self._reject_v1_route_for_source_v2(
- SourceV1WriteRouteId.RESTORE,
- "source.basic_configure_v2",
+ overlapping = [
"source.harmonics_configure_v2",
"source.harmonics_disable_v2",
"source.modulation_configure_v2",
@@ -10245,8 +10264,12 @@ def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus
"source.coupling_configure_v2",
"source.tracking_configure_v2",
"source.phase_relation_configure_v2",
- "source.output_v2",
- )
+ ]
+ if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"):
+ overlapping.append("source.basic_configure_v2")
+ if self._maps_v1_routes_to_source_v2("source.output_v2"):
+ overlapping.append("source.output_v2")
+ self._reject_v1_route_for_source_v2(SourceV1WriteRouteId.RESTORE, *overlapping)
self.set_output(channel=state.channel, enabled=False)
self.set_function(channel=state.channel, function=state.function)
self.set_amplitude_vpp(channel=state.channel, value_vpp=state.amplitude_vpp)
@@ -10261,14 +10284,14 @@ def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus
def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.basic_configure_v2"):
+ if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"):
request = SourceBasicConfigureRequest(
channel=channel,
patch=SourceBasicPatch(
frequency_hz=PatchValue(PatchAction.SET, value_hz),
),
)
- if self._declares_source_v2_capability("source.basic_live_configure_v2"):
+ if self._maps_v1_routes_to_source_v2("source.basic_live_configure_v2"):
try:
transaction = self._configure_basic_live_v2_transaction(request)
except _SourceV2BasicRequiresOffMutation:
@@ -10305,7 +10328,7 @@ def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus:
def set_output(self, channel: int | None, enabled: bool) -> SourceStatus:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.output_v2"):
+ if self._maps_v1_routes_to_source_v2("source.output_v2"):
transaction = self._set_output_v2_transaction(
SourceOutputRequest(channel=channel, enabled=enabled),
)
@@ -10379,7 +10402,7 @@ def _set_function_v1(
def set_function(self, channel: int | None, function: str) -> SourceStatus:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.basic_configure_v2"):
+ if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"):
waveform = self._source_v2_waveform_from_v1(function)
if self._source_v2_basic_declares_waveform(
channel=channel,
@@ -10412,7 +10435,7 @@ def set_function(self, channel: int | None, function: str) -> SourceStatus:
def set_square_duty_cycle(self, channel: int | None, duty_percent: float) -> SourceStatus:
source_cfg = self._source_config()
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.basic_configure_v2"):
+ if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"):
transaction = self._configure_basic_v2_transaction(
SourceBasicConfigureRequest(
channel=channel,
@@ -10442,14 +10465,14 @@ def set_amplitude_vpp(self, channel: int | None, value_vpp: float) -> SourceStat
source_cfg = self._source_config()
self._check_source_vpp(value_vpp, field="source amplitude / 信号源幅度")
channel = source_cfg.default_channel if channel is None else channel
- if self._declares_source_v2_capability("source.basic_configure_v2"):
+ if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"):
request = SourceBasicConfigureRequest(
channel=channel,
patch=SourceBasicPatch(
amplitude_vpp=PatchValue(PatchAction.SET, value_vpp),
),
)
- if self._declares_source_v2_capability("source.basic_live_configure_v2"):
+ if self._maps_v1_routes_to_source_v2("source.basic_live_configure_v2"):
try:
transaction = self._configure_basic_live_v2_transaction(request)
except _SourceV2BasicRequiresOffMutation:
@@ -10488,13 +10511,18 @@ def upload_arbitrary_waveform(
output_on: bool = False,
) -> SourceStatus:
source_cfg = self._source_config()
- self._reject_v1_route_for_source_v2(
- SourceV1WriteRouteId.UPLOAD_ARBITRARY,
- "source.basic_configure_v2",
- "source.output_v2",
+ overlapping = [
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
"source.arbitrary_volatile_replace_v2",
+ ]
+ if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"):
+ overlapping.append("source.basic_configure_v2")
+ if self._maps_v1_routes_to_source_v2("source.output_v2"):
+ overlapping.append("source.output_v2")
+ self._reject_v1_route_for_source_v2(
+ SourceV1WriteRouteId.UPLOAD_ARBITRARY,
+ *overlapping,
)
self._require_finite(
playback_frequency_hz,
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 2d57f9b..1a61e11 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -648,6 +648,16 @@ def test_source_v2_commands_accept_explicit_channels(self):
"1.5",
]
)
+ live = build_parser().parse_args(
+ [
+ "source",
+ "basic-live-configure-v2",
+ "--channel",
+ "2",
+ "--frequency-hz",
+ "2000",
+ ]
+ )
output = build_parser().parse_args(["source", "output-v2", "--channel", "2", "on"])
harmonics = build_parser().parse_args(
[
@@ -768,6 +778,10 @@ def test_source_v2_commands_accept_explicit_channels(self):
self.assertEqual(basic.waveform, "square")
self.assertEqual(basic.frequency_hz, 1000.0)
self.assertEqual(basic.amplitude_vpp, 1.5)
+ self.assertEqual(live.command, "basic-live-configure-v2")
+ self.assertEqual(live.channel, 2)
+ self.assertEqual(live.frequency_hz, 2000.0)
+ self.assertIsNone(live.amplitude_vpp)
self.assertEqual(output.command, "output-v2")
self.assertEqual(output.channel, 2)
self.assertEqual(output.state, "on")
@@ -814,6 +828,33 @@ def test_source_v2_commands_accept_explicit_channels(self):
self.assertEqual(burst.internal_period_s, 0.25)
self.assertEqual(burst.delay_s, 0.5)
+ def test_source_basic_live_configure_v2_dispatches_typed_request(self):
+ payload = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.basic_live_configure_v2",
+ }
+ service = Mock()
+ service.configure_basic_live_v2.return_value = (object(), payload)
+ stdout = io.StringIO()
+
+ with patch("wavebench.cli._load_source_service", return_value=service), redirect_stdout(stdout):
+ code = main(
+ [
+ "source",
+ "basic-live-configure-v2",
+ "--channel",
+ "2",
+ "--amplitude-vpp",
+ "1.5",
+ ]
+ )
+
+ self.assertEqual(code, 0)
+ request = service.configure_basic_live_v2.call_args.args[0]
+ self.assertEqual(request.channel, 2)
+ self.assertEqual(request.patch.amplitude_vpp.value, 1.5)
+ self.assertEqual(json.loads(stdout.getvalue()), payload)
+
def test_source_harmonics_disable_v2_dispatches_typed_request(self):
payload = {
"schema": "wavebench.source.operation.v1",
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index 83d69d6..c880e8d 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -584,6 +584,37 @@ def test_source_v2_steps_validate_explicit_channels_and_closed_basic_patch(self)
with self.assertRaisesRegex(ConfigError, "leading_transition_s must be <="):
load_run_plan(oversized_transition)
+ def test_source_basic_live_v2_step_requires_one_live_field(self):
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "source.basic_live_configure_v2"
+channel = 2
+frequency_hz = 2000
+"""))
+
+ self.assertEqual(
+ plan.steps[0].fields,
+ {"channel": 2, "frequency_hz": 2000.0},
+ )
+
+ no_field = self._write_plan("""
+[[steps]]
+kind = "source.basic_live_configure_v2"
+channel = 2
+""")
+ with self.assertRaisesRegex(ConfigError, "requires exactly one"):
+ load_run_plan(no_field)
+
+ two_fields = self._write_plan("""
+[[steps]]
+kind = "source.basic_live_configure_v2"
+channel = 2
+frequency_hz = 2000
+amplitude_vpp = 1.5
+""")
+ with self.assertRaisesRegex(ConfigError, "requires exactly one"):
+ load_run_plan(two_fields)
+
def test_source_v2_harmonic_disable_step_accepts_only_channel(self):
plan = load_run_plan(self._write_plan("""
[[steps]]
@@ -607,6 +638,7 @@ def test_format_run_plan_schema_lists_expect_and_power_output(self):
self.assertIn("power.output", text)
self.assertIn("source.arb_load", text)
self.assertIn("source.basic_configure_v2", text)
+ self.assertIn("source.basic_live_configure_v2", text)
self.assertIn("source.output_enable_v2", text)
self.assertIn("source.harmonics_configure_v2", text)
self.assertIn("source.harmonics_disable_v2", text)
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 81716e3..9f6d6cb 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -470,6 +470,38 @@ def test_check_requires_source_v2_capability_before_opening_session(self):
open_services.assert_not_called()
+ def test_check_requires_source_basic_live_v2_capability_before_opening_session(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "source.basic_live_configure_v2"
+channel = 1
+frequency_hz = 1000
+""",
+ )
+ )
+ descriptor = SimpleNamespace(
+ driver_id="minimal.source-v2",
+ capabilities=(
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.output_v2",
+ ),
+ )
+ service = RunService(config=make_config(tmp), logger=CommandLogger())
+
+ with patch(
+ "wavebench.services.run_service.resolve_instrument_descriptor",
+ return_value=descriptor,
+ ), patch.object(service, "_run_instrument_services") as open_services:
+ with self.assertRaisesRegex(ConfigError, "source.basic_live_configure_v2"):
+ service.run(plan)
+
+ open_services.assert_not_called()
+
def test_check_requires_source_v2_harmonic_capability_before_opening_session(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
@@ -1865,6 +1897,47 @@ def _run_safety_guards(self, plan, *, services=None):
run_data = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
self.assertEqual(run_data["source_operations"], [artifact])
+ def test_runs_source_basic_live_v2_step_and_writes_operation_artifact(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "source.basic_live_configure_v2"
+channel = 1
+frequency_hz = 2000
+""",
+ )
+ )
+ artifact = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.basic_live_configure_v2",
+ }
+ source = Mock()
+ source.configure_basic_live_v2.return_value = (SimpleNamespace(), artifact)
+
+ class OfflineV2RunService(RunService):
+ def check(self, plan):
+ del plan
+
+ @contextmanager
+ def _run_instrument_services(self, plan):
+ del plan
+ yield RunInstrumentServices(source=source)
+
+ def _run_safety_guards(self, plan, *, services=None):
+ del plan, services
+
+ result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+ run_data = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+
+ request = source.configure_basic_live_v2.call_args.args[0]
+ self.assertEqual(request.channel, 1)
+ self.assertEqual(request.patch.frequency_hz.value, 2000.0)
+ self.assertEqual(run_data["source_operations"], [artifact])
+ self.assertEqual(result.steps[0].artifact["source_operation"], artifact)
+
def test_restores_source_state_after_success_when_enabled(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py
index 9e1ead7..5191540 100644
--- a/tests/test_source_basic_configure_v2.py
+++ b/tests/test_source_basic_configure_v2.py
@@ -301,6 +301,93 @@ def forbidden_direct_route(*args: object, **kwargs: object) -> object:
raise AttributeError(name)
+class _AdditiveV2Driver(_BasicWriteDriver):
+ """Expose V2 explicitly while retaining the complete legacy V1 routes."""
+
+ def __init__(self, **kwargs: object) -> None:
+ super().__init__(**kwargs) # type: ignore[arg-type]
+ self.v1_frequency_requests: list[tuple[int, float, bool, bool]] = []
+ self.v1_function_requests: list[tuple[int, str, bool]] = []
+ self.v1_amplitude_requests: list[tuple[int, float, bool]] = []
+ self.v1_output_requests: list[tuple[int, bool, bool]] = []
+ self.v1_upload_calls = 0
+
+ def get_status(self, channel: int) -> SourceStatus:
+ return SourceStatus(
+ channel=channel,
+ output="ON" if self.output_enabled else "OFF",
+ function="SIN",
+ frequency_hz=1_000.0,
+ amplitude=1.0,
+ amplitude_unit="VPP",
+ offset_v=0.0,
+ phase_deg=0.0,
+ frequency_mode="FIX",
+ sweep_enabled="OFF",
+ apply_raw=None,
+ square_duty_cycle_percent=50.0,
+ )
+
+ def set_frequency(
+ self,
+ channel: int,
+ value_hz: float,
+ *,
+ ensure_fix_mode: bool,
+ check_errors: bool,
+ ) -> SourceStatus:
+ self.v1_frequency_requests.append((channel, value_hz, ensure_fix_mode, check_errors))
+ return replace(self.get_status(channel), frequency_hz=value_hz)
+
+ def set_function(
+ self,
+ channel: int,
+ function: str,
+ *,
+ check_errors: bool,
+ ) -> SourceStatus:
+ self.v1_function_requests.append((channel, function, check_errors))
+ return replace(self.get_status(channel), function=function.strip().upper())
+
+ def set_amplitude_vpp(
+ self,
+ channel: int,
+ value_vpp: float,
+ *,
+ check_errors: bool,
+ ) -> SourceStatus:
+ self.v1_amplitude_requests.append((channel, value_vpp, check_errors))
+ return replace(self.get_status(channel), amplitude=value_vpp)
+
+ def set_output(
+ self,
+ channel: int,
+ enabled: bool,
+ *,
+ check_errors: bool,
+ ) -> SourceStatus:
+ self.v1_output_requests.append((channel, enabled, check_errors))
+ self.output_enabled = enabled
+ return self.get_status(channel)
+
+ def upload_dg4000_dac14_block(self, **kwargs: object) -> SourceStatus:
+ self.v1_upload_calls += 1
+ return SourceStatus(
+ channel=kwargs["channel"], # type: ignore[arg-type]
+ output="ON" if kwargs["output_on"] else "OFF",
+ function="USER",
+ frequency_hz=kwargs["playback_frequency_hz"], # type: ignore[arg-type]
+ amplitude=kwargs["amplitude_vpp"], # type: ignore[arg-type]
+ amplitude_unit="VPP",
+ offset_v=kwargs["offset_v"], # type: ignore[arg-type]
+ phase_deg=0.0,
+ frequency_mode="FIX",
+ sweep_enabled="OFF",
+ apply_raw=None,
+ square_duty_cycle_percent=None,
+ )
+
+
class _LegacyWaveformFallbackDriver(_BasicWriteDriver):
"""V1 function support retained outside a narrower V2 basic profile."""
@@ -534,6 +621,52 @@ def _dual_contract_service() -> tuple[SourceService, _DualContractDriver]:
)
+def _additive_v2_service() -> tuple[SourceService, _AdditiveV2Driver]:
+ session_state = InstrumentSessionState(epoch_id="source-additive-v2")
+ driver = _AdditiveV2Driver(session_state=session_state, combined=True)
+ descriptor = replace(
+ source_descriptor(
+ driver=driver,
+ extensions=replace(
+ _write_extensions(
+ include_output=True,
+ live_frequency=True,
+ live_amplitude_vpp=True,
+ ),
+ v1_route_migration_enabled=False,
+ ),
+ ),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.basic_configure_v2",
+ "source.basic_live_configure_v2",
+ "source.output_v2",
+ "source.status",
+ "source.set_frequency",
+ "source.set_function",
+ "source.set_amplitude_vpp",
+ "source.output",
+ "source.arbitrary_upload",
+ ),
+ )
+ validate_source_descriptor(descriptor)
+ validate_declared_capabilities(descriptor, driver)
+ config = _config()
+ assert config.source is not None
+ config = replace(config, source=replace(config.source, check_errors=False))
+ return (
+ SourceService(
+ config=config,
+ logger=CommandLogger(),
+ session=driver, # type: ignore[arg-type]
+ descriptor=descriptor,
+ transport=driver.transport,
+ session_state=session_state,
+ ),
+ driver,
+ )
+
+
def _frequency_request(value_hz: float = 2_000.0) -> SourceBasicConfigureRequest:
return SourceBasicConfigureRequest(
channel=1,
@@ -985,6 +1118,82 @@ def test_v1_restore_route_rejects_partial_v2_restore_before_io() -> None:
assert driver.transport.counters.write_requests == 0
+def test_additive_v2_keeps_legacy_routes_and_exposes_explicit_v2(tmp_path: Path) -> None:
+ service, driver = _additive_v2_service()
+
+ status = service.set_frequency(channel=1, value_hz=2_000.0)
+
+ assert status.frequency_hz == 2_000.0
+ assert driver.v1_frequency_requests == [(1, 2_000.0, True, False)]
+ assert driver.basic_requests == []
+ assert service._declares_source_v2_basic_restore() is False
+
+ result, _ = service.configure_basic_v2(_frequency_request(3_000.0))
+
+ assert result.basic.frequency_hz.value == 3_000.0
+ assert driver.basic_requests == [_frequency_request(3_000.0)]
+
+ output = service.set_output(channel=1, enabled=True)
+
+ assert output.output == "ON"
+ assert driver.v1_output_requests == [(1, True, False)]
+ assert driver.output_requests == []
+
+ service.set_output(channel=1, enabled=False)
+ waveform = tmp_path / "waveform.csv"
+ waveform.write_text("0\n1\n", encoding="utf-8")
+ uploaded = service.upload_arbitrary_waveform(
+ channel=1,
+ file_path=str(waveform),
+ playback_frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ )
+
+ assert uploaded.function == "USER"
+ assert driver.v1_upload_calls == 1
+
+ restored = service.restore_restorable_state(
+ RestorableSourceState(
+ channel=1,
+ output="ON",
+ function="SIN",
+ frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ amplitude_unit="VPP",
+ )
+ )
+
+ assert restored.output == "ON"
+ assert driver.v1_function_requests == [(1, "SIN", False)]
+ assert driver.v1_amplitude_requests == [(1, 1.0, False)]
+ assert driver.v1_frequency_requests[-1] == (1, 1_000.0, True, False)
+ assert driver.v1_output_requests[-2:] == [(1, False, False), (1, True, False)]
+ assert driver.output_requests == []
+
+
+def test_migrating_v2_keeps_v1_arbitrary_upload_rejected_before_file_load() -> None:
+ service, driver = _additive_v2_service()
+ assert service.descriptor is not None
+ assert service.descriptor.source_extensions is not None
+ service.descriptor = replace(
+ service.descriptor,
+ source_extensions=replace(
+ service.descriptor.source_extensions,
+ v1_route_migration_enabled=True,
+ ),
+ )
+
+ with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"):
+ service.upload_arbitrary_waveform(
+ channel=1,
+ file_path="does-not-exist.csv",
+ playback_frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ )
+
+ assert driver.v1_upload_calls == 0
+
+
@pytest.mark.parametrize(
"operation",
("upload", "trigger_burst", "trigger_sweep"),
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index e583d67..bcf9eeb 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -220,6 +220,14 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"display_load_readable",
"polarity_readable",
),
+ "SourceDescriptorExtensions": (
+ "contract_version",
+ "topology",
+ "features",
+ "query_contract",
+ "safety_profile",
+ "v1_route_migration_enabled",
+ ),
"SourceNoiseOverlayCapabilityProfile": (
"enabled_readable",
"scale_kinds",
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index e1ab871..b569221 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -75,6 +75,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
}
expected_v2_run_steps = {
"source.basic_configure_v2",
+ "source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
"source.harmonics_configure_v2",
@@ -134,6 +135,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
with TemporaryDirectory() as tmp:
valid_steps = {
"source.basic_configure_v2": "channel = 1\nfrequency_hz = 1000\n",
+ "source.basic_live_configure_v2": "channel = 1\nfrequency_hz = 1000\n",
"source.output_enable_v2": "channel = 1\n",
"source.output_disable_v2": "channel = 1\n",
"source.harmonics_configure_v2": "channel = 1\norder = 8\npreset = \"odd\"\n",
From a03d42bdb930483df4572de6ff89baa4776d3223 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 01:30:40 +0800
Subject: [PATCH 31/44] feat(source): expose Counter V2 CLI and run steps
---
src/wavebench/cli.py | 77 ++++++++++
src/wavebench/cli_parser.py | 32 ++++
src/wavebench/services/run_plan.py | 57 +++++++
src/wavebench/services/run_safety.py | 4 +
src/wavebench/services/run_service.py | 79 ++++++++++
tests/test_cli.py | 111 ++++++++++++++
tests/test_run_plan.py | 56 +++++++
tests/test_run_service.py | 208 +++++++++++++++++++++++++-
tests/test_source_v1_routes.py | 8 +
9 files changed, 631 insertions(+), 1 deletion(-)
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index c645dde..e6649a6 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -327,6 +327,44 @@ def patch_value(value: object):
)
+def _source_counter_configure_v2_request(args: argparse.Namespace):
+ from .instruments.source_extensions import (
+ PatchAction,
+ PatchValue,
+ SourceCounterConfigurationPatch,
+ SourceCounterConfigureRequest,
+ SourceInputCoupling,
+ )
+
+ coupling = getattr(args, "coupling", None)
+ statistics_enabled = getattr(args, "statistics_enabled", None)
+
+ def patch_value(value: object):
+ return (
+ PatchValue(PatchAction.SET, value)
+ if value is not None
+ else PatchValue(PatchAction.KEEP)
+ )
+
+ try:
+ return SourceCounterConfigureRequest(
+ input_id=args.input_id,
+ patch=SourceCounterConfigurationPatch(
+ coupling=patch_value(
+ SourceInputCoupling(coupling) if coupling is not None else None
+ ),
+ impedance_ohm=patch_value(getattr(args, "impedance_ohm", None)),
+ attenuation=patch_value(getattr(args, "attenuation", None)),
+ trigger_level_v=patch_value(getattr(args, "trigger_level_v", None)),
+ statistics_enabled=patch_value(
+ statistics_enabled == "on" if statistics_enabled is not None else None
+ ),
+ ),
+ )
+ except ValueError as exc:
+ raise ConfigError(str(exc)) from exc
+
+
def _source_cross_channel_configure_v2_request(
args: argparse.Namespace,
request_type: type[object],
@@ -1434,6 +1472,45 @@ def _main(argv: list[str] | None = None) -> int:
else:
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
+ if args.command == "counter-configure-v2":
+ _, payload = service.configure_counter_v2(
+ _source_counter_configure_v2_request(args)
+ )
+ if args.json:
+ _emit_json_result(payload)
+ else:
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
+ return 0
+ if args.command in {"counter-enable-v2", "counter-disable-v2"}:
+ from wavebench.instruments.source_extensions import SourceCounterEnableRequest
+
+ _, payload = service.set_counter_enabled_v2(
+ SourceCounterEnableRequest(
+ input_id=args.input_id,
+ enabled=args.command == "counter-enable-v2",
+ )
+ )
+ if args.json:
+ _emit_json_result(payload)
+ else:
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
+ return 0
+ if args.command == "counter-measure-v2":
+ from wavebench.instruments.source_extensions import (
+ SourceCounterMeasureRequest,
+ source_v2_to_data,
+ )
+
+ payload = source_v2_to_data(
+ service.measure_counter_v2(
+ SourceCounterMeasureRequest(input_id=args.input_id)
+ )
+ )
+ if args.json:
+ _emit_json_result(payload)
+ else:
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
+ return 0
if args.command == "harmonics-configure-v2":
from wavebench.instruments.source_extensions import (
SourceHarmonicConfigureRequest,
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index e1a5ed6..2d16737 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -859,6 +859,38 @@ def build_parser() -> argparse.ArgumentParser:
source_output_v2.add_argument("state", choices=("on", "off"))
add_runtime_options(source_output_v2)
+ source_counter_configure_v2 = source_sub.add_parser(
+ "counter-configure-v2",
+ help="Configure exactly one declared Source V2 Counter field without enabling it",
+ )
+ source_counter_configure_v2.add_argument("--input-id", required=True)
+ source_counter_field = source_counter_configure_v2.add_mutually_exclusive_group(required=True)
+ source_counter_field.add_argument("--coupling", choices=("ac", "dc"))
+ source_counter_field.add_argument("--impedance-ohm", type=float)
+ source_counter_field.add_argument("--attenuation", type=int)
+ source_counter_field.add_argument("--trigger-level-v", type=float)
+ source_counter_field.add_argument("--statistics-enabled", choices=("on", "off"))
+ add_runtime_options(source_counter_configure_v2)
+
+ for command, enabled in (
+ ("counter-enable-v2", True),
+ ("counter-disable-v2", False),
+ ):
+ source_counter_output_v2 = source_sub.add_parser(
+ command,
+ help=("Enable" if enabled else "Disable")
+ + " one declared Source V2 Counter input",
+ )
+ source_counter_output_v2.add_argument("--input-id", required=True)
+ add_runtime_options(source_counter_output_v2)
+
+ source_counter_measure_v2 = source_sub.add_parser(
+ "counter-measure-v2",
+ help="Read one already-enabled declared Source V2 Counter input",
+ )
+ source_counter_measure_v2.add_argument("--input-id", required=True)
+ add_runtime_options(source_counter_measure_v2)
+
source_harmonics_configure_v2 = source_sub.add_parser(
"harmonics-configure-v2",
help="Configure one OFF Source V2 channel with a declared Harmonic preset",
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index e524c5c..6133dc0 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -44,6 +44,10 @@
"source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_disable_v2",
+ "source.counter_measure_v2",
"source.harmonics_configure_v2",
"source.harmonics_disable_v2",
"source.modulation_configure_v2",
@@ -108,6 +112,10 @@
"source.basic_live_configure_v2": ("channel",),
"source.output_enable_v2": ("channel",),
"source.output_disable_v2": ("channel",),
+ "source.counter_configure_v2": ("input_id",),
+ "source.counter_enable_v2": ("input_id",),
+ "source.counter_disable_v2": ("input_id",),
+ "source.counter_measure_v2": ("input_id",),
"source.harmonics_configure_v2": ("channel", "order", "preset"),
"source.harmonics_disable_v2": ("channel",),
"source.modulation_configure_v2": ("channel", "depth_percent", "internal_frequency_hz"),
@@ -253,6 +261,17 @@
},
"source.output_enable_v2": {"on_failure"},
"source.output_disable_v2": {"on_failure"},
+ "source.counter_configure_v2": {
+ "coupling",
+ "impedance_ohm",
+ "attenuation",
+ "trigger_level_v",
+ "statistics_enabled",
+ "on_failure",
+ },
+ "source.counter_enable_v2": {"on_failure"},
+ "source.counter_disable_v2": {"on_failure"},
+ "source.counter_measure_v2": {"on_failure"},
"source.harmonics_configure_v2": {"on_failure"},
"source.harmonics_disable_v2": {"on_failure"},
"source.modulation_configure_v2": {"on_failure"},
@@ -316,6 +335,10 @@
"source.basic_live_configure_v2": "Change exactly one declared frequency or Vpp field while one Source V2 channel remains enabled.",
"source.output_enable_v2": "Turn one Source V2 channel output on after a fresh V2 readback.",
"source.output_disable_v2": "Turn one Source V2 channel output off without requiring Vpp or offset readback.",
+ "source.counter_configure_v2": "Configure exactly one declared Source V2 Counter field without enabling the Counter.",
+ "source.counter_enable_v2": "Enable one declared Source V2 Counter input after a fresh V2 readback.",
+ "source.counter_disable_v2": "Disable one declared Source V2 Counter input without changing its configuration.",
+ "source.counter_measure_v2": "Read one already-enabled Source V2 Counter input without changing its configuration.",
"source.harmonics_configure_v2": "Configure one OFF Source V2 channel with a declared Harmonic preset; it does not enable output.",
"source.harmonics_disable_v2": "Disable Harmonic on one OFF Source V2 channel; it does not enable output.",
"source.modulation_configure_v2": "Configure one OFF Source V2 channel with internal sine AM; it does not enable output.",
@@ -861,6 +884,40 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
if value < 0:
raise ConfigError(f"{prefix}.{field} must be >= 0")
fields[field] = value
+ elif kind == "source.counter_configure_v2":
+ fields["input_id"] = _non_empty_str(fields["input_id"], f"{prefix}.input_id")
+ configurable = {
+ "coupling",
+ "impedance_ohm",
+ "attenuation",
+ "trigger_level_v",
+ "statistics_enabled",
+ }
+ selected = configurable & fields.keys()
+ if len(selected) != 1:
+ raise ConfigError(
+ f"{prefix} source.counter_configure_v2 requires exactly one Counter field"
+ )
+ field = next(iter(selected))
+ if field == "coupling":
+ coupling = _non_empty_str(fields[field], f"{prefix}.{field}").lower()
+ if coupling not in {"ac", "dc"}:
+ raise ConfigError(f"{prefix}.{field} must be 'ac' or 'dc'")
+ fields[field] = coupling
+ elif field == "impedance_ohm":
+ fields[field] = _positive_float(fields[field], f"{prefix}.{field}")
+ elif field == "attenuation":
+ fields[field] = _positive_int(fields[field], f"{prefix}.{field}")
+ elif field == "trigger_level_v":
+ fields[field] = _finite_float(fields[field], f"{prefix}.{field}")
+ elif not isinstance(fields[field], bool):
+ raise ConfigError(f"{prefix}.{field} must be true or false")
+ elif kind in {
+ "source.counter_enable_v2",
+ "source.counter_disable_v2",
+ "source.counter_measure_v2",
+ }:
+ fields["input_id"] = _non_empty_str(fields["input_id"], f"{prefix}.input_id")
elif kind == "source.harmonics_configure_v2":
order = fields["order"]
if isinstance(order, bool) or not isinstance(order, int):
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index f7315df..bbe4c67 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -44,6 +44,10 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) ->
"source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_disable_v2",
+ "source.counter_measure_v2",
"source.harmonics_configure_v2",
"source.harmonics_disable_v2",
"source.modulation_configure_v2",
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index 9068479..f0a3b12 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -45,11 +45,16 @@
SourceBasicPatch,
SourceBurstConfigureRequest,
SourceCombineConfigureRequest,
+ SourceCounterConfigurationPatch,
+ SourceCounterConfigureRequest,
+ SourceCounterEnableRequest,
+ SourceCounterMeasureRequest,
SourceCouplingConfigureRequest,
SourceFmModulationConfigureRequest,
SourceHarmonicConfigureRequest,
SourceHarmonicDisableRequest,
SourceHarmonicPreset,
+ SourceInputCoupling,
SourceModulationConfigureRequest,
SourceOutputRequest,
SourcePhaseRelationConfigureRequest,
@@ -61,6 +66,7 @@
SourceTrackingConfigureRequest,
SourceWaveformKind,
SourceStorageWriteMode,
+ source_v2_to_data,
)
from wavebench.logging import CommandLogger
from wavebench.services.power_service import PowerService
@@ -548,6 +554,12 @@ def add_source_restore_capabilities() -> None:
)
elif step.kind in {"source.output_enable_v2", "source.output_disable_v2"}:
add("source", "source.snapshot_v2", "source.output_v2")
+ elif step.kind == "source.counter_configure_v2":
+ add("source", "source.snapshot_v2", "source.counter_configure_v2")
+ elif step.kind in {"source.counter_enable_v2", "source.counter_disable_v2"}:
+ add("source", "source.snapshot_v2", "source.counter_enable_v2")
+ elif step.kind == "source.counter_measure_v2":
+ add("source", "source.snapshot_v2", "source.counter_measure_v2")
elif step.kind == "source.harmonics_configure_v2":
add("source", "source.snapshot_v2", "source.harmonics_configure_v2")
elif step.kind == "source.harmonics_disable_v2":
@@ -793,6 +805,22 @@ def report_close_errors() -> None:
)
},
)
+ except WaveBenchError as exc:
+ if not self._safety_gate_for_step(plan, step)["enabled"]:
+ raise
+ step_failure = exc
+ record = RunStepRecord(
+ index=step.index,
+ kind=step.kind,
+ status="failed",
+ fields=step.fields,
+ artifact={
+ "error": error_envelope(
+ exc,
+ operation=f"run.step.{step.kind}",
+ )
+ },
+ )
append_source_operation_artifact(
record.artifact.get("source_operation")
)
@@ -1447,6 +1475,57 @@ def _run_step(
SourceOutputRequest(channel=step.fields["channel"], enabled=False)
)
artifact = {"source_operation": source_operation}
+ elif step.kind == "source.counter_configure_v2":
+ fields = step.fields
+ _, source_operation = self._source_service(services=services).configure_counter_v2(
+ SourceCounterConfigureRequest(
+ input_id=fields["input_id"],
+ patch=SourceCounterConfigurationPatch(
+ coupling=(
+ PatchValue(
+ PatchAction.SET,
+ SourceInputCoupling(fields["coupling"]),
+ )
+ if "coupling" in fields
+ else PatchValue(PatchAction.KEEP)
+ ),
+ impedance_ohm=(
+ PatchValue(PatchAction.SET, fields["impedance_ohm"])
+ if "impedance_ohm" in fields
+ else PatchValue(PatchAction.KEEP)
+ ),
+ attenuation=(
+ PatchValue(PatchAction.SET, fields["attenuation"])
+ if "attenuation" in fields
+ else PatchValue(PatchAction.KEEP)
+ ),
+ trigger_level_v=(
+ PatchValue(PatchAction.SET, fields["trigger_level_v"])
+ if "trigger_level_v" in fields
+ else PatchValue(PatchAction.KEEP)
+ ),
+ statistics_enabled=(
+ PatchValue(PatchAction.SET, fields["statistics_enabled"])
+ if "statistics_enabled" in fields
+ else PatchValue(PatchAction.KEEP)
+ ),
+ ),
+ )
+ )
+ artifact = {"source_operation": source_operation}
+ elif step.kind in {"source.counter_enable_v2", "source.counter_disable_v2"}:
+ _, source_operation = self._source_service(services=services).set_counter_enabled_v2(
+ SourceCounterEnableRequest(
+ input_id=step.fields["input_id"],
+ enabled=step.kind == "source.counter_enable_v2",
+ )
+ )
+ artifact = {"source_operation": source_operation}
+ elif step.kind == "source.counter_measure_v2":
+ result = self._source_service(services=services).measure_counter_v2(
+ SourceCounterMeasureRequest(input_id=step.fields["input_id"])
+ )
+ artifact = {"counter_measurement": source_v2_to_data(result)}
elif step.kind == "source.harmonics_configure_v2":
_, source_operation = self._source_service(services=services).configure_harmonics_v2(
SourceHarmonicConfigureRequest(
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1a61e11..708b1a6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -659,6 +659,25 @@ def test_source_v2_commands_accept_explicit_channels(self):
]
)
output = build_parser().parse_args(["source", "output-v2", "--channel", "2", "on"])
+ counter_configure = build_parser().parse_args(
+ [
+ "source",
+ "counter-configure-v2",
+ "--input-id",
+ "counter",
+ "--coupling",
+ "dc",
+ ]
+ )
+ counter_enable = build_parser().parse_args(
+ ["source", "counter-enable-v2", "--input-id", "counter"]
+ )
+ counter_disable = build_parser().parse_args(
+ ["source", "counter-disable-v2", "--input-id", "counter"]
+ )
+ counter_measure = build_parser().parse_args(
+ ["source", "counter-measure-v2", "--input-id", "counter"]
+ )
harmonics = build_parser().parse_args(
[
"source",
@@ -785,6 +804,15 @@ def test_source_v2_commands_accept_explicit_channels(self):
self.assertEqual(output.command, "output-v2")
self.assertEqual(output.channel, 2)
self.assertEqual(output.state, "on")
+ self.assertEqual(counter_configure.command, "counter-configure-v2")
+ self.assertEqual(counter_configure.input_id, "counter")
+ self.assertEqual(counter_configure.coupling, "dc")
+ self.assertEqual(counter_enable.command, "counter-enable-v2")
+ self.assertEqual(counter_enable.input_id, "counter")
+ self.assertEqual(counter_disable.command, "counter-disable-v2")
+ self.assertEqual(counter_disable.input_id, "counter")
+ self.assertEqual(counter_measure.command, "counter-measure-v2")
+ self.assertEqual(counter_measure.input_id, "counter")
self.assertEqual(harmonics.command, "harmonics-configure-v2")
self.assertEqual(harmonics.channel, 2)
self.assertEqual(harmonics.order, 8)
@@ -855,6 +883,89 @@ def test_source_basic_live_configure_v2_dispatches_typed_request(self):
self.assertEqual(request.patch.amplitude_vpp.value, 1.5)
self.assertEqual(json.loads(stdout.getvalue()), payload)
+ def test_source_counter_v2_commands_dispatch_typed_requests(self):
+ from wavebench.instruments.source_extensions import (
+ SourceCounterMeasureResult,
+ SourceCounterMeasurementKind,
+ SourceCounterMeasurementV2,
+ SourceInputCoupling,
+ )
+
+ configure_payload = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.counter_configure_v2",
+ }
+ enable_payload = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.counter_enable_v2",
+ }
+ service = Mock()
+ service.configure_counter_v2.return_value = (object(), configure_payload)
+ service.set_counter_enabled_v2.return_value = (object(), enable_payload)
+ service.measure_counter_v2.return_value = SourceCounterMeasureResult(
+ "counter",
+ (
+ SourceCounterMeasurementV2(SourceCounterMeasurementKind.DUTY_PERCENT, 50.0),
+ SourceCounterMeasurementV2(
+ SourceCounterMeasurementKind.FREQUENCY_HZ,
+ 1_000.0,
+ ),
+ ),
+ )
+
+ with patch("wavebench.cli._load_source_service", return_value=service):
+ self.assertEqual(
+ main(
+ [
+ "source",
+ "counter-configure-v2",
+ "--input-id",
+ "counter",
+ "--coupling",
+ "dc",
+ ]
+ ),
+ 0,
+ )
+ self.assertEqual(
+ main(["source", "counter-enable-v2", "--input-id", "counter"]),
+ 0,
+ )
+ stdout = io.StringIO()
+ with redirect_stdout(stdout):
+ self.assertEqual(
+ main(["source", "counter-measure-v2", "--input-id", "counter"]),
+ 0,
+ )
+
+ configure_request = service.configure_counter_v2.call_args.args[0]
+ self.assertEqual(configure_request.input_id, "counter")
+ self.assertEqual(configure_request.patch.coupling.value, SourceInputCoupling.DC)
+ enable_request = service.set_counter_enabled_v2.call_args.args[0]
+ self.assertEqual(enable_request.input_id, "counter")
+ self.assertTrue(enable_request.enabled)
+ measure_request = service.measure_counter_v2.call_args.args[0]
+ self.assertEqual(measure_request.input_id, "counter")
+ self.assertEqual(
+ json.loads(stdout.getvalue()),
+ {
+ "type": "SourceCounterMeasureResult",
+ "input_id": "counter",
+ "measurements": [
+ {
+ "type": "SourceCounterMeasurementV2",
+ "kind": "duty_percent",
+ "value": 50.0,
+ },
+ {
+ "type": "SourceCounterMeasurementV2",
+ "kind": "frequency_hz",
+ "value": 1_000.0,
+ },
+ ],
+ },
+ )
+
def test_source_harmonics_disable_v2_dispatches_typed_request(self):
payload = {
"schema": "wavebench.source.operation.v1",
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index c880e8d..e26081e 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -615,6 +615,60 @@ def test_source_basic_live_v2_step_requires_one_live_field(self):
with self.assertRaisesRegex(ConfigError, "requires exactly one"):
load_run_plan(two_fields)
+ def test_source_counter_v2_steps_require_one_config_field(self):
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "source.counter_configure_v2"
+input_id = "counter"
+coupling = "DC"
+
+[[steps]]
+kind = "source.counter_enable_v2"
+input_id = "counter"
+
+[[steps]]
+kind = "source.counter_measure_v2"
+input_id = "counter"
+
+[[steps]]
+kind = "source.counter_disable_v2"
+input_id = "counter"
+"""))
+
+ self.assertEqual(
+ plan.steps[0].fields,
+ {"input_id": "counter", "coupling": "dc"},
+ )
+ self.assertEqual(plan.steps[1].fields, {"input_id": "counter"})
+ self.assertEqual(plan.steps[2].fields, {"input_id": "counter"})
+ self.assertEqual(plan.steps[3].fields, {"input_id": "counter"})
+
+ no_field = self._write_plan("""
+[[steps]]
+kind = "source.counter_configure_v2"
+input_id = "counter"
+""")
+ with self.assertRaisesRegex(ConfigError, "requires exactly one Counter field"):
+ load_run_plan(no_field)
+
+ two_fields = self._write_plan("""
+[[steps]]
+kind = "source.counter_configure_v2"
+input_id = "counter"
+coupling = "ac"
+attenuation = 10
+""")
+ with self.assertRaisesRegex(ConfigError, "requires exactly one Counter field"):
+ load_run_plan(two_fields)
+
+ invalid_input = self._write_plan("""
+[[steps]]
+kind = "source.counter_measure_v2"
+input_id = ""
+""")
+ with self.assertRaisesRegex(ConfigError, "input_id"):
+ load_run_plan(invalid_input)
+
def test_source_v2_harmonic_disable_step_accepts_only_channel(self):
plan = load_run_plan(self._write_plan("""
[[steps]]
@@ -640,6 +694,8 @@ def test_format_run_plan_schema_lists_expect_and_power_output(self):
self.assertIn("source.basic_configure_v2", text)
self.assertIn("source.basic_live_configure_v2", text)
self.assertIn("source.output_enable_v2", text)
+ self.assertIn("source.counter_configure_v2", text)
+ self.assertIn("source.counter_measure_v2", text)
self.assertIn("source.harmonics_configure_v2", text)
self.assertIn("source.harmonics_disable_v2", text)
self.assertIn("source.modulation_configure_v2", text)
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 9f6d6cb..187080f 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -25,7 +25,7 @@
WaveformConfig,
)
from wavebench.drivers.dp800 import PowerStatus
-from wavebench.errors import ConfigError, SessionHealthError, TransportIOError
+from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError
from wavebench.logging import CommandLogger
from wavebench.services.run_plan import load_run_plan
from wavebench.services.run_service import RunInstrumentServices, RunService
@@ -415,6 +415,48 @@ def _apply_safety_gate(self, step, gate, *, services=None):
self.assertEqual(len(result.steps), 1)
self.assertEqual(run_data["error"]["code"], "safety_gate_failed")
+ def test_expected_step_failure_runs_safety_gate_before_stopping(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "sleep"
+duration_s = 0.001
+""",
+ )
+ )
+ plan.steps[0].fields["safety_gate"] = {
+ "enabled": True,
+ "source_channels": [1],
+ }
+
+ class OfflineRunService(RunService):
+ @contextmanager
+ def _run_instrument_services(self, plan):
+ del plan
+ yield RunInstrumentServices()
+
+ def _run_safety_guards(self, plan, *, services=None):
+ del plan, services
+
+ def _run_step(self, plan, step, **kwargs):
+ del plan, step, kwargs
+ raise DataError("Counter measurement is not ready")
+
+ def _apply_safety_gate(self, step, gate, *, services=None):
+ del step, gate, services
+ return {"status": "ok", "actions": [{"state": "off"}]}
+
+ result = OfflineRunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+ run_data = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+
+ self.assertEqual(len(result.steps), 1)
+ self.assertEqual(result.steps[0].artifact["error"]["code"], "data_error")
+ self.assertEqual(result.steps[0].artifact["safety_gate"]["status"], "ok")
+ self.assertEqual(run_data["error"]["code"], "safety_gate_failed")
+
def test_check_rejects_missing_capability_before_opening_session(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
@@ -502,6 +544,57 @@ def test_check_requires_source_basic_live_v2_capability_before_opening_session(s
open_services.assert_not_called()
+ def test_check_requires_source_counter_measure_v2_capability_before_opening_session(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "source.counter_measure_v2"
+input_id = "counter"
+""",
+ )
+ )
+ descriptor = SimpleNamespace(
+ driver_id="minimal.source-v2",
+ capabilities=("source.snapshot_v2",),
+ )
+ service = RunService(config=make_config(tmp), logger=CommandLogger())
+
+ with patch(
+ "wavebench.services.run_service.resolve_instrument_descriptor",
+ return_value=descriptor,
+ ), patch.object(service, "_run_instrument_services") as open_services:
+ with self.assertRaisesRegex(ConfigError, "source.counter_measure_v2"):
+ service.run(plan)
+
+ open_services.assert_not_called()
+
+ def test_check_uses_counter_enable_capability_for_counter_disable_v2(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "source.counter_disable_v2"
+input_id = "counter"
+""",
+ )
+ )
+ descriptor = SimpleNamespace(
+ driver_id="minimal.source-v2",
+ capabilities=("source.snapshot_v2", "source.counter_enable_v2"),
+ )
+ service = RunService(config=make_config(tmp), logger=CommandLogger())
+
+ with patch(
+ "wavebench.services.run_service.resolve_instrument_descriptor",
+ return_value=descriptor,
+ ):
+ service.check(plan)
+
def test_check_requires_source_v2_harmonic_capability_before_opening_session(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
@@ -1938,6 +2031,119 @@ def _run_safety_guards(self, plan, *, services=None):
self.assertEqual(run_data["source_operations"], [artifact])
self.assertEqual(result.steps[0].artifact["source_operation"], artifact)
+ def test_runs_source_counter_v2_steps_and_keeps_measurement_out_of_source_operations(self):
+ from wavebench.instruments.source_extensions import (
+ SourceCounterMeasureResult,
+ SourceCounterMeasurementKind,
+ SourceCounterMeasurementV2,
+ SourceInputCoupling,
+ )
+
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "source.counter_configure_v2"
+input_id = "counter"
+coupling = "dc"
+
+[[steps]]
+kind = "source.counter_enable_v2"
+input_id = "counter"
+
+[[steps]]
+kind = "source.counter_measure_v2"
+input_id = "counter"
+
+[[steps]]
+kind = "source.counter_disable_v2"
+input_id = "counter"
+""",
+ )
+ )
+ configure_artifact = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.counter_configure_v2",
+ }
+ enable_artifact = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.counter_enable_v2",
+ }
+ disable_artifact = {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.counter_disable_v2",
+ }
+ source = Mock()
+ source.configure_counter_v2.return_value = (SimpleNamespace(), configure_artifact)
+ source.set_counter_enabled_v2.side_effect = [
+ (SimpleNamespace(), enable_artifact),
+ (SimpleNamespace(), disable_artifact),
+ ]
+ source.measure_counter_v2.return_value = SourceCounterMeasureResult(
+ "counter",
+ (
+ SourceCounterMeasurementV2(SourceCounterMeasurementKind.DUTY_PERCENT, 50.0),
+ SourceCounterMeasurementV2(
+ SourceCounterMeasurementKind.FREQUENCY_HZ,
+ 1_000.0,
+ ),
+ ),
+ )
+
+ class OfflineV2RunService(RunService):
+ def check(self, plan):
+ del plan
+
+ @contextmanager
+ def _run_instrument_services(self, plan):
+ del plan
+ yield RunInstrumentServices(source=source)
+
+ def _run_safety_guards(self, plan, *, services=None):
+ del plan, services
+
+ result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+ run_data = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+
+ configure_request = source.configure_counter_v2.call_args.args[0]
+ self.assertEqual(configure_request.input_id, "counter")
+ self.assertEqual(
+ configure_request.patch.coupling.value,
+ SourceInputCoupling.DC,
+ )
+ self.assertEqual(
+ [call.args[0].enabled for call in source.set_counter_enabled_v2.call_args_list],
+ [True, False],
+ )
+ self.assertEqual(source.measure_counter_v2.call_args.args[0].input_id, "counter")
+ self.assertEqual(
+ run_data["source_operations"],
+ [configure_artifact, enable_artifact, disable_artifact],
+ )
+ self.assertEqual(
+ result.steps[2].artifact,
+ {
+ "counter_measurement": {
+ "type": "SourceCounterMeasureResult",
+ "input_id": "counter",
+ "measurements": [
+ {
+ "type": "SourceCounterMeasurementV2",
+ "kind": "duty_percent",
+ "value": 50.0,
+ },
+ {
+ "type": "SourceCounterMeasurementV2",
+ "kind": "frequency_hz",
+ "value": 1_000.0,
+ },
+ ],
+ }
+ },
+ )
+
def test_restores_source_state_after_success_when_enabled(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index b569221..93f014e 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -78,6 +78,10 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.basic_live_configure_v2",
"source.output_enable_v2",
"source.output_disable_v2",
+ "source.counter_configure_v2",
+ "source.counter_enable_v2",
+ "source.counter_disable_v2",
+ "source.counter_measure_v2",
"source.harmonics_configure_v2",
"source.harmonics_disable_v2",
"source.modulation_configure_v2",
@@ -138,6 +142,10 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.basic_live_configure_v2": "channel = 1\nfrequency_hz = 1000\n",
"source.output_enable_v2": "channel = 1\n",
"source.output_disable_v2": "channel = 1\n",
+ "source.counter_configure_v2": "input_id = \"counter\"\ncoupling = \"dc\"\n",
+ "source.counter_enable_v2": "input_id = \"counter\"\n",
+ "source.counter_disable_v2": "input_id = \"counter\"\n",
+ "source.counter_measure_v2": "input_id = \"counter\"\n",
"source.harmonics_configure_v2": "channel = 1\norder = 8\npreset = \"odd\"\n",
"source.harmonics_disable_v2": "channel = 1\n",
"source.modulation_configure_v2": "channel = 1\ndepth_percent = 80\ninternal_frequency_hz = 25\n",
From f9b82118c6ee02021e03266aa3989fde46ee0869 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 01:30:50 +0800
Subject: [PATCH 32/44] docs(source): document Counter V2 operations
---
...77\347\224\250\346\214\207\345\215\227.md" | 25 +++++++++++++-
...345\231\250\346\217\222\344\273\266API.md" | 29 ++++++++++++----
...345\207\272\345\256\211\345\205\250RFC.md" | 33 +++++++++++++++++--
3 files changed, 78 insertions(+), 9 deletions(-)
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 f3824b2..815458e 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"
@@ -419,7 +419,7 @@ state = "on"
### Source V2 基础、高级配置与 ARB 写 step
-声明 `source.snapshot_v2` 与对应写 capability 的插件可以使用十七个 Source V2 step。基础配置只在目标输出已关闭时执行;输出 ON 与 OFF 分别使用不同 step:
+声明 `source.snapshot_v2` 与对应写 capability 的插件可以使用 Source V2 step。基础配置只在目标输出已关闭时执行;输出 ON 与 OFF 分别使用不同 step:
```toml
[[steps]]
@@ -438,6 +438,23 @@ channel = 1
kind = "source.output_disable_v2"
channel = 1
+[[steps]]
+kind = "source.counter_configure_v2"
+input_id = "counter"
+coupling = "ac"
+
+[[steps]]
+kind = "source.counter_enable_v2"
+input_id = "counter"
+
+[[steps]]
+kind = "source.counter_measure_v2"
+input_id = "counter"
+
+[[steps]]
+kind = "source.counter_disable_v2"
+input_id = "counter"
+
[[steps]]
kind = "source.harmonics_configure_v2"
channel = 1
@@ -522,6 +539,12 @@ enabled = true
要求 `channel`、整数 `order >= 2` 与 `all`、`even`、`odd` 之一的 `preset`;核心还会在执行前检查运行时
profile 是否支持该 order 和预设。`source.modulation_configure_v2` 要求 `channel`、位于 `[0, 100]` 的
`depth_percent` 与有限正值 `internal_frequency_hz`;它只配置内部正弦 AM。
+`source.counter_configure_v2` 要求安全 token 形式的 `input_id`,并在 coupling、
+`impedance_ohm`、`attenuation`、`trigger_level_v` 与 `statistics_enabled` 中恰好指定一个字段。
+它不启用 Counter。`source.counter_enable_v2` 与 `source.counter_disable_v2` 只接受 `input_id`,
+不隐式改写输入配置;`source.counter_measure_v2` 也只接受 `input_id`,要求 Counter 已启用。
+Counter 测量 artifact 位于该 step 的 `counter_measurement`,不进入 `source_operations`。实际信号、
+输入阻抗与最大 Vpp 仍必须由计划 safety 和接线确认;Counter step 不替代输出安全门。
`source.modulation_pm_configure_v2` 要求 `channel`、位于 `[0, 360]` 的 `phase_deviation_deg` 与有限正值
`internal_frequency_hz`;它只配置内部正弦 PM。
`source.modulation_fm_configure_v2` 要求 `channel`、有限正值 `frequency_deviation_hz` 与有限正值
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 8c0db84..5248bad 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"
@@ -429,7 +429,11 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot
| `source.arbitrary_upload` | `upload_dg4000_dac14_block` |
| `source.snapshot_v2` | `execute_source_query_plan_v2` |
| `source.basic_configure_v2` | `configure_source_basic_v2` |
+| `source.basic_live_configure_v2` | `configure_source_basic_live_v2` |
| `source.output_v2` | `set_source_output_v2` |
+| `source.counter_configure_v2` | `configure_source_counter_v2` |
+| `source.counter_enable_v2` | `set_source_counter_enabled_v2` |
+| `source.counter_measure_v2` | `measure_source_counter_v2` |
| `source.harmonics_configure_v2` | `configure_source_harmonics_v2` |
| `source.harmonics_disable_v2` | `disable_source_harmonics_v2` |
| `source.modulation_configure_v2` | `configure_source_modulation_v2` |
@@ -448,7 +452,8 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot
### Source V2 扩展
-`source.snapshot_v2`、`source.basic_configure_v2`、`source.output_v2`、
+`source.snapshot_v2`、`source.basic_configure_v2`、`source.basic_live_configure_v2`、`source.output_v2`、
+`source.counter_configure_v2`、`source.counter_enable_v2`、`source.counter_measure_v2`、
`source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.modulation_pm_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.burst_configure_v2`、`source.pulse_configure_v2`、`source.arbitrary_storage_v2`、`source.arbitrary_select_v2`、`source.combine_configure_v2`、`source.coupling_configure_v2`、`source.tracking_configure_v2` 和 `source.phase_relation_configure_v2` 从核心 `0.8.24` 开始提供,仍使用
`wavebench.instrument.v2`。采用任一 Source V2 capability 的
wheel 依赖和 descriptor `wavebench_min_version` 都必须为 `0.8.24` 或更高的 `0.8.x` 版本。
@@ -456,7 +461,8 @@ wheel 依赖和 descriptor `wavebench_min_version` 都必须为 `0.8.24` 或更
修改 descriptor 或提高版本下限。
插件从 `wavebench.instruments` 导入 `SourceDescriptorExtensions`、`SourceSnapshotV2Driver`、
-`SourceBasicConfigureV2Driver`、`SourceOutputV2Driver`、`SourceHarmonicConfigureV2Driver`、`SourceHarmonicDisableV2Driver`、
+`SourceBasicConfigureV2Driver`、`SourceBasicLiveConfigureV2Driver`、`SourceOutputV2Driver`、
+`SourceCounterConfigureV2Driver`、`SourceCounterEnableV2Driver`、`SourceCounterMeasureV2Driver`、`SourceHarmonicConfigureV2Driver`、`SourceHarmonicDisableV2Driver`、
`SourceModulationConfigureV2Driver`、`SourcePmModulationConfigureV2Driver`、`SourceFmModulationConfigureV2Driver`、`SourcePwmModulationConfigureV2Driver`、`SourceSweepConfigureV2Driver`、`SourceBurstConfigureV2Driver`、`SourcePulseConfigureV2Driver`、`SourceArbitraryStorageV2Driver`、`SourceArbitrarySelectV2Driver`、`SourceCombineConfigureV2Driver`、`SourceCouplingConfigureV2Driver`、`SourceTrackingConfigureV2Driver`、`SourcePhaseRelationConfigureV2Driver`、query
plan/execution record 和各类 typed profile。核心签发 semantic query plan;snapshot driver 只负责将
item 转成合法的厂商协议查询并返回类型化执行记录。插件不得返回完整 `SourceSnapshotV2`,也不得自行判定 `UNSUPPORTED`、
@@ -477,7 +483,11 @@ wavebench source snapshot-v2
```text
SourceService.configure_basic_v2(request, *, correlation_id=None)
+SourceService.configure_basic_live_v2(request, *, correlation_id=None)
SourceService.set_output_v2(request, *, correlation_id=None)
+SourceService.configure_counter_v2(request, *, correlation_id=None)
+SourceService.set_counter_enabled_v2(request, *, correlation_id=None)
+SourceService.measure_counter_v2(request, *, correlation_id=None)
SourceService.configure_harmonics_v2(request, *, correlation_id=None)
SourceService.disable_harmonics_v2(request, *, correlation_id=None)
SourceService.configure_modulation_v2(request, *, correlation_id=None)
@@ -490,7 +500,12 @@ SourceService.configure_pulse_v2(request, *, correlation_id=None)
SourceService.mutate_arbitrary_storage_v2(request, *, payload, correlation_id=None)
SourceService.select_arbitrary_v2(request, *, correlation_id=None)
wavebench source basic-configure-v2 --channel N ...
+wavebench source basic-live-configure-v2 --channel N (--frequency-hz HZ | --amplitude-vpp VPP)
wavebench source output-v2 --channel N on|off
+wavebench source counter-configure-v2 --input-id INPUT_ID
+wavebench source counter-enable-v2 --input-id INPUT_ID
+wavebench source counter-disable-v2 --input-id INPUT_ID
+wavebench source counter-measure-v2 --input-id INPUT_ID
wavebench source harmonics-configure-v2 --channel N --order N --preset all|even|odd
wavebench source harmonics-disable-v2 --channel N
wavebench source modulation-configure-v2 --channel N --depth-percent PERCENT --internal-frequency-hz HZ
@@ -504,7 +519,8 @@ wavebench source arbitrary-storage-v2 --channel N --slot-id SLOT --payload-file
wavebench source arbitrary-select-v2 --channel N --slot-id SLOT --playback-mode dds|true-arb (--playback-frequency-hz HZ | --sample-rate-hz HZ)
```
-十三个 Service 方法分别返回 `(typed_result, operation_artifact)`。`operation_artifact` 使用
+十六个 mutation Service 方法分别返回 `(typed_result, operation_artifact)`;
+`measure_counter_v2()` 是只读方法,直接返回 `SourceCounterMeasureResult`。`operation_artifact` 使用
`wavebench.source.operation.v1`,不得包含 raw SCPI、完整响应、资源地址、序列号、授权 token 或 nonce。
`source.harmonics_configure_v2` 只允许单通道的 `all`、`even`、`odd` 预设。descriptor 必须同时声明
@@ -566,9 +582,10 @@ selection request 使用 DDS 的 `playback_frequency_hz` 或 true-ARB 的 `sampl
并回读 Basic `arbitrary` waveform、selected slot、mode、对应速率和 storage digest;它不会授权 output ON。若声明任一
ARB V2 capability,V1 `upload_arbitrary_waveform` 会在读取本地 waveform 文件、构造块或发送仪器 I/O 前拒绝。
-run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`source.output_disable_v2`、
-`source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.modulation_pm_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.burst_configure_v2`、`source.pulse_configure_v2`、`source.arbitrary_storage_v2`、`source.arbitrary_select_v2`、`source.combine_configure_v2`、`source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2` 十八个 Source V2 step;它们的 artifact 只在实际执行时写入
-`run.json.source_operations`。
+run plan 接受 `source.basic_configure_v2`、`source.basic_live_configure_v2`、`source.output_enable_v2`、`source.output_disable_v2`、
+`source.counter_configure_v2`、`source.counter_enable_v2`、`source.counter_disable_v2`、`source.counter_measure_v2`、
+`source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.modulation_pm_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.burst_configure_v2`、`source.pulse_configure_v2`、`source.arbitrary_storage_v2`、`source.arbitrary_select_v2`、`source.combine_configure_v2`、`source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2` 二十三个 Source V2 step;mutation artifact 只在实际执行时写入
+`run.json.source_operations`,而 `source.counter_measure_v2` 使用其 step 专有的 `counter_measurement` artifact。
旧 `source.*` setter、output、trigger 和 ARB 路径继续保留。双合同插件上,四个 basic setter 与
`set_output` 会进入相应 V2 transaction;restore、ARB upload 和 V2 output 重叠的 trigger 在仪器 I/O 前
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 75ece30..a01a845 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -3722,8 +3722,37 @@ Counter 按副作用拆开,而不是继续沿用 V1 的「完整 profile 一
`AUTO` gate-time、无法精确表达的厂商 preset、HF rejection、sensitivity、statistics display
和 statistics clear 不进入这组首版合同。前四项需要各自可读的通用状态模型;clear 是
-破坏性动作,必须以后续单独 capability 明确授权。D1-4 只冻结 model、Protocol 和操作
-元数据,不注册 capability、不改变 CLI 或 run schema,也不授权任何真实插件写入。
+破坏性动作,必须以后续单独 capability 明确授权。D1-4 冻结 model、Protocol 和操作
+元数据;D6 在不改变 V1 路由的前提下,逐项注册以下 capability:
+
+```text
+source.counter_configure_v2
+source.counter_enable_v2
+source.counter_measure_v2
+```
+
+### D6 已实现:Counter V2 入口与安全语义
+
+Core 提供四个显式 CLI 入口:
+
+```text
+wavebench source counter-configure-v2 --input-id INPUT_ID
+wavebench source counter-enable-v2 --input-id INPUT_ID
+wavebench source counter-disable-v2 --input-id INPUT_ID
+wavebench source counter-measure-v2 --input-id INPUT_ID
+```
+
+run plan 对应 `source.counter_configure_v2`、`source.counter_enable_v2`、
+`source.counter_disable_v2` 与 `source.counter_measure_v2`。配置 step 必须恰好指定一个
+字段;measure 只写入该 step 的 `counter_measurement` typed artifact,不伪造 mutation
+artifact,也不写入 `run.json.source_operations`。
+
+Counter 在刚启用或无输入时可能返回暂未形成的零测量。V2 snapshot 将这种已收到但不合法的
+测量标为 `UNAVAILABLE`/`response_invalid_value`,仍保留已读到的 enabled 与配置字段,
+以便安全 disable 事务继续执行。legacy `source.counter_profile` 与
+`source.counter_measure_v2` 保持严格:前者仍拒绝不完整 profile,后者仍只接受有效五元组。
+当 run step 抛出预期的 WaveBench 错误且计划配置了 safety gate 时,Core 先执行已授权的
+输出 OFF,再把失败写入 run artifact。
### M6-B 已实现:ARB storage 与 selection
From 2b43ed3471002cf97377af861f69d810c46efc0b Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 10:49:40 +0800
Subject: [PATCH 33/44] test(source): lock V1 V2 migration boundary
---
...345\207\272\345\256\211\345\205\250RFC.md" | 10 +++---
tests/test_cli.py | 8 +++--
tests/test_source_arbitrary_v2.py | 32 ++++++++++++++++++-
3 files changed, 43 insertions(+), 7 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index a01a845..8d0bcac 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -2763,7 +2763,7 @@ scheme、原始等级、按 `wavebench.source.a0-a5.v1` 重新评定的等级和
| 旧核心 + 新插件 | 受管安装由 wheel `Requires-Dist` 在 entry point import 前拒绝;绕过 package inspection 的直接 `pip --no-deps` 或手工安装不承诺零导入,且不属于支持组合 |
| 新核心 + 新插件 | 只对明确声明并通过验证的 Source V2 capability 使用新合同 |
| 新核心 + 同时声明 V1/V2 的新插件 | 新 operation 只使用 V2;默认策略下,同义或副作用重叠的旧写入口映射/拒绝,不相交的旧 operation 保持 V1;单次事务不混用两套安全视图 |
-| 新核心 + `v1_route_migration_enabled=false` 的双合同插件 | 只有显式 V2 operation 使用 V2;既有 V1 route 保持原合同,不能用不完整的 V2 组合替换 legacy composite transaction |
+| 新核心 + `v1_route_migration_enabled=false` 的双合同插件 | 只有显式 V2 operation 使用 V2;Basic/Output 自动迁移与由它们引出的 gate 不接管旧 route。已单独声明的高级 V2 capability 仍保留其真实字段/发信号重叠 gate,不能用不完整的 V2 组合绕过 legacy composite transaction |
R2 决定保持 `wavebench.instrument.v2`。`source_extensions` 是带默认值的末尾扩展,新 Protocol
不改变现有 `SourceDriver`,新 capability 通过最低核心版本门显式 opt in。只有删除 Source V1、
@@ -3209,7 +3209,8 @@ Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使
- 新类型、descriptor 字段和 artifact 键必须 append-only;既有 `SourceDriver`、`SourceStatus`、
V1 CLI、V1 run step、V1 JSON 和 V1 artifact 不改变语义。
- V1-only 插件继续执行 V1 路径。双合同插件默认在 M5-D 将同义或副作用重叠的 V1 route 映射到 V2;
- `v1_route_migration_enabled=false` 可使显式 V2 surface 与完整 legacy V1 transaction 并存。
+ `v1_route_migration_enabled=false` 可使显式 V2 Basic/Output surface 与其 legacy transaction 并存,
+ 但不取消任何已单独声明高级 V2 capability 的真实重叠安全门。
`set_function` 有一项兼容例外:目标波形未在当前 V2 Basic profile 声明,或 V2 preflight 无法为当前
旧状态提供最终 Vpp/Offset 时,核心继续调用既有 V1 setter,不进入 V2 MAIN 写入;其余无法无损映射
的重叠 route 在仪器 I/O 前拒绝。不相交的 V1 route 保持原行为。
@@ -3360,8 +3361,9 @@ operation artifact 同时保存到 step 的 `artifact.source_operation` 和非
| `source.output_v2` | `trigger_burst`、`trigger_sweep` | 属于可能发信号的重叠 route,在仪器 I/O 前拒绝。 |
| 当前两个写 capability 均未覆盖 | `configure_coupling`、`configure_harmonics`、AM/FM/PM/PWM、`configure_pulse`、`configure_burst`、`configure_sweep` | 保持 V1 路径,等待对应 feature 的 V2 capability。 |
-设为 `false` 的双合同插件保留上述所有 V1 route;只有显式 V2 Service、CLI 或 run step 调用 V2
-transaction。该选择适用于 V1 composite transaction 无法由当前的窄 V2 operation 等价表示的设备。
+设为 `false` 的双合同插件保留上述由 Basic/Output 自动迁移或自动 gate 的 V1 route;只有显式 V2 Service、CLI
+或 run step 调用这些 V2 transaction。已单独声明的高级 capability 仍会在其相同字段或发信号的 V1 route 上
+失败关闭,不能借该开关绕过。该选择适用于 V1 composite transaction 无法由当前的窄 V2 operation 等价表示的设备。
V1-only 插件继续使用原 V1 route。默认迁移的双合同 V1 setter 返回值仅为兼容显示而从 V2
postcondition flatten 为 `SourceStatus`;该 adapter 不参与 V2 preflight、预算、恢复或 capability 决策。
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 708b1a6..7e42970 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1344,8 +1344,12 @@ def test_executable_plugin_info_loads_v2_descriptor(self):
def test_executable_plugin_doctor_loads_descriptors(self):
stdout = io.StringIO()
- with redirect_stdout(stdout):
- code = main(["plugin", "doctor", "--load"])
+ with patch(
+ "wavebench.instruments.registry.entry_points",
+ return_value=FakePluginEntryPoints(),
+ ):
+ with redirect_stdout(stdout):
+ code = main(["plugin", "doctor", "--load"])
self.assertEqual(code, 0)
self.assertIn("可执行描述符有效", stdout.getvalue())
diff --git a/tests/test_source_arbitrary_v2.py b/tests/test_source_arbitrary_v2.py
index d3641fa..5859a16 100644
--- a/tests/test_source_arbitrary_v2.py
+++ b/tests/test_source_arbitrary_v2.py
@@ -336,6 +336,7 @@ def _extensions(
SourceArbitraryPlaybackMode.DDS,
SourceArbitraryPlaybackMode.TRUE_ARB,
),
+ v1_route_migration_enabled: bool = True,
):
base = source_extensions()
basic, output = base.features
@@ -373,6 +374,7 @@ def _extensions(
)
return replace(
base,
+ v1_route_migration_enabled=v1_route_migration_enabled,
features=(
arbitrary,
replace(
@@ -429,6 +431,7 @@ def _service(
dual_contract: bool = False,
volatile: bool = False,
volatile_write_error: bool = False,
+ v1_route_migration_enabled: bool = True,
playback_modes: tuple[SourceArbitraryPlaybackMode, ...] = (
SourceArbitraryPlaybackMode.DDS,
SourceArbitraryPlaybackMode.TRUE_ARB,
@@ -452,7 +455,13 @@ def _service(
if volatile:
capabilities.append("source.arbitrary_volatile_replace_v2")
descriptor = replace(
- source_descriptor(driver=driver, extensions=_extensions(playback_modes=playback_modes)),
+ source_descriptor(
+ driver=driver,
+ extensions=_extensions(
+ playback_modes=playback_modes,
+ v1_route_migration_enabled=v1_route_migration_enabled,
+ ),
+ ),
capabilities=tuple(capabilities),
)
validate_source_descriptor(descriptor)
@@ -765,3 +774,24 @@ def test_v1_arbitrary_upload_rejects_before_loading_file_or_io_for_dual_contract
assert driver.transport.counters.binary_write_requests == 0
assert driver.transport.counters.write_requests == 0
assert driver.transport.counters.query_calls == 0
+
+
+def test_v1_arbitrary_upload_keeps_the_advanced_overlap_gate_when_migration_is_disabled() -> None:
+ service, driver = _service(
+ dual_contract=True,
+ volatile=True,
+ v1_route_migration_enabled=False,
+ )
+
+ with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"):
+ service.upload_arbitrary_waveform(
+ channel=1,
+ file_path="does-not-exist.csv",
+ playback_frequency_hz=1_000.0,
+ amplitude_vpp=1.0,
+ )
+
+ assert driver.v1_upload_calls == 0
+ assert driver.transport.counters.binary_write_requests == 0
+ assert driver.transport.counters.write_requests == 0
+ assert driver.transport.counters.query_calls == 0
From 0d7460880a5ca841afb088d78a9588854736b24c Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 11:03:52 +0800
Subject: [PATCH 34/44] feat(source): add volatile ARB and sweep run surfaces
---
...345\207\272\345\256\211\345\205\250RFC.md" | 24 ++++--
src/wavebench/cli.py | 40 ++++++++++
src/wavebench/cli_parser.py | 17 ++++
src/wavebench/services/run_plan.py | 26 +++++-
src/wavebench/services/run_safety.py | 2 +
src/wavebench/services/run_service.py | 42 ++++++++++
tests/test_run_plan.py | 43 ++++++++++
tests/test_run_service.py | 79 +++++++++++++++++++
tests/test_source_snapshot_v2.py | 47 +++++++++++
9 files changed, 314 insertions(+), 6 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 8d0bcac..9c5434f 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -7,7 +7,7 @@
> 实施状态:P0、M1–M4、M4.5、C1、M5-A、M5-B、M5-C、M5-D、C2 与 M6-A 的 Harmonic 配置/关闭、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM、内部 Sweep 子项已进入核心
> `0.8.24` 开发线;R7 已接受。
> 当前注册 `source.snapshot_v2`、`source.basic_configure_v2`、`source.output_v2` 和
-> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座,
+> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.burst_fire_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.sweep_fire_v2`、`source.arbitrary_volatile_replace_v2` 及三项 Counter capability;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座,
> M5-D 已开放受限的 Source V2 写入口,C2 已补齐候选发布的核心兼容与离线发布物门。M6-A 已完成;
> 在该里程碑范围内,Harmonic、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM 与内部 Sweep 子项均具备公开 Service、CLI 与 run plan 入口。
> 本分支另记录 R8 候选设计:修正 Coupling 写合同,并拆分 Noise Overlay 与 Sync 写事务。
@@ -3692,10 +3692,13 @@ fire preflight 必须证明 snapshot 一致、目标输出为 ON、Vpp/Offset
`external_measurement_required = true`。
driver 异常、结果类型错误或后置条件失败时,Core 清除 receipt,只允许一次 V2 output OFF recovery 与独立回读,
-不会重新 fire 或恢复 ON。D1-3 不增加 CLI 命令或 run plan step;现有 V1 trigger 仅在对应 fire capability 已声明且
-同 session receipt 有效时映射到本 operation。现有 CLI 与 run step 仍构造默认 internal 请求;为保持已有
-operation artifact 字节形状,默认 `trigger_source=internal` 不写入 request payload,manual 请求则显式记录该字段。
-物理发出能力必须在具体插件的 A4 实机验收中由外部测量证明。
+不会重新 fire 或恢复 ON。D1-3 不增加独立 CLI fire 命令:独立命令无法保留 configure receipt 所属的 session。
+`source.sweep_configure_v2` CLI 与 run step 可显式传入 `trigger_source=manual`;Core 另提供只在 run plan 中使用的
+`source.sweep_fire_v2` step。该 step 必须在同一计划的 manual configure、`source.output_enable_v2` 之后执行,
+并以 `source.output_disable_v2` 结束。Burst 仍没有对应 run step。现有 V1 trigger 仅在对应 fire capability 已声明且
+同 session receipt 有效时映射到本 operation。为保持已有 operation artifact 字节形状,默认
+`trigger_source=internal` 不写入 request payload,manual 请求则显式记录该字段。物理发出能力必须在具体插件的
+A4 实机验收中由外部测量证明。
### D1-4 合同冻结:volatile ARB 与 Counter
@@ -3713,6 +3716,17 @@ selected waveform ID、内容是否能由设备读回验证、以及旧 volatile
可恢复旧内容。二进制写一旦尝试且后续失败,Core 只可尝试一次输出 OFF 收敛;旧内容
保持 `unrecoverable`,不得重传或 rollback。
+Core 提供 additive CLI:
+
+```text
+wavebench source arbitrary-volatile-replace-v2 --channel N --payload-file FILE --point-count N
+```
+
+CLI 在创建 Source Service 前读取本地 payload、计算摘要并构造 typed request;文件不可读或 request 无效时不打开仪器。
+run plan 使用 `source.arbitrary_volatile_replace_v2`,字段为 `channel`、相对 plan 的 `file` 和 `point_count`。
+execution intent 仅保存文件名、摘要与大小,Source operation artifact 不保存 payload 或本地路径。该核心入口不构成任何
+真实插件的 capability 声明;声明后会与 legacy ARB upload 形成 V1 overlap gate,必须单独完成等价性审计与实机验收。
+
Counter 按副作用拆开,而不是继续沿用 V1 的「完整 profile 一次设置」模型:
- `source.counter_configure_v2` 只允许一个显式字段:AC/DC coupling、输入阻抗、衰减、
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index e6649a6..ca8c811 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -447,6 +447,7 @@ def _source_sweep_configure_v2_request(args: argparse.Namespace):
from .instruments.source_extensions import (
SourceSweepConfigureRequest,
SourceSweepSpacing,
+ SourceTriggerSource,
)
try:
@@ -457,6 +458,7 @@ def _source_sweep_configure_v2_request(args: argparse.Namespace):
spacing=SourceSweepSpacing(args.spacing),
steps=args.steps,
sweep_time_s=args.sweep_time_s,
+ trigger_source=SourceTriggerSource(args.trigger_source),
)
except ValueError as exc:
raise ConfigError(str(exc)) from exc
@@ -497,6 +499,32 @@ def _source_arbitrary_storage_v2_request(
raise ConfigError(str(exc)) from exc
+def _source_arbitrary_volatile_replace_v2_request(
+ args: argparse.Namespace,
+) -> tuple[object, bytes]:
+ from .instruments.source_extensions import SourceArbitraryVolatileReplaceRequest
+
+ payload_path = Path(args.payload_file)
+ try:
+ payload = payload_path.read_bytes()
+ except OSError as exc:
+ raise ConfigError(
+ f"source.arbitrary_volatile_replace_v2 payload file is unreadable: {payload_path}"
+ ) from exc
+ try:
+ return (
+ SourceArbitraryVolatileReplaceRequest(
+ channel=args.channel,
+ payload_sha256="sha256:" + sha256(payload).hexdigest(),
+ payload_size_bytes=len(payload),
+ point_count=args.point_count,
+ ),
+ payload,
+ )
+ except ValueError as exc:
+ raise ConfigError(str(exc)) from exc
+
+
def _source_arbitrary_select_v2_request(args: argparse.Namespace):
from .instruments.source_extensions import (
SourceArbitraryPlaybackMode,
@@ -1335,6 +1363,18 @@ def _main(argv: list[str] | None = None) -> int:
else:
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
+ if args.command == "arbitrary-volatile-replace-v2":
+ request, volatile_payload = _source_arbitrary_volatile_replace_v2_request(args)
+ service = _load_source_service(args)
+ _, payload = service.replace_arbitrary_volatile_v2(
+ request,
+ payload=volatile_payload,
+ )
+ if args.json:
+ _emit_json_result(payload)
+ else:
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
+ return 0
service = _load_source_service(args)
if args.command == "idn":
print(service.idn())
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index 2d16737..f3555a3 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -996,6 +996,11 @@ def build_parser() -> argparse.ArgumentParser:
)
source_sweep_configure_v2.add_argument("--steps", type=int, required=True)
source_sweep_configure_v2.add_argument("--sweep-time-s", type=float, required=True)
+ source_sweep_configure_v2.add_argument(
+ "--trigger-source",
+ choices=("internal", "manual"),
+ default="internal",
+ )
add_runtime_options(source_sweep_configure_v2)
source_burst_configure_v2 = source_sub.add_parser(
@@ -1047,6 +1052,18 @@ def build_parser() -> argparse.ArgumentParser:
source_arbitrary_storage_v2.add_argument("--expected-previous-sha256")
add_runtime_options(source_arbitrary_storage_v2)
+ source_arbitrary_volatile_replace_v2 = source_sub.add_parser(
+ "arbitrary-volatile-replace-v2",
+ help=(
+ "Replace the selected Source V2 volatile ARB workspace while output is OFF; "
+ "the previous workspace content is not recoverable"
+ ),
+ )
+ source_arbitrary_volatile_replace_v2.add_argument("--channel", type=int, required=True)
+ source_arbitrary_volatile_replace_v2.add_argument("--payload-file", required=True)
+ source_arbitrary_volatile_replace_v2.add_argument("--point-count", type=int, required=True)
+ add_runtime_options(source_arbitrary_volatile_replace_v2)
+
source_arbitrary_select_v2 = source_sub.add_parser(
"arbitrary-select-v2",
help="Select one named Source V2 ARB waveform while the target output is OFF",
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 6133dc0..94d4c93 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -55,9 +55,11 @@
"source.modulation_fm_configure_v2",
"source.modulation_pwm_configure_v2",
"source.sweep_configure_v2",
+ "source.sweep_fire_v2",
"source.burst_configure_v2",
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
+ "source.arbitrary_volatile_replace_v2",
"source.arbitrary_select_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
@@ -141,6 +143,7 @@
"steps",
"sweep_time_s",
),
+ "source.sweep_fire_v2": ("channel",),
"source.burst_configure_v2": (
"channel",
"cycles",
@@ -156,6 +159,7 @@
"trailing_transition_s",
),
"source.arbitrary_storage_v2": ("channel", "slot_id", "file", "write_mode"),
+ "source.arbitrary_volatile_replace_v2": ("channel", "file", "point_count"),
"source.arbitrary_select_v2": ("channel", "slot_id", "playback_mode"),
"source.combine_configure_v2": ("channels", "enabled"),
"source.coupling_configure_v2": ("channels", "enabled"),
@@ -282,10 +286,12 @@
"width_deviation_s",
"on_failure",
},
- "source.sweep_configure_v2": {"on_failure"},
+ "source.sweep_configure_v2": {"trigger_source", "on_failure"},
+ "source.sweep_fire_v2": {"on_failure"},
"source.burst_configure_v2": {"on_failure"},
"source.pulse_configure_v2": {"on_failure"},
"source.arbitrary_storage_v2": {"expected_previous_sha256", "on_failure"},
+ "source.arbitrary_volatile_replace_v2": {"on_failure"},
"source.arbitrary_select_v2": {
"playback_frequency_hz",
"sample_rate_hz",
@@ -346,9 +352,11 @@
"source.modulation_fm_configure_v2": "Configure one OFF Source V2 channel with internal sine FM; it does not enable output.",
"source.modulation_pwm_configure_v2": "Configure one OFF Source V2 channel with internal sine PWM; it does not enable output.",
"source.sweep_configure_v2": "Configure one OFF Source V2 channel with an internal sweep; it does not enable or fire output.",
+ "source.sweep_fire_v2": "Fire one already configured manual Source V2 sweep in the same run session; external measurement is still required.",
"source.burst_configure_v2": "Configure one OFF Source V2 channel with an internal Triggered Burst; it does not enable or fire output.",
"source.pulse_configure_v2": "Configure one OFF Source V2 channel with a WIDTH pulse shape; it does not enable output.",
"source.arbitrary_storage_v2": "Write one named Source V2 ARB storage slot without selecting or enabling it. The payload file is recorded by digest only.",
+ "source.arbitrary_volatile_replace_v2": "Replace one volatile Source V2 ARB workspace while output is OFF. The previous workspace content is not recoverable; the payload file is recorded by digest only.",
"source.arbitrary_select_v2": "Select one named Source V2 ARB waveform while the target output is OFF; it does not enable output.",
"source.combine_configure_v2": "Enable or disable one declared Source V2 Combine relation while every affected output is OFF.",
"source.coupling_configure_v2": "Enable or disable one declared Source V2 Coupling relation while every affected output is OFF.",
@@ -1020,6 +1028,16 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
fields["stop_hz"] = stop_hz
fields["spacing"] = spacing
fields["sweep_time_s"] = sweep_time_s
+ if "trigger_source" in fields:
+ trigger_source = _non_empty_str(
+ fields["trigger_source"],
+ f"{prefix}.trigger_source",
+ ).lower()
+ if trigger_source not in {"internal", "manual"}:
+ raise ConfigError(
+ f"{prefix}.trigger_source must be internal or manual"
+ )
+ fields["trigger_source"] = trigger_source
elif kind == "source.burst_configure_v2":
cycles = fields["cycles"]
if isinstance(cycles, bool) or not isinstance(cycles, int):
@@ -1082,6 +1100,12 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
f"{prefix}.expected_previous_sha256 must be sha256:<64 lowercase hex>"
)
fields["write_mode"] = write_mode
+ elif kind == "source.arbitrary_volatile_replace_v2":
+ fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file")
+ fields["point_count"] = _positive_int(
+ fields["point_count"],
+ f"{prefix}.point_count",
+ )
elif kind == "source.arbitrary_select_v2":
fields["slot_id"] = _non_empty_str(fields["slot_id"], f"{prefix}.slot_id")
if _SOURCE_STORAGE_TOKEN.fullmatch(fields["slot_id"]) is None:
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index bbe4c67..b035235 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -55,9 +55,11 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) ->
"source.modulation_fm_configure_v2",
"source.modulation_pwm_configure_v2",
"source.sweep_configure_v2",
+ "source.sweep_fire_v2",
"source.burst_configure_v2",
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
+ "source.arbitrary_volatile_replace_v2",
"source.arbitrary_select_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index f0a3b12..8494938 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -41,6 +41,7 @@
SourceArbitraryPlaybackMode,
SourceArbitrarySelectRequest,
SourceArbitraryStorageRequest,
+ SourceArbitraryVolatileReplaceRequest,
SourceBasicConfigureRequest,
SourceBasicPatch,
SourceBurstConfigureRequest,
@@ -51,6 +52,7 @@
SourceCounterMeasureRequest,
SourceCouplingConfigureRequest,
SourceFmModulationConfigureRequest,
+ SourceFireRequest,
SourceHarmonicConfigureRequest,
SourceHarmonicDisableRequest,
SourceHarmonicPreset,
@@ -63,6 +65,7 @@
SourcePulseConfigureRequest,
SourceSweepConfigureRequest,
SourceSweepSpacing,
+ SourceTriggerSource,
SourceTrackingConfigureRequest,
SourceWaveformKind,
SourceStorageWriteMode,
@@ -574,12 +577,22 @@ def add_source_restore_capabilities() -> None:
add("source", "source.snapshot_v2", "source.modulation_pwm_configure_v2")
elif step.kind == "source.sweep_configure_v2":
add("source", "source.snapshot_v2", "source.sweep_configure_v2")
+ elif step.kind == "source.sweep_fire_v2":
+ add(
+ "source",
+ "source.snapshot_v2",
+ "source.sweep_configure_v2",
+ "source.sweep_fire_v2",
+ "source.output_v2",
+ )
elif step.kind == "source.burst_configure_v2":
add("source", "source.snapshot_v2", "source.burst_configure_v2")
elif step.kind == "source.pulse_configure_v2":
add("source", "source.snapshot_v2", "source.pulse_configure_v2")
elif step.kind == "source.arbitrary_storage_v2":
add("source", "source.snapshot_v2", "source.arbitrary_storage_v2")
+ elif step.kind == "source.arbitrary_volatile_replace_v2":
+ add("source", "source.snapshot_v2", "source.arbitrary_volatile_replace_v2")
elif step.kind == "source.arbitrary_select_v2":
add("source", "source.snapshot_v2", "source.arbitrary_select_v2")
elif step.kind == "source.combine_configure_v2":
@@ -1586,9 +1599,17 @@ def _run_step(
spacing=SourceSweepSpacing(step.fields["spacing"]),
steps=step.fields["steps"],
sweep_time_s=step.fields["sweep_time_s"],
+ trigger_source=SourceTriggerSource(
+ step.fields.get("trigger_source", "internal")
+ ),
)
)
artifact = {"source_operation": source_operation}
+ elif step.kind == "source.sweep_fire_v2":
+ _, source_operation = self._source_service(services=services).fire_sweep_v2(
+ SourceFireRequest(channel=step.fields["channel"])
+ )
+ artifact = {"source_operation": source_operation}
elif step.kind == "source.burst_configure_v2":
_, source_operation = self._source_service(services=services).configure_burst_v2(
SourceBurstConfigureRequest(
@@ -1633,6 +1654,27 @@ def _run_step(
payload=payload,
)
artifact = {"source_operation": source_operation}
+ elif step.kind == "source.arbitrary_volatile_replace_v2":
+ payload_path = Path(step.fields["file"])
+ if not payload_path.is_absolute():
+ payload_path = plan.path.parent / payload_path
+ try:
+ payload = payload_path.read_bytes()
+ except OSError as exc:
+ raise ConfigError(
+ "source.arbitrary_volatile_replace_v2 payload file is unreadable: "
+ f"{payload_path}"
+ ) from exc
+ _, source_operation = self._source_service(services=services).replace_arbitrary_volatile_v2(
+ SourceArbitraryVolatileReplaceRequest(
+ channel=step.fields["channel"],
+ payload_sha256="sha256:" + sha256(payload).hexdigest(),
+ payload_size_bytes=len(payload),
+ point_count=step.fields["point_count"],
+ ),
+ payload=payload,
+ )
+ artifact = {"source_operation": source_operation}
elif step.kind == "source.arbitrary_select_v2":
_, source_operation = self._source_service(services=services).select_arbitrary_v2(
SourceArbitrarySelectRequest(
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index e26081e..d865cd3 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -703,13 +703,56 @@ def test_format_run_plan_schema_lists_expect_and_power_output(self):
self.assertIn("source.modulation_fm_configure_v2", text)
self.assertIn("source.modulation_pwm_configure_v2", text)
self.assertIn("source.sweep_configure_v2", text)
+ self.assertIn("source.sweep_fire_v2", text)
self.assertIn("source.burst_configure_v2", text)
self.assertIn("source.pulse_configure_v2", text)
+ self.assertIn("source.arbitrary_volatile_replace_v2", text)
self.assertIn("sweep.frequency_response", text)
self.assertIn("[steps.expect]", text)
self.assertIn("[steps.expect_fft]", text)
self.assertIn("frequency_estimate_hz", text)
+ def test_source_v2_manual_sweep_fire_and_volatile_arb_plan_fields(self):
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "source.sweep_configure_v2"
+channel = 1
+start_hz = 100
+stop_hz = 1000
+spacing = "linear"
+steps = 10
+sweep_time_s = 1
+trigger_source = "manual"
+
+[[steps]]
+kind = "source.sweep_fire_v2"
+channel = 1
+
+[[steps]]
+kind = "source.arbitrary_volatile_replace_v2"
+channel = 1
+file = "volatile.bin"
+point_count = 2
+"""))
+
+ self.assertEqual(plan.steps[0].fields["trigger_source"], "manual")
+ self.assertEqual(plan.steps[1].kind, "source.sweep_fire_v2")
+ self.assertEqual(plan.steps[2].fields["point_count"], 2)
+
+ invalid = self._write_plan("""
+[[steps]]
+kind = "source.sweep_configure_v2"
+channel = 1
+start_hz = 100
+stop_hz = 1000
+spacing = "linear"
+steps = 10
+sweep_time_s = 1
+trigger_source = "external"
+""")
+ with self.assertRaisesRegex(ConfigError, "trigger_source must be internal or manual"):
+ load_run_plan(invalid)
+
def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self):
plan = load_run_plan(self._write_plan("""
[[steps]]
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 187080f..3c11df5 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -2825,6 +2825,85 @@ def _run_safety_guards(self, plan, *, services=None):
self.assertEqual(run_data["source_operations"], artifacts)
self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False))
+ def test_runs_manual_sweep_fire_and_volatile_arb_without_payload_in_artifacts(self):
+ with TemporaryDirectory() as tmp:
+ payload = b"wavebench-volatile-arb-sentinel-45a80a30"
+ payload_path = Path(tmp) / "volatile.bin"
+ payload_path.write_bytes(payload)
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "source.sweep_configure_v2"
+channel = 1
+start_hz = 100
+stop_hz = 1000
+spacing = "linear"
+steps = 10
+sweep_time_s = 1
+trigger_source = "manual"
+
+[[steps]]
+kind = "source.sweep_fire_v2"
+channel = 1
+
+[[steps]]
+kind = "source.arbitrary_volatile_replace_v2"
+channel = 1
+file = "volatile.bin"
+point_count = 2
+""",
+ )
+ )
+ artifacts = [
+ {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.sweep_configure_v2",
+ },
+ {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.sweep_fire_v2",
+ },
+ {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.arbitrary_volatile_replace_v2",
+ "request": {"payload_sha256": "sha256:" + sha256(payload).hexdigest()},
+ },
+ ]
+ source = Mock()
+ source.configure_sweep_v2.return_value = (SimpleNamespace(), artifacts[0])
+ source.fire_sweep_v2.return_value = (SimpleNamespace(), artifacts[1])
+ source.replace_arbitrary_volatile_v2.return_value = (SimpleNamespace(), artifacts[2])
+
+ class OfflineV2RunService(RunService):
+ def check(self, plan):
+ del plan
+
+ @contextmanager
+ def _run_instrument_services(self, plan):
+ del plan
+ yield RunInstrumentServices(source=source)
+
+ def _run_safety_guards(self, plan, *, services=None):
+ del plan, services
+
+ result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+ run_data = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+
+ sweep_request = source.configure_sweep_v2.call_args.args[0]
+ self.assertEqual(sweep_request.trigger_source.value, "manual")
+ self.assertEqual(source.fire_sweep_v2.call_args.args[0].channel, 1)
+ volatile_request = source.replace_arbitrary_volatile_v2.call_args.args[0]
+ self.assertEqual(volatile_request.point_count, 2)
+ self.assertEqual(volatile_request.payload_size_bytes, len(payload))
+ self.assertEqual(
+ source.replace_arbitrary_volatile_v2.call_args.kwargs["payload"],
+ payload,
+ )
+ self.assertEqual(run_data["source_operations"], artifacts)
+ self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py
index f13edcf..c367102 100644
--- a/tests/test_source_snapshot_v2.py
+++ b/tests/test_source_snapshot_v2.py
@@ -936,6 +936,7 @@ def configure_sweep_v2(self, request):
assert request.spacing.value == "linear"
assert request.steps == 101
assert request.sweep_time_s == 1.0
+ assert request.trigger_source.value == "manual"
return object(), sweep_artifact
def configure_burst_v2(self, request):
@@ -1091,6 +1092,8 @@ def configure_burst_v2(self, request):
"101",
"--sweep-time-s",
"1",
+ "--trigger-source",
+ "manual",
"--config",
"unused.toml",
]
@@ -1458,6 +1461,50 @@ def select_arbitrary_v2(self, request):
assert "abc" not in json.dumps(storage_payload, ensure_ascii=False)
+def test_source_v2_volatile_arbitrary_cli_emits_payload_free_operation_artifact(
+ tmp_path,
+ capsys,
+) -> None:
+ payload_file = tmp_path / "volatile.bin"
+ payload_file.write_bytes(b"\x00\x00\xff\x3f")
+ artifact = {
+ "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA,
+ "operation": "source.arbitrary_volatile_replace_v2",
+ "request": {"payload_sha256": "sha256:" + "a" * 64},
+ }
+
+ class _Service:
+ def replace_arbitrary_volatile_v2(self, request, *, payload):
+ assert request.channel == 1
+ assert request.payload_size_bytes == 4
+ assert request.point_count == 2
+ assert payload == b"\x00\x00\xff\x3f"
+ return object(), artifact
+
+ with patch("wavebench.cli._load_source_service", return_value=_Service()):
+ exit_code = cli.main(
+ [
+ "--json",
+ "source",
+ "arbitrary-volatile-replace-v2",
+ "--channel",
+ "1",
+ "--payload-file",
+ str(payload_file),
+ "--point-count",
+ "2",
+ "--config",
+ "unused.toml",
+ ]
+ )
+
+ result = json.loads(capsys.readouterr().out)
+ assert exit_code == 0
+ assert result["result"] == artifact
+ assert payload_file.name not in json.dumps(result, ensure_ascii=False)
+ assert payload_file.read_bytes().hex() not in json.dumps(result, ensure_ascii=False)
+
+
def test_source_v2_arbitrary_cli_rejects_invalid_request_before_loading_service(tmp_path, capsys) -> None:
payload_file = tmp_path / "payload.bin"
payload_file.write_bytes(b"abc")
From f45997520357960b81f297a1845eafec0d05a119 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 12:35:56 +0800
Subject: [PATCH 35/44] feat(source): model sweep implicit disable interlocks
---
.../source_extension_capabilities.py | 67 ++++++++
.../instruments/source_extensions.py | 16 ++
src/wavebench/services/source_service.py | 158 ++++++++++++++++--
tests/test_source_extensions.py | 2 +
tests/test_source_sweep_v2.py | 27 ++-
5 files changed, 253 insertions(+), 17 deletions(-)
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 14828bf..8158eb2 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -699,6 +699,7 @@ def _validate_write_contract(
raise ConfigError(
"source.sweep_configure_v2 requires readable output state on every channel"
)
+ _validate_sweep_implicit_disable_features(extensions, configurable)
if "source.sweep_fire_v2" in capabilities:
required = {"source.sweep_configure_v2", "source.output_v2"}
@@ -1214,6 +1215,72 @@ def _channels_with_sweep_configuration_readback(
return sweep_channels & basic_sweep_channels
+def _validate_sweep_implicit_disable_features(
+ extensions: SourceDescriptorExtensions,
+ configurable: frozenset[int],
+) -> None:
+ readable_channels = {
+ feature: _channels_with_inactive_feature_readback(extensions, feature)
+ for feature in (SourceFeature.BURST, SourceFeature.MODULATION)
+ }
+ for sweep in extensions.features:
+ if (
+ sweep.feature is not SourceFeature.SWEEP
+ or sweep.scope is not SourceFacetScope.CHANNEL
+ or sweep.support is not SupportState.SUPPORTED
+ or SourceFeatureDirection.CONFIGURE not in sweep.directions
+ or not isinstance(sweep.profile, SourceSweepCapabilityProfile)
+ ):
+ continue
+ for channel in set(sweep.channels) & configurable:
+ for feature in sweep.profile.implicit_disable_features:
+ if channel not in readable_channels[feature]:
+ raise ConfigError(
+ "source.sweep_configure_v2 requires readable inactive "
+ f"{feature.value} state on every configured channel"
+ )
+
+
+def _channels_with_inactive_feature_readback(
+ extensions: SourceDescriptorExtensions,
+ feature: SourceFeature,
+) -> frozenset[int]:
+ field = {
+ SourceFeature.BURST: SourceFieldId.BURST,
+ SourceFeature.MODULATION: SourceFieldId.MODULATION,
+ }.get(feature)
+ if field is None:
+ raise ValueError("sweep implicit disable feature is unsupported")
+ has_required_unconditional_query = any(
+ facet.feature is feature
+ and facet.scope is SourceFacetScope.CHANNEL
+ and facet.fields == (field,)
+ and not facet.activation_any
+ and facet.required
+ for facet in extensions.query_contract.facets
+ )
+ if not has_required_unconditional_query:
+ return frozenset()
+ return frozenset(
+ channel
+ for candidate in extensions.features
+ if (
+ candidate.feature is feature
+ and candidate.scope is SourceFacetScope.CHANNEL
+ and candidate.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.READ in candidate.directions
+ and (
+ isinstance(candidate.profile, SourceBurstCapabilityProfile)
+ and candidate.profile.inactive_readable
+ if feature is SourceFeature.BURST
+ else isinstance(candidate.profile, SourceModulationCapabilityProfile)
+ and candidate.profile.inactive_readable
+ )
+ )
+ for channel in candidate.channels
+ )
+
+
def _channels_with_arbitrary_storage_mutation_readback(
extensions: SourceDescriptorExtensions,
) -> frozenset[int]:
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index a40fe02..f77b637 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -629,6 +629,7 @@ class SourceSweepCapabilityProfile:
timing_readable: bool
marker_readable: bool
configuration_readable: bool = False
+ implicit_disable_features: tuple[SourceFeature, ...] = ()
def __post_init__(self) -> None:
_require_enum_tuple(self.spacing_modes, SourceSweepSpacing, "sweep spacing_modes")
@@ -636,6 +637,19 @@ def __post_init__(self) -> None:
_require_bool(self.timing_readable, "sweep timing_readable")
_require_bool(self.marker_readable, "sweep marker_readable")
_require_bool(self.configuration_readable, "sweep configuration_readable")
+ _require_enum_tuple(
+ self.implicit_disable_features,
+ SourceFeature,
+ "sweep implicit_disable_features",
+ allow_empty=True,
+ )
+ if not set(self.implicit_disable_features) <= {
+ SourceFeature.BURST,
+ SourceFeature.MODULATION,
+ }:
+ raise ValueError(
+ "sweep implicit_disable_features only supports burst and modulation"
+ )
@dataclass(frozen=True, slots=True)
@@ -646,6 +660,7 @@ class SourceBurstCapabilityProfile:
gate_readable: bool
triggered_internal_configuration_readable: bool = False
triggered_manual_configuration_readable: bool = False
+ inactive_readable: bool = False
def __post_init__(self) -> None:
_require_enum_tuple(self.modes, SourceBurstMode, "burst modes")
@@ -660,6 +675,7 @@ def __post_init__(self) -> None:
self.triggered_manual_configuration_readable,
"burst triggered_manual_configuration_readable",
)
+ _require_bool(self.inactive_readable, "burst inactive_readable")
@dataclass(frozen=True, slots=True)
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index de0b9c3..50f4ffd 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -2808,12 +2808,23 @@ def _configure_sweep_v2_transaction(
raise ConfigError(f"{operation} requires validated source_extensions")
if session_state is None:
raise ConfigError(f"{operation} requires a connection-bound session state")
- fields = self._source_sweep_v2_fields(request.channel)
+ interlock_features = self._source_sweep_v2_interlock_features(
+ extensions,
+ request.channel,
+ operation=operation,
+ )
+ fields = self._source_sweep_v2_fields(
+ request.channel,
+ interlock_features=interlock_features,
+ )
basic_field = next(field for field in fields if field.field is SourceFieldId.BASIC)
output_field = next(
field for field in fields if field.field is SourceFieldId.OUTPUT
)
sweep_field = next(field for field in fields if field.field is SourceFieldId.SWEEP)
+ postcondition_fields = tuple(
+ field for field in fields if field.field is not SourceFieldId.IDENTITY
+ )
target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel)
context = SourceOperationContextCoordinator(
session_state=session_state,
@@ -2825,16 +2836,7 @@ def _configure_sweep_v2_transaction(
required_off_outputs=(target_scope,),
emergency_off_outputs=(target_scope,),
restore_order=(),
- non_restorable_fields=tuple(
- field
- for field in fields
- if field.field
- in {
- SourceFieldId.BASIC,
- SourceFieldId.OUTPUT,
- SourceFieldId.SWEEP,
- }
- ),
+ non_restorable_fields=postcondition_fields,
correlation_id=correlation_id,
)
preflight_snapshot: SourceSnapshotV2 | None = None
@@ -2871,6 +2873,12 @@ def _configure_sweep_v2_transaction(
preflight_sweep,
preflight_output,
)
+ preflight_interlocks = self._source_sweep_v2_interlock_observations(
+ preflight_snapshot,
+ request.channel,
+ interlock_features,
+ operation=operation,
+ )
context.bind_baseline_snapshot_digest(
source_v2_digest(
(
@@ -2878,6 +2886,7 @@ def _configure_sweep_v2_transaction(
preflight_basic,
preflight_sweep,
preflight_output,
+ preflight_interlocks,
)
)
)
@@ -2909,7 +2918,7 @@ def _configure_sweep_v2_transaction(
postcondition = context.make_phase_spec(
SourceOperationPhase.POSTCONDITION,
allowed_io={"query"},
- fields=(basic_field, output_field, sweep_field),
+ fields=postcondition_fields,
max_steps=extensions.query_contract.max_queries,
)
with context.authorize_phase(postcondition) as authorization:
@@ -2939,7 +2948,7 @@ def _configure_sweep_v2_transaction(
context.complete_phase_verification(
authorization,
io_kind="query",
- fields=(basic_field, output_field, sweep_field),
+ fields=postcondition_fields,
)
except BaseException as exc:
failure = exc
@@ -5470,12 +5479,52 @@ def _source_pulse_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]:
)
@staticmethod
- def _source_sweep_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]:
+ def _source_sweep_v2_interlock_features(
+ extensions: SourceDescriptorExtensions,
+ channel: int,
+ *,
+ operation: str,
+ ) -> tuple[SourceFeature, ...]:
+ feature = next(
+ (
+ candidate
+ for candidate in extensions.features
+ if candidate.feature is SourceFeature.SWEEP
+ and candidate.scope is SourceFacetScope.CHANNEL
+ and channel in candidate.channels
+ and candidate.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.CONFIGURE in candidate.directions
+ ),
+ None,
+ )
+ if feature is None or not isinstance(feature.profile, SourceSweepCapabilityProfile):
+ raise ConfigError(f"{operation} requires declared sweep configuration support")
+ return feature.profile.implicit_disable_features
+
+ @staticmethod
+ def _source_sweep_v2_interlock_field(feature: SourceFeature) -> SourceFieldId:
+ if feature is SourceFeature.BURST:
+ return SourceFieldId.BURST
+ if feature is SourceFeature.MODULATION:
+ return SourceFieldId.MODULATION
+ raise ValueError("sweep interlock feature is unsupported")
+
+ @classmethod
+ def _source_sweep_v2_fields(
+ cls,
+ channel: int,
+ *,
+ interlock_features: tuple[SourceFeature, ...] = (),
+ ) -> tuple[SourceFieldRef, ...]:
target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel)
fields = (
SourceFieldRef(SourceFieldId.BASIC, target),
SourceFieldRef(SourceFieldId.OUTPUT, target),
SourceFieldRef(SourceFieldId.SWEEP, target),
+ *(
+ SourceFieldRef(cls._source_sweep_v2_interlock_field(feature), target)
+ for feature in interlock_features
+ ),
SourceFieldRef(
SourceFieldId.IDENTITY,
SourceScopeRef(SourceFacetScope.INSTRUMENT),
@@ -6902,6 +6951,61 @@ def _source_sweep_basic_runtime_profile(
raise ConfigError(f"{operation} requires readable basic state at runtime")
return feature.profile
+ @classmethod
+ def _source_sweep_v2_interlock_observations(
+ cls,
+ snapshot: SourceSnapshotV2,
+ channel: int,
+ features: tuple[SourceFeature, ...],
+ *,
+ operation: str,
+ ) -> tuple[tuple[SourceFeature, Observed[object]], ...]:
+ target = next((item for item in snapshot.channels if item.channel == channel), None)
+ if target is None:
+ raise ConfigError(f"{operation} target channel is absent from snapshot")
+ observations = {
+ SourceFeature.BURST: target.burst,
+ SourceFeature.MODULATION: target.modulation,
+ }
+ return tuple(
+ (feature, observations[feature])
+ for feature in features
+ if feature in observations
+ )
+
+ @classmethod
+ def _validate_source_sweep_v2_interlocks(
+ cls,
+ snapshot: SourceSnapshotV2,
+ channel: int,
+ features: tuple[SourceFeature, ...],
+ *,
+ operation: str,
+ ) -> None:
+ expected_types = {
+ SourceFeature.BURST: BurstFacet,
+ SourceFeature.MODULATION: ModulationFacet,
+ }
+ for feature, observed in cls._source_sweep_v2_interlock_observations(
+ snapshot,
+ channel,
+ features,
+ operation=operation,
+ ):
+ expected_type = expected_types[feature]
+ if observed.availability is not Availability.VALUE or not isinstance(
+ observed.value,
+ expected_type,
+ ):
+ raise ConfigError(
+ f"{operation} requires readable inactive {feature.value} state"
+ )
+ if (
+ observed.value.enabled.availability is not Availability.VALUE
+ or observed.value.enabled.value is not False
+ ):
+ raise ConfigError(f"{operation} requires inactive {feature.value} state")
+
def _validate_source_sweep_v2_preflight(
self,
request: SourceSweepConfigureRequest,
@@ -6931,6 +7035,12 @@ def _validate_source_sweep_v2_preflight(
raise ConfigError(f"{operation} requires sweep timing and marker readback")
if not profile.configuration_readable:
raise ConfigError(f"{operation} requires configured internal sweep readback")
+ self._validate_source_sweep_v2_interlocks(
+ snapshot,
+ request.channel,
+ profile.implicit_disable_features,
+ operation=operation,
+ )
basic_profile = self._source_sweep_basic_runtime_profile(
snapshot,
channel=request.channel,
@@ -7036,8 +7146,24 @@ def _validate_source_sweep_v2_postcondition(
if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False:
raise ConfigError(f"{operation} postcondition reports output ON")
self._validate_source_sweep_v2_readback(request, basic, sweep, operation=operation)
- if result.basic != basic or result.sweep != sweep:
- raise ConfigError(f"{operation} result readback does not match postcondition")
+ profile = self._source_sweep_runtime_profile(
+ snapshot,
+ channel=request.channel,
+ operation=operation,
+ )
+ self._validate_source_sweep_v2_interlocks(
+ snapshot,
+ request.channel,
+ profile.implicit_disable_features,
+ operation=operation,
+ )
+ # The driver MAIN phase is write-only. Some instruments maintain
+ # query-only Sweep fields (for example center/span or a disabled
+ # marker's retained frequency) that a bounded configure request does
+ # not own. The independently acquired postcondition snapshot above,
+ # not the driver MAIN result, is therefore authoritative for those
+ # fields. Both result and snapshot are separately validated against
+ # the declared request and safety scope.
@staticmethod
def _source_arbitrary_runtime_profile(
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index bcf9eeb..bda4fa4 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -256,6 +256,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"timing_readable",
"marker_readable",
"configuration_readable",
+ "implicit_disable_features",
),
"SourceBurstCapabilityProfile": (
"modes",
@@ -264,6 +265,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"gate_readable",
"triggered_internal_configuration_readable",
"triggered_manual_configuration_readable",
+ "inactive_readable",
),
"SourcePulseCapabilityProfile": (
"hold_modes",
diff --git a/tests/test_source_sweep_v2.py b/tests/test_source_sweep_v2.py
index b8993c3..213661c 100644
--- a/tests/test_source_sweep_v2.py
+++ b/tests/test_source_sweep_v2.py
@@ -131,6 +131,7 @@ def __init__(
session_state: InstrumentSessionState,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ result_marker_frequency_hz: float | None = None,
post_fire_mismatch: bool = False,
raise_after_fire: bool = False,
) -> None:
@@ -140,6 +141,7 @@ def __init__(
)
self.output_enabled = output_enabled
self.postcondition_mismatch = postcondition_mismatch
+ self.result_marker_frequency_hz = result_marker_frequency_hz
self.post_fire_mismatch = post_fire_mismatch
self.raise_after_fire = raise_after_fire
self.basic = basic_facet()
@@ -212,10 +214,21 @@ def configure_source_sweep_v2(
sweep_time_s=request.sweep_time_s,
trigger_source=request.trigger_source,
)
+ result_sweep = self.sweep
+ if self.result_marker_frequency_hz is not None:
+ result_sweep = replace(
+ result_sweep,
+ marker=Observed.value_of(
+ SourceSweepMarker(
+ enabled=Observed.value_of(False),
+ frequency_hz=Observed.value_of(self.result_marker_frequency_hz),
+ )
+ ),
+ )
return SourceSweepConfigureResult(
channel=request.channel,
basic=self.basic,
- sweep=self.sweep,
+ sweep=result_sweep,
output_enabled=False,
)
@@ -375,6 +388,7 @@ def _service(
*,
output_enabled: bool = False,
postcondition_mismatch: bool = False,
+ result_marker_frequency_hz: float | None = None,
post_fire_mismatch: bool = False,
raise_after_fire: bool = False,
dual_contract: bool = False,
@@ -390,6 +404,7 @@ def _service(
session_state=session_state,
output_enabled=output_enabled,
postcondition_mismatch=postcondition_mismatch,
+ result_marker_frequency_hz=result_marker_frequency_hz,
post_fire_mismatch=post_fire_mismatch,
raise_after_fire=raise_after_fire,
)
@@ -540,6 +555,16 @@ def test_sweep_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> No
}
+def test_sweep_configure_v2_uses_independent_postcondition_for_query_only_fields() -> None:
+ service, driver = _service(result_marker_frequency_hz=550.0)
+
+ result, _ = service.configure_sweep_v2(_request())
+
+ assert result.sweep.marker.value.frequency_hz.value == 550.0
+ assert driver.sweep.marker.value.frequency_hz.availability is Availability.NOT_APPLICABLE
+ assert driver.output_requests == []
+
+
def test_sweep_configure_v2_rejects_unsupported_spacing_before_write() -> None:
service, driver = _service(spacing_modes=(SourceSweepSpacing.LINEAR,))
From 78b1886cf21ca52d1c44e8278a63d540df577907 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 13:21:12 +0800
Subject: [PATCH 36/44] test(source): preserve sweep overlap gate on opt-out
---
tests/test_source_sweep_v2.py | 18 ++++++++++++++++++
tests/test_source_v1_routes.py | 8 +++++---
2 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/tests/test_source_sweep_v2.py b/tests/test_source_sweep_v2.py
index 213661c..88ccc42 100644
--- a/tests/test_source_sweep_v2.py
+++ b/tests/test_source_sweep_v2.py
@@ -293,6 +293,7 @@ def _extensions(
SourceSweepSpacing.STEP,
),
include_fire: bool = False,
+ v1_route_migration_enabled: bool = True,
):
base = source_extensions()
basic, output = base.features
@@ -361,6 +362,7 @@ def _extensions(
),
max_queries=7,
),
+ v1_route_migration_enabled=v1_route_migration_enabled,
)
@@ -393,6 +395,7 @@ def _service(
raise_after_fire: bool = False,
dual_contract: bool = False,
include_fire: bool = False,
+ v1_route_migration_enabled: bool = True,
spacing_modes: tuple[SourceSweepSpacing, ...] = (
SourceSweepSpacing.LINEAR,
SourceSweepSpacing.LOGARITHMIC,
@@ -423,6 +426,7 @@ def _service(
extensions=_extensions(
spacing_modes=spacing_modes,
include_fire=include_fire,
+ v1_route_migration_enabled=v1_route_migration_enabled,
),
),
capabilities=tuple(capabilities),
@@ -589,6 +593,20 @@ def test_v1_sweep_routes_reject_before_io_for_a_dual_contract_driver() -> None:
assert driver.transport.counters.query_calls == 0
+def test_v1_sweep_overlap_stays_closed_when_migration_is_disabled() -> None:
+ service, driver = _service(dual_contract=True, v1_route_migration_enabled=False)
+
+ with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"):
+ service.configure_sweep(object()) # type: ignore[arg-type]
+ with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"):
+ service.trigger_sweep(channel=1)
+
+ assert driver.v1_sweep_configure_calls == 0
+ assert driver.v1_sweep_trigger_calls == 0
+ assert driver.transport.counters.write_requests == 0
+ assert driver.transport.counters.query_calls == 0
+
+
def test_v1_restore_rejects_before_io_for_a_sweep_v2_driver() -> None:
service, driver = _service(dual_contract=True)
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index 93f014e..30c6290 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -88,11 +88,13 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.modulation_pm_configure_v2",
"source.modulation_fm_configure_v2",
"source.modulation_pwm_configure_v2",
- "source.sweep_configure_v2",
- "source.burst_configure_v2",
+ "source.sweep_configure_v2",
+ "source.sweep_fire_v2",
+ "source.burst_configure_v2",
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
- "source.arbitrary_select_v2",
+ "source.arbitrary_select_v2",
+ "source.arbitrary_volatile_replace_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
From eabe3d359775e4ab1ee409d83c5ba18563bc77e3 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 13:46:26 +0800
Subject: [PATCH 37/44] docs(source): clarify sweep and volatile boundaries
---
...\276\223\345\207\272\345\256\211\345\205\250RFC.md" | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index 9c5434f..bf17066 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -3658,6 +3658,12 @@ descriptor 必须声明 Sweep `READ`/`CONFIGURE`、至少一个 spacing、inte
readback;同一 channel 的 Basic `READ` 必须声明 `sweep` frequency mode,Output `READ` 与 output state readback
也为必需。核心在运行时按 request 检查所选 spacing,不要求每个设备支持全部三种 spacing。
+部分设备在启用 Sweep 时会隐式关闭已有 Burst 或 Modulation。`SourceSweepCapabilityProfile` 因而在既有字段末尾
+追加默认空的 `implicit_disable_features`;只允许声明 `burst` 与 `modulation`。声明该副作用的 driver 必须为相同
+channel 提供无条件、required 的纯读 facet 和明确的 inactive readback。Core 将这些字段加入本次动态 transaction
+closure,在 preflight 与 postcondition 都要求 `enabled = false`;它不自动关闭、恢复或重新启用它们。未声明该字段的
+设备继续使用原有 Basic/Output/Sweep 闭包,不增加查询或能力门。
+
该 operation 使用 `POTENTIAL_WHILE_OFF`,复用 fresh consistent snapshot、目标 output OFF、单次 driver 写、独立
postcondition 和主写入后的最多一次 V2 OFF recovery;没有额外 RMS、端接、热、共享功率或 trigger 接线门。本子项只
授权配置,不构成任何 fire 或输出 ON 授权。
@@ -3726,6 +3732,10 @@ CLI 在创建 Source Service 前读取本地 payload、计算摘要并构造 typ
run plan 使用 `source.arbitrary_volatile_replace_v2`,字段为 `channel`、相对 plan 的 `file` 和 `point_count`。
execution intent 仅保存文件名、摘要与大小,Source operation artifact 不保存 payload 或本地路径。该核心入口不构成任何
真实插件的 capability 声明;声明后会与 legacy ARB upload 形成 V1 overlap gate,必须单独完成等价性审计与实机验收。
+若 legacy upload 同时负责 binary 传输、频率/Vpp/offset、`output_on`、错误队列和 basic restore,当前窄的 volatile
+replace operation 不能作为无损桥接。`v1_route_migration_enabled = false` 只关闭 Basic/Output 的自动迁移,不能解除
+这一已声明高级 capability 与 V1 composite transaction 的重叠门;保留 legacy 路由或另立完整的复合合同是仅有的
+兼容选择。
Counter 按副作用拆开,而不是继续沿用 V1 的「完整 profile 一次设置」模型:
From 41ac7fd32342383fd8ab9f99e392569eecbe34ad Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 14:58:03 +0800
Subject: [PATCH 38/44] fix(source): preserve digest compatibility for additive
defaults
---
.../instruments/source_extensions.py | 50 ++++++++++++--
tests/test_source_extensions.py | 69 +++++++++++++++++++
2 files changed, 113 insertions(+), 6 deletions(-)
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index f77b637..f137c37 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -5781,8 +5781,32 @@ def configure_source_phase_relation_v2(
) -> SourceCrossChannelConfigureResult: ...
-def source_v2_to_data(value: object) -> object:
- """Convert Source V2 public values into strict JSON-compatible data."""
+def _source_v2_omits_additive_default(value: object, field_name: str) -> bool:
+ """Keep additive defaults out of canonical hashes for older plugin wheels."""
+
+ if isinstance(value, SourceBasicCapabilityProfile):
+ return (
+ field_name in {
+ "live_frequency_configurable",
+ "live_amplitude_vpp_configurable",
+ }
+ and getattr(value, field_name) is False
+ )
+ if isinstance(value, SourceSweepCapabilityProfile):
+ return field_name == "implicit_disable_features" and value.implicit_disable_features == ()
+ if isinstance(value, SourceBurstCapabilityProfile):
+ return (
+ field_name
+ in {"triggered_manual_configuration_readable", "inactive_readable"}
+ and getattr(value, field_name) is False
+ )
+ if isinstance(value, SourceDescriptorExtensions):
+ return field_name == "v1_route_migration_enabled" and value.v1_route_migration_enabled is True
+ return False
+
+
+def _source_v2_to_data(value: object, *, canonical: bool) -> object:
+ """Convert Source V2 values, optionally preserving historical hash semantics."""
if isinstance(value, StrEnum):
return value.value
@@ -5793,22 +5817,36 @@ def source_v2_to_data(value: object) -> object:
raise ValueError("Source V2 JSON cannot contain non-finite floats")
return value
if isinstance(value, tuple):
- return [source_v2_to_data(item) for item in value]
+ return [_source_v2_to_data(item, canonical=canonical) for item in value]
if isinstance(value, dict):
if any(not isinstance(key, str) for key in value):
raise TypeError("Source V2 JSON object keys must be strings")
- return {key: source_v2_to_data(value[key]) for key in sorted(value)}
+ return {
+ key: _source_v2_to_data(value[key], canonical=canonical)
+ for key in sorted(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] = source_v2_to_data(getattr(value, item.name))
+ if canonical and _source_v2_omits_additive_default(value, item.name):
+ continue
+ payload[item.name] = _source_v2_to_data(
+ getattr(value, item.name),
+ canonical=canonical,
+ )
return payload
raise TypeError(f"unsupported Source V2 JSON value: {type(value).__name__}")
+def source_v2_to_data(value: object) -> object:
+ """Convert Source V2 public values into strict JSON-compatible data."""
+
+ return _source_v2_to_data(value, canonical=False)
+
+
def source_v2_canonical_json(value: object) -> str:
return json.dumps(
- source_v2_to_data(value),
+ _source_v2_to_data(value, canonical=True),
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index bda4fa4..0f3f543 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import fields, replace
+import json
from math import nan
from pathlib import Path
import re
@@ -29,6 +30,7 @@
SourceReasonCode,
SupportState,
source_v2_canonical_json,
+ source_v2_to_data,
)
from tests.source_v2_fixtures import (
@@ -194,6 +196,73 @@ def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None
Observed.value_of(1.0, evidence_refs=("/tmp/private.json",))
+def test_source_v2_canonical_json_omits_only_additive_legacy_defaults() -> None:
+ basic = module.SourceBasicCapabilityProfile(
+ waveform_kinds=(module.SourceWaveformKind.SINE,),
+ frequency_modes=(module.SourceFrequencyMode.FIXED,),
+ amplitude_units=(module.SourceAmplitudeUnit.VPP,),
+ offset_readable=True,
+ phase_readable=True,
+ square_duty_readable=False,
+ )
+ basic_data = source_v2_to_data(basic)
+ basic_canonical = json.loads(source_v2_canonical_json(basic))
+ assert basic_data["live_frequency_configurable"] is False
+ assert basic_data["live_amplitude_vpp_configurable"] is False
+ assert "live_frequency_configurable" not in basic_canonical
+ assert "live_amplitude_vpp_configurable" not in basic_canonical
+ assert "live_frequency_configurable" in json.loads(
+ source_v2_canonical_json(replace(basic, live_frequency_configurable=True))
+ )
+
+ extensions = source_extensions()
+ extensions_data = source_v2_to_data(extensions)
+ extensions_canonical = json.loads(source_v2_canonical_json(extensions))
+ assert extensions_data["v1_route_migration_enabled"] is True
+ assert "v1_route_migration_enabled" not in extensions_canonical
+ assert json.loads(
+ source_v2_canonical_json(replace(extensions, v1_route_migration_enabled=False))
+ )["v1_route_migration_enabled"] is False
+
+ sweep = module.SourceSweepCapabilityProfile(
+ spacing_modes=(module.SourceSweepSpacing.LINEAR,),
+ trigger_sources=(module.SourceTriggerSource.INTERNAL,),
+ timing_readable=True,
+ marker_readable=True,
+ )
+ assert source_v2_to_data(sweep)["implicit_disable_features"] == []
+ assert "implicit_disable_features" not in json.loads(source_v2_canonical_json(sweep))
+ assert json.loads(
+ source_v2_canonical_json(
+ replace(sweep, implicit_disable_features=(module.SourceFeature.BURST,))
+ )
+ )["implicit_disable_features"] == ["burst"]
+
+ burst = module.SourceBurstCapabilityProfile(
+ modes=(module.SourceBurstMode.TRIGGERED,),
+ trigger_sources=(module.SourceTriggerSource.INTERNAL,),
+ timing_readable=True,
+ gate_readable=True,
+ )
+ burst_data = source_v2_to_data(burst)
+ burst_canonical = json.loads(source_v2_canonical_json(burst))
+ assert burst_data["triggered_manual_configuration_readable"] is False
+ assert burst_data["inactive_readable"] is False
+ assert "triggered_manual_configuration_readable" not in burst_canonical
+ assert "inactive_readable" not in burst_canonical
+ interlocked_burst = json.loads(
+ source_v2_canonical_json(
+ replace(
+ burst,
+ triggered_manual_configuration_readable=True,
+ inactive_readable=True,
+ )
+ )
+ )
+ assert interlocked_burst["triggered_manual_configuration_readable"] is True
+ assert interlocked_burst["inactive_readable"] is True
+
+
def test_resistance_bounds_require_two_finite_positive_limits() -> None:
assert module.ResistanceBounds(49.5, 50.5).maximum_ohm == 50.5
From 19a0a54f3cfc0a84c8f3b335b121452b4f715514 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 15:13:10 +0800
Subject: [PATCH 39/44] feat(source): admit sweep fire conformance evidence
---
src/wavebench/instruments/source_conformance.py | 4 ++++
tests/test_source_conformance.py | 15 ++++++++++++++-
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/wavebench/instruments/source_conformance.py b/src/wavebench/instruments/source_conformance.py
index 4344c0a..7b39adf 100644
--- a/src/wavebench/instruments/source_conformance.py
+++ b/src/wavebench/instruments/source_conformance.py
@@ -110,6 +110,10 @@
SourceFeature.SWEEP,
frozenset({SourceFeatureDirection.CONFIGURE}),
),
+ "source.sweep_fire_v2": (
+ SourceFeature.SWEEP,
+ frozenset({SourceFeatureDirection.FIRE}),
+ ),
"source.arbitrary_storage_v2": (
SourceFeature.ARBITRARY,
frozenset({SourceFeatureDirection.CONFIGURE}),
diff --git a/tests/test_source_conformance.py b/tests/test_source_conformance.py
index 33faad8..c9f61e5 100644
--- a/tests/test_source_conformance.py
+++ b/tests/test_source_conformance.py
@@ -9,6 +9,7 @@
from tests.source_v2_fixtures import source_descriptor, source_extensions
from wavebench.errors import ConfigError
from wavebench.instruments.source_conformance import (
+ _CAPABILITY_SCOPE,
SOURCE_CONFORMANCE_DIRECTORY,
SOURCE_CONFORMANCE_SCHEMA,
SOURCE_CONFORMANCE_SCHEME,
@@ -18,7 +19,12 @@
source_conformance_wheel_binding_digest,
validate_source_conformance_distribution,
)
-from wavebench.instruments.source_extensions import SOURCE_CONTRACT_VERSION, source_v2_digest
+from wavebench.instruments.source_extensions import (
+ SOURCE_CONTRACT_VERSION,
+ SourceFeature,
+ SourceFeatureDirection,
+ source_v2_digest,
+)
def _manifest_document(
@@ -71,6 +77,13 @@ def test_manifest_parser_verifies_required_identity_and_digest() -> None:
parse_source_conformance_manifest(document)
+def test_sweep_fire_conformance_scope_matches_the_v2_contract() -> None:
+ assert _CAPABILITY_SCOPE["source.sweep_fire_v2"] == (
+ SourceFeature.SWEEP,
+ frozenset({SourceFeatureDirection.FIRE}),
+ )
+
+
@pytest.mark.parametrize(
("field", "value", "message"),
[
From 46fb5af1a84c6169ad78b7d9f312d0bc442408fc Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 17:46:57 +0800
Subject: [PATCH 40/44] feat(plugins): manage multi-entry distributions
---
src/wavebench/plugins/lifecycle.py | 385 ++++++++++++++++++-----
src/wavebench/plugins/package_inspect.py | 5 -
tests/test_plugin_lifecycle.py | 182 ++++++++++-
tests/test_plugin_package_inspect.py | 5 +-
4 files changed, 492 insertions(+), 85 deletions(-)
diff --git a/src/wavebench/plugins/lifecycle.py b/src/wavebench/plugins/lifecycle.py
index ccd36b5..9f63aa9 100644
--- a/src/wavebench/plugins/lifecycle.py
+++ b/src/wavebench/plugins/lifecycle.py
@@ -23,13 +23,14 @@
from .package_inspect import (
PluginPackage,
+ WheelEntryPoint,
build_subprocess_environment,
inspect_plugin_package,
)
-LEDGER_SCHEMA_VERSION = 1
-JOURNAL_SCHEMA_VERSION = 1
+LEDGER_SCHEMA_VERSION = 2
+JOURNAL_SCHEMA_VERSION = 2
@dataclass(frozen=True)
@@ -164,7 +165,7 @@ def installed(self) -> tuple[InstalledPlugin, ...]:
file_owners = self._file_owners() if self._ledger_plugins(ledger) else {}
results: list[InstalledPlugin] = []
managed_distributions: set[str] = set()
- for driver_id, raw_record in sorted(self._ledger_plugins(ledger).items()):
+ for raw_record in self._ledger_plugins(ledger).values():
record = self._record(raw_record)
normalized = canonicalize_name(record["distribution"])
managed_distributions.add(normalized)
@@ -177,14 +178,16 @@ def installed(self) -> tuple[InstalledPlugin, ...]:
detail = "multiple installed distributions match / 存在多个同名分发"
else:
item = matches[0]
- expected_entry = (driver_id, record["entry_point"])
+ expected_entries = self._record_entry_point_pairs(record)
actual_entries = tuple(
- (entry["name"], entry["value"])
- for entry in item.get("entry_points", ())
+ sorted(
+ (str(entry["name"]), str(entry["value"]))
+ for entry in item.get("entry_points", ())
+ )
)
healthy = (
item.get("version") == record["version"]
- and expected_entry in actual_entries
+ and actual_entries == expected_entries
and item.get("integrity") is True
and item.get("metadata_sha256") == record["metadata_sha256"]
and item.get("record_sha256") == record["installed_record_sha256"]
@@ -201,15 +204,16 @@ def installed(self) -> tuple[InstalledPlugin, ...]:
else:
status = "healthy" if healthy else "drifted"
detail = "" if healthy else "installed metadata or files drifted / 安装元数据或文件已漂移"
- results.append(
+ results.extend(
InstalledPlugin(
- driver_id=driver_id,
+ driver_id=entry.driver_id,
distribution=record["distribution"],
version=record["version"],
status=status,
wheel_sha256=record["wheel_sha256"],
detail=detail,
)
+ for entry in self._record_entry_points(record)
)
for normalized, matches in sorted(inventory.items()):
if normalized in managed_distributions:
@@ -291,7 +295,7 @@ def remove(self, driver_id: str, *, dry_run: bool = False) -> LifecycleResult:
ledger = self._load_ledger(environment)
current = self.info(driver_id)
self._require_healthy(current)
- record = self._record(self._ledger_plugins(ledger)[driver_id])
+ record = self._record_for_driver_id(ledger, driver_id)
self._assert_distribution_file_ownership(record["distribution"])
rollback_wheel = self._record_wheel(record)
journal = self._journal(
@@ -308,7 +312,7 @@ def remove(self, driver_id: str, *, dry_run: bool = False) -> LifecycleResult:
self._update_journal(journal, "pip_finished")
if self._distribution_inventory(record["distribution"]):
raise ConfigError("plugin remove postflight failed / 插件卸载后检查失败")
- updated = self._without_record(ledger, driver_id)
+ updated = self._without_record(ledger, record["normalized_distribution"])
self._write_json(self.ledger_path, updated)
self._update_journal(journal, "ledger_committed")
self._remove_journal()
@@ -327,6 +331,9 @@ def recover(self) -> LifecycleResult:
if not self.journal_path.exists():
return LifecycleResult("nothing-to-recover", "", "", "")
journal = self._read_json(self.journal_path, "transaction journal")
+ if journal.get("schema_version") == 1:
+ journal = self._migrate_journal_v1(journal)
+ self._write_json(self.journal_path, journal)
self._validate_journal(journal, environment)
return self._recover_journal(journal, environment)
@@ -339,7 +346,18 @@ def _replace(
) -> LifecycleResult:
with self._inspected_input(path) as package:
self._assert_package_identity(package)
- driver_id = package.driver_ids[0]
+ ledger = self._load_ledger(self.environment())
+ current_record = self._record_for_distribution(
+ ledger,
+ package.normalized_distribution,
+ )
+ self._assert_replacement_entry_points(package, current_record)
+ self._assert_additional_entry_points_available(
+ package,
+ current_record,
+ ledger,
+ )
+ driver_id = self._record_entry_points(current_record)[0].driver_id
current = self.info(driver_id)
self._require_healthy(current)
if canonicalize_name(current.distribution) != package.normalized_distribution:
@@ -378,7 +396,12 @@ def _replace(
environment = self.environment()
self._assert_no_pending_journal()
ledger = self._load_ledger(environment)
- record = self._record(self._ledger_plugins(ledger).get(driver_id))
+ record = self._record_for_distribution(
+ ledger,
+ package.normalized_distribution,
+ )
+ self._assert_replacement_entry_points(package, record)
+ self._assert_additional_entry_points_available(package, record, ledger)
locked_current = self.info(driver_id)
if locked_current.status != "healthy":
raise ConfigError("managed plugin must be healthy before replacement / 替换前插件必须健康")
@@ -421,7 +444,7 @@ def _run_install_transaction(
package: PluginPackage,
cached_wheel: Path,
ledger: dict[str, object],
- previous_record: dict[str, str] | None,
+ previous_record: dict[str, object] | None,
) -> None:
environment = self.environment()
record = self._package_record(package, cached_wheel)
@@ -441,7 +464,7 @@ def _run_install_transaction(
record["installed_files_sha256"] = postflight["files_sha256"]
record["installed_record_sha256"] = postflight["record_sha256"]
self._update_journal(journal, "postflight_finished")
- updated = self._with_record(ledger, package.driver_ids[0], record)
+ updated = self._with_record(ledger, record)
self._write_json(self.ledger_path, updated)
self._update_journal(journal, "ledger_committed")
self._remove_journal()
@@ -459,7 +482,7 @@ def _run_install_transaction(
def _rollback_uninstall(
self,
- record: dict[str, str],
+ record: dict[str, object],
ledger: dict[str, object],
journal: dict[str, object],
original: Exception,
@@ -481,7 +504,7 @@ def _rollback_uninstall(
def _rollback_install(
self,
wheel: Path,
- record: dict[str, str],
+ record: dict[str, object],
ledger: dict[str, object],
journal: dict[str, object],
original: Exception,
@@ -520,9 +543,10 @@ def _recover_journal(
if not isinstance(before, dict) or not isinstance(package, dict):
raise ConfigError("invalid plugin transaction journal / 插件事务日志无效")
record = self._record(package)
- driver_id = record["driver_id"]
+ driver_id = self._record_entry_points(record)[0].driver_id
+ record_key = record["normalized_distribution"]
current_ledger = self._load_ledger(environment)
- before_record_raw = self._ledger_plugins(before).get(driver_id)
+ before_record_raw = self._ledger_plugins(before).get(record_key)
before_record = self._record(before_record_raw) if before_record_raw is not None else None
before_matches = (
self._distribution_absent(record)
@@ -543,9 +567,9 @@ def _recover_journal(
if stage == "ledger_committed":
if operation == "remove":
- ledger_matches = driver_id not in self._ledger_plugins(current_ledger)
+ ledger_matches = record_key not in self._ledger_plugins(current_ledger)
else:
- ledger_record = self._ledger_plugins(current_ledger).get(driver_id)
+ ledger_record = self._ledger_plugins(current_ledger).get(record_key)
ledger_matches = ledger_record == package
if not desired_matches or not ledger_matches:
return self._recovery_required()
@@ -555,12 +579,12 @@ def _recover_journal(
if stage in {"pip_started", "pip_finished", "postflight_finished"}:
if desired_matches:
if operation == "remove":
- updated = self._without_record(before, driver_id)
+ updated = self._without_record(before, record_key)
else:
postflight = self._postflight(record)
record["installed_files_sha256"] = str(postflight["files_sha256"])
record["installed_record_sha256"] = str(postflight["record_sha256"])
- updated = self._with_record(before, driver_id, record)
+ updated = self._with_record(before, record)
self._write_json(self.ledger_path, updated)
self._remove_journal()
return LifecycleResult("recovered-to-desired", driver_id, record["distribution"], record["version"])
@@ -584,7 +608,7 @@ def _recovery_required() -> LifecycleResult:
def _record_matches_environment(
self,
- record: dict[str, str],
+ record: dict[str, object],
*,
allow_empty_digest: bool = False,
) -> bool:
@@ -592,16 +616,18 @@ def _record_matches_environment(
if len(matches) != 1:
return False
item = matches[0]
- expected_entry = (record["driver_id"], record["entry_point"])
+ expected_entries = self._record_entry_point_pairs(record)
actual_entries = tuple(
- (entry["name"], entry["value"])
- for entry in item.get("entry_points", ())
+ sorted(
+ (str(entry["name"]), str(entry["value"]))
+ for entry in item.get("entry_points", ())
+ )
)
digest_matches = item.get("files_sha256") == record["installed_files_sha256"]
record_matches = item.get("record_sha256") == record["installed_record_sha256"]
metadata_matches = bool(
item.get("version") == record["version"]
- and expected_entry in actual_entries
+ and actual_entries == expected_entries
and item.get("integrity") is True
and item.get("metadata_sha256") == record["metadata_sha256"]
)
@@ -621,20 +647,21 @@ def _record_matches_environment(
return False
return True
- def _distribution_absent(self, record: dict[str, str]) -> bool:
+ def _distribution_absent(self, record: dict[str, object]) -> bool:
return not self._distribution_inventory(record["distribution"])
def _assert_package_identity(self, package: PluginPackage) -> None:
- driver_id = package.driver_ids[0]
builtin_references = {
reference
for descriptor in BUILTIN_INSTRUMENTS
for reference in (descriptor.driver_id, *descriptor.aliases)
}
- if driver_id in builtin_references:
+ for driver_id in package.driver_ids:
+ if driver_id not in builtin_references:
+ continue
expected_distribution = BUILTIN_MIGRATION_DISTRIBUTIONS.get(driver_id)
if package.normalized_distribution == canonicalize_name(expected_distribution or ""):
- return
+ continue
raise ConfigError(
f"external plugin conflicts with built-in driver / "
f"外置插件与内置驱动冲突: {driver_id}"
@@ -645,15 +672,28 @@ def _assert_first_install_allowed(
package: PluginPackage,
ledger: dict[str, object],
) -> None:
- driver_id = package.driver_ids[0]
- if driver_id in self._ledger_plugins(ledger):
+ records = tuple(
+ self._record(raw_record)
+ for raw_record in self._ledger_plugins(ledger).values()
+ )
+ if package.normalized_distribution in self._ledger_plugins(ledger):
raise ConfigError("plugin is already managed; use upgrade or downgrade / 插件已受管,请使用升级或降级")
+ managed_driver_ids = {
+ entry.driver_id
+ for record in records
+ for entry in self._record_entry_points(record)
+ }
+ if managed_driver_ids.intersection(package.driver_ids):
+ raise ConfigError("managed driver ID is already installed / 受管的驱动 ID 已安装")
inventory = self._inventory()
if package.normalized_distribution in inventory:
raise ConfigError("unmanaged distribution is already installed / 未受管的同名分发已安装")
for matches in inventory.values():
for item in matches:
- if any(entry["name"] == driver_id for entry in item.get("entry_points", ())):
+ if any(
+ str(entry["name"]) in package.driver_ids
+ for entry in item.get("entry_points", ())
+ ):
raise ConfigError("unmanaged driver ID is already installed / 未受管的同名驱动已安装")
self._assert_no_file_ownership_conflicts(
package,
@@ -736,7 +776,7 @@ def _cache_wheel(self, package: PluginPackage) -> Path:
self._fsync_directory(target_dir)
return target
- def _record_wheel(self, record: dict[str, str]) -> Path:
+ def _record_wheel(self, record: dict[str, object]) -> Path:
candidate = self.state_dir / record["wheel_cache"]
try:
candidate.resolve().relative_to(self.wheel_cache.resolve())
@@ -746,14 +786,15 @@ def _record_wheel(self, record: dict[str, str]) -> Path:
raise ConfigError("managed rollback wheel is missing or changed / 受管回滚 wheel 缺失或已变化")
return candidate
- def _package_record(self, package: PluginPackage, cached_wheel: Path) -> dict[str, str]:
- entry = package.entry_points[0]
+ def _package_record(self, package: PluginPackage, cached_wheel: Path) -> dict[str, object]:
return {
- "driver_id": entry.driver_id,
"distribution": package.distribution,
"normalized_distribution": package.normalized_distribution,
"version": package.version,
- "entry_point": entry.value,
+ "entry_points": [
+ {"driver_id": entry.driver_id, "value": entry.value}
+ for entry in package.entry_points
+ ],
"wheel_sha256": package.sha256,
"metadata_sha256": package.metadata_sha256,
"record_sha256": package.record_sha256,
@@ -762,7 +803,7 @@ def _package_record(self, package: PluginPackage, cached_wheel: Path) -> dict[st
"installed_record_sha256": "",
}
- def _postflight(self, record: dict[str, str]) -> dict[str, object]:
+ def _postflight(self, record: dict[str, object]) -> dict[str, object]:
script = r'''
import base64
import csv
@@ -783,12 +824,20 @@ def _postflight(self, record: dict[str, str]) -> dict[str, object]:
(
expected_name,
expected_version,
- expected_driver,
- expected_value,
+ expected_entries_json,
expected_metadata_sha256,
expected_installed_record_sha256,
wheel_path,
) = __import__("sys").argv[1:]
+try:
+ expected_entries = tuple(
+ sorted(
+ (str(item["driver_id"]), str(item["value"]))
+ for item in json.loads(expected_entries_json)
+ )
+ )
+except (KeyError, TypeError, ValueError, json.JSONDecodeError):
+ raise SystemExit("expected entry point record is invalid")
paths = list(dict.fromkeys((sysconfig.get_paths()["purelib"], sysconfig.get_paths()["platlib"])))
matches = []
for dist in distributions(path=paths):
@@ -817,27 +866,34 @@ def installed_metadata_hash(suffix):
if expected_installed_record_sha256 and installed_record_sha256 != expected_installed_record_sha256:
raise SystemExit("installed RECORD hash mismatch")
entries = [entry for entry in dist.entry_points if entry.group == "wavebench.instruments"]
-if len(entries) != 1 or entries[0].name != expected_driver or entries[0].value != expected_value:
+actual_entries = tuple(sorted((entry.name, entry.value) for entry in entries))
+if actual_entries != expected_entries:
raise SystemExit("installed entry point mismatch")
-descriptor = descriptor_from_entry_point(entries[0].load())
-if descriptor.driver_id != expected_driver or descriptor.aliases:
- raise SystemExit("descriptor identity mismatch")
-descriptor = descriptor.with_distribution(
- distribution=dist.metadata.get("Name", ""),
- version=dist.version,
- source=f"entry_point:{expected_driver}",
- origin="entry_point",
-)
-_validate_descriptor(descriptor, expected_kind=None)
-validate_source_conformance_distribution(descriptor, dist)
-validate_source_plugin_dependencies(
- descriptor,
- tuple(dist.metadata.get_all("Requires-Dist") or ()),
-)
-validate_rf_source_plugin_dependencies(
- descriptor,
- tuple(dist.metadata.get_all("Requires-Dist") or ()),
-)
+for expected_driver, expected_value in expected_entries:
+ entry = next(
+ item
+ for item in entries
+ if item.name == expected_driver and item.value == expected_value
+ )
+ descriptor = descriptor_from_entry_point(entry.load())
+ if descriptor.driver_id != expected_driver or descriptor.aliases:
+ raise SystemExit("descriptor identity mismatch")
+ descriptor = descriptor.with_distribution(
+ distribution=dist.metadata.get("Name", ""),
+ version=dist.version,
+ source=f"entry_point:{expected_driver}",
+ origin="entry_point",
+ )
+ _validate_descriptor(descriptor, expected_kind=None)
+ validate_source_conformance_distribution(descriptor, dist)
+ validate_source_plugin_dependencies(
+ 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:
@@ -877,7 +933,7 @@ def installed_metadata_hash(suffix):
file_rows.append((str(item), encoded))
files_digest = hashlib.sha256(json.dumps(sorted(file_rows)).encode()).hexdigest()
print(json.dumps({
- "driver_id": descriptor.driver_id,
+ "driver_ids": [driver_id for driver_id, _value in expected_entries],
"files_sha256": files_digest,
"record_sha256": installed_record_sha256,
}))
@@ -891,8 +947,13 @@ def installed_metadata_hash(suffix):
script,
canonicalize_name(record["distribution"]),
record["version"],
- record["driver_id"],
- record["entry_point"],
+ json.dumps(
+ [
+ {"driver_id": entry.driver_id, "value": entry.value}
+ for entry in self._record_entry_points(record)
+ ],
+ sort_keys=True,
+ ),
record["metadata_sha256"],
record["installed_record_sha256"],
str(self._record_wheel(record)),
@@ -1125,7 +1186,10 @@ def _load_ledger(self, environment: EnvironmentInfo) -> dict[str, object]:
if not self.ledger_path.exists():
return self.empty_ledger(environment)
ledger = self._read_json(self.ledger_path, "plugin ledger")
- if ledger.get("schema_version") != LEDGER_SCHEMA_VERSION:
+ schema_version = ledger.get("schema_version")
+ if schema_version == 1:
+ ledger = self._migrate_ledger_v1(ledger)
+ elif schema_version != LEDGER_SCHEMA_VERSION:
raise ConfigError("unsupported plugin ledger schema / 不支持的插件账本 schema")
stored_environment = ledger.get("environment")
if not isinstance(stored_environment, dict) or stored_environment.get("fingerprint") != environment.fingerprint:
@@ -1141,7 +1205,7 @@ def _ledger_plugins(ledger: dict[str, object]) -> dict[str, object]:
return plugins
@staticmethod
- def _record(value: object) -> dict[str, str]:
+ def _legacy_record(value: object) -> dict[str, str]:
if not isinstance(value, dict):
raise ConfigError("invalid managed plugin record / 受管插件记录无效")
required = (
@@ -1161,21 +1225,196 @@ def _record(value: object) -> dict[str, str]:
raise ConfigError("invalid managed plugin record / 受管插件记录无效")
return {field: str(value[field]) for field in required}
- def _with_record(
+ def _migrate_ledger_v1(self, ledger: dict[str, object]) -> dict[str, object]:
+ migrated: dict[str, object] = {}
+ for driver_id, raw_record in self._ledger_plugins(ledger).items():
+ legacy = self._legacy_record(raw_record)
+ if driver_id != legacy["driver_id"]:
+ raise ConfigError("invalid plugin ledger / 插件账本无效")
+ normalized = legacy["normalized_distribution"]
+ if normalized in migrated:
+ raise ConfigError("legacy plugin ledger has duplicate distributions / 旧插件账本存在重复分发")
+ migrated[normalized] = self._legacy_record_to_record(legacy)
+ return {
+ "schema_version": LEDGER_SCHEMA_VERSION,
+ "environment": ledger.get("environment"),
+ "generation": ledger.get("generation", 0),
+ "plugins": migrated,
+ }
+
+ def _migrate_journal_v1(self, journal: dict[str, object]) -> dict[str, object]:
+ before = journal.get("before_ledger")
+ package = journal.get("package")
+ if not isinstance(before, dict) or not isinstance(package, dict):
+ raise ConfigError("invalid plugin transaction journal / 插件事务日志无效")
+ migrated = dict(journal)
+ migrated["schema_version"] = JOURNAL_SCHEMA_VERSION
+ migrated["before_ledger"] = self._migrate_ledger_v1(before)
+ migrated["package"] = self._legacy_record_to_record(self._legacy_record(package))
+ return migrated
+
+ @staticmethod
+ def _legacy_record_to_record(legacy: dict[str, str]) -> dict[str, object]:
+ return {
+ "distribution": legacy["distribution"],
+ "normalized_distribution": legacy["normalized_distribution"],
+ "version": legacy["version"],
+ "entry_points": [
+ {
+ "driver_id": legacy["driver_id"],
+ "value": legacy["entry_point"],
+ }
+ ],
+ "wheel_sha256": legacy["wheel_sha256"],
+ "metadata_sha256": legacy["metadata_sha256"],
+ "record_sha256": legacy["record_sha256"],
+ "wheel_cache": legacy["wheel_cache"],
+ "installed_files_sha256": legacy["installed_files_sha256"],
+ "installed_record_sha256": legacy["installed_record_sha256"],
+ }
+
+ @staticmethod
+ def _record(value: object) -> dict[str, object]:
+ if not isinstance(value, dict):
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ required = (
+ "distribution",
+ "normalized_distribution",
+ "version",
+ "wheel_sha256",
+ "metadata_sha256",
+ "record_sha256",
+ "wheel_cache",
+ "installed_files_sha256",
+ "installed_record_sha256",
+ )
+ if any(not isinstance(value.get(field), str) for field in required):
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ entries = value.get("entry_points")
+ if not isinstance(entries, list) or not entries:
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ points: list[WheelEntryPoint] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ driver_id = entry.get("driver_id")
+ entry_point = entry.get("value")
+ if (
+ not isinstance(driver_id, str)
+ or not driver_id
+ or driver_id.strip() != driver_id
+ or any(character.isspace() for character in driver_id)
+ or not isinstance(entry_point, str)
+ or not entry_point.strip()
+ or ":" not in entry_point
+ ):
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ points.append(WheelEntryPoint(driver_id, entry_point))
+ ordered = tuple(sorted(points, key=lambda entry: entry.driver_id))
+ if tuple(points) != ordered or len({entry.driver_id for entry in points}) != len(points):
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ record = {field: str(value[field]) for field in required}
+ if record["normalized_distribution"] != canonicalize_name(record["distribution"]):
+ raise ConfigError("invalid managed plugin record / 受管插件记录无效")
+ record["entry_points"] = [
+ {"driver_id": entry.driver_id, "value": entry.value}
+ for entry in points
+ ]
+ return record
+
+ @staticmethod
+ def _record_entry_points(record: dict[str, object]) -> tuple[WheelEntryPoint, ...]:
+ return tuple(
+ WheelEntryPoint(str(entry["driver_id"]), str(entry["value"]))
+ for entry in record["entry_points"]
+ )
+
+ @classmethod
+ def _record_entry_point_pairs(cls, record: dict[str, object]) -> tuple[tuple[str, str], ...]:
+ return tuple((entry.driver_id, entry.value) for entry in cls._record_entry_points(record))
+
+ def _record_for_distribution(
+ self,
+ ledger: dict[str, object],
+ normalized_distribution: str,
+ ) -> dict[str, object]:
+ raw_record = self._ledger_plugins(ledger).get(normalized_distribution)
+ if raw_record is None:
+ raise ConfigError("managed plugin not found / 未找到受管插件")
+ return self._record(raw_record)
+
+ def _record_for_driver_id(
self,
ledger: dict[str, object],
driver_id: str,
- record: dict[str, str],
+ ) -> dict[str, object]:
+ for raw_record in self._ledger_plugins(ledger).values():
+ record = self._record(raw_record)
+ if any(entry.driver_id == driver_id for entry in self._record_entry_points(record)):
+ return record
+ raise ConfigError(f"installed plugin not found / 未找到已安装插件: {driver_id}")
+
+ def _assert_replacement_entry_points(
+ self,
+ package: PluginPackage,
+ record: dict[str, object],
+ ) -> None:
+ expected = set(self._record_entry_point_pairs(record))
+ actual = {(entry.driver_id, entry.value) for entry in package.entry_points}
+ if not expected <= actual:
+ raise ConfigError(
+ "replacement entry points do not preserve managed plugin / "
+ "替换包的 entry point 未保留受管插件"
+ )
+
+ def _assert_additional_entry_points_available(
+ self,
+ package: PluginPackage,
+ record: dict[str, object],
+ ledger: dict[str, object],
+ ) -> None:
+ existing_driver_ids = {
+ entry.driver_id for entry in self._record_entry_points(record)
+ }
+ additional_driver_ids = set(package.driver_ids) - existing_driver_ids
+ if not additional_driver_ids:
+ return
+ for raw_record in self._ledger_plugins(ledger).values():
+ candidate = self._record(raw_record)
+ if candidate["normalized_distribution"] == record["normalized_distribution"]:
+ continue
+ if additional_driver_ids.intersection(
+ entry.driver_id for entry in self._record_entry_points(candidate)
+ ):
+ raise ConfigError("managed driver ID is already installed / 受管的驱动 ID 已安装")
+ for normalized, matches in self._inventory().items():
+ if normalized == record["normalized_distribution"]:
+ continue
+ for item in matches:
+ if any(
+ str(entry["name"]) in additional_driver_ids
+ for entry in item.get("entry_points", ())
+ ):
+ raise ConfigError("unmanaged driver ID is already installed / 未受管的同名驱动已安装")
+
+ def _with_record(
+ self,
+ ledger: dict[str, object],
+ record: dict[str, object],
) -> dict[str, object]:
updated = json.loads(json.dumps(ledger))
updated["generation"] = int(updated.get("generation", 0)) + 1
- self._ledger_plugins(updated)[driver_id] = record
+ self._ledger_plugins(updated)[record["normalized_distribution"]] = record
return updated
- def _without_record(self, ledger: dict[str, object], driver_id: str) -> dict[str, object]:
+ def _without_record(
+ self,
+ ledger: dict[str, object],
+ normalized_distribution: str,
+ ) -> dict[str, object]:
updated = json.loads(json.dumps(ledger))
updated["generation"] = int(updated.get("generation", 0)) + 1
- del self._ledger_plugins(updated)[driver_id]
+ del self._ledger_plugins(updated)[normalized_distribution]
return updated
def _journal(
diff --git a/src/wavebench/plugins/package_inspect.py b/src/wavebench/plugins/package_inspect.py
index 11811cd..1819fa1 100644
--- a/src/wavebench/plugins/package_inspect.py
+++ b/src/wavebench/plugins/package_inspect.py
@@ -408,11 +408,6 @@ def _validated_entry_points(items: Iterable[tuple[str, object]]) -> tuple[WheelE
result.append(WheelEntryPoint(name, value.strip()))
if not result:
raise ConfigError("plugin wheel has no instrument entry points / 插件 wheel 没有仪器 entry point")
- if len(result) != 1:
- raise ConfigError(
- "plugin wheel must provide exactly one instrument entry point / "
- "插件 wheel 必须恰好提供一个仪器 entry point"
- )
return tuple(sorted(result, key=lambda item: item.driver_id))
diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py
index e126e50..f5bf4b5 100644
--- a/tests/test_plugin_lifecycle.py
+++ b/tests/test_plugin_lifecycle.py
@@ -258,6 +258,7 @@ def _plugin_wheel(
*,
version: str,
driver_id: str = "example.scope",
+ additional_driver_id: str | None = None,
distribution: str = "wavebench-example-scope",
kind: str = "scope",
capabilities: tuple[str, ...] = ("scope.idn",),
@@ -272,6 +273,10 @@ def _plugin_wheel(
) -> Path:
if source_v2 and legacy_source_v1:
raise ValueError("a fixture wheel cannot be both Source V1-only and Source V2")
+ if additional_driver_id is not None and (
+ broken_descriptor or source_v2 or legacy_source_v1 or additional_driver_id == driver_id
+ ):
+ raise ValueError("additional fixture entry points require distinct default descriptors")
filename_name = distribution.replace("-", "_")
dist_info = f"{filename_name}-{version}.dist-info"
package_name = "wavebench_example_scope"
@@ -303,6 +308,15 @@ def _plugin_wheel(
source_v2_write=source_v2_write,
)
else:
+ additional_descriptor = (
+ f"""
+
+def descriptor_v2():
+ return _descriptor({additional_driver_id!r})
+"""
+ if additional_driver_id is not None
+ else ""
+ )
package = f'''from wavebench.instruments.api import InstrumentDescriptor
@@ -314,9 +328,9 @@ def close(self):
pass
-def descriptor():
+def _descriptor(driver_id):
return InstrumentDescriptor(
- driver_id={driver_id!r},
+ driver_id=driver_id,
kind={kind!r},
display_name="Example Instrument",
manufacturer="Example",
@@ -329,6 +343,11 @@ def descriptor():
permissions=("instrument.io",),
factory=lambda context: Driver(),
)
+
+
+def descriptor():
+ return _descriptor({driver_id!r})
+{additional_descriptor}
'''.encode()
members = {
f"{dist_info}/METADATA": metadata,
@@ -338,8 +357,12 @@ def descriptor():
f"{package_name}/__init__.py": package,
}
if include_entry_point:
+ entries = [(driver_id, f"{package_name}:descriptor")]
+ if additional_driver_id is not None:
+ entries.append((additional_driver_id, f"{package_name}:descriptor_v2"))
members[f"{dist_info}/entry_points.txt"] = (
- f"[wavebench.instruments]\n{driver_id} = {package_name}:descriptor\n"
+ "[wavebench.instruments]\n"
+ + "".join(f"{name} = {target}\n" for name, target in entries)
).encode()
output = io.StringIO(newline="")
writer = csv.writer(output, lineterminator="\n")
@@ -792,6 +815,157 @@ def test_install_status_and_remove_round_trip(tmp_path):
assert lifecycle.installed() == ()
+def test_multi_entry_point_distribution_installs_updates_and_removes_atomically(tmp_path):
+ python = _target_venv(tmp_path)
+ v1 = _plugin_wheel(
+ tmp_path,
+ version="0.1.0",
+ driver_id="example.scope",
+ additional_driver_id="example.scope-v2",
+ distribution="wavebench-example-multi",
+ )
+ v2 = _plugin_wheel(
+ tmp_path,
+ version="0.2.0",
+ driver_id="example.scope",
+ additional_driver_id="example.scope-v2",
+ distribution="wavebench-example-multi",
+ )
+ lifecycle = PluginLifecycle(python_executable=python)
+
+ assert lifecycle.install(v1).status == "installed"
+ assert [
+ (item.driver_id, item.version, item.status)
+ for item in lifecycle.installed()
+ ] == [
+ ("example.scope", "0.1.0", "healthy"),
+ ("example.scope-v2", "0.1.0", "healthy"),
+ ]
+ assert lifecycle.info("example.scope-v2").distribution == "wavebench-example-multi"
+ registry_script = """
+from wavebench.instruments.registry import build_instrument_registry
+
+registry = build_instrument_registry()
+assert registry.resolve("example.scope", expected_kind="scope").origin == "entry_point"
+assert registry.resolve("example.scope-v2", expected_kind="scope").origin == "entry_point"
+"""
+ _run([str(python), "-I", "-c", registry_script])
+
+ assert lifecycle.upgrade(v2).status == "upgraded"
+ assert {item.version for item in lifecycle.installed()} == {"0.2.0"}
+ assert lifecycle.remove("example.scope-v2").status == "removed"
+ assert lifecycle.installed() == ()
+ ledger = json.loads(lifecycle.ledger_path.read_text(encoding="utf-8"))
+ assert ledger["plugins"] == {}
+
+
+def test_lifecycle_migrates_a_single_entry_v1_ledger_on_next_mutation(tmp_path):
+ python = _target_venv(tmp_path)
+ wheel = _plugin_wheel(tmp_path, version="0.1.0")
+ lifecycle = PluginLifecycle(python_executable=python)
+ lifecycle.install(wheel)
+ current = json.loads(lifecycle.ledger_path.read_text(encoding="utf-8"))
+ record = next(iter(current["plugins"].values()))
+ entry = record["entry_points"][0]
+ legacy_record = {
+ "driver_id": entry["driver_id"],
+ "distribution": record["distribution"],
+ "normalized_distribution": record["normalized_distribution"],
+ "version": record["version"],
+ "entry_point": entry["value"],
+ "wheel_sha256": record["wheel_sha256"],
+ "metadata_sha256": record["metadata_sha256"],
+ "record_sha256": record["record_sha256"],
+ "wheel_cache": record["wheel_cache"],
+ "installed_files_sha256": record["installed_files_sha256"],
+ "installed_record_sha256": record["installed_record_sha256"],
+ }
+ lifecycle._write_json(
+ lifecycle.ledger_path,
+ {
+ "schema_version": 1,
+ "environment": current["environment"],
+ "generation": current["generation"],
+ "plugins": {entry["driver_id"]: legacy_record},
+ },
+ )
+
+ assert lifecycle.info("example.scope").status == "healthy"
+ assert lifecycle.remove("example.scope").status == "removed"
+ migrated = json.loads(lifecycle.ledger_path.read_text(encoding="utf-8"))
+ assert migrated["schema_version"] == 2
+ assert migrated["plugins"] == {}
+
+
+def test_lifecycle_upgrades_a_legacy_single_entry_distribution_to_add_opt_in_entry(tmp_path):
+ python = _target_venv(tmp_path)
+ v1 = _plugin_wheel(
+ tmp_path,
+ version="0.1.0",
+ driver_id="example.scope",
+ distribution="wavebench-example-multi",
+ )
+ v2 = _plugin_wheel(
+ tmp_path,
+ version="0.2.0",
+ driver_id="example.scope",
+ additional_driver_id="example.scope-v2",
+ distribution="wavebench-example-multi",
+ )
+ lifecycle = PluginLifecycle(python_executable=python)
+ lifecycle.install(v1)
+
+ assert lifecycle.upgrade(v2).status == "upgraded"
+ assert [item.driver_id for item in lifecycle.installed()] == [
+ "example.scope",
+ "example.scope-v2",
+ ]
+
+
+def test_lifecycle_recovers_a_v1_prepared_journal(tmp_path):
+ python = _target_venv(tmp_path)
+ wheel = _plugin_wheel(tmp_path, version="0.1.0")
+ lifecycle = PluginLifecycle(python_executable=python)
+ environment = lifecycle.environment()
+ lifecycle.state_dir.mkdir(mode=0o700)
+ with lifecycle._inspected_input(wheel) as package:
+ cached = lifecycle._cache_wheel(package)
+ record = lifecycle._package_record(package, cached)
+ entry = record["entry_points"][0]
+ legacy_record = {
+ "driver_id": entry["driver_id"],
+ "distribution": record["distribution"],
+ "normalized_distribution": record["normalized_distribution"],
+ "version": record["version"],
+ "entry_point": entry["value"],
+ "wheel_sha256": record["wheel_sha256"],
+ "metadata_sha256": record["metadata_sha256"],
+ "record_sha256": record["record_sha256"],
+ "wheel_cache": record["wheel_cache"],
+ "installed_files_sha256": record["installed_files_sha256"],
+ "installed_record_sha256": record["installed_record_sha256"],
+ }
+ lifecycle._write_json(
+ lifecycle.journal_path,
+ {
+ "schema_version": 1,
+ "environment": environment.to_json(),
+ "operation": "install",
+ "stage": "prepared",
+ "before_ledger": {
+ "schema_version": 1,
+ "environment": environment.to_json(),
+ "generation": 0,
+ "plugins": {},
+ },
+ "package": legacy_record,
+ },
+ )
+
+ assert lifecycle.recover().status == "recovered-before-mutation"
+ assert not lifecycle.journal_path.exists()
+
+
def test_source_directory_install_keeps_built_wheel_alive_until_cached(tmp_path):
python = _target_venv(tmp_path)
source = _plugin_source(tmp_path)
@@ -996,7 +1170,7 @@ def test_prepared_journal_blocks_mutation_and_can_be_recovered(tmp_path):
lifecycle.journal_path.write_text(
json.dumps(
{
- "schema_version": 1,
+ "schema_version": 2,
"environment": environment.to_json(),
"operation": "install",
"stage": "prepared",
diff --git a/tests/test_plugin_package_inspect.py b/tests/test_plugin_package_inspect.py
index d2152d5..b19add4 100644
--- a/tests/test_plugin_package_inspect.py
+++ b/tests/test_plugin_package_inspect.py
@@ -339,7 +339,7 @@ def test_inspect_wheel_rejects_excessive_uncompressed_size(tmp_path, monkeypatch
inspect_plugin_wheel(path)
-def test_inspect_wheel_rejects_multi_driver_distribution(tmp_path):
+def test_inspect_wheel_accepts_distinct_entry_points_from_one_distribution(tmp_path):
path = _wheel(
tmp_path,
entry_points=(
@@ -349,8 +349,7 @@ def test_inspect_wheel_rejects_multi_driver_distribution(tmp_path):
),
)
- with pytest.raises(ConfigError, match="exactly one instrument entry point"):
- inspect_plugin_wheel(path)
+ assert inspect_plugin_wheel(path).driver_ids == ("example.dmm", "example.scope")
def test_inspect_source_directory_builds_one_offline_wheel(tmp_path):
From a12226d45123a45c00f226f2a283286c5851591a Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:20:25 +0800
Subject: [PATCH 41/44] fix(source): allow volatile replace from inactive arb
state
---
.../instruments/source_extensions.py | 1 -
src/wavebench/services/source_service.py | 24 +++----
tests/test_source_arbitrary_v2.py | 67 ++++++++++++++++++-
3 files changed, 75 insertions(+), 17 deletions(-)
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index f137c37..a29713f 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -1792,7 +1792,6 @@ def __post_init__(self) -> None:
energy_effect=SourceEnergyEffect.POTENTIAL_WHILE_OFF,
storage_effect=SourceStorageEffect.REPLACE,
required_fields=(
- SourceFieldId.ARBITRARY_SELECTION,
SourceFieldId.BASIC,
SourceFieldId.OUTPUT,
SourceFieldId.IDENTITY,
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 50f4ffd..5c566c7 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -4082,6 +4082,11 @@ def _replace_arbitrary_volatile_v2_transaction(
output_field = next(
field for field in fields if field.field is SourceFieldId.OUTPUT
)
+ identity_field = next(
+ field for field in fields if field.field is SourceFieldId.IDENTITY
+ )
+ preflight_fields = (basic_field, output_field, identity_field)
+ postcondition_fields = (selection_field, basic_field, output_field)
target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel)
context = SourceOperationContextCoordinator(
session_state=session_state,
@@ -4117,7 +4122,7 @@ def _replace_arbitrary_volatile_v2_transaction(
preflight = context.make_phase_spec(
SourceOperationPhase.PREFLIGHT,
allowed_io={"query"},
- fields=fields,
+ fields=preflight_fields,
max_steps=extensions.query_contract.max_queries,
)
with context.authorize_phase(preflight) as authorization:
@@ -4126,11 +4131,7 @@ def _replace_arbitrary_volatile_v2_transaction(
correlation_id=context.correlation_id,
deadline=authorization.deadline,
)
- (
- preflight_basic,
- preflight_arbitrary,
- preflight_output,
- ) = self._source_v2_arbitrary_select_target(
+ preflight_basic, preflight_output = self._source_v2_target(
preflight_snapshot,
request.channel,
operation=operation,
@@ -4139,7 +4140,6 @@ def _replace_arbitrary_volatile_v2_transaction(
request,
preflight_snapshot,
preflight_basic,
- preflight_arbitrary,
preflight_output,
)
context.bind_baseline_snapshot_digest(
@@ -4147,7 +4147,6 @@ def _replace_arbitrary_volatile_v2_transaction(
(
request.channel,
preflight_basic,
- preflight_arbitrary,
preflight_output,
)
)
@@ -4155,7 +4154,7 @@ def _replace_arbitrary_volatile_v2_transaction(
context.complete_phase_verification(
authorization,
io_kind="query",
- fields=fields,
+ fields=preflight_fields,
)
main = context.make_phase_spec(
@@ -4180,7 +4179,7 @@ def _replace_arbitrary_volatile_v2_transaction(
postcondition = context.make_phase_spec(
SourceOperationPhase.POSTCONDITION,
allowed_io={"query"},
- fields=fields,
+ fields=postcondition_fields,
max_steps=extensions.query_contract.max_queries,
)
with context.authorize_phase(postcondition) as authorization:
@@ -4209,7 +4208,7 @@ def _replace_arbitrary_volatile_v2_transaction(
context.complete_phase_verification(
authorization,
io_kind="query",
- fields=fields,
+ fields=postcondition_fields,
)
except BaseException as exc:
failure = exc
@@ -7481,10 +7480,9 @@ def _validate_source_arbitrary_volatile_replace_v2_preflight(
request: SourceArbitraryVolatileReplaceRequest,
snapshot: SourceSnapshotV2,
basic: BasicWaveFacet,
- arbitrary: ArbitraryFacet,
output: OutputFacet,
) -> None:
- del basic, arbitrary
+ del basic
operation = "source.arbitrary_volatile_replace_v2"
if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
raise ConfigError(f"{operation} requires a fresh consistent snapshot")
diff --git a/tests/test_source_arbitrary_v2.py b/tests/test_source_arbitrary_v2.py
index 5859a16..aced82d 100644
--- a/tests/test_source_arbitrary_v2.py
+++ b/tests/test_source_arbitrary_v2.py
@@ -34,6 +34,9 @@
SourceArbitraryStorageSlot,
SourceArbitraryVolatileReplaceRequest,
SourceArbitraryVolatileReplaceResult,
+ SourceActivationPredicate,
+ SourceActivationRule,
+ SourceAnchorField,
SourceConstraintApplicability,
SourceFacetQueryContract,
SourceFacetScope,
@@ -149,15 +152,32 @@ def __init__(
self.volatile_requests: list[tuple[SourceArbitraryVolatileReplaceRequest, bytes]] = []
self.output_requests: list[SourceOutputRequest] = []
self.v1_upload_calls = 0
+ self.query_plans = []
+ self.query_records = []
def close(self) -> None:
self.transport.close()
def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord:
+ self.query_plans.append(plan)
records = []
for index, item in enumerate(plan.items):
if index == 0:
self.transport.query("SOURCE:STATE?")
+ if (
+ item.activation_any
+ and self.basic.waveform_kind.value is not SourceWaveformKind.ARBITRARY
+ ):
+ records.append(
+ SourceProtocolQueryRecord(
+ item_id=item.item_id,
+ effect=item.effect,
+ outcome=SourceQueryItemOutcome.SKIPPED,
+ query_count=0,
+ reason_code=SourceReasonCode.INACTIVE_BY_ANCHOR,
+ )
+ )
+ continue
observations = []
for field in item.fields:
if field.field is SourceFieldId.IDENTITY:
@@ -184,7 +204,7 @@ def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord:
observations=tuple(observations),
)
)
- return SourceQueryExecutionRecord(
+ record = SourceQueryExecutionRecord(
contract_version=SOURCE_CONTRACT_VERSION,
plan_id=plan.plan_id,
items=tuple(records),
@@ -192,6 +212,8 @@ def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord:
device_revision_token_before="revision-1",
device_revision_token_after="revision-1",
)
+ self.query_records.append(record)
+ return record
def read_source_arbitrary_storage_v2(
self,
@@ -337,6 +359,7 @@ def _extensions(
SourceArbitraryPlaybackMode.TRUE_ARB,
),
v1_route_migration_enabled: bool = True,
+ arbitrary_active_only: bool = False,
):
base = source_extensions()
basic, output = base.features
@@ -367,10 +390,21 @@ def _extensions(
feature=SourceFeature.ARBITRARY,
scope=SourceFacetScope.CHANNEL,
fields=(SourceFieldId.ARBITRARY_SELECTION,),
- activation_any=(),
+ activation_any=(
+ SourceActivationRule(
+ predicates=(
+ SourceActivationPredicate(
+ field=SourceAnchorField.WAVEFORM_KIND,
+ equals=SourceWaveformKind.ARBITRARY,
+ ),
+ ),
+ ),
+ )
+ if arbitrary_active_only
+ else (),
effect=SourceQueryEffect.PURE_READ,
max_queries=1,
- required=True,
+ required=not arbitrary_active_only,
)
return replace(
base,
@@ -436,6 +470,7 @@ def _service(
SourceArbitraryPlaybackMode.DDS,
SourceArbitraryPlaybackMode.TRUE_ARB,
),
+ arbitrary_active_only: bool = False,
) -> tuple[SourceService, _ArbitraryWriteDriver]:
session_state = InstrumentSessionState(epoch_id="source-arbitrary-v2")
driver = _ArbitraryWriteDriver(
@@ -460,6 +495,7 @@ def _service(
extensions=_extensions(
playback_modes=playback_modes,
v1_route_migration_enabled=v1_route_migration_enabled,
+ arbitrary_active_only=arbitrary_active_only,
),
),
capabilities=tuple(capabilities),
@@ -691,6 +727,31 @@ def test_arbitrary_volatile_replace_v2_writes_once_and_keeps_output_off() -> Non
]
+def test_arbitrary_volatile_replace_v2_accepts_an_inactive_preflight_selection() -> None:
+ service, driver = _service(volatile=True, arbitrary_active_only=True)
+ payload = b"\x00\x00\xff\x3f"
+
+ result, _ = service.replace_arbitrary_volatile_v2(
+ _volatile_request(payload),
+ payload=payload,
+ )
+
+ assert result.selected_waveform_id == "volatile"
+ assert driver.volatile_requests == [(_volatile_request(payload), payload)]
+ assert any(
+ record.outcome is SourceQueryItemOutcome.SKIPPED
+ for record in driver.query_records[0].items
+ )
+ assert any(
+ record.outcome is SourceQueryItemOutcome.OBSERVED
+ and any(
+ observation.field.field is SourceFieldId.ARBITRARY_SELECTION
+ for observation in record.observations
+ )
+ for record in driver.query_records[1].items
+ )
+
+
def test_arbitrary_volatile_replace_v2_rejects_invalid_payload_and_preflight_before_write() -> None:
payload = b"\x00\x00\xff\x3f"
request = _volatile_request(payload)
From 338aaae78fef11a1c7e99d0a9ca63cb62711a6e0 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:29:57 +0800
Subject: [PATCH 42/44] fix(source): allow stale disabled sweep marker
---
src/wavebench/instruments/models.py | 2 +-
tests/test_instrument_models.py | 28 ++++++++++++++++++++++++++--
2 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/src/wavebench/instruments/models.py b/src/wavebench/instruments/models.py
index bbff409..45d067e 100644
--- a/src/wavebench/instruments/models.py
+++ b/src/wavebench/instruments/models.py
@@ -2642,7 +2642,7 @@ def __post_init__(self) -> None:
raise ValueError("unsupported source sweep trigger slope")
if self.trigger_out not in {"OFF", "POSITIVE", "NEGATIVE"}:
raise ValueError("unsupported source sweep trigger output")
- if not self.start_hz <= self.marker_frequency_hz <= self.stop_hz:
+ if self.marker_enabled and not self.start_hz <= self.marker_frequency_hz <= self.stop_hz:
raise ValueError("source sweep marker frequency must be within start and stop")
if self.marker_enabled and self.spacing == "STEP":
raise ValueError("source sweep marker cannot be enabled with step spacing")
diff --git a/tests/test_instrument_models.py b/tests/test_instrument_models.py
index e237c92..1faa101 100644
--- a/tests/test_instrument_models.py
+++ b/tests/test_instrument_models.py
@@ -437,6 +437,19 @@ def test_source_sweep_profile_serializes_complete_query_only_snapshot():
}
+def test_source_sweep_profile_preserves_disabled_marker_frequency_outside_window():
+ profile = _source_sweep_profile(
+ start_hz=1000.0,
+ stop_hz=2000.0,
+ center_hz=1500.0,
+ span_hz=1000.0,
+ marker_frequency_hz=550.0,
+ )
+
+ assert profile.marker_enabled is False
+ assert profile.marker_frequency_hz == 550.0
+
+
@pytest.mark.parametrize(
"changes, message",
[
@@ -456,7 +469,7 @@ def test_source_sweep_profile_serializes_complete_query_only_snapshot():
({"trigger_source": "BUS"}, "trigger source"),
({"trigger_slope": "BOTH"}, "trigger slope"),
({"trigger_out": "HIGH"}, "trigger output"),
- ({"marker_frequency_hz": 1001.0}, "marker frequency"),
+ ({"marker_enabled": True, "marker_frequency_hz": 1001.0}, "marker frequency"),
({"spacing": "STEP", "marker_enabled": True}, "step spacing"),
],
)
@@ -526,6 +539,17 @@ def test_source_sweep_configuration_accepts_center_span_without_duplicate_window
assert configuration.effective_stop_hz == 1000.0
+def test_source_sweep_configuration_accepts_disabled_marker_frequency_outside_window():
+ configuration = _source_sweep_configuration(
+ start_hz=1000.0,
+ stop_hz=2000.0,
+ marker_frequency_hz=550.0,
+ )
+
+ assert configuration.marker_enabled is False
+ assert configuration.marker_frequency_hz == 550.0
+
+
def test_source_sweep_configuration_accepts_a_restorable_zero_span_window():
configuration = _source_sweep_configuration(
start_hz=1000.0,
@@ -578,7 +602,7 @@ def test_source_sweep_configuration_can_restore_a_complete_profile():
({"steps": 1}, "steps"),
({"sweep_time_s": 0.0}, "sweep time"),
({"trigger_source": "BUS"}, "trigger source"),
- ({"marker_frequency_hz": 1001.0}, "marker frequency"),
+ ({"marker_enabled": True, "marker_frequency_hz": 1001.0}, "marker frequency"),
],
)
def test_source_sweep_configuration_rejects_ambiguous_or_unsafe_targets(changes, message):
From 5880d90c91ded02e67f89f611a7e2bb30ec26255 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 21:47:22 +0800
Subject: [PATCH 43/44] feat(source): add opt-in volatile workspace replace
---
...345\207\272\345\256\211\345\205\250RFC.md" | 49 +-
src/wavebench/cli.py | 40 ++
src/wavebench/cli_parser.py | 15 +
.../instruments/source_conformance.py | 4 +
.../source_extension_capabilities.py | 61 ++-
.../instruments/source_extensions.py | 163 ++++++
src/wavebench/services/operation_specs.py | 30 ++
src/wavebench/services/run_plan.py | 10 +
src/wavebench/services/run_safety.py | 1 +
src/wavebench/services/run_service.py | 30 ++
src/wavebench/services/source_service.py | 497 ++++++++++++++++-
tests/test_run_service.py | 21 +
tests/test_source_arbitrary_workspace_v2.py | 498 ++++++++++++++++++
tests/test_source_extensions.py | 20 +-
tests/test_source_v1_routes.py | 9 +-
15 files changed, 1433 insertions(+), 15 deletions(-)
create mode 100644 tests/test_source_arbitrary_workspace_v2.py
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index bf17066..ef463e4 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -7,7 +7,7 @@
> 实施状态:P0、M1–M4、M4.5、C1、M5-A、M5-B、M5-C、M5-D、C2 与 M6-A 的 Harmonic 配置/关闭、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM、内部 Sweep 子项已进入核心
> `0.8.24` 开发线;R7 已接受。
> 当前注册 `source.snapshot_v2`、`source.basic_configure_v2`、`source.output_v2` 和
-> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.burst_fire_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.sweep_fire_v2`、`source.arbitrary_volatile_replace_v2` 及三项 Counter capability;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座,
+> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.burst_fire_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.sweep_fire_v2`、`source.arbitrary_volatile_replace_v2`、`source.arbitrary_workspace_volatile_replace_v2` 及三项 Counter capability;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座,
> M5-D 已开放受限的 Source V2 写入口,C2 已补齐候选发布的核心兼容与离线发布物门。M6-A 已完成;
> 在该里程碑范围内,Harmonic、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM 与内部 Sweep 子项均具备公开 Service、CLI 与 run plan 入口。
> 本分支另记录 R8 候选设计:修正 Coupling 写合同,并拆分 Noise Overlay 与 Sync 写事务。
@@ -660,6 +660,16 @@ SourceCounterMeasureResult
SourceCounterMeasureV2Driver
```
+D1-5/无通道 VOLATILE workspace 在上述清单末尾追加以下精确条目:
+
+```text
+SourceArbitraryWorkspaceCapabilityProfile
+SourceArbitraryWorkspaceVolatileReplaceRequest
+SourceArbitraryWorkspaceVolatileReplaceResult
+SourceArbitraryWorkspaceVolatileReplaceV2Driver
+SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT
+```
+
### capability 与 Protocol
capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。
@@ -703,6 +713,7 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。
| `source.arbitrary_storage_v2` | `mutate_source_arbitrary_storage_v2` | 创建或覆盖 ARB 存储槽位 |
| `source.arbitrary_select_v2` | `select_source_arbitrary_v2` | 选择并配置已存在的 ARB |
| `source.arbitrary_volatile_replace_v2` | `replace_source_arbitrary_volatile_v2` | 替换通道唯一的易失 ARB 工作区;上传会选择该工作区 |
+| `source.arbitrary_workspace_volatile_replace_v2` | `replace_source_arbitrary_workspace_volatile_v2` | 替换无通道归属的易失工作区;不声明任一通道已选择该内容 |
| `source.counter_configure_v2` | `configure_source_counter_v2` | 单字段 Counter 输入配置 |
| `source.counter_enable_v2` | `set_source_counter_enabled_v2` | 单独启用或关闭 Counter |
| `source.counter_measure_v2` | `measure_source_counter_v2` | 对已启用 Counter 的只读测量 |
@@ -1027,6 +1038,7 @@ class SourceFeature(StrEnum):
BURST = "burst"
PULSE = "pulse"
ARBITRARY = "arbitrary"
+ ARBITRARY_WORKSPACE = "arbitrary_workspace"
COUNTER = "counter"
REFERENCE_CLOCK = "reference_clock"
SYNC = "sync"
@@ -1193,6 +1205,14 @@ class SourceArbitraryCapabilityProfile:
storage_max_payload_bytes: int | None = None
+@dataclass(frozen=True, slots=True)
+class SourceArbitraryWorkspaceCapabilityProfile:
+ workspace_id: str
+ volatile_replace_min_points: int
+ volatile_replace_max_points: int
+ volatile_replace_max_payload_bytes: int
+
+
@dataclass(frozen=True, slots=True)
class SourceCounterCapabilityProfile:
input_ids: tuple[str, ...]
@@ -1255,6 +1275,7 @@ SourceFeatureProfile: TypeAlias = (
| SourceBurstCapabilityProfile
| SourcePulseCapabilityProfile
| SourceArbitraryCapabilityProfile
+ | SourceArbitraryWorkspaceCapabilityProfile
| SourceCounterCapabilityProfile
| SourceReferenceClockCapabilityProfile
| SourceSyncCapabilityProfile
@@ -1336,6 +1357,7 @@ class SourceFieldId(StrEnum):
PULSE = "source.channel.pulse"
ARBITRARY_SELECTION = "source.channel.arbitrary_selection"
ARBITRARY_STORAGE = "source.channel.arbitrary_storage"
+ ARBITRARY_WORKSPACE = "source.instrument.arbitrary_workspace"
ARM_STATE = "source.channel.arm_state"
TRIGGER_STATE = "source.channel.trigger_state"
COMBINE = "source.cross_channel.combine"
@@ -3053,7 +3075,7 @@ R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归:
## Accepted 决议基线
-1. 11 个 `SourceFeatureProfile`、8 个 channel facet、2 个非通道状态、嵌套 helper、reason code 和
+1. 闭合的 `SourceFeatureProfile` union、8 个 channel facet、2 个非通道状态、嵌套 helper、reason code 和
canonical serializer 按本文冻结,不保留 `object` 或自由 mapping。
2. `source_extensions.__all__`、顶层 identity re-export 和 descriptor append-only 布局按本文冻结。
3. `source.snapshot_v2` 的 `OperationSpec`、Service、CLI JSON、snapshot document 和只读 operation
@@ -3737,6 +3759,29 @@ replace operation 不能作为无损桥接。`v1_route_migration_enabled = false
这一已声明高级 capability 与 V1 composite transaction 的重叠门;保留 legacy 路由或另立完整的复合合同是仅有的
兼容选择。
+### D1-5 合同冻结:无通道 VOLATILE workspace
+
+有些设备把 `VOLATILE` binary 写定义为「当前通道」动作,却不提供可读或可写的 SCPI selector。此时不得把它塞入
+`source.arbitrary_volatile_replace_v2`:该操作的 `channel`、selection/basic 后置条件和单通道 recovery 都会成为虚假承诺。
+
+`source.arbitrary_workspace_volatile_replace_v2` 是独立 opt-in 合同。它使用 `ARBITRARY_WORKSPACE` 的
+`INSTRUMENT` scope 和独立 `SourceArbitraryWorkspaceCapabilityProfile`;request 不含 channel,只带 payload SHA-256、
+字节数和点数。结果只确认名为 `workspace_id` 的无通道工作区已尝试写入,且明确携带
+`content_readback_verified` 与 `previous_content_restorable`。它不会声称哪一路已选择 USER,也不会把 host digest
+伪装成设备内容读回。
+
+前置和后置条件均要求 topology 的全部输出 OFF。MAIN 只能发一次 binary write;二义写、driver 异常或后置条件失败后,
+Core 对每个仍可用的 topology 通道最多执行一次 OFF recovery;一旦 recovery I/O 使 session poisoned,后续物理 I/O
+必须停止。它不重传、不恢复旧内容,也不允许后续配置写。CLI 为:
+
+```text
+wavebench source arbitrary-workspace-volatile-replace-v2 --payload-file FILE --point-count N
+```
+
+run step 为 `source.arbitrary_workspace_volatile_replace_v2`,字段只有相对 plan 的 `file` 和 `point_count`。intent 与
+artifact 只记录 payload 身份信息,并显式标记 `channel_selection=unverified` 和旧内容不可恢复。该操作不等价、也不迁移
+legacy `source.arbitrary_upload`;它只有在独立 descriptor 显式声明且具备全拓扑 output read/disable 支撑时才可路由。
+
Counter 按副作用拆开,而不是继续沿用 V1 的「完整 profile 一次设置」模型:
- `source.counter_configure_v2` 只允许一个显式字段:AC/DC coupling、输入阻抗、衰减、
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index ca8c811..1de1752 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -525,6 +525,32 @@ def _source_arbitrary_volatile_replace_v2_request(
raise ConfigError(str(exc)) from exc
+def _source_arbitrary_workspace_volatile_replace_v2_request(
+ args: argparse.Namespace,
+) -> tuple[object, bytes]:
+ from .instruments.source_extensions import SourceArbitraryWorkspaceVolatileReplaceRequest
+
+ payload_path = Path(args.payload_file)
+ try:
+ payload = payload_path.read_bytes()
+ except OSError as exc:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 payload file is unreadable: "
+ f"{payload_path}"
+ ) from exc
+ try:
+ return (
+ SourceArbitraryWorkspaceVolatileReplaceRequest(
+ payload_sha256="sha256:" + sha256(payload).hexdigest(),
+ payload_size_bytes=len(payload),
+ point_count=args.point_count,
+ ),
+ payload,
+ )
+ except ValueError as exc:
+ raise ConfigError(str(exc)) from exc
+
+
def _source_arbitrary_select_v2_request(args: argparse.Namespace):
from .instruments.source_extensions import (
SourceArbitraryPlaybackMode,
@@ -1375,6 +1401,20 @@ def _main(argv: list[str] | None = None) -> int:
else:
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
+ if args.command == "arbitrary-workspace-volatile-replace-v2":
+ request, workspace_payload = _source_arbitrary_workspace_volatile_replace_v2_request(
+ args
+ )
+ service = _load_source_service(args)
+ _, payload = service.replace_arbitrary_workspace_volatile_v2(
+ request,
+ payload=workspace_payload,
+ )
+ if args.json:
+ _emit_json_result(payload)
+ else:
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
+ return 0
service = _load_source_service(args)
if args.command == "idn":
print(service.idn())
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index f3555a3..6462451 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -1064,6 +1064,21 @@ def build_parser() -> argparse.ArgumentParser:
source_arbitrary_volatile_replace_v2.add_argument("--point-count", type=int, required=True)
add_runtime_options(source_arbitrary_volatile_replace_v2)
+ source_arbitrary_workspace_volatile_replace_v2 = source_sub.add_parser(
+ "arbitrary-workspace-volatile-replace-v2",
+ help=(
+ "Replace an unscoped Source V2 volatile ARB workspace while every output is OFF; "
+ "the affected channel is not identified and the previous content is not recoverable"
+ ),
+ )
+ source_arbitrary_workspace_volatile_replace_v2.add_argument("--payload-file", required=True)
+ source_arbitrary_workspace_volatile_replace_v2.add_argument(
+ "--point-count",
+ type=int,
+ required=True,
+ )
+ add_runtime_options(source_arbitrary_workspace_volatile_replace_v2)
+
source_arbitrary_select_v2 = source_sub.add_parser(
"arbitrary-select-v2",
help="Select one named Source V2 ARB waveform while the target output is OFF",
diff --git a/src/wavebench/instruments/source_conformance.py b/src/wavebench/instruments/source_conformance.py
index 7b39adf..ebc321f 100644
--- a/src/wavebench/instruments/source_conformance.py
+++ b/src/wavebench/instruments/source_conformance.py
@@ -126,6 +126,10 @@
SourceFeature.ARBITRARY,
frozenset({SourceFeatureDirection.CONFIGURE}),
),
+ "source.arbitrary_workspace_volatile_replace_v2": (
+ SourceFeature.ARBITRARY_WORKSPACE,
+ frozenset({SourceFeatureDirection.CONFIGURE}),
+ ),
"source.counter_configure_v2": (
SourceFeature.COUNTER,
frozenset({SourceFeatureDirection.CONFIGURE}),
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index 8158eb2..daec6d7 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -17,6 +17,7 @@
SOURCE_SNAPSHOT_MIN_CORE_VERSION,
SourceAmplitudeUnit,
SourceArbitraryCapabilityProfile,
+ SourceArbitraryWorkspaceCapabilityProfile,
SourceCouplingCapabilityProfile,
SourceCounterCapabilityProfile,
SourceCrossChannelCapabilityProfile,
@@ -74,6 +75,9 @@
"source.arbitrary_volatile_replace_v2": (
"replace_source_arbitrary_volatile_v2",
),
+ "source.arbitrary_workspace_volatile_replace_v2": (
+ "replace_source_arbitrary_workspace_volatile_v2",
+ ),
"source.counter_configure_v2": ("configure_source_counter_v2",),
"source.counter_enable_v2": ("set_source_counter_enabled_v2",),
"source.counter_measure_v2": ("measure_source_counter_v2",),
@@ -105,6 +109,7 @@
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
"source.counter_configure_v2",
"source.counter_enable_v2",
"source.combine_configure_v2",
@@ -238,13 +243,20 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None:
for feature in extensions.features
}
for feature in extensions.features:
+ unreadable_workspace = (
+ feature.feature is SourceFeature.ARBITRARY_WORKSPACE
+ and feature.scope is SourceFacetScope.INSTRUMENT
+ and feature.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.CONFIGURE in feature.directions
+ and SourceFeatureDirection.READ not in feature.directions
+ )
if feature.support.value == "supported" and (
SourceFeatureDirection.READ not in feature.directions
- ):
+ ) and not unreadable_workspace:
raise ConfigError(
f"supported Source V2 feature {feature.feature.value!r} must declare read"
)
- if feature.support.value == "supported" and not any(
+ if feature.support.value == "supported" and not unreadable_workspace and not any(
facet.feature is feature.feature and facet.scope is feature.scope
for facet in extensions.query_contract.facets
):
@@ -422,6 +434,11 @@ def _validate_write_contract(
basic_readable = _channels_with_basic_final_vpp(extensions)
output_readable = _channels_with_output_readback(extensions)
+ output_disabled = _channels_with_direction(
+ extensions,
+ SourceFeature.OUTPUT,
+ SourceFeatureDirection.DISABLE,
+ )
if "source.basic_configure_v2" in capabilities:
configurable = _channels_with_direction(
@@ -806,6 +823,43 @@ def _validate_write_contract(
"channel"
)
+ if "source.arbitrary_workspace_volatile_replace_v2" in capabilities:
+ if "source.output_v2" not in capabilities:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 requires source.output_v2"
+ )
+ profiles = tuple(
+ feature.profile
+ for feature in extensions.features
+ if (
+ feature.feature is SourceFeature.ARBITRARY_WORKSPACE
+ and feature.scope is SourceFacetScope.INSTRUMENT
+ and feature.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.CONFIGURE in feature.directions
+ and isinstance(feature.profile, SourceArbitraryWorkspaceCapabilityProfile)
+ )
+ )
+ if len(profiles) != 1:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 requires one configured "
+ "instrument arbitrary workspace profile"
+ )
+ if len(extensions.topology.channels) > 8:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 supports at most eight "
+ "protected output channels"
+ )
+ if not set(extensions.topology.channels) <= output_readable:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 requires readable output "
+ "state on every topology channel"
+ )
+ if not set(extensions.topology.channels) <= output_disabled:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 requires output DISABLE "
+ "support on every topology channel"
+ )
+
_validate_cross_channel_write_capability(
extensions,
capabilities,
@@ -1413,6 +1467,9 @@ def _validate_declared_write_directions(
"source.arbitrary_volatile_replace_v2",
}
),
+ (SourceFeature.ARBITRARY_WORKSPACE, SourceFeatureDirection.CONFIGURE): frozenset(
+ {"source.arbitrary_workspace_volatile_replace_v2"}
+ ),
(SourceFeature.COUNTER, SourceFeatureDirection.CONFIGURE): frozenset(
{"source.counter_configure_v2"}
),
diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py
index a29713f..b991b45 100644
--- a/src/wavebench/instruments/source_extensions.py
+++ b/src/wavebench/instruments/source_extensions.py
@@ -172,6 +172,7 @@ class SourceFeature(StrEnum):
BURST = "burst"
PULSE = "pulse"
ARBITRARY = "arbitrary"
+ ARBITRARY_WORKSPACE = "arbitrary_workspace"
COUNTER = "counter"
REFERENCE_CLOCK = "reference_clock"
SYNC = "sync"
@@ -774,6 +775,43 @@ def __post_init__(self) -> None:
)
+@dataclass(frozen=True, slots=True)
+class SourceArbitraryWorkspaceCapabilityProfile:
+ """One unscoped volatile arbitrary-waveform workspace.
+
+ This profile deliberately describes storage only. It does not assert that
+ a binary write selects a waveform on any particular output channel.
+ """
+
+ workspace_id: str
+ volatile_replace_min_points: int
+ volatile_replace_max_points: int
+ volatile_replace_max_payload_bytes: int
+
+ def __post_init__(self) -> None:
+ _require_token(self.workspace_id, "arbitrary workspace_id")
+ _require_int(
+ self.volatile_replace_min_points,
+ "arbitrary workspace volatile_replace_min_points",
+ minimum=1,
+ )
+ _require_int(
+ self.volatile_replace_max_points,
+ "arbitrary workspace volatile_replace_max_points",
+ minimum=1,
+ )
+ _require_int(
+ self.volatile_replace_max_payload_bytes,
+ "arbitrary workspace volatile_replace_max_payload_bytes",
+ minimum=1,
+ )
+ if self.volatile_replace_min_points > self.volatile_replace_max_points:
+ raise ValueError(
+ "arbitrary workspace volatile_replace_min_points must not exceed "
+ "volatile_replace_max_points"
+ )
+
+
class SourceQueryEffect(StrEnum):
PURE_READ = "pure_read"
STATEFUL_CONSUMING_READ = "stateful_consuming_read"
@@ -961,6 +999,7 @@ def __post_init__(self) -> None:
| SourceBurstCapabilityProfile
| SourcePulseCapabilityProfile
| SourceArbitraryCapabilityProfile
+ | SourceArbitraryWorkspaceCapabilityProfile
| SourceCounterCapabilityProfile
| SourceReferenceClockCapabilityProfile
| SourceSyncCapabilityProfile
@@ -1026,6 +1065,7 @@ class SourceFieldId(StrEnum):
PULSE = "source.channel.pulse"
ARBITRARY_SELECTION = "source.channel.arbitrary_selection"
ARBITRARY_STORAGE = "source.channel.arbitrary_storage"
+ ARBITRARY_WORKSPACE = "source.instrument.arbitrary_workspace"
ARM_STATE = "source.channel.arm_state"
TRIGGER_STATE = "source.channel.trigger_state"
COMBINE = "source.cross_channel.combine"
@@ -1054,6 +1094,7 @@ class SourceFieldId(StrEnum):
SourceFieldId.PULSE: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.ARBITRARY_SELECTION: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.ARBITRARY_STORAGE: frozenset({SourceFacetScope.CHANNEL}),
+ SourceFieldId.ARBITRARY_WORKSPACE: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFieldId.ARM_STATE: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.TRIGGER_STATE: frozenset({SourceFacetScope.CHANNEL}),
SourceFieldId.COMBINE: frozenset({SourceFacetScope.CHANNEL_SET}),
@@ -1816,6 +1857,29 @@ def __post_init__(self) -> None:
)
+SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT = SourceOperationContract(
+ operation="source.arbitrary_workspace_volatile_replace_v2",
+ capability="source.arbitrary_workspace_volatile_replace_v2",
+ feature=SourceFeature.ARBITRARY_WORKSPACE,
+ direction=SourceFeatureDirection.CONFIGURE,
+ energy_effect=SourceEnergyEffect.POTENTIAL_WHILE_OFF,
+ storage_effect=SourceStorageEffect.REPLACE,
+ required_fields=(
+ SourceFieldId.OUTPUT,
+ SourceFieldId.IDENTITY,
+ ),
+ changed_fields=(SourceFieldId.ARBITRARY_WORKSPACE,),
+ postcondition_fields=(SourceFieldId.OUTPUT,),
+ cleanup_verification_fields=(SourceFieldId.OUTPUT,),
+ v1_equivalent_routes=(),
+ v1_overlapping_routes=(SourceV1WriteRouteId.UPLOAD_ARBITRARY,),
+ operation_timeout_ms=5_000,
+ main_max_steps=1,
+ recovery_max_steps=8,
+ verification_max_steps=2,
+)
+
+
SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract(
operation="source.counter_configure_v2",
capability="source.counter_configure_v2",
@@ -2243,6 +2307,7 @@ def __post_init__(self) -> None:
SourceFeature.BURST: SourceBurstCapabilityProfile,
SourceFeature.PULSE: SourcePulseCapabilityProfile,
SourceFeature.ARBITRARY: SourceArbitraryCapabilityProfile,
+ SourceFeature.ARBITRARY_WORKSPACE: SourceArbitraryWorkspaceCapabilityProfile,
SourceFeature.COUNTER: SourceCounterCapabilityProfile,
SourceFeature.REFERENCE_CLOCK: SourceReferenceClockCapabilityProfile,
SourceFeature.SYNC: SourceSyncCapabilityProfile,
@@ -2265,6 +2330,7 @@ def __post_init__(self) -> None:
SourceFeature.BURST: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.PULSE: frozenset({SourceFacetScope.CHANNEL}),
SourceFeature.ARBITRARY: frozenset({SourceFacetScope.CHANNEL}),
+ SourceFeature.ARBITRARY_WORKSPACE: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFeature.COUNTER: frozenset({SourceFacetScope.INPUT}),
SourceFeature.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}),
SourceFeature.SYNC: frozenset({SourceFacetScope.CHANNEL}),
@@ -3165,6 +3231,36 @@ def __post_init__(self) -> None:
)
+@dataclass(frozen=True, slots=True)
+class SourceArbitraryWorkspaceVolatileReplaceRequest:
+ """Replace an unscoped volatile arbitrary-waveform workspace.
+
+ The request deliberately carries no channel: devices using this contract do
+ not expose a verified selector for the workspace write.
+ """
+
+ payload_sha256: str
+ payload_size_bytes: int
+ point_count: int
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None:
+ raise ValueError(
+ "source arbitrary workspace volatile replace payload_sha256 must be "
+ "sha256:<64 lowercase hex>"
+ )
+ _require_int(
+ self.payload_size_bytes,
+ "source arbitrary workspace volatile replace payload_size_bytes",
+ minimum=1,
+ )
+ _require_int(
+ self.point_count,
+ "source arbitrary workspace volatile replace point_count",
+ minimum=1,
+ )
+
+
@dataclass(frozen=True, slots=True)
class SourceArbitraryStorageSlot:
channel: int
@@ -3868,6 +3964,59 @@ def __post_init__(self) -> None:
)
+@dataclass(frozen=True, slots=True)
+class SourceArbitraryWorkspaceVolatileReplaceResult:
+ workspace_id: str
+ payload_sha256: str
+ payload_size_bytes: int
+ point_count: int
+ write_completed: bool
+ content_readback_verified: bool
+ previous_content_restorable: bool
+
+ def __post_init__(self) -> None:
+ _require_token(
+ self.workspace_id,
+ "source arbitrary workspace volatile replace result workspace_id",
+ )
+ if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None:
+ raise ValueError(
+ "source arbitrary workspace volatile replace result payload_sha256 must be "
+ "sha256:<64 lowercase hex>"
+ )
+ _require_int(
+ self.payload_size_bytes,
+ "source arbitrary workspace volatile replace result payload_size_bytes",
+ minimum=1,
+ )
+ _require_int(
+ self.point_count,
+ "source arbitrary workspace volatile replace result point_count",
+ minimum=1,
+ )
+ _require_bool(
+ self.write_completed,
+ "source arbitrary workspace volatile replace result write_completed",
+ )
+ _require_bool(
+ self.content_readback_verified,
+ "source arbitrary workspace volatile replace result content_readback_verified",
+ )
+ _require_bool(
+ self.previous_content_restorable,
+ "source arbitrary workspace volatile replace result previous_content_restorable",
+ )
+ if not self.write_completed:
+ raise ValueError(
+ "source arbitrary workspace volatile replace result requires write_completed=True"
+ )
+ if self.previous_content_restorable:
+ raise ValueError(
+ "source arbitrary workspace volatile replace result cannot claim previous "
+ "content is restorable"
+ )
+
+
@dataclass(frozen=True, slots=True)
class HarmonicFacet:
enabled: Observed[bool]
@@ -5716,6 +5865,15 @@ def replace_source_arbitrary_volatile_v2(
) -> SourceArbitraryVolatileReplaceResult: ...
+@runtime_checkable
+class SourceArbitraryWorkspaceVolatileReplaceV2Driver(InstrumentDriver, Protocol):
+ def replace_source_arbitrary_workspace_volatile_v2(
+ self,
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ payload: bytes,
+ ) -> SourceArbitraryWorkspaceVolatileReplaceResult: ...
+
+
@runtime_checkable
class SourceArbitrarySelectV2Driver(InstrumentDriver, Protocol):
def select_source_arbitrary_v2(
@@ -6161,4 +6319,9 @@ def source_snapshot_timestamp_utc() -> str:
"SourceCounterMeasureRequest",
"SourceCounterMeasureResult",
"SourceCounterMeasureV2Driver",
+ "SourceArbitraryWorkspaceCapabilityProfile",
+ "SourceArbitraryWorkspaceVolatileReplaceRequest",
+ "SourceArbitraryWorkspaceVolatileReplaceResult",
+ "SourceArbitraryWorkspaceVolatileReplaceV2Driver",
+ "SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT",
]
diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py
index 0756bd4..226e777 100644
--- a/src/wavebench/services/operation_specs.py
+++ b/src/wavebench/services/operation_specs.py
@@ -938,6 +938,36 @@ def _spec(
"no_retry",
),
),
+ _spec(
+ "source.arbitrary_workspace_volatile_replace_v2",
+ "source",
+ required_capabilities=("source.arbitrary_workspace_volatile_replace_v2",),
+ effect="write",
+ lease_mode="exclusive",
+ changed_fields=("source.instrument.arbitrary_workspace",),
+ restore_coverage="source-v2-arbitrary-workspace-volatile",
+ required_verified_fields=(
+ "source.identity",
+ "source.channel.output",
+ ),
+ verification_fields=(
+ "source.identity",
+ "source.channel.output",
+ ),
+ postcondition_fields=("source.channel.output",),
+ cleanup_verification_fields=("source.channel.output",),
+ timeout_source="operation.timeout_ms",
+ operation_timeout_ms=5_000,
+ error_check_minimum="disabled",
+ risk_flags=(
+ "source_v2",
+ "all_outputs_must_be_off",
+ "arbitrary_workspace_volatile_replace",
+ "unscoped_workspace",
+ "payload_not_artifact",
+ "no_retry",
+ ),
+ ),
_spec(
"source.counter_configure_v2",
"source",
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 94d4c93..0bf7488 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -60,6 +60,7 @@
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
"source.arbitrary_select_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
@@ -160,6 +161,7 @@
),
"source.arbitrary_storage_v2": ("channel", "slot_id", "file", "write_mode"),
"source.arbitrary_volatile_replace_v2": ("channel", "file", "point_count"),
+ "source.arbitrary_workspace_volatile_replace_v2": ("file", "point_count"),
"source.arbitrary_select_v2": ("channel", "slot_id", "playback_mode"),
"source.combine_configure_v2": ("channels", "enabled"),
"source.coupling_configure_v2": ("channels", "enabled"),
@@ -292,6 +294,7 @@
"source.pulse_configure_v2": {"on_failure"},
"source.arbitrary_storage_v2": {"expected_previous_sha256", "on_failure"},
"source.arbitrary_volatile_replace_v2": {"on_failure"},
+ "source.arbitrary_workspace_volatile_replace_v2": {"on_failure"},
"source.arbitrary_select_v2": {
"playback_frequency_hz",
"sample_rate_hz",
@@ -357,6 +360,7 @@
"source.pulse_configure_v2": "Configure one OFF Source V2 channel with a WIDTH pulse shape; it does not enable output.",
"source.arbitrary_storage_v2": "Write one named Source V2 ARB storage slot without selecting or enabling it. The payload file is recorded by digest only.",
"source.arbitrary_volatile_replace_v2": "Replace one volatile Source V2 ARB workspace while output is OFF. The previous workspace content is not recoverable; the payload file is recorded by digest only.",
+ "source.arbitrary_workspace_volatile_replace_v2": "Replace one unscoped volatile Source V2 ARB workspace only while every topology output is OFF. It does not identify an affected channel; the previous workspace content is not recoverable and the payload file is recorded by digest only.",
"source.arbitrary_select_v2": "Select one named Source V2 ARB waveform while the target output is OFF; it does not enable output.",
"source.combine_configure_v2": "Enable or disable one declared Source V2 Combine relation while every affected output is OFF.",
"source.coupling_configure_v2": "Enable or disable one declared Source V2 Coupling relation while every affected output is OFF.",
@@ -1106,6 +1110,12 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
fields["point_count"],
f"{prefix}.point_count",
)
+ elif kind == "source.arbitrary_workspace_volatile_replace_v2":
+ fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file")
+ fields["point_count"] = _positive_int(
+ fields["point_count"],
+ f"{prefix}.point_count",
+ )
elif kind == "source.arbitrary_select_v2":
fields["slot_id"] = _non_empty_str(fields["slot_id"], f"{prefix}.slot_id")
if _SOURCE_STORAGE_TOKEN.fullmatch(fields["slot_id"]) is None:
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index b035235..ac4fccc 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -60,6 +60,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) ->
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
"source.arbitrary_select_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index 8494938..bb68ddf 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -42,6 +42,7 @@
SourceArbitrarySelectRequest,
SourceArbitraryStorageRequest,
SourceArbitraryVolatileReplaceRequest,
+ SourceArbitraryWorkspaceVolatileReplaceRequest,
SourceBasicConfigureRequest,
SourceBasicPatch,
SourceBurstConfigureRequest,
@@ -593,6 +594,13 @@ def add_source_restore_capabilities() -> None:
add("source", "source.snapshot_v2", "source.arbitrary_storage_v2")
elif step.kind == "source.arbitrary_volatile_replace_v2":
add("source", "source.snapshot_v2", "source.arbitrary_volatile_replace_v2")
+ elif step.kind == "source.arbitrary_workspace_volatile_replace_v2":
+ add(
+ "source",
+ "source.snapshot_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
+ "source.output_v2",
+ )
elif step.kind == "source.arbitrary_select_v2":
add("source", "source.snapshot_v2", "source.arbitrary_select_v2")
elif step.kind == "source.combine_configure_v2":
@@ -1675,6 +1683,28 @@ def _run_step(
payload=payload,
)
artifact = {"source_operation": source_operation}
+ elif step.kind == "source.arbitrary_workspace_volatile_replace_v2":
+ payload_path = Path(step.fields["file"])
+ if not payload_path.is_absolute():
+ payload_path = plan.path.parent / payload_path
+ try:
+ payload = payload_path.read_bytes()
+ except OSError as exc:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 payload file is "
+ f"unreadable: {payload_path}"
+ ) from exc
+ _, source_operation = self._source_service(
+ services=services
+ ).replace_arbitrary_workspace_volatile_v2(
+ SourceArbitraryWorkspaceVolatileReplaceRequest(
+ payload_sha256="sha256:" + sha256(payload).hexdigest(),
+ payload_size_bytes=len(payload),
+ point_count=step.fields["point_count"],
+ ),
+ payload=payload,
+ )
+ artifact = {"source_operation": source_operation}
elif step.kind == "source.arbitrary_select_v2":
_, source_operation = self._source_service(services=services).select_arbitrary_v2(
SourceArbitrarySelectRequest(
diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py
index 5c566c7..5fef9a6 100644
--- a/src/wavebench/services/source_service.py
+++ b/src/wavebench/services/source_service.py
@@ -77,6 +77,7 @@
SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT,
SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT,
SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
+ SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT,
SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT,
@@ -116,6 +117,10 @@
SourceArbitraryVolatileReplaceRequest,
SourceArbitraryVolatileReplaceResult,
SourceArbitraryVolatileReplaceV2Driver,
+ SourceArbitraryWorkspaceCapabilityProfile,
+ SourceArbitraryWorkspaceVolatileReplaceRequest,
+ SourceArbitraryWorkspaceVolatileReplaceResult,
+ SourceArbitraryWorkspaceVolatileReplaceV2Driver,
SourceBasicCapabilityProfile,
SourceBasicConfigureRequest,
SourceBasicConfigureResult,
@@ -393,6 +398,15 @@ class _SourceArbitraryVolatileReplaceV2Transaction:
snapshot: SourceSnapshotV2
+@dataclass(frozen=True, slots=True)
+class _SourceArbitraryWorkspaceVolatileReplaceV2Transaction:
+ """Core transaction result for one unscoped volatile ARB workspace replacement."""
+
+ result: SourceArbitraryWorkspaceVolatileReplaceResult
+ artifact: dict[str, object]
+ snapshot: SourceSnapshotV2
+
+
@dataclass(frozen=True, slots=True)
class _SourceCounterV2Transaction:
"""Core transaction result for one independently verified Counter mutation."""
@@ -932,6 +946,22 @@ def replace_arbitrary_volatile_v2(
)
return transaction.result, transaction.artifact
+ def replace_arbitrary_workspace_volatile_v2(
+ self,
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ *,
+ payload: bytes,
+ correlation_id: str | None = None,
+ ) -> tuple[SourceArbitraryWorkspaceVolatileReplaceResult, dict[str, object]]:
+ """Replace an unscoped volatile workspace after every source output is OFF."""
+
+ transaction = self._replace_arbitrary_workspace_volatile_v2_transaction(
+ request,
+ payload=payload,
+ correlation_id=correlation_id,
+ )
+ return transaction.result, transaction.artifact
+
def configure_counter_v2(
self,
request: SourceCounterConfigureRequest,
@@ -4263,6 +4293,226 @@ def _replace_arbitrary_volatile_v2_transaction(
context.complete()
raise
+ def _replace_arbitrary_workspace_volatile_v2_transaction(
+ self,
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ *,
+ payload: bytes,
+ correlation_id: str | None = None,
+ ) -> _SourceArbitraryWorkspaceVolatileReplaceV2Transaction:
+ """Replace an unscoped volatile workspace without claiming channel selection."""
+
+ operation = "source.arbitrary_workspace_volatile_replace_v2"
+ if not isinstance(request, SourceArbitraryWorkspaceVolatileReplaceRequest):
+ raise ConfigError(
+ f"{operation} requires SourceArbitraryWorkspaceVolatileReplaceRequest"
+ )
+ self._validate_source_arbitrary_workspace_volatile_replace_v2_payload(request, payload)
+ self._require(
+ operation,
+ "source.snapshot_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
+ )
+ with self._source_session() as source:
+ descriptor = self.descriptor
+ extensions = None if descriptor is None else descriptor.source_extensions
+ session_state = self.session_state
+ if not isinstance(extensions, SourceDescriptorExtensions):
+ raise ConfigError(f"{operation} requires validated source_extensions")
+ if session_state is None:
+ raise ConfigError(f"{operation} requires a connection-bound session state")
+ channels = extensions.topology.channels
+ fields = self._source_arbitrary_workspace_volatile_replace_v2_fields(channels)
+ workspace_field = next(
+ field for field in fields if field.field is SourceFieldId.ARBITRARY_WORKSPACE
+ )
+ identity_field = next(field for field in fields if field.field is SourceFieldId.IDENTITY)
+ output_fields = tuple(
+ field for field in fields if field.field is SourceFieldId.OUTPUT
+ )
+ output_scopes = tuple(
+ SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel)
+ for channel in channels
+ )
+ context = SourceOperationContextCoordinator(
+ session_state=session_state,
+ operation_spec=require_operation_spec(operation),
+ operation_contract=SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT,
+ connection_timeout_ms=self.config.connection.timeout_ms,
+ baseline_snapshot_digest=None,
+ fields=fields,
+ required_off_outputs=output_scopes,
+ emergency_off_outputs=output_scopes,
+ restore_order=(),
+ non_restorable_fields=tuple(
+ field
+ for field in fields
+ if field.field
+ in {
+ SourceFieldId.ARBITRARY_WORKSPACE,
+ SourceFieldId.OUTPUT,
+ }
+ ),
+ correlation_id=correlation_id,
+ )
+ preflight_snapshot: SourceSnapshotV2 | None = None
+ postcondition_snapshot: SourceSnapshotV2 | None = None
+ result: SourceArbitraryWorkspaceVolatileReplaceResult | None = None
+ main_entered = False
+ failure: BaseException | None = None
+ recovery: dict[str, object] | None = None
+
+ try:
+ preflight = context.make_phase_spec(
+ SourceOperationPhase.PREFLIGHT,
+ allowed_io={"query"},
+ fields=(*output_fields, identity_field),
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(preflight) as authorization:
+ preflight_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ profile = self._source_arbitrary_workspace_runtime_profile(
+ preflight_snapshot,
+ operation=operation,
+ )
+ preflight_outputs = tuple(
+ self._source_v2_output_target(
+ preflight_snapshot,
+ channel,
+ operation=operation,
+ )
+ for channel in channels
+ )
+ self._validate_source_arbitrary_workspace_volatile_replace_v2_preflight(
+ request,
+ preflight_snapshot,
+ profile,
+ preflight_outputs,
+ )
+ context.bind_baseline_snapshot_digest(
+ source_v2_digest(
+ {
+ "workspace": profile,
+ "channels": channels,
+ "outputs": preflight_outputs,
+ }
+ )
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=(*output_fields, identity_field),
+ )
+
+ main = context.make_phase_spec(
+ SourceOperationPhase.MAIN,
+ allowed_io={"write_bytes"},
+ fields=(workspace_field,),
+ max_steps=SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT.main_max_steps,
+ )
+ try:
+ with context.authorize_phase(main):
+ main_entered = True
+ result = cast(
+ SourceArbitraryWorkspaceVolatileReplaceV2Driver,
+ source,
+ ).replace_source_arbitrary_workspace_volatile_v2(request, payload)
+ self._validate_source_arbitrary_workspace_volatile_replace_v2_result(
+ request,
+ profile,
+ result,
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is None:
+ try:
+ postcondition = context.make_phase_spec(
+ SourceOperationPhase.POSTCONDITION,
+ allowed_io={"query"},
+ fields=output_fields,
+ max_steps=extensions.query_contract.max_queries,
+ )
+ with context.authorize_phase(postcondition) as authorization:
+ postcondition_snapshot = self._snapshot_v2_with_open_source(
+ source,
+ correlation_id=context.correlation_id,
+ deadline=authorization.deadline,
+ )
+ postcondition_outputs = tuple(
+ self._source_v2_output_target(
+ postcondition_snapshot,
+ channel,
+ operation=operation,
+ )
+ for channel in channels
+ )
+ self._validate_source_arbitrary_workspace_volatile_replace_v2_postcondition(
+ postcondition_snapshot,
+ postcondition_outputs,
+ )
+ context.complete_phase_verification(
+ authorization,
+ io_kind="query",
+ fields=output_fields,
+ )
+ except BaseException as exc:
+ failure = exc
+
+ if failure is not None:
+ if main_entered:
+ try:
+ context.mark_failure_required()
+ recovery = self._recover_source_v2_outputs_off(
+ context,
+ source,
+ channels,
+ extensions,
+ output_fields,
+ operation=operation,
+ )
+ except BaseException:
+ recovery = {
+ "status": "recovery_setup_failed",
+ "session_health": session_state.health.value,
+ }
+ context.complete()
+ if main_entered:
+ self._attach_source_arbitrary_workspace_volatile_replace_v2_diagnostics(
+ failure,
+ context=context,
+ request=request,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ recovery=recovery,
+ )
+ raise failure
+
+ context.complete()
+ assert result is not None
+ assert preflight_snapshot is not None
+ assert postcondition_snapshot is not None
+ return _SourceArbitraryWorkspaceVolatileReplaceV2Transaction(
+ result=result,
+ artifact=self._source_arbitrary_workspace_volatile_replace_v2_artifact(
+ context=context,
+ request=request,
+ preflight_snapshot=preflight_snapshot,
+ postcondition_snapshot=postcondition_snapshot,
+ result=result,
+ ),
+ snapshot=postcondition_snapshot,
+ )
+ except BaseException:
+ if not context.terminal:
+ context.complete()
+ raise
+
def _mutate_counter_v2_transaction(
self,
request: SourceCounterConfigureRequest | SourceCounterEnableRequest,
@@ -5620,6 +5870,35 @@ def _source_arbitrary_volatile_replace_v2_fields(
)
)
+ @staticmethod
+ def _source_arbitrary_workspace_volatile_replace_v2_fields(
+ channels: tuple[int, ...],
+ ) -> tuple[SourceFieldRef, ...]:
+ instrument = SourceScopeRef(SourceFacetScope.INSTRUMENT)
+ fields = (
+ SourceFieldRef(SourceFieldId.ARBITRARY_WORKSPACE, instrument),
+ SourceFieldRef(SourceFieldId.IDENTITY, instrument),
+ *(
+ SourceFieldRef(
+ SourceFieldId.OUTPUT,
+ SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel),
+ )
+ for channel in channels
+ ),
+ )
+ return tuple(
+ sorted(
+ fields,
+ key=lambda field: (
+ field.field.value,
+ field.target.scope.value,
+ -1 if field.target.channel is None else field.target.channel,
+ field.target.channels,
+ "" if field.target.input_id is None else field.target.input_id,
+ ),
+ )
+ )
+
@staticmethod
def _source_counter_v2_fields(input_id: str) -> tuple[SourceFieldRef, ...]:
target = SourceScopeRef(SourceFacetScope.INPUT, input_id=input_id)
@@ -7164,6 +7443,30 @@ def _validate_source_sweep_v2_postcondition(
# fields. Both result and snapshot are separately validated against
# the declared request and safety scope.
+ @staticmethod
+ def _source_arbitrary_workspace_runtime_profile(
+ snapshot: SourceSnapshotV2,
+ *,
+ operation: str,
+ ) -> SourceArbitraryWorkspaceCapabilityProfile:
+ feature = next(
+ (
+ candidate
+ for candidate in snapshot.runtime_profile.features
+ if candidate.feature is SourceFeature.ARBITRARY_WORKSPACE
+ and candidate.scope is SourceFacetScope.INSTRUMENT
+ and candidate.support is SupportState.SUPPORTED
+ and SourceFeatureDirection.CONFIGURE in candidate.directions
+ ),
+ None,
+ )
+ if feature is None or not isinstance(
+ feature.profile,
+ SourceArbitraryWorkspaceCapabilityProfile,
+ ):
+ raise ConfigError(f"{operation} is not available for the runtime instrument")
+ return feature.profile
+
@staticmethod
def _source_arbitrary_runtime_profile(
snapshot: SourceSnapshotV2,
@@ -7557,6 +7860,83 @@ def _validate_source_arbitrary_volatile_replace_v2_postcondition(
):
raise ConfigError(f"{operation} selected waveform readback does not match result")
+ @staticmethod
+ def _validate_source_arbitrary_workspace_volatile_replace_v2_payload(
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ payload: object,
+ ) -> None:
+ if not isinstance(payload, bytes):
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 payload must be bytes"
+ )
+ if len(payload) != request.payload_size_bytes:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 payload size does not match "
+ "the request"
+ )
+ digest = "sha256:" + sha256(payload).hexdigest()
+ if digest != request.payload_sha256:
+ raise ConfigError(
+ "source.arbitrary_workspace_volatile_replace_v2 payload SHA-256 does not "
+ "match the request"
+ )
+
+ @staticmethod
+ def _validate_source_arbitrary_workspace_volatile_replace_v2_preflight(
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ snapshot: SourceSnapshotV2,
+ profile: SourceArbitraryWorkspaceCapabilityProfile,
+ outputs: tuple[OutputFacet, ...],
+ ) -> None:
+ operation = "source.arbitrary_workspace_volatile_replace_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} requires a fresh consistent snapshot")
+ if any(
+ output.enabled.availability is not Availability.VALUE or output.enabled.value is not False
+ for output in outputs
+ ):
+ raise ConfigError(f"{operation} requires every topology output OFF")
+ if request.point_count < profile.volatile_replace_min_points or request.point_count > profile.volatile_replace_max_points:
+ raise ConfigError(f"{operation} point count exceeds the runtime profile")
+ if request.payload_size_bytes > profile.volatile_replace_max_payload_bytes:
+ raise ConfigError(f"{operation} payload size exceeds the runtime profile")
+
+ @staticmethod
+ def _validate_source_arbitrary_workspace_volatile_replace_v2_result(
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ profile: SourceArbitraryWorkspaceCapabilityProfile,
+ result: object,
+ ) -> None:
+ operation = "source.arbitrary_workspace_volatile_replace_v2"
+ if not isinstance(result, SourceArbitraryWorkspaceVolatileReplaceResult):
+ raise ConfigError(
+ "replace_source_arbitrary_workspace_volatile_v2() returned an invalid "
+ "SourceArbitraryWorkspaceVolatileReplaceResult"
+ )
+ if (
+ result.workspace_id != profile.workspace_id
+ or result.payload_sha256 != request.payload_sha256
+ or result.payload_size_bytes != request.payload_size_bytes
+ or result.point_count != request.point_count
+ ):
+ raise ConfigError(f"{operation} result does not match the request")
+ if not result.write_completed:
+ raise ConfigError(f"{operation} result does not prove the write")
+
+ @staticmethod
+ def _validate_source_arbitrary_workspace_volatile_replace_v2_postcondition(
+ snapshot: SourceSnapshotV2,
+ outputs: tuple[OutputFacet, ...],
+ ) -> None:
+ operation = "source.arbitrary_workspace_volatile_replace_v2"
+ if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT:
+ raise ConfigError(f"{operation} postcondition snapshot is inconsistent")
+ if any(
+ output.enabled.availability is not Availability.VALUE or output.enabled.value is not False
+ for output in outputs
+ ):
+ raise ConfigError(f"{operation} postcondition reports an output ON")
+
@staticmethod
def _source_counter_configuration_field(
request: SourceCounterConfigureRequest,
@@ -8558,6 +8938,9 @@ def _recover_source_v2_outputs_off(
return {"status": "not_attempted", "reason": "output_method_unavailable"}
if len(channels) != len(output_fields) or len(channels) > context.operation_contract.recovery_max_steps:
return {"status": "not_attempted", "reason": "recovery_range_unavailable"}
+ attempted_channels: list[int] = []
+ failed_channels: list[int] = []
+ halted_reason: str | None = None
try:
safe_state = context.make_phase_spec(
SourceOperationPhase.FAILURE_SAFE_STATE,
@@ -8567,21 +8950,44 @@ def _recover_source_v2_outputs_off(
)
with context.authorize_phase(safe_state):
for channel in channels:
- result = cast(SourceOutputV2Driver, source).set_source_output_v2(
- SourceOutputRequest(channel=channel, enabled=False)
- )
- if (
- not isinstance(result, SourceOutputResult)
- or result.channel != channel
- or result.enabled
- ):
- raise ConfigError(f"{operation} recovery OFF is not proven")
+ if session_state.health is SessionHealth.POISONED:
+ halted_reason = "session_poisoned"
+ break
+ attempted_channels.append(channel)
+ try:
+ result = cast(SourceOutputV2Driver, source).set_source_output_v2(
+ SourceOutputRequest(channel=channel, enabled=False)
+ )
+ if (
+ not isinstance(result, SourceOutputResult)
+ or result.channel != channel
+ or result.enabled
+ ):
+ raise ConfigError(f"{operation} recovery OFF is not proven")
+ except BaseException:
+ failed_channels.append(channel)
+ if session_state.health is SessionHealth.POISONED:
+ halted_reason = "session_poisoned"
+ break
except BaseException:
return {
"status": "off_failed",
"channels": list(channels),
+ "attempted_channels": attempted_channels,
+ "failed_channels": failed_channels,
+ "session_health": session_state.health.value,
+ }
+ if failed_channels or len(attempted_channels) != len(channels):
+ recovery = {
+ "status": "off_failed",
+ "channels": list(channels),
+ "attempted_channels": attempted_channels,
+ "failed_channels": failed_channels,
"session_health": session_state.health.value,
}
+ if halted_reason is not None:
+ recovery["reason"] = halted_reason
+ return recovery
if session_state.health is SessionHealth.POISONED:
return {
"status": "off_sent_unverified",
@@ -9254,6 +9660,65 @@ def _source_arbitrary_volatile_replace_v2_artifact(
)
return artifact
+ def _source_arbitrary_workspace_volatile_replace_v2_artifact(
+ self,
+ *,
+ context: SourceOperationContextCoordinator,
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ preflight_snapshot: SourceSnapshotV2 | None,
+ postcondition_snapshot: SourceSnapshotV2 | None,
+ result: SourceArbitraryWorkspaceVolatileReplaceResult | None,
+ recovery: dict[str, object] | None = None,
+ ) -> dict[str, object]:
+ artifact = context.artifact()
+ descriptor_digest = (
+ None
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.descriptor_digest
+ )
+ artifact["capability_decision"] = {
+ "capability": "source.arbitrary_workspace_volatile_replace_v2",
+ "contract_version": SOURCE_CONTRACT_VERSION,
+ "descriptor_digest": descriptor_digest,
+ }
+ artifact["request"] = source_v2_to_data(request)
+ if preflight_snapshot is not None:
+ artifact["preflight"] = {
+ "topology_channels": [item.channel for item in preflight_snapshot.channels],
+ "snapshot_digest": source_v2_digest(preflight_snapshot),
+ "consistency": preflight_snapshot.consistency.state.value,
+ }
+ if result is not None:
+ artifact["mutation"] = {"result": source_v2_to_data(result)}
+ if postcondition_snapshot is not None:
+ artifact["postcondition"] = {
+ "snapshot_digest": source_v2_digest(postcondition_snapshot),
+ "consistency": postcondition_snapshot.consistency.state.value,
+ }
+ if recovery is not None:
+ artifact["recovery"] = dict(recovery)
+ artifact["final_state"] = {
+ "session_health": context.session_state.health.value,
+ "topology_outputs_expected": "off",
+ "channel_selection": "unverified",
+ "content_readback_verified": (
+ None if result is None else result.content_readback_verified
+ ),
+ "previous_content": "unrecoverable",
+ }
+ artifact["evidence_refs"] = sorted(
+ {
+ evidence_ref
+ for feature in (
+ ()
+ if preflight_snapshot is None
+ else preflight_snapshot.runtime_profile.features
+ )
+ for evidence_ref in feature.evidence_refs
+ }
+ )
+ return artifact
+
def _source_counter_v2_artifact(
self,
*,
@@ -9750,6 +10215,20 @@ def _attach_source_arbitrary_volatile_replace_v2_diagnostics(
except Exception:
pass
+ def _attach_source_arbitrary_workspace_volatile_replace_v2_diagnostics(
+ self,
+ exc: BaseException,
+ **kwargs: object,
+ ) -> None:
+ try:
+ setattr(
+ exc,
+ "source_operation_artifact",
+ self._source_arbitrary_workspace_volatile_replace_v2_artifact(**kwargs),
+ )
+ except Exception:
+ pass
+
def _attach_source_counter_v2_diagnostics(
self,
exc: BaseException,
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 3c11df5..ed06dae 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -2853,6 +2853,11 @@ def test_runs_manual_sweep_fire_and_volatile_arb_without_payload_in_artifacts(se
channel = 1
file = "volatile.bin"
point_count = 2
+
+[[steps]]
+kind = "source.arbitrary_workspace_volatile_replace_v2"
+file = "volatile.bin"
+point_count = 2
""",
)
)
@@ -2870,11 +2875,20 @@ def test_runs_manual_sweep_fire_and_volatile_arb_without_payload_in_artifacts(se
"operation": "source.arbitrary_volatile_replace_v2",
"request": {"payload_sha256": "sha256:" + sha256(payload).hexdigest()},
},
+ {
+ "schema": "wavebench.source.operation.v1",
+ "operation": "source.arbitrary_workspace_volatile_replace_v2",
+ "request": {"payload_sha256": "sha256:" + sha256(payload).hexdigest()},
+ },
]
source = Mock()
source.configure_sweep_v2.return_value = (SimpleNamespace(), artifacts[0])
source.fire_sweep_v2.return_value = (SimpleNamespace(), artifacts[1])
source.replace_arbitrary_volatile_v2.return_value = (SimpleNamespace(), artifacts[2])
+ source.replace_arbitrary_workspace_volatile_v2.return_value = (
+ SimpleNamespace(),
+ artifacts[3],
+ )
class OfflineV2RunService(RunService):
def check(self, plan):
@@ -2901,6 +2915,13 @@ def _run_safety_guards(self, plan, *, services=None):
source.replace_arbitrary_volatile_v2.call_args.kwargs["payload"],
payload,
)
+ workspace_request = source.replace_arbitrary_workspace_volatile_v2.call_args.args[0]
+ self.assertEqual(workspace_request.point_count, 2)
+ self.assertEqual(workspace_request.payload_size_bytes, len(payload))
+ self.assertEqual(
+ source.replace_arbitrary_workspace_volatile_v2.call_args.kwargs["payload"],
+ payload,
+ )
self.assertEqual(run_data["source_operations"], artifacts)
self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False))
diff --git a/tests/test_source_arbitrary_workspace_v2.py b/tests/test_source_arbitrary_workspace_v2.py
new file mode 100644
index 0000000..2b1f553
--- /dev/null
+++ b/tests/test_source_arbitrary_workspace_v2.py
@@ -0,0 +1,498 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from hashlib import sha256
+from pathlib import Path
+
+import pytest
+
+from wavebench.config import (
+ AutoscaleConfig,
+ ConnectionConfig,
+ OutputConfig,
+ SafetyLimitsConfig,
+ ScopeConfig,
+ SourceConfig,
+ WaveBenchConfig,
+ WaveformConfig,
+)
+from wavebench.errors import ConfigError
+from wavebench.instruments.capabilities import validate_declared_capabilities
+from wavebench.instruments.source_extension_capabilities import validate_source_descriptor
+from wavebench.instruments.source_extensions import (
+ SOURCE_CONTRACT_VERSION,
+ Observed,
+ OutputFacet,
+ SourceArbitraryWorkspaceCapabilityProfile,
+ SourceArbitraryWorkspaceVolatileReplaceRequest,
+ SourceArbitraryWorkspaceVolatileReplaceResult,
+ SourceAmplitudeUnit,
+ SourceBasicCapabilityProfile,
+ SourceConstraintApplicability,
+ SourceDescriptorExtensions,
+ SourceFacetQueryContract,
+ SourceFacetScope,
+ SourceFeature,
+ SourceFeatureCapability,
+ SourceFeatureDirection,
+ SourceFieldId,
+ SourceFrequencyMode,
+ SourceOutputCapabilityProfile,
+ SourceOutputPolarity,
+ SourceOutputRequest,
+ SourceOutputResult,
+ SourceProtocolQueryRecord,
+ SourceQueryContract,
+ SourceQueryEffect,
+ SourceQueryExecutionRecord,
+ SourceQueryItemOutcome,
+ SourceRuntimeIdentity,
+ SourceSafetyProfile,
+ SourceTopologyContract,
+ SourceTypedObservation,
+ SourceWaveformKind,
+ SupportState,
+)
+from wavebench.logging import CommandLogger
+from wavebench.services.source_service import SourceService
+from wavebench.transport.contracts import ReplayPolicy
+from wavebench.transport.guarded import GuardedAuditedTransport
+from wavebench.transport.session import InstrumentSessionState
+
+from tests.source_v2_fixtures import basic_facet, missing, source_descriptor
+
+
+class _TextTransport:
+ resource = "fake-source-arbitrary-workspace-v2"
+
+ def record_event(self, direction: str, text: str) -> None:
+ del direction, text
+
+ def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str:
+ del command, replay
+ return "ok"
+
+ def write(self, command: str) -> None:
+ del command
+
+ def write_bytes(self, command: bytes) -> None:
+ del command
+
+ def close(self) -> None:
+ pass
+
+
+def _digest(payload: bytes) -> str:
+ return "sha256:" + sha256(payload).hexdigest()
+
+
+def _output(enabled: bool) -> OutputFacet:
+ return OutputFacet(
+ enabled=Observed.value_of(enabled),
+ display_load=missing(),
+ polarity=Observed.value_of(SourceOutputPolarity.NORMAL),
+ )
+
+
+class _WorkspaceDriver:
+ def __init__(
+ self,
+ *,
+ session_state: InstrumentSessionState,
+ outputs: dict[int, bool] | None = None,
+ postcondition_mismatch: bool = False,
+ write_error: bool = False,
+ invalid_recovery_result_channels: tuple[int, ...] = (),
+ ) -> None:
+ self.transport = GuardedAuditedTransport(
+ _TextTransport(),
+ session_state=session_state,
+ )
+ self.outputs = {1: False, 2: False} if outputs is None else dict(outputs)
+ self.postcondition_mismatch = postcondition_mismatch
+ self.write_error = write_error
+ self.invalid_recovery_result_channels = frozenset(invalid_recovery_result_channels)
+ self.workspace_requests: list[
+ tuple[SourceArbitraryWorkspaceVolatileReplaceRequest, bytes]
+ ] = []
+ self.output_requests: list[SourceOutputRequest] = []
+
+ def close(self) -> None:
+ self.transport.close()
+
+ def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord:
+ records = []
+ for item in plan.items:
+ if item.phase.value == "anchor_before":
+ self.transport.query("SOURCE:STATE?")
+ observations = []
+ for field in item.fields:
+ if field.field is SourceFieldId.IDENTITY:
+ value = SourceRuntimeIdentity(
+ manufacturer="Example",
+ model="EX2",
+ firmware_id="1.0",
+ )
+ elif field.field is SourceFieldId.BASIC:
+ value = basic_facet()
+ elif field.field is SourceFieldId.OUTPUT:
+ assert field.target.channel is not None
+ enabled = self.outputs[field.target.channel]
+ if (
+ self.postcondition_mismatch
+ and self.workspace_requests
+ and not self.output_requests
+ and field.target.channel == 2
+ ):
+ enabled = True
+ value = _output(enabled)
+ else: # pragma: no cover - the descriptor has no other read fields.
+ raise AssertionError(field)
+ observations.append(SourceTypedObservation(field, value))
+ records.append(
+ SourceProtocolQueryRecord(
+ item_id=item.item_id,
+ effect=item.effect,
+ outcome=SourceQueryItemOutcome.OBSERVED,
+ query_count=(1 if item.phase.value == "anchor_before" else 0),
+ observations=tuple(observations),
+ )
+ )
+ return SourceQueryExecutionRecord(
+ contract_version=SOURCE_CONTRACT_VERSION,
+ plan_id=plan.plan_id,
+ items=tuple(records),
+ query_count=sum(record.query_count for record in records),
+ device_revision_token_before="revision-1",
+ device_revision_token_after="revision-1",
+ )
+
+ def replace_source_arbitrary_workspace_volatile_v2(
+ self,
+ request: SourceArbitraryWorkspaceVolatileReplaceRequest,
+ payload: bytes,
+ ) -> SourceArbitraryWorkspaceVolatileReplaceResult:
+ self.transport.write_bytes(payload)
+ self.workspace_requests.append((request, payload))
+ if self.write_error:
+ raise ConfigError("fake workspace binary write result is unknown")
+ return SourceArbitraryWorkspaceVolatileReplaceResult(
+ workspace_id="volatile",
+ payload_sha256=request.payload_sha256,
+ payload_size_bytes=request.payload_size_bytes,
+ point_count=request.point_count,
+ write_completed=True,
+ content_readback_verified=False,
+ previous_content_restorable=False,
+ )
+
+ def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult:
+ self.transport.write("SOURCE:OUTPUT")
+ self.output_requests.append(request)
+ self.outputs[request.channel] = request.enabled
+ if request.enabled:
+ raise AssertionError("the workspace fixture only uses recovery OFF")
+ if request.channel in self.invalid_recovery_result_channels:
+ return SourceOutputResult(channel=request.channel + 10, enabled=False)
+ return SourceOutputResult(channel=request.channel, enabled=False)
+
+
+def _extensions() -> SourceDescriptorExtensions:
+ applicability = SourceConstraintApplicability()
+ return SourceDescriptorExtensions(
+ contract_version=SOURCE_CONTRACT_VERSION,
+ topology=SourceTopologyContract((1, 2)),
+ features=(
+ SourceFeatureCapability(
+ feature=SourceFeature.ARBITRARY_WORKSPACE,
+ support=SupportState.SUPPORTED,
+ directions=(SourceFeatureDirection.CONFIGURE,),
+ scope=SourceFacetScope.INSTRUMENT,
+ channels=(),
+ applicability=applicability,
+ profile=SourceArbitraryWorkspaceCapabilityProfile(
+ workspace_id="volatile",
+ volatile_replace_min_points=2,
+ volatile_replace_max_points=16_384,
+ volatile_replace_max_payload_bytes=32_768,
+ ),
+ ),
+ *(
+ SourceFeatureCapability(
+ feature=SourceFeature.BASIC,
+ support=SupportState.SUPPORTED,
+ directions=(SourceFeatureDirection.READ,),
+ scope=SourceFacetScope.CHANNEL,
+ channels=(channel,),
+ applicability=applicability,
+ profile=SourceBasicCapabilityProfile(
+ waveform_kinds=(SourceWaveformKind.SINE,),
+ frequency_modes=(SourceFrequencyMode.FIXED,),
+ amplitude_units=(SourceAmplitudeUnit.VPP,),
+ offset_readable=False,
+ phase_readable=False,
+ square_duty_readable=False,
+ ),
+ )
+ for channel in (1, 2)
+ ),
+ *(
+ SourceFeatureCapability(
+ feature=SourceFeature.OUTPUT,
+ support=SupportState.SUPPORTED,
+ directions=(
+ SourceFeatureDirection.DISABLE,
+ SourceFeatureDirection.ENABLE,
+ SourceFeatureDirection.READ,
+ ),
+ scope=SourceFacetScope.CHANNEL,
+ channels=(channel,),
+ applicability=applicability,
+ profile=SourceOutputCapabilityProfile(
+ output_readable=True,
+ display_load_readable=False,
+ polarity_readable=True,
+ ),
+ )
+ for channel in (1, 2)
+ ),
+ ),
+ query_contract=SourceQueryContract(
+ anchor_fields=(
+ SourceFieldId.BASIC,
+ SourceFieldId.OUTPUT,
+ SourceFieldId.IDENTITY,
+ ),
+ facets=(
+ SourceFacetQueryContract(
+ feature=SourceFeature.BASIC,
+ scope=SourceFacetScope.CHANNEL,
+ fields=(SourceFieldId.BASIC,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ ),
+ SourceFacetQueryContract(
+ feature=SourceFeature.BASIC,
+ scope=SourceFacetScope.INSTRUMENT,
+ fields=(SourceFieldId.IDENTITY,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ ),
+ SourceFacetQueryContract(
+ feature=SourceFeature.OUTPUT,
+ scope=SourceFacetScope.CHANNEL,
+ fields=(SourceFieldId.OUTPUT,),
+ activation_any=(),
+ effect=SourceQueryEffect.PURE_READ,
+ max_queries=1,
+ required=True,
+ ),
+ ),
+ max_queries=16,
+ timeout_ms=2_000,
+ ),
+ safety_profile=SourceSafetyProfile(),
+ )
+
+
+def _config() -> 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("wavebench.toml"),
+ source=SourceConfig("example.source-v2", "TCPIP::source::INSTR", 1, False, True, 0),
+ safety_limits=SafetyLimitsConfig(),
+ )
+
+
+def _service(
+ *,
+ outputs: dict[int, bool] | None = None,
+ postcondition_mismatch: bool = False,
+ write_error: bool = False,
+ invalid_recovery_result_channels: tuple[int, ...] = (),
+) -> tuple[SourceService, _WorkspaceDriver]:
+ session_state = InstrumentSessionState(epoch_id="source-arbitrary-workspace-v2")
+ driver = _WorkspaceDriver(
+ session_state=session_state,
+ outputs=outputs,
+ postcondition_mismatch=postcondition_mismatch,
+ write_error=write_error,
+ invalid_recovery_result_channels=invalid_recovery_result_channels,
+ )
+ descriptor = replace(
+ source_descriptor(driver=driver, extensions=_extensions()),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.output_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
+ ),
+ )
+ validate_source_descriptor(descriptor)
+ validate_declared_capabilities(descriptor, driver)
+ return (
+ SourceService(
+ config=_config(),
+ logger=CommandLogger(),
+ session=driver, # type: ignore[arg-type]
+ descriptor=descriptor,
+ transport=driver.transport,
+ session_state=session_state,
+ ),
+ driver,
+ )
+
+
+def _request(payload: bytes) -> SourceArbitraryWorkspaceVolatileReplaceRequest:
+ return SourceArbitraryWorkspaceVolatileReplaceRequest(
+ payload_sha256=_digest(payload),
+ payload_size_bytes=len(payload),
+ point_count=len(payload) // 2,
+ )
+
+
+def test_workspace_result_cannot_claim_previous_content_is_restorable() -> None:
+ payload = b"\x00\x00\xff\x3f"
+
+ with pytest.raises(ValueError, match="cannot claim previous content is restorable"):
+ SourceArbitraryWorkspaceVolatileReplaceResult(
+ workspace_id="volatile",
+ payload_sha256=_digest(payload),
+ payload_size_bytes=len(payload),
+ point_count=len(payload) // 2,
+ write_completed=True,
+ content_readback_verified=False,
+ previous_content_restorable=True,
+ )
+
+
+def test_workspace_capability_requires_off_support_on_every_topology_channel() -> None:
+ extensions = replace(
+ _extensions(),
+ features=tuple(
+ replace(feature, directions=(SourceFeatureDirection.READ,))
+ if feature.feature is SourceFeature.OUTPUT and feature.channels == (2,)
+ else feature
+ for feature in _extensions().features
+ ),
+ )
+ session_state = InstrumentSessionState(epoch_id="workspace-missing-off-support")
+ driver = _WorkspaceDriver(session_state=session_state)
+ descriptor = replace(
+ source_descriptor(driver=driver, extensions=extensions),
+ capabilities=(
+ "source.snapshot_v2",
+ "source.output_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
+ ),
+ )
+
+ with pytest.raises(ConfigError, match="output DISABLE support on every topology channel"):
+ validate_source_descriptor(descriptor)
+
+
+def test_workspace_volatile_replace_writes_once_without_claiming_a_channel() -> None:
+ service, driver = _service()
+ payload = b"\x00\x00\xff\x3f"
+ request = _request(payload)
+
+ result, artifact = service.replace_arbitrary_workspace_volatile_v2(
+ request,
+ payload=payload,
+ correlation_id="arb-workspace",
+ )
+
+ assert driver.workspace_requests == [(request, payload)]
+ assert driver.output_requests == []
+ assert driver.transport.counters.binary_write_completed == 1
+ assert result.workspace_id == "volatile"
+ assert artifact["operation"] == "source.arbitrary_workspace_volatile_replace_v2"
+ assert artifact["final_state"] == {
+ "session_health": "healthy",
+ "topology_outputs_expected": "off",
+ "channel_selection": "unverified",
+ "content_readback_verified": False,
+ "previous_content": "unrecoverable",
+ }
+ assert payload.hex() not in repr(artifact)
+ assert [item["phase"] for item in artifact["phases"]] == [
+ "preflight",
+ "main",
+ "postcondition",
+ ]
+
+
+def test_workspace_volatile_replace_requires_every_output_off_before_binary_write() -> None:
+ service, driver = _service(outputs={1: False, 2: True})
+ payload = b"\x00\x00\xff\x3f"
+
+ with pytest.raises(ConfigError, match="every topology output OFF"):
+ service.replace_arbitrary_workspace_volatile_v2(_request(payload), payload=payload)
+
+ assert driver.workspace_requests == []
+ assert driver.transport.counters.binary_write_requests == 0
+ assert driver.output_requests == []
+
+
+@pytest.mark.parametrize(
+ ("postcondition_mismatch", "write_error", "message"),
+ (
+ (True, False, "postcondition reports an output ON"),
+ (False, True, "workspace binary write result is unknown"),
+ ),
+)
+def test_workspace_volatile_replace_recovers_every_output_after_main_failure(
+ postcondition_mismatch: bool,
+ write_error: bool,
+ message: str,
+) -> None:
+ service, driver = _service(
+ postcondition_mismatch=postcondition_mismatch,
+ write_error=write_error,
+ )
+ payload = b"\x00\x00\xff\x3f"
+
+ with pytest.raises(ConfigError, match=message) as raised:
+ service.replace_arbitrary_workspace_volatile_v2(_request(payload), payload=payload)
+
+ artifact = raised.value.source_operation_artifact
+ assert driver.transport.counters.binary_write_requests == 1
+ assert driver.output_requests == [
+ SourceOutputRequest(channel=1, enabled=False),
+ SourceOutputRequest(channel=2, enabled=False),
+ ]
+ assert artifact["recovery"] == {
+ "status": "off_verified",
+ "channels": [1, 2],
+ "session_health": "uncertain",
+ }
+
+
+def test_workspace_recovery_continues_after_an_unproven_off_result() -> None:
+ service, driver = _service(
+ write_error=True,
+ invalid_recovery_result_channels=(1,),
+ )
+ payload = b"\x00\x00\xff\x3f"
+
+ with pytest.raises(ConfigError, match="workspace binary write result is unknown") as raised:
+ service.replace_arbitrary_workspace_volatile_v2(_request(payload), payload=payload)
+
+ assert driver.output_requests == [
+ SourceOutputRequest(channel=1, enabled=False),
+ SourceOutputRequest(channel=2, enabled=False),
+ ]
+ assert raised.value.source_operation_artifact["recovery"] == {
+ "status": "off_failed",
+ "channels": [1, 2],
+ "attempted_channels": [1, 2],
+ "failed_channels": [1],
+ "session_health": "uncertain",
+ }
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index 0f3f543..a9e31e9 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -173,7 +173,16 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None:
re.S,
)
assert match is not None
- assert module.__all__[fire_start + len(fire_exports) :] == match.group(1).splitlines()
+ d1_4_exports = match.group(1).splitlines()
+ d1_4_start = fire_start + len(fire_exports)
+ assert module.__all__[d1_4_start : d1_4_start + len(d1_4_exports)] == d1_4_exports
+ match = re.search(
+ r"D1-5/无通道 VOLATILE workspace 在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```",
+ rfc,
+ re.S,
+ )
+ assert match is not None
+ assert module.__all__[d1_4_start + len(d1_4_exports) :] == match.group(1).splitlines()
def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None:
@@ -354,6 +363,12 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None:
"volatile_replace_max_points",
"volatile_replace_max_payload_bytes",
),
+ "SourceArbitraryWorkspaceCapabilityProfile": (
+ "workspace_id",
+ "volatile_replace_min_points",
+ "volatile_replace_max_points",
+ "volatile_replace_max_payload_bytes",
+ ),
"SourceCounterCapabilityProfile": (
"input_ids",
"measurement_kinds",
@@ -744,6 +759,9 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None:
"source.arbitrary_volatile_replace_v2": (
"replace_source_arbitrary_volatile_v2",
),
+ "source.arbitrary_workspace_volatile_replace_v2": (
+ "replace_source_arbitrary_workspace_volatile_v2",
+ ),
"source.counter_configure_v2": ("configure_source_counter_v2",),
"source.counter_enable_v2": ("set_source_counter_enabled_v2",),
"source.counter_measure_v2": ("measure_source_counter_v2",),
diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py
index 30c6290..777594f 100644
--- a/tests/test_source_v1_routes.py
+++ b/tests/test_source_v1_routes.py
@@ -48,6 +48,7 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
"source.counter_configure_v2",
"source.counter_enable_v2",
"source.counter_disable_v2",
@@ -95,6 +96,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
"source.arbitrary_volatile_replace_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
"source.combine_configure_v2",
"source.coupling_configure_v2",
"source.tracking_configure_v2",
@@ -124,6 +126,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.pulse_configure_v2",
"source.arbitrary_storage_v2",
"source.arbitrary_select_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
"source.counter_configure_v2",
"source.counter_enable_v2",
"source.counter_disable_v2",
@@ -136,7 +139,10 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
spec.operation
for spec in list_operation_specs(instrument_kind="source")
if "_v2" in spec.operation and spec.effect == "write"
- } == expected_v2_operations | {"source.arbitrary_volatile_replace_v2"}
+ } == expected_v2_operations | {
+ "source.arbitrary_volatile_replace_v2",
+ "source.arbitrary_workspace_volatile_replace_v2",
+ }
with TemporaryDirectory() as tmp:
valid_steps = {
@@ -159,6 +165,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive()
"source.pulse_configure_v2": "channel = 1\nwidth_s = 1e-6\ndelay_s = 0\nleading_transition_s = 1e-8\ntrailing_transition_s = 1e-8\n",
"source.arbitrary_storage_v2": "channel = 1\nslot_id = \"slot_a\"\nfile = \"payload.bin\"\nwrite_mode = \"create_only\"\n",
"source.arbitrary_select_v2": "channel = 1\nslot_id = \"slot_a\"\nplayback_mode = \"dds\"\nplayback_frequency_hz = 1000\n",
+ "source.arbitrary_workspace_volatile_replace_v2": "file = \"payload.bin\"\npoint_count = 2\n",
"source.combine_configure_v2": "channels = [1, 2]\nenabled = true\n",
"source.coupling_configure_v2": "channels = [1, 2]\nenabled = true\n",
"source.tracking_configure_v2": "channels = [1, 2]\nenabled = true\n",
From 675becf30a61256aa6ca3537e3c15567a108ba0b Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 31 Aug 2026 22:40:46 +0800
Subject: [PATCH 44/44] fix(source): allow disable-only output v2
---
...345\207\272\345\256\211\345\205\250RFC.md" | 7 +--
.../source_extension_capabilities.py | 11 +++--
tests/test_source_extensions.py | 20 ++++++++-
tests/test_source_output_v2.py | 44 ++++++++++++++++---
4 files changed, 68 insertions(+), 14 deletions(-)
diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
index ef463e4..b13b094 100644
--- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
+++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md"
@@ -3262,9 +3262,10 @@ M5-A 只增加闭合的单通道 model,不提供自由 mapping 或通用 patch
声明任一 M5-A 写 capability 的 descriptor 必须同时声明 `source.snapshot_v2`。基础配置要求同一
channel 的 Basic 支持 `READ` 与 `CONFIGURE`,并能回读最终 Vpp、Offset 和输出状态;输出 capability
-要求同一 channel 的 Output 支持 `READ`、`ENABLE` 与 `DISABLE`,并能回读输出状态。启用动作在运行时
-另行要求同一 channel 可返回最终 Vpp 与 Offset;关闭动作不以它们为条件。方向、profile、channel 或
-required method 不匹配时,在 factory 及仪器 I/O 前失败。
+至少要求同一 channel 的 Output 支持 `READ` 与 `DISABLE`,并能回读输出状态。`ENABLE` 是可选方向;若声明,
+每个可开启 channel 必须也支持 `DISABLE`。这样可为仅需安全关闭的受限 capability 提供 output substrate,
+而任意 enable request 仍会在运行时方向校验后、写入前失败。启用动作另行要求同一 channel 可返回最终 Vpp 与
+Offset;关闭动作不以它们为条件。方向、profile、channel 或 required method 不匹配时,在 factory 或相应写入前失败。
M5-A 不增加 `SourceService` 写方法、CLI 写命令或 run plan step;现有 V1 setter、CLI、run plan、TUI
和 artifact 保持原样。capability 注册只让核心识别插件合同,不构成可调用写入口。
diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py
index daec6d7..67d5f9c 100644
--- a/src/wavebench/instruments/source_extension_capabilities.py
+++ b/src/wavebench/instruments/source_extension_capabilities.py
@@ -896,11 +896,16 @@ def _validate_write_contract(
SourceFeature.OUTPUT,
SourceFeatureDirection.DISABLE,
)
- if not enabled or enabled != disabled:
+ if not disabled:
raise ConfigError(
- "source.output_v2 requires matching output ENABLE and DISABLE directions"
+ "source.output_v2 requires output DISABLE directions"
)
- if not enabled <= output_readable:
+ if not enabled <= disabled:
+ raise ConfigError(
+ "source.output_v2 requires every output ENABLE direction to have matching "
+ "DISABLE support"
+ )
+ if not disabled <= output_readable:
raise ConfigError(
"source.output_v2 requires readable output state on every channel"
)
diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py
index a9e31e9..b6fccce 100644
--- a/tests/test_source_extensions.py
+++ b/tests/test_source_extensions.py
@@ -2269,7 +2269,25 @@ def set_source_output_v2(self, request):
),
)
)
- with pytest.raises(ConfigError, match="matching output ENABLE and DISABLE"):
+ disable_only_descriptor = replace(
+ descriptor,
+ source_extensions=replace(
+ write_extensions,
+ features=(
+ write_extensions.features[0],
+ replace(
+ write_extensions.features[1],
+ directions=(
+ SourceFeatureDirection.DISABLE,
+ SourceFeatureDirection.READ,
+ ),
+ ),
+ ),
+ ),
+ )
+ validate_source_descriptor(disable_only_descriptor)
+
+ with pytest.raises(ConfigError, match="requires output DISABLE directions"):
validate_source_descriptor(
replace(
descriptor,
diff --git a/tests/test_source_output_v2.py b/tests/test_source_output_v2.py
index c933b6f..4e789a2 100644
--- a/tests/test_source_output_v2.py
+++ b/tests/test_source_output_v2.py
@@ -191,7 +191,15 @@ def _config(*, limits: SafetyLimitsConfig = SafetyLimitsConfig()) -> WaveBenchCo
)
-def _extensions(*, final_vpp_available: bool):
+def _extensions(
+ *,
+ final_vpp_available: bool,
+ output_directions: tuple[SourceFeatureDirection, ...] = (
+ SourceFeatureDirection.DISABLE,
+ SourceFeatureDirection.ENABLE,
+ SourceFeatureDirection.READ,
+ ),
+):
base = source_extensions()
basic, output = base.features
if not final_vpp_available:
@@ -206,11 +214,7 @@ def _extensions(*, final_vpp_available: bool):
second_basic = replace(basic, channels=(2,))
output = replace(
output,
- directions=(
- SourceFeatureDirection.DISABLE,
- SourceFeatureDirection.ENABLE,
- SourceFeatureDirection.READ,
- ),
+ directions=output_directions,
)
second_output = replace(output, channels=(2,))
return replace(
@@ -228,6 +232,11 @@ def _service(
ignore_enable: bool = False,
raise_after_output_write: bool = False,
limits: SafetyLimitsConfig = SafetyLimitsConfig(),
+ output_directions: tuple[SourceFeatureDirection, ...] = (
+ SourceFeatureDirection.DISABLE,
+ SourceFeatureDirection.ENABLE,
+ SourceFeatureDirection.READ,
+ ),
) -> tuple[SourceService, _OutputDriver]:
session_state = InstrumentSessionState(epoch_id="source-output-v2")
driver = _OutputDriver(
@@ -238,7 +247,13 @@ def _service(
raise_after_output_write=raise_after_output_write,
)
descriptor = replace(
- source_descriptor(driver=driver, extensions=_extensions(final_vpp_available=final_vpp_available)),
+ source_descriptor(
+ driver=driver,
+ extensions=_extensions(
+ final_vpp_available=final_vpp_available,
+ output_directions=output_directions,
+ ),
+ ),
capabilities=("source.snapshot_v2", "source.output_v2"),
)
validate_source_descriptor(descriptor)
@@ -321,6 +336,21 @@ def test_output_disable_v2_does_not_require_final_vpp_or_offset() -> None:
assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)]
+def test_output_enable_v2_rejects_a_disable_only_runtime_profile_before_write() -> None:
+ service, driver = _service(
+ output_directions=(
+ SourceFeatureDirection.DISABLE,
+ SourceFeatureDirection.READ,
+ )
+ )
+
+ with pytest.raises(ConfigError, match="not available for the runtime target channel"):
+ service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True))
+
+ assert driver.output_requests == []
+ assert driver.transport.counters.write_requests == 0
+
+
def test_output_enable_v2_rejects_missing_final_vpp_or_offset_before_write() -> None:
service, driver = _service(final_vpp_available=False)