Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/development/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ WaveBench 的代码、文档、schema 和测试在同一仓库中维护。提交
按[测试说明](testing.md)选择检查:局部修改先做聚焦验证,文档或 Skill 修改检查相关内容,跨模块或合并评估执行集成检查。远端 CI 仍按仓库 workflow 执行,不要求每个编辑步骤重跑全量测试。

新增或修改用户可见行为时,更新唯一 canonical Reference,并用[文档工作流](documentation.md)进行 scoped review。插件专用流程见[插件开发](plugin-development.md)。

尚未发布的示波器观察入口及执行验收合同见[开发线示波器观察](scope-observation.md)。
85 changes: 85 additions & 0 deletions docs/development/scope-observation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 开发线示波器观察

> 状态:`Proposed / Future`,开发线已实现、尚未正式发布。
> 本页面向实现与测试人员,记录开发线的执行合同;不表示已安装的正式版本提供这些入口。

## 实现入口与职责

CLI 的 `scope observe` 和 MCP 的 `scope.observe` / `scope.advise` 由
`services/agent_observe.py`、`services/agent_advise.py`、`ScopeService` 与 `mcp_http.py`
共同实现。CLI 参数以本开发线的 `cli_parser.py` 和离线 `--help` 为准。
Core 表达观察、访问约束、会话和报告语义;型号通道、端接与传输差异由 descriptor 和 driver
提供,不在观察层维护型号范围表。

MCP 的 `doctor.config` 同属未发布入口,调用 `doctor_records()` 并返回结构化检查记录。
它不读取波形或执行建议;实际查询合同以 doctor 与 driver 实现为准。

## 观察与建议

默认 `scope observe` 只获取身份、可用状态与输入耦合,不读取波形。观察路径收紧有效访问
权限,原配置为 `disabled` 时在打开连接前拒绝。只有具备纯查询合同的状态才进入结果;缺少字段或
capability 时保留不可用原因,不把部分状态当作完整快照。

MCP `scope.advise` 依据可用状态和调用方的 `expected_frequencies_hz` 提供显示或时基建议,
不会执行建议。无可用档位、测量值或期望频率时返回逐通道 `advice_unavailable`,表示无法
评估当前设置。查询失败不能生成「无需调整」的判断。调用方提供的频率只标为 configured,
不能当作实测证据;带低周期告警的测量频率不能直接用于时基建议。

生成的 focus 建议遵循现有 CLI 格式:`--vertical-scale CHANNEL=V_PER_DIV`;
隐藏其它通道使用 `--hide-others`。建议的执行仍须经过命令自身的 access、capability 和
运行时安全检查。

## 显式波形报告与预检

`scope observe --fetch-waveform` 会操作仪器,可能停止采集、启用通道显示、改变波形传输
source/mode/format/points,以及按配置消费错误队列。它不恢复原来的运行状态。MCP 不提供
这一读取路径。

`--target-cycles` 与 `--target-vertical-divisions` 必须是有限正数;expectation 的字段名、
类型、有限性与取值范围由 `data/expectations.py` 校验。这些输入在创建仪器服务前拒绝。
型号相关的通道支持验证可能需要连接后的纯查询预检;全部请求通道必须在第一次采集写入
前完成验证。它不是「任意型号输入错误都在打开会话前拒绝」的保证。

离线 access、capability 或波形配置预检失败时不构造 driver 或 transport。观察路径的
factory 构造阶段对所有 descriptor 启用 Core I/O 锁,禁止通过 context transport 查询或
写入;构造与声明校验完成后才释放。需要在构造阶段进行设备初始化的旧插件不能使用这一
入口,应将设备操作移入明确的执行方法。该约束只保护 Core transport,不提供 Python 沙箱。

报告在受控持续会话和独占资源租约内执行。最终高阻确认与波形读取共用会话和租约,避免
遵守同一资源锁的其它进程在两者之间改变输入设置;资源锁不能阻止前面板或外部软件操作。
不确定 I/O 或会话健康故障使剩余采集停止,并记录中止原因;不自动重连继续写入。

持续会话不证明波形来自同一次 acquisition。报告仍将 `same_acquisition` 标为 false,
跳过相位、相关性、延迟和交点,保留同步无关摘要。正式同步时序分析应使用
`scope capture --synchronized` 产物和既有 `analysis.pair` 同步证明合同。
`data.relationships` 的默认值同样不认定同步;其显式断言仅供已确认来源的内部分析。

## 期望检查与输出

`--expect <file.toml>` 需要显式波形报告;示例格式为:

```toml
[channels.1]
frequency_hz = 1000
frequency_tolerance_ratio = 0.05
vpp_v = 3.3
duty_percent = 50
```

汇总保留全部待验收通道。无法取得或评估波形时通道为 `unavailable`;全部不可用时总体为
`unavailable`,有可用验收但不完整时为 `partial`,已确认的 `fail` 优先。
无可执行期望指标时为 `skipped`。报告成功返回不等于所有期望通过,调用方须检查验收汇总
以及 warnings 和逐通道结果。

## 维护与验收

聚焦验证覆盖生命周期关闭与借用、跨进程租约竞争、非法后续通道零采集写入、会话故障后的
剩余通道中止、禁用配置及离线失败零构造、legacy factory I/O 拦截、真实 guard 的观察零写,
以及缺证据建议与同步默认值。测试只使用 fake
transport 和合成信号,不连接设备;实机 evidence 属于单独授权的插件验证。

发布时依据实际 tag 和实现将正式可用行为转入 CLI Reference 和 MCP How-to,并保留唯一
事实来源。本页不提前承诺发布版本、插件支持范围或硬件验收结论。

相关合同见[安全模型](../concepts/safety-model.md)、[会话与恢复](../concepts/sessions-and-recovery.md)、
[插件模型](../concepts/plugin-model.md)和[测试说明](testing.md)。
15 changes: 4 additions & 11 deletions docs/how-to/serve-mcp.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 启动只读 MCP 服务

WaveBench HTTP MCP 服务提供本机或受控网络中的离线信息和只读仪器观察。它不提供 raw SCPI、输出控制或 run 执行。
WaveBench HTTP MCP 服务提供本机或受控网络中的离线信息和 run plan 检查。它不提供 raw SCPI、输出控制或 run 执行。

## 启动服务

Expand All @@ -20,23 +20,16 @@ python -m wavebench mcp serve \
| `GET /tools` | Bearer token | 列出只读工具。 |
| `POST /call`、`POST /mcp` | Bearer token | 调用 MCP 工具或 JSON-RPC 方法。 |

请求体上限为 1 MiB。所有工具都是只读的,不会改变仪器状态,也不会读取波形。工具的权威列表和元数据以 `GET /tools` 返回为准:
请求体上限为 1 MiB。已发布的工具不连接仪器;安装版本的实际工具列表以 `GET /tools` 返回为准:

| 工具 | 行为 |
| --- | --- |
| `run.schema` | 返回 run plan schema。 |
| `run.check` | 只接受项目内的 `plans/*.toml`,离线解析并校验 run plan。 |
| `capture.inspect` | 只读取项目内的离线采集包摘要。 |
| `doctor.config` | 对配置中的仪器执行只读 doctor 检查,返回结构化记录。 |
| `scope.observe` | 读取示波器身份、每通道状态快照和输入耦合安全;不读取波形。 |
| `scope.advise` | 基于只读状态快照和调用方给出的 `expected_frequencies_hz` 建议显示/采集参数;不应用建议。 |

`doctor.config`、`scope.observe` 和 `scope.advise` 是实验性工具:开发线已实现并有离线测试,但尚未随正式版本发布,
支持范围不作承诺。

`scope.observe` 和 `scope.advise` 不读取示波器波形。波形摘要、期望值检查和跨通道关系分析需要
先显式读取波形(属于会改变仪器状态的写路径),请在操作者明确执行
[`scope observe --fetch-waveform`](../reference/cli.md) 时进行,不通过 MCP 暴露。
开发线新增的 `doctor.config`、`scope.observe` 和 `scope.advise` 尚未随正式版本发布,
不构成本页的功能承诺。其实施边界见[开发线示波器观察](../development/scope-observation.md)。

## Verification

Expand Down
35 changes: 4 additions & 31 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,13 @@ python -m wavebench --json <domain> <command> ...
| --- | --- | --- |
| 离线、只读本地文件 | `run schema`、`run check`、`run intent`、`run compare`、`run resume`、`capture inspect`、`capability explain`、`lock status` | 不连接仪器。 |
| 离线且可能写本地文件 | `run template --output`、`run report`、`run report-index` | 不连接仪器,但会创建模板、报告或索引文件。 |
| 连接读取或预检 | `doctor`、状态/身份查询、`scope observe`(不带 `--fetch-waveform`)、`run verify` | 会访问配置的仪器,不应改变实验设置。 |
| 可能改变状态或触发采集 | 输出和 setter、`scope auto`/`scope capture`、`scope observe --fetch-waveform`、非 fake TUI、`run plan` | 可能写入仪器、触发采集或切换输出。 |
| 连接读取或预检 | `doctor`、状态/身份查询、`run verify` | 会访问配置的仪器,不应改变实验设置。 |
| 可能改变状态或触发采集 | 输出和 setter、`scope auto`/`scope capture`、非 fake TUI、`run plan` | 可能写入仪器、触发采集或切换输出。 |

`scope fetch` 读取已有波形,但仍是仪器 I/O;不要把它当作离线命令。每次硬件操作前确认接线、输入阻抗、输出状态和安全限制。WaveBench 不会自动执行 `*RST`,也不会因设置电压、幅度或频率而自动开启输出。

`scope observe` 是实验性命令:开发线已实现并有离线测试,但尚未随正式版本发布,兼容性和支持范围不作承诺。
它默认只读:只查询身份、通道状态快照和输入耦合安全,不读取波形。
`scope observe --fetch-waveform` 是显式写路径,可能停止正在运行的采集、修改波形传输
source/mode/format/points 并打开通道显示;它逐通道读取波形,因此多通道结果不保证来自同一次
acquisition,此时跨通道的相位、相关性、延迟和交点不会被计算(`correlation`、`intersections`
返回 `skipped`,相位为 `null`)。需要驱动可证明的同一次采集时,使用
`scope capture --synchronized`(通道和输出格式要求见其 `--help`)。

期望值检查通过 `--expect <file.toml>` 提供,需要 `--fetch-waveform`:

```toml
[channels.1]
frequency_hz = 1000
frequency_tolerance_ratio = 0.05
vpp_v = 3.3
duty_percent = 50
```

字段名、类型、有限性和取值范围由实现严格校验(`src/wavebench/data/expectations.py` 的 `validate_expectation()`);
拼错字段名会直接报错,不会被静默忽略。上面的 TOML 只示范格式,字段全集以该实现为准。任何输入错误都在
打开仪器会话之前被拒绝,因此不会产生仪器写入。

`--target-cycles` 和 `--target-vertical-divisions` 必须为有限正数,并在加载配置或创建仪器服务
之前校验。生成的 focus 建议使用 `--vertical-scale CHANNEL=V_PER_DIV`;隐藏其他通道的参数为
`--hide-others`。建议不会自动执行。

期望值汇总的 `channels` 保留所有待验收通道。波形读取、安全检查或期望值计算失败的通道标记为
`unavailable`;没有可用检查结果时汇总为 `unavailable`,部分通道已有 `pass`/`warn` 结果时为
`partial`。已确认的 `fail` 仍优先返回 `fail`。未提供期望值或期望值没有可执行指标时保持 `skipped`。
`scope observe` 尚未随正式版本发布,不属于本页的已发布命令承诺。开发线的参数、输出和
副作用合同见[开发线示波器观察](../development/scope-observation.md)。

## JSON 输出与退出码

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ nav:
- 开发:
- 贡献: development/contributing.md
- 测试: development/testing.md
- 开发线示波器观察: development/scope-observation.md
- 文档工作流: development/documentation.md
- 插件开发: development/plugin-development.md
- 新增仪器驱动: development/instrument-drivers.md
Expand Down
11 changes: 9 additions & 2 deletions src/wavebench/data/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
def analyze_waveform_relationships(
waveforms: dict[int, WaveformData],
*,
same_acquisition: bool = True,
same_acquisition: bool = False,
max_correlation_points: int = 4096,
max_intersections: int = 64,
) -> list[dict[str, Any]]:
Expand All @@ -34,10 +34,17 @@ def analyze_waveform_pair(
left: WaveformData,
right: WaveformData,
*,
same_acquisition: bool = True,
same_acquisition: bool = False,
max_correlation_points: int = 4096,
max_intersections: int = 64,
) -> dict[str, Any]:
"""Summarize waveforms; timing requires an explicit shared-acquisition assertion.

This helper does not validate capture provenance. Real capture timing analysis
must use the validated synchronization contract in ``pair_analysis``.
"""
if not isinstance(same_acquisition, bool):
raise ValueError("same_acquisition must be a boolean")
left_summary = left.summary()
right_summary = right.summary()
warnings: list[str] = []
Expand Down
13 changes: 12 additions & 1 deletion src/wavebench/instruments/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,16 @@ def open_instrument_driver(
serial_config: DmmConfig | None = None,
access: AccessMode = "read_write",
lease: ResourceLease | None = None,
force_deferred_io: bool = False,
) -> OpenedInstrument:
"""Construct and validate a driver, optionally deferring all guarded I/O.

Observation callers use force_deferred_io for legacy descriptors as well as
V2 descriptors. The latch is released only after factory validation succeeds.
"""

if not isinstance(force_deferred_io, bool):
raise ConfigError("force_deferred_io must be bool")
normalized_access = normalize_access_mode(access, "access")
if lease is not None and lease.fingerprint != resource_fingerprint(resource, lease.lock_id):
raise ConfigError("resource lease does not match configured instrument resource")
Expand All @@ -82,7 +91,9 @@ def open_instrument_driver(
strict_v2_capability_opt_in = bool(
set(descriptor.capabilities) & SCOPE_STRICT_V2_CAPABILITIES
)
construction_latched = bounded_binary_profile_opt_in or strict_v2_capability_opt_in
construction_latched = (
force_deferred_io or bounded_binary_profile_opt_in or strict_v2_capability_opt_in
)
backend = _select_backend(configured_backend, descriptor.backends)
_validate_resource_scheme(resource, descriptor.resource_schemes)
try:
Expand Down
57 changes: 42 additions & 15 deletions src/wavebench/services/agent_advise.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def _recommendations(
channel = channel_section.get("channel")
if not isinstance(channel, int):
continue
recommendation_count = len(recommendations)
summary = _waveform_summary(channel_section)
snapshot = _scope_status_data(channel_section)
frequency_hz, source, confidence, withheld_reason = _frequency_for_advice(
Expand All @@ -152,7 +153,8 @@ def _recommendations(
"time_range_s": time_range,
"vertical_scale_v_per_div": vertical_scale,
}
if snapshot and snapshot.get("channel", {}).get("enabled") is False:
snapshot_channel = None if snapshot is None else snapshot.get("channel")
if isinstance(snapshot_channel, dict) and snapshot_channel.get("enabled") is False:
recommendations.append(
_command_recommendation(
"display_on",
Expand Down Expand Up @@ -201,6 +203,8 @@ def _recommendations(
},
)
)
if len(recommendations) == recommendation_count:
recommendations.append(_unavailable_advice(channel))
span = _frequency_span(channel_profiles)
if span is not None and span["ratio_high_over_low"] > 10.0:
recommendations.append(
Expand All @@ -223,19 +227,27 @@ def _recommendations(
}
)
if not recommendations:
recommendations.append(
{
"id": "no_adjustment_needed",
"priority": "low",
"action": "keep_current_scope_settings",
"reason": "No obvious display or acquisition-window issue was found.",
"mutates_instrument_if_applied": False,
"raw_scpi": False,
}
)
recommendations.append(_unavailable_advice(None))
return recommendations


def _unavailable_advice(channel: int | None) -> dict[str, Any]:
recommendation: dict[str, Any] = {
"id": "advice_unavailable",
"priority": "normal",
"action": "obtain_scope_evidence",
"reason": (
"No usable display settings, waveform metrics or expected frequency are available; "
"the current settings could not be assessed."
),
"mutates_instrument_if_applied": False,
"raw_scpi": False,
}
if channel is not None:
recommendation["channel"] = channel
return recommendation


def _waveform_summary(channel_section: dict[str, Any]) -> dict[str, Any] | None:
waveform = channel_section.get("waveform", {})
if waveform.get("status") != "ok":
Expand All @@ -246,6 +258,8 @@ def _waveform_summary(channel_section: dict[str, Any]) -> dict[str, Any] | None:

def _scope_status_data(channel_section: dict[str, Any]) -> dict[str, Any] | None:
status = channel_section.get("scope_status", {})
if status.get("status") not in {"ok", "partial"}:
return None
data = status.get("data")
return data if isinstance(data, dict) else None

Expand Down Expand Up @@ -279,7 +293,10 @@ def _summary_frequency(summary: dict[str, Any] | None) -> float | None:
if summary is None:
return None
value = summary.get("frequency_estimate_hz")
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
if (
not isinstance(value, (int, float)) or isinstance(value, bool)
or not math.isfinite(value) or value <= 0
):
return None
return float(value)

Expand All @@ -304,12 +321,20 @@ def _recommended_vertical_scale(
target_vertical_divisions: float,
) -> float | None:
vpp = None if summary is None else summary.get("voltage_vpp_v")
if isinstance(vpp, (int, float)) and not isinstance(vpp, bool) and vpp > 0:
if (
isinstance(vpp, (int, float)) and not isinstance(vpp, bool)
and math.isfinite(vpp) and vpp > 0
):
return float(vpp) / target_vertical_divisions
scale = None
if snapshot is not None:
scale = snapshot.get("channel", {}).get("scale_v_per_div")
if isinstance(scale, (int, float)) and not isinstance(scale, bool) and scale > 0:
snapshot_channel = snapshot.get("channel")
if isinstance(snapshot_channel, dict):
scale = snapshot_channel.get("scale_v_per_div")
if (
isinstance(scale, (int, float)) and not isinstance(scale, bool)
and math.isfinite(scale) and scale > 0
):
return float(scale)
return None

Expand Down Expand Up @@ -420,6 +445,8 @@ def _agent_hints(
recommendations: list[dict[str, Any]],
) -> list[str]:
hints = list(observation.get("agent_hints", []))
if any(item["id"] == "advice_unavailable" for item in recommendations):
hints.append("advise: insufficient evidence is not a recommendation to keep current settings")
if any(item["id"] == "separate_timebase_profiles" for item in recommendations):
hints.append("advise: run focus/observe per channel when frequencies differ greatly")
if any(item["id"] == "timebase_advice_withheld" for item in recommendations):
Expand Down
Loading
Loading