From 4fa88b648f4ef6012f38a6d7a80b55eb811d29b7 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 4 Sep 2026 14:46:46 +0800
Subject: [PATCH 01/30] feat(run): add offline signal processing pipelines
---
docs/reference/generated/run-schema.md | 9 +
src/wavebench/cli.py | 1 +
src/wavebench/cli_output.py | 5 +-
src/wavebench/data/signal_pipeline.py | 279 ++++++++++++
src/wavebench/mcp_http.py | 7 +-
src/wavebench/report/html.py | 206 ++++++++-
src/wavebench/services/execution_intent.py | 2 +
.../services/frequency_response_evidence.py | 5 +-
src/wavebench/services/operation_specs.py | 1 +
src/wavebench/services/run_analysis.py | 3 +
src/wavebench/services/run_artifacts.py | 6 +-
src/wavebench/services/run_pipeline.py | 414 ++++++++++++++++++
src/wavebench/services/run_plan.py | 227 +++++++++-
src/wavebench/services/run_safety.py | 1 +
src/wavebench/services/run_service.py | 116 ++++-
tests/test_execution_intent.py | 61 +++
tests/test_report.py | 98 +++++
tests/test_run_artifacts.py | 21 +-
tests/test_run_pipeline.py | 293 +++++++++++++
tests/test_run_plan_analysis.py | 261 +++++++++++
tests/test_run_service.py | 47 +-
tests/test_run_service_analysis.py | 298 +++++++++++++
tests/test_signal_pipeline.py | 227 ++++++++++
23 files changed, 2571 insertions(+), 17 deletions(-)
create mode 100644 src/wavebench/data/signal_pipeline.py
create mode 100644 src/wavebench/services/run_pipeline.py
create mode 100644 tests/test_run_pipeline.py
create mode 100644 tests/test_run_plan_analysis.py
create mode 100644 tests/test_run_service_analysis.py
create mode 100644 tests/test_signal_pipeline.py
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index 32e58d69..f01831ba 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -12,8 +12,13 @@ Top-level tables:
[safety] optional: scope_guard_channel, require_scope_coupling_not, allow_50ohm, safety_gate, off_source_channels, off_power_channels
[restore] optional: source_state, source_channel, source_channels
[[steps]] required: kind
+ [[steps]] optional structural field: id matching ^[a-z][a-z0-9_-]{0,63}$
Supported step kinds:
+ - analysis.pipeline
+ required: source, operations
+ optional : expect, on_failure
+ note : Process one earlier scope.capture NPY after all hardware sessions close. Uses a validated linear NumPy operator list and never opens an instrument.
- dmm.read
required: -
optional : expect, function, on_failure, safety_gate
@@ -237,4 +242,8 @@ Supported step kinds:
scope.capture [steps.expect_fft] metrics:
FFT checks analyze the saved NPY waveform.
Common metrics: peak_frequency_hz, peak_amplitude_v, thd_ratio, harmonic_2_amplitude_v.
+
+analysis.pipeline metrics:
+ Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.
+ Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.
```
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index 4d5215dc..c93468f7 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -808,6 +808,7 @@ def _run_plan_payload(plan) -> dict[str, object]:
"index": step.index,
"kind": step.kind,
"fields": _json_payload(step.fields),
+ **({"id": step.id} if step.id is not None else {}),
}
for step in plan.steps
],
diff --git a/src/wavebench/cli_output.py b/src/wavebench/cli_output.py
index ab7c9202..aaa25727 100644
--- a/src/wavebench/cli_output.py
+++ b/src/wavebench/cli_output.py
@@ -281,10 +281,11 @@ def _print_scpi_probe_result(result: ScpiProbeResult) -> None:
print(f"idn_match={'yes' if result.matched else 'no'}")
def _format_step_summary(step: RunStep) -> str:
+ identity = f" id={step.id}" if step.id is not None else ""
if not step.fields:
- return f"{step.index}: {step.kind}"
+ return f"{step.index}: {step.kind}{identity}"
fields = " ".join(f"{key}={value}" for key, value in step.fields.items())
- return f"{step.index}: {step.kind} {fields}"
+ return f"{step.index}: {step.kind}{identity} {fields}"
def _print_run_plan_summary(plan: RunPlan) -> None:
diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py
new file mode 100644
index 00000000..fbe92a57
--- /dev/null
+++ b/src/wavebench/data/signal_pipeline.py
@@ -0,0 +1,279 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+import numpy as np
+
+from wavebench.errors import DataError
+
+
+ANALYSIS_TIME_METRICS = frozenset({
+ "voltage_min_v",
+ "voltage_max_v",
+ "voltage_mean_v",
+ "voltage_rms_v",
+ "voltage_vpp_v",
+})
+ANALYSIS_FREQUENCY_METRICS = frozenset({
+ "peak_frequency_hz",
+ "peak_amplitude_v",
+ "noise_floor_v",
+ "thd_ratio",
+ *(
+ f"harmonic_{order}_{field}"
+ for order in range(2, 6)
+ for field in ("frequency_hz", "amplitude_v")
+ ),
+})
+SIGNIFICANT_PEAK_V = 1e-12
+
+
+@dataclass(frozen=True)
+class TimeSignal:
+ time_s: np.ndarray
+ voltage_v: np.ndarray
+ coherent_gain: float = 1.0
+ window_name: str | None = None
+
+ def as_array(self) -> np.ndarray:
+ return np.column_stack((self.time_s, self.voltage_v))
+
+
+@dataclass(frozen=True)
+class FrequencySignal:
+ frequency_hz: np.ndarray
+ spectrum_v: np.ndarray
+ samples: int
+ sample_interval_s: float
+ coherent_gain: float
+ window_name: str | None
+
+ @property
+ def amplitude_v(self) -> np.ndarray:
+ return np.abs(self.spectrum_v)
+
+ @property
+ def sample_rate_hz(self) -> float:
+ return 1.0 / self.sample_interval_s
+
+ @property
+ def resolution_hz(self) -> float:
+ if self.frequency_hz.size < 2:
+ return 0.0
+ return float(self.frequency_hz[1] - self.frequency_hz[0])
+
+ def as_array(self) -> np.ndarray:
+ return np.column_stack(
+ (
+ self.frequency_hz,
+ self.spectrum_v.real,
+ self.spectrum_v.imag,
+ self.amplitude_v,
+ )
+ )
+
+
+def validate_waveform(data: Any) -> TimeSignal:
+ array = np.asarray(data)
+ if array.ndim != 2 or array.shape[1:] != (2,) or array.shape[0] < 1:
+ raise DataError("analysis pipeline input must be a non-empty Nx2 waveform array")
+ if not np.issubdtype(array.dtype, np.number) or np.issubdtype(
+ array.dtype, np.complexfloating
+ ):
+ raise DataError("analysis pipeline input must contain real numeric values")
+ array = np.array(array, dtype=np.float64, copy=True)
+ if not np.all(np.isfinite(array)):
+ raise DataError("analysis pipeline input must contain only finite values")
+ if array.shape[0] > 1 and not np.all(np.diff(array[:, 0]) > 0):
+ raise DataError("analysis pipeline time axis must be strictly increasing")
+ return TimeSignal(time_s=array[:, 0], voltage_v=array[:, 1])
+
+
+def remove_dc(signal: TimeSignal) -> TimeSignal:
+ voltage = signal.voltage_v - float(np.mean(signal.voltage_v))
+ return _replace_voltage(signal, voltage)
+
+
+def detrend_linear(signal: TimeSignal) -> TimeSignal:
+ if signal.time_s.size < 2:
+ raise DataError("linear detrend requires at least two samples")
+ centered_time = signal.time_s - float(np.mean(signal.time_s))
+ centered_voltage = signal.voltage_v - float(np.mean(signal.voltage_v))
+ denominator = float(np.dot(centered_time, centered_time))
+ if not np.isfinite(denominator) or denominator <= 0:
+ raise DataError("linear detrend requires a usable time axis")
+ slope = float(np.dot(centered_time, centered_voltage) / denominator)
+ trend = float(np.mean(signal.voltage_v)) + slope * centered_time
+ voltage = signal.voltage_v - trend
+ return _replace_voltage(signal, voltage)
+
+
+def window_signal(signal: TimeSignal, name: str) -> TimeSignal:
+ windows = {
+ "hann": np.hanning,
+ "hamming": np.hamming,
+ "blackman": np.blackman,
+ }
+ factory = windows.get(name)
+ if factory is None:
+ raise DataError("analysis window must be one of hann, hamming, blackman")
+ window = factory(signal.voltage_v.size)
+ gain = float(np.mean(window))
+ coherent_gain = signal.coherent_gain * gain
+ if not np.isfinite(coherent_gain) or coherent_gain <= 0:
+ raise DataError("analysis window has an invalid coherent gain")
+ voltage = signal.voltage_v * window
+ result = _replace_voltage(signal, voltage)
+ return TimeSignal(
+ time_s=result.time_s,
+ voltage_v=result.voltage_v,
+ coherent_gain=coherent_gain,
+ window_name=name,
+ )
+
+
+def fft_signal(signal: TimeSignal) -> FrequencySignal:
+ samples = int(signal.voltage_v.size)
+ if samples < 4:
+ raise DataError("analysis FFT requires at least four samples")
+ intervals = np.diff(signal.time_s)
+ sample_interval = float(np.median(intervals))
+ if not np.allclose(intervals, sample_interval, rtol=1e-6, atol=0.0):
+ raise DataError("analysis FFT requires uniformly sampled data")
+ if not np.isfinite(signal.coherent_gain) or signal.coherent_gain <= 0:
+ raise DataError("analysis FFT requires a positive coherent gain")
+
+ spectrum = np.fft.rfft(signal.voltage_v) / (samples * signal.coherent_gain)
+ if samples % 2 == 0:
+ spectrum[1:-1] *= 2.0
+ else:
+ spectrum[1:] *= 2.0
+ frequencies = np.fft.rfftfreq(samples, d=sample_interval)
+ if not np.all(np.isfinite(spectrum)):
+ raise DataError("analysis FFT produced non-finite values")
+ return FrequencySignal(
+ frequency_hz=frequencies,
+ spectrum_v=spectrum,
+ samples=samples,
+ sample_interval_s=sample_interval,
+ coherent_gain=signal.coherent_gain,
+ window_name=signal.window_name,
+ )
+
+
+def measure_time(signal: TimeSignal, metrics: Iterable[str]) -> dict[str, float]:
+ selected = _selected_metrics(metrics, ANALYSIS_TIME_METRICS, "time")
+ voltage = signal.voltage_v
+ minimum = float(np.min(voltage))
+ maximum = float(np.max(voltage))
+ scale = float(np.max(np.abs(voltage)))
+ rms = 0.0 if scale == 0 else float(scale * np.sqrt(np.mean((voltage / scale) ** 2)))
+ values = {
+ "voltage_min_v": minimum,
+ "voltage_max_v": maximum,
+ "voltage_mean_v": float(np.mean(voltage)),
+ "voltage_rms_v": rms,
+ "voltage_vpp_v": maximum - minimum,
+ }
+ return {metric: _finite_metric(values[metric], metric) for metric in selected}
+
+
+def measure_frequency(
+ signal: FrequencySignal, metrics: Iterable[str]
+) -> tuple[dict[str, float | None], list[str]]:
+ selected = _selected_metrics(metrics, ANALYSIS_FREQUENCY_METRICS, "frequency")
+ amplitudes = signal.amplitude_v
+ non_dc = amplitudes[1:]
+ peak_index = int(np.argmax(non_dc) + 1)
+ peak_amplitude = float(amplitudes[peak_index])
+ significant = peak_amplitude > SIGNIFICANT_PEAK_V
+ warnings: list[str] = []
+
+ values: dict[str, float | None] = {}
+ if significant:
+ peak_frequency = float(signal.frequency_hz[peak_index])
+ values["peak_frequency_hz"] = peak_frequency
+ values["peak_amplitude_v"] = peak_amplitude
+ else:
+ peak_frequency = None
+ values["peak_frequency_hz"] = None
+ values["peak_amplitude_v"] = None
+ warnings.append("no_significant_non_dc_peak")
+
+ noise_bins = np.delete(non_dc, peak_index - 1) if significant else non_dc
+ if noise_bins.size:
+ values["noise_floor_v"] = float(np.median(noise_bins))
+ else:
+ values["noise_floor_v"] = None
+ warnings.append("noise_floor_unavailable")
+
+ requested_orders = {
+ order
+ for order in range(2, 6)
+ if any(metric.startswith(f"harmonic_{order}_") for metric in selected)
+ }
+ if "thd_ratio" in selected:
+ requested_orders.update(range(2, 6))
+
+ harmonic_amplitudes: list[float] = []
+ for order in sorted(requested_orders):
+ frequency_key = f"harmonic_{order}_frequency_hz"
+ amplitude_key = f"harmonic_{order}_amplitude_v"
+ if peak_frequency is None:
+ values[frequency_key] = None
+ values[amplitude_key] = None
+ continue
+ target = peak_frequency * order
+ if target > float(signal.frequency_hz[-1]):
+ values[frequency_key] = None
+ values[amplitude_key] = None
+ warnings.append(f"harmonic_{order}_out_of_band")
+ continue
+ index = int(np.argmin(np.abs(signal.frequency_hz - target)))
+ harmonic_amplitude = float(amplitudes[index])
+ values[frequency_key] = float(signal.frequency_hz[index])
+ values[amplitude_key] = harmonic_amplitude
+ harmonic_amplitudes.append(harmonic_amplitude)
+
+ if "thd_ratio" in selected:
+ values["thd_ratio"] = (
+ None
+ if not significant
+ else float(np.sqrt(np.sum(np.square(harmonic_amplitudes))) / peak_amplitude)
+ )
+
+ return {
+ metric: None if values[metric] is None else _finite_metric(values[metric], metric)
+ for metric in selected
+ }, warnings
+
+
+def _replace_voltage(signal: TimeSignal, voltage: np.ndarray) -> TimeSignal:
+ if not np.all(np.isfinite(voltage)):
+ raise DataError("analysis operation produced non-finite values")
+ return TimeSignal(
+ time_s=signal.time_s.copy(),
+ voltage_v=np.asarray(voltage, dtype=np.float64),
+ coherent_gain=signal.coherent_gain,
+ window_name=signal.window_name,
+ )
+
+
+def _selected_metrics(
+ metrics: Iterable[str], allowed: frozenset[str], domain: str
+) -> list[str]:
+ selected = list(metrics)
+ unsupported = [metric for metric in selected if metric not in allowed]
+ if unsupported:
+ raise DataError(f"unsupported {domain}-domain metric: {unsupported[0]}")
+ if len(set(selected)) != len(selected):
+ raise DataError(f"duplicate {domain}-domain metric")
+ return selected
+
+
+def _finite_metric(value: float, name: str) -> float:
+ result = float(value)
+ if not np.isfinite(result):
+ raise DataError(f"analysis metric {name} is not finite")
+ return result
diff --git a/src/wavebench/mcp_http.py b/src/wavebench/mcp_http.py
index 5f7a1bd0..4f3a344a 100644
--- a/src/wavebench/mcp_http.py
+++ b/src/wavebench/mcp_http.py
@@ -167,7 +167,12 @@ def _run_check_tool(arguments: dict[str, Any], config_path: Path) -> dict[str, A
"name": plan.name,
"label": plan.label,
"steps": [
- {"index": step.index, "kind": step.kind, "fields": step.fields}
+ {
+ "index": step.index,
+ "kind": step.kind,
+ "fields": step.fields,
+ **({"id": step.id} if step.id is not None else {}),
+ }
for step in plan.steps
],
"safety": {
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 3593600a..2fcfbb86 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -233,6 +233,7 @@ def render_run_report_html(
plotly_url=plotly_url if not compact else None,
)
artifact_links_block = "" if compact else _artifact_links_block(artifact_links)
+ signal_processing_block = "" if compact else _signal_processing_block(run, report_output_dir)
signals_block = "" if compact else _signals_block(signals)
waveform_previews_block = "" if compact else _waveform_previews_block(waveform_previews)
evidence_summary_block = "" if compact else _evidence_summary_block(evidence)
@@ -394,6 +395,7 @@ def render_run_report_html(
{dmm_block}
{sweep_block}
{frequency_response_block}
+ {signal_processing_block}
{acceptance_block}
{expectations_block}
{signals_block}
@@ -473,7 +475,7 @@ def _build_report_manifest(
warnings.append(
f"step {reference.step_index}: capture package missing: {reference.package}"
)
- return {
+ manifest = {
"schema": "wavebench.report_manifest.v1",
"report": artifact_url(report_path, output_dir),
"run_json": artifact_url(run.run_json_path, output_dir),
@@ -536,6 +538,10 @@ def _build_report_manifest(
),
"warnings": warnings,
}
+ analysis_pipelines = _analysis_manifest_entries(run, output_dir)
+ if analysis_pipelines:
+ manifest["analysis_pipelines"] = analysis_pipelines
+ return manifest
def _summary_block(summary: ReportSummary, *, compact: bool = False) -> str:
@@ -1909,6 +1915,16 @@ def _build_report_summary(
warning_messages.update(str(item) for item in warnings if item)
elif warnings:
warning_messages.add(str(warnings))
+ analysis_pipeline = (
+ artifact.get("analysis_pipeline", {})
+ if isinstance(artifact.get("analysis_pipeline"), dict)
+ else {}
+ )
+ analysis_warnings = analysis_pipeline.get("warnings", [])
+ if isinstance(analysis_warnings, list):
+ warning_messages.update(str(item) for item in analysis_warnings if item)
+ elif analysis_warnings:
+ warning_messages.add(str(analysis_warnings))
expect = artifact.get("expect", {}) if isinstance(artifact.get("expect"), dict) else {}
expect_fft = artifact.get("expect_fft", {}) if isinstance(artifact.get("expect_fft"), dict) else {}
if expect.get("status") == "failed":
@@ -1955,6 +1971,138 @@ def _build_evidence_summary(
)
+def _signal_processing_block(run: RunPackage, output_dir: Path) -> str:
+ rows: list[str] = []
+ for step in run.steps:
+ if step.get("kind") != "analysis.pipeline":
+ continue
+ artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
+ pipeline = (
+ artifact.get("analysis_pipeline", {})
+ if isinstance(artifact.get("analysis_pipeline"), dict)
+ else {}
+ )
+ operations = pipeline.get("operations", [])
+ operation_names = (
+ [
+ str(operation.get("op"))
+ for operation in operations
+ if isinstance(operation, dict) and operation.get("op")
+ ]
+ if isinstance(operations, list)
+ else []
+ )
+ metrics = artifact.get("metrics", {}) if isinstance(artifact.get("metrics"), dict) else {}
+ metrics_text = " | ".join(
+ f"{name}={'null' if value is None else _format_plain(value)}"
+ for name, value in metrics.items()
+ )
+ warnings = pipeline.get("warnings", [])
+ warnings_text = (
+ " | ".join(str(item) for item in warnings)
+ if isinstance(warnings, list)
+ else str(warnings or "")
+ )
+ export_links: list[str] = []
+ exports = pipeline.get("exports", [])
+ if isinstance(exports, list):
+ for export in exports:
+ if not isinstance(export, dict) or not isinstance(export.get("path"), str):
+ continue
+ path = _resolve_run_artifact_path(run.path, export["path"])
+ href = escape(artifact_url(path, output_dir), quote=True)
+ label = escape(f"{export.get('name', '')}.{export.get('format', '')}".strip("."))
+ export_links.append(f'{label} ')
+ manifest_link = _analysis_file_link(
+ run, output_dir, pipeline.get("manifest"), "manifest.json"
+ )
+ metrics_link = _analysis_file_link(
+ run, output_dir, pipeline.get("metrics"), "metrics.json"
+ )
+ links = " ".join(item for item in [manifest_link, metrics_link, *export_links] if item)
+ status = str(pipeline.get("status") or step.get("status") or "")
+ rows.append(
+ "
"
+ f"{escape(str(step.get('index', '')))} · {escape(str(step.get('id', '-')))} "
+ f'{escape(status)} '
+ f"{escape(str(pipeline.get('source_step', '-')))} "
+ f"{escape(' → '.join(operation_names))} "
+ f"{escape(metrics_text)} "
+ f"{escape(warnings_text)} "
+ f"{escape(str(pipeline.get('failed_stage', '')))} "
+ f"{links} "
+ " "
+ )
+ if not rows:
+ return ""
+ return f"""信号处理 / Signal processing
+
+步骤 / Step 状态 / Status 来源 / Source 算子 / Operations 指标 / Metrics 警告 / Warnings 失败阶段 / Failed stage 产物 / Artifacts
+
+{chr(10).join(rows)}
+
+
+"""
+
+
+def _analysis_file_link(
+ run: RunPackage,
+ output_dir: Path,
+ raw_path: Any,
+ label: str,
+) -> str:
+ if not isinstance(raw_path, str) or not raw_path:
+ return ""
+ path = _resolve_run_artifact_path(run.path, raw_path)
+ href = escape(artifact_url(path, output_dir), quote=True)
+ return f'{escape(label)} '
+
+
+def _analysis_manifest_entries(run: RunPackage, output_dir: Path) -> list[dict[str, Any]]:
+ entries: list[dict[str, Any]] = []
+ for step in run.steps:
+ if step.get("kind") != "analysis.pipeline":
+ continue
+ artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
+ pipeline = (
+ artifact.get("analysis_pipeline", {})
+ if isinstance(artifact.get("analysis_pipeline"), dict)
+ else {}
+ )
+ manifest_path = _optional_run_artifact_path(run.path, pipeline.get("manifest"))
+ metrics_path = _optional_run_artifact_path(run.path, pipeline.get("metrics"))
+ export_entries: list[dict[str, Any]] = []
+ exports = pipeline.get("exports", [])
+ if isinstance(exports, list):
+ for raw_export in exports:
+ if not isinstance(raw_export, dict):
+ continue
+ path = _optional_run_artifact_path(run.path, raw_export.get("path"))
+ if path is None:
+ continue
+ export_entries.append({
+ "name": raw_export.get("name"),
+ "format": raw_export.get("format"),
+ "path": artifact_url(path, output_dir),
+ "exists": path.is_file(),
+ "sha256": raw_export.get("sha256"),
+ })
+ entries.append({
+ "step_index": step.get("index"),
+ "step_id": step.get("id"),
+ "status": pipeline.get("status", step.get("status")),
+ "source_step": pipeline.get("source_step"),
+ "manifest": artifact_url(manifest_path, output_dir) if manifest_path is not None else None,
+ "manifest_exists": manifest_path.is_file() if manifest_path is not None else False,
+ "metrics": artifact_url(metrics_path, output_dir) if metrics_path is not None else None,
+ "metrics_exists": metrics_path.is_file() if metrics_path is not None else False,
+ "warnings": pipeline.get("warnings", []),
+ "failed_stage": pipeline.get("failed_stage"),
+ "exports": export_entries,
+ })
+ return entries
+
+
def _collect_artifact_links(
run: RunPackage, output_dir: Path, screenshots: list[ReportScreenshot]
) -> list[ReportArtifactLink]:
@@ -2085,6 +2233,50 @@ def _collect_artifact_links(
status=_availability_text(True),
)
)
+ for step in run.steps:
+ if step.get("kind") != "analysis.pipeline":
+ continue
+ artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
+ pipeline = (
+ artifact.get("analysis_pipeline", {})
+ if isinstance(artifact.get("analysis_pipeline"), dict)
+ else {}
+ )
+ step_index = str(step.get("index", ""))
+ for field, kind, label in (
+ ("manifest", "处理清单 / Processing manifest", "manifest.json"),
+ ("metrics", "分析指标 / Analysis metrics", "metrics.json"),
+ ):
+ path = _optional_run_artifact_path(run.path, pipeline.get(field))
+ if path is None:
+ continue
+ links.append(
+ ReportArtifactLink(
+ step_index=step_index,
+ kind=kind,
+ label=label,
+ href=artifact_url(path, output_dir),
+ status=_availability_text(path.is_file()),
+ )
+ )
+ exports = pipeline.get("exports", [])
+ if not isinstance(exports, list):
+ continue
+ for export in exports:
+ if not isinstance(export, dict):
+ continue
+ path = _optional_run_artifact_path(run.path, export.get("path"))
+ if path is None:
+ continue
+ links.append(
+ ReportArtifactLink(
+ step_index=step_index,
+ kind="处理导出 / Processing export",
+ label=path.name,
+ href=artifact_url(path, output_dir),
+ status=_availability_text(path.is_file()),
+ )
+ )
return links
@@ -2478,6 +2670,18 @@ def _resolve_artifact_path(run_path: Path, artifact_path: str) -> Path:
return root / path
+def _resolve_run_artifact_path(run_path: Path, artifact_path: str) -> Path:
+ normalized = artifact_path.replace("\\", "/")
+ path = Path(normalized)
+ return path if path.is_absolute() else run_path / path
+
+
+def _optional_run_artifact_path(run_path: Path, raw_path: Any) -> Path | None:
+ if not isinstance(raw_path, str) or not raw_path:
+ return None
+ return _resolve_run_artifact_path(run_path, raw_path)
+
+
def _project_root_from_run_path(run_path: Path) -> Path:
parts = run_path.parts
if len(parts) >= 3 and parts[-3:-1] == ("data", "runs"):
diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py
index 4bb07cfa..3aeeaf84 100644
--- a/src/wavebench/services/execution_intent.py
+++ b/src/wavebench/services/execution_intent.py
@@ -99,6 +99,8 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig) -> ExecutionI
"safety_gate": _safe_parameters(fields.get("safety_gate", {})),
},
}
+ if step.id is not None:
+ entry["step_id"] = step.id
operations.append(entry)
safety = _safe_parameters(asdict(plan.safety))
diff --git a/src/wavebench/services/frequency_response_evidence.py b/src/wavebench/services/frequency_response_evidence.py
index 5d69f1b4..6be7520e 100644
--- a/src/wavebench/services/frequency_response_evidence.py
+++ b/src/wavebench/services/frequency_response_evidence.py
@@ -40,7 +40,10 @@ def plan_digest(plan: Any) -> str:
# the requested measurement grid or signal semantics. Excluding it lets
# a resumed plan reuse points produced by its original plan.
fields.pop("resume_from", None)
- steps.append({"index": step.index, "kind": step.kind, "fields": fields})
+ payload = {"index": step.index, "kind": step.kind, "fields": fields}
+ if getattr(step, "id", None) is not None:
+ payload["id"] = step.id
+ steps.append(payload)
return digest(
{
"name": getattr(plan, "name", ""),
diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py
index 0e503c25..ee64420d 100644
--- a/src/wavebench/services/operation_specs.py
+++ b/src/wavebench/services/operation_specs.py
@@ -372,6 +372,7 @@ def _spec(
_spec("run.check", None, effect="offline", lease_mode="none"),
_spec("run.intent", None, effect="offline", lease_mode="none"),
_spec("run.sleep", None, effect="offline", lease_mode="none"),
+ _spec("analysis.pipeline", None, effect="offline", lease_mode="none"),
_spec("lock.status", None, effect="offline", lease_mode="none"),
_spec("run.report", None, effect="offline", lease_mode="none"),
_spec("run.compare", None, effect="offline", lease_mode="none"),
diff --git a/src/wavebench/services/run_analysis.py b/src/wavebench/services/run_analysis.py
index d68f9329..0666c861 100644
--- a/src/wavebench/services/run_analysis.py
+++ b/src/wavebench/services/run_analysis.py
@@ -19,6 +19,9 @@ def capture_fft_summary(capture: Any) -> dict[str, Any]:
def step_status(artifact: dict[str, Any]) -> str:
+ analysis_pipeline = artifact.get("analysis_pipeline")
+ if isinstance(analysis_pipeline, dict) and analysis_pipeline.get("status") == "failed":
+ return "failed"
response = artifact.get("frequency_response", {})
if isinstance(response, dict) and response.get("status") == "failed":
return "failed"
diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py
index acc77105..868ee5d5 100644
--- a/src/wavebench/services/run_artifacts.py
+++ b/src/wavebench/services/run_artifacts.py
@@ -20,15 +20,19 @@ class RunStepRecord:
status: str
fields: dict[str, Any]
artifact: dict[str, Any]
+ id: str | None = None
def as_dict(self) -> dict[str, Any]:
- return {
+ result = {
"index": self.index,
"kind": self.kind,
"status": self.status,
"fields": self.fields,
"artifact": self.artifact,
}
+ if self.id is not None:
+ result["id"] = self.id
+ return result
def write_step_record(steps_dir: Path, record: RunStepRecord) -> None:
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
new file mode 100644
index 00000000..49812cc7
--- /dev/null
+++ b/src/wavebench/services/run_pipeline.py
@@ -0,0 +1,414 @@
+from __future__ import annotations
+
+import csv
+from hashlib import sha256
+import json
+import os
+from pathlib import Path
+import tempfile
+from typing import Any, Iterator
+
+import numpy as np
+
+from wavebench.data.signal_pipeline import (
+ FrequencySignal,
+ TimeSignal,
+ detrend_linear,
+ fft_signal,
+ measure_frequency,
+ measure_time,
+ remove_dc,
+ validate_waveform,
+ window_signal,
+)
+from wavebench.errors import DataError, error_envelope
+from wavebench.services.run_analysis import evaluate_expect
+from wavebench.services.run_artifacts import RunStepRecord
+from wavebench.services.run_plan import RunStep
+
+
+ANALYSIS_PIPELINE_SCHEMA = "wavebench.analysis_pipeline.v1"
+ANALYSIS_METRICS_SCHEMA = "wavebench.analysis_metrics.v1"
+
+
+def execute_analysis_pipeline(
+ *,
+ run_dir: Path,
+ step: RunStep,
+ source_step: RunStep,
+ source_record: RunStepRecord | None,
+) -> dict[str, Any]:
+ processing_dir = run_dir / "processing" / (
+ f"{step.index:02d}_{step.id or 'analysis_pipeline'}"
+ )
+ processing_dir.mkdir(parents=True, exist_ok=False)
+ metrics_path = processing_dir / "metrics.json"
+ manifest_path = processing_dir / "manifest.json"
+
+ operations = step.fields["operations"]
+ metrics: dict[str, float | None] = {
+ metric: None
+ for operation in operations
+ if operation["op"] == "measure"
+ for metric in operation["metrics"]
+ }
+ warnings: list[str] = []
+ exports: list[dict[str, Any]] = []
+ stages: list[dict[str, Any]] = []
+ sampling: dict[str, Any] | None = None
+ window: dict[str, Any] | None = None
+ source: dict[str, Any] = {
+ "step": source_step.id,
+ "step_index": source_step.index,
+ "status": source_record.status if source_record is not None else "unavailable",
+ }
+ failure: dict[str, Any] | None = None
+ failed_stage: str | None = None
+
+ try:
+ _, source_details, waveform = _load_source_waveform(
+ run_dir=run_dir,
+ source_step=source_step,
+ source_record=source_record,
+ )
+ source.update(source_details)
+ signal: TimeSignal | FrequencySignal = validate_waveform(waveform)
+ sampling = _time_sampling(signal)
+ stages.append({"stage": "source", "status": "ok", "domain": "time"})
+
+ for operation_index, operation in enumerate(operations):
+ op = operation["op"]
+ stage = {
+ "index": operation_index,
+ "op": op,
+ "status": "running",
+ "input_domain": _domain(signal),
+ }
+ stages.append(stage)
+ try:
+ if op == "remove_dc":
+ assert isinstance(signal, TimeSignal)
+ signal = remove_dc(signal)
+ elif op == "detrend":
+ assert isinstance(signal, TimeSignal)
+ signal = detrend_linear(signal)
+ elif op == "window":
+ assert isinstance(signal, TimeSignal)
+ signal = window_signal(signal, operation["name"])
+ window = {
+ "name": signal.window_name,
+ "coherent_gain": signal.coherent_gain,
+ }
+ elif op == "fft":
+ assert isinstance(signal, TimeSignal)
+ signal = fft_signal(signal)
+ sampling.update({
+ "sample_interval_s": signal.sample_interval_s,
+ "sample_rate_hz": signal.sample_rate_hz,
+ "resolution_hz": signal.resolution_hz,
+ })
+ elif op == "measure":
+ if isinstance(signal, TimeSignal):
+ measured = measure_time(signal, operation["metrics"])
+ operation_warnings: list[str] = []
+ else:
+ measured, operation_warnings = measure_frequency(
+ signal, operation["metrics"]
+ )
+ metrics.update(measured)
+ _extend_unique(warnings, operation_warnings)
+ if operation_warnings:
+ stage["warnings"] = operation_warnings
+ elif op == "export":
+ exported: list[dict[str, Any]] = []
+ for item in _export_signal(
+ run_dir=run_dir,
+ processing_dir=processing_dir,
+ signal=signal,
+ name=operation["name"],
+ formats=operation["formats"],
+ ):
+ exported.append(item)
+ exports.append(item)
+ stage["exports"] = [item["path"] for item in exported]
+ else: # pragma: no cover - RunPlan validation owns this invariant
+ raise DataError(f"unsupported analysis operation: {op}")
+ except Exception:
+ stage["status"] = "failed"
+ raise
+ stage["status"] = "ok"
+ stage["output_domain"] = _domain(signal)
+ except Exception as exc: # noqa: BLE001 - analysis failures are step artifacts
+ failed_stage = _failed_stage(stages)
+ failure = error_envelope(
+ exc,
+ operation=(
+ f"analysis.pipeline.{failed_stage}"
+ if failed_stage is not None
+ else "analysis.pipeline"
+ ),
+ )
+ if not stages or stages[0].get("stage") != "source":
+ stages.insert(0, {"stage": "source", "status": "failed"})
+ for operation_index in range(len(stages) - 1, len(operations)):
+ operation = operations[operation_index]
+ stages.append({
+ "index": operation_index,
+ "op": operation["op"],
+ "status": "skipped",
+ })
+
+ status = "failed" if failure is not None else "ok"
+ partial = failure is not None and (
+ bool(exports)
+ or any(stage.get("status") == "ok" and "index" in stage for stage in stages)
+ )
+ metrics_document = {
+ "schema": ANALYSIS_METRICS_SCHEMA,
+ "metrics": metrics,
+ }
+ manifest: dict[str, Any] = {
+ "schema": ANALYSIS_PIPELINE_SCHEMA,
+ "status": status,
+ "partial": partial,
+ "source": source,
+ "operations": operations,
+ "stages": stages,
+ "sampling": sampling,
+ "window": window,
+ "metrics": _derived_relative(metrics_path, run_dir),
+ "warnings": warnings,
+ "exports": exports,
+ "definitions": {
+ "amplitude": "single_sided_peak_v",
+ "noise_floor_v": "median_non_dc_non_peak_bin_peak_amplitude_v",
+ "thd_ratio": "rss_in_band_harmonic_2_through_5_over_fundamental_peak",
+ },
+ }
+ if failed_stage is not None:
+ manifest["failed_stage"] = failed_stage
+ if failure is not None:
+ manifest["error"] = failure
+
+ _atomic_write_json(metrics_path, metrics_document)
+ _atomic_write_json(manifest_path, manifest)
+
+ pipeline_artifact: dict[str, Any] = {
+ "schema": ANALYSIS_PIPELINE_SCHEMA,
+ "status": status,
+ "manifest": _derived_relative(manifest_path, run_dir),
+ "metrics": _derived_relative(metrics_path, run_dir),
+ "source_step": source_step.id,
+ "source_status": source["status"],
+ "operations": operations,
+ "warnings": warnings,
+ "exports": exports,
+ }
+ if failed_stage is not None:
+ pipeline_artifact["failed_stage"] = failed_stage
+ if failure is not None:
+ pipeline_artifact["error"] = failure
+
+ artifact: dict[str, Any] = {
+ "analysis_pipeline": pipeline_artifact,
+ "metrics": metrics,
+ }
+ if "expect" in step.fields:
+ artifact["expect"] = evaluate_expect(metrics, step.fields["expect"])
+ return artifact
+
+
+def _load_source_waveform(
+ *,
+ run_dir: Path,
+ source_step: RunStep,
+ source_record: RunStepRecord | None,
+) -> tuple[Path, dict[str, Any], np.ndarray]:
+ if source_record is None:
+ raise DataError(f"source capture step {source_step.id!r} was not executed")
+ package_text = source_record.artifact.get("package")
+ metadata_text = source_record.artifact.get("metadata")
+ if not isinstance(package_text, str) or not package_text:
+ raise DataError(f"source capture step {source_step.id!r} has no package artifact")
+ if not isinstance(metadata_text, str) or not metadata_text:
+ raise DataError(f"source capture step {source_step.id!r} has no metadata artifact")
+
+ package = Path(package_text).resolve()
+ if not package.is_dir():
+ raise DataError(f"source capture package is unavailable: {package}")
+ metadata_path = _resolve_package_member(package, metadata_text, label="metadata")
+ try:
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
+ raise DataError(f"source capture metadata is unreadable: {metadata_path}: {exc}") from exc
+ if not isinstance(metadata, dict):
+ raise DataError("source capture metadata must be a JSON object")
+ files = metadata.get("files")
+ if not isinstance(files, dict):
+ raise DataError("source capture metadata has no files table")
+ npy_text = files.get("npy")
+ if not isinstance(npy_text, str) or not npy_text:
+ raise DataError("source capture metadata has no NPY artifact")
+ waveform_path = _resolve_package_member(package, npy_text, label="NPY")
+ try:
+ waveform = np.load(waveform_path, allow_pickle=False)
+ except Exception as exc: # noqa: BLE001 - NumPy load errors become structured data errors
+ raise DataError(f"source capture NPY is unreadable: {waveform_path}: {exc}") from exc
+
+ return waveform_path, {
+ "package": _run_relative(package, run_dir),
+ "metadata": _run_relative(metadata_path, run_dir),
+ "npy": _run_relative(waveform_path, run_dir),
+ "npy_sha256": _sha256_file(waveform_path),
+ }, waveform
+
+
+def _resolve_package_member(package: Path, raw: str, *, label: str) -> Path:
+ candidate = Path(raw)
+ if ".." in candidate.parts:
+ raise DataError(f"source capture {label} path must not contain '..'")
+ candidates = [candidate] if candidate.is_absolute() else [Path.cwd() / candidate, package / candidate]
+ package = package.resolve()
+ for unresolved in candidates:
+ resolved = unresolved.resolve()
+ try:
+ resolved.relative_to(package)
+ except ValueError:
+ continue
+ if resolved.is_file():
+ return resolved
+ raise DataError(f"source capture {label} path escapes its capture package or is unavailable")
+
+
+def _export_signal(
+ *,
+ run_dir: Path,
+ processing_dir: Path,
+ signal: TimeSignal | FrequencySignal,
+ name: str,
+ formats: list[str],
+) -> Iterator[dict[str, Any]]:
+ exports_dir = processing_dir / "exports"
+ exports_dir.mkdir(parents=True, exist_ok=True)
+ if isinstance(signal, TimeSignal):
+ columns = ["time_s", "voltage_v"]
+ else:
+ columns = ["frequency_hz", "real_v", "imaginary_v", "amplitude_v"]
+ data = signal.as_array()
+ for file_format in formats:
+ target = exports_dir / f"{name}.{file_format}"
+ if target.exists(): # pragma: no cover - parser prevents duplicate export names
+ raise DataError(f"analysis export already exists: {target.name}")
+ if file_format == "npy":
+ _atomic_write_npy(target, data)
+ elif file_format == "csv":
+ _atomic_write_csv(target, columns, data)
+ else: # pragma: no cover - RunPlan validation owns this invariant
+ raise DataError(f"unsupported analysis export format: {file_format}")
+ yield {
+ "name": name,
+ "format": file_format,
+ "path": _derived_relative(target, run_dir),
+ "sha256": _sha256_file(target),
+ "columns": columns,
+ }
+
+
+def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
+ encoded = (
+ json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
+ ).encode("utf-8")
+ _atomic_write_bytes(path, encoded)
+
+
+def _atomic_write_npy(path: Path, data: np.ndarray) -> None:
+ temporary = _temporary_path(path)
+ try:
+ with temporary.open("wb") as file:
+ np.save(file, data, allow_pickle=False)
+ file.flush()
+ os.fsync(file.fileno())
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def _atomic_write_csv(path: Path, columns: list[str], data: np.ndarray) -> None:
+ temporary = _temporary_path(path)
+ try:
+ with temporary.open("w", newline="", encoding="utf-8") as file:
+ writer = csv.writer(file)
+ writer.writerow(columns)
+ writer.writerows(data.tolist())
+ file.flush()
+ os.fsync(file.fileno())
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def _atomic_write_bytes(path: Path, data: bytes) -> None:
+ temporary = _temporary_path(path)
+ try:
+ with temporary.open("xb") as file:
+ file.write(data)
+ file.flush()
+ os.fsync(file.fileno())
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def _temporary_path(path: Path) -> Path:
+ descriptor, raw_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
+ os.close(descriptor)
+ temporary = Path(raw_path)
+ temporary.unlink()
+ return temporary
+
+
+def _sha256_file(path: Path) -> str:
+ digest = sha256()
+ with path.open("rb") as file:
+ while chunk := file.read(1024 * 1024):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _time_sampling(signal: TimeSignal) -> dict[str, Any]:
+ return {
+ "samples": int(signal.time_s.size),
+ "time_start_s": float(signal.time_s[0]),
+ "time_stop_s": float(signal.time_s[-1]),
+ "strictly_increasing": True,
+ }
+
+
+def _domain(signal: TimeSignal | FrequencySignal) -> str:
+ return "time" if isinstance(signal, TimeSignal) else "frequency"
+
+
+def _failed_stage(stages: list[dict[str, Any]]) -> str | None:
+ for stage in reversed(stages):
+ if stage.get("status") == "failed":
+ if "index" in stage:
+ return f"operations[{stage['index']}]"
+ return str(stage.get("stage", "source"))
+ return "source"
+
+
+def _run_relative(path: Path, run_dir: Path) -> str:
+ return Path(os.path.relpath(path.resolve(), run_dir.resolve())).as_posix()
+
+
+def _derived_relative(path: Path, run_dir: Path) -> str:
+ try:
+ return path.resolve().relative_to(run_dir.resolve()).as_posix()
+ except ValueError as exc: # pragma: no cover - paths are constructed below run_dir
+ raise DataError("analysis derived path escapes the run directory") from exc
+
+
+def _extend_unique(target: list[str], values: list[str]) -> None:
+ for value in values:
+ if value not in target:
+ target.append(value)
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 0bf74882..0ecbefe9 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -9,6 +9,10 @@
import tomllib
from wavebench.config import normalize_waveform_points
+from wavebench.data.signal_pipeline import (
+ ANALYSIS_FREQUENCY_METRICS,
+ ANALYSIS_TIME_METRICS,
+)
from wavebench.errors import ConfigError
from wavebench.services.frequency_response import FIT_METHODS
from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
@@ -17,6 +21,7 @@
ALLOWED_STEP_KINDS = {
+ "analysis.pipeline",
"scope.auto",
"scope.capture",
"sweep.frequency_response",
@@ -75,8 +80,11 @@
_SOURCE_STORAGE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$")
_SOURCE_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
+_STEP_ID = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
+_ANALYSIS_EXPORT_NAME = _STEP_ID
_REQUIRED_FIELDS = {
+ "analysis.pipeline": ("source", "operations"),
"power.set": ("voltage_v", "current_limit_a"),
"power.output": ("state",),
"source.set_freq": ("frequency_hz",),
@@ -172,6 +180,7 @@
}
_OPTIONAL_FIELDS = {
+ "analysis.pipeline": {"expect", "on_failure"},
"scope.auto": {"on_failure"},
"scope.capture": {
"channel",
@@ -314,11 +323,14 @@
# Failure handling is a common contract for every executable step. Keeping the
# fields in the schema table makes ``run schema`` and unknown-key diagnostics stay
# in sync as new step kinds are added.
-for _step_fields in _OPTIONAL_FIELDS.values():
- _step_fields.update({"on_failure", "safety_gate"})
+for _step_kind, _step_fields in _OPTIONAL_FIELDS.items():
+ _step_fields.add("on_failure")
+ if _step_kind != "analysis.pipeline":
+ _step_fields.add("safety_gate")
_STEP_NOTES = {
+ "analysis.pipeline": "Process one earlier scope.capture NPY after all hardware sessions close. Uses a validated linear NumPy operator list and never opens an instrument.",
"scope.auto": "Explicit RTM2032 AUToscale. It changes front-panel settings and is never inserted implicitly.",
"scope.capture": "Trigger one acquisition, write a capture package, and optionally evaluate quality/expect checks. Use target_vpp or vertical_scale_v_per_div to fit the waveform vertically before capture.",
"sweep.frequency_response": "Sweep a source through discrete frequencies, capture reference and response channels in one acquisition per point, and write a Bode response CSV.",
@@ -416,6 +428,7 @@ def format_run_plan_schema() -> str:
" [safety] optional: scope_guard_channel, require_scope_coupling_not, allow_50ohm, safety_gate, off_source_channels, off_power_channels",
" [restore] optional: source_state, source_channel, source_channels",
" [[steps]] required: kind",
+ " [[steps]] optional structural field: id matching ^[a-z][a-z0-9_-]{0,63}$",
"",
"Supported step kinds:",
]
@@ -435,6 +448,10 @@ def format_run_plan_schema() -> str:
"scope.capture [steps.expect_fft] metrics:",
" FFT checks analyze the saved NPY waveform.",
" Common metrics: peak_frequency_hz, peak_amplitude_v, thd_ratio, harmonic_2_amplitude_v.",
+ "",
+ "analysis.pipeline metrics:",
+ " Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.",
+ " Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.",
])
return "\n".join(lines)
@@ -464,6 +481,7 @@ class RunStep:
index: int
kind: str
fields: dict[str, Any]
+ id: str | None = None
@dataclass(frozen=True)
@@ -503,6 +521,7 @@ def load_run_plan(path: str | Path) -> RunPlan:
raise ConfigError("run plan requires at least one [[steps]] entry")
steps = [_parse_step(index, item) for index, item in enumerate(steps_raw)]
_validate_frequency_response_steps(steps)
+ _validate_analysis_steps(steps)
return RunPlan(path=plan_path, name=name, label=label, safety=safety, restore=restore, steps=steps)
@@ -639,7 +658,7 @@ def _parse_step(index: int, raw: Any) -> RunStep:
)
schema = STEP_SCHEMAS[kind]
- allowed_fields = {"kind", *schema.required, *schema.optional}
+ allowed_fields = {"id", "kind", *schema.required, *schema.optional}
_reject_unknown_keys(table, allowed_fields, f"steps[{index}]")
for field in schema.required:
if field not in table:
@@ -651,9 +670,10 @@ def _parse_step(index: int, raw: Any) -> RunStep:
"Run `python -m wavebench run schema` for examples."
)
- fields = {key: value for key, value in table.items() if key != "kind"}
+ step_id = _normalize_step_id(table["id"], f"steps[{index}].id") if "id" in table else None
+ fields = {key: value for key, value in table.items() if key not in {"id", "kind"}}
_normalize_step_fields(index, kind, fields)
- return RunStep(index=index, kind=kind, fields=fields)
+ return RunStep(index=index, kind=kind, fields=fields, id=step_id)
def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> None:
@@ -669,7 +689,9 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
)
if "channel" in fields:
fields["channel"] = _positive_int(fields["channel"], f"{prefix}.channel")
- if kind == "scope.capture":
+ if kind == "analysis.pipeline":
+ _normalize_analysis_pipeline_fields(prefix, fields)
+ elif kind == "scope.capture":
if "label" in fields:
fields["label"] = _non_empty_str(fields["label"], f"{prefix}.label")
if "points" in fields:
@@ -1184,6 +1206,199 @@ def _validate_frequency_response_steps(steps: list[RunStep]) -> None:
labels.add(label)
+def _validate_analysis_steps(steps: list[RunStep]) -> None:
+ by_id: dict[str, RunStep] = {}
+ for step in steps:
+ if step.id is None:
+ continue
+ if step.id in by_id:
+ raise ConfigError(f"duplicate step id: {step.id!r}")
+ by_id[step.id] = step
+
+ for step in steps:
+ if step.kind != "analysis.pipeline":
+ continue
+ source_id = step.fields["source"]["step"]
+ source = by_id.get(source_id)
+ if source is None:
+ raise ConfigError(
+ f"steps[{step.index}].source references unknown step id {source_id!r}"
+ )
+ if source.index >= step.index:
+ raise ConfigError(
+ f"steps[{step.index}].source must reference an earlier step"
+ )
+ if source.kind != "scope.capture":
+ raise ConfigError(
+ f"steps[{step.index}].source must reference a scope.capture step"
+ )
+ if source.fields.get("save_npy") is not True:
+ raise ConfigError(
+ f"steps[{step.index}].source scope.capture must explicitly set save_npy = true"
+ )
+
+ analysis_started = False
+ for step in steps:
+ if step.kind == "analysis.pipeline":
+ analysis_started = True
+ elif analysis_started:
+ raise ConfigError("analysis.pipeline steps must form a contiguous suffix of the plan")
+
+
+def _normalize_step_id(value: Any, name: str) -> str:
+ if not isinstance(value, str) or _STEP_ID.fullmatch(value) is None:
+ raise ConfigError(f"{name} must match ^[a-z][a-z0-9_-]{{0,63}}$")
+ return value
+
+
+def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) -> None:
+ source = _table(fields["source"], f"{prefix}.source")
+ _reject_unknown_keys(source, {"step"}, f"{prefix}.source")
+ if "step" not in source:
+ raise ConfigError(f"{prefix}.source.step is required")
+ fields["source"] = {
+ "step": _normalize_step_id(source["step"], f"{prefix}.source.step")
+ }
+
+ raw_operations = fields["operations"]
+ if not isinstance(raw_operations, list) or not raw_operations:
+ raise ConfigError(f"{prefix}.operations must be a non-empty array")
+
+ normalized: list[dict[str, Any]] = []
+ transforms: set[str] = set()
+ measured: set[str] = set()
+ export_names: set[str] = set()
+ domain = "time"
+ has_result = False
+
+ allowed_fields = {
+ "remove_dc": {"op"},
+ "detrend": {"op", "method"},
+ "window": {"op", "name"},
+ "fft": {"op"},
+ "measure": {"op", "metrics"},
+ "export": {"op", "name", "formats"},
+ }
+ required_fields = {
+ "detrend": {"method"},
+ "window": {"name"},
+ "measure": {"metrics"},
+ "export": {"name", "formats"},
+ }
+
+ for operation_index, raw_operation in enumerate(raw_operations):
+ operation_prefix = f"{prefix}.operations[{operation_index}]"
+ if not isinstance(raw_operation, dict):
+ raise ConfigError(f"{operation_prefix} operation must be a TOML table")
+ raw_op = raw_operation.get("op")
+ if not isinstance(raw_op, str) or not raw_op.strip():
+ raise ConfigError(f"{operation_prefix}.op must be a non-empty string")
+ op = raw_op.strip().lower()
+ if op not in allowed_fields:
+ raise ConfigError(f"{operation_prefix} has unsupported op {op!r}")
+
+ unknown = sorted(set(raw_operation) - allowed_fields[op])
+ if unknown:
+ names = ", ".join(repr(name) for name in unknown)
+ raise ConfigError(f"{operation_prefix} {op} has unknown field {names}")
+ missing = sorted(required_fields.get(op, set()) - set(raw_operation))
+ if missing:
+ names = ", ".join(repr(name) for name in missing)
+ raise ConfigError(f"{operation_prefix} {op} missing required field {names}")
+
+ operation: dict[str, Any] = {"op": op}
+ if op in {"remove_dc", "detrend", "window", "fft"}:
+ if op in transforms:
+ raise ConfigError(f"{prefix} operation {op!r} may appear at most once")
+ if op in {"remove_dc", "detrend"} and transforms & {"remove_dc", "detrend"}:
+ raise ConfigError(f"{prefix} remove_dc and detrend are mutually exclusive")
+ if domain == "frequency":
+ raise ConfigError(f"{prefix} operation {op!r} must appear before fft")
+ if op in {"remove_dc", "detrend"} and "window" in transforms:
+ raise ConfigError(f"{prefix} operation {op!r} must appear before window")
+ transforms.add(op)
+
+ if op == "detrend":
+ method = raw_operation["method"]
+ if not isinstance(method, str) or method.lower() != "linear":
+ raise ConfigError(f"{operation_prefix}.method must be 'linear'")
+ operation["method"] = "linear"
+ elif op == "window":
+ name = raw_operation["name"]
+ if not isinstance(name, str) or name.lower() not in {"hann", "hamming", "blackman"}:
+ raise ConfigError(
+ f"{operation_prefix}.name must be one of hann, hamming, blackman"
+ )
+ operation["name"] = name.lower()
+ elif op == "fft":
+ domain = "frequency"
+ elif op == "measure":
+ raw_metrics = raw_operation["metrics"]
+ if not isinstance(raw_metrics, list) or not raw_metrics:
+ raise ConfigError(f"{operation_prefix}.metrics must be a non-empty array")
+ metrics: list[str] = []
+ permitted = ANALYSIS_TIME_METRICS if domain == "time" else ANALYSIS_FREQUENCY_METRICS
+ for raw_metric in raw_metrics:
+ if not isinstance(raw_metric, str) or not raw_metric:
+ raise ConfigError(f"{operation_prefix}.metrics entries must be non-empty strings")
+ metric = raw_metric
+ if metric not in permitted:
+ other_domain = (
+ metric in ANALYSIS_FREQUENCY_METRICS
+ if domain == "time"
+ else metric in ANALYSIS_TIME_METRICS
+ )
+ if other_domain:
+ raise ConfigError(
+ f"{operation_prefix} metric {metric!r} requires "
+ f"{'frequency' if domain == 'time' else 'time'}-domain data"
+ )
+ raise ConfigError(f"{operation_prefix} has unsupported metric {metric!r}")
+ if metric in measured:
+ raise ConfigError(f"{prefix} has duplicate metric {metric!r}")
+ measured.add(metric)
+ metrics.append(metric)
+ operation["metrics"] = metrics
+ has_result = True
+ elif op == "export":
+ name = raw_operation["name"]
+ if not isinstance(name, str) or _ANALYSIS_EXPORT_NAME.fullmatch(name) is None:
+ raise ConfigError(
+ f"{operation_prefix}.name must match ^[a-z][a-z0-9_-]{{0,63}}$"
+ )
+ if name in export_names:
+ raise ConfigError(f"{prefix} has duplicate export name {name!r}")
+ export_names.add(name)
+ raw_formats = raw_operation["formats"]
+ if not isinstance(raw_formats, list) or not raw_formats:
+ raise ConfigError(f"{operation_prefix}.formats must be a non-empty array")
+ formats: list[str] = []
+ for raw_format in raw_formats:
+ if not isinstance(raw_format, str) or raw_format.lower() not in {"npy", "csv"}:
+ raise ConfigError(f"{operation_prefix} format must be one of npy, csv")
+ file_format = raw_format.lower()
+ if file_format in formats:
+ raise ConfigError(f"{operation_prefix} has duplicate export format {file_format!r}")
+ formats.append(file_format)
+ operation["name"] = name
+ operation["formats"] = formats
+ has_result = True
+ normalized.append(operation)
+
+ if not has_result:
+ raise ConfigError(f"{prefix}.operations requires at least one measure or export operation")
+ fields["operations"] = normalized
+
+ if "expect" in fields:
+ expect = _parse_expect(fields["expect"], f"{prefix}.expect")
+ unavailable = sorted(set(expect) - measured)
+ if unavailable:
+ raise ConfigError(
+ f"{prefix}.expect metric {unavailable[0]!r} must be selected by a measure operation"
+ )
+ fields["expect"] = expect
+
+
def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) -> None:
for name in ("source_channel", "reference_channel", "response_channel"):
if name in fields:
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index ac4fccc2..c03fe186 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -14,6 +14,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) ->
EXECUTABLE_STEP_KINDS = {
+ "analysis.pipeline",
"power.status",
"power.set",
"power.output",
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index bb68ddf6..dc3a15bd 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -121,6 +121,7 @@
step_status,
)
from wavebench.services.run_plan import RunPlan, RunStep
+from wavebench.services.run_pipeline import execute_analysis_pipeline
from wavebench.services.run_restore import restore_source_state, snapshot_source_state
from wavebench.services.run_safety import (
check_run_plan_safety_limits,
@@ -672,6 +673,7 @@ def _driver_reference(self, kind: str) -> str:
def _plan_instruments(self, plan: RunPlan) -> set[str]:
instruments = {step.kind.split(".", 1)[0] for step in plan.steps if "." in step.kind}
instruments.discard("sleep")
+ instruments.discard("analysis")
if "sweep" in instruments:
instruments.discard("sweep")
instruments.update({"source", "scope"})
@@ -706,6 +708,8 @@ def run(
if execution_intent is not None:
intent = verify_execution_intent(execution_intent, plan, self.config)
plan_hash = intent.plan_digest
+ analysis_steps = [step for step in plan.steps if step.kind == "analysis.pipeline"]
+ hardware_steps = plan.steps[: len(plan.steps) - len(analysis_steps)]
with self._run_instrument_lifecycle(plan) as services:
self._run_safety_guards(plan, services=services)
run_dir = new_package_dir(run_output_base(self.config), plan.label)
@@ -791,7 +795,7 @@ def report_close_errors() -> None:
plan,
source_service_factory=lambda: self._source_service(services=services),
)
- for step in plan.steps:
+ for step in hardware_steps:
step_failure: BaseException | None = None
try:
record = self._run_step(
@@ -819,6 +823,7 @@ def report_close_errors() -> None:
kind=step.kind,
status="failed",
fields=step.fields,
+ id=step.id,
artifact={
"error": error_envelope(
exc,
@@ -835,6 +840,7 @@ def report_close_errors() -> None:
kind=step.kind,
status="failed",
fields=step.fields,
+ id=step.id,
artifact={
"error": error_envelope(
exc,
@@ -1078,7 +1084,110 @@ def report_close_errors() -> None:
steps=records,
)
- return result
+ if not analysis_steps or run_failure is not None or services.close_errors:
+ return result
+
+ source_steps = {
+ step.id: step
+ for step in hardware_steps
+ if step.kind == "scope.capture" and step.id is not None
+ }
+ source_records = {record.index: record for record in records}
+ analysis_failure: dict[str, Any] | None = None
+ try:
+ for step in analysis_steps:
+ source_step = source_steps[step.fields["source"]["step"]]
+ source_record = source_records.get(source_step.index)
+ try:
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+ except Exception as exc: # noqa: BLE001 - preserve offline step failure
+ payload = error_envelope(
+ exc,
+ operation=f"run.step.{step.kind}",
+ )
+ artifact = {
+ "analysis_pipeline": {
+ "schema": "wavebench.analysis_pipeline.v1",
+ "status": "failed",
+ "source_step": source_step.id,
+ "operations": step.fields["operations"],
+ "warnings": [],
+ "exports": [],
+ "failed_stage": "setup",
+ "error": payload,
+ },
+ "metrics": {},
+ }
+ if "expect" in step.fields:
+ artifact["expect"] = evaluate_expect({}, step.fields["expect"])
+ record = RunStepRecord(
+ index=step.index,
+ kind=step.kind,
+ status=step_status(artifact),
+ fields=step.fields,
+ artifact=artifact,
+ id=step.id,
+ )
+ records.append(record)
+ write_step_record(steps_dir, record)
+ if record.status == "failed" and step.fields.get("on_failure", "stop") == "stop":
+ analysis_failure = {
+ "type": "StepFailure",
+ "code": "step_failed",
+ "message": f"run step {step.index} ({step.kind}) failed",
+ "step_index": step.index,
+ "step_kind": step.kind,
+ "policy": "stop",
+ }
+ pipeline_error = artifact.get("analysis_pipeline", {}).get("error")
+ if isinstance(pipeline_error, dict):
+ analysis_failure["error"] = pipeline_error
+ break
+ except KeyboardInterrupt as exc:
+ interruption_error = {
+ "type": "KeyboardInterrupt",
+ "message": str(exc) or "run interrupted by user",
+ }
+ write_run_files(
+ plan=plan,
+ run_json_path=run_json_path,
+ summary_csv_path=summary_csv_path,
+ status="failed",
+ records=records,
+ error=interruption_error,
+ restore_state=restore_state,
+ restore_error=None,
+ provenance=provenance,
+ source_operations=source_operations,
+ rf_source_operations=rf_source_operations,
+ )
+ raise
+
+ run_status = "failed" if any(record.status == "failed" for record in records) else "ok"
+ write_run_files(
+ plan=plan,
+ run_json_path=run_json_path,
+ summary_csv_path=summary_csv_path,
+ status=run_status,
+ records=records,
+ error=analysis_failure,
+ restore_state=restore_state,
+ restore_error=None,
+ provenance=provenance,
+ source_operations=source_operations,
+ rf_source_operations=rf_source_operations,
+ )
+ return RunResult(
+ run_dir=run_dir,
+ run_json_path=run_json_path,
+ summary_csv_path=summary_csv_path,
+ steps=records,
+ )
@contextmanager
def _run_instrument_lifecycle(
@@ -1827,6 +1936,7 @@ def _run_step(
status=step_status(artifact),
fields=step.fields,
artifact=artifact,
+ id=step.id,
)
def _run_frequency_response_step(
@@ -2543,6 +2653,7 @@ def _frequency_response_execution_error(
status="failed",
fields=step.fields,
artifact=artifact,
+ id=step.id,
)
return _FrequencyResponseExecutionError(record, cause)
@@ -2760,6 +2871,7 @@ def _run_scope_capture_step(
retry = service.capture_waveform(
channel=channel, label=f"{label}_auto_retry{attempt}"
)
+ capture = retry
artifact = self._capture_artifact(retry, service)
artifacts.append(artifact)
attempts.append(self._recovery_attempt_record(attempt, "auto_retry", artifact))
diff --git a/tests/test_execution_intent.py b/tests/test_execution_intent.py
index fbe1c2f1..03379d4d 100644
--- a/tests/test_execution_intent.py
+++ b/tests/test_execution_intent.py
@@ -54,6 +54,67 @@ def test_execution_intent_is_stable_and_does_not_expose_resources() -> None:
assert "TCPIP::" not in json.dumps(first.as_dict())
+def test_analysis_pipeline_intent_is_explicitly_offline_and_carries_step_ids() -> None:
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+id = "spectrum_main"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "remove_dc" },
+ { op = "fft" },
+ { op = "measure", metrics = ["peak_frequency_hz"] },
+]
+""",
+ )
+ )
+
+ intent = build_execution_intent(plan, make_config(tmp))
+
+ capture, analysis = intent.operations
+ assert capture["step_id"] == "capture_main"
+ assert analysis["step_id"] == "spectrum_main"
+ assert analysis["operation"] == "analysis.pipeline"
+ assert analysis["instrument_kind"] is None
+ assert analysis["effect"] == "offline"
+ assert analysis["lease_mode"] == "none"
+ assert analysis["parameters"]["source"] == {"step": "capture_main"}
+ assert analysis["parameters"]["operations"][1] == {"op": "fft"}
+
+
+def test_step_id_changes_plan_and_intent_digest_without_changing_legacy_shape() -> None:
+ with TemporaryDirectory() as tmp:
+ legacy = _sleep_plan(tmp)
+ identified = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+id = "wait"
+kind = "sleep"
+duration_s = 0.01
+""",
+ )
+ )
+
+ legacy_intent = build_execution_intent(legacy, make_config(tmp))
+ identified_intent = build_execution_intent(identified, make_config(tmp))
+
+ assert legacy_intent.plan_digest != identified_intent.plan_digest
+ assert legacy_intent.intent_digest != identified_intent.intent_digest
+ assert "step_id" not in legacy_intent.operations[0]
+ assert identified_intent.operations[0]["step_id"] == "wait"
+
+
def test_execution_intent_rejects_plan_or_config_change() -> None:
with TemporaryDirectory() as tmp:
plan = _sleep_plan(tmp)
diff --git a/tests/test_report.py b/tests/test_report.py
index 44ed7fc2..201e7fd7 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -24,6 +24,104 @@
class RunReportTests(unittest.TestCase):
+ def test_run_report_has_independent_signal_processing_section_and_manifest_entries(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ processing = run_dir / "processing" / "01_spectrum_main"
+ exports = processing / "exports"
+ exports.mkdir(parents=True)
+ (processing / "manifest.json").write_text("{}", encoding="utf-8")
+ (processing / "metrics.json").write_text("{}", encoding="utf-8")
+ (exports / "spectrum.csv").write_text(
+ "frequency_hz,real_v,imaginary_v,amplitude_v\n",
+ encoding="utf-8",
+ )
+ (run_dir / "run.json").write_text(
+ json.dumps({
+ "status": "failed",
+ "steps": [
+ {
+ "index": 1,
+ "id": "spectrum_main",
+ "kind": "analysis.pipeline",
+ "status": "failed",
+ "artifact": {
+ "metrics": {
+ "peak_frequency_hz": 1000.0,
+ "thd_ratio": None,
+ },
+ "analysis_pipeline": {
+ "schema": "wavebench.analysis_pipeline.v1",
+ "status": "failed",
+ "source_step": "capture_main",
+ "operations": [
+ {"op": "remove_dc"},
+ {"op": "fft"},
+ {"op": "measure", "metrics": ["peak_frequency_hz"]},
+ ],
+ "manifest": "processing/01_spectrum_main/manifest.json",
+ "metrics": "processing/01_spectrum_main/metrics.json",
+ "warnings": ["harmonic_5_out_of_band"],
+ "failed_stage": "operations[3]",
+ "exports": [
+ {
+ "name": "spectrum",
+ "format": "csv",
+ "path": "processing/01_spectrum_main/exports/spectrum.csv",
+ "sha256": "abc",
+ }
+ ],
+ },
+ },
+ }
+ ],
+ }),
+ encoding="utf-8",
+ )
+
+ output = write_run_report_html(load_run_package(run_dir))
+
+ html = output.read_text(encoding="utf-8")
+ self.assertIn("信号处理 / Signal processing ", html)
+ self.assertIn("spectrum_main", html)
+ self.assertIn("capture_main", html)
+ self.assertIn("remove_dc → fft → measure", html)
+ self.assertIn("peak_frequency_hz=1000", html)
+ self.assertIn("thd_ratio=null", html)
+ self.assertIn("harmonic_5_out_of_band", html)
+ self.assertIn("operations[3]", html)
+ self.assertIn('href="processing/01_spectrum_main/exports/spectrum.csv"', html)
+ manifest = json.loads(
+ (run_dir / "report-assets" / "manifest.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(len(manifest["analysis_pipelines"]), 1)
+ analysis = manifest["analysis_pipelines"][0]
+ self.assertEqual(analysis["step_id"], "spectrum_main")
+ self.assertEqual(analysis["manifest"], "processing/01_spectrum_main/manifest.json")
+ self.assertTrue(analysis["manifest_exists"])
+ self.assertEqual(
+ analysis["exports"][0]["path"],
+ "processing/01_spectrum_main/exports/spectrum.csv",
+ )
+ self.assertTrue(analysis["exports"][0]["exists"])
+
+ def test_report_manifest_omits_analysis_list_for_legacy_run(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(
+ json.dumps({"status": "ok", "steps": []}), encoding="utf-8"
+ )
+
+ output = write_run_report_html(load_run_package(run_dir))
+
+ html = output.read_text(encoding="utf-8")
+ self.assertNotIn("信号处理 / Signal processing ", html)
+ manifest = json.loads(
+ (run_dir / "report-assets" / "manifest.json").read_text(encoding="utf-8")
+ )
+ self.assertNotIn("analysis_pipelines", manifest)
+
def test_response_svg_uses_a_separate_two_column_legend_area(self):
svg = _response_svg(
[[(100.0, 1.0), (1000.0, 2.0)]],
diff --git a/tests/test_run_artifacts.py b/tests/test_run_artifacts.py
index eaa761d5..ec102066 100644
--- a/tests/test_run_artifacts.py
+++ b/tests/test_run_artifacts.py
@@ -9,7 +9,7 @@
import pytest
-from wavebench.services.run_artifacts import RunStepRecord, write_run_files
+from wavebench.services.run_artifacts import RunStepRecord, write_run_files, write_step_record
from wavebench.services.run_plan import load_run_plan
from wavebench.services.source_state import RestorableSourceState
@@ -118,6 +118,25 @@ def test_nonempty_source_operation_namespace_is_additive_to_v1_run_artifacts() -
assert enriched["steps"] == baseline["steps"]
+def test_step_id_is_conditional_and_does_not_change_step_filename() -> None:
+ with TemporaryDirectory() as tmp:
+ directory = Path(tmp)
+ record = RunStepRecord(
+ index=3,
+ kind="analysis.pipeline",
+ status="ok",
+ fields={"source": {"step": "capture_main"}, "operations": []},
+ artifact={"metrics": {}},
+ id="spectrum_main",
+ )
+
+ write_step_record(directory, record)
+
+ path = directory / "03_analysis_pipeline.json"
+ assert path.is_file()
+ assert json.loads(path.read_text(encoding="utf-8"))["id"] == "spectrum_main"
+
+
@pytest.mark.parametrize(
"source_operations",
[
diff --git a/tests/test_run_pipeline.py b/tests/test_run_pipeline.py
new file mode 100644
index 00000000..30d1d356
--- /dev/null
+++ b/tests/test_run_pipeline.py
@@ -0,0 +1,293 @@
+from hashlib import sha256
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+from unittest.mock import patch
+
+import numpy as np
+
+from wavebench.services.run_artifacts import RunStepRecord
+from wavebench.services.run_pipeline import execute_analysis_pipeline
+from wavebench.services.run_plan import RunStep
+
+
+class AnalysisPipelineArtifactTests(unittest.TestCase):
+ def source(
+ self,
+ root: Path,
+ data: np.ndarray,
+ *,
+ npy_metadata_path: str | None = None,
+ status: str = "ok",
+ ) -> tuple[RunStep, RunStepRecord, Path]:
+ package = root / "raw" / "capture"
+ package.mkdir(parents=True)
+ npy_path = package / "ch1.npy"
+ np.save(npy_path, data)
+ metadata = package / "metadata.json"
+ metadata.write_text(
+ json.dumps({
+ "operation": {"channel": 1},
+ "files": {"npy": npy_metadata_path or str(npy_path)},
+ }),
+ encoding="utf-8",
+ )
+ source_step = RunStep(
+ index=0,
+ kind="scope.capture",
+ fields={"save_npy": True},
+ id="capture_main",
+ )
+ source_record = RunStepRecord(
+ index=0,
+ kind="scope.capture",
+ status=status,
+ fields=source_step.fields,
+ artifact={"package": str(package), "metadata": str(metadata)},
+ )
+ return source_step, source_record, npy_path
+
+ def pipeline(
+ self,
+ operations: list[dict[str, object]],
+ *,
+ expect: dict[str, dict[str, float]] | None = None,
+ ) -> RunStep:
+ fields: dict[str, object] = {
+ "source": {"step": "capture_main"},
+ "operations": operations,
+ }
+ if expect is not None:
+ fields["expect"] = expect
+ return RunStep(
+ index=1,
+ kind="analysis.pipeline",
+ fields=fields,
+ id="spectrum_main",
+ )
+
+ def waveform(self, *, nonuniform: bool = False) -> np.ndarray:
+ samples = 1000
+ time_s = np.arange(samples, dtype=float) / 10_000.0
+ if nonuniform:
+ time_s[500:] += 1e-5
+ voltage_v = 0.5 + np.sin(2 * np.pi * 100.0 * time_s)
+ return np.column_stack((time_s, voltage_v))
+
+ def test_success_writes_versioned_metrics_manifest_and_frequency_exports(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, source_npy = self.source(root, self.waveform())
+ source_before = sha256(source_npy.read_bytes()).hexdigest()
+ step = self.pipeline(
+ [
+ {"op": "measure", "metrics": ["voltage_mean_v"]},
+ {"op": "remove_dc"},
+ {"op": "window", "name": "hann"},
+ {"op": "fft"},
+ {
+ "op": "measure",
+ "metrics": [
+ "peak_frequency_hz",
+ "peak_amplitude_v",
+ "noise_floor_v",
+ "thd_ratio",
+ ],
+ },
+ {"op": "export", "name": "spectrum", "formats": ["npy", "csv"]},
+ ],
+ expect={"peak_frequency_hz": {"min": 99.0, "max": 101.0}},
+ )
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ processing = run_dir / "processing" / "01_spectrum_main"
+ manifest = json.loads((processing / "manifest.json").read_text(encoding="utf-8"))
+ metrics = json.loads((processing / "metrics.json").read_text(encoding="utf-8"))
+ exported_npy = processing / "exports" / "spectrum.npy"
+ exported_csv = processing / "exports" / "spectrum.csv"
+ self.assertEqual(manifest["schema"], "wavebench.analysis_pipeline.v1")
+ self.assertEqual(manifest["status"], "ok")
+ self.assertFalse(manifest["partial"])
+ self.assertEqual(manifest["source"]["step"], "capture_main")
+ self.assertEqual(manifest["source"]["npy_sha256"], source_before)
+ self.assertEqual(manifest["window"]["name"], "hann")
+ self.assertAlmostEqual(manifest["window"]["coherent_gain"], np.mean(np.hanning(1000)))
+ self.assertEqual(metrics["schema"], "wavebench.analysis_metrics.v1")
+ self.assertAlmostEqual(metrics["metrics"]["peak_frequency_hz"], 100.0)
+ self.assertEqual(artifact["expect"]["status"], "ok")
+ self.assertEqual(artifact["analysis_pipeline"]["manifest"], "processing/01_spectrum_main/manifest.json")
+ self.assertEqual(np.load(exported_npy).shape, (501, 4))
+ self.assertEqual(
+ exported_csv.read_text(encoding="utf-8").splitlines()[0],
+ "frequency_hz,real_v,imaginary_v,amplitude_v",
+ )
+ self.assertEqual(sha256(source_npy.read_bytes()).hexdigest(), source_before)
+ self.assertFalse(list(processing.rglob("*.tmp")))
+ for export in manifest["exports"]:
+ export_path = run_dir / export["path"]
+ self.assertEqual(export["sha256"], sha256(export_path.read_bytes()).hexdigest())
+
+ def test_source_expectation_failure_still_allows_complete_npy(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, _ = self.source(root, self.waveform(), status="failed")
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=self.pipeline([
+ {"op": "measure", "metrics": ["voltage_mean_v"]},
+ ]),
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "ok")
+ self.assertEqual(artifact["analysis_pipeline"]["source_status"], "failed")
+
+ def test_metadata_path_traversal_is_a_structured_pipeline_failure(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ outside = root / "raw" / "outside.npy"
+ outside.parent.mkdir(parents=True)
+ np.save(outside, self.waveform())
+ outside_before = sha256(outside.read_bytes()).hexdigest()
+ source_step, source_record, _ = self.source(
+ root,
+ self.waveform(),
+ npy_metadata_path="../outside.npy",
+ )
+ step = self.pipeline(
+ [{"op": "measure", "metrics": ["voltage_mean_v"]}],
+ expect={"voltage_mean_v": {"min": -1.0, "max": 1.0}},
+ )
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "failed")
+ self.assertEqual(artifact["analysis_pipeline"]["failed_stage"], "source")
+ self.assertIsNone(artifact["metrics"]["voltage_mean_v"])
+ self.assertEqual(artifact["expect"]["checks"]["voltage_mean_v"]["reason"], "unavailable")
+ manifest = json.loads(
+ (run_dir / artifact["analysis_pipeline"]["manifest"]).read_text(encoding="utf-8")
+ )
+ self.assertEqual(manifest["stages"][0]["status"], "failed")
+ self.assertIn("must not contain '..'", manifest["error"]["message"])
+ self.assertEqual(sha256(outside.read_bytes()).hexdigest(), outside_before)
+
+ def test_completed_time_export_survives_later_fft_failure(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, _ = self.source(
+ root, self.waveform(nonuniform=True)
+ )
+ step = self.pipeline([
+ {"op": "export", "name": "time", "formats": ["npy", "csv"]},
+ {"op": "fft"},
+ {"op": "measure", "metrics": ["peak_frequency_hz"]},
+ ])
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ processing = run_dir / "processing" / "01_spectrum_main"
+ manifest = json.loads((processing / "manifest.json").read_text(encoding="utf-8"))
+ metrics = json.loads((processing / "metrics.json").read_text(encoding="utf-8"))
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "failed")
+ self.assertTrue(manifest["partial"])
+ self.assertEqual(len(manifest["exports"]), 2)
+ self.assertTrue((processing / "exports" / "time.npy").is_file())
+ self.assertEqual(
+ (processing / "exports" / "time.csv")
+ .read_text(encoding="utf-8")
+ .splitlines()[0],
+ "time_s,voltage_v",
+ )
+ self.assertEqual(np.load(processing / "exports" / "time.npy").shape, (1000, 2))
+ self.assertIsNone(metrics["metrics"]["peak_frequency_hz"])
+ self.assertEqual(manifest["failed_stage"], "operations[1]")
+ self.assertEqual(manifest["stages"][-1]["status"], "skipped")
+
+ def test_completed_export_is_recorded_if_a_later_format_write_fails(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, _ = self.source(root, self.waveform())
+ step = self.pipeline([
+ {"op": "export", "name": "time", "formats": ["npy", "csv"]},
+ ])
+
+ with patch(
+ "wavebench.services.run_pipeline._atomic_write_csv",
+ side_effect=OSError("disk full"),
+ ):
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ manifest = json.loads(
+ (run_dir / artifact["analysis_pipeline"]["manifest"]).read_text(encoding="utf-8")
+ )
+ self.assertEqual(manifest["status"], "failed")
+ self.assertTrue(manifest["partial"])
+ self.assertEqual([item["format"] for item in manifest["exports"]], ["npy"])
+
+ def test_missing_source_record_still_writes_null_metrics_and_manifest(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step = RunStep(
+ index=0,
+ kind="scope.capture",
+ fields={"save_npy": True},
+ id="capture_main",
+ )
+ step = self.pipeline([
+ {"op": "measure", "metrics": ["voltage_mean_v"]},
+ ])
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=None,
+ )
+
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "failed")
+ metrics_text = (
+ run_dir / artifact["analysis_pipeline"]["metrics"]
+ ).read_text(encoding="utf-8")
+ self.assertIn('"voltage_mean_v": null', metrics_text)
+ self.assertNotIn("NaN", metrics_text)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_run_plan_analysis.py b/tests/test_run_plan_analysis.py
new file mode 100644
index 00000000..f68fbedc
--- /dev/null
+++ b/tests/test_run_plan_analysis.py
@@ -0,0 +1,261 @@
+from pathlib import Path
+import tempfile
+import unittest
+
+from wavebench.errors import ConfigError
+from wavebench.services.run_plan import STEP_SCHEMAS, format_run_plan_schema, load_run_plan
+
+
+class AnalysisPipelineRunPlanTests(unittest.TestCase):
+ def write_plan(self, content: str) -> Path:
+ directory = tempfile.TemporaryDirectory()
+ self.addCleanup(directory.cleanup)
+ path = Path(directory.name) / "plan.toml"
+ path.write_text(content, encoding="utf-8")
+ return path
+
+ def analysis_plan(
+ self,
+ operations: str,
+ *,
+ capture_id: str = "capture_main",
+ capture_extra: str = "save_npy = true",
+ analysis_extra: str = "",
+ prefix: str = "",
+ suffix: str = "",
+ ) -> str:
+ return f"""
+{prefix}
+[[steps]]
+id = "{capture_id}"
+kind = "scope.capture"
+{capture_extra}
+
+[[steps]]
+id = "spectrum_main"
+kind = "analysis.pipeline"
+source = {{ step = "{capture_id}" }}
+operations = [{operations}]
+{analysis_extra}
+{suffix}
+"""
+
+ def test_loads_pipeline_with_structural_ids_and_normalized_operations(self) -> None:
+ plan = load_run_plan(
+ self.write_plan(
+ self.analysis_plan(
+ """
+ { op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v"] },
+ { op = "remove_dc" },
+ { op = "window", name = "hann" },
+ { op = "fft" },
+ { op = "measure", metrics = ["peak_frequency_hz", "thd_ratio"] },
+ { op = "export", name = "spectrum", formats = ["npy", "csv"] },
+""",
+ analysis_extra="""
+[steps.expect]
+peak_frequency_hz = { min = 990, max = 1010 }
+thd_ratio = { max = 0.05 }
+""",
+ )
+ )
+ )
+
+ capture, analysis = plan.steps
+ self.assertEqual(capture.id, "capture_main")
+ self.assertEqual(analysis.id, "spectrum_main")
+ self.assertNotIn("id", capture.fields)
+ self.assertNotIn("id", analysis.fields)
+ self.assertEqual(analysis.fields["source"], {"step": "capture_main"})
+ self.assertEqual(
+ analysis.fields["operations"][-1],
+ {"op": "export", "name": "spectrum", "formats": ["npy", "csv"]},
+ )
+ self.assertEqual(analysis.fields["expect"]["thd_ratio"], {"max": 0.05})
+
+ def test_step_id_is_optional_and_validated_plan_wide(self) -> None:
+ legacy = load_run_plan(
+ self.write_plan(
+ """
+[[steps]]
+kind = "sleep"
+duration_s = 0.1
+"""
+ )
+ )
+ self.assertIsNone(legacy.steps[0].id)
+
+ for bad_id in ("", "1capture", "Capture", "capture.main", "a" * 65):
+ with self.subTest(bad_id=bad_id):
+ path = self.write_plan(
+ f"""
+[[steps]]
+id = "{bad_id}"
+kind = "sleep"
+duration_s = 0.1
+"""
+ )
+ with self.assertRaisesRegex(ConfigError, "id must match"):
+ load_run_plan(path)
+
+ duplicate = self.write_plan(
+ """
+[[steps]]
+id = "same"
+kind = "sleep"
+duration_s = 0.1
+
+[[steps]]
+id = "same"
+kind = "sleep"
+duration_s = 0.1
+"""
+ )
+ with self.assertRaisesRegex(ConfigError, "duplicate step id"):
+ load_run_plan(duplicate)
+
+ def test_pipeline_source_must_be_earlier_explicit_npy_capture(self) -> None:
+ cases = {
+ "unknown": self.analysis_plan(
+ '{ op = "measure", metrics = ["voltage_mean_v"] }'
+ ).replace('step = "capture_main"', 'step = "missing"'),
+ "save_npy": self.analysis_plan(
+ '{ op = "measure", metrics = ["voltage_mean_v"] }',
+ capture_extra="",
+ ),
+ "scope.capture": """
+[[steps]]
+id = "wait"
+kind = "sleep"
+duration_s = 0.1
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "wait" }
+operations = [{ op = "measure", metrics = ["voltage_mean_v"] }]
+""",
+ "earlier": """
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "later" }
+operations = [{ op = "measure", metrics = ["voltage_mean_v"] }]
+
+[[steps]]
+id = "later"
+kind = "scope.capture"
+save_npy = true
+""",
+ }
+ for message, content in cases.items():
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ConfigError, message):
+ load_run_plan(self.write_plan(content))
+
+ def test_pipeline_steps_must_form_contiguous_suffix(self) -> None:
+ path = self.write_plan(
+ self.analysis_plan(
+ '{ op = "measure", metrics = ["voltage_mean_v"] }',
+ suffix="""
+[[steps]]
+kind = "sleep"
+duration_s = 0.1
+""",
+ )
+ )
+ with self.assertRaisesRegex(ConfigError, "contiguous suffix"):
+ load_run_plan(path)
+
+ def test_pipeline_rejects_safety_gate(self) -> None:
+ path = self.write_plan(
+ self.analysis_plan(
+ '{ op = "measure", metrics = ["voltage_mean_v"] }',
+ analysis_extra="safety_gate = true",
+ )
+ )
+ with self.assertRaisesRegex(ConfigError, "unknown key.*safety_gate"):
+ load_run_plan(path)
+
+ def test_pipeline_operator_parameters_and_domains_are_strict(self) -> None:
+ cases = {
+ "operations must be a non-empty array": "",
+ "operation must be a TOML table": '"remove_dc"',
+ "unsupported op": '{ op = "smooth" }',
+ "unknown field 'method'": '{ op = "remove_dc", method = "linear" }',
+ "method must be 'linear'": '{ op = "detrend", method = "constant" }',
+ "name must be one of": '{ op = "window", name = "bartlett" }',
+ "metrics must be a non-empty array": '{ op = "measure", metrics = [] }',
+ "metric 'peak_frequency_hz' requires frequency-domain data": (
+ '{ op = "measure", metrics = ["peak_frequency_hz"] }'
+ ),
+ "metric 'voltage_rms_v' requires time-domain data": (
+ '{ op = "fft" }, { op = "measure", metrics = ["voltage_rms_v"] }'
+ ),
+ "name must match": '{ op = "export", name = "../bad", formats = ["npy"] }',
+ "formats must be a non-empty array": '{ op = "export", name = "data", formats = [] }',
+ "format must be one of": (
+ '{ op = "export", name = "data", formats = ["json"] }'
+ ),
+ }
+ for message, operations in cases.items():
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ConfigError, message):
+ load_run_plan(self.write_plan(self.analysis_plan(operations)))
+
+ def test_pipeline_rejects_duplicate_and_misordered_configuration(self) -> None:
+ cases = {
+ "at most once": (
+ '{ op = "remove_dc" }, { op = "remove_dc" }, '
+ '{ op = "export", name = "data", formats = ["npy"] }'
+ ),
+ "mutually exclusive": (
+ '{ op = "remove_dc" }, { op = "detrend", method = "linear" }, '
+ '{ op = "export", name = "data", formats = ["npy"] }'
+ ),
+ "must appear before window": (
+ '{ op = "window", name = "hann" }, { op = "remove_dc" }, '
+ '{ op = "export", name = "data", formats = ["npy"] }'
+ ),
+ "must appear before fft": (
+ '{ op = "fft" }, { op = "window", name = "hann" }, '
+ '{ op = "export", name = "data", formats = ["npy"] }'
+ ),
+ "duplicate metric": (
+ '{ op = "measure", metrics = ["voltage_mean_v"] }, '
+ '{ op = "measure", metrics = ["voltage_mean_v"] }'
+ ),
+ "duplicate export name": (
+ '{ op = "export", name = "data", formats = ["npy"] }, '
+ '{ op = "export", name = "data", formats = ["csv"] }'
+ ),
+ "duplicate export format": (
+ '{ op = "export", name = "data", formats = ["npy", "npy"] }'
+ ),
+ "requires at least one measure or export": '{ op = "remove_dc" }',
+ }
+ for message, operations in cases.items():
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ConfigError, message):
+ load_run_plan(self.write_plan(self.analysis_plan(operations)))
+
+ def test_pipeline_expect_requires_an_explicitly_measured_metric(self) -> None:
+ path = self.write_plan(
+ self.analysis_plan(
+ '{ op = "export", name = "data", formats = ["npy"] }',
+ analysis_extra="""
+[steps.expect]
+voltage_mean_v = { min = -0.1, max = 0.1 }
+""",
+ )
+ )
+ with self.assertRaisesRegex(ConfigError, "must be selected by a measure operation"):
+ load_run_plan(path)
+
+ def test_generated_schema_lists_pipeline_contract(self) -> None:
+ schema = format_run_plan_schema()
+ self.assertIn("analysis.pipeline", STEP_SCHEMAS)
+ self.assertIn("[[steps]] optional structural field: id", schema)
+ self.assertIn("analysis.pipeline metrics", schema)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index ed06dae9..9f881a0d 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -116,11 +116,11 @@ def fake_capture(
voltage_mean_v: float = 0.0,
duty_cycle: float | None = None,
frequency_error_ratio: float | None = 0.0,
+ waveform_frequency_hz: float = 1000.0,
):
package = Path(tmp) / name
package.mkdir()
metadata = package / "metadata.json"
- metadata.write_text("{}", encoding="utf-8")
summary = {
"quality_warnings": warnings or [],
"frequency_estimate_hz": frequency_hz,
@@ -134,8 +134,17 @@ def fake_capture(
waveform_path = package / "ch1.npy"
sample_rate = 100_000.0
times = np.arange(4096) / sample_rate
- volts = np.sin(2 * np.pi * 1000.0 * times) + 0.1 * np.sin(2 * np.pi * 2000.0 * times)
+ volts = np.sin(2 * np.pi * waveform_frequency_hz * times) + 0.1 * np.sin(
+ 2 * np.pi * 2 * waveform_frequency_hz * times
+ )
np.save(waveform_path, np.column_stack((times, volts)))
+ metadata.write_text(
+ json.dumps({
+ "operation": {"channel": 1},
+ "files": {"npy": str(waveform_path)},
+ }),
+ encoding="utf-8",
+ )
waveform = SimpleNamespace(summary=lambda **kwargs: summary)
return SimpleNamespace(package_dir=package, metadata_path=metadata, waveform=waveform, npy_path=waveform_path)
@@ -1234,6 +1243,40 @@ def test_scope_capture_auto_recovers_until_warning_is_clear(self):
self.assertEqual(artifact["quality_recovery"]["max_auto_recover_attempts"], 2)
self.assertIn("low_points_per_cycle", artifact["quality_recovery"]["attempts"][0]["quality"]["warnings"][0])
+ def test_scope_capture_fft_uses_final_recovery_capture(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "scope.capture"
+label = "recovered_fft"
+quality_gate = true
+auto_recover = true
+
+[steps.expect_fft]
+peak_frequency_hz = { min = 1980.0, max = 2020.0 }
+""",
+ )
+ )
+ first = fake_capture(tmp, "initial", ["low_points_per_cycle: too sparse"])
+ accepted = fake_capture(
+ tmp,
+ "accepted",
+ [],
+ waveform_frequency_hz=2000.0,
+ )
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls:
+ scope_cls.return_value.capture_waveform.side_effect = [first, accepted]
+
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ artifact = result.steps[0].artifact
+ self.assertEqual(artifact["package"], str(accepted.package_dir))
+ self.assertEqual(artifact["fft"]["status"], "ok")
+ self.assertEqual(artifact["expect_fft"]["status"], "ok")
+
def test_scope_capture_uses_configured_recovery_attempts_and_accepts_consistency(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(
diff --git a/tests/test_run_service_analysis.py b/tests/test_run_service_analysis.py
new file mode 100644
index 00000000..3540b6ab
--- /dev/null
+++ b/tests/test_run_service_analysis.py
@@ -0,0 +1,298 @@
+from contextlib import contextmanager
+import json
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from wavebench.errors import ConfigError
+from wavebench.logging import CommandLogger
+from wavebench.services.run_artifacts import RunStepRecord
+from wavebench.services.run_pipeline import execute_analysis_pipeline as execute_pipeline
+from wavebench.services.run_plan import load_run_plan
+from wavebench.services.run_service import RunInstrumentServices, RunService
+
+from test_run_service import fake_capture, make_config, write_plan
+
+
+def _plan(tmp: str, *, capture_failure: str = "continue", two_analyses: bool = False):
+ second = """
+[[steps]]
+id = "spectrum_second"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [{ op = "measure", metrics = ["voltage_mean_v"] }]
+""" if two_analyses else ""
+ return load_run_plan(
+ write_plan(
+ tmp,
+ f"""
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+on_failure = "{capture_failure}"
+
+[[steps]]
+id = "spectrum_main"
+kind = "analysis.pipeline"
+source = {{ step = "capture_main" }}
+operations = [
+ {{ op = "measure", metrics = ["voltage_mean_v"] }},
+ {{ op = "remove_dc" }},
+ {{ op = "fft" }},
+ {{ op = "measure", metrics = ["peak_frequency_hz"] }},
+ {{ op = "export", name = "spectrum", formats = ["npy", "csv"] }},
+]
+{second}
+""",
+ )
+ )
+
+
+class PhaseRunService(RunService):
+ def __init__(self, *args, capture_record: RunStepRecord, events: list[str], **kwargs):
+ super().__init__(*args, **kwargs)
+ self.capture_record = capture_record
+ self.events = events
+ self.lifecycle_services = RunInstrumentServices()
+
+ def check(self, plan):
+ return None
+
+ def _run_safety_guards(self, plan, *, services=None):
+ return None
+
+ @contextmanager
+ def _run_instrument_services(self, plan):
+ self.events.append("session_open")
+ try:
+ yield self.lifecycle_services
+ finally:
+ self.events.append("session_and_lease_closed")
+
+ def _run_step(self, plan, step, **kwargs):
+ self.events.append(f"hardware:{step.id}")
+ return RunStepRecord(
+ index=step.index,
+ kind=step.kind,
+ status=self.capture_record.status,
+ fields=step.fields,
+ artifact=self.capture_record.artifact,
+ id=step.id,
+ )
+
+
+def _capture_record(tmp: str, *, status: str = "ok") -> RunStepRecord:
+ capture = fake_capture(tmp, "capture", [])
+ return RunStepRecord(
+ index=0,
+ kind="scope.capture",
+ status=status,
+ fields={"save_npy": True, "on_failure": "continue"},
+ artifact={
+ "package": str(capture.package_dir),
+ "metadata": str(capture.metadata_path),
+ "quality": {"status": "ok", "warnings": []},
+ },
+ id="capture_main",
+ )
+
+
+def test_analysis_runs_only_after_sessions_and_leases_are_released() -> None:
+ with TemporaryDirectory() as tmp:
+ events: list[str] = []
+ service = PhaseRunService(
+ config=make_config(tmp),
+ logger=CommandLogger(),
+ capture_record=_capture_record(tmp),
+ events=events,
+ )
+ original = execute_pipeline
+
+ def tracked_execute(**kwargs):
+ events.append("analysis")
+ return original(**kwargs)
+
+ def tracked_restore(*args, **kwargs):
+ events.append("restore")
+ return None
+
+ with patch(
+ "wavebench.services.run_service.restore_source_state",
+ side_effect=tracked_restore,
+ ), patch(
+ "wavebench.services.run_service.execute_analysis_pipeline", side_effect=tracked_execute
+ ):
+ result = service.run(_plan(tmp))
+
+ assert events == [
+ "session_open",
+ "hardware:capture_main",
+ "restore",
+ "session_and_lease_closed",
+ "analysis",
+ ]
+ assert [record.id for record in result.steps] == ["capture_main", "spectrum_main"]
+ assert result.steps[1].status == "ok"
+ assert (result.run_dir / "processing/01_spectrum_main/manifest.json").is_file()
+ assert (result.run_dir / "processing/01_spectrum_main/exports/spectrum.csv").is_file()
+ run = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+ assert run["status"] == "ok"
+ assert run["steps"][0]["id"] == "capture_main"
+ assert run["steps"][1]["id"] == "spectrum_main"
+
+
+def test_failed_capture_expectation_with_continue_can_still_be_analyzed() -> None:
+ with TemporaryDirectory() as tmp:
+ events: list[str] = []
+ service = PhaseRunService(
+ config=make_config(tmp),
+ logger=CommandLogger(),
+ capture_record=_capture_record(tmp, status="failed"),
+ events=events,
+ )
+
+ result = service.run(_plan(tmp))
+
+ assert [record.status for record in result.steps] == ["failed", "ok"]
+ assert result.steps[1].artifact["analysis_pipeline"]["source_status"] == "failed"
+ run = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+ assert run["status"] == "failed"
+ assert "error" not in run
+
+
+def test_hardware_stop_skips_analysis_suffix() -> None:
+ with TemporaryDirectory() as tmp:
+ events: list[str] = []
+ service = PhaseRunService(
+ config=make_config(tmp),
+ logger=CommandLogger(),
+ capture_record=_capture_record(tmp, status="failed"),
+ events=events,
+ )
+ with patch("wavebench.services.run_service.execute_analysis_pipeline") as execute:
+ result = service.run(_plan(tmp, capture_failure="stop"))
+
+ execute.assert_not_called()
+ assert len(result.steps) == 1
+ run = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+ assert run["error"]["code"] == "step_failed"
+
+
+def test_session_close_failure_skips_analysis_and_is_not_overwritten() -> None:
+ with TemporaryDirectory() as tmp:
+ events: list[str] = []
+ service = PhaseRunService(
+ config=make_config(tmp),
+ logger=CommandLogger(),
+ capture_record=_capture_record(tmp),
+ events=events,
+ )
+ service.lifecycle_services.close_errors.append({
+ "operation": "session.close.scope",
+ "type": "RuntimeError",
+ "error": {
+ "schema": "wavebench.error.v1",
+ "code": "unexpected_error",
+ "type": "RuntimeError",
+ "message": "close failed",
+ "exit_code": 1,
+ "operation": "session.close.scope",
+ },
+ })
+
+ with patch("wavebench.services.run_service.execute_analysis_pipeline") as execute:
+ result = service.run(_plan(tmp))
+
+ execute.assert_not_called()
+ run = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+ assert run["status"] == "failed"
+ assert run["error"]["code"] == "session_close_failed"
+
+
+def test_restore_failure_occurs_before_and_skips_analysis() -> None:
+ with TemporaryDirectory() as tmp:
+ events: list[str] = []
+ service = PhaseRunService(
+ config=make_config(tmp),
+ logger=CommandLogger(),
+ capture_record=_capture_record(tmp),
+ events=events,
+ )
+ restore_error = {
+ "schema": "wavebench.error.v1",
+ "code": "restore_failed",
+ "type": "ConfigError",
+ "message": "restore failed",
+ "exit_code": 2,
+ }
+ with patch(
+ "wavebench.services.run_service.restore_source_state",
+ return_value=restore_error,
+ ), patch("wavebench.services.run_service.execute_analysis_pipeline") as execute:
+ try:
+ service.run(_plan(tmp))
+ except ConfigError:
+ pass
+ else: # pragma: no cover - assertion helper without pytest dependency
+ raise AssertionError("restore failure should be raised")
+
+ execute.assert_not_called()
+
+
+def test_analysis_on_failure_controls_only_the_analysis_suffix() -> None:
+ for policy, expected_calls in (("stop", 1), ("continue", 2)):
+ with TemporaryDirectory() as tmp:
+ events: list[str] = []
+ plan = _plan(tmp, two_analyses=True)
+ plan.steps[1].fields["on_failure"] = policy
+ service = PhaseRunService(
+ config=make_config(tmp),
+ logger=CommandLogger(),
+ capture_record=_capture_record(tmp),
+ events=events,
+ )
+ failed = {
+ "analysis_pipeline": {
+ "schema": "wavebench.analysis_pipeline.v1",
+ "status": "failed",
+ "error": {"message": "analysis failed"},
+ },
+ "metrics": {},
+ }
+ succeeded = {
+ "analysis_pipeline": {
+ "schema": "wavebench.analysis_pipeline.v1",
+ "status": "ok",
+ },
+ "metrics": {"voltage_mean_v": 0.0},
+ }
+ with patch(
+ "wavebench.services.run_service.execute_analysis_pipeline",
+ side_effect=[failed, succeeded],
+ ) as execute:
+ result = service.run(plan)
+
+ assert execute.call_count == expected_calls
+ assert len(result.steps) == 1 + expected_calls
+ assert events.count("hardware:capture_main") == 1
+
+
+def test_legacy_plan_does_not_enter_analysis_phase() -> None:
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+kind = "sleep"
+duration_s = 0.001
+""",
+ )
+ )
+ with patch("wavebench.services.run_service.execute_analysis_pipeline") as execute:
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ execute.assert_not_called()
+ run = json.loads(result.run_json_path.read_text(encoding="utf-8"))
+ assert len(run["steps"]) == 1
+ assert "id" not in run["steps"][0]
diff --git a/tests/test_signal_pipeline.py b/tests/test_signal_pipeline.py
new file mode 100644
index 00000000..736b9d92
--- /dev/null
+++ b/tests/test_signal_pipeline.py
@@ -0,0 +1,227 @@
+import unittest
+
+import numpy as np
+
+from wavebench.data.signal_pipeline import (
+ ANALYSIS_FREQUENCY_METRICS,
+ FrequencySignal,
+ detrend_linear,
+ fft_signal,
+ measure_frequency,
+ measure_time,
+ remove_dc,
+ validate_waveform,
+ window_signal,
+)
+from wavebench.errors import DataError
+
+
+class SignalPipelineTests(unittest.TestCase):
+ def waveform(self, samples: int = 1000, sample_rate_hz: float = 10_000.0) -> np.ndarray:
+ time_s = np.arange(samples, dtype=float) / sample_rate_hz
+ voltage_v = (
+ 1.5 * np.sin(2 * np.pi * 100.0 * time_s)
+ + 0.15 * np.sin(2 * np.pi * 200.0 * time_s)
+ + 0.075 * np.sin(2 * np.pi * 300.0 * time_s)
+ )
+ return np.column_stack((time_s, voltage_v))
+
+ def test_waveform_validation_is_strict(self) -> None:
+ valid = self.waveform(8)
+ self.assertEqual(validate_waveform(valid).as_array().shape, (8, 2))
+
+ invalid = (
+ np.empty((0, 2)),
+ np.zeros((4, 3)),
+ np.array([["0", "1"], ["1", "2"]]),
+ np.array([[0.0, 1.0], [1.0, np.nan]]),
+ np.array([[0.0, 1.0], [0.0, 2.0]]),
+ np.array([[0.0, 1.0], [-1.0, 2.0]]),
+ )
+ for data in invalid:
+ with self.subTest(data=data):
+ with self.assertRaises(DataError):
+ validate_waveform(data)
+
+ def test_remove_dc_subtracts_arithmetic_mean(self) -> None:
+ signal = validate_waveform(
+ np.column_stack((np.arange(4, dtype=float), [1.0, 2.0, 3.0, 8.0]))
+ )
+ result = remove_dc(signal)
+ np.testing.assert_allclose(result.voltage_v, [-2.5, -1.5, -0.5, 4.5])
+ self.assertEqual(float(np.mean(result.voltage_v)), 0.0)
+
+ def test_linear_detrend_removes_slope_and_intercept_on_centered_time(self) -> None:
+ time_s = np.array([1000.0, 1000.2, 1000.5, 1001.0])
+ residual = np.array([0.2, -0.1, -0.1, 0.2])
+ residual -= np.mean(residual)
+ centered_time = time_s - np.mean(time_s)
+ residual -= np.dot(centered_time, residual) / np.dot(centered_time, centered_time) * centered_time
+ voltage_v = 4.0 + 2.5 * centered_time + residual
+
+ result = detrend_linear(validate_waveform(np.column_stack((time_s, voltage_v))))
+
+ self.assertAlmostEqual(float(np.mean(result.voltage_v)), 0.0, places=12)
+ self.assertAlmostEqual(float(np.dot(centered_time, result.voltage_v)), 0.0, places=12)
+ np.testing.assert_allclose(result.voltage_v, residual, atol=1e-12)
+
+ def test_numpy_window_definitions_and_coherent_gain(self) -> None:
+ signal = validate_waveform(
+ np.column_stack((np.arange(8, dtype=float), np.ones(8)))
+ )
+ for name, expected in (
+ ("hann", np.hanning(8)),
+ ("hamming", np.hamming(8)),
+ ("blackman", np.blackman(8)),
+ ):
+ with self.subTest(name=name):
+ result = window_signal(signal, name)
+ np.testing.assert_array_equal(result.voltage_v, expected)
+ self.assertEqual(result.coherent_gain, float(np.mean(expected)))
+ self.assertEqual(result.window_name, name)
+
+ def test_even_fft_preserves_dc_and_nyquist_without_double_scaling(self) -> None:
+ samples = 8
+ indices = np.arange(samples)
+ time_s = indices / samples
+ voltage_v = 3.0 + 2.0 * np.cos(2 * np.pi * indices / samples) + 5.0 * (-1.0) ** indices
+
+ result = fft_signal(validate_waveform(np.column_stack((time_s, voltage_v))))
+
+ self.assertAlmostEqual(float(result.amplitude_v[0]), 3.0, places=12)
+ self.assertAlmostEqual(float(result.amplitude_v[1]), 2.0, places=12)
+ self.assertAlmostEqual(float(result.amplitude_v[-1]), 5.0, places=12)
+
+ def test_odd_fft_doubles_the_last_non_dc_bin(self) -> None:
+ samples = 9
+ indices = np.arange(samples)
+ time_s = indices / samples
+ voltage_v = 2.0 * np.cos(2 * np.pi * 4 * indices / samples)
+
+ result = fft_signal(validate_waveform(np.column_stack((time_s, voltage_v))))
+
+ self.assertAlmostEqual(float(result.amplitude_v[4]), 2.0, places=12)
+
+ def test_fft_uses_window_coherent_gain(self) -> None:
+ signal = validate_waveform(
+ np.column_stack((np.arange(8, dtype=float) / 8.0, np.full(8, 2.5)))
+ )
+ windowed = window_signal(signal, "hamming")
+
+ result = fft_signal(windowed)
+
+ self.assertAlmostEqual(float(result.amplitude_v[0]), 2.5, places=12)
+ self.assertEqual(result.coherent_gain, float(np.mean(np.hamming(8))))
+
+ def test_fft_rejects_short_or_nonuniform_input(self) -> None:
+ with self.assertRaisesRegex(DataError, "at least four"):
+ fft_signal(validate_waveform(self.waveform(3)))
+
+ data = self.waveform(8)
+ data[4:, 0] += 1e-4
+ with self.assertRaisesRegex(DataError, "uniformly sampled"):
+ fft_signal(validate_waveform(data))
+
+ def test_time_metrics_have_fixed_units_and_peak_semantics(self) -> None:
+ signal = validate_waveform(
+ np.column_stack((np.arange(4, dtype=float), [-2.0, -1.0, 1.0, 2.0]))
+ )
+ metrics = measure_time(
+ signal,
+ [
+ "voltage_min_v",
+ "voltage_max_v",
+ "voltage_mean_v",
+ "voltage_rms_v",
+ "voltage_vpp_v",
+ ],
+ )
+ self.assertEqual(metrics["voltage_min_v"], -2.0)
+ self.assertEqual(metrics["voltage_max_v"], 2.0)
+ self.assertEqual(metrics["voltage_mean_v"], 0.0)
+ self.assertAlmostEqual(metrics["voltage_rms_v"], np.sqrt(2.5))
+ self.assertEqual(metrics["voltage_vpp_v"], 4.0)
+
+ def test_finite_input_cannot_emit_nonfinite_metrics(self) -> None:
+ signal = validate_waveform(
+ np.column_stack((np.arange(2, dtype=float), [-1e308, 1e308]))
+ )
+
+ with self.assertRaisesRegex(DataError, "not finite"):
+ measure_time(signal, ["voltage_vpp_v"])
+
+ def test_frequency_metrics_find_peak_harmonics_thd_and_noise_floor(self) -> None:
+ spectrum = fft_signal(validate_waveform(self.waveform()))
+ metrics, warnings = measure_frequency(spectrum, sorted(ANALYSIS_FREQUENCY_METRICS))
+
+ self.assertEqual(warnings, [])
+ self.assertAlmostEqual(metrics["peak_frequency_hz"], 100.0)
+ self.assertAlmostEqual(metrics["peak_amplitude_v"], 1.5, places=12)
+ self.assertAlmostEqual(metrics["harmonic_2_frequency_hz"], 200.0)
+ self.assertAlmostEqual(metrics["harmonic_2_amplitude_v"], 0.15, places=12)
+ self.assertAlmostEqual(metrics["harmonic_3_amplitude_v"], 0.075, places=12)
+ self.assertAlmostEqual(
+ metrics["thd_ratio"],
+ np.sqrt(0.15**2 + 0.075**2) / 1.5,
+ places=12,
+ )
+ self.assertLess(metrics["noise_floor_v"], 1e-13)
+
+ def test_harmonics_outside_nyquist_are_null_with_warnings(self) -> None:
+ samples = 80
+ sample_rate_hz = 8000.0
+ time_s = np.arange(samples) / sample_rate_hz
+ voltage_v = np.sin(2 * np.pi * 2000.0 * time_s)
+ spectrum = fft_signal(validate_waveform(np.column_stack((time_s, voltage_v))))
+
+ metrics, warnings = measure_frequency(
+ spectrum,
+ [
+ "harmonic_2_frequency_hz",
+ "harmonic_2_amplitude_v",
+ "harmonic_3_frequency_hz",
+ "harmonic_3_amplitude_v",
+ "thd_ratio",
+ ],
+ )
+
+ self.assertAlmostEqual(metrics["harmonic_2_frequency_hz"], 4000.0)
+ self.assertIsNone(metrics["harmonic_3_frequency_hz"])
+ self.assertIsNone(metrics["harmonic_3_amplitude_v"])
+ self.assertIn("harmonic_3_out_of_band", warnings)
+ self.assertIn("harmonic_5_out_of_band", warnings)
+
+ def test_silent_spectrum_has_no_peak_or_thd(self) -> None:
+ time_s = np.arange(8, dtype=float) / 8.0
+ spectrum = fft_signal(
+ validate_waveform(np.column_stack((time_s, np.full(8, 1e-13))))
+ )
+
+ metrics, warnings = measure_frequency(
+ spectrum,
+ ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio"],
+ )
+
+ self.assertIsNone(metrics["peak_frequency_hz"])
+ self.assertIsNone(metrics["peak_amplitude_v"])
+ self.assertIsNone(metrics["thd_ratio"])
+ self.assertEqual(metrics["noise_floor_v"], 0.0)
+ self.assertIn("no_significant_non_dc_peak", warnings)
+
+ def test_noise_floor_is_median_after_excluding_dc_and_main_peak(self) -> None:
+ spectrum = FrequencySignal(
+ frequency_hz=np.arange(5, dtype=float),
+ spectrum_v=np.array([100.0, 10.0, 1.0, 3.0, 5.0], dtype=complex),
+ samples=8,
+ sample_interval_s=0.125,
+ coherent_gain=1.0,
+ window_name=None,
+ )
+
+ metrics, _ = measure_frequency(spectrum, ["noise_floor_v"])
+
+ self.assertEqual(metrics["noise_floor_v"], 3.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 6cd747167158517b0a01b4f9e98c4ae6525ce455 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 4 Sep 2026 14:47:05 +0800
Subject: [PATCH 02/30] docs(run): document signal processing pipelines
---
docs/development/documentation.md | 2 +
docs/reference/artifacts.md | 42 ++++++++++++++-
docs/reference/run-schema.md | 53 +++++++++++++++++++
docs/tech-doc-term-allowlist.json | 3 ++
plans/README.md | 1 +
plans/example_signal_processing_pipeline.toml | 36 +++++++++++++
6 files changed, 136 insertions(+), 1 deletion(-)
create mode 100644 docs/tech-doc-term-allowlist.json
create mode 100644 plans/example_signal_processing_pipeline.toml
diff --git a/docs/development/documentation.md b/docs/development/documentation.md
index cf2bd942..8c0bfc79 100644
--- a/docs/development/documentation.md
+++ b/docs/development/documentation.md
@@ -13,6 +13,8 @@ WaveBench 文档采用 docs-as-code:文档和代码一起版本控制、review
中文页面在结构、事实和边界确定后,再应用 `tech-doc-style-chinese`。该写作层不负责决定页面类别或信息架构。
+WaveBench 的受控术语豁免记录在 `docs/tech-doc-term-allowlist.json`。运行文案检查器时必须通过 `--term-allowlist docs/tech-doc-term-allowlist.json` 显式传入;检查器不会自动发现该文件。每个条目使用完整字面术语作为键,并附非空理由,不接受正则表达式或无理由豁免。
+
## 机械检查
```bash
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 5527ac4d..372fdc0f 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -13,6 +13,11 @@
summary.csv 面向快速查看和表格导入的摘要
steps/
00_.json 单个 step 记录
+ processing/ 仅在存在 analysis.pipeline 时生成
+ 01_/
+ manifest.json
+ metrics.json
+ exports/
```
`run report ` 只读取已有产物并生成离线报告,不连接仪器,也不修改原始采集数据;它会在运行目录或显式输出位置写入派生的 HTML,使用 `--pdf` 时还会写入 PDF。
@@ -32,7 +37,42 @@
## step 记录
-每个 `steps/_.json` 记录包含 `index`、`kind`、`status`、`fields` 和 `artifact`。具体 `artifact` 形状取决于 step;采集、频响、DMM、Source V2 和 RF Source 不共享一张人工字段表。
+每个 `steps/_.json` 记录包含 `index`、`kind`、`status`、`fields` 和 `artifact`。step 声明 ID 时还会包含 `id`;没有 ID 的旧记录不增加该字段,文件名仍保持原格式。具体 `artifact` 形状取决于 step;采集、频响、DMM、Source V2、RF Source 和离线分析不共享一张人工字段表。
+
+## 信号处理派生产物
+
+每个 `analysis.pipeline` step 使用独立目录:
+
+```text
+/processing/_/
+ manifest.json
+ metrics.json
+ exports/
+ .npy
+ .csv
+```
+
+`manifest.json` 的 schema 为 `wavebench.analysis_pipeline.v1`。它记录来源 step 及状态、来源 capture package/metadata/NPY 的 run-relative POSIX 路径、原始 NPY 的 SHA-256、规范化算子、逐阶段状态、采样信息、窗与相干增益、警告、导出、数值定义和结构化错误。某个后续算子失败时,已经完成的导出会保留,并由 `partial` 和 `failed_stage` 标明部分结果。
+
+`metrics.json` 的 schema 为 `wavebench.analysis_metrics.v1`,结构如下:
+
+```json
+{
+ "schema": "wavebench.analysis_metrics.v1",
+ "metrics": {
+ "peak_frequency_hz": 1000.0,
+ "thd_ratio": null
+ }
+}
+```
+
+指标值只写有限 JSON 数字或 `null`,不写 `NaN`、`Infinity`。step artifact 的 `metrics` 保留同一份小型映射;`expect` 继续使用既有 `{ min, max }` 结果结构,因此 `summary.csv` 的 expectation 列和 HTML 验收表不需要另一套解释。
+
+时域 NPY 和 CSV 固定为 `time_s,voltage_v` 两列。频域 NPY 和 CSV 固定为 `frequency_hz,real_v,imaginary_v,amplitude_v` 四列。每个导出记录文件路径、列名和 SHA-256;路径相对于 run 目录并使用 POSIX 分隔符。来源 NPY 保持原样,处理器只读取 capture package 内经过边界校验的文件。
+
+频域 `amplitude_v` 是单边峰值幅度,不是 RMS。`noise_floor_v` 是排除 DC 与主峰后的非 DC 幅度 bin 中位数,表示每 bin 峰值幅度,不表示积分噪声。THD 使用 Nyquist 范围内的 H2~H5。
+
+HTML 报告在存在分析 step 时增加「信号处理 / Signal processing」区域,并在报告 manifest 中条件性增加 `analysis_pipelines`。没有分析 step 的旧报告 manifest 不增加该字段。
## `summary.csv`
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index 90d808b6..aff92f37 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -25,3 +25,56 @@ python -m wavebench run template --print
当前 schema 的 canonical source 是 `src/wavebench/services/run_plan.py` 中的 step schema 以及 `python -m wavebench run schema` 的输出。模板名称与默认内容来自 template registry。页面中的计划片段只能说明一个任务,不能作为完整字段表或型号 capability 的来源。
实际执行步骤、连接预检和副作用见[执行一次实验](../how-to/run-an-experiment.md)。字段错误和 schema 变更的排查见[run plan 排错](../how-to/troubleshooting.md)。
+
+## 稳定 step ID 与离线分析
+
+每个 `[[steps]]` 都可以声明结构字段 `id`。ID 必须匹配 `^[a-z][a-z0-9_-]{0,63}$`,并在同一个 plan 内唯一;没有 ID 的既有 plan 无需迁移。`id` 不属于 step 的执行参数,因此不会出现在 `RunStep.fields` 中。
+
+`analysis.pipeline` 使用稳定 ID 引用同一 plan 内更早的 `scope.capture`:
+
+```toml
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+channel = 1
+save_npy = true
+on_failure = "continue"
+
+[[steps]]
+id = "spectrum_main"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
+ { op = "remove_dc" },
+ { op = "window", name = "hann" },
+ { op = "fft" },
+ { op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio"] },
+ { op = "export", name = "spectrum", formats = ["npy", "csv"] },
+]
+
+[steps.expect]
+peak_frequency_hz = { min = 990, max = 1010 }
+thd_ratio = { max = 0.05 }
+```
+
+来源 capture 必须显式设置 `save_npy = true`。首版不接受历史 capture package 路径,也不接受其他 step 类型或后续 step 作为来源。所有 `analysis.pipeline` 必须形成 plan 的连续末尾部分;硬件步骤、恢复、会话关闭和租约释放完成后,才会执行离线分析。分析 step 支持 `on_failure`,不支持 step 局部 `safety_gate`,也不会触发硬件安全门。
+
+## 首版算子合同
+
+`operations` 是有序的 TOML 内联表数组。首版允许以下算子:
+
+| 算子 | 参数 | 输入/输出域 |
+| --- | --- | --- |
+| `remove_dc` | 无 | 时域 → 时域 |
+| `detrend` | `method = "linear"` | 时域 → 时域 |
+| `window` | `name = "hann|hamming|blackman"` | 时域 → 时域 |
+| `fft` | 无 | 时域 → 频域 |
+| `measure` | 非空 `metrics` 数组 | 观察当前域,不改变数据 |
+| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
+
+变换算子各至多出现一次;`remove_dc` 与 `detrend` 互斥。去直流或去趋势必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
+
+时域指标为 `voltage_min_v`、`voltage_max_v`、`voltage_mean_v`、`voltage_rms_v` 和 `voltage_vpp_v`。频域指标为 `peak_frequency_hz`、`peak_amplitude_v`、`noise_floor_v`、`thd_ratio`,以及 `harmonic_2`~`harmonic_5` 的 `frequency_hz` 和 `amplitude_v` 字段。`[steps.expect]` 只能引用流水线中已显式选择的测量指标。
+
+完整示例见 `plans/example_signal_processing_pipeline.toml`。数值定义和派生产物结构见[运行产物 Reference](artifacts.md)。旧 `scope.capture` 的 `expect_fft` 保持原有算法,不由新流水线重定义。
diff --git a/docs/tech-doc-term-allowlist.json b/docs/tech-doc-term-allowlist.json
new file mode 100644
index 00000000..a0a6716a
--- /dev/null
+++ b/docs/tech-doc-term-allowlist.json
@@ -0,0 +1,3 @@
+{
+ "H5": "信号处理语境中的第五谐波标识,不表示 HTML5"
+}
diff --git a/plans/README.md b/plans/README.md
index 6446550d..cd492444 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -20,6 +20,7 @@ wavebench run check --plan plans/example_scope_expect_quality.toml
这些文件适合阅读和改成自己的 plan,但仍需要真实设备才能执行:
- `example_scope_expect_quality.toml`
+- `example_signal_processing_pipeline.toml`
- `example_source_scope_dmm_report.toml`
- `example_dmm_acv_source_smoke.toml`
- `demo_dg4202_10k_screenshot_report.toml`
diff --git a/plans/example_signal_processing_pipeline.toml b/plans/example_signal_processing_pipeline.toml
new file mode 100644
index 00000000..3084b368
--- /dev/null
+++ b/plans/example_signal_processing_pipeline.toml
@@ -0,0 +1,36 @@
+# Example WaveBench signal-processing run plan.
+# This plan performs a real scope acquisition. Confirm the input, probe ratio,
+# coupling, voltage range, and bench wiring before execution.
+
+[experiment]
+name = "example_signal_processing_pipeline"
+label = "example_signal_processing_pipeline"
+
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+channel = 1
+label = "signal_processing_source"
+points = "def"
+save_csv = false
+save_npy = true
+quality_gate = true
+auto_recover = true
+on_failure = "continue"
+
+[[steps]]
+id = "spectrum_main"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
+ { op = "remove_dc" },
+ { op = "window", name = "hann" },
+ { op = "fft" },
+ { op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio", "harmonic_2_frequency_hz", "harmonic_2_amplitude_v"] },
+ { op = "export", name = "spectrum", formats = ["npy", "csv"] },
+]
+
+[steps.expect]
+peak_frequency_hz = { min = 990, max = 1010 }
+thd_ratio = { max = 0.05 }
From 889ebf37a91ce054ffeb4a170b393851072b00a7 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 4 Sep 2026 15:01:49 +0800
Subject: [PATCH 03/30] docs: allow signal processing cutoff terminology
---
docs/tech-doc-term-allowlist.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/tech-doc-term-allowlist.json b/docs/tech-doc-term-allowlist.json
index a0a6716a..69c95b17 100644
--- a/docs/tech-doc-term-allowlist.json
+++ b/docs/tech-doc-term-allowlist.json
@@ -1,3 +1,4 @@
{
- "H5": "信号处理语境中的第五谐波标识,不表示 HTML5"
+ "H5": "信号处理语境中的第五谐波标识,不表示 HTML5",
+ "截止频率": "信号处理中的标准术语,表示滤波器通带与阻带的边界,不表示时间截至"
}
From 584b87bdc8103de3d2d0ca6ebe8f79511ebe25fe Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 4 Sep 2026 21:54:50 +0800
Subject: [PATCH 04/30] feat(run): add FIR pipeline filters
---
src/wavebench/data/signal_pipeline.py | 140 +++++++++++++++++++-
src/wavebench/report/html.py | 20 ++-
src/wavebench/services/run_pipeline.py | 100 +++++++++++++-
src/wavebench/services/run_plan.py | 75 ++++++++++-
src/wavebench/services/run_service.py | 6 +-
tests/test_execution_intent.py | 35 +++++
tests/test_report.py | 14 +-
tests/test_run_pipeline.py | 169 +++++++++++++++++++++++-
tests/test_run_plan_analysis.py | 101 +++++++++++++++
tests/test_run_service_analysis.py | 41 ++++++
tests/test_signal_pipeline.py | 172 +++++++++++++++++++++++++
11 files changed, 859 insertions(+), 14 deletions(-)
diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py
index fbe92a57..c6297149 100644
--- a/src/wavebench/data/signal_pipeline.py
+++ b/src/wavebench/data/signal_pipeline.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, Iterable
+from typing import Any, Iterable, Sequence
import numpy as np
@@ -26,6 +26,8 @@
for field in ("frequency_hz", "amplitude_v")
),
})
+ANALYSIS_FIR_RESPONSES = frozenset({"lowpass", "highpass", "bandpass", "bandstop"})
+ANALYSIS_FIR_MODES = frozenset({"causal", "zero_phase"})
SIGNIFICANT_PEAK_V = 1e-12
@@ -74,6 +76,18 @@ def as_array(self) -> np.ndarray:
)
+@dataclass(frozen=True)
+class FirFilterResult:
+ signal: TimeSignal
+ taps: np.ndarray
+ sample_interval_s: float
+ scipy_version: str
+
+ @property
+ def sample_rate_hz(self) -> float:
+ return 1.0 / self.sample_interval_s
+
+
def validate_waveform(data: Any) -> TimeSignal:
array = np.asarray(data)
if array.ndim != 2 or array.shape[1:] != (2,) or array.shape[0] < 1:
@@ -133,14 +147,87 @@ def window_signal(signal: TimeSignal, name: str) -> TimeSignal:
)
+def filter_fir(
+ signal: TimeSignal,
+ *,
+ response: str,
+ cutoff_hz: float | Sequence[float],
+ numtaps: int,
+ mode: str,
+) -> FirFilterResult:
+ if response not in ANALYSIS_FIR_RESPONSES:
+ raise DataError("analysis FIR response must be lowpass, highpass, bandpass, or bandstop")
+ if mode not in ANALYSIS_FIR_MODES:
+ raise DataError("analysis FIR mode must be causal or zero_phase")
+ if isinstance(numtaps, bool) or not isinstance(numtaps, int) or numtaps < 3 or numtaps % 2 == 0:
+ raise DataError("analysis FIR numtaps must be an odd integer >= 3")
+
+ cutoff = _fir_cutoff(response, cutoff_hz)
+ sample_interval = _uniform_sample_interval(signal, "analysis FIR filter")
+ sample_rate = 1.0 / sample_interval
+ nyquist = sample_rate / 2.0
+ cutoff_values = [cutoff] if isinstance(cutoff, float) else cutoff
+ if any(value >= nyquist for value in cutoff_values):
+ raise DataError(
+ f"analysis FIR cutoff_hz must be below Nyquist frequency {nyquist:.17g} Hz"
+ )
+
+ if mode == "zero_phase":
+ minimum_samples = 3 * numtaps + 1
+ if signal.voltage_v.size < minimum_samples:
+ raise DataError(
+ "analysis zero-phase FIR requires at least "
+ f"{minimum_samples} samples for numtaps={numtaps}"
+ )
+
+ try:
+ from scipy import __version__ as scipy_version
+ from scipy.signal import filtfilt, firwin, lfilter
+ except ImportError as exc: # pragma: no cover - RunService checks this before execution
+ raise DataError(
+ "analysis FIR filter requires SciPy; install WaveBench with `.[analysis]`"
+ ) from exc
+
+ try:
+ taps = np.asarray(
+ firwin(
+ numtaps,
+ cutoff,
+ window="hamming",
+ pass_zero=response,
+ scale=True,
+ fs=sample_rate,
+ ),
+ dtype=np.float64,
+ )
+ if mode == "causal":
+ voltage = lfilter(taps, [1.0], signal.voltage_v, axis=-1)
+ else:
+ voltage = filtfilt(
+ taps,
+ [1.0],
+ signal.voltage_v,
+ axis=-1,
+ padtype="odd",
+ padlen=3 * numtaps,
+ method="pad",
+ )
+ except ValueError as exc:
+ raise DataError(f"analysis FIR filter failed: {exc}") from exc
+
+ return FirFilterResult(
+ signal=_replace_voltage(signal, np.asarray(voltage, dtype=np.float64)),
+ taps=taps,
+ sample_interval_s=sample_interval,
+ scipy_version=scipy_version,
+ )
+
+
def fft_signal(signal: TimeSignal) -> FrequencySignal:
samples = int(signal.voltage_v.size)
if samples < 4:
raise DataError("analysis FFT requires at least four samples")
- intervals = np.diff(signal.time_s)
- sample_interval = float(np.median(intervals))
- if not np.allclose(intervals, sample_interval, rtol=1e-6, atol=0.0):
- raise DataError("analysis FFT requires uniformly sampled data")
+ sample_interval = _uniform_sample_interval(signal, "analysis FFT")
if not np.isfinite(signal.coherent_gain) or signal.coherent_gain <= 0:
raise DataError("analysis FFT requires a positive coherent gain")
@@ -260,6 +347,49 @@ def _replace_voltage(signal: TimeSignal, voltage: np.ndarray) -> TimeSignal:
)
+def _fir_cutoff(
+ response: str, cutoff_hz: float | Sequence[float]
+) -> float | list[float]:
+ if response in {"lowpass", "highpass"}:
+ if isinstance(cutoff_hz, Sequence) and not isinstance(cutoff_hz, (str, bytes)):
+ raise DataError(f"analysis FIR {response} cutoff_hz must be a positive number")
+ return _positive_finite(cutoff_hz, "analysis FIR cutoff_hz")
+
+ if (
+ not isinstance(cutoff_hz, Sequence)
+ or isinstance(cutoff_hz, (str, bytes))
+ or len(cutoff_hz) != 2
+ ):
+ raise DataError(f"analysis FIR {response} cutoff_hz must contain two frequencies")
+ values = [_positive_finite(value, "analysis FIR cutoff_hz") for value in cutoff_hz]
+ if values[1] <= values[0]:
+ raise DataError("analysis FIR cutoff_hz must be strictly increasing")
+ return values
+
+
+def _positive_finite(value: Any, name: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float, np.integer, np.floating)):
+ raise DataError(f"{name} must be a positive finite number")
+ result = float(value)
+ if not np.isfinite(result) or result <= 0:
+ raise DataError(f"{name} must be a positive finite number")
+ return result
+
+
+def _uniform_sample_interval(signal: TimeSignal, operation: str) -> float:
+ if signal.time_s.size < 2:
+ raise DataError(f"{operation} requires at least two samples")
+ intervals = np.diff(signal.time_s)
+ sample_interval = float(np.median(intervals))
+ if (
+ not np.isfinite(sample_interval)
+ or sample_interval <= 0
+ or not np.allclose(intervals, sample_interval, rtol=1e-6, atol=0.0)
+ ):
+ raise DataError(f"{operation} requires uniformly sampled data")
+ return sample_interval
+
+
def _selected_metrics(
metrics: Iterable[str], allowed: frozenset[str], domain: str
) -> list[str]:
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 2fcfbb86..ef931e75 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -1985,7 +1985,7 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str:
operations = pipeline.get("operations", [])
operation_names = (
[
- str(operation.get("op"))
+ _analysis_operation_label(operation)
for operation in operations
if isinstance(operation, dict) and operation.get("op")
]
@@ -2045,6 +2045,24 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str:
"""
+def _analysis_operation_label(operation: dict[str, Any]) -> str:
+ op = str(operation.get("op", ""))
+ if op != "filter":
+ return op
+ cutoff = operation.get("cutoff_hz")
+ if isinstance(cutoff, list):
+ cutoff_text = "–".join(_format_plain(value) for value in cutoff)
+ else:
+ cutoff_text = _format_plain(cutoff)
+ return (
+ "filter("
+ f"{operation.get('family', '')}, {operation.get('response', '')}, "
+ f"{cutoff_text} Hz, {operation.get('numtaps', '')} taps, "
+ f"{operation.get('mode', '')}"
+ ")"
+ )
+
+
def _analysis_file_link(
run: RunPackage,
output_dir: Path,
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index 49812cc7..297eaca5 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -2,6 +2,7 @@
import csv
from hashlib import sha256
+from importlib import import_module
import json
import os
from pathlib import Path
@@ -12,25 +13,49 @@
from wavebench.data.signal_pipeline import (
FrequencySignal,
+ FirFilterResult,
TimeSignal,
detrend_linear,
fft_signal,
+ filter_fir,
measure_frequency,
measure_time,
remove_dc,
validate_waveform,
window_signal,
)
-from wavebench.errors import DataError, error_envelope
+from wavebench.errors import ConfigError, DataError, error_envelope
from wavebench.services.run_analysis import evaluate_expect
from wavebench.services.run_artifacts import RunStepRecord
-from wavebench.services.run_plan import RunStep
+from wavebench.services.run_plan import RunPlan, RunStep
ANALYSIS_PIPELINE_SCHEMA = "wavebench.analysis_pipeline.v1"
ANALYSIS_METRICS_SCHEMA = "wavebench.analysis_metrics.v1"
+def ensure_analysis_pipeline_dependencies(plan: RunPlan) -> None:
+ needs_scipy = any(
+ operation["op"] == "filter" and operation["family"] == "fir"
+ for step in plan.steps
+ if step.kind == "analysis.pipeline"
+ for operation in step.fields["operations"]
+ )
+ if not needs_scipy:
+ return
+ try:
+ scipy_signal = import_module("scipy.signal")
+ except ImportError as exc:
+ raise ConfigError(
+ "analysis FIR filter requires SciPy; install WaveBench with `.[analysis]`"
+ ) from exc
+ if not all(callable(getattr(scipy_signal, name, None)) for name in ("firwin", "lfilter", "filtfilt")):
+ raise ConfigError(
+ "analysis FIR filter requires compatible SciPy signal support; "
+ "install WaveBench with `.[analysis]`"
+ )
+
+
def execute_analysis_pipeline(
*,
run_dir: Path,
@@ -55,6 +80,7 @@ def execute_analysis_pipeline(
warnings: list[str] = []
exports: list[dict[str, Any]] = []
stages: list[dict[str, Any]] = []
+ filters: list[dict[str, Any]] = []
sampling: dict[str, Any] | None = None
window: dict[str, Any] | None = None
source: dict[str, Any] = {
@@ -92,6 +118,25 @@ def execute_analysis_pipeline(
elif op == "detrend":
assert isinstance(signal, TimeSignal)
signal = detrend_linear(signal)
+ elif op == "filter":
+ assert isinstance(signal, TimeSignal)
+ result = filter_fir(
+ signal,
+ response=operation["response"],
+ cutoff_hz=operation["cutoff_hz"],
+ numtaps=operation["numtaps"],
+ mode=operation["mode"],
+ )
+ signal = result.signal
+ filter_metadata = _fir_filter_metadata(
+ operation_index, operation, result
+ )
+ filters.append(filter_metadata)
+ stage["filter"] = filter_metadata
+ sampling.update({
+ "sample_interval_s": result.sample_interval_s,
+ "sample_rate_hz": result.sample_rate_hz,
+ })
elif op == "window":
assert isinstance(signal, TimeSignal)
signal = window_signal(signal, operation["name"])
@@ -185,6 +230,8 @@ def execute_analysis_pipeline(
"thd_ratio": "rss_in_band_harmonic_2_through_5_over_fundamental_peak",
},
}
+ if filters:
+ manifest["filters"] = filters
if failed_stage is not None:
manifest["failed_stage"] = failed_stage
if failure is not None:
@@ -218,6 +265,55 @@ def execute_analysis_pipeline(
return artifact
+def _fir_filter_metadata(
+ operation_index: int,
+ operation: dict[str, Any],
+ result: FirFilterResult,
+) -> dict[str, Any]:
+ numtaps = operation["numtaps"]
+ mode = operation["mode"]
+ metadata: dict[str, Any] = {
+ "operation_index": operation_index,
+ "family": "fir",
+ "response": operation["response"],
+ "cutoff_hz": operation["cutoff_hz"],
+ "numtaps": numtaps,
+ "sample_rate_hz": result.sample_rate_hz,
+ "design_function": "scipy.signal.firwin",
+ "design_window": "hamming",
+ "scale": True,
+ "scipy_version": result.scipy_version,
+ "coefficients_sha256": sha256(
+ np.asarray(result.taps, dtype="
allowed_fields = {
"remove_dc": {"op"},
"detrend": {"op", "method"},
+ "filter": {"op", "family", "response", "cutoff_hz", "numtaps", "mode"},
"window": {"op", "name"},
"fft": {"op"},
"measure": {"op", "metrics"},
@@ -1281,6 +1284,7 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
}
required_fields = {
"detrend": {"method"},
+ "filter": {"family", "response", "cutoff_hz", "numtaps", "mode"},
"window": {"name"},
"measure": {"metrics"},
"export": {"name", "formats"},
@@ -1318,7 +1322,68 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
raise ConfigError(f"{prefix} operation {op!r} must appear before window")
transforms.add(op)
- if op == "detrend":
+ if op == "filter":
+ if domain == "frequency":
+ raise ConfigError(f"{prefix} operation 'filter' must appear before fft")
+ if "window" in transforms:
+ raise ConfigError(f"{prefix} operation 'filter' must appear before window")
+
+ if op == "filter":
+ family = raw_operation["family"]
+ if not isinstance(family, str) or family.strip().lower() != "fir":
+ raise ConfigError(f"{operation_prefix}.family must be 'fir'")
+ response = raw_operation["response"]
+ if (
+ not isinstance(response, str)
+ or response.strip().lower() not in ANALYSIS_FIR_RESPONSES
+ ):
+ raise ConfigError(
+ f"{operation_prefix}.response must be one of "
+ "lowpass, highpass, bandpass, bandstop"
+ )
+ response = response.strip().lower()
+ raw_cutoff = raw_operation["cutoff_hz"]
+ if response in {"lowpass", "highpass"}:
+ cutoff: float | list[float] = _analysis_positive_float(
+ raw_cutoff, f"{operation_prefix}.cutoff_hz"
+ )
+ else:
+ if not isinstance(raw_cutoff, list) or len(raw_cutoff) != 2:
+ raise ConfigError(
+ f"{operation_prefix}.cutoff_hz must be a two-element array "
+ f"for {response}"
+ )
+ cutoff = [
+ _analysis_positive_float(value, f"{operation_prefix}.cutoff_hz")
+ for value in raw_cutoff
+ ]
+ if cutoff[1] <= cutoff[0]:
+ raise ConfigError(
+ f"{operation_prefix}.cutoff_hz must be strictly increasing"
+ )
+ numtaps = raw_operation["numtaps"]
+ if (
+ isinstance(numtaps, bool)
+ or not isinstance(numtaps, int)
+ or numtaps < 3
+ or numtaps % 2 == 0
+ ):
+ raise ConfigError(
+ f"{operation_prefix}.numtaps must be an odd integer >= 3"
+ )
+ mode = raw_operation["mode"]
+ if not isinstance(mode, str) or mode.strip().lower() not in ANALYSIS_FIR_MODES:
+ raise ConfigError(
+ f"{operation_prefix}.mode must be 'causal' or 'zero_phase'"
+ )
+ operation.update({
+ "family": "fir",
+ "response": response,
+ "cutoff_hz": cutoff,
+ "numtaps": numtaps,
+ "mode": mode.strip().lower(),
+ })
+ elif op == "detrend":
method = raw_operation["method"]
if not isinstance(method, str) or method.lower() != "linear":
raise ConfigError(f"{operation_prefix}.method must be 'linear'")
@@ -1399,6 +1464,12 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
fields["expect"] = expect
+def _analysis_positive_float(value: Any, name: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise ConfigError(f"{name} must be a positive number")
+ return _positive_float(value, name)
+
+
def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) -> None:
for name in ("source_channel", "reference_channel", "response_channel"):
if name in fields:
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index dc3a15bd..5b2bb805 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -121,7 +121,10 @@
step_status,
)
from wavebench.services.run_plan import RunPlan, RunStep
-from wavebench.services.run_pipeline import execute_analysis_pipeline
+from wavebench.services.run_pipeline import (
+ ensure_analysis_pipeline_dependencies,
+ execute_analysis_pipeline,
+)
from wavebench.services.run_restore import restore_source_state, snapshot_source_state
from wavebench.services.run_safety import (
check_run_plan_safety_limits,
@@ -308,6 +311,7 @@ def verify(self, plan: RunPlan) -> list[RunPreflightRecord]:
def check(self, plan: RunPlan) -> None:
check_run_plan_safety_limits(plan, self.config.safety_limits)
reject_unsupported_steps(plan)
+ ensure_analysis_pipeline_dependencies(plan)
self._check_frequency_response_baselines(plan)
self._check_frequency_response_resumes(plan)
self._check_rf_source_access(plan)
diff --git a/tests/test_execution_intent.py b/tests/test_execution_intent.py
index 03379d4d..818a453a 100644
--- a/tests/test_execution_intent.py
+++ b/tests/test_execution_intent.py
@@ -91,6 +91,41 @@ def test_analysis_pipeline_intent_is_explicitly_offline_and_carries_step_ids() -
assert analysis["parameters"]["operations"][1] == {"op": "fft"}
+def test_analysis_pipeline_intent_carries_normalized_fir_design() -> None:
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "filter", family = "FIR", response = "BANDSTOP", cutoff_hz = [49, 51], numtaps = 101, mode = "ZERO_PHASE" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+]
+""",
+ )
+ )
+
+ intent = build_execution_intent(plan, make_config(tmp))
+
+ fir = intent.operations[1]["parameters"]["operations"][0]
+ assert fir == {
+ "op": "filter",
+ "family": "fir",
+ "response": "bandstop",
+ "cutoff_hz": [49.0, 51.0],
+ "numtaps": 101,
+ "mode": "zero_phase",
+ }
+
+
def test_step_id_changes_plan_and_intent_digest_without_changing_legacy_shape() -> None:
with TemporaryDirectory() as tmp:
legacy = _sleep_plan(tmp)
diff --git a/tests/test_report.py b/tests/test_report.py
index 201e7fd7..fba315f4 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -56,6 +56,14 @@ def test_run_report_has_independent_signal_processing_section_and_manifest_entri
"source_step": "capture_main",
"operations": [
{"op": "remove_dc"},
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "bandstop",
+ "cutoff_hz": [49.0, 51.0],
+ "numtaps": 101,
+ "mode": "zero_phase",
+ },
{"op": "fft"},
{"op": "measure", "metrics": ["peak_frequency_hz"]},
],
@@ -85,7 +93,11 @@ def test_run_report_has_independent_signal_processing_section_and_manifest_entri
self.assertIn("信号处理 / Signal processing ", html)
self.assertIn("spectrum_main", html)
self.assertIn("capture_main", html)
- self.assertIn("remove_dc → fft → measure", html)
+ self.assertIn(
+ "remove_dc → filter(fir, bandstop, 49–51 Hz, 101 taps, zero_phase) "
+ "→ fft → measure",
+ html,
+ )
self.assertIn("peak_frequency_hz=1000", html)
self.assertIn("thd_ratio=null", html)
self.assertIn("harmonic_5_out_of_band", html)
diff --git a/tests/test_run_pipeline.py b/tests/test_run_pipeline.py
index 30d1d356..fa5ea724 100644
--- a/tests/test_run_pipeline.py
+++ b/tests/test_run_pipeline.py
@@ -1,4 +1,5 @@
from hashlib import sha256
+import importlib.util
import json
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -7,9 +8,16 @@
import numpy as np
+from wavebench.errors import ConfigError
from wavebench.services.run_artifacts import RunStepRecord
-from wavebench.services.run_pipeline import execute_analysis_pipeline
-from wavebench.services.run_plan import RunStep
+from wavebench.services.run_pipeline import (
+ ensure_analysis_pipeline_dependencies,
+ execute_analysis_pipeline,
+)
+from wavebench.services.run_plan import RunStep, load_run_plan
+
+
+HAS_SCIPY = importlib.util.find_spec("scipy") is not None
class AnalysisPipelineArtifactTests(unittest.TestCase):
@@ -117,6 +125,7 @@ def test_success_writes_versioned_metrics_manifest_and_frequency_exports(self) -
self.assertEqual(manifest["schema"], "wavebench.analysis_pipeline.v1")
self.assertEqual(manifest["status"], "ok")
self.assertFalse(manifest["partial"])
+ self.assertNotIn("filters", manifest)
self.assertEqual(manifest["source"]["step"], "capture_main")
self.assertEqual(manifest["source"]["npy_sha256"], source_before)
self.assertEqual(manifest["window"]["name"], "hann")
@@ -136,6 +145,162 @@ def test_success_writes_versioned_metrics_manifest_and_frequency_exports(self) -
export_path = run_dir / export["path"]
self.assertEqual(export["sha256"], sha256(export_path.read_bytes()).hexdigest())
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_serial_fir_filters_write_metadata_and_time_export(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, source_npy = self.source(root, self.waveform())
+ source_before = sha256(source_npy.read_bytes()).hexdigest()
+ step = self.pipeline([
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "bandstop",
+ "cutoff_hz": [49.0, 51.0],
+ "numtaps": 31,
+ "mode": "zero_phase",
+ },
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "lowpass",
+ "cutoff_hz": 1000.0,
+ "numtaps": 33,
+ "mode": "causal",
+ },
+ {"op": "export", "name": "filtered", "formats": ["npy", "csv"]},
+ ])
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ processing = run_dir / "processing" / "01_spectrum_main"
+ manifest = json.loads((processing / "manifest.json").read_text(encoding="utf-8"))
+ filters = manifest["filters"]
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "ok")
+ self.assertEqual([item["operation_index"] for item in filters], [0, 1])
+ self.assertEqual([item["response"] for item in filters], ["bandstop", "lowpass"])
+ self.assertEqual(filters[0]["execution_function"], "scipy.signal.filtfilt")
+ self.assertEqual(filters[0]["effective_magnitude_response"], "single_pass_squared")
+ self.assertEqual(filters[0]["boundary"], "odd_extension")
+ self.assertEqual(filters[0]["padlen"], 93)
+ self.assertEqual(filters[1]["execution_function"], "scipy.signal.lfilter")
+ self.assertEqual(filters[1]["initial_state"], "zeros")
+ self.assertEqual(filters[1]["nominal_single_pass_group_delay_samples"], 16.0)
+ self.assertAlmostEqual(filters[0]["sample_rate_hz"], 10_000.0)
+ self.assertEqual(len(filters[0]["coefficients_sha256"]), 64)
+ self.assertEqual(manifest["stages"][1]["filter"], filters[0])
+ self.assertEqual(manifest["stages"][2]["filter"], filters[1])
+ self.assertEqual(np.load(processing / "exports" / "filtered.npy").shape, (1000, 2))
+ self.assertEqual(
+ (processing / "exports" / "filtered.csv")
+ .read_text(encoding="utf-8")
+ .splitlines()[0],
+ "time_s,voltage_v",
+ )
+ self.assertEqual(sha256(source_npy.read_bytes()).hexdigest(), source_before)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_completed_filter_metadata_survives_later_nyquist_failure(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, _ = self.source(root, self.waveform())
+ step = self.pipeline([
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "lowpass",
+ "cutoff_hz": 1000.0,
+ "numtaps": 31,
+ "mode": "causal",
+ },
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "lowpass",
+ "cutoff_hz": 5000.0,
+ "numtaps": 31,
+ "mode": "causal",
+ },
+ {"op": "export", "name": "filtered", "formats": ["npy"]},
+ ])
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ manifest = json.loads(
+ (run_dir / artifact["analysis_pipeline"]["manifest"]).read_text(
+ encoding="utf-8"
+ )
+ )
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "failed")
+ self.assertEqual(artifact["analysis_pipeline"]["failed_stage"], "operations[1]")
+ self.assertTrue(manifest["partial"])
+ self.assertEqual(len(manifest["filters"]), 1)
+ self.assertEqual(manifest["stages"][2]["status"], "failed")
+ self.assertEqual(manifest["stages"][3]["status"], "skipped")
+ self.assertIn("below Nyquist", manifest["error"]["message"])
+
+ def test_fir_dependency_check_is_conditional_and_actionable(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ no_filter_path = root / "no_filter.toml"
+ no_filter_path.write_text(
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [{ op = "measure", metrics = ["voltage_mean_v"] }]
+""",
+ encoding="utf-8",
+ )
+ fir_path = root / "fir.toml"
+ fir_path.write_text(
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "filter", family = "fir", response = "lowpass", cutoff_hz = 1000, numtaps = 31, mode = "causal" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+]
+""",
+ encoding="utf-8",
+ )
+
+ with patch("wavebench.services.run_pipeline.import_module") as load_dependency:
+ ensure_analysis_pipeline_dependencies(load_run_plan(no_filter_path))
+ load_dependency.assert_not_called()
+
+ with patch(
+ "wavebench.services.run_pipeline.import_module",
+ side_effect=ImportError("scipy unavailable"),
+ ):
+ with self.assertRaisesRegex(ConfigError, r"\.\[analysis\]"):
+ ensure_analysis_pipeline_dependencies(load_run_plan(fir_path))
+
def test_source_expectation_failure_still_allows_complete_npy(self) -> None:
with TemporaryDirectory() as tmp:
root = Path(tmp)
diff --git a/tests/test_run_plan_analysis.py b/tests/test_run_plan_analysis.py
index f68fbedc..069b0c7c 100644
--- a/tests/test_run_plan_analysis.py
+++ b/tests/test_run_plan_analysis.py
@@ -183,6 +183,38 @@ def test_pipeline_operator_parameters_and_domains_are_strict(self) -> None:
"unknown field 'method'": '{ op = "remove_dc", method = "linear" }',
"method must be 'linear'": '{ op = "detrend", method = "constant" }',
"name must be one of": '{ op = "window", name = "bartlett" }',
+ "filter missing required field 'mode'": (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31 }'
+ ),
+ "family must be 'fir'": (
+ '{ op = "filter", family = "iir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal" }'
+ ),
+ "response must be one of": (
+ '{ op = "filter", family = "fir", response = "multiband", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal" }'
+ ),
+ "cutoff_hz must be a positive number": (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = [1000, 2000], numtaps = 31, mode = "causal" }'
+ ),
+ "two-element array": (
+ '{ op = "filter", family = "fir", response = "bandpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal" }'
+ ),
+ "strictly increasing": (
+ '{ op = "filter", family = "fir", response = "bandstop", '
+ 'cutoff_hz = [2000, 1000], numtaps = 31, mode = "causal" }'
+ ),
+ "numtaps must be an odd integer": (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 32, mode = "causal" }'
+ ),
+ "mode must be 'causal' or 'zero_phase'": (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "automatic" }'
+ ),
"metrics must be a non-empty array": '{ op = "measure", metrics = [] }',
"metric 'peak_frequency_hz' requires frequency-domain data": (
'{ op = "measure", metrics = ["peak_frequency_hz"] }'
@@ -201,6 +233,63 @@ def test_pipeline_operator_parameters_and_domains_are_strict(self) -> None:
with self.assertRaisesRegex(ConfigError, message):
load_run_plan(self.write_plan(self.analysis_plan(operations)))
+ def test_pipeline_normalizes_all_fir_responses_and_allows_serial_filters(self) -> None:
+ plan = load_run_plan(
+ self.write_plan(
+ self.analysis_plan(
+ """
+ { op = "filter", family = "FIR", response = "LOWPASS", cutoff_hz = 1000, numtaps = 31, mode = "CAUSAL" },
+ { op = "filter", family = "fir", response = "highpass", cutoff_hz = 100, numtaps = 33, mode = "zero_phase" },
+ { op = "filter", family = "fir", response = "bandpass", cutoff_hz = [100, 1000], numtaps = 35, mode = "causal" },
+ { op = "filter", family = "fir", response = "bandstop", cutoff_hz = [49, 51], numtaps = 37, mode = "zero_phase" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+"""
+ )
+ )
+ )
+
+ filters = plan.steps[1].fields["operations"][:4]
+ self.assertEqual(
+ [operation["response"] for operation in filters],
+ ["lowpass", "highpass", "bandpass", "bandstop"],
+ )
+ self.assertEqual(filters[0]["cutoff_hz"], 1000.0)
+ self.assertEqual(filters[2]["cutoff_hz"], [100.0, 1000.0])
+ self.assertEqual(
+ [operation["mode"] for operation in filters],
+ ["causal", "zero_phase", "causal", "zero_phase"],
+ )
+
+ def test_pipeline_fir_numeric_parameters_are_strict(self) -> None:
+ cases = (
+ (
+ "cutoff_hz must be a positive number",
+ 'cutoff_hz = "1000", numtaps = 31',
+ ),
+ ("cutoff_hz must be finite", "cutoff_hz = nan, numtaps = 31"),
+ ("cutoff_hz must be > 0", "cutoff_hz = 0, numtaps = 31"),
+ ("numtaps must be an odd integer", "cutoff_hz = 1000, numtaps = 31.0"),
+ ("numtaps must be an odd integer", "cutoff_hz = 1000, numtaps = 1"),
+ ("numtaps must be an odd integer", "cutoff_hz = 1000, numtaps = true"),
+ )
+ for message, parameters in cases:
+ with self.subTest(parameters=parameters):
+ operations = (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ f'{parameters}, mode = "causal" }}, '
+ '{ op = "export", name = "filtered", formats = ["npy"] }'
+ )
+ with self.assertRaisesRegex(ConfigError, message):
+ load_run_plan(self.write_plan(self.analysis_plan(operations)))
+
+ unknown = (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal", taps = [1] }, '
+ '{ op = "export", name = "filtered", formats = ["npy"] }'
+ )
+ with self.assertRaisesRegex(ConfigError, "unknown field 'taps'"):
+ load_run_plan(self.write_plan(self.analysis_plan(unknown)))
+
def test_pipeline_rejects_duplicate_and_misordered_configuration(self) -> None:
cases = {
"at most once": (
@@ -219,6 +308,18 @@ def test_pipeline_rejects_duplicate_and_misordered_configuration(self) -> None:
'{ op = "fft" }, { op = "window", name = "hann" }, '
'{ op = "export", name = "data", formats = ["npy"] }'
),
+ "filter.*must appear before window": (
+ '{ op = "window", name = "hann" }, '
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal" }, '
+ '{ op = "export", name = "data", formats = ["npy"] }'
+ ),
+ "filter.*must appear before fft": (
+ '{ op = "fft" }, '
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal" }, '
+ '{ op = "export", name = "data", formats = ["npy"] }'
+ ),
"duplicate metric": (
'{ op = "measure", metrics = ["voltage_mean_v"] }, '
'{ op = "measure", metrics = ["voltage_mean_v"] }'
diff --git a/tests/test_run_service_analysis.py b/tests/test_run_service_analysis.py
index 3540b6ab..4ab79d30 100644
--- a/tests/test_run_service_analysis.py
+++ b/tests/test_run_service_analysis.py
@@ -1,5 +1,6 @@
from contextlib import contextmanager
import json
+from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
@@ -296,3 +297,43 @@ def test_legacy_plan_does_not_enter_analysis_phase() -> None:
run = json.loads(result.run_json_path.read_text(encoding="utf-8"))
assert len(run["steps"]) == 1
assert "id" not in run["steps"][0]
+
+
+def test_missing_fir_dependency_is_rejected_before_instrument_lifecycle() -> None:
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "filter", family = "fir", response = "lowpass", cutoff_hz = 1000, numtaps = 31, mode = "causal" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+]
+""",
+ )
+ )
+ service = RunService(config=make_config(tmp), logger=CommandLogger())
+
+ with patch(
+ "wavebench.services.run_service.ensure_analysis_pipeline_dependencies",
+ side_effect=ConfigError(
+ "analysis FIR filter requires SciPy; install WaveBench with `.[analysis]`"
+ ),
+ ), patch.object(service, "_run_instrument_services") as open_services:
+ try:
+ service.run(plan)
+ except ConfigError as exc:
+ assert ".[analysis]" in str(exc)
+ else: # pragma: no cover - assertion helper without pytest dependency
+ raise AssertionError("missing FIR dependency should be rejected")
+
+ open_services.assert_not_called()
+ assert not (Path(tmp) / "data" / "runs").exists()
diff --git a/tests/test_signal_pipeline.py b/tests/test_signal_pipeline.py
index 736b9d92..7ffc9426 100644
--- a/tests/test_signal_pipeline.py
+++ b/tests/test_signal_pipeline.py
@@ -1,4 +1,6 @@
+import importlib.util
import unittest
+from unittest.mock import patch
import numpy as np
@@ -7,6 +9,7 @@
FrequencySignal,
detrend_linear,
fft_signal,
+ filter_fir,
measure_frequency,
measure_time,
remove_dc,
@@ -16,6 +19,9 @@
from wavebench.errors import DataError
+HAS_SCIPY = importlib.util.find_spec("scipy") is not None
+
+
class SignalPipelineTests(unittest.TestCase):
def waveform(self, samples: int = 1000, sample_rate_hz: float = 10_000.0) -> np.ndarray:
time_s = np.arange(samples, dtype=float) / sample_rate_hz
@@ -80,6 +86,172 @@ def test_numpy_window_definitions_and_coherent_gain(self) -> None:
self.assertEqual(result.coherent_gain, float(np.mean(expected)))
self.assertEqual(result.window_name, name)
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_causal_fir_matches_firwin_and_preserves_time_axis(self) -> None:
+ from scipy.signal import firwin
+
+ samples = 64
+ sample_rate_hz = 1000.0
+ time_s = np.arange(samples, dtype=float) / sample_rate_hz
+ voltage_v = np.zeros(samples)
+ voltage_v[0] = 1.0
+ signal = validate_waveform(np.column_stack((time_s, voltage_v)))
+
+ result = filter_fir(
+ signal,
+ response="lowpass",
+ cutoff_hz=100.0,
+ numtaps=11,
+ mode="causal",
+ )
+ expected_taps = firwin(
+ 11,
+ 100.0,
+ window="hamming",
+ pass_zero="lowpass",
+ scale=True,
+ fs=result.sample_rate_hz,
+ )
+
+ np.testing.assert_array_equal(result.signal.time_s, time_s)
+ np.testing.assert_allclose(result.taps, expected_taps, rtol=0, atol=0)
+ np.testing.assert_allclose(result.signal.voltage_v[:11], expected_taps, atol=1e-15)
+ np.testing.assert_allclose(result.signal.voltage_v[11:], 0.0, atol=1e-15)
+ self.assertAlmostEqual(result.sample_rate_hz, sample_rate_hz)
+ self.assertTrue(result.scipy_version)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_zero_phase_fir_fixes_padding_and_minimum_samples(self) -> None:
+ from scipy.signal import filtfilt
+
+ numtaps = 11
+ sample_rate_hz = 1000.0
+ too_short = self.waveform(3 * numtaps, sample_rate_hz)
+ with self.assertRaisesRegex(DataError, "at least 34 samples"):
+ filter_fir(
+ validate_waveform(too_short),
+ response="lowpass",
+ cutoff_hz=100.0,
+ numtaps=numtaps,
+ mode="zero_phase",
+ )
+
+ minimum = validate_waveform(self.waveform(3 * numtaps + 1, sample_rate_hz))
+ with patch("scipy.signal.filtfilt", wraps=filtfilt) as apply_filter:
+ result = filter_fir(
+ minimum,
+ response="lowpass",
+ cutoff_hz=100.0,
+ numtaps=numtaps,
+ mode="zero_phase",
+ )
+
+ self.assertEqual(result.signal.voltage_v.size, 3 * numtaps + 1)
+ self.assertTrue(np.all(np.isfinite(result.signal.voltage_v)))
+ self.assertEqual(
+ apply_filter.call_args.kwargs,
+ {
+ "axis": -1,
+ "padtype": "odd",
+ "padlen": 3 * numtaps,
+ "method": "pad",
+ },
+ )
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_zero_phase_fir_squares_the_single_pass_magnitude_response(self) -> None:
+ sample_rate_hz = 10_000.0
+ samples = 10_000
+ frequency_hz = 1000.0
+ time_s = np.arange(samples, dtype=float) / sample_rate_hz
+ voltage_v = np.sin(2 * np.pi * frequency_hz * time_s)
+ signal = validate_waveform(np.column_stack((time_s, voltage_v)))
+
+ causal = filter_fir(
+ signal,
+ response="lowpass",
+ cutoff_hz=frequency_hz,
+ numtaps=101,
+ mode="causal",
+ ).signal.voltage_v
+ zero_phase = filter_fir(
+ signal,
+ response="lowpass",
+ cutoff_hz=frequency_hz,
+ numtaps=101,
+ mode="zero_phase",
+ ).signal.voltage_v
+ interior = slice(2000, 8000)
+ basis = np.exp(-2j * np.pi * frequency_hz * time_s[interior])
+ causal_amplitude = 2 * abs(np.dot(causal[interior], basis)) / basis.size
+ zero_phase_amplitude = 2 * abs(np.dot(zero_phase[interior], basis)) / basis.size
+
+ self.assertAlmostEqual(zero_phase_amplitude, causal_amplitude**2, places=10)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_fir_supports_low_high_bandpass_and_bandstop(self) -> None:
+ sample_rate_hz = 10_000.0
+ samples = 6000
+ frequencies = (500.0, 1500.0, 3000.0)
+ time_s = np.arange(samples, dtype=float) / sample_rate_hz
+ voltage_v = sum(np.sin(2 * np.pi * frequency * time_s) for frequency in frequencies)
+ signal = validate_waveform(np.column_stack((time_s, voltage_v)))
+ cases = {
+ "lowpass": (1000.0, {500.0}, {3000.0}),
+ "highpass": (2000.0, {3000.0}, {500.0}),
+ "bandpass": ([1000.0, 2000.0], {1500.0}, {500.0, 3000.0}),
+ "bandstop": ([1000.0, 2000.0], {500.0, 3000.0}, {1500.0}),
+ }
+
+ for response, (cutoff_hz, passed, rejected) in cases.items():
+ with self.subTest(response=response):
+ filtered = filter_fir(
+ signal,
+ response=response,
+ cutoff_hz=cutoff_hz,
+ numtaps=101,
+ mode="zero_phase",
+ ).signal.voltage_v
+ interior = slice(500, -500)
+ interior_time = time_s[interior]
+ amplitudes = {
+ frequency: 2
+ * abs(
+ np.dot(
+ filtered[interior],
+ np.exp(-2j * np.pi * frequency * interior_time),
+ )
+ )
+ / interior_time.size
+ for frequency in frequencies
+ }
+ for frequency in passed:
+ self.assertGreater(amplitudes[frequency], 0.8)
+ for frequency in rejected:
+ self.assertLess(amplitudes[frequency], 0.01)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_fir_rejects_nonuniform_sampling_and_nyquist_cutoff(self) -> None:
+ data = self.waveform(1000, 10_000.0)
+ data[500:, 0] += 1e-5
+ with self.assertRaisesRegex(DataError, "uniformly sampled"):
+ filter_fir(
+ validate_waveform(data),
+ response="lowpass",
+ cutoff_hz=1000.0,
+ numtaps=31,
+ mode="causal",
+ )
+
+ with self.assertRaisesRegex(DataError, "below Nyquist"):
+ filter_fir(
+ validate_waveform(self.waveform(1000, 10_000.0)),
+ response="lowpass",
+ cutoff_hz=5000.0,
+ numtaps=31,
+ mode="causal",
+ )
+
def test_even_fft_preserves_dc_and_nyquist_without_double_scaling(self) -> None:
samples = 8
indices = np.arange(samples)
From 9be5d180069e8097eda8e0b2a1a8614c3d6541e6 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Fri, 4 Sep 2026 21:55:02 +0800
Subject: [PATCH 05/30] docs(run): document FIR pipeline filters
---
docs/reference/artifacts.md | 6 ++--
docs/reference/generated/run-schema.md | 2 +-
docs/reference/run-schema.md | 29 +++++++++++++++++--
docs/tech-doc-term-allowlist.json | 1 +
plans/README.md | 2 ++
plans/example_signal_processing_pipeline.toml | 1 +
6 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 372fdc0f..9cb4adca 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -52,7 +52,9 @@
.csv
```
-`manifest.json` 的 schema 为 `wavebench.analysis_pipeline.v1`。它记录来源 step 及状态、来源 capture package/metadata/NPY 的 run-relative POSIX 路径、原始 NPY 的 SHA-256、规范化算子、逐阶段状态、采样信息、窗与相干增益、警告、导出、数值定义和结构化错误。某个后续算子失败时,已经完成的导出会保留,并由 `partial` 和 `failed_stage` 标明部分结果。
+`manifest.json` 的 schema 为 `wavebench.analysis_pipeline.v1`。它记录来源 step 及状态、来源 capture package/metadata/NPY 的 run-relative POSIX 路径、原始 NPY 的 SHA-256、规范化算子、逐阶段状态、采样信息、窗与相干增益、警告、导出、数值定义和结构化错误。某个后续算子失败时,已经完成的导出和 FIR stage 元数据会保留,并由 `partial` 和 `failed_stage` 标明部分结果。
+
+存在成功 FIR stage 时,manifest 条件性增加 `filters` 数组,并在对应 stage 中记录同一份滤波元数据。每项包括 operation index、响应、截止频率、tap 数、实际采样率、SciPy 版本、设计窗、缩放方式、执行函数、遍数、边界规则和单程名义群延迟。`coefficients_sha256` 是实际系数转为 little-endian float64 连续字节后的 SHA-256,可用于核对设计结果;manifest 不写入完整系数数组。零相位 stage 另外记录固定的 `method`、`padtype` 和 `padlen`。没有成功 FIR stage 的既有流水线不增加 `filters` 字段。
`metrics.json` 的 schema 为 `wavebench.analysis_metrics.v1`,结构如下:
@@ -72,7 +74,7 @@
频域 `amplitude_v` 是单边峰值幅度,不是 RMS。`noise_floor_v` 是排除 DC 与主峰后的非 DC 幅度 bin 中位数,表示每 bin 峰值幅度,不表示积分噪声。THD 使用 Nyquist 范围内的 H2~H5。
-HTML 报告在存在分析 step 时增加「信号处理 / Signal processing」区域,并在报告 manifest 中条件性增加 `analysis_pipelines`。没有分析 step 的旧报告 manifest 不增加该字段。
+HTML 报告在存在分析 step 时增加「信号处理 / Signal processing」区域;FIR 算子会同时显示 family、响应、截止频率、tap 数和执行模式。报告 manifest 条件性增加 `analysis_pipelines`,没有分析 step 的旧报告 manifest 不增加该字段。
## `summary.csv`
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index f01831ba..273d7e51 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -18,7 +18,7 @@ Supported step kinds:
- analysis.pipeline
required: source, operations
optional : expect, on_failure
- note : Process one earlier scope.capture NPY after all hardware sessions close. Uses a validated linear NumPy operator list and never opens an instrument.
+ note : Process one earlier scope.capture NPY after all hardware sessions close. Uses a validated linear operator list, checks optional dependencies on demand, and never opens an instrument.
- dmm.read
required: -
optional : expect, function, on_failure, safety_gate
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index aff92f37..6af846c3 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -47,6 +47,7 @@ source = { step = "capture_main" }
operations = [
{ op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
{ op = "remove_dc" },
+ { op = "filter", family = "fir", response = "bandstop", cutoff_hz = [49.0, 51.0], numtaps = 101, mode = "zero_phase" },
{ op = "window", name = "hann" },
{ op = "fft" },
{ op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio"] },
@@ -60,20 +61,42 @@ thd_ratio = { max = 0.05 }
来源 capture 必须显式设置 `save_npy = true`。首版不接受历史 capture package 路径,也不接受其他 step 类型或后续 step 作为来源。所有 `analysis.pipeline` 必须形成 plan 的连续末尾部分;硬件步骤、恢复、会话关闭和租约释放完成后,才会执行离线分析。分析 step 支持 `on_failure`,不支持 step 局部 `safety_gate`,也不会触发硬件安全门。
-## 首版算子合同
+## 算子合同
-`operations` 是有序的 TOML 内联表数组。首版允许以下算子:
+`operations` 是有序的 TOML 内联表数组。当前允许以下算子:
| 算子 | 参数 | 输入/输出域 |
| --- | --- | --- |
| `remove_dc` | 无 | 时域 → 时域 |
| `detrend` | `method = "linear"` | 时域 → 时域 |
+| `filter` | `family = "fir"`、`response`、`cutoff_hz`、奇数 `numtaps`、`mode` | 时域 → 时域 |
| `window` | `name = "hann|hamming|blackman"` | 时域 → 时域 |
| `fft` | 无 | 时域 → 频域 |
| `measure` | 非空 `metrics` 数组 | 观察当前域,不改变数据 |
| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
-变换算子各至多出现一次;`remove_dc` 与 `detrend` 互斥。去直流或去趋势必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
+`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
+
+### FIR 滤波
+
+FIR 算子同时支持四种响应:
+
+- `lowpass`/`highpass` 使用单个有限正数 `cutoff_hz`。
+- `bandpass`/`bandstop` 使用两个有限正数组成的严格递增数组 `cutoff_hz`。
+- `numtaps` 必须是大于等于 3 的奇数。
+- `mode` 必须显式设置为 `causal` 或 `zero_phase`。
+
+实际采样率由来源 NPY 的时间轴计算。时间轴必须等间隔,所有截止频率必须严格低于 Nyquist 频率;这两个条件依赖采集结果,因此在离线分析 step 执行时校验并形成结构化产物。
+
+`causal` 使用 Hamming 设计窗的 `scipy.signal.firwin` 和零初始状态的单向 `lfilter`,保留起始暂态及名义群延迟。`zero_phase` 固定使用 `filtfilt` 的奇延拓、`method = "pad"` 和 `padlen = 3 * numtaps`,因此至少需要 `3 * numtaps + 1` 个采样点。零相位模式的有效幅频响应为单向 FIR 幅频响应的平方;两种模式都保持样本数和时间轴,不自动裁剪或补偿时间。
+
+FIR 需要可选分析依赖:
+
+```bash
+python -m pip install -e ".[analysis]"
+```
+
+只有 Plan 包含 FIR 算子时,`run check` 才检查 SciPy;缺少依赖时会在租约、session 和仪器 I/O 之前失败。未使用 FIR 的 NumPy 流水线不需要 SciPy。
时域指标为 `voltage_min_v`、`voltage_max_v`、`voltage_mean_v`、`voltage_rms_v` 和 `voltage_vpp_v`。频域指标为 `peak_frequency_hz`、`peak_amplitude_v`、`noise_floor_v`、`thd_ratio`,以及 `harmonic_2`~`harmonic_5` 的 `frequency_hz` 和 `amplitude_v` 字段。`[steps.expect]` 只能引用流水线中已显式选择的测量指标。
diff --git a/docs/tech-doc-term-allowlist.json b/docs/tech-doc-term-allowlist.json
index 69c95b17..a9228b85 100644
--- a/docs/tech-doc-term-allowlist.json
+++ b/docs/tech-doc-term-allowlist.json
@@ -1,4 +1,5 @@
{
"H5": "信号处理语境中的第五谐波标识,不表示 HTML5",
+ "截止点": "信号处理中的标准术语,表示滤波器响应定义中的频率边界,不表示时间截至",
"截止频率": "信号处理中的标准术语,表示滤波器通带与阻带的边界,不表示时间截至"
}
diff --git a/plans/README.md b/plans/README.md
index cd492444..fcfe92d6 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -13,6 +13,8 @@ wavebench run check --plan plans/example_scope_expect_quality.toml
`run verify` 会读取配置并查询相关仪器,适合执行前预检。`run plan` 会进行真实实验,执行前应确认接线、scope coupling、输出状态、保护限值和 `[restore]` 范围。`run report` 和 `run calibrate` 读取已有产物,不需要再次连接仪器;校准相关拟合需要安装 `.[analysis]`。
+`example_signal_processing_pipeline.toml` 包含 FIR 带阻和零相位处理,也需要安装 `.[analysis]`。`run check` 只在 Plan 选择需要 SciPy 的算子时检查该可选依赖。
+
## 计划分类
### 通用示例
diff --git a/plans/example_signal_processing_pipeline.toml b/plans/example_signal_processing_pipeline.toml
index 3084b368..60e6f0b8 100644
--- a/plans/example_signal_processing_pipeline.toml
+++ b/plans/example_signal_processing_pipeline.toml
@@ -25,6 +25,7 @@ source = { step = "capture_main" }
operations = [
{ op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
{ op = "remove_dc" },
+ { op = "filter", family = "fir", response = "bandstop", cutoff_hz = [49.0, 51.0], numtaps = 101, mode = "zero_phase" },
{ op = "window", name = "hann" },
{ op = "fft" },
{ op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio", "harmonic_2_frequency_hz", "harmonic_2_amplitude_v"] },
From 35a6613f327d097859aedbd44cb88f9ae93f4f22 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 11:05:46 +0800
Subject: [PATCH 06/30] feat(run): add SOS IIR pipeline filters
---
docs/reference/artifacts.md | 8 +-
docs/reference/run-schema.md | 34 ++-
plans/README.md | 2 +-
plans/example_signal_processing_pipeline.toml | 1 +
src/wavebench/data/signal_pipeline.py | 221 ++++++++++++++-
src/wavebench/report/html.py | 12 +-
src/wavebench/services/run_pipeline.py | 146 ++++++++--
src/wavebench/services/run_plan.py | 208 +++++++++++---
tests/test_execution_intent.py | 38 +++
tests/test_report.py | 12 +
tests/test_run_pipeline.py | 157 ++++++++++-
tests/test_run_plan_analysis.py | 117 +++++++-
tests/test_run_service_analysis.py | 8 +-
tests/test_signal_pipeline.py | 263 ++++++++++++++++++
14 files changed, 1139 insertions(+), 88 deletions(-)
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 9cb4adca..adf281ea 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -52,9 +52,11 @@
.csv
```
-`manifest.json` 的 schema 为 `wavebench.analysis_pipeline.v1`。它记录来源 step 及状态、来源 capture package/metadata/NPY 的 run-relative POSIX 路径、原始 NPY 的 SHA-256、规范化算子、逐阶段状态、采样信息、窗与相干增益、警告、导出、数值定义和结构化错误。某个后续算子失败时,已经完成的导出和 FIR stage 元数据会保留,并由 `partial` 和 `failed_stage` 标明部分结果。
+`manifest.json` 的 schema 为 `wavebench.analysis_pipeline.v1`。它记录来源 step 及状态、来源 capture package/metadata/NPY 的 run-relative POSIX 路径、原始 NPY 的 SHA-256、规范化算子、逐阶段状态、采样信息、窗与相干增益、警告、导出、数值定义和结构化错误。某个后续算子失败时,已经完成的导出和 filter stage 元数据会保留,并由 `partial` 和 `failed_stage` 标明部分结果。
-存在成功 FIR stage 时,manifest 条件性增加 `filters` 数组,并在对应 stage 中记录同一份滤波元数据。每项包括 operation index、响应、截止频率、tap 数、实际采样率、SciPy 版本、设计窗、缩放方式、执行函数、遍数、边界规则和单程名义群延迟。`coefficients_sha256` 是实际系数转为 little-endian float64 连续字节后的 SHA-256,可用于核对设计结果;manifest 不写入完整系数数组。零相位 stage 另外记录固定的 `method`、`padtype` 和 `padlen`。没有成功 FIR stage 的既有流水线不增加 `filters` 字段。
+存在成功 filter stage 时,manifest 条件性增加 `filters` 数组,并在对应 stage 中记录同一份滤波元数据。FIR 项包括响应、截止频率、tap 数、实际采样率、SciPy 版本、设计窗、缩放方式、执行函数、遍数、边界规则和单程名义群延迟。`coefficients_sha256` 是实际 FIR 系数转为 little-endian float64 连续字节后的 SHA-256。零相位 FIR 另外记录固定的 `method`、`padtype` 和 `padlen`。
+
+IIR 项记录 design、响应、截止频率、原型阶数、变换后的数字滤波器阶数、实际采样率、设计函数、SOS section 数、SciPy 版本、稳定性、最大极点模、执行函数、遍数和边界规则。`sos_sha256` 是实际 SOS 转为 little-endian float64 连续字节后的 SHA-256;Chebyshev/Elliptic 的纹波或衰减参数只在适用时出现,零相位 IIR 另外记录实际 `padtype` 和 `padlen`。manifest 不写入完整 FIR 系数或 SOS。没有成功 filter stage 的既有流水线不增加 `filters` 字段。
`metrics.json` 的 schema 为 `wavebench.analysis_metrics.v1`,结构如下:
@@ -74,7 +76,7 @@
频域 `amplitude_v` 是单边峰值幅度,不是 RMS。`noise_floor_v` 是排除 DC 与主峰后的非 DC 幅度 bin 中位数,表示每 bin 峰值幅度,不表示积分噪声。THD 使用 Nyquist 范围内的 H2~H5。
-HTML 报告在存在分析 step 时增加「信号处理 / Signal processing」区域;FIR 算子会同时显示 family、响应、截止频率、tap 数和执行模式。报告 manifest 条件性增加 `analysis_pipelines`,没有分析 step 的旧报告 manifest 不增加该字段。
+HTML 报告在存在分析 step 时增加「信号处理 / Signal processing」区域。FIR 算子显示 family、响应、截止频率、tap 数和执行模式;IIR 算子显示 family、响应、截止频率、design、order 和执行模式。报告 manifest 条件性增加 `analysis_pipelines`,没有分析 step 的旧报告 manifest 不增加该字段。
## `summary.csv`
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index 6af846c3..bfcee38e 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -48,6 +48,7 @@ operations = [
{ op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
{ op = "remove_dc" },
{ op = "filter", family = "fir", response = "bandstop", cutoff_hz = [49.0, 51.0], numtaps = 101, mode = "zero_phase" },
+ { op = "filter", family = "iir", design = "butterworth", response = "highpass", cutoff_hz = 20.0, order = 4, mode = "causal" },
{ op = "window", name = "hann" },
{ op = "fft" },
{ op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio"] },
@@ -69,13 +70,13 @@ thd_ratio = { max = 0.05 }
| --- | --- | --- |
| `remove_dc` | 无 | 时域 → 时域 |
| `detrend` | `method = "linear"` | 时域 → 时域 |
-| `filter` | `family = "fir"`、`response`、`cutoff_hz`、奇数 `numtaps`、`mode` | 时域 → 时域 |
+| `filter` | FIR 或 IIR 的判别式设计参数 | 时域 → 时域 |
| `window` | `name = "hann|hamming|blackman"` | 时域 → 时域 |
| `fft` | 无 | 时域 → 频域 |
| `measure` | 非空 `metrics` 数组 | 观察当前域,不改变数据 |
| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
-`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
+`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR/IIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
### FIR 滤波
@@ -96,7 +97,34 @@ FIR 需要可选分析依赖:
python -m pip install -e ".[analysis]"
```
-只有 Plan 包含 FIR 算子时,`run check` 才检查 SciPy;缺少依赖时会在租约、session 和仪器 I/O 之前失败。未使用 FIR 的 NumPy 流水线不需要 SciPy。
+Plan 包含 FIR 或 IIR 算子时,`run check` 检查 SciPy;缺少依赖时会在租约、session 和仪器 I/O 之前失败。没有 filter 的 NumPy 流水线不需要 SciPy。
+
+### IIR 滤波
+
+IIR 继续使用同一个 `filter` 算子。`family = "iir"` 时必须声明 `design` 和 `order`:
+
+```toml
+{ op = "filter", family = "iir", design = "butterworth", response = "lowpass", cutoff_hz = 5000.0, order = 4, mode = "causal" }
+{ op = "filter", family = "iir", design = "chebyshev1", response = "highpass", cutoff_hz = 100.0, order = 4, ripple_db = 1.0, mode = "zero_phase" }
+{ op = "filter", family = "iir", design = "chebyshev2", response = "bandpass", cutoff_hz = [100.0, 5000.0], order = 6, attenuation_db = 40.0, mode = "causal" }
+{ op = "filter", family = "iir", design = "elliptic", response = "bandstop", cutoff_hz = [49.0, 51.0], order = 6, ripple_db = 1.0, attenuation_db = 60.0, mode = "zero_phase" }
+```
+
+参数按 `design` 严格区分:
+
+- `butterworth` 不接受 `ripple_db` 或 `attenuation_db`。
+- `chebyshev1` 必须且只接受 `ripple_db`。
+- `chebyshev2` 必须且只接受 `attenuation_db`。
+- `elliptic` 必须同时接受 `ripple_db` 和 `attenuation_db`,且纹波必须小于衰减。
+- `order` 必须是 1~12 的整数;`ripple_db` 位于 `(0, 20]`,`attenuation_db` 位于 `(0, 200]`。
+
+四种设计都支持低通、高通、带通和带阻,并固定使用 SciPy 的 SOS 输出。设计后会校验二阶节形状、有限系数、单位化分母和所有极点严格位于单位圆内。`order` 对低通/高通表示数字滤波器阶数,对带通/带阻表示原型阶数;带型变换后的数字滤波器阶数为 `2 * order`。
+
+单程临界频率的含义随设计而异:Butterworth 是 `-3 dB` 点;Chebyshev I 和 Elliptic 是通带纹波边缘;Chebyshev II 是阻带衰减边缘。`cutoff_hz` 始终表示单程 SciPy 设计参数,零相位输出不会重新把它解释为最终 `-3 dB` 点。
+
+`causal` 使用 `sosfilt` 和全零初始状态。`zero_phase` 使用 `sosfiltfilt` 与固定奇延拓;padding 长度根据实际 SOS 明确计算并写入 manifest,输入点数必须大于该长度。零相位的有效幅频响应仍是单程幅频响应的平方。两种模式都保留原时间轴和样本数。
+
+FIR 和 IIR 共用 `.[analysis]` 可选依赖。`run check` 根据 Plan 实际选择的 family、design 和 mode 检查所需 SciPy 函数;没有 filter 的流水线不触发该检查。
时域指标为 `voltage_min_v`、`voltage_max_v`、`voltage_mean_v`、`voltage_rms_v` 和 `voltage_vpp_v`。频域指标为 `peak_frequency_hz`、`peak_amplitude_v`、`noise_floor_v`、`thd_ratio`,以及 `harmonic_2`~`harmonic_5` 的 `frequency_hz` 和 `amplitude_v` 字段。`[steps.expect]` 只能引用流水线中已显式选择的测量指标。
diff --git a/plans/README.md b/plans/README.md
index fcfe92d6..626f0823 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -13,7 +13,7 @@ wavebench run check --plan plans/example_scope_expect_quality.toml
`run verify` 会读取配置并查询相关仪器,适合执行前预检。`run plan` 会进行真实实验,执行前应确认接线、scope coupling、输出状态、保护限值和 `[restore]` 范围。`run report` 和 `run calibrate` 读取已有产物,不需要再次连接仪器;校准相关拟合需要安装 `.[analysis]`。
-`example_signal_processing_pipeline.toml` 包含 FIR 带阻和零相位处理,也需要安装 `.[analysis]`。`run check` 只在 Plan 选择需要 SciPy 的算子时检查该可选依赖。
+`example_signal_processing_pipeline.toml` 包含 FIR 带阻、IIR 高通、因果和零相位处理,需要安装 `.[analysis]`。`run check` 只在 Plan 选择需要 SciPy 的算子时检查该可选依赖。
## 计划分类
diff --git a/plans/example_signal_processing_pipeline.toml b/plans/example_signal_processing_pipeline.toml
index 60e6f0b8..e8a1bfea 100644
--- a/plans/example_signal_processing_pipeline.toml
+++ b/plans/example_signal_processing_pipeline.toml
@@ -26,6 +26,7 @@ operations = [
{ op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
{ op = "remove_dc" },
{ op = "filter", family = "fir", response = "bandstop", cutoff_hz = [49.0, 51.0], numtaps = 101, mode = "zero_phase" },
+ { op = "filter", family = "iir", design = "butterworth", response = "highpass", cutoff_hz = 20.0, order = 4, mode = "causal" },
{ op = "window", name = "hann" },
{ op = "fft" },
{ op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v", "noise_floor_v", "thd_ratio", "harmonic_2_frequency_hz", "harmonic_2_amplitude_v"] },
diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py
index c6297149..86d62d36 100644
--- a/src/wavebench/data/signal_pipeline.py
+++ b/src/wavebench/data/signal_pipeline.py
@@ -28,6 +28,15 @@
})
ANALYSIS_FIR_RESPONSES = frozenset({"lowpass", "highpass", "bandpass", "bandstop"})
ANALYSIS_FIR_MODES = frozenset({"causal", "zero_phase"})
+ANALYSIS_IIR_DESIGNS = frozenset({
+ "butterworth",
+ "chebyshev1",
+ "chebyshev2",
+ "elliptic",
+})
+ANALYSIS_IIR_MAX_ORDER = 12
+ANALYSIS_IIR_MAX_RIPPLE_DB = 20.0
+ANALYSIS_IIR_MAX_ATTENUATION_DB = 200.0
SIGNIFICANT_PEAK_V = 1e-12
@@ -88,6 +97,20 @@ def sample_rate_hz(self) -> float:
return 1.0 / self.sample_interval_s
+@dataclass(frozen=True)
+class IirFilterResult:
+ signal: TimeSignal
+ sos: np.ndarray
+ sample_interval_s: float
+ scipy_version: str
+ max_pole_magnitude: float
+ zero_phase_padlen: int
+
+ @property
+ def sample_rate_hz(self) -> float:
+ return 1.0 / self.sample_interval_s
+
+
def validate_waveform(data: Any) -> TimeSignal:
array = np.asarray(data)
if array.ndim != 2 or array.shape[1:] != (2,) or array.shape[0] < 1:
@@ -162,7 +185,7 @@ def filter_fir(
if isinstance(numtaps, bool) or not isinstance(numtaps, int) or numtaps < 3 or numtaps % 2 == 0:
raise DataError("analysis FIR numtaps must be an odd integer >= 3")
- cutoff = _fir_cutoff(response, cutoff_hz)
+ cutoff = _filter_cutoff(response, cutoff_hz, family="FIR")
sample_interval = _uniform_sample_interval(signal, "analysis FIR filter")
sample_rate = 1.0 / sample_interval
nyquist = sample_rate / 2.0
@@ -182,7 +205,7 @@ def filter_fir(
try:
from scipy import __version__ as scipy_version
- from scipy.signal import filtfilt, firwin, lfilter
+ from scipy import signal as scipy_signal
except ImportError as exc: # pragma: no cover - RunService checks this before execution
raise DataError(
"analysis FIR filter requires SciPy; install WaveBench with `.[analysis]`"
@@ -190,7 +213,7 @@ def filter_fir(
try:
taps = np.asarray(
- firwin(
+ scipy_signal.firwin(
numtaps,
cutoff,
window="hamming",
@@ -201,9 +224,9 @@ def filter_fir(
dtype=np.float64,
)
if mode == "causal":
- voltage = lfilter(taps, [1.0], signal.voltage_v, axis=-1)
+ voltage = scipy_signal.lfilter(taps, [1.0], signal.voltage_v, axis=-1)
else:
- voltage = filtfilt(
+ voltage = scipy_signal.filtfilt(
taps,
[1.0],
signal.voltage_v,
@@ -223,6 +246,111 @@ def filter_fir(
)
+def filter_iir(
+ signal: TimeSignal,
+ *,
+ design: str,
+ response: str,
+ cutoff_hz: float | Sequence[float],
+ order: int,
+ mode: str,
+ ripple_db: float | None = None,
+ attenuation_db: float | None = None,
+) -> IirFilterResult:
+ if design not in ANALYSIS_IIR_DESIGNS:
+ raise DataError(
+ "analysis IIR design must be butterworth, chebyshev1, chebyshev2, or elliptic"
+ )
+ if response not in ANALYSIS_FIR_RESPONSES:
+ raise DataError("analysis IIR response must be lowpass, highpass, bandpass, or bandstop")
+ if mode not in ANALYSIS_FIR_MODES:
+ raise DataError("analysis IIR mode must be causal or zero_phase")
+ if (
+ isinstance(order, bool)
+ or not isinstance(order, int)
+ or not 1 <= order <= ANALYSIS_IIR_MAX_ORDER
+ ):
+ raise DataError(f"analysis IIR order must be an integer from 1 to {ANALYSIS_IIR_MAX_ORDER}")
+ ripple, attenuation = _iir_design_parameters(
+ design,
+ ripple_db=ripple_db,
+ attenuation_db=attenuation_db,
+ )
+
+ cutoff = _filter_cutoff(response, cutoff_hz, family="IIR")
+ sample_interval = _uniform_sample_interval(signal, "analysis IIR filter")
+ sample_rate = 1.0 / sample_interval
+ nyquist = sample_rate / 2.0
+ cutoff_values = [cutoff] if isinstance(cutoff, float) else cutoff
+ if any(value >= nyquist for value in cutoff_values):
+ raise DataError(
+ f"analysis IIR cutoff_hz must be below Nyquist frequency {nyquist:.17g} Hz"
+ )
+
+ try:
+ from scipy import __version__ as scipy_version
+ from scipy import signal as scipy_signal
+ except ImportError as exc: # pragma: no cover - RunService checks this before execution
+ raise DataError(
+ "analysis IIR filter requires SciPy; install WaveBench with `.[analysis]`"
+ ) from exc
+
+ design_kwargs = {
+ "btype": response,
+ "output": "sos",
+ "fs": sample_rate,
+ }
+ try:
+ if design == "butterworth":
+ raw_sos = scipy_signal.butter(order, cutoff, **design_kwargs)
+ elif design == "chebyshev1":
+ raw_sos = scipy_signal.cheby1(order, ripple, cutoff, **design_kwargs)
+ elif design == "chebyshev2":
+ raw_sos = scipy_signal.cheby2(order, attenuation, cutoff, **design_kwargs)
+ else:
+ raw_sos = scipy_signal.ellip(order, ripple, attenuation, cutoff, **design_kwargs)
+ sos = _validated_sos(raw_sos)
+ _, poles, _ = scipy_signal.sos2zpk(sos)
+ except (FloatingPointError, OverflowError, ValueError, ZeroDivisionError) as exc:
+ raise DataError(f"analysis IIR filter design failed: {exc}") from exc
+
+ if not np.all(np.isfinite(poles)):
+ raise DataError("analysis IIR filter design produced non-finite poles")
+ max_pole_magnitude = float(np.max(np.abs(poles)))
+ if not np.isfinite(max_pole_magnitude) or max_pole_magnitude >= 1.0:
+ raise DataError("analysis IIR filter design is not stable")
+
+ padlen = _sos_zero_phase_padlen(sos)
+ if mode == "zero_phase" and signal.voltage_v.size <= padlen:
+ raise DataError(
+ "analysis zero-phase IIR requires at least "
+ f"{padlen + 1} samples for {sos.shape[0]} SOS sections"
+ )
+
+ try:
+ if mode == "causal":
+ voltage = scipy_signal.sosfilt(sos, signal.voltage_v, axis=-1, zi=None)
+ else:
+ voltage = scipy_signal.sosfiltfilt(
+ sos,
+ signal.voltage_v,
+ axis=-1,
+ padtype="odd",
+ padlen=padlen,
+ )
+ except (FloatingPointError, OverflowError, ValueError) as exc:
+ raise DataError(f"analysis IIR filter execution failed: {exc}") from exc
+
+ return IirFilterResult(
+ signal=_replace_voltage(signal, np.asarray(voltage, dtype=np.float64)),
+ sos=sos,
+ sample_interval_s=sample_interval,
+ scipy_version=scipy_version,
+ max_pole_magnitude=max_pole_magnitude,
+ zero_phase_padlen=padlen,
+ )
+
+
def fft_signal(signal: TimeSignal) -> FrequencySignal:
samples = int(signal.voltage_v.size)
if samples < 4:
@@ -347,26 +475,95 @@ def _replace_voltage(signal: TimeSignal, voltage: np.ndarray) -> TimeSignal:
)
-def _fir_cutoff(
- response: str, cutoff_hz: float | Sequence[float]
+def _filter_cutoff(
+ response: str,
+ cutoff_hz: float | Sequence[float],
+ *,
+ family: str,
) -> float | list[float]:
if response in {"lowpass", "highpass"}:
if isinstance(cutoff_hz, Sequence) and not isinstance(cutoff_hz, (str, bytes)):
- raise DataError(f"analysis FIR {response} cutoff_hz must be a positive number")
- return _positive_finite(cutoff_hz, "analysis FIR cutoff_hz")
+ raise DataError(f"analysis {family} {response} cutoff_hz must be a positive number")
+ return _positive_finite(cutoff_hz, f"analysis {family} cutoff_hz")
if (
not isinstance(cutoff_hz, Sequence)
or isinstance(cutoff_hz, (str, bytes))
or len(cutoff_hz) != 2
):
- raise DataError(f"analysis FIR {response} cutoff_hz must contain two frequencies")
- values = [_positive_finite(value, "analysis FIR cutoff_hz") for value in cutoff_hz]
+ raise DataError(f"analysis {family} {response} cutoff_hz must contain two frequencies")
+ values = [_positive_finite(value, f"analysis {family} cutoff_hz") for value in cutoff_hz]
if values[1] <= values[0]:
- raise DataError("analysis FIR cutoff_hz must be strictly increasing")
+ raise DataError(f"analysis {family} cutoff_hz must be strictly increasing")
return values
+def _iir_design_parameters(
+ design: str,
+ *,
+ ripple_db: float | None,
+ attenuation_db: float | None,
+) -> tuple[float | None, float | None]:
+ needs_ripple = design in {"chebyshev1", "elliptic"}
+ needs_attenuation = design in {"chebyshev2", "elliptic"}
+ if needs_ripple != (ripple_db is not None):
+ requirement = "requires" if needs_ripple else "does not accept"
+ raise DataError(f"analysis IIR {design} {requirement} ripple_db")
+ if needs_attenuation != (attenuation_db is not None):
+ requirement = "requires" if needs_attenuation else "does not accept"
+ raise DataError(f"analysis IIR {design} {requirement} attenuation_db")
+
+ ripple = (
+ _bounded_positive_finite(
+ ripple_db,
+ "analysis IIR ripple_db",
+ maximum=ANALYSIS_IIR_MAX_RIPPLE_DB,
+ )
+ if ripple_db is not None
+ else None
+ )
+ attenuation = (
+ _bounded_positive_finite(
+ attenuation_db,
+ "analysis IIR attenuation_db",
+ maximum=ANALYSIS_IIR_MAX_ATTENUATION_DB,
+ )
+ if attenuation_db is not None
+ else None
+ )
+ if design == "elliptic" and ripple is not None and attenuation is not None:
+ if ripple >= attenuation:
+ raise DataError("analysis IIR elliptic ripple_db must be less than attenuation_db")
+ return ripple, attenuation
+
+
+def _bounded_positive_finite(value: Any, name: str, *, maximum: float) -> float:
+ result = _positive_finite(value, name)
+ if result > maximum:
+ raise DataError(f"{name} must be <= {maximum:g}")
+ return result
+
+
+def _validated_sos(value: Any) -> np.ndarray:
+ try:
+ sos = np.asarray(value, dtype=np.float64)
+ except (TypeError, ValueError) as exc:
+ raise DataError("analysis IIR filter design must produce numeric SOS coefficients") from exc
+ if sos.ndim != 2 or sos.shape[0] < 1 or sos.shape[1] != 6:
+ raise DataError("analysis IIR filter design must produce an Nx6 SOS array")
+ if not np.all(np.isfinite(sos)):
+ raise DataError("analysis IIR filter design produced non-finite SOS coefficients")
+ if not np.array_equal(sos[:, 3], np.ones(sos.shape[0])):
+ raise DataError("analysis IIR filter SOS denominators must have a0 = 1")
+ return sos
+
+
+def _sos_zero_phase_padlen(sos: np.ndarray) -> int:
+ zeros_at_origin = int(np.count_nonzero(sos[:, 2] == 0.0))
+ poles_at_origin = int(np.count_nonzero(sos[:, 5] == 0.0))
+ return 3 * (2 * sos.shape[0] + 1 - min(zeros_at_origin, poles_at_origin))
+
+
def _positive_finite(value: Any, name: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float, np.integer, np.floating)):
raise DataError(f"{name} must be a positive finite number")
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index ef931e75..10742f0d 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -2054,12 +2054,14 @@ def _analysis_operation_label(operation: dict[str, Any]) -> str:
cutoff_text = "–".join(_format_plain(value) for value in cutoff)
else:
cutoff_text = _format_plain(cutoff)
+ family = operation.get("family", "")
+ if family == "fir":
+ details = f"{operation.get('numtaps', '')} taps"
+ else:
+ details = f"{operation.get('design', '')}, order {operation.get('order', '')}"
return (
- "filter("
- f"{operation.get('family', '')}, {operation.get('response', '')}, "
- f"{cutoff_text} Hz, {operation.get('numtaps', '')} taps, "
- f"{operation.get('mode', '')}"
- ")"
+ f"filter({family}, {operation.get('response', '')}, {cutoff_text} Hz, "
+ f"{details}, {operation.get('mode', '')})"
)
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index 297eaca5..ee3ca44b 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -14,10 +14,12 @@
from wavebench.data.signal_pipeline import (
FrequencySignal,
FirFilterResult,
+ IirFilterResult,
TimeSignal,
detrend_linear,
fft_signal,
filter_fir,
+ filter_iir,
measure_frequency,
measure_time,
remove_dc,
@@ -35,24 +37,48 @@
def ensure_analysis_pipeline_dependencies(plan: RunPlan) -> None:
- needs_scipy = any(
- operation["op"] == "filter" and operation["family"] == "fir"
+ filters = [
+ operation
for step in plan.steps
if step.kind == "analysis.pipeline"
for operation in step.fields["operations"]
- )
- if not needs_scipy:
+ if operation["op"] == "filter"
+ ]
+ if not filters:
return
+ required_functions: set[str] = set()
+ for operation in filters:
+ if operation["family"] == "fir":
+ required_functions.add("firwin")
+ required_functions.add(
+ "lfilter" if operation["mode"] == "causal" else "filtfilt"
+ )
+ else:
+ required_functions.update({
+ {
+ "butterworth": "butter",
+ "chebyshev1": "cheby1",
+ "chebyshev2": "cheby2",
+ "elliptic": "ellip",
+ }[operation["design"]],
+ "sos2zpk",
+ "sosfilt" if operation["mode"] == "causal" else "sosfiltfilt",
+ })
try:
scipy_signal = import_module("scipy.signal")
except ImportError as exc:
raise ConfigError(
- "analysis FIR filter requires SciPy; install WaveBench with `.[analysis]`"
+ "analysis filter requires SciPy; install WaveBench with `.[analysis]`"
) from exc
- if not all(callable(getattr(scipy_signal, name, None)) for name in ("firwin", "lfilter", "filtfilt")):
+ missing = sorted(
+ name
+ for name in required_functions
+ if not callable(getattr(scipy_signal, name, None))
+ )
+ if missing:
raise ConfigError(
- "analysis FIR filter requires compatible SciPy signal support; "
- "install WaveBench with `.[analysis]`"
+ "analysis filter requires compatible SciPy signal support "
+ f"({', '.join(missing)}); install WaveBench with `.[analysis]`"
)
@@ -120,17 +146,32 @@ def execute_analysis_pipeline(
signal = detrend_linear(signal)
elif op == "filter":
assert isinstance(signal, TimeSignal)
- result = filter_fir(
- signal,
- response=operation["response"],
- cutoff_hz=operation["cutoff_hz"],
- numtaps=operation["numtaps"],
- mode=operation["mode"],
- )
+ if operation["family"] == "fir":
+ result = filter_fir(
+ signal,
+ response=operation["response"],
+ cutoff_hz=operation["cutoff_hz"],
+ numtaps=operation["numtaps"],
+ mode=operation["mode"],
+ )
+ filter_metadata = _fir_filter_metadata(
+ operation_index, operation, result
+ )
+ else:
+ result = filter_iir(
+ signal,
+ design=operation["design"],
+ response=operation["response"],
+ cutoff_hz=operation["cutoff_hz"],
+ order=operation["order"],
+ mode=operation["mode"],
+ ripple_db=operation.get("ripple_db"),
+ attenuation_db=operation.get("attenuation_db"),
+ )
+ filter_metadata = _iir_filter_metadata(
+ operation_index, operation, result
+ )
signal = result.signal
- filter_metadata = _fir_filter_metadata(
- operation_index, operation, result
- )
filters.append(filter_metadata)
stage["filter"] = filter_metadata
sampling.update({
@@ -314,6 +355,75 @@ def _fir_filter_metadata(
return metadata
+def _iir_filter_metadata(
+ operation_index: int,
+ operation: dict[str, Any],
+ result: IirFilterResult,
+) -> dict[str, Any]:
+ design = operation["design"]
+ order = operation["order"]
+ mode = operation["mode"]
+ sections = int(result.sos.shape[0])
+ metadata: dict[str, Any] = {
+ "operation_index": operation_index,
+ "family": "iir",
+ "design": design,
+ "response": operation["response"],
+ "cutoff_hz": operation["cutoff_hz"],
+ "order": order,
+ "digital_filter_order": (
+ 2 * order if operation["response"] in {"bandpass", "bandstop"} else order
+ ),
+ "sample_rate_hz": result.sample_rate_hz,
+ "design_function": {
+ "butterworth": "scipy.signal.butter",
+ "chebyshev1": "scipy.signal.cheby1",
+ "chebyshev2": "scipy.signal.cheby2",
+ "elliptic": "scipy.signal.ellip",
+ }[design],
+ "design_output": "sos",
+ "critical_frequency_semantics": {
+ "butterworth": "single_pass_minus_3_db",
+ "chebyshev1": "single_pass_passband_ripple_edge",
+ "chebyshev2": "single_pass_stopband_attenuation_edge",
+ "elliptic": "single_pass_passband_ripple_edge",
+ }[design],
+ "sections": sections,
+ "sos_shape": [sections, 6],
+ "sos_sha256": sha256(
+ np.asarray(result.sos, dtype="
allowed_fields = {
"remove_dc": {"op"},
"detrend": {"op", "method"},
- "filter": {"op", "family", "response", "cutoff_hz", "numtaps", "mode"},
+ "filter": {
+ "op",
+ "family",
+ "design",
+ "response",
+ "cutoff_hz",
+ "numtaps",
+ "order",
+ "ripple_db",
+ "attenuation_db",
+ "mode",
+ },
"window": {"op", "name"},
"fft": {"op"},
"measure": {"op", "metrics"},
@@ -1284,7 +1299,7 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
}
required_fields = {
"detrend": {"method"},
- "filter": {"family", "response", "cutoff_hz", "numtaps", "mode"},
+ "filter": {"family", "response", "cutoff_hz", "mode"},
"window": {"name"},
"measure": {"metrics"},
"export": {"name", "formats"},
@@ -1330,8 +1345,55 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
if op == "filter":
family = raw_operation["family"]
- if not isinstance(family, str) or family.strip().lower() != "fir":
- raise ConfigError(f"{operation_prefix}.family must be 'fir'")
+ if not isinstance(family, str) or family.strip().lower() not in {"fir", "iir"}:
+ raise ConfigError(f"{operation_prefix}.family must be 'fir' or 'iir'")
+ family = family.strip().lower()
+ if family == "fir":
+ _validate_analysis_filter_fields(
+ raw_operation,
+ allowed={"op", "family", "response", "cutoff_hz", "numtaps", "mode"},
+ required={"numtaps"},
+ prefix=operation_prefix,
+ )
+ design = None
+ else:
+ _validate_analysis_filter_fields(
+ raw_operation,
+ allowed=set(raw_operation),
+ required={"design", "order"},
+ prefix=operation_prefix,
+ )
+ raw_design = raw_operation["design"]
+ if (
+ not isinstance(raw_design, str)
+ or raw_design.strip().lower() not in ANALYSIS_IIR_DESIGNS
+ ):
+ raise ConfigError(
+ f"{operation_prefix}.design must be one of "
+ "butterworth, chebyshev1, chebyshev2, elliptic"
+ )
+ design = raw_design.strip().lower()
+ design_fields = {
+ "butterworth": set(),
+ "chebyshev1": {"ripple_db"},
+ "chebyshev2": {"attenuation_db"},
+ "elliptic": {"ripple_db", "attenuation_db"},
+ }[design]
+ _validate_analysis_filter_fields(
+ raw_operation,
+ allowed={
+ "op",
+ "family",
+ "design",
+ "response",
+ "cutoff_hz",
+ "order",
+ "mode",
+ *design_fields,
+ },
+ required=design_fields,
+ prefix=operation_prefix,
+ )
response = raw_operation["response"]
if (
not isinstance(response, str)
@@ -1342,47 +1404,75 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
"lowpass, highpass, bandpass, bandstop"
)
response = response.strip().lower()
- raw_cutoff = raw_operation["cutoff_hz"]
- if response in {"lowpass", "highpass"}:
- cutoff: float | list[float] = _analysis_positive_float(
- raw_cutoff, f"{operation_prefix}.cutoff_hz"
+ cutoff = _normalize_analysis_filter_cutoff(
+ raw_operation["cutoff_hz"],
+ response=response,
+ name=f"{operation_prefix}.cutoff_hz",
+ )
+ mode = raw_operation["mode"]
+ if not isinstance(mode, str) or mode.strip().lower() not in ANALYSIS_FIR_MODES:
+ raise ConfigError(
+ f"{operation_prefix}.mode must be 'causal' or 'zero_phase'"
)
+ normalized_mode = mode.strip().lower()
+ if family == "fir":
+ numtaps = raw_operation["numtaps"]
+ if (
+ isinstance(numtaps, bool)
+ or not isinstance(numtaps, int)
+ or numtaps < 3
+ or numtaps % 2 == 0
+ ):
+ raise ConfigError(
+ f"{operation_prefix}.numtaps must be an odd integer >= 3"
+ )
+ operation = {
+ "op": "filter",
+ "family": "fir",
+ "response": response,
+ "cutoff_hz": cutoff,
+ "numtaps": numtaps,
+ "mode": normalized_mode,
+ }
else:
- if not isinstance(raw_cutoff, list) or len(raw_cutoff) != 2:
+ order = raw_operation["order"]
+ if (
+ isinstance(order, bool)
+ or not isinstance(order, int)
+ or not 1 <= order <= ANALYSIS_IIR_MAX_ORDER
+ ):
raise ConfigError(
- f"{operation_prefix}.cutoff_hz must be a two-element array "
- f"for {response}"
+ f"{operation_prefix}.order must be an integer from 1 to "
+ f"{ANALYSIS_IIR_MAX_ORDER}"
+ )
+ operation = {
+ "op": "filter",
+ "family": "iir",
+ "design": design,
+ "response": response,
+ "cutoff_hz": cutoff,
+ "order": order,
+ }
+ if "ripple_db" in raw_operation:
+ operation["ripple_db"] = _analysis_bounded_positive_float(
+ raw_operation["ripple_db"],
+ f"{operation_prefix}.ripple_db",
+ maximum=ANALYSIS_IIR_MAX_RIPPLE_DB,
+ )
+ if "attenuation_db" in raw_operation:
+ operation["attenuation_db"] = _analysis_bounded_positive_float(
+ raw_operation["attenuation_db"],
+ f"{operation_prefix}.attenuation_db",
+ maximum=ANALYSIS_IIR_MAX_ATTENUATION_DB,
)
- cutoff = [
- _analysis_positive_float(value, f"{operation_prefix}.cutoff_hz")
- for value in raw_cutoff
- ]
- if cutoff[1] <= cutoff[0]:
+ if (
+ design == "elliptic"
+ and operation["ripple_db"] >= operation["attenuation_db"]
+ ):
raise ConfigError(
- f"{operation_prefix}.cutoff_hz must be strictly increasing"
+ f"{operation_prefix}.ripple_db must be less than attenuation_db"
)
- numtaps = raw_operation["numtaps"]
- if (
- isinstance(numtaps, bool)
- or not isinstance(numtaps, int)
- or numtaps < 3
- or numtaps % 2 == 0
- ):
- raise ConfigError(
- f"{operation_prefix}.numtaps must be an odd integer >= 3"
- )
- mode = raw_operation["mode"]
- if not isinstance(mode, str) or mode.strip().lower() not in ANALYSIS_FIR_MODES:
- raise ConfigError(
- f"{operation_prefix}.mode must be 'causal' or 'zero_phase'"
- )
- operation.update({
- "family": "fir",
- "response": response,
- "cutoff_hz": cutoff,
- "numtaps": numtaps,
- "mode": mode.strip().lower(),
- })
+ operation["mode"] = normalized_mode
elif op == "detrend":
method = raw_operation["method"]
if not isinstance(method, str) or method.lower() != "linear":
@@ -1470,6 +1560,46 @@ def _analysis_positive_float(value: Any, name: str) -> float:
return _positive_float(value, name)
+def _analysis_bounded_positive_float(value: Any, name: str, *, maximum: float) -> float:
+ result = _analysis_positive_float(value, name)
+ if result > maximum:
+ raise ConfigError(f"{name} must be <= {maximum:g}")
+ return result
+
+
+def _normalize_analysis_filter_cutoff(
+ raw: Any,
+ *,
+ response: str,
+ name: str,
+) -> float | list[float]:
+ if response in {"lowpass", "highpass"}:
+ return _analysis_positive_float(raw, name)
+ if not isinstance(raw, list) or len(raw) != 2:
+ raise ConfigError(f"{name} must be a two-element array for {response}")
+ cutoff = [_analysis_positive_float(value, name) for value in raw]
+ if cutoff[1] <= cutoff[0]:
+ raise ConfigError(f"{name} must be strictly increasing")
+ return cutoff
+
+
+def _validate_analysis_filter_fields(
+ raw: dict[str, Any],
+ *,
+ allowed: set[str],
+ required: set[str],
+ prefix: str,
+) -> None:
+ unknown = sorted(set(raw) - allowed)
+ if unknown:
+ names = ", ".join(repr(name) for name in unknown)
+ raise ConfigError(f"{prefix} filter has unknown field {names}")
+ missing = sorted(required - set(raw))
+ if missing:
+ names = ", ".join(repr(name) for name in missing)
+ raise ConfigError(f"{prefix} filter missing required field {names}")
+
+
def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) -> None:
for name in ("source_channel", "reference_channel", "response_channel"):
if name in fields:
diff --git a/tests/test_execution_intent.py b/tests/test_execution_intent.py
index 818a453a..833cee43 100644
--- a/tests/test_execution_intent.py
+++ b/tests/test_execution_intent.py
@@ -126,6 +126,44 @@ def test_analysis_pipeline_intent_carries_normalized_fir_design() -> None:
}
+def test_analysis_pipeline_intent_carries_normalized_iir_design() -> None:
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(
+ write_plan(
+ tmp,
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "filter", family = "IIR", design = "ELLIPTIC", response = "BANDSTOP", cutoff_hz = [49, 51], order = 6, ripple_db = 1, attenuation_db = 60, mode = "ZERO_PHASE" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+]
+""",
+ )
+ )
+
+ intent = build_execution_intent(plan, make_config(tmp))
+
+ iir = intent.operations[1]["parameters"]["operations"][0]
+ assert iir == {
+ "op": "filter",
+ "family": "iir",
+ "response": "bandstop",
+ "cutoff_hz": [49.0, 51.0],
+ "mode": "zero_phase",
+ "design": "elliptic",
+ "order": 6,
+ "ripple_db": 1.0,
+ "attenuation_db": 60.0,
+ }
+
+
def test_step_id_changes_plan_and_intent_digest_without_changing_legacy_shape() -> None:
with TemporaryDirectory() as tmp:
legacy = _sleep_plan(tmp)
diff --git a/tests/test_report.py b/tests/test_report.py
index fba315f4..5b80b802 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -64,6 +64,17 @@ def test_run_report_has_independent_signal_processing_section_and_manifest_entri
"numtaps": 101,
"mode": "zero_phase",
},
+ {
+ "op": "filter",
+ "family": "iir",
+ "design": "elliptic",
+ "response": "bandstop",
+ "cutoff_hz": [49.0, 51.0],
+ "order": 6,
+ "ripple_db": 1.0,
+ "attenuation_db": 60.0,
+ "mode": "zero_phase",
+ },
{"op": "fft"},
{"op": "measure", "metrics": ["peak_frequency_hz"]},
],
@@ -95,6 +106,7 @@ def test_run_report_has_independent_signal_processing_section_and_manifest_entri
self.assertIn("capture_main", html)
self.assertIn(
"remove_dc → filter(fir, bandstop, 49–51 Hz, 101 taps, zero_phase) "
+ "→ filter(iir, bandstop, 49–51 Hz, elliptic, order 6, zero_phase) "
"→ fft → measure",
html,
)
diff --git a/tests/test_run_pipeline.py b/tests/test_run_pipeline.py
index fa5ea724..66d37f6b 100644
--- a/tests/test_run_pipeline.py
+++ b/tests/test_run_pipeline.py
@@ -3,6 +3,7 @@
import json
from pathlib import Path
from tempfile import TemporaryDirectory
+from types import SimpleNamespace
import unittest
from unittest.mock import patch
@@ -206,6 +207,123 @@ def test_serial_fir_filters_write_metadata_and_time_export(self) -> None:
)
self.assertEqual(sha256(source_npy.read_bytes()).hexdigest(), source_before)
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_mixed_fir_iir_filters_write_stable_sos_metadata(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, source_npy = self.source(root, self.waveform())
+ source_before = sha256(source_npy.read_bytes()).hexdigest()
+ step = self.pipeline([
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "lowpass",
+ "cutoff_hz": 2000.0,
+ "numtaps": 31,
+ "mode": "causal",
+ },
+ {
+ "op": "filter",
+ "family": "iir",
+ "design": "elliptic",
+ "response": "bandstop",
+ "cutoff_hz": [49.0, 51.0],
+ "order": 4,
+ "ripple_db": 1.0,
+ "attenuation_db": 60.0,
+ "mode": "zero_phase",
+ },
+ {"op": "export", "name": "filtered", "formats": ["npy"]},
+ ])
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ manifest = json.loads(
+ (run_dir / artifact["analysis_pipeline"]["manifest"]).read_text(
+ encoding="utf-8"
+ )
+ )
+ filters = manifest["filters"]
+ iir = filters[1]
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "ok")
+ self.assertEqual([item["family"] for item in filters], ["fir", "iir"])
+ self.assertEqual(iir["operation_index"], 1)
+ self.assertEqual(iir["design"], "elliptic")
+ self.assertEqual(iir["response"], "bandstop")
+ self.assertEqual(iir["order"], 4)
+ self.assertEqual(iir["digital_filter_order"], 8)
+ self.assertEqual(iir["design_function"], "scipy.signal.ellip")
+ self.assertEqual(iir["design_output"], "sos")
+ self.assertEqual(iir["critical_frequency_semantics"], "single_pass_passband_ripple_edge")
+ self.assertEqual(iir["sos_shape"], [iir["sections"], 6])
+ self.assertEqual(len(iir["sos_sha256"]), 64)
+ self.assertTrue(iir["stable"])
+ self.assertLess(iir["max_pole_magnitude"], 1.0)
+ self.assertEqual(iir["ripple_db"], 1.0)
+ self.assertEqual(iir["attenuation_db"], 60.0)
+ self.assertEqual(iir["execution_function"], "scipy.signal.sosfiltfilt")
+ self.assertEqual(iir["effective_magnitude_response"], "single_pass_squared")
+ self.assertEqual(iir["boundary"], "odd_extension")
+ self.assertGreater(iir["padlen"], 0)
+ self.assertEqual(manifest["stages"][2]["filter"], iir)
+ self.assertEqual(sha256(source_npy.read_bytes()).hexdigest(), source_before)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_completed_iir_metadata_survives_later_filter_failure(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "runs" / "run"
+ run_dir.mkdir(parents=True)
+ source_step, source_record, _ = self.source(root, self.waveform())
+ step = self.pipeline([
+ {
+ "op": "filter",
+ "family": "iir",
+ "design": "butterworth",
+ "response": "lowpass",
+ "cutoff_hz": 1000.0,
+ "order": 4,
+ "mode": "causal",
+ },
+ {
+ "op": "filter",
+ "family": "iir",
+ "design": "butterworth",
+ "response": "lowpass",
+ "cutoff_hz": 5000.0,
+ "order": 4,
+ "mode": "causal",
+ },
+ {"op": "export", "name": "filtered", "formats": ["npy"]},
+ ])
+
+ artifact = execute_analysis_pipeline(
+ run_dir=run_dir,
+ step=step,
+ source_step=source_step,
+ source_record=source_record,
+ )
+
+ manifest = json.loads(
+ (run_dir / artifact["analysis_pipeline"]["manifest"]).read_text(
+ encoding="utf-8"
+ )
+ )
+ self.assertEqual(artifact["analysis_pipeline"]["status"], "failed")
+ self.assertEqual(artifact["analysis_pipeline"]["failed_stage"], "operations[1]")
+ self.assertTrue(manifest["partial"])
+ self.assertEqual(len(manifest["filters"]), 1)
+ self.assertEqual(manifest["filters"][0]["family"], "iir")
+ self.assertEqual(manifest["stages"][2]["status"], "failed")
+ self.assertIn("below Nyquist", manifest["error"]["message"])
+
@unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
def test_completed_filter_metadata_survives_later_nyquist_failure(self) -> None:
with TemporaryDirectory() as tmp:
@@ -253,7 +371,7 @@ def test_completed_filter_metadata_survives_later_nyquist_failure(self) -> None:
self.assertEqual(manifest["stages"][3]["status"], "skipped")
self.assertIn("below Nyquist", manifest["error"]["message"])
- def test_fir_dependency_check_is_conditional_and_actionable(self) -> None:
+ def test_filter_dependency_check_is_conditional_and_actionable(self) -> None:
with TemporaryDirectory() as tmp:
root = Path(tmp)
no_filter_path = root / "no_filter.toml"
@@ -286,6 +404,24 @@ def test_fir_dependency_check_is_conditional_and_actionable(self) -> None:
{ op = "filter", family = "fir", response = "lowpass", cutoff_hz = 1000, numtaps = 31, mode = "causal" },
{ op = "export", name = "filtered", formats = ["npy"] },
]
+""",
+ encoding="utf-8",
+ )
+ iir_path = root / "iir.toml"
+ iir_path.write_text(
+ """
+[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+
+[[steps]]
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "filter", family = "iir", design = "butterworth", response = "lowpass", cutoff_hz = 1000, order = 4, mode = "zero_phase" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+]
""",
encoding="utf-8",
)
@@ -301,6 +437,25 @@ def test_fir_dependency_check_is_conditional_and_actionable(self) -> None:
with self.assertRaisesRegex(ConfigError, r"\.\[analysis\]"):
ensure_analysis_pipeline_dependencies(load_run_plan(fir_path))
+ available = SimpleNamespace(
+ butter=lambda: None,
+ sos2zpk=lambda: None,
+ sosfiltfilt=lambda: None,
+ )
+ with patch(
+ "wavebench.services.run_pipeline.import_module",
+ return_value=available,
+ ):
+ ensure_analysis_pipeline_dependencies(load_run_plan(iir_path))
+
+ del available.sosfiltfilt
+ with patch(
+ "wavebench.services.run_pipeline.import_module",
+ return_value=available,
+ ):
+ with self.assertRaisesRegex(ConfigError, "sosfiltfilt"):
+ ensure_analysis_pipeline_dependencies(load_run_plan(iir_path))
+
def test_source_expectation_failure_still_allows_complete_npy(self) -> None:
with TemporaryDirectory() as tmp:
root = Path(tmp)
diff --git a/tests/test_run_plan_analysis.py b/tests/test_run_plan_analysis.py
index 069b0c7c..268c07d2 100644
--- a/tests/test_run_plan_analysis.py
+++ b/tests/test_run_plan_analysis.py
@@ -187,8 +187,8 @@ def test_pipeline_operator_parameters_and_domains_are_strict(self) -> None:
'{ op = "filter", family = "fir", response = "lowpass", '
'cutoff_hz = 1000, numtaps = 31 }'
),
- "family must be 'fir'": (
- '{ op = "filter", family = "iir", response = "lowpass", '
+ "family must be 'fir' or 'iir'": (
+ '{ op = "filter", family = "biquad", response = "lowpass", '
'cutoff_hz = 1000, numtaps = 31, mode = "causal" }'
),
"response must be one of": (
@@ -290,6 +290,119 @@ def test_pipeline_fir_numeric_parameters_are_strict(self) -> None:
with self.assertRaisesRegex(ConfigError, "unknown field 'taps'"):
load_run_plan(self.write_plan(self.analysis_plan(unknown)))
+ def test_pipeline_normalizes_all_iir_designs_and_allows_mixed_filters(self) -> None:
+ plan = load_run_plan(
+ self.write_plan(
+ self.analysis_plan(
+ """
+ { op = "filter", family = "IIR", design = "BUTTERWORTH", response = "LOWPASS", cutoff_hz = 1000, order = 4, mode = "CAUSAL" },
+ { op = "filter", family = "iir", design = "chebyshev1", response = "highpass", cutoff_hz = 100, order = 5, ripple_db = 1, mode = "zero_phase" },
+ { op = "filter", family = "iir", design = "chebyshev2", response = "bandpass", cutoff_hz = [100, 1000], order = 6, attenuation_db = 40, mode = "causal" },
+ { op = "filter", family = "iir", design = "elliptic", response = "bandstop", cutoff_hz = [49, 51], order = 12, ripple_db = 1, attenuation_db = 60, mode = "zero_phase" },
+ { op = "filter", family = "fir", response = "lowpass", cutoff_hz = 2000, numtaps = 31, mode = "causal" },
+ { op = "export", name = "filtered", formats = ["npy"] },
+"""
+ )
+ )
+ )
+
+ filters = plan.steps[1].fields["operations"][:5]
+ self.assertEqual(
+ [operation.get("design") for operation in filters],
+ ["butterworth", "chebyshev1", "chebyshev2", "elliptic", None],
+ )
+ self.assertEqual(filters[0]["order"], 4)
+ self.assertEqual(filters[1]["ripple_db"], 1.0)
+ self.assertNotIn("attenuation_db", filters[1])
+ self.assertEqual(filters[2]["attenuation_db"], 40.0)
+ self.assertEqual(filters[3]["cutoff_hz"], [49.0, 51.0])
+ self.assertEqual(filters[3]["ripple_db"], 1.0)
+ self.assertEqual(filters[3]["attenuation_db"], 60.0)
+ self.assertEqual(
+ filters[4],
+ {
+ "op": "filter",
+ "family": "fir",
+ "response": "lowpass",
+ "cutoff_hz": 2000.0,
+ "numtaps": 31,
+ "mode": "causal",
+ },
+ )
+ self.assertEqual(
+ list(filters[4]),
+ ["op", "family", "response", "cutoff_hz", "numtaps", "mode"],
+ )
+
+ def test_pipeline_iir_discriminated_parameters_are_strict(self) -> None:
+ common = (
+ 'op = "filter", family = "iir", response = "lowpass", '
+ 'cutoff_hz = 1000, mode = "causal"'
+ )
+ cases = {
+ "missing required field 'design', 'order'": f"{{ {common} }}",
+ "design must be one of": f'{{ {common}, design = "bessel", order = 4 }}',
+ "order must be an integer from 1 to 12": (
+ f'{{ {common}, design = "butterworth", order = 0 }}'
+ ),
+ "filter has unknown field 'ripple_db'": (
+ f'{{ {common}, design = "butterworth", order = 4, ripple_db = 1 }}'
+ ),
+ "missing required field 'ripple_db'": (
+ f'{{ {common}, design = "chebyshev1", order = 4 }}'
+ ),
+ "filter has unknown field 'attenuation_db'": (
+ f'{{ {common}, design = "chebyshev1", order = 4, '
+ "ripple_db = 1, attenuation_db = 40 }"
+ ),
+ "missing required field 'attenuation_db'": (
+ f'{{ {common}, design = "chebyshev2", order = 4 }}'
+ ),
+ "ripple_db must be > 0": (
+ f'{{ {common}, design = "chebyshev1", order = 4, ripple_db = 0 }}'
+ ),
+ "ripple_db must be <= 20": (
+ f'{{ {common}, design = "chebyshev1", order = 4, ripple_db = 21 }}'
+ ),
+ "attenuation_db must be <= 200": (
+ f'{{ {common}, design = "chebyshev2", order = 4, '
+ "attenuation_db = 201 }"
+ ),
+ "ripple_db must be less than attenuation_db": (
+ f'{{ {common}, design = "elliptic", order = 4, '
+ "ripple_db = 20, attenuation_db = 20 }"
+ ),
+ "filter has unknown field 'numtaps'": (
+ f'{{ {common}, design = "butterworth", order = 4, numtaps = 31 }}'
+ ),
+ }
+ for message, operation in cases.items():
+ with self.subTest(message=message):
+ operations = (
+ f"{operation}, "
+ '{ op = "export", name = "filtered", formats = ["npy"] }'
+ )
+ with self.assertRaisesRegex(ConfigError, message):
+ load_run_plan(self.write_plan(self.analysis_plan(operations)))
+
+ for order in (13, 4.0, True):
+ with self.subTest(order=order):
+ value = str(order).lower()
+ operation = (
+ f'{{ {common}, design = "butterworth", order = {value} }}, '
+ '{ op = "export", name = "filtered", formats = ["npy"] }'
+ )
+ with self.assertRaisesRegex(ConfigError, "order must be an integer from 1 to 12"):
+ load_run_plan(self.write_plan(self.analysis_plan(operation)))
+
+ fir_with_iir_field = (
+ '{ op = "filter", family = "fir", response = "lowpass", '
+ 'cutoff_hz = 1000, numtaps = 31, mode = "causal", order = 4 }, '
+ '{ op = "export", name = "filtered", formats = ["npy"] }'
+ )
+ with self.assertRaisesRegex(ConfigError, "filter has unknown field 'order'"):
+ load_run_plan(self.write_plan(self.analysis_plan(fir_with_iir_field)))
+
def test_pipeline_rejects_duplicate_and_misordered_configuration(self) -> None:
cases = {
"at most once": (
diff --git a/tests/test_run_service_analysis.py b/tests/test_run_service_analysis.py
index 4ab79d30..8b688ef3 100644
--- a/tests/test_run_service_analysis.py
+++ b/tests/test_run_service_analysis.py
@@ -299,7 +299,7 @@ def test_legacy_plan_does_not_enter_analysis_phase() -> None:
assert "id" not in run["steps"][0]
-def test_missing_fir_dependency_is_rejected_before_instrument_lifecycle() -> None:
+def test_missing_filter_dependency_is_rejected_before_instrument_lifecycle() -> None:
with TemporaryDirectory() as tmp:
plan = load_run_plan(
write_plan(
@@ -314,7 +314,7 @@ def test_missing_fir_dependency_is_rejected_before_instrument_lifecycle() -> Non
kind = "analysis.pipeline"
source = { step = "capture_main" }
operations = [
- { op = "filter", family = "fir", response = "lowpass", cutoff_hz = 1000, numtaps = 31, mode = "causal" },
+ { op = "filter", family = "iir", design = "butterworth", response = "lowpass", cutoff_hz = 1000, order = 4, mode = "causal" },
{ op = "export", name = "filtered", formats = ["npy"] },
]
""",
@@ -325,7 +325,7 @@ def test_missing_fir_dependency_is_rejected_before_instrument_lifecycle() -> Non
with patch(
"wavebench.services.run_service.ensure_analysis_pipeline_dependencies",
side_effect=ConfigError(
- "analysis FIR filter requires SciPy; install WaveBench with `.[analysis]`"
+ "analysis filter requires SciPy; install WaveBench with `.[analysis]`"
),
), patch.object(service, "_run_instrument_services") as open_services:
try:
@@ -333,7 +333,7 @@ def test_missing_fir_dependency_is_rejected_before_instrument_lifecycle() -> Non
except ConfigError as exc:
assert ".[analysis]" in str(exc)
else: # pragma: no cover - assertion helper without pytest dependency
- raise AssertionError("missing FIR dependency should be rejected")
+ raise AssertionError("missing filter dependency should be rejected")
open_services.assert_not_called()
assert not (Path(tmp) / "data" / "runs").exists()
diff --git a/tests/test_signal_pipeline.py b/tests/test_signal_pipeline.py
index 7ffc9426..dab56e1d 100644
--- a/tests/test_signal_pipeline.py
+++ b/tests/test_signal_pipeline.py
@@ -10,6 +10,7 @@
detrend_linear,
fft_signal,
filter_fir,
+ filter_iir,
measure_frequency,
measure_time,
remove_dc,
@@ -243,6 +244,268 @@ def test_fir_rejects_nonuniform_sampling_and_nyquist_cutoff(self) -> None:
mode="causal",
)
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_causal_iir_designs_match_direct_sos_execution(self) -> None:
+ from scipy import signal as scipy_signal
+
+ sample_rate_hz = 10_000.0
+ time_signal = validate_waveform(self.waveform(2000, sample_rate_hz))
+ cases = {
+ "butterworth": (scipy_signal.butter, {}, (4, 1000.0)),
+ "chebyshev1": (
+ scipy_signal.cheby1,
+ {"ripple_db": 1.0},
+ (4, 1.0, 1000.0),
+ ),
+ "chebyshev2": (
+ scipy_signal.cheby2,
+ {"attenuation_db": 40.0},
+ (4, 40.0, 1000.0),
+ ),
+ "elliptic": (
+ scipy_signal.ellip,
+ {"ripple_db": 1.0, "attenuation_db": 40.0},
+ (4, 1.0, 40.0, 1000.0),
+ ),
+ }
+
+ for design, (factory, request_parameters, scipy_args) in cases.items():
+ with self.subTest(design=design):
+ result = filter_iir(
+ time_signal,
+ design=design,
+ response="lowpass",
+ cutoff_hz=1000.0,
+ order=4,
+ mode="causal",
+ **request_parameters,
+ )
+ expected_sos = factory(
+ *scipy_args,
+ btype="lowpass",
+ output="sos",
+ fs=result.sample_rate_hz,
+ )
+ expected_voltage = scipy_signal.sosfilt(
+ expected_sos,
+ time_signal.voltage_v,
+ axis=-1,
+ zi=None,
+ )
+
+ np.testing.assert_array_equal(result.signal.time_s, time_signal.time_s)
+ np.testing.assert_allclose(result.sos, expected_sos, rtol=0, atol=0)
+ np.testing.assert_allclose(
+ result.signal.voltage_v,
+ expected_voltage,
+ rtol=0,
+ atol=0,
+ )
+ self.assertTrue(0.0 < result.max_pole_magnitude < 1.0)
+ self.assertTrue(result.scipy_version)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_zero_phase_iir_fixes_padding_and_minimum_samples(self) -> None:
+ from scipy import signal as scipy_signal
+
+ sample_rate_hz = 1000.0
+ sos = scipy_signal.butter(
+ 3,
+ 100.0,
+ btype="lowpass",
+ output="sos",
+ fs=sample_rate_hz,
+ )
+ padlen = 3 * (
+ 2 * len(sos)
+ + 1
+ - min(np.count_nonzero(sos[:, 2] == 0), np.count_nonzero(sos[:, 5] == 0))
+ )
+ with self.assertRaisesRegex(DataError, f"at least {padlen + 1} samples"):
+ filter_iir(
+ validate_waveform(self.waveform(padlen, sample_rate_hz)),
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=100.0,
+ order=3,
+ mode="zero_phase",
+ )
+
+ minimum = validate_waveform(self.waveform(padlen + 1, sample_rate_hz))
+ with patch("scipy.signal.sosfiltfilt", wraps=scipy_signal.sosfiltfilt) as apply_filter:
+ result = filter_iir(
+ minimum,
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=100.0,
+ order=3,
+ mode="zero_phase",
+ )
+
+ self.assertEqual(result.zero_phase_padlen, padlen)
+ self.assertEqual(result.signal.voltage_v.size, padlen + 1)
+ self.assertEqual(
+ apply_filter.call_args.kwargs,
+ {"axis": -1, "padtype": "odd", "padlen": padlen},
+ )
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_zero_phase_iir_squares_the_single_pass_magnitude_response(self) -> None:
+ sample_rate_hz = 10_000.0
+ samples = 10_000
+ frequency_hz = 1000.0
+ time_s = np.arange(samples, dtype=float) / sample_rate_hz
+ voltage_v = np.sin(2 * np.pi * frequency_hz * time_s)
+ time_signal = validate_waveform(np.column_stack((time_s, voltage_v)))
+
+ causal = filter_iir(
+ time_signal,
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=frequency_hz,
+ order=4,
+ mode="causal",
+ ).signal.voltage_v
+ zero_phase = filter_iir(
+ time_signal,
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=frequency_hz,
+ order=4,
+ mode="zero_phase",
+ ).signal.voltage_v
+ interior = slice(2000, 8000)
+ basis = np.exp(-2j * np.pi * frequency_hz * time_s[interior])
+ causal_amplitude = 2 * abs(np.dot(causal[interior], basis)) / basis.size
+ zero_phase_amplitude = 2 * abs(np.dot(zero_phase[interior], basis)) / basis.size
+
+ self.assertAlmostEqual(zero_phase_amplitude, causal_amplitude**2, places=10)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_iir_supports_low_high_bandpass_and_bandstop(self) -> None:
+ sample_rate_hz = 10_000.0
+ samples = 6000
+ frequencies = (500.0, 1500.0, 3000.0)
+ time_s = np.arange(samples, dtype=float) / sample_rate_hz
+ voltage_v = sum(np.sin(2 * np.pi * frequency * time_s) for frequency in frequencies)
+ time_signal = validate_waveform(np.column_stack((time_s, voltage_v)))
+ cases = {
+ "lowpass": (1000.0, {500.0}, {3000.0}),
+ "highpass": (2000.0, {3000.0}, {500.0}),
+ "bandpass": ([1000.0, 2000.0], {1500.0}, {500.0, 3000.0}),
+ "bandstop": ([1000.0, 2000.0], {500.0, 3000.0}, {1500.0}),
+ }
+
+ for response, (cutoff_hz, passed, rejected) in cases.items():
+ with self.subTest(response=response):
+ filtered = filter_iir(
+ time_signal,
+ design="butterworth",
+ response=response,
+ cutoff_hz=cutoff_hz,
+ order=8,
+ mode="zero_phase",
+ ).signal.voltage_v
+ interior = slice(500, -500)
+ interior_time = time_s[interior]
+ amplitudes = {
+ frequency: 2
+ * abs(
+ np.dot(
+ filtered[interior],
+ np.exp(-2j * np.pi * frequency * interior_time),
+ )
+ )
+ / interior_time.size
+ for frequency in frequencies
+ }
+ for frequency in passed:
+ self.assertGreater(amplitudes[frequency], 0.8)
+ for frequency in rejected:
+ self.assertLess(amplitudes[frequency], 0.01)
+
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_iir_rejects_bad_sampling_parameters_and_unstable_sos(self) -> None:
+ time_signal = validate_waveform(self.waveform(1000, 10_000.0))
+ with self.assertRaisesRegex(DataError, "below Nyquist"):
+ filter_iir(
+ time_signal,
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=5000.0,
+ order=4,
+ mode="causal",
+ )
+
+ nonuniform = self.waveform(1000, 10_000.0)
+ nonuniform[500:, 0] += 1e-5
+ with self.assertRaisesRegex(DataError, "uniformly sampled"):
+ filter_iir(
+ validate_waveform(nonuniform),
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=1000.0,
+ order=4,
+ mode="causal",
+ )
+
+ unstable = np.array([[1.0, 0.0, 0.0, 1.0, -2.0, 0.0]])
+ with patch("scipy.signal.butter", return_value=unstable):
+ with self.assertRaisesRegex(DataError, "not stable"):
+ filter_iir(
+ time_signal,
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=1000.0,
+ order=4,
+ mode="causal",
+ )
+
+ invalid_sos = (
+ (np.ones((2, 5)), "Nx6 SOS"),
+ (np.array([[1.0, 0.0, 0.0, 2.0, 0.0, 0.0]]), "a0 = 1"),
+ (np.array([[1.0, np.nan, 0.0, 1.0, 0.0, 0.0]]), "non-finite"),
+ )
+ for sos, message in invalid_sos:
+ with self.subTest(message=message), patch(
+ "scipy.signal.butter", return_value=sos
+ ):
+ with self.assertRaisesRegex(DataError, message):
+ filter_iir(
+ time_signal,
+ design="butterworth",
+ response="lowpass",
+ cutoff_hz=1000.0,
+ order=4,
+ mode="causal",
+ )
+
+ def test_iir_parameters_are_validated_without_scipy(self) -> None:
+ time_signal = validate_waveform(self.waveform(100, 10_000.0))
+ cases = (
+ ({"design": "bessel"}, "design must be"),
+ ({"order": 0}, "order must be"),
+ ({"order": 13}, "order must be"),
+ ({"ripple_db": None}, "requires ripple_db"),
+ ({"ripple_db": 21.0}, "must be <= 20"),
+ ({"ripple_db": 20.0, "attenuation_db": 20.0}, "less than"),
+ )
+ for overrides, message in cases:
+ request = {
+ "design": "chebyshev1",
+ "response": "lowpass",
+ "cutoff_hz": 1000.0,
+ "order": 4,
+ "mode": "causal",
+ "ripple_db": 1.0,
+ }
+ request.update(overrides)
+ if "attenuation_db" in overrides:
+ request["design"] = "elliptic"
+ with self.subTest(overrides=overrides):
+ with self.assertRaisesRegex(DataError, message):
+ filter_iir(time_signal, **request)
+
with self.assertRaisesRegex(DataError, "below Nyquist"):
filter_fir(
validate_waveform(self.waveform(1000, 10_000.0)),
From 49e320e2cd6cbd97837adf86c46bbbbae85cdb3e Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 11:27:41 +0800
Subject: [PATCH 07/30] feat(run): add explicit Welch PSD pipelines
---
docs/reference/artifacts.md | 6 +-
docs/reference/generated/run-schema.md | 7 +
docs/reference/run-schema.md | 30 ++-
plans/example_signal_processing_pipeline.toml | 10 +
src/wavebench/data/signal_pipeline.py | 95 +++++++
src/wavebench/report/html.py | 5 +
src/wavebench/services/run_pipeline.py | 74 +++++-
src/wavebench/services/run_plan.py | 27 +-
tests/test_psd_pipeline.py | 231 ++++++++++++++++++
9 files changed, 470 insertions(+), 15 deletions(-)
create mode 100644 tests/test_psd_pipeline.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index adf281ea..84dc22d6 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -72,7 +72,11 @@ IIR 项记录 design、响应、截止频率、原型阶数、变换后的数字
指标值只写有限 JSON 数字或 `null`,不写 `NaN`、`Infinity`。step artifact 的 `metrics` 保留同一份小型映射;`expect` 继续使用既有 `{ min, max }` 结果结构,因此 `summary.csv` 的 expectation 列和 HTML 验收表不需要另一套解释。
-时域 NPY 和 CSV 固定为 `time_s,voltage_v` 两列。频域 NPY 和 CSV 固定为 `frequency_hz,real_v,imaginary_v,amplitude_v` 四列。每个导出记录文件路径、列名和 SHA-256;路径相对于 run 目录并使用 POSIX 分隔符。来源 NPY 保持原样,处理器只读取 capture package 内经过边界校验的文件。
+时域 NPY 和 CSV 固定为 `time_s,voltage_v` 两列。FFT 频域 NPY 和 CSV 固定为 `frequency_hz,real_v,imaginary_v,amplitude_v` 四列。PSD NPY 和 CSV 固定为 `frequency_hz,psd_v2_per_hz` 两列。每个导出记录文件路径、列名和 SHA-256;路径相对于 run 目录并使用 POSIX 分隔符。来源 NPY 保持原样,处理器只读取 capture package 内经过边界校验的文件。
+
+成功执行 PSD 时,manifest 条件性增加 `psd` 对象,并在对应 stage 中记录同一份元数据,输出域为 `psd`。该对象包括规范化参数、执行函数、SciPy 版本、实际采样率、周期窗标记、窗功率增益、窗 SHA-256、完整分段数和丢弃尾点数。窗 SHA-256 使用实际周期窗的 little-endian float64 字节计算。`bin_spacing_hz` 为采样率除以 `nfft`;`segment_frequency_scale_hz` 为采样率除以 `nperseg`,不表示加窗后的等效噪声带宽。
+
+PSD 元数据同时记录单边密度缩放、`V^2/Hz` 单位和归一化公式。仅有一段或存在尾点时写入警告;后续导出失败仍保留成功 PSD 的元数据。没有成功 PSD 的流水线不增加 `psd` 字段,schema 继续使用 `wavebench.analysis_pipeline.v1`。PSD 不产生新的标量指标,未选择时域测量时 `metrics` 为空映射。HTML 报告显示 Welch 分段参数、警告和导出链接。
频域 `amplitude_v` 是单边峰值幅度,不是 RMS。`noise_floor_v` 是排除 DC 与主峰后的非 DC 幅度 bin 中位数,表示每 bin 峰值幅度,不表示积分噪声。THD 使用 Nyquist 范围内的 H2~H5。
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index 273d7e51..b7170424 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -246,4 +246,11 @@ scope.capture [steps.expect_fft] metrics:
analysis.pipeline metrics:
Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.
Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.
+ PSD domain: export only; no scalar metrics.
+
+analysis.pipeline PSD operation:
+ psd requires method=welch, window=hann|hamming|blackman, nperseg>=4, 0<=noverlap=nperseg, detrend=none|constant|linear, average=mean|median.
+ All parameters are explicit; lengths are integers. Segment windows are periodic.
+ Requires time data before window or fft. Only export may follow psd; at least one PSD export is required.
+ Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.
```
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index bfcee38e..c0b9b8a7 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -73,6 +73,7 @@ thd_ratio = { max = 0.05 }
| `filter` | FIR 或 IIR 的判别式设计参数 | 时域 → 时域 |
| `window` | `name = "hann|hamming|blackman"` | 时域 → 时域 |
| `fft` | 无 | 时域 → 频域 |
+| `psd` | Welch 分段参数,见下文 | 时域 → PSD |
| `measure` | 非空 `metrics` 数组 | 观察当前域,不改变数据 |
| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
@@ -97,7 +98,7 @@ FIR 需要可选分析依赖:
python -m pip install -e ".[analysis]"
```
-Plan 包含 FIR 或 IIR 算子时,`run check` 检查 SciPy;缺少依赖时会在租约、session 和仪器 I/O 之前失败。没有 filter 的 NumPy 流水线不需要 SciPy。
+Plan 包含 FIR、IIR 或 PSD 算子时,`run check` 检查 SciPy;缺少依赖时会在租约、session 和仪器 I/O 之前失败。仅使用 NumPy 算子的流水线不需要 SciPy。
### IIR 滤波
@@ -124,7 +125,32 @@ IIR 继续使用同一个 `filter` 算子。`family = "iir"` 时必须声明 `de
`causal` 使用 `sosfilt` 和全零初始状态。`zero_phase` 使用 `sosfiltfilt` 与固定奇延拓;padding 长度根据实际 SOS 明确计算并写入 manifest,输入点数必须大于该长度。零相位的有效幅频响应仍是单程幅频响应的平方。两种模式都保留原时间轴和样本数。
-FIR 和 IIR 共用 `.[analysis]` 可选依赖。`run check` 根据 Plan 实际选择的 family、design 和 mode 检查所需 SciPy 函数;没有 filter 的流水线不触发该检查。
+FIR、IIR 和 PSD 共用 `.[analysis]` 可选依赖。`run check` 根据 Plan 实际选择的算子参数检查所需 SciPy 函数。
+
+### Welch 功率谱密度
+
+PSD 算子将时域数据转换为单边功率谱密度,单位为 `V²/Hz`。全部参数必须显式声明:
+
+```toml
+{ op = "psd", method = "welch", window = "hann", nperseg = 256, noverlap = 128, nfft = 256, detrend = "none", average = "mean" }
+{ op = "export", name = "density", formats = ["npy", "csv"] }
+```
+
+| 参数 | 合同 |
+| --- | --- |
+| `method` | 固定为 `welch` |
+| `window` | `hann`、`hamming` 或 `blackman`,每段使用周期窗 |
+| `nperseg` | 每段样本数,整数且至少为 4 |
+| `noverlap` | 相邻段重叠样本数,整数且满足 `0 <= noverlap < nperseg` |
+| `nfft` | 每段 FFT 长度,整数且不小于 `nperseg`;较大值只做补零 |
+| `detrend` | `none`、`constant` 或 `linear`,在每段加窗前执行 |
+| `average` | `mean` 或经过偏差修正的 `median` |
+
+PSD 可以跟在去直流、去趋势或 FIR/IIR 之后,但不能跟在整段 `window` 或 `fft` 之后。每条流水线至多有一个 PSD;PSD 之后只允许 `export`,且至少导出一次。需要同时生成 FFT 和 PSD 时,使用两个分析 step 引用同一个 capture。PSD 之前可以测量时域指标,PSD 本身暂不提供标量指标,不能复用 FFT 的峰值幅度、THD 或噪声底。
+
+运行时按实际时间轴检查等间隔采样,容差为 `rtol=1e-6, atol=0`。样本数小于 `nperseg` 时失败,不自动缩短段长。只处理完整段;不足一段的尾点不补齐,并在 manifest 中记录数量。仅有一段时仍可导出,同时记录没有跨段平均的警告。
+
+数值实现使用 [SciPy Welch](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.welch.html),固定 `scaling="density"` 和单边输出。每段按 `sample_rate_hz × sum(window²)` 归一化,仅将非 DC、非偶数点 Nyquist 的 bin 功率乘 2。`detrend="none"` 不隐式去直流。频率 bin 间距为 `sample_rate_hz / nfft`,补零不会改善由段长与窗决定的分辨能力。
时域指标为 `voltage_min_v`、`voltage_max_v`、`voltage_mean_v`、`voltage_rms_v` 和 `voltage_vpp_v`。频域指标为 `peak_frequency_hz`、`peak_amplitude_v`、`noise_floor_v`、`thd_ratio`,以及 `harmonic_2`~`harmonic_5` 的 `frequency_hz` 和 `amplitude_v` 字段。`[steps.expect]` 只能引用流水线中已显式选择的测量指标。
diff --git a/plans/example_signal_processing_pipeline.toml b/plans/example_signal_processing_pipeline.toml
index e8a1bfea..08c0af8c 100644
--- a/plans/example_signal_processing_pipeline.toml
+++ b/plans/example_signal_processing_pipeline.toml
@@ -36,3 +36,13 @@ operations = [
[steps.expect]
peak_frequency_hz = { min = 990, max = 1010 }
thd_ratio = { max = 0.05 }
+
+# Independent density analysis of the same raw capture; no whole-signal window.
+[[steps]]
+id = "density_main"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [
+ { op = "psd", method = "welch", window = "hann", nperseg = 256, noverlap = 128, nfft = 256, detrend = "constant", average = "mean" },
+ { op = "export", name = "density", formats = ["npy", "csv"] },
+]
diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py
index 86d62d36..55a0a1f0 100644
--- a/src/wavebench/data/signal_pipeline.py
+++ b/src/wavebench/data/signal_pipeline.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
+from hashlib import sha256
from typing import Any, Iterable, Sequence
import numpy as np
@@ -97,6 +98,100 @@ def sample_rate_hz(self) -> float:
return 1.0 / self.sample_interval_s
+@dataclass(frozen=True)
+class PsdSignal:
+ frequency_hz: np.ndarray
+ psd_v2_per_hz: np.ndarray
+ sample_interval_s: float
+ samples: int
+ parameters: dict[str, Any]
+ segment_count: int
+ discarded_tail_samples: int
+ window_power_gain: float
+ window_sha256: str
+ scipy_version: str
+
+ def as_array(self) -> np.ndarray:
+ return np.column_stack((self.frequency_hz, self.psd_v2_per_hz))
+
+
+def normalize_psd_parameters(
+ *, method: str, window: str, nperseg: int, noverlap: int, nfft: int,
+ detrend: str, average: str,
+) -> dict[str, Any]:
+ parameters: dict[str, Any] = {}
+ for name, value, allowed in (
+ ("method", method, {"welch"}),
+ ("window", window, {"hann", "hamming", "blackman"}),
+ ("detrend", detrend, {"none", "constant", "linear"}),
+ ("average", average, {"mean", "median"}),
+ ):
+ if not isinstance(value, str) or value.strip().lower() not in allowed:
+ raise DataError(f"analysis PSD {name} must be one of {', '.join(sorted(allowed))}")
+ parameters[name] = value.strip().lower()
+ for name, value in (("nperseg", nperseg), ("noverlap", noverlap), ("nfft", nfft)):
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise DataError(f"analysis PSD {name} must be an integer")
+ parameters[name] = value
+ if nperseg < 4:
+ raise DataError("analysis PSD nperseg must be >= 4")
+ if not 0 <= noverlap < nperseg:
+ raise DataError("analysis PSD noverlap must satisfy 0 <= noverlap < nperseg")
+ if nfft < nperseg:
+ raise DataError("analysis PSD nfft must be >= nperseg")
+ return parameters
+
+
+def welch_psd(
+ signal: TimeSignal, *, method: str, window: str, nperseg: int,
+ noverlap: int, nfft: int, detrend: str, average: str,
+) -> PsdSignal:
+ parameters = normalize_psd_parameters(
+ method=method, window=window, nperseg=nperseg, noverlap=noverlap,
+ nfft=nfft, detrend=detrend, average=average,
+ )
+ if signal.window_name is not None or signal.coherent_gain != 1.0:
+ raise DataError("analysis PSD must not follow a whole-signal window")
+ samples = int(signal.voltage_v.size)
+ if samples < nperseg:
+ raise DataError("analysis PSD requires at least nperseg samples; segment length is not reduced")
+ sample_interval = _uniform_sample_interval(signal, "analysis PSD")
+ sample_rate = 1.0 / sample_interval
+ if not np.isfinite(sample_rate):
+ raise DataError("analysis PSD requires a finite sample rate")
+ try:
+ from scipy import __version__ as scipy_version
+ from scipy import signal as scipy_signal
+ except ImportError as exc:
+ raise DataError("analysis PSD requires SciPy; install WaveBench with `.[analysis]`") from exc
+ weights = scipy_signal.get_window(parameters["window"], nperseg, fftbins=True)
+ try:
+ frequencies, density = scipy_signal.welch(
+ signal.voltage_v, fs=sample_rate, window=weights, nperseg=nperseg,
+ noverlap=noverlap, nfft=nfft,
+ detrend=False if parameters["detrend"] == "none" else parameters["detrend"],
+ average=parameters["average"], scaling="density", return_onesided=True, axis=-1,
+ )
+ except (ValueError, FloatingPointError, OverflowError) as exc:
+ raise DataError(f"analysis PSD failed: {exc}") from exc
+ if (
+ frequencies.shape != (nfft // 2 + 1,) or density.shape != frequencies.shape
+ or not np.all(np.isfinite(frequencies)) or not np.all(np.isfinite(density))
+ or np.any(density < 0)
+ ):
+ raise DataError("analysis PSD must produce finite nonnegative one-sided density")
+ hop = nperseg - noverlap
+ return PsdSignal(
+ frequency_hz=frequencies, psd_v2_per_hz=density,
+ sample_interval_s=sample_interval, samples=samples, parameters=parameters,
+ segment_count=1 + (samples - nperseg) // hop,
+ discarded_tail_samples=(samples - nperseg) % hop,
+ window_power_gain=float(np.mean(weights ** 2)),
+ window_sha256=sha256(np.asarray(weights, dtype=" str:
def _analysis_operation_label(operation: dict[str, Any]) -> str:
op = str(operation.get("op", ""))
+ if op == "psd":
+ return "psd(" + ", ".join(
+ f"{key}={operation.get(key, '')}"
+ for key in ("method", "window", "nperseg", "noverlap", "nfft", "detrend", "average")
+ ) + ")"
if op != "filter":
return op
cutoff = operation.get("cutoff_hz")
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index ee3ca44b..b9cf4c89 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -15,6 +15,7 @@
FrequencySignal,
FirFilterResult,
IirFilterResult,
+ PsdSignal,
TimeSignal,
detrend_linear,
fft_signal,
@@ -25,6 +26,7 @@
remove_dc,
validate_waveform,
window_signal,
+ welch_psd,
)
from wavebench.errors import ConfigError, DataError, error_envelope
from wavebench.services.run_analysis import evaluate_expect
@@ -37,18 +39,20 @@
def ensure_analysis_pipeline_dependencies(plan: RunPlan) -> None:
- filters = [
+ operations = [
operation
for step in plan.steps
if step.kind == "analysis.pipeline"
for operation in step.fields["operations"]
- if operation["op"] == "filter"
+ if operation["op"] in {"filter", "psd"}
]
- if not filters:
+ if not operations:
return
required_functions: set[str] = set()
- for operation in filters:
- if operation["family"] == "fir":
+ for operation in operations:
+ if operation["op"] == "psd":
+ required_functions.update({"welch", "get_window"})
+ elif operation["family"] == "fir":
required_functions.add("firwin")
required_functions.add(
"lfilter" if operation["mode"] == "causal" else "filtfilt"
@@ -68,7 +72,7 @@ def ensure_analysis_pipeline_dependencies(plan: RunPlan) -> None:
scipy_signal = import_module("scipy.signal")
except ImportError as exc:
raise ConfigError(
- "analysis filter requires SciPy; install WaveBench with `.[analysis]`"
+ "analysis processing requires SciPy; install WaveBench with `.[analysis]`"
) from exc
missing = sorted(
name
@@ -77,7 +81,7 @@ def ensure_analysis_pipeline_dependencies(plan: RunPlan) -> None:
)
if missing:
raise ConfigError(
- "analysis filter requires compatible SciPy signal support "
+ "analysis processing requires compatible SciPy signal support "
f"({', '.join(missing)}); install WaveBench with `.[analysis]`"
)
@@ -109,6 +113,7 @@ def execute_analysis_pipeline(
filters: list[dict[str, Any]] = []
sampling: dict[str, Any] | None = None
window: dict[str, Any] | None = None
+ psd: dict[str, Any] | None = None
source: dict[str, Any] = {
"step": source_step.id,
"step_index": source_step.index,
@@ -124,7 +129,7 @@ def execute_analysis_pipeline(
source_record=source_record,
)
source.update(source_details)
- signal: TimeSignal | FrequencySignal = validate_waveform(waveform)
+ signal: TimeSignal | FrequencySignal | PsdSignal = validate_waveform(waveform)
sampling = _time_sampling(signal)
stages.append({"stage": "source", "status": "ok", "domain": "time"})
@@ -193,14 +198,55 @@ def execute_analysis_pipeline(
"sample_rate_hz": signal.sample_rate_hz,
"resolution_hz": signal.resolution_hz,
})
+ elif op == "psd":
+ assert isinstance(signal, TimeSignal)
+ signal = welch_psd(
+ signal, **{key: value for key, value in operation.items() if key != "op"}
+ )
+ rate = 1.0 / signal.sample_interval_s
+ psd = {
+ **signal.parameters,
+ "operation_index": operation_index,
+ "execution_function": "scipy.signal.welch",
+ "scipy_version": signal.scipy_version,
+ "sample_rate_hz": rate,
+ "window_periodic": True,
+ "window_power_gain": signal.window_power_gain,
+ "window_sha256": signal.window_sha256,
+ "segment_count": signal.segment_count,
+ "discarded_tail_samples": signal.discarded_tail_samples,
+ "bin_spacing_hz": rate / operation["nfft"],
+ "segment_frequency_scale_hz": rate / operation["nperseg"],
+ "scaling": "density",
+ "units": "V^2/Hz",
+ "return_onesided": True,
+ "normalization": "sample_rate_hz * sum(window ** 2)",
+ }
+ stage["psd"] = psd
+ sampling.update({
+ "sample_interval_s": signal.sample_interval_s,
+ "sample_rate_hz": rate,
+ })
+ psd_warnings = []
+ if signal.segment_count == 1:
+ psd_warnings.append("PSD has only one segment; no segment averaging")
+ if signal.discarded_tail_samples:
+ psd_warnings.append(
+ f"PSD discarded {signal.discarded_tail_samples} trailing samples"
+ )
+ if psd_warnings:
+ stage["warnings"] = psd_warnings
+ _extend_unique(warnings, psd_warnings)
elif op == "measure":
if isinstance(signal, TimeSignal):
measured = measure_time(signal, operation["metrics"])
operation_warnings: list[str] = []
- else:
+ elif isinstance(signal, FrequencySignal):
measured, operation_warnings = measure_frequency(
signal, operation["metrics"]
)
+ else:
+ raise DataError("PSD does not support scalar metrics")
metrics.update(measured)
_extend_unique(warnings, operation_warnings)
if operation_warnings:
@@ -273,6 +319,8 @@ def execute_analysis_pipeline(
}
if filters:
manifest["filters"] = filters
+ if psd is not None:
+ manifest["psd"] = psd
if failed_stage is not None:
manifest["failed_stage"] = failed_stage
if failure is not None:
@@ -490,7 +538,7 @@ def _export_signal(
*,
run_dir: Path,
processing_dir: Path,
- signal: TimeSignal | FrequencySignal,
+ signal: TimeSignal | FrequencySignal | PsdSignal,
name: str,
formats: list[str],
) -> Iterator[dict[str, Any]]:
@@ -498,6 +546,8 @@ def _export_signal(
exports_dir.mkdir(parents=True, exist_ok=True)
if isinstance(signal, TimeSignal):
columns = ["time_s", "voltage_v"]
+ elif isinstance(signal, PsdSignal):
+ columns = ["frequency_hz", "psd_v2_per_hz"]
else:
columns = ["frequency_hz", "real_v", "imaginary_v", "amplitude_v"]
data = signal.as_array()
@@ -590,7 +640,9 @@ def _time_sampling(signal: TimeSignal) -> dict[str, Any]:
}
-def _domain(signal: TimeSignal | FrequencySignal) -> str:
+def _domain(signal: TimeSignal | FrequencySignal | PsdSignal) -> str:
+ if isinstance(signal, PsdSignal):
+ return "psd"
return "time" if isinstance(signal, TimeSignal) else "frequency"
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 304d0eb3..efc58b4e 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -18,8 +18,9 @@
ANALYSIS_IIR_MAX_ORDER,
ANALYSIS_IIR_MAX_RIPPLE_DB,
ANALYSIS_TIME_METRICS,
+ normalize_psd_parameters,
)
-from wavebench.errors import ConfigError
+from wavebench.errors import ConfigError, DataError
from wavebench.services.frequency_response import FIT_METHODS
from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
from wavebench.services.frequency_response_baseline import normalize_frequency_response_baseline
@@ -458,6 +459,13 @@ def format_run_plan_schema() -> str:
"analysis.pipeline metrics:",
" Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.",
" Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.",
+ " PSD domain: export only; no scalar metrics.",
+ "",
+ "analysis.pipeline PSD operation:",
+ " psd requires method=welch, window=hann|hamming|blackman, nperseg>=4, 0<=noverlap=nperseg, detrend=none|constant|linear, average=mean|median.",
+ " All parameters are explicit; lengths are integers. Segment windows are periodic.",
+ " Requires time data before window or fft. Only export may follow psd; at least one PSD export is required.",
+ " Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.",
])
return "\n".join(lines)
@@ -1294,6 +1302,7 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
},
"window": {"op", "name"},
"fft": {"op"},
+ "psd": {"op", "method", "window", "nperseg", "noverlap", "nfft", "detrend", "average"},
"measure": {"op", "metrics"},
"export": {"op", "name", "formats"},
}
@@ -1301,6 +1310,7 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
"detrend": {"method"},
"filter": {"family", "response", "cutoff_hz", "mode"},
"window": {"name"},
+ "psd": {"method", "window", "nperseg", "noverlap", "nfft", "detrend", "average"},
"measure": {"metrics"},
"export": {"name", "formats"},
}
@@ -1326,6 +1336,11 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
raise ConfigError(f"{operation_prefix} {op} missing required field {names}")
operation: dict[str, Any] = {"op": op}
+ if domain == "psd" and op != "export":
+ raise ConfigError(f"{operation_prefix}: only export is supported after psd")
+ if op == "psd":
+ if domain != "time" or "window" in transforms:
+ raise ConfigError(f"{operation_prefix}: psd requires time data before window or fft")
if op in {"remove_dc", "detrend", "window", "fft"}:
if op in transforms:
raise ConfigError(f"{prefix} operation {op!r} may appear at most once")
@@ -1487,6 +1502,14 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
operation["name"] = name.lower()
elif op == "fft":
domain = "frequency"
+ elif op == "psd":
+ try:
+ operation.update(normalize_psd_parameters(
+ **{key: value for key, value in raw_operation.items() if key != "op"}
+ ))
+ except DataError as exc:
+ raise ConfigError(f"{operation_prefix}: {exc}") from exc
+ domain = "psd"
elif op == "measure":
raw_metrics = raw_operation["metrics"]
if not isinstance(raw_metrics, list) or not raw_metrics:
@@ -1542,6 +1565,8 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
if not has_result:
raise ConfigError(f"{prefix}.operations requires at least one measure or export operation")
+ if domain == "psd" and normalized[-1]["op"] != "export":
+ raise ConfigError(f"{prefix}.operations requires an export after psd")
fields["operations"] = normalized
if "expect" in fields:
diff --git a/tests/test_psd_pipeline.py b/tests/test_psd_pipeline.py
new file mode 100644
index 00000000..8b8aef97
--- /dev/null
+++ b/tests/test_psd_pipeline.py
@@ -0,0 +1,231 @@
+from hashlib import sha256
+import json
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+from wavebench.data.packages import load_run_package
+from wavebench.data.signal_pipeline import validate_waveform, welch_psd, window_signal
+from wavebench.errors import ConfigError, DataError
+from wavebench.logging import CommandLogger
+from wavebench.report.html import write_run_report_html
+from wavebench.services.execution_intent import build_execution_intent
+from wavebench.services.run_pipeline import (
+ ensure_analysis_pipeline_dependencies,
+ execute_analysis_pipeline,
+)
+from wavebench.services.run_plan import load_run_plan
+from wavebench.services.run_service import RunService
+
+import test_run_pipeline as artifact_helpers
+from test_run_service import make_config
+
+
+PARAMS = dict(method="welch", window="hann", nperseg=16, noverlap=8,
+ nfft=16, detrend="none", average="mean")
+EXPORT = dict(op="export", name="density", formats=["npy", "csv"])
+
+
+def plan_for(tmp_path, operations):
+ tables = ["{ " + ", ".join(f"{k} = {json.dumps(v)}" for k, v in op.items()) + " }"
+ for op in operations]
+ path = tmp_path / "plan.toml"
+ path.write_text('''[[steps]]
+id = "capture_main"
+kind = "scope.capture"
+save_npy = true
+[[steps]]
+id = "density_main"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+operations = [''' + ", ".join(tables) + "]\n", encoding="utf-8")
+ return load_run_plan(path)
+
+
+def signal_for(voltage, fs=128):
+ return validate_waveform(np.column_stack((np.arange(len(voltage)) / fs, voltage)))
+
+
+@pytest.mark.parametrize("field,value", [
+ ("method", "periodogram"), ("window", "boxcar"), ("detrend", False),
+ ("average", "sum"), ("nperseg", 3), ("nperseg", 16.0), ("nfft", True),
+ ("noverlap", -1), ("noverlap", 16), ("nfft", 15), ("nfft", "16"),
+])
+def test_psd_invalid_parameters_rejected_offline(tmp_path, field, value):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [dict(op="psd", **(PARAMS | {field: value})), EXPORT])
+
+
+@pytest.mark.parametrize("field", list(PARAMS))
+def test_psd_requires_every_parameter(tmp_path, field):
+ params = PARAMS.copy()
+ del params[field]
+ with pytest.raises(ConfigError, match="missing required"):
+ plan_for(tmp_path, [dict(op="psd", **params), EXPORT])
+
+
+@pytest.mark.parametrize("operations", [
+ [dict(op="psd", **PARAMS, scaling="spectrum"), EXPORT],
+ [dict(op="window", name="hann"), dict(op="psd", **PARAMS), EXPORT],
+ [dict(op="fft"), dict(op="psd", **PARAMS), EXPORT],
+ [dict(op="psd", **PARAMS), dict(op="fft"), EXPORT],
+ [dict(op="psd", **PARAMS), dict(op="psd", **PARAMS), EXPORT],
+ [dict(op="psd", **PARAMS), dict(op="measure", metrics=["peak_amplitude_v"])],
+ [dict(op="psd", **PARAMS), dict(op="remove_dc"), EXPORT],
+ [dict(op="measure", metrics=["voltage_rms_v"]), dict(op="psd", **PARAMS)],
+ [dict(op="export", name="before", formats=["npy"]), dict(op="psd", **PARAMS)],
+])
+def test_psd_domain_and_result_rules(tmp_path, operations):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, operations)
+
+
+def test_psd_normalization_and_offline_intent(tmp_path):
+ plan = plan_for(tmp_path, [
+ dict(op="measure", metrics=["voltage_rms_v"]), dict(op="remove_dc"),
+ dict(op="psd", **(PARAMS | dict(window=" HANN ", average="MEDIAN"))), EXPORT,
+ ])
+ intent = build_execution_intent(plan, make_config(str(tmp_path)))
+ operation = intent.operations[1]
+ assert operation["instrument_kind"] is None
+ assert operation["effect"] == "offline"
+ assert operation["lease_mode"] == "none"
+ assert operation["parameters"]["operations"][2] == dict(
+ op="psd", **(PARAMS | dict(average="median"))
+ )
+
+
+@pytest.mark.parametrize("window", ["hann", "hamming", "blackman"])
+@pytest.mark.parametrize("nfft", [16, 17, 32])
+@pytest.mark.parametrize("average", ["mean", "median"])
+def test_psd_matches_independent_segment_periodograms(window, nfft, average):
+ pytest.importorskip("scipy")
+ values = np.random.default_rng(81).normal(size=43)
+ values[18] += 15 # Median must differ materially from mean.
+ params = PARAMS | dict(window=window, nfft=nfft, average=average)
+ result = welch_psd(signal_for(values), **params)
+ weights = {"hann": np.hanning, "hamming": np.hamming, "blackman": np.blackman}[window](17)[:-1]
+ segments = np.array([values[start:start + 16] for start in (0, 8, 16, 24)])
+ spectra = np.abs(np.fft.rfft(segments * weights, n=nfft)) ** 2
+ spectra /= 128 * np.sum(weights ** 2)
+ spectra[:, 1:(-1 if nfft % 2 == 0 else None)] *= 2
+ # Four segments: SciPy's median bias correction is 1 + 1/3 - 1/2 = 5/6.
+ expected = spectra.mean(axis=0) if average == "mean" else np.median(spectra, axis=0) / (5 / 6)
+ np.testing.assert_allclose(result.psd_v2_per_hz, expected, rtol=2e-13, atol=1e-15)
+ np.testing.assert_allclose(result.frequency_hz, np.arange(nfft // 2 + 1) * 128 / nfft)
+ assert result.segment_count == 4
+ assert result.discarded_tail_samples == 3
+ assert result.window_power_gain == pytest.approx(np.mean(weights ** 2))
+
+
+@pytest.mark.parametrize("nfft", [16, 17, 64])
+@pytest.mark.parametrize("values", [np.ones(16) * 3, (-1.) ** np.arange(16) * 3])
+def test_psd_dc_nyquist_and_zero_padding_conserve_power(nfft, values):
+ pytest.importorskip("scipy")
+ result = welch_psd(signal_for(values), **(PARAMS | dict(nfft=nfft)))
+ assert np.sum(result.psd_v2_per_hz) * 128 / nfft == pytest.approx(9.)
+ if nfft == 16:
+ index = 0 if values[1] > 0 else 8
+ assert result.psd_v2_per_hz[index] == pytest.approx(0.75)
+
+
+@pytest.mark.parametrize("detrend", ["constant", "linear"])
+def test_psd_detrend_is_per_segment(detrend):
+ pytest.importorskip("scipy")
+ values = np.arange(40.) * 0.3 + np.sin(np.arange(40.))
+ result = welch_psd(signal_for(values), **(PARAMS | dict(detrend=detrend)))
+ weights = np.hanning(17)[:-1]
+ segments = np.array([values[i:i + 16] for i in (0, 8, 16, 24)])
+ segments -= segments.mean(axis=1, keepdims=True)
+ if detrend == "linear":
+ axis = np.arange(16.) - 7.5
+ segments -= np.outer(segments @ axis / (axis @ axis), axis)
+ expected = np.mean(abs(np.fft.rfft(segments * weights)) ** 2, axis=0)
+ expected /= 128 * np.sum(weights ** 2)
+ expected[1:-1] *= 2
+ np.testing.assert_allclose(result.psd_v2_per_hz, expected, atol=1e-16)
+
+
+def test_psd_rejects_short_nonuniform_and_windowed_input():
+ with pytest.raises(DataError, match="nperseg"):
+ welch_psd(signal_for(np.ones(15)), **PARAMS)
+ signal = signal_for(np.ones(16))
+ with pytest.raises(DataError, match="whole-signal window"):
+ welch_psd(window_signal(signal, "hann"), **PARAMS)
+ signal.time_s[5] += 0.001
+ with pytest.raises(DataError, match="uniformly sampled"):
+ welch_psd(signal, **PARAMS)
+
+
+def test_psd_artifacts_report_and_source_immutability(tmp_path):
+ pytest.importorskip("scipy")
+ helper = artifact_helpers.AnalysisPipelineArtifactTests()
+ source, record, raw = helper.source(tmp_path, signal_for(np.ones(19)).as_array())
+ original = raw.read_bytes()
+ plan = plan_for(tmp_path, [dict(op="psd", **PARAMS), EXPORT])
+ artifact = execute_analysis_pipeline(run_dir=tmp_path, step=plan.steps[1],
+ source_step=source, source_record=record)
+ pipeline = artifact["analysis_pipeline"]
+ assert pipeline["status"] == "ok"
+ manifest = json.loads((tmp_path / pipeline["manifest"]).read_text())
+ assert manifest["stages"][1]["output_domain"] == "psd"
+ assert manifest["psd"]["segment_count"] == 1
+ assert manifest["psd"]["discarded_tail_samples"] == 3
+ assert manifest["psd"]["window_periodic"] is True
+ assert len(manifest["psd"]["window_sha256"]) == 64
+ assert len(pipeline["warnings"]) == 2
+ npy, csv = [tmp_path / item["path"] for item in pipeline["exports"]]
+ np.testing.assert_allclose(np.load(npy), np.loadtxt(csv, delimiter=",", skiprows=1))
+ assert csv.read_text().splitlines()[0] == "frequency_hz,psd_v2_per_hz"
+ for item in pipeline["exports"]:
+ assert item["columns"] == ["frequency_hz", "psd_v2_per_hz"]
+ assert item["sha256"] == sha256((tmp_path / item["path"]).read_bytes()).hexdigest()
+ assert item["path"].startswith("processing/01_density_main/exports/")
+ assert raw.read_bytes() == original
+ assert artifact["metrics"] == {}
+ (tmp_path / "run.json").write_text(json.dumps(dict(status="ok", steps=[dict(
+ index=1, id="density_main", kind="analysis.pipeline", status="ok", artifact=artifact
+ )])))
+ html = write_run_report_html(load_run_package(tmp_path)).read_text()
+ assert "psd(method=welch, window=hann, nperseg=16, noverlap=8" in html
+ assert 'href="processing/01_density_main/exports/density.csv"' in html
+ assert "PSD discarded 3 trailing samples" in html
+
+
+def test_failed_psd_preserves_earlier_export(tmp_path):
+ helper = artifact_helpers.AnalysisPipelineArtifactTests()
+ source, record, _ = helper.source(tmp_path, signal_for(np.ones(8)).as_array())
+ before = dict(op="export", name="before", formats=["npy"])
+ plan = plan_for(tmp_path, [before, dict(op="psd", **PARAMS), EXPORT])
+ artifact = execute_analysis_pipeline(run_dir=tmp_path, step=plan.steps[1],
+ source_step=source, source_record=record)
+ pipeline = artifact["analysis_pipeline"]
+ manifest = json.loads((tmp_path / pipeline["manifest"]).read_text())
+ assert manifest["partial"] is True
+ assert manifest["failed_stage"] == "operations[1]"
+ assert "psd" not in manifest
+ assert manifest["stages"][-1]["status"] == "skipped"
+ assert (tmp_path / pipeline["exports"][0]["path"]).is_file()
+
+
+@pytest.mark.parametrize("missing", ["welch", "get_window", "module"])
+def test_psd_dependency_checked_before_hardware(tmp_path, missing):
+ plan = plan_for(tmp_path, [dict(op="psd", **PARAMS), EXPORT])
+ service = RunService(config=make_config(str(tmp_path)), logger=CommandLogger())
+ available = {name: lambda: None for name in ("welch", "get_window") if name != missing}
+ with patch("wavebench.services.run_pipeline.import_module",
+ side_effect=ImportError if missing == "module" else None,
+ return_value=SimpleNamespace(**available)), \
+ patch.object(service, "_run_instrument_services") as hardware:
+ with pytest.raises(ConfigError, match=r"\.\[analysis\]"):
+ service.run(plan)
+ hardware.assert_not_called()
+
+
+def test_numpy_only_does_not_require_scipy(tmp_path):
+ plan = plan_for(tmp_path, [EXPORT])
+ with patch("wavebench.services.run_pipeline.import_module") as imported:
+ ensure_analysis_pipeline_dependencies(plan)
+ imported.assert_not_called()
From 6844a33b6110b52b0bdd26900986d0833acfea42 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 11:50:21 +0800
Subject: [PATCH 08/30] feat(analysis): run standalone recipes on historical
captures
---
docs/reference/artifacts.md | 6 ++
docs/reference/run-schema.md | 11 +++
plans/example_analysis_recipe.toml | 8 ++
src/wavebench/cli.py | 8 ++
src/wavebench/cli_parser.py | 9 ++
src/wavebench/services/analysis_service.py | 104 +++++++++++++++++++++
src/wavebench/services/run_pipeline.py | 58 ++++++++----
src/wavebench/services/run_plan.py | 4 +
tests/test_analysis_service.py | 99 ++++++++++++++++++++
9 files changed, 287 insertions(+), 20 deletions(-)
create mode 100644 plans/example_analysis_recipe.toml
create mode 100644 src/wavebench/services/analysis_service.py
create mode 100644 tests/test_analysis_service.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 84dc22d6..7c9bf27d 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -1,5 +1,11 @@
# 运行产物 Reference
+## 独立离线分析
+
+`analysis run` 在新输出目录写入 `analysis.json`、`manifest.json`、`metrics.json` 和 `exports/`。`analysis.json` 使用 `wavebench.analysis.v1`,包含总体状态、WaveBench 版本、规范化配方及其 SHA-256、来源与处理结果。manifest 使用 `wavebench.offline_pipeline.v1`,复用 stage 与数值字段;派生路径以该分析目录为基准。
+
+离线来源记录 capture package 绝对路径、通道、包内相对 NPY 路径及原始摘要。来源没有状态字段时记录 `null`,不推断为采集成功。不生成虚构 run 或采集 step,既有 RunPlan 的产物 schema 与来源路径合同保持不变。
+
本页说明 `run plan` 写入的运行产物入口。字段的 machine source 是 `src/wavebench/services/run_artifacts.py` 和对应的 typed result;不要从旧 Guide 推断新增或可选字段。
## 输出
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index c0b9b8a7..f2fecea9 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -1,5 +1,16 @@
# run plan Reference
+## 独立离线配方
+
+`analysis` 命令直接处理历史 capture package,不需要仪器配置。显式选择一个通道,配方只包含 `schema = "wavebench.analysis_recipe.v1"`、`operations` 和可选 `[expect]`,共用下文的算子与验收合同。示例为 `plans/example_analysis_recipe.toml`。
+
+```bash
+wavebench analysis check --capture data/capture --channel 1 --recipe plans/example_analysis_recipe.toml
+wavebench analysis run --capture data/capture --channel 1 --recipe plans/example_analysis_recipe.toml --output data/analysis_trial_1
+```
+
+输出必须是新的独立目录,不能位于来源 capture package 或既有 run 内。再次分析应使用另一个输出目录。来源读取或算子失败写入分析产物;配置和输出目录不合法时在执行前拒绝。验收失败时命令返回非零状态。
+
本页说明如何查询 WaveBench 当前支持的 run plan 结构。完整的 step、必填字段、可选字段和简要行为由离线命令生成,不在 Guide 中复制维护。
## Synopsis
diff --git a/plans/example_analysis_recipe.toml b/plans/example_analysis_recipe.toml
new file mode 100644
index 00000000..0d89c347
--- /dev/null
+++ b/plans/example_analysis_recipe.toml
@@ -0,0 +1,8 @@
+schema = "wavebench.analysis_recipe.v1"
+operations = [
+ { op = "remove_dc" },
+ { op = "window", name = "hann" },
+ { op = "fft" },
+ { op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v"] },
+ { op = "export", name = "spectrum", formats = ["npy", "csv"] },
+]
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index c93468f7..e858609c 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -1076,6 +1076,14 @@ def _main(argv: list[str] | None = None) -> int:
refresh_interval_s=args.refresh_interval,
log_path=args.log_file,
)
+ if args.domain == "analysis":
+ from .services.analysis_service import check_analysis, run_analysis
+
+ options = dict(capture=Path(args.capture), channel=args.channel, recipe=Path(args.recipe))
+ result = (run_analysis(**options, output=Path(args.output))
+ if args.command == "run" else check_analysis(**options))
+ print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False))
+ return 0 if result["status"] == "ok" else 1
if args.domain == "capture":
if args.command == "inspect":
package = load_capture_package(args.path)
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index b73f1600..77057b4b 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -43,6 +43,15 @@ def build_parser() -> argparse.ArgumentParser:
sweep_parser = subparsers.add_parser("sweep", help="Source/scope sweep commands")
run_parser = subparsers.add_parser("run", help="Multi-instrument run plan commands")
capture_parser = subparsers.add_parser("capture", help="Offline capture package commands")
+ analysis_parser = subparsers.add_parser("analysis", help="Offline signal processing")
+ analysis_sub = analysis_parser.add_subparsers(dest="command", required=True)
+ for command in ("check", "run"):
+ analysis_command = analysis_sub.add_parser(command)
+ analysis_command.add_argument("--capture", required=True)
+ analysis_command.add_argument("--channel", type=int, required=True)
+ analysis_command.add_argument("--recipe", required=True)
+ if command == "run":
+ analysis_command.add_argument("--output", required=True)
mcp_parser = subparsers.add_parser("mcp", help="HTTP MCP server / HTTP MCP 服务")
tui_parser = subparsers.add_parser("tui", help="Launch terminal UI / 启动终端界面")
net_parser = subparsers.add_parser("net", help="Network discovery helpers / 网络发现工具")
diff --git a/src/wavebench/services/analysis_service.py b/src/wavebench/services/analysis_service.py
new file mode 100644
index 00000000..db4d701d
--- /dev/null
+++ b/src/wavebench/services/analysis_service.py
@@ -0,0 +1,104 @@
+"""Offline recipes reuse the RunPlan operator contract and execution engine."""
+from __future__ import annotations
+
+from hashlib import sha256
+from pathlib import Path
+import json
+import tomllib
+from typing import Any
+
+import numpy as np
+
+from wavebench import __version__
+from wavebench.data.packages import load_capture_package
+from wavebench.data.signal_pipeline import validate_waveform
+from wavebench.errors import ConfigError, DataError
+from wavebench.services.run_pipeline import (
+ _atomic_write_json, _resolve_package_member, _sha256_file,
+ ensure_operation_dependencies, execute_pipeline,
+)
+from wavebench.services.run_plan import normalize_analysis_operations
+
+
+RECIPE_SCHEMA = "wavebench.analysis_recipe.v1"
+RESULT_SCHEMA = "wavebench.analysis.v1"
+
+
+def load_analysis_recipe(path: str | Path) -> dict[str, Any]:
+ try:
+ fields = tomllib.loads(Path(path).read_text(encoding="utf-8-sig"))
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
+ raise ConfigError(f"cannot read analysis recipe: {exc}") from exc
+ if fields.get("schema") != RECIPE_SCHEMA:
+ raise ConfigError(f"analysis recipe schema must be {RECIPE_SCHEMA}")
+ if set(fields) - {"schema", "operations", "expect"}:
+ raise ConfigError("analysis recipe has unknown fields")
+ if "operations" not in fields:
+ raise ConfigError("analysis recipe requires operations")
+ normalize_analysis_operations("recipe", fields)
+ ensure_operation_dependencies(fields["operations"])
+ return fields
+
+
+def load_analysis_source(capture: Path, channel: int) -> tuple[dict[str, Any], np.ndarray]:
+ if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1:
+ raise ConfigError("analysis channel must be a positive integer")
+ package_path = capture.resolve()
+ _resolve_package_member(package_path, "metadata.json", label="metadata")
+ try:
+ package = load_capture_package(package_path)
+ except (TypeError, ValueError, KeyError) as exc:
+ raise DataError(f"invalid capture metadata: {exc}") from exc
+ candidates = [item for item in package.channels if item.channel == channel]
+ if len(candidates) != 1:
+ raise DataError(f"capture must contain exactly one channel {channel}")
+ raw = candidates[0].files.get("npy")
+ if not isinstance(raw, str) or not raw:
+ raise DataError("selected capture channel has no NPY")
+ path = _resolve_package_member(package_path, raw, label="NPY")
+ try:
+ waveform = np.load(path, allow_pickle=False)
+ validate_waveform(waveform)
+ except (OSError, ValueError) as exc:
+ raise DataError(f"cannot load capture waveform: {exc}") from exc
+ return {
+ "kind": "capture_package", "package": str(package_path), "channel": channel,
+ "npy": path.relative_to(package_path).as_posix(), "npy_sha256": _sha256_file(path),
+ "status": package.metadata.get("status") if isinstance(package.metadata.get("status"), str) else None,
+ }, waveform
+
+
+def check_analysis(capture: Path, channel: int, recipe: Path) -> dict[str, Any]:
+ fields = load_analysis_recipe(recipe)
+ source, data = load_analysis_source(capture, channel)
+ return {"schema": "wavebench.analysis_check.v1", "status": "ok", "source": source,
+ "samples": len(data), "recipe": fields}
+
+
+def run_analysis(capture: Path, channel: int, recipe: Path, output: Path) -> dict[str, Any]:
+ fields = load_analysis_recipe(recipe)
+ capture = capture.resolve()
+ output = output.resolve()
+ if output.exists():
+ raise ConfigError("analysis output must be a new directory")
+ if output == capture or capture in output.parents or output in capture.parents:
+ raise ConfigError("analysis output must be separate from the capture package")
+ if any((parent / "run.json").exists() for parent in output.parents):
+ raise ConfigError("analysis output must not modify an existing run")
+ source = {"kind": "capture_package", "package": str(capture), "channel": channel, "status": None}
+ artifact = execute_pipeline(
+ run_dir=output, processing_dir=output, fields=fields, source=source,
+ load_source=lambda: load_analysis_source(capture, channel),
+ schema="wavebench.offline_pipeline.v1",
+ )
+ failed = artifact["analysis_pipeline"]["status"] == "failed"
+ if "expect" in artifact:
+ failed = failed or artifact["expect"]["status"] != "ok"
+ result = {
+ "schema": RESULT_SCHEMA, "status": "failed" if failed else "ok",
+ "wavebench_version": __version__, "source": source, "recipe": fields,
+ "recipe_sha256": sha256(json.dumps(fields, sort_keys=True, allow_nan=False).encode()).hexdigest(),
+ "artifact": artifact,
+ }
+ _atomic_write_json(output / "analysis.json", result)
+ return result
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index b9cf4c89..cedf817c 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -7,7 +7,7 @@
import os
from pathlib import Path
import tempfile
-from typing import Any, Iterator
+from typing import Any, Callable, Iterator
import numpy as np
@@ -39,11 +39,17 @@
def ensure_analysis_pipeline_dependencies(plan: RunPlan) -> None:
- operations = [
+ ensure_operation_dependencies([
operation
- for step in plan.steps
- if step.kind == "analysis.pipeline"
+ for step in plan.steps if step.kind == "analysis.pipeline"
for operation in step.fields["operations"]
+ ])
+
+
+def ensure_operation_dependencies(all_operations: list[dict[str, Any]]) -> None:
+ operations = [
+ operation
+ for operation in all_operations
if operation["op"] in {"filter", "psd"}
]
if not operations:
@@ -96,11 +102,32 @@ def execute_analysis_pipeline(
processing_dir = run_dir / "processing" / (
f"{step.index:02d}_{step.id or 'analysis_pipeline'}"
)
+ def load_source() -> tuple[dict[str, Any], np.ndarray]:
+ _, details, waveform = _load_source_waveform(
+ run_dir=run_dir, source_step=source_step, source_record=source_record,
+ )
+ return details, waveform
+
+ return execute_pipeline(
+ run_dir=run_dir, processing_dir=processing_dir, fields=step.fields,
+ source={
+ "step": source_step.id, "step_index": source_step.index,
+ "status": source_record.status if source_record is not None else "unavailable",
+ },
+ load_source=load_source,
+ )
+
+
+def execute_pipeline(
+ *, run_dir: Path, processing_dir: Path, fields: dict[str, Any],
+ source: dict[str, Any], load_source: Callable[[], tuple[dict[str, Any], np.ndarray]],
+ schema: str = ANALYSIS_PIPELINE_SCHEMA,
+) -> dict[str, Any]:
processing_dir.mkdir(parents=True, exist_ok=False)
metrics_path = processing_dir / "metrics.json"
manifest_path = processing_dir / "manifest.json"
- operations = step.fields["operations"]
+ operations = fields["operations"]
metrics: dict[str, float | None] = {
metric: None
for operation in operations
@@ -114,20 +141,11 @@ def execute_analysis_pipeline(
sampling: dict[str, Any] | None = None
window: dict[str, Any] | None = None
psd: dict[str, Any] | None = None
- source: dict[str, Any] = {
- "step": source_step.id,
- "step_index": source_step.index,
- "status": source_record.status if source_record is not None else "unavailable",
- }
failure: dict[str, Any] | None = None
failed_stage: str | None = None
try:
- _, source_details, waveform = _load_source_waveform(
- run_dir=run_dir,
- source_step=source_step,
- source_record=source_record,
- )
+ source_details, waveform = load_source()
source.update(source_details)
signal: TimeSignal | FrequencySignal | PsdSignal = validate_waveform(waveform)
sampling = _time_sampling(signal)
@@ -300,7 +318,7 @@ def execute_analysis_pipeline(
"metrics": metrics,
}
manifest: dict[str, Any] = {
- "schema": ANALYSIS_PIPELINE_SCHEMA,
+ "schema": schema,
"status": status,
"partial": partial,
"source": source,
@@ -330,11 +348,11 @@ def execute_analysis_pipeline(
_atomic_write_json(manifest_path, manifest)
pipeline_artifact: dict[str, Any] = {
- "schema": ANALYSIS_PIPELINE_SCHEMA,
+ "schema": schema,
"status": status,
"manifest": _derived_relative(manifest_path, run_dir),
"metrics": _derived_relative(metrics_path, run_dir),
- "source_step": source_step.id,
+ "source_step": source.get("step"),
"source_status": source["status"],
"operations": operations,
"warnings": warnings,
@@ -349,8 +367,8 @@ def execute_analysis_pipeline(
"analysis_pipeline": pipeline_artifact,
"metrics": metrics,
}
- if "expect" in step.fields:
- artifact["expect"] = evaluate_expect(metrics, step.fields["expect"])
+ if "expect" in fields:
+ artifact["expect"] = evaluate_expect(metrics, fields["expect"])
return artifact
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index efc58b4e..be9a8bee 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -1273,6 +1273,10 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) ->
fields["source"] = {
"step": _normalize_step_id(source["step"], f"{prefix}.source.step")
}
+ normalize_analysis_operations(prefix, fields)
+
+
+def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
raw_operations = fields["operations"]
if not isinstance(raw_operations, list) or not raw_operations:
diff --git a/tests/test_analysis_service.py b/tests/test_analysis_service.py
new file mode 100644
index 00000000..3d6c585c
--- /dev/null
+++ b/tests/test_analysis_service.py
@@ -0,0 +1,99 @@
+import json
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+from wavebench.cli import main
+from wavebench.errors import ConfigError
+from wavebench.services.analysis_service import check_analysis, run_analysis
+
+
+@pytest.fixture
+def analysis_input(tmp_path):
+ capture = tmp_path / "capture"
+ capture.mkdir()
+ np.save(capture / "ch1.npy", np.column_stack((np.arange(128.) / 128, np.ones(128))))
+ (capture / "metadata.json").write_text(json.dumps({
+ "waveform": {"summary": {"channel": 1}}, "files": {"npy": "ch1.npy"}
+ }))
+ recipe = tmp_path / "recipe.toml"
+ recipe.write_text('''schema = "wavebench.analysis_recipe.v1"
+operations = [
+ {op="measure", metrics=["voltage_mean_v"]},
+ {op="export", name="waveform", formats=["npy", "csv"]},
+]
+[expect]
+voltage_mean_v = {min=0.9, max=1.1}
+''')
+ return capture, recipe
+
+
+def test_offline_execution_without_config_and_source_unchanged(tmp_path, analysis_input):
+ capture, recipe = analysis_input
+ original = {path.name: path.read_bytes() for path in capture.iterdir()}
+ output = tmp_path / "analysis"
+ with patch("wavebench.cli.load_config", side_effect=AssertionError("hardware config")):
+ assert main(["analysis", "check", "--capture", str(capture), "--channel", "1",
+ "--recipe", str(recipe)]) == 0
+ assert main(["analysis", "run", "--capture", str(capture), "--channel", "1",
+ "--recipe", str(recipe), "--output", str(output)]) == 0
+ result = json.loads((output / "analysis.json").read_text())
+ assert result["artifact"]["metrics"] == {"voltage_mean_v": 1.0}
+ assert result["source"]["status"] is None
+ assert result["source"]["npy"] == "ch1.npy"
+ manifest = json.loads((output / "manifest.json").read_text())
+ assert manifest["schema"] == "wavebench.offline_pipeline.v1"
+ assert manifest["exports"][0]["path"] == "exports/waveform.npy"
+ assert {p.name: p.read_bytes() for p in capture.iterdir()} == original
+ assert not (output / "run.json").exists()
+ assert run_analysis(capture, 1, recipe, tmp_path / "again") == result
+
+
+def test_multichannel_selection(tmp_path, analysis_input):
+ capture, recipe = analysis_input
+ np.save(capture / "ch2.npy", np.column_stack((np.arange(128.), np.ones(128) * 2)))
+ (capture / "metadata.json").write_text(json.dumps({
+ "channels": {"1": {}, "2": {}},
+ "files": {"1": {"npy": "ch1.npy"}, "2": {"npy": "ch2.npy"}},
+ }))
+ result = run_analysis(capture, 2, recipe, tmp_path / "analysis")
+ assert result["artifact"]["metrics"]["voltage_mean_v"] == 2
+ assert result["status"] == "failed"
+ assert result["artifact"]["analysis_pipeline"]["status"] == "ok"
+
+
+@pytest.mark.parametrize("fault", ["channel", "missing_npy", "escape", "symlink", "bad_array"])
+def test_source_failure_is_recorded(tmp_path, analysis_input, fault):
+ capture, recipe = analysis_input
+ channel = 2 if fault == "channel" else 1
+ if fault == "missing_npy":
+ (capture / "ch1.npy").unlink()
+ elif fault == "escape":
+ (capture / "metadata.json").write_text(json.dumps({
+ "waveform": {"summary": {"channel": 1}}, "files": {"npy": "../outside.npy"}
+ }))
+ elif fault == "symlink":
+ outside = tmp_path / "outside.npy"
+ (capture / "ch1.npy").rename(outside)
+ (capture / "ch1.npy").symlink_to(outside)
+ elif fault == "bad_array":
+ np.save(capture / "ch1.npy", np.ones(10))
+ result = run_analysis(capture, channel, recipe, tmp_path / "analysis")
+ assert result["status"] == "failed"
+ assert result["artifact"]["analysis_pipeline"]["failed_stage"] == "source"
+
+
+def test_output_boundaries_and_strict_recipe(tmp_path, analysis_input):
+ capture, recipe = analysis_input
+ for output in (capture, capture / "derived", tmp_path):
+ with pytest.raises(ConfigError):
+ run_analysis(capture, 1, recipe, output)
+ run = tmp_path / "run"
+ run.mkdir()
+ (run / "run.json").write_text("{}")
+ with pytest.raises(ConfigError, match="existing run"):
+ run_analysis(capture, 1, recipe, run / "derived")
+ recipe.write_text(recipe.read_text().replace("operations =", "unknown = 1\noperations ="))
+ with pytest.raises(ConfigError, match="unknown"):
+ check_analysis(capture, 1, recipe)
From 2671c997cfc258354ca487502cf7ba338ee412f3 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 11:55:55 +0800
Subject: [PATCH 09/30] feat(report): plot and compare persisted signal
analysis exports
---
docs/reference/artifacts.md | 4 +
src/wavebench/cli.py | 5 ++
src/wavebench/cli_parser.py | 3 +
src/wavebench/report/analysis.py | 145 +++++++++++++++++++++++++++++++
src/wavebench/report/html.py | 7 ++
tests/test_analysis_report.py | 87 +++++++++++++++++++
6 files changed, 251 insertions(+)
create mode 100644 src/wavebench/report/analysis.py
create mode 100644 tests/test_analysis_report.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 7c9bf27d..d80831f6 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -2,6 +2,10 @@
## 独立离线分析
+`analysis report [...] --output comparison.html` 读取已保存的导出,生成独立 HTML;输出文件必须尚不存在。常规 run HTML 报告也会展示派生曲线。时域和 FFT 纵轴单位为 V,PSD 为 V²/Hz,使用线性坐标;同来源摘要、通道与数据域的曲线可叠加,不同来源分开显示。图形保留真实横轴,不自动补偿滤波延迟。
+
+报告检查导出路径与 SHA-256,损坏或缺失导出显示警告。NPY 和 CSV 同时存在时优先读取 NPY,读取失败可回退到 CSV。显示抽稀保留局部极值,指标始终来自完整数据的既有产物;报告不重新执行算子。
+
`analysis run` 在新输出目录写入 `analysis.json`、`manifest.json`、`metrics.json` 和 `exports/`。`analysis.json` 使用 `wavebench.analysis.v1`,包含总体状态、WaveBench 版本、规范化配方及其 SHA-256、来源与处理结果。manifest 使用 `wavebench.offline_pipeline.v1`,复用 stage 与数值字段;派生路径以该分析目录为基准。
离线来源记录 capture package 绝对路径、通道、包内相对 NPY 路径及原始摘要。来源没有状态字段时记录 `null`,不推断为采集成功。不生成虚构 run 或采集 step,既有 RunPlan 的产物 schema 与来源路径合同保持不变。
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index e858609c..3bd6483c 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -1077,6 +1077,11 @@ def _main(argv: list[str] | None = None) -> int:
log_path=args.log_file,
)
if args.domain == "analysis":
+ if args.command == "report":
+ from .report.analysis import write_analysis_report
+
+ print(write_analysis_report([Path(path) for path in args.paths], Path(args.output)))
+ return 0
from .services.analysis_service import check_analysis, run_analysis
options = dict(capture=Path(args.capture), channel=args.channel, recipe=Path(args.recipe))
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index 77057b4b..0c8fec6d 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -45,6 +45,9 @@ def build_parser() -> argparse.ArgumentParser:
capture_parser = subparsers.add_parser("capture", help="Offline capture package commands")
analysis_parser = subparsers.add_parser("analysis", help="Offline signal processing")
analysis_sub = analysis_parser.add_subparsers(dest="command", required=True)
+ analysis_report = analysis_sub.add_parser("report", help="Plot persisted analysis exports")
+ analysis_report.add_argument("paths", nargs="+")
+ analysis_report.add_argument("--output", required=True)
for command in ("check", "run"):
analysis_command = analysis_sub.add_parser(command)
analysis_command.add_argument("--capture", required=True)
diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py
new file mode 100644
index 00000000..d7a05385
--- /dev/null
+++ b/src/wavebench/report/analysis.py
@@ -0,0 +1,145 @@
+"""Read-only visualisation of persisted pipeline exports."""
+from __future__ import annotations
+
+from html import escape
+import json
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from wavebench.errors import ConfigError
+from wavebench.services.run_pipeline import _sha256_file, _atomic_write_bytes
+
+
+COLUMNS = {
+ ("time_s", "voltage_v"): (1, "time_s", "V"),
+ ("frequency_hz", "real_v", "imaginary_v", "amplitude_v"): (3, "frequency_hz", "V"),
+ ("frequency_hz", "psd_v2_per_hz"): (1, "frequency_hz", "V²/Hz"),
+}
+
+
+def artifact_file(root: Path, raw: str) -> Path:
+ if not isinstance(raw, str) or not raw or Path(raw).is_absolute() or ".." in Path(raw).parts:
+ raise ValueError("artifact path must be relative to its result directory")
+ path = (root / raw).resolve()
+ if not path.is_relative_to(root.resolve()) or not path.is_file():
+ raise ValueError("artifact escapes its result directory or is missing")
+ return path
+
+
+def analysis_entries(root: Path) -> list[tuple[Path, str, dict[str, Any]]]:
+ try:
+ if (root / "analysis.json").is_file():
+ result = json.loads((root / "analysis.json").read_text())
+ if result["schema"] != "wavebench.analysis.v1":
+ raise ValueError("unsupported analysis schema")
+ return [(root, root.name, result["artifact"])]
+ run = json.loads((root / "run.json").read_text())
+ return [(root, f"{root.name}/{step.get('id', step['index'])}", step["artifact"])
+ for step in run["steps"] if step["kind"] == "analysis.pipeline"]
+ except (OSError, ValueError, KeyError, TypeError) as exc:
+ raise ConfigError(f"cannot read analysis results in {root}: {exc}") from exc
+
+
+def display_samples(data: np.ndarray, maximum: int = 1200) -> np.ndarray:
+ """Preserve extrema within display buckets; never use this data for metrics."""
+ if len(data) <= maximum:
+ return data
+ indices = {0, len(data) - 1}
+ edges = np.linspace(0, len(data), (maximum - 2) // 2 + 1, dtype=int)
+ for start, stop in zip(edges[:-1], edges[1:]):
+ chunk = data[start:stop, 1]
+ indices.update((start + int(np.argmin(chunk)), start + int(np.argmax(chunk))))
+ return data[sorted(indices)]
+
+
+def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str) -> str:
+ width, height, pad = 900, 300, 55
+ xmin = min(float(data[0, 0]) for _, data in curves)
+ xmax = max(float(data[-1, 0]) for _, data in curves)
+ ymin = min(float(np.min(data[:, 1])) for _, data in curves)
+ ymax = max(float(np.max(data[:, 1])) for _, data in curves)
+ if ymax == ymin:
+ margin = max(abs(ymin) * .05, 1e-12)
+ ymin, ymax = ymin - margin, ymax + margin
+ colors = ("#2563eb", "#dc2626", "#059669", "#9333ea", "#d97706")
+ parts = [f'',
+ ' ']
+ for fraction in np.linspace(0, 1, 5):
+ x, y = pad + fraction * (width - 2 * pad), height - pad - fraction * (height - 2 * pad)
+ parts.append(f'{xmin + fraction * (xmax-xmin):.5g} ')
+ parts.append(f'{ymin + fraction * (ymax-ymin):.5g} ')
+ for index, (label, data) in enumerate(curves):
+ sampled = display_samples(data)
+ points = " ".join(f"{pad+(x-xmin)/(xmax-xmin)*(width-2*pad):.2f},{height-pad-(y-ymin)/(ymax-ymin)*(height-2*pad):.2f}"
+ for x, y in sampled)
+ parts.append(f'{escape(label)} ')
+ parts.append(f'{escape(x_label)} {escape(units)} ')
+ parts.append("" + "".join(f'{escape(label)} ' for i, (label, _) in enumerate(curves)) + " ")
+ return "".join(parts)
+
+
+def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *, details: bool = True) -> str:
+ groups: dict[tuple, list[tuple[str, np.ndarray]]] = {}
+ sections: list[str] = []
+ for root, label, artifact in entries:
+ try:
+ pipeline = artifact["analysis_pipeline"]
+ manifest = json.loads(artifact_file(root, pipeline["manifest"]).read_text())
+ if details:
+ sections.append(f"{escape(label)} {escape(json.dumps(artifact, indent=2, ensure_ascii=False))} ")
+ sections.append(f'{escape(label)}: sampling={escape(json.dumps(manifest.get("sampling")))}
')
+ source = manifest["source"]
+ seen: set[tuple] = set()
+ for item in sorted(manifest["exports"], key=lambda x: x.get("format") != "npy"):
+ try:
+ columns = tuple(item["columns"])
+ column, x_label, units = COLUMNS[columns]
+ identity = (item["name"], columns)
+ if identity in seen:
+ continue
+ path = artifact_file(root, item["path"])
+ if _sha256_file(path) != item["sha256"]:
+ raise ValueError("export SHA-256 mismatch")
+ data = (np.load(path, allow_pickle=False, mmap_mode="r") if item["format"] == "npy"
+ else np.loadtxt(path, delimiter=",", skiprows=1, ndmin=2))
+ if data.ndim != 2 or data.shape[1] != len(columns) or len(data) < 2:
+ raise ValueError("invalid export shape")
+ if np.iscomplexobj(data) or not np.all(np.isfinite(data)) or not np.all(np.diff(data[:, 0]) > 0):
+ raise ValueError("invalid export values or axis")
+ seen.add(identity)
+ key = (source.get("npy_sha256") or label, source.get("channel"), columns)
+ groups.setdefault(key, []).append((f"{label}: {item['name']}", data[:, [0, column]]))
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ sections.append(f'{escape(label)}: curve unavailable: {escape(str(exc))}
')
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ sections.append(f'{escape(label)}: analysis unavailable: {escape(str(exc))}
')
+ for (_, _, columns), curves in groups.items():
+ _, x_label, units = COLUMNS[columns]
+ sections.append(curves_svg(curves, x_label, units))
+ if entries and not groups:
+ sections.append("No usable curve exports / 没有可用的曲线导出
")
+ return "".join(sections)
+
+
+def write_analysis_report(paths: list[Path], output: Path) -> Path:
+ entries = [entry for root in paths for entry in analysis_entries(root.resolve())]
+ output = output.resolve()
+ if output.exists():
+ raise ConfigError("analysis report output must be a new file")
+ for root, _, artifact in entries:
+ try:
+ manifest = json.loads(artifact_file(root, artifact["analysis_pipeline"]["manifest"]).read_text())
+ package = Path(manifest["source"]["package"])
+ package = package if package.is_absolute() else root / package
+ if output.is_relative_to(package.resolve()):
+ raise ConfigError("analysis report must not modify a source capture package")
+ except (OSError, ValueError, KeyError, TypeError):
+ pass # Broken manifests are displayed as per-entry errors below.
+ html = (' '
+ 'Signal processing '
+ '信号处理 / Signal processing ' + render_analysis_sections(entries) + '')
+ output.parent.mkdir(parents=True, exist_ok=True)
+ _atomic_write_bytes(output, html.encode("utf-8"))
+ return output
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 48d58ebb..2fdaf088 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -2035,6 +2035,12 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str:
)
if not rows:
return ""
+ from .analysis import render_analysis_sections
+
+ curves = render_analysis_sections([
+ (run.path, str(step.get("id", step["index"])), step.get("artifact", {}))
+ for step in run.steps if step.get("kind") == "analysis.pipeline"
+ ], details=False)
return f"""信号处理 / Signal processing
步骤 / Step 状态 / Status 来源 / Source 算子 / Operations 指标 / Metrics 警告 / Warnings 失败阶段 / Failed stage 产物 / Artifacts
@@ -2042,6 +2048,7 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str:
{chr(10).join(rows)}
+{curves}
"""
diff --git a/tests/test_analysis_report.py b/tests/test_analysis_report.py
new file mode 100644
index 00000000..c504591d
--- /dev/null
+++ b/tests/test_analysis_report.py
@@ -0,0 +1,87 @@
+import json
+
+import numpy as np
+import pytest
+
+from wavebench.errors import ConfigError
+from wavebench.report.analysis import display_samples, write_analysis_report
+from wavebench.services.analysis_service import run_analysis
+from test_analysis_service import analysis_input as analysis_input
+
+
+def test_comparison_uses_saved_exports_and_preserves_metrics(tmp_path, analysis_input):
+ capture, recipe = analysis_input
+ first, second = tmp_path / "first", tmp_path / "second"
+ run_analysis(capture, 1, recipe, first)
+ run_analysis(capture, 1, recipe, second)
+ original = (first / "metrics.json").read_bytes()
+ html = write_analysis_report([first, second], tmp_path / "compare.html").read_text()
+ assert html.count("
Date: Sat, 5 Sep 2026 12:02:52 +0800
Subject: [PATCH 10/30] fix(report): protect capture directories with damaged
manifests
---
src/wavebench/report/analysis.py | 2 ++
tests/test_analysis_report.py | 3 +++
2 files changed, 5 insertions(+)
diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py
index d7a05385..d7dca1ca 100644
--- a/src/wavebench/report/analysis.py
+++ b/src/wavebench/report/analysis.py
@@ -128,6 +128,8 @@ def write_analysis_report(paths: list[Path], output: Path) -> Path:
output = output.resolve()
if output.exists():
raise ConfigError("analysis report output must be a new file")
+ if any((parent / "metadata.json").is_file() for parent in output.parents):
+ raise ConfigError("analysis report must not modify a source capture package")
for root, _, artifact in entries:
try:
manifest = json.loads(artifact_file(root, artifact["analysis_pipeline"]["manifest"]).read_text())
diff --git a/tests/test_analysis_report.py b/tests/test_analysis_report.py
index c504591d..6a01b3e1 100644
--- a/tests/test_analysis_report.py
+++ b/tests/test_analysis_report.py
@@ -26,6 +26,9 @@ def test_comparison_uses_saved_exports_and_preserves_metrics(tmp_path, analysis_
write_analysis_report([first], tmp_path / "compare.html")
with pytest.raises(ConfigError, match="source capture"):
write_analysis_report([first], capture / "report.html")
+ (first / "manifest.json").write_text("invalid json")
+ with pytest.raises(ConfigError, match="source capture"):
+ write_analysis_report([first], capture / "report.html")
def test_bad_exports_fall_back_to_csv_and_show_warning(tmp_path, analysis_input):
From c317913818832d6c4db4f938de2a73fe816bf309 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 12:02:53 +0800
Subject: [PATCH 11/30] feat(analysis): measure named PSD bands with
expectations
---
docs/reference/artifacts.md | 2 +-
docs/reference/generated/run-schema.md | 4 +-
docs/reference/run-schema.md | 15 +++-
plans/example_psd_recipe.toml | 7 ++
src/wavebench/data/pipeline_operations.py | 85 +++++++++++++++++++++++
src/wavebench/services/run_pipeline.py | 12 ++++
src/wavebench/services/run_plan.py | 31 +++++++--
tests/test_pipeline_band.py | 85 +++++++++++++++++++++++
8 files changed, 231 insertions(+), 10 deletions(-)
create mode 100644 plans/example_psd_recipe.toml
create mode 100644 src/wavebench/data/pipeline_operations.py
create mode 100644 tests/test_pipeline_band.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index d80831f6..64149553 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -86,7 +86,7 @@ IIR 项记录 design、响应、截止频率、原型阶数、变换后的数字
成功执行 PSD 时,manifest 条件性增加 `psd` 对象,并在对应 stage 中记录同一份元数据,输出域为 `psd`。该对象包括规范化参数、执行函数、SciPy 版本、实际采样率、周期窗标记、窗功率增益、窗 SHA-256、完整分段数和丢弃尾点数。窗 SHA-256 使用实际周期窗的 little-endian float64 字节计算。`bin_spacing_hz` 为采样率除以 `nfft`;`segment_frequency_scale_hz` 为采样率除以 `nperseg`,不表示加窗后的等效噪声带宽。
-PSD 元数据同时记录单边密度缩放、`V^2/Hz` 单位和归一化公式。仅有一段或存在尾点时写入警告;后续导出失败仍保留成功 PSD 的元数据。没有成功 PSD 的流水线不增加 `psd` 字段,schema 继续使用 `wavebench.analysis_pipeline.v1`。PSD 不产生新的标量指标,未选择时域测量时 `metrics` 为空映射。HTML 报告显示 Welch 分段参数、警告和导出链接。
+PSD 元数据同时记录单边密度缩放、`V^2/Hz` 单位和归一化公式。仅有一段或存在尾点时写入警告;后续导出失败仍保留成功 PSD 的元数据。没有成功 PSD 的流水线不增加 `psd` 字段,schema 继续使用 `wavebench.analysis_pipeline.v1`。`measure_band` 在对应 stage 的 `measurement` 中记录选中 bin 数量、间距、积分和边界规则,以及 Welch 平均方式;标量保存为 `_` 并复用现有 `metrics` 与 `expect`。没有测量算子时 `metrics` 为空映射。HTML 报告显示 Welch 分段参数、警告和导出链接。
频域 `amplitude_v` 是单边峰值幅度,不是 RMS。`noise_floor_v` 是排除 DC 与主峰后的非 DC 幅度 bin 中位数,表示每 bin 峰值幅度,不表示积分噪声。THD 使用 Nyquist 范围内的 H2~H5。
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index b7170424..1584794a 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -246,11 +246,11 @@ scope.capture [steps.expect_fft] metrics:
analysis.pipeline metrics:
Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.
Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.
- PSD domain: export only; no scalar metrics.
+ PSD domain: measure_band requires name, band_hz, exclude_hz and metrics=mean_square_v2|rms_v|noise_rms_v. Metric keys are _.
analysis.pipeline PSD operation:
psd requires method=welch, window=hann|hamming|blackman, nperseg>=4, 0<=noverlap=nperseg, detrend=none|constant|linear, average=mean|median.
All parameters are explicit; lengths are integers. Segment windows are periodic.
- Requires time data before window or fft. Only export may follow psd; at least one PSD export is required.
+ Requires time data before window or fft. Only export or measure_band may follow psd; at least one PSD result is required.
Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.
```
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index f2fecea9..f089bf9d 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -86,6 +86,7 @@ thd_ratio = { max = 0.05 }
| `fft` | 无 | 时域 → 频域 |
| `psd` | Welch 分段参数,见下文 | 时域 → PSD |
| `measure` | 非空 `metrics` 数组 | 观察当前域,不改变数据 |
+| `measure_band` | `name`、`band_hz`、`exclude_hz`、`metrics` | 观察 PSD,不改变数据 |
| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR/IIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
@@ -157,7 +158,19 @@ PSD 算子将时域数据转换为单边功率谱密度,单位为 `V²/Hz`。
| `detrend` | `none`、`constant` 或 `linear`,在每段加窗前执行 |
| `average` | `mean` 或经过偏差修正的 `median` |
-PSD 可以跟在去直流、去趋势或 FIR/IIR 之后,但不能跟在整段 `window` 或 `fft` 之后。每条流水线至多有一个 PSD;PSD 之后只允许 `export`,且至少导出一次。需要同时生成 FFT 和 PSD 时,使用两个分析 step 引用同一个 capture。PSD 之前可以测量时域指标,PSD 本身暂不提供标量指标,不能复用 FFT 的峰值幅度、THD 或噪声底。
+PSD 可以跟在去直流、去趋势或 FIR/IIR 之后,但不能跟在整段 `window` 或 `fft` 之后。每条流水线至多有一个 PSD;PSD 之后允许 `export` 和 `measure_band`,至少执行其中一个。需要同时生成 FFT 和 PSD 时,使用两个分析 step 引用同一个 capture。PSD 的频带测量不复用 FFT 的峰值幅度、THD 或噪声底。
+
+### PSD 频带验收
+
+```toml
+{ op = "measure_band", name = "audio", band_hz = [20, 20000], exclude_hz = [[990, 1010]], metrics = ["mean_square_v2", "rms_v", "noise_rms_v"] }
+```
+
+所有字段必填;没有排除频带时显式填写 `exclude_hz = []`。频带边界必须非负且严格递增,排除区间必须位于测量频带内;运行时测量频带不得超过实际 Nyquist。按闭区间选择 bin 中心,并按闭区间排除;结果为剩余 bin 密度之和乘 bin 间距,DC 与偶数点 Nyquist 均使用完整 bin 权重。空结果写入 `null` 和警告。
+
+`mean_square_v2` 单位为 V²,`rms_v` 为其平方根,单位为 V;没有负载信息时不转换为瓦特。`noise_rms_v` 使用同一积分,但要求显式提供非空信号排除区间,其噪声含义依赖声明的频带选择。所有指标均应用 `exclude_hz`;需要未排除的带内 RMS 时另建一个名称不同的测量。
+
+上例产生 `audio_mean_square_v2`、`audio_rms_v` 和 `audio_noise_rms_v`,可在 `[steps.expect]` 或离线配方 `[expect]` 中使用 min/max。命名测量不可重名。积分沿用所选 Welch 均值或中位数估计,不额外重标定。
运行时按实际时间轴检查等间隔采样,容差为 `rtol=1e-6, atol=0`。样本数小于 `nperseg` 时失败,不自动缩短段长。只处理完整段;不足一段的尾点不补齐,并在 manifest 中记录数量。仅有一段时仍可导出,同时记录没有跨段平均的警告。
diff --git a/plans/example_psd_recipe.toml b/plans/example_psd_recipe.toml
new file mode 100644
index 00000000..a086233c
--- /dev/null
+++ b/plans/example_psd_recipe.toml
@@ -0,0 +1,7 @@
+schema = "wavebench.analysis_recipe.v1"
+operations = [
+ { op = "psd", method = "welch", window = "hann", nperseg = 256, noverlap = 128, nfft = 256, detrend = "constant", average = "mean" },
+ { op = "measure_band", name = "signal", band_hz = [0, 10000], exclude_hz = [], metrics = ["mean_square_v2", "rms_v"] },
+ { op = "export", name = "density", formats = ["npy", "csv"] },
+]
+# Requires an input sampling rate of at least 20 kHz.
diff --git a/src/wavebench/data/pipeline_operations.py b/src/wavebench/data/pipeline_operations.py
new file mode 100644
index 00000000..a2ae4a58
--- /dev/null
+++ b/src/wavebench/data/pipeline_operations.py
@@ -0,0 +1,85 @@
+"""Additional signal operators; independent of services and instruments."""
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import numpy as np
+
+from wavebench.data.signal_pipeline import PsdSignal
+from wavebench.errors import DataError
+
+
+BAND_METRICS = {"mean_square_v2", "rms_v", "noise_rms_v"}
+
+
+def result_name(value: Any) -> str:
+ if not isinstance(value, str) or re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", value) is None:
+ raise DataError("result name must match ^[a-z][a-z0-9_-]{0,63}$")
+ return value
+
+
+def number(value: Any, name: str, *, minimum: float = 0) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise DataError(f"{name} must be a finite number >= {minimum}")
+ value = float(value)
+ if not np.isfinite(value) or value < minimum:
+ raise DataError(f"{name} must be a finite number >= {minimum}")
+ return value
+
+
+def interval(value: Any, name: str) -> list[float]:
+ if not isinstance(value, list) or len(value) != 2:
+ raise DataError(f"{name} must contain two frequency limits")
+ result = [number(item, name) for item in value]
+ if result[0] >= result[1]:
+ raise DataError(f"{name} limits must increase")
+ return result
+
+
+def normalize_band(operation: dict[str, Any]) -> dict[str, Any]:
+ name = result_name(operation["name"])
+ band = interval(operation["band_hz"], "band_hz")
+ if not isinstance(operation["exclude_hz"], list):
+ raise DataError("exclude_hz must be an array of frequency intervals")
+ excluded = [interval(item, "exclude_hz") for item in operation["exclude_hz"]]
+ if any(low < band[0] or high > band[1] for low, high in excluded):
+ raise DataError("excluded intervals must lie within band_hz")
+ metrics = operation["metrics"]
+ if (not isinstance(metrics, list) or not metrics
+ or any(not isinstance(item, str) or item not in BAND_METRICS for item in metrics)
+ or len(set(metrics)) != len(metrics)):
+ raise DataError("band metrics must select distinct mean_square_v2, rms_v or noise_rms_v")
+ if "noise_rms_v" in metrics and not excluded:
+ raise DataError("noise_rms_v requires explicit signal exclusion intervals")
+ return dict(op="measure_band", name=name, band_hz=band, exclude_hz=excluded, metrics=metrics[:])
+
+
+def measure_band(signal: PsdSignal, operation: dict[str, Any]) -> tuple[dict, dict, list[str]]:
+ operation = normalize_band(operation)
+ low, high = operation["band_hz"]
+ nyquist = .5 / signal.sample_interval_s
+ if high > nyquist and not np.isclose(high, nyquist, rtol=1e-9, atol=0):
+ raise DataError("band_hz exceeds the actual Nyquist frequency")
+ frequencies = signal.frequency_hz
+ selected = (frequencies >= low) & (frequencies <= high)
+ for left, right in operation["exclude_hz"]:
+ selected &= ~((frequencies >= left) & (frequencies <= right))
+ count = int(np.count_nonzero(selected))
+ spacing = float(frequencies[1] - frequencies[0])
+ warnings = []
+ if count:
+ mean_square = float(np.sum(signal.psd_v2_per_hz[selected]) * spacing)
+ if not np.isfinite(mean_square) or mean_square < 0:
+ raise DataError("band measurement must be finite and nonnegative")
+ rms = float(np.sqrt(mean_square))
+ else:
+ mean_square = rms = None
+ warnings.append(f"{operation['name']}: no bins remain in the selected band")
+ values = {"mean_square_v2": mean_square, "rms_v": rms, "noise_rms_v": rms}
+ metrics = {f"{operation['name']}_{key}": values[key] for key in operation["metrics"]}
+ return metrics, {
+ "name": operation["name"], "selected_bins": count, "bin_spacing_hz": spacing,
+ "integration": "sum_selected_bin_density_times_bin_spacing",
+ "interval_rule": "closed_bin_centers", "average": signal.parameters["average"],
+ }, warnings
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index cedf817c..41ac926f 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -29,6 +29,7 @@
welch_psd,
)
from wavebench.errors import ConfigError, DataError, error_envelope
+from wavebench.data.pipeline_operations import measure_band
from wavebench.services.run_analysis import evaluate_expect
from wavebench.services.run_artifacts import RunStepRecord
from wavebench.services.run_plan import RunPlan, RunStep
@@ -134,6 +135,9 @@ def execute_pipeline(
if operation["op"] == "measure"
for metric in operation["metrics"]
}
+ for operation in operations:
+ if operation["op"] == "measure_band":
+ metrics.update({f"{operation['name']}_{metric}": None for metric in operation["metrics"]})
warnings: list[str] = []
exports: list[dict[str, Any]] = []
stages: list[dict[str, Any]] = []
@@ -255,6 +259,14 @@ def execute_pipeline(
if psd_warnings:
stage["warnings"] = psd_warnings
_extend_unique(warnings, psd_warnings)
+ elif op == "measure_band":
+ assert isinstance(signal, PsdSignal)
+ measured, metadata, operation_warnings = measure_band(signal, operation)
+ metrics.update(measured)
+ stage["measurement"] = metadata
+ if operation_warnings:
+ stage["warnings"] = operation_warnings
+ _extend_unique(warnings, operation_warnings)
elif op == "measure":
if isinstance(signal, TimeSignal):
measured = measure_time(signal, operation["metrics"])
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index be9a8bee..a488d615 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -21,6 +21,7 @@
normalize_psd_parameters,
)
from wavebench.errors import ConfigError, DataError
+from wavebench.data.pipeline_operations import normalize_band
from wavebench.services.frequency_response import FIT_METHODS
from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
from wavebench.services.frequency_response_baseline import normalize_frequency_response_baseline
@@ -459,12 +460,12 @@ def format_run_plan_schema() -> str:
"analysis.pipeline metrics:",
" Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.",
" Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.",
- " PSD domain: export only; no scalar metrics.",
+ " PSD domain: measure_band requires name, band_hz, exclude_hz and metrics=mean_square_v2|rms_v|noise_rms_v. Metric keys are _.",
"",
"analysis.pipeline PSD operation:",
" psd requires method=welch, window=hann|hamming|blackman, nperseg>=4, 0<=noverlap=nperseg, detrend=none|constant|linear, average=mean|median.",
" All parameters are explicit; lengths are integers. Segment windows are periodic.",
- " Requires time data before window or fft. Only export may follow psd; at least one PSD export is required.",
+ " Requires time data before window or fft. Only export or measure_band may follow psd; at least one PSD result is required.",
" Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.",
])
return "\n".join(lines)
@@ -1285,9 +1286,11 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
normalized: list[dict[str, Any]] = []
transforms: set[str] = set()
measured: set[str] = set()
+ result_names: set[str] = set()
export_names: set[str] = set()
domain = "time"
has_result = False
+ psd_result = False
allowed_fields = {
"remove_dc": {"op"},
@@ -1308,6 +1311,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"fft": {"op"},
"psd": {"op", "method", "window", "nperseg", "noverlap", "nfft", "detrend", "average"},
"measure": {"op", "metrics"},
+ "measure_band": {"op", "name", "band_hz", "exclude_hz", "metrics"},
"export": {"op", "name", "formats"},
}
required_fields = {
@@ -1316,6 +1320,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"window": {"name"},
"psd": {"method", "window", "nperseg", "noverlap", "nfft", "detrend", "average"},
"measure": {"metrics"},
+ "measure_band": {"name", "band_hz", "exclude_hz", "metrics"},
"export": {"name", "formats"},
}
@@ -1340,8 +1345,8 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
raise ConfigError(f"{operation_prefix} {op} missing required field {names}")
operation: dict[str, Any] = {"op": op}
- if domain == "psd" and op != "export":
- raise ConfigError(f"{operation_prefix}: only export is supported after psd")
+ if domain == "psd" and op not in {"export", "measure_band"}:
+ raise ConfigError(f"{operation_prefix}: only export or measure_band is supported after psd")
if op == "psd":
if domain != "time" or "window" in transforms:
raise ConfigError(f"{operation_prefix}: psd requires time data before window or fft")
@@ -1514,6 +1519,19 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
except DataError as exc:
raise ConfigError(f"{operation_prefix}: {exc}") from exc
domain = "psd"
+ elif op == "measure_band":
+ if domain != "psd":
+ raise ConfigError(f"{operation_prefix}: measure_band requires PSD data")
+ try:
+ operation = normalize_band(raw_operation)
+ except DataError as exc:
+ raise ConfigError(f"{operation_prefix}: {exc}") from exc
+ keys = {f"{operation['name']}_{metric}" for metric in operation["metrics"]}
+ if keys & measured or operation["name"] in result_names:
+ raise ConfigError(f"{operation_prefix}: duplicate measurement name")
+ result_names.add(operation["name"])
+ measured.update(keys)
+ has_result = psd_result = True
elif op == "measure":
raw_metrics = raw_operation["metrics"]
if not isinstance(raw_metrics, list) or not raw_metrics:
@@ -1565,12 +1583,13 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
operation["name"] = name
operation["formats"] = formats
has_result = True
+ psd_result = psd_result or domain == "psd"
normalized.append(operation)
if not has_result:
raise ConfigError(f"{prefix}.operations requires at least one measure or export operation")
- if domain == "psd" and normalized[-1]["op"] != "export":
- raise ConfigError(f"{prefix}.operations requires an export after psd")
+ if domain == "psd" and not psd_result:
+ raise ConfigError(f"{prefix}.operations requires export or measure_band after psd")
fields["operations"] = normalized
if "expect" in fields:
diff --git a/tests/test_pipeline_band.py b/tests/test_pipeline_band.py
new file mode 100644
index 00000000..04744852
--- /dev/null
+++ b/tests/test_pipeline_band.py
@@ -0,0 +1,85 @@
+import json
+
+import numpy as np
+import pytest
+
+from wavebench.data.pipeline_operations import measure_band
+from wavebench.data.signal_pipeline import PsdSignal, welch_psd
+from wavebench.errors import ConfigError, DataError
+from wavebench.services.analysis_service import run_analysis
+from test_analysis_service import analysis_input as analysis_input
+from test_psd_pipeline import PARAMS, plan_for, signal_for
+
+
+BAND = dict(op="measure_band", name="audio", band_hz=[0, 64], exclude_hz=[],
+ metrics=["mean_square_v2", "rms_v"])
+
+
+def density():
+ return PsdSignal(np.arange(5.) * 16, np.ones(5), 1/128, 8,
+ {"average": "mean"}, 1, 0, 1, "", "")
+
+
+def test_closed_bin_integration_and_exclusion():
+ values, metadata, warnings = measure_band(density(), BAND)
+ assert values["audio_mean_square_v2"] == 80 # Both endpoints have full bin weight.
+ assert values["audio_rms_v"] == pytest.approx(np.sqrt(80))
+ assert metadata["selected_bins"] == 5 and warnings == []
+ values, metadata, _ = measure_band(density(), BAND | dict(exclude_hz=[[16, 32]], metrics=["noise_rms_v"]))
+ assert values["audio_noise_rms_v"] == pytest.approx(np.sqrt(48))
+ assert metadata["selected_bins"] == 3
+
+
+def test_empty_band_and_nyquist_boundary():
+ values, _, warnings = measure_band(density(), BAND | dict(band_hz=[1, 2]))
+ assert all(value is None for value in values.values()) and warnings
+ with pytest.raises(DataError, match="Nyquist"):
+ measure_band(density(), BAND | dict(band_hz=[0, 65]))
+
+
+@pytest.mark.parametrize("nfft", [16, 17, 64])
+def test_integrated_psd_power_and_zero_padding(nfft):
+ pytest.importorskip("scipy")
+ values = np.cos(np.arange(16) * np.pi) * 3
+ signal = welch_psd(signal_for(values), **(PARAMS | dict(nfft=nfft)))
+ measured, _, _ = measure_band(signal, BAND)
+ assert measured["audio_mean_square_v2"] == pytest.approx(9)
+ assert measured["audio_rms_v"] == pytest.approx(3)
+
+
+@pytest.mark.parametrize("change", [
+ dict(name="../bad"), dict(band_hz=[2, 1]), dict(band_hz=[True, 5]),
+ dict(exclude_hz=[[0, 65]]), dict(exclude_hz="none"), dict(metrics=["noise_rms_v"]),
+ dict(metrics=["rms_v", "rms_v"]), dict(metrics=["power_w"]),
+])
+def test_band_static_validation(tmp_path, change):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [dict(op="psd", **PARAMS), BAND | change])
+
+
+def test_band_requires_psd_and_unique_name(tmp_path):
+ with pytest.raises(ConfigError, match="requires PSD"):
+ plan_for(tmp_path, [BAND])
+ with pytest.raises(ConfigError, match="duplicate"):
+ plan_for(tmp_path, [dict(op="psd", **PARAMS), BAND,
+ BAND | dict(metrics=["noise_rms_v"], exclude_hz=[[0, 1]])])
+
+
+def test_band_expectation_and_stage_metadata(tmp_path, analysis_input):
+ pytest.importorskip("scipy")
+ capture, recipe = analysis_input
+ recipe.write_text('''schema="wavebench.analysis_recipe.v1"
+operations=[
+{op="psd",method="welch",window="hann",nperseg=16,noverlap=8,nfft=16,detrend="none",average="mean"},
+{op="measure_band",name="all",band_hz=[0,64],exclude_hz=[],metrics=["rms_v"]},
+]
+[expect]
+all_rms_v={min=0.99,max=1.01}
+''')
+ output = tmp_path / "analysis"
+ result = run_analysis(capture, 1, recipe, output)
+ assert result["status"] == "ok"
+ assert result["artifact"]["metrics"]["all_rms_v"] == pytest.approx(1)
+ manifest = json.loads((output / "manifest.json").read_text())
+ assert manifest["stages"][2]["measurement"]["selected_bins"] == 9
+ assert manifest["stages"][2]["output_domain"] == "psd"
From be5654cc8c811c6f953ff326d631048baaad43f1 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 12:10:21 +0800
Subject: [PATCH 12/30] feat(analysis): detect named peaks and render peak
tables
---
docs/reference/artifacts.md | 2 +
docs/reference/generated/run-schema.md | 4 +-
docs/reference/run-schema.md | 15 ++-
src/wavebench/data/pipeline_operations.py | 83 +++++++++++++++-
src/wavebench/report/analysis.py | 31 +++++-
src/wavebench/services/run_pipeline.py | 39 +++++++-
src/wavebench/services/run_plan.py | 25 +++--
tests/test_pipeline_peaks.py | 115 ++++++++++++++++++++++
8 files changed, 295 insertions(+), 19 deletions(-)
create mode 100644 tests/test_pipeline_peaks.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 64149553..3ea59dcb 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -2,6 +2,8 @@
## 独立离线分析
+`peaks` 将峰列表写入处理目录的 `peaks/.json` 和 `peaks/.csv`。JSON 使用 `wavebench.peaks.v1`,记录数据域、单位、SciPy 版本、检测输入两列 little-endian float64 的 SHA-256、完整数量、保留数量、截断状态和峰属性。CSV 列为 `index,position,value,prominence,width,polarity`;极性以 1/-1 表示。manifest、对应 stage 及 step artifact 条件性增加峰表路径与文件摘要。后续算子失败保留已完成峰表,截断会记录警告。
+
`analysis report [...] --output comparison.html` 读取已保存的导出,生成独立 HTML;输出文件必须尚不存在。常规 run HTML 报告也会展示派生曲线。时域和 FFT 纵轴单位为 V,PSD 为 V²/Hz,使用线性坐标;同来源摘要、通道与数据域的曲线可叠加,不同来源分开显示。图形保留真实横轴,不自动补偿滤波延迟。
报告检查导出路径与 SHA-256,损坏或缺失导出显示警告。NPY 和 CSV 同时存在时优先读取 NPY,读取失败可回退到 CSV。显示抽稀保留局部极值,指标始终来自完整数据的既有产物;报告不重新执行算子。
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index 1584794a..67269cde 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -251,6 +251,8 @@ analysis.pipeline metrics:
analysis.pipeline PSD operation:
psd requires method=welch, window=hann|hamming|blackman, nperseg>=4, 0<=noverlap=nperseg, detrend=none|constant|linear, average=mean|median.
All parameters are explicit; lengths are integers. Segment windows are periodic.
- Requires time data before window or fft. Only export or measure_band may follow psd; at least one PSD result is required.
+ Requires time data before window or fft. Only export, measure_band or peaks may follow psd; at least one PSD result is required.
Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.
+ peaks requires name, polarity=positive|negative|both, height>=0, prominence>=0, distance>0, width>=0, max_peaks=1..10000, metrics=[count].
+ Peak distance/width use seconds in time and Hz in spectra; spectral polarity must be positive. Produces _count and JSON/CSV tables without changing signal domain.
```
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index f089bf9d..173ea3ea 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -87,6 +87,7 @@ thd_ratio = { max = 0.05 }
| `psd` | Welch 分段参数,见下文 | 时域 → PSD |
| `measure` | 非空 `metrics` 数组 | 观察当前域,不改变数据 |
| `measure_band` | `name`、`band_hz`、`exclude_hz`、`metrics` | 观察 PSD,不改变数据 |
+| `peaks` | 命名检测、筛选条件和数量上限,见下文 | 观察当前域,不改变数据 |
| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR/IIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
@@ -158,7 +159,19 @@ PSD 算子将时域数据转换为单边功率谱密度,单位为 `V²/Hz`。
| `detrend` | `none`、`constant` 或 `linear`,在每段加窗前执行 |
| `average` | `mean` 或经过偏差修正的 `median` |
-PSD 可以跟在去直流、去趋势或 FIR/IIR 之后,但不能跟在整段 `window` 或 `fft` 之后。每条流水线至多有一个 PSD;PSD 之后允许 `export` 和 `measure_band`,至少执行其中一个。需要同时生成 FFT 和 PSD 时,使用两个分析 step 引用同一个 capture。PSD 的频带测量不复用 FFT 的峰值幅度、THD 或噪声底。
+PSD 可以跟在去直流、去趋势或 FIR/IIR 之后,但不能跟在整段 `window` 或 `fft` 之后。每条流水线至多有一个 PSD;PSD 之后允许 `export`、`measure_band` 和 `peaks`,至少执行其中一个。需要同时生成 FFT 和 PSD 时,使用两个分析 step 引用同一个 capture。PSD 的频带测量不复用 FFT 的峰值幅度、THD 或噪声底。
+
+### 通用峰值检测
+
+```toml
+{ op = "peaks", name = "tones", polarity = "positive", height = 0.01, prominence = 0.01, distance = 10, width = 0, max_peaks = 20, metrics = ["count"] }
+```
+
+所有字段必填。`height`、`prominence`、`width` 非负,零值表示不设对应下限;`distance` 必须为正;`max_peaks` 为 1~10000 的整数。时域支持 `positive`、`negative` 和 `both`,FFT/PSD 只允许 `positive`。极性表示局部极大/极小方向,不保证电压绝对正负;例如负直流偏置上的局部极大值,在 `height = 0` 时也会保留。负峰在电压取反后检测,结果仍保存原始电压。高度和显著性单位随域为 V 或 V²/Hz;距离和宽度单位在时域为秒、频域为 Hz。时域要求等间隔采样。
+
+先按高度、显著性、半显著性宽度筛选,再以带极性的高度降序、位置升序确定间隔竞争和输出顺序,最后截断至上限。负峰的高度按取反后的值排序,正负峰共同竞争间隔。端点不视为峰,平台峰选择中间样本,偶数长度时取靠前样本;不对峰位置做亚 bin 插值。峰宽使用半显著性高度的插值交点。
+
+`metrics = ["count"]` 显式生成 `_count`,表示截断前、筛选后峰数量,可用于 `expect`。峰列表写入独立 JSON/CSV;空列表是合法结果。检测不改变信号数据域,可继续变换、测量或导出。报告只在峰表的信号摘要与绘制曲线一致时标记峰。
### PSD 频带验收
diff --git a/src/wavebench/data/pipeline_operations.py b/src/wavebench/data/pipeline_operations.py
index a2ae4a58..e8deb463 100644
--- a/src/wavebench/data/pipeline_operations.py
+++ b/src/wavebench/data/pipeline_operations.py
@@ -2,11 +2,14 @@
from __future__ import annotations
import re
+from hashlib import sha256
from typing import Any
import numpy as np
-from wavebench.data.signal_pipeline import PsdSignal
+from wavebench.data.signal_pipeline import (
+ FrequencySignal, PsdSignal, TimeSignal, _uniform_sample_interval,
+)
from wavebench.errors import DataError
@@ -37,6 +40,84 @@ def interval(value: Any, name: str) -> list[float]:
return result
+def integer(value: Any, name: str, minimum: int, maximum: int) -> int:
+ if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
+ raise DataError(f"{name} must be an integer in [{minimum}, {maximum}]")
+ return value
+
+
+def normalize_peaks(operation: dict[str, Any]) -> dict[str, Any]:
+ if operation["polarity"] not in ("positive", "negative", "both"):
+ raise DataError("peak polarity must be positive, negative or both")
+ if operation["metrics"] != ["count"]:
+ raise DataError("peaks metrics must explicitly select [count]")
+ normalized = dict(op="peaks", name=result_name(operation["name"]),
+ polarity=operation["polarity"], metrics=["count"])
+ for key in ("height", "prominence", "distance", "width"):
+ normalized[key] = number(operation[key], key)
+ if normalized["distance"] == 0:
+ raise DataError("peak distance must be > 0")
+ normalized["max_peaks"] = integer(operation["max_peaks"], "max_peaks", 1, 10000)
+ return normalized
+
+
+def detect_peaks(signal: TimeSignal | FrequencySignal | PsdSignal, operation: dict[str, Any]) -> dict:
+ operation = normalize_peaks(operation)
+ if isinstance(signal, TimeSignal):
+ interval_s = _uniform_sample_interval(signal, "peak detection")
+ axis, values = signal.time_s, signal.voltage_v
+ spacing, domain, axis_unit, units = interval_s, "time", "s", "V"
+ else:
+ if operation["polarity"] != "positive":
+ raise DataError("spectral peaks require positive polarity")
+ axis = signal.frequency_hz
+ values = signal.amplitude_v if isinstance(signal, FrequencySignal) else signal.psd_v2_per_hz
+ spacing = float(axis[1] - axis[0])
+ domain = "frequency" if isinstance(signal, FrequencySignal) else "psd"
+ axis_unit, units = "Hz", "V" if domain == "frequency" else "V^2/Hz"
+ from scipy import __version__ as scipy_version
+ from scipy.signal import find_peaks
+
+ candidates = []
+ signs = (1, -1) if operation["polarity"] == "both" else ((1,) if operation["polarity"] == "positive" else (-1,))
+ for sign in signs:
+ indices, properties = find_peaks(
+ values * sign, height=operation["height"] or None,
+ prominence=(operation["prominence"], None), width=(None, None), rel_height=.5,
+ )
+ for i, index in enumerate(indices):
+ width = float(properties["widths"][i] * spacing)
+ if width < operation["width"]:
+ continue
+ candidates.append({
+ "index": int(index), "position": float(axis[index]), "value": float(values[index]),
+ "prominence": float(properties["prominences"][i]), "width": width, "polarity": sign,
+ })
+ candidates.sort(key=lambda peak: (-peak["value"] * peak["polarity"], peak["position"], -peak["polarity"]))
+ # Higher signed height wins; equal heights keep the earlier sample deterministically.
+ accepted = []
+ blocked = np.zeros(len(axis), dtype=bool)
+ for peak in candidates:
+ if blocked[peak["index"]]:
+ continue
+ left = np.searchsorted(axis, peak["position"] - operation["distance"], side="right")
+ right = np.searchsorted(axis, peak["position"] + operation["distance"], side="left")
+ blocked[left:right] = True
+ blocked[peak["index"]] = True
+ accepted.append(peak)
+ if any(not np.isfinite(row[key]) for row in accepted for key in ("position", "value", "prominence", "width")):
+ raise DataError("peak properties must be finite")
+ return {
+ "schema": "wavebench.peaks.v1", "name": operation["name"], "domain": domain,
+ "axis_unit": axis_unit, "value_unit": units, "scipy_version": scipy_version,
+ "signal_sha256": sha256(np.asarray(np.column_stack((axis, values)), dtype=" operation["max_peaks"],
+ "width_rule": "half_prominence", "endpoint_rule": "excluded",
+ "peaks": accepted[:operation["max_peaks"]],
+ }
+
+
def normalize_band(operation: dict[str, Any]) -> dict[str, Any]:
name = result_name(operation["name"])
band = interval(operation["band_hz"], "band_hz")
diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py
index d7dca1ca..0df70b81 100644
--- a/src/wavebench/report/analysis.py
+++ b/src/wavebench/report/analysis.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from html import escape
+from hashlib import sha256
import json
from pathlib import Path
from typing import Any
@@ -54,7 +55,8 @@ def display_samples(data: np.ndarray, maximum: int = 1200) -> np.ndarray:
return data[sorted(indices)]
-def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str) -> str:
+def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str,
+ markers: dict[str, list[dict]] | None = None) -> str:
width, height, pad = 900, 300, 55
xmin = min(float(data[0, 0]) for _, data in curves)
xmax = max(float(data[-1, 0]) for _, data in curves)
@@ -75,6 +77,10 @@ def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str) -
points = " ".join(f"{pad+(x-xmin)/(xmax-xmin)*(width-2*pad):.2f},{height-pad-(y-ymin)/(ymax-ymin)*(height-2*pad):.2f}"
for x, y in sampled)
parts.append(f'{escape(label)} ')
+ for peak in (markers or {}).get(label, []):
+ px = pad + (peak["position"] - xmin) / (xmax - xmin) * (width - 2 * pad)
+ py = height - pad - (peak["value"] - ymin) / (ymax - ymin) * (height - 2 * pad)
+ parts.append(f'{peak["position"]:.6g}: {peak["value"]:.6g} ')
parts.append(f'{escape(x_label)} {escape(units)} ')
parts.append("" + "".join(f'{escape(label)} ' for i, (label, _) in enumerate(curves)) + " ")
return "".join(parts)
@@ -83,6 +89,7 @@ def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str) -
def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *, details: bool = True) -> str:
groups: dict[tuple, list[tuple[str, np.ndarray]]] = {}
sections: list[str] = []
+ markers: dict[str, list[dict]] = {}
for root, label, artifact in entries:
try:
pipeline = artifact["analysis_pipeline"]
@@ -91,6 +98,20 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *,
sections.append(f"{escape(label)} {escape(json.dumps(artifact, indent=2, ensure_ascii=False))} ")
sections.append(f'{escape(label)}: sampling={escape(json.dumps(manifest.get("sampling")))}
')
source = manifest["source"]
+ peak_sets = {}
+ for peak in manifest.get("peaks", []):
+ try:
+ peak_file = artifact_file(root, peak["json"])
+ if _sha256_file(peak_file) != peak["json_sha256"]:
+ raise ValueError("peak table SHA-256 mismatch")
+ detected = json.loads(peak_file.read_text())
+ rows = detected["peaks"]
+ if not isinstance(rows, list) or any(not isinstance(row, dict) or not all(isinstance(row.get(key), (int, float)) and np.isfinite(row[key]) for key in ("position", "value")) for row in rows):
+ raise ValueError("invalid peak table")
+ peak_sets.setdefault(detected["signal_sha256"], []).extend(rows)
+ sections.append(f'{escape(label)}: {escape(peak["name"])} peaks={escape(str(peak["count"]))}, retained={escape(str(peak["retained_count"]))}
')
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ sections.append(f'Peak table unavailable: {escape(str(exc))}
')
seen: set[tuple] = set()
for item in sorted(manifest["exports"], key=lambda x: x.get("format") != "npy"):
try:
@@ -110,14 +131,18 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *,
raise ValueError("invalid export values or axis")
seen.add(identity)
key = (source.get("npy_sha256") or label, source.get("channel"), columns)
- groups.setdefault(key, []).append((f"{label}: {item['name']}", data[:, [0, column]]))
+ curve_label = f"{label}: {item['name']}"
+ curve = data[:, [0, column]]
+ groups.setdefault(key, []).append((curve_label, curve))
+ fingerprint = sha256(np.asarray(curve, dtype="{escape(label)}: curve unavailable: {escape(str(exc))}')
except (OSError, ValueError, TypeError, KeyError) as exc:
sections.append(f'{escape(label)}: analysis unavailable: {escape(str(exc))}
')
for (_, _, columns), curves in groups.items():
_, x_label, units = COLUMNS[columns]
- sections.append(curves_svg(curves, x_label, units))
+ sections.append(curves_svg(curves, x_label, units, markers))
if entries and not groups:
sections.append("No usable curve exports / 没有可用的曲线导出
")
return "".join(sections)
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index 41ac926f..be8d94cb 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -29,7 +29,7 @@
welch_psd,
)
from wavebench.errors import ConfigError, DataError, error_envelope
-from wavebench.data.pipeline_operations import measure_band
+from wavebench.data.pipeline_operations import measure_band, detect_peaks
from wavebench.services.run_analysis import evaluate_expect
from wavebench.services.run_artifacts import RunStepRecord
from wavebench.services.run_plan import RunPlan, RunStep
@@ -51,13 +51,15 @@ def ensure_operation_dependencies(all_operations: list[dict[str, Any]]) -> None:
operations = [
operation
for operation in all_operations
- if operation["op"] in {"filter", "psd"}
+ if operation["op"] in {"filter", "psd", "peaks"}
]
if not operations:
return
required_functions: set[str] = set()
for operation in operations:
- if operation["op"] == "psd":
+ if operation["op"] == "peaks":
+ required_functions.add("find_peaks")
+ elif operation["op"] == "psd":
required_functions.update({"welch", "get_window"})
elif operation["family"] == "fir":
required_functions.add("firwin")
@@ -136,12 +138,13 @@ def execute_pipeline(
for metric in operation["metrics"]
}
for operation in operations:
- if operation["op"] == "measure_band":
+ if operation["op"] in {"measure_band", "peaks"}:
metrics.update({f"{operation['name']}_{metric}": None for metric in operation["metrics"]})
warnings: list[str] = []
exports: list[dict[str, Any]] = []
stages: list[dict[str, Any]] = []
filters: list[dict[str, Any]] = []
+ peaks: list[dict[str, Any]] = []
sampling: dict[str, Any] | None = None
window: dict[str, Any] | None = None
psd: dict[str, Any] | None = None
@@ -259,6 +262,30 @@ def execute_pipeline(
if psd_warnings:
stage["warnings"] = psd_warnings
_extend_unique(warnings, psd_warnings)
+ elif op == "peaks":
+ detected = detect_peaks(signal, operation)
+ peak_dir = processing_dir / "peaks"
+ peak_dir.mkdir(exist_ok=True)
+ json_path = peak_dir / f"{operation['name']}.json"
+ csv_path = peak_dir / f"{operation['name']}.csv"
+ _atomic_write_json(json_path, detected)
+ columns = ["index", "position", "value", "prominence", "width", "polarity"]
+ _atomic_write_csv(csv_path, columns, np.asarray([
+ [row[key] for key in columns] for row in detected["peaks"]
+ ]).reshape(-1, len(columns)))
+ metadata = {key: value for key, value in detected.items() if key != "peaks"}
+ metadata.update({
+ "json": _derived_relative(json_path, run_dir),
+ "csv": _derived_relative(csv_path, run_dir),
+ "json_sha256": _sha256_file(json_path), "csv_sha256": _sha256_file(csv_path),
+ })
+ peaks.append(metadata)
+ stage["peaks"] = metadata
+ metrics[f"{operation['name']}_count"] = detected["count"]
+ if detected["truncated"]:
+ message = f"{operation['name']}: peak table truncated to {detected['retained_count']} rows"
+ stage["warnings"] = [message]
+ _extend_unique(warnings, [message])
elif op == "measure_band":
assert isinstance(signal, PsdSignal)
measured, metadata, operation_warnings = measure_band(signal, operation)
@@ -351,6 +378,8 @@ def execute_pipeline(
manifest["filters"] = filters
if psd is not None:
manifest["psd"] = psd
+ if peaks:
+ manifest["peaks"] = peaks
if failed_stage is not None:
manifest["failed_stage"] = failed_stage
if failure is not None:
@@ -372,6 +401,8 @@ def execute_pipeline(
}
if failed_stage is not None:
pipeline_artifact["failed_stage"] = failed_stage
+ if peaks:
+ pipeline_artifact["peaks"] = peaks
if failure is not None:
pipeline_artifact["error"] = failure
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index a488d615..09a44aee 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -21,7 +21,7 @@
normalize_psd_parameters,
)
from wavebench.errors import ConfigError, DataError
-from wavebench.data.pipeline_operations import normalize_band
+from wavebench.data.pipeline_operations import normalize_band, normalize_peaks
from wavebench.services.frequency_response import FIT_METHODS
from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
from wavebench.services.frequency_response_baseline import normalize_frequency_response_baseline
@@ -465,8 +465,10 @@ def format_run_plan_schema() -> str:
"analysis.pipeline PSD operation:",
" psd requires method=welch, window=hann|hamming|blackman, nperseg>=4, 0<=noverlap=nperseg, detrend=none|constant|linear, average=mean|median.",
" All parameters are explicit; lengths are integers. Segment windows are periodic.",
- " Requires time data before window or fft. Only export or measure_band may follow psd; at least one PSD result is required.",
+ " Requires time data before window or fft. Only export, measure_band or peaks may follow psd; at least one PSD result is required.",
" Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.",
+ " peaks requires name, polarity=positive|negative|both, height>=0, prominence>=0, distance>0, width>=0, max_peaks=1..10000, metrics=[count].",
+ " Peak distance/width use seconds in time and Hz in spectra; spectral polarity must be positive. Produces _count and JSON/CSV tables without changing signal domain.",
])
return "\n".join(lines)
@@ -1312,6 +1314,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"psd": {"op", "method", "window", "nperseg", "noverlap", "nfft", "detrend", "average"},
"measure": {"op", "metrics"},
"measure_band": {"op", "name", "band_hz", "exclude_hz", "metrics"},
+ "peaks": {"op", "name", "polarity", "height", "prominence", "distance", "width", "max_peaks", "metrics"},
"export": {"op", "name", "formats"},
}
required_fields = {
@@ -1321,6 +1324,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"psd": {"method", "window", "nperseg", "noverlap", "nfft", "detrend", "average"},
"measure": {"metrics"},
"measure_band": {"name", "band_hz", "exclude_hz", "metrics"},
+ "peaks": {"name", "polarity", "height", "prominence", "distance", "width", "max_peaks", "metrics"},
"export": {"name", "formats"},
}
@@ -1345,8 +1349,8 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
raise ConfigError(f"{operation_prefix} {op} missing required field {names}")
operation: dict[str, Any] = {"op": op}
- if domain == "psd" and op not in {"export", "measure_band"}:
- raise ConfigError(f"{operation_prefix}: only export or measure_band is supported after psd")
+ if domain == "psd" and op not in {"export", "measure_band", "peaks"}:
+ raise ConfigError(f"{operation_prefix}: only export, measure_band or peaks is supported after psd")
if op == "psd":
if domain != "time" or "window" in transforms:
raise ConfigError(f"{operation_prefix}: psd requires time data before window or fft")
@@ -1519,19 +1523,22 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
except DataError as exc:
raise ConfigError(f"{operation_prefix}: {exc}") from exc
domain = "psd"
- elif op == "measure_band":
- if domain != "psd":
+ elif op in {"measure_band", "peaks"}:
+ if op == "measure_band" and domain != "psd":
raise ConfigError(f"{operation_prefix}: measure_band requires PSD data")
try:
- operation = normalize_band(raw_operation)
+ operation = (normalize_band(raw_operation) if op == "measure_band" else normalize_peaks(raw_operation))
except DataError as exc:
raise ConfigError(f"{operation_prefix}: {exc}") from exc
+ if op == "peaks" and domain != "time" and operation["polarity"] != "positive":
+ raise ConfigError(f"{operation_prefix}: spectral peaks require positive polarity")
keys = {f"{operation['name']}_{metric}" for metric in operation["metrics"]}
if keys & measured or operation["name"] in result_names:
raise ConfigError(f"{operation_prefix}: duplicate measurement name")
result_names.add(operation["name"])
measured.update(keys)
- has_result = psd_result = True
+ has_result = True
+ psd_result = psd_result or domain == "psd"
elif op == "measure":
raw_metrics = raw_operation["metrics"]
if not isinstance(raw_metrics, list) or not raw_metrics:
@@ -1589,7 +1596,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
if not has_result:
raise ConfigError(f"{prefix}.operations requires at least one measure or export operation")
if domain == "psd" and not psd_result:
- raise ConfigError(f"{prefix}.operations requires export or measure_band after psd")
+ raise ConfigError(f"{prefix}.operations requires export, measure_band or peaks after psd")
fields["operations"] = normalized
if "expect" in fields:
diff --git a/tests/test_pipeline_peaks.py b/tests/test_pipeline_peaks.py
new file mode 100644
index 00000000..73c2c226
--- /dev/null
+++ b/tests/test_pipeline_peaks.py
@@ -0,0 +1,115 @@
+import json
+from unittest.mock import patch
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+
+from wavebench.data.pipeline_operations import detect_peaks
+from wavebench.data.signal_pipeline import fft_signal, welch_psd
+from wavebench.errors import ConfigError
+from wavebench.report.analysis import write_analysis_report
+from wavebench.services.analysis_service import run_analysis
+from wavebench.services.run_pipeline import ensure_operation_dependencies
+from test_analysis_service import analysis_input as analysis_input
+from test_psd_pipeline import PARAMS, plan_for, signal_for
+
+
+PEAKS = dict(op="peaks", name="tones", polarity="positive", height=0,
+ prominence=0, distance=1., width=0, max_peaks=100, metrics=["count"])
+
+
+def test_plateaus_endpoints_ties_and_distance():
+ pytest.importorskip("scipy")
+ signal = signal_for([20, 0, 5, 5, 0, 5, 0, 2, 0, 20], fs=1)
+ result = detect_peaks(signal, PEAKS)
+ assert [peak["index"] for peak in result["peaks"]] == [2, 5, 7]
+ assert result["peaks"][0]["width"] == pytest.approx(2)
+ result = detect_peaks(signal, PEAKS | dict(distance=4))
+ assert [peak["index"] for peak in result["peaks"]] == [2, 7]
+ result = detect_peaks(signal, PEAKS | dict(max_peaks=1))
+ assert result["count"] == 3 and result["retained_count"] == 1 and result["truncated"]
+
+
+def test_time_polarity_and_physical_width():
+ pytest.importorskip("scipy")
+ signal = signal_for([0, 3, 0, -5, 0, 2, 0], fs=10)
+ result = detect_peaks(signal, PEAKS | dict(polarity="both", distance=.1))
+ assert [peak["value"] for peak in result["peaks"]] == [-5, 3, 2]
+ assert result["axis_unit"] == "s" and result["value_unit"] == "V"
+ assert result["peaks"][0]["position"] == pytest.approx(.3)
+ negative = detect_peaks(signal, PEAKS | dict(polarity="negative", distance=.1))
+ assert any(peak["value"] == -5 for peak in negative["peaks"])
+ result = detect_peaks(signal, PEAKS | dict(distance=.1, width=.5))
+ assert result["count"] == 0
+
+
+def test_zero_height_disables_threshold_on_offset_signal():
+ pytest.importorskip("scipy")
+ signal = signal_for([-3, -1, -3, -4, -5], fs=1)
+ result = detect_peaks(signal, PEAKS)
+ assert result["peaks"][0]["value"] == -1
+ assert detect_peaks(signal, PEAKS | dict(height=.01))["count"] == 0
+
+
+def test_multitone_fft_and_psd():
+ pytest.importorskip("scipy")
+ axis = np.arange(128.) / 128
+ signal = signal_for(np.sin(2*np.pi*8*axis) + .5*np.sin(2*np.pi*24*axis))
+ fft = detect_peaks(fft_signal(signal), PEAKS | dict(height=.1, prominence=.1))
+ assert [peak["position"] for peak in fft["peaks"]] == [8, 24]
+ assert fft["value_unit"] == "V"
+ psd = welch_psd(signal, **(PARAMS | dict(nperseg=128, noverlap=0, nfft=128)))
+ result = detect_peaks(psd, PEAKS | dict(prominence=.01))
+ assert [peak["position"] for peak in result["peaks"]] == [8, 24]
+ assert result["value_unit"] == "V^2/Hz"
+ assert detect_peaks(signal_for(np.zeros(30)), PEAKS)["count"] == 0
+
+
+@pytest.mark.parametrize("change", [dict(name="bad/name"), dict(polarity="up"),
+ dict(distance=0), dict(width=-1), dict(height=True), dict(prominence=float("inf")),
+ dict(max_peaks=0), dict(max_peaks=10001), dict(max_peaks=2.5), dict(metrics=[]),
+])
+def test_peaks_strict_configuration(tmp_path, change):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [PEAKS | change])
+
+
+def test_peaks_wrong_domain_and_duplicate_name(tmp_path):
+ with pytest.raises(ConfigError, match="positive"):
+ plan_for(tmp_path, [dict(op="fft"), PEAKS | dict(polarity="negative")])
+ with pytest.raises(ConfigError, match="duplicate"):
+ plan_for(tmp_path, [PEAKS, PEAKS])
+
+
+def test_peak_artifacts_expectation_and_plot_markers(tmp_path, analysis_input):
+ pytest.importorskip("scipy")
+ capture, recipe = analysis_input
+ axis = np.arange(128.) / 128
+ np.save(capture / "ch1.npy", np.column_stack((axis, np.sin(2*np.pi*8*axis))))
+ recipe.write_text('''schema="wavebench.analysis_recipe.v1"
+operations=[
+{op="fft"},
+{op="peaks",name="tones",polarity="positive",height=0.1,prominence=0.1,distance=1,width=0,max_peaks=10,metrics=["count"]},
+{op="export",name="spectrum",formats=["npy","csv"]},
+]
+[expect]
+tones_count={min=1,max=1}
+''')
+ output = tmp_path / "analysis"
+ result = run_analysis(capture, 1, recipe, output)
+ assert result["status"] == "ok"
+ assert result["artifact"]["metrics"]["tones_count"] == 1
+ manifest = json.loads((output / "manifest.json").read_text())
+ entry = manifest["peaks"][0]
+ detected = json.loads((output / entry["json"]).read_text())
+ assert detected["peaks"][0]["position"] == 8
+ assert (output / entry["csv"]).read_text().splitlines()[0] == "index,position,value,prominence,width,polarity"
+ html = write_analysis_report([output], tmp_path / "report.html").read_text()
+ assert html.count('class="peak-marker"') == 1
+
+
+def test_peak_dependency_preflight():
+ with patch("wavebench.services.run_pipeline.import_module", return_value=SimpleNamespace()):
+ with pytest.raises(ConfigError, match="find_peaks"):
+ ensure_operation_dependencies([PEAKS])
From ff8a11d70579e35072060f8211b352f9d3f9e170 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 18:24:30 +0800
Subject: [PATCH 13/30] feat(analysis): add explicit moving average and
Savitzky-Golay smoothing
---
docs/reference/artifacts.md | 2 +
docs/reference/generated/run-schema.md | 2 +
docs/reference/run-schema.md | 14 +++
src/wavebench/data/pipeline_operations.py | 62 ++++++++++++
src/wavebench/services/run_pipeline.py | 16 +++-
src/wavebench/services/run_plan.py | 13 ++-
tests/test_pipeline_smooth.py | 111 ++++++++++++++++++++++
tests/test_run_plan_analysis.py | 2 +-
8 files changed, 218 insertions(+), 4 deletions(-)
create mode 100644 tests/test_pipeline_smooth.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 3ea59dcb..3adf7acf 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -2,6 +2,8 @@
## 独立离线分析
+成功平滑时,manifest 条件性增加 `transformations`,对应 stage 记录同一份 `transformation`。其中包括规范化参数、实际采样率、左右边界影响样本数、系数摘要、时间轴是否平移、可定义的名义群延迟;Savitzky–Golay 另外记录 SciPy 版本和窗口评价位置。未执行成功时不增加该项,后续失败保留此前的变换记录。
+
`peaks` 将峰列表写入处理目录的 `peaks/.json` 和 `peaks/.csv`。JSON 使用 `wavebench.peaks.v1`,记录数据域、单位、SciPy 版本、检测输入两列 little-endian float64 的 SHA-256、完整数量、保留数量、截断状态和峰属性。CSV 列为 `index,position,value,prominence,width,polarity`;极性以 1/-1 表示。manifest、对应 stage 及 step artifact 条件性增加峰表路径与文件摘要。后续算子失败保留已完成峰表,截断会记录警告。
`analysis report [...] --output comparison.html` 读取已保存的导出,生成独立 HTML;输出文件必须尚不存在。常规 run HTML 报告也会展示派生曲线。时域和 FFT 纵轴单位为 V,PSD 为 V²/Hz,使用线性坐标;同来源摘要、通道与数据域的曲线可叠加,不同来源分开显示。图形保留真实横轴,不自动补偿滤波延迟。
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index 67269cde..39f91553 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -255,4 +255,6 @@ analysis.pipeline PSD operation:
Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.
peaks requires name, polarity=positive|negative|both, height>=0, prominence>=0, distance>0, width>=0, max_peaks=1..10000, metrics=[count].
Peak distance/width use seconds in time and Hz in spectra; spectral polarity must be positive. Produces _count and JSON/CSV tables without changing signal domain.
+ smooth requires method=moving_average|savgol, odd window_length=3..1001, mode=centered|causal, boundary=reflect|edge. Causal requires edge.
+ savgol requires polyorder=0..min(5,window_length-1); moving_average rejects polyorder. Smooth requires uniform time data before window/fft/psd.
```
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index 173ea3ea..434f047f 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -82,6 +82,7 @@ thd_ratio = { max = 0.05 }
| `remove_dc` | 无 | 时域 → 时域 |
| `detrend` | `method = "linear"` | 时域 → 时域 |
| `filter` | FIR 或 IIR 的判别式设计参数 | 时域 → 时域 |
+| `smooth` | 方法、奇数窗口长度、模式和边界,见下文 | 时域 → 时域 |
| `window` | `name = "hann|hamming|blackman"` | 时域 → 时域 |
| `fft` | 无 | 时域 → 频域 |
| `psd` | Welch 分段参数,见下文 | 时域 → PSD |
@@ -92,6 +93,19 @@ thd_ratio = { max = 0.05 }
`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR/IIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
+### 时域平滑
+
+```toml
+{ op = "smooth", method = "moving_average", window_length = 5, mode = "causal", boundary = "edge" }
+{ op = "smooth", method = "savgol", window_length = 11, polyorder = 2, mode = "centered", boundary = "reflect" }
+```
+
+`method`、`window_length`、`mode` 和 `boundary` 必填。窗口为 3~1001 的奇数,输入必须等间隔且长度不小于窗口。`savgol` 另需 `polyorder`,为 0~5 且小于窗口长度的整数;移动平均不接受该字段。平滑必须在整段 window、FFT 和 PSD 之前,可串联多个 stage。
+
+`centered` 使用左右等长窗口,边界可选 `reflect`(不重复端点的反射)或 `edge`(首末值延拓)。`causal` 仅使用当前及过去样本,起始处只允许 `edge`,不允许引入未来样本的反射。输出样本数和时间轴不变,不自动补偿延迟。
+
+移动平均各点等权;因果模式名义群延迟为 `(window_length - 1) / 2` 个样本。Savitzky–Golay 使用零阶导数系数,居中模式在窗口中点评价,因果模式在末点评价;因果模式不声明固定群延迟。系数非有限或常量增益校验失败时明确失败,不静默修正。移动平均仅使用 NumPy,Savitzky–Golay 按需检查 SciPy 的 `savgol_coeffs`。
+
### FIR 滤波
FIR 算子同时支持四种响应:
diff --git a/src/wavebench/data/pipeline_operations.py b/src/wavebench/data/pipeline_operations.py
index e8deb463..cb88862b 100644
--- a/src/wavebench/data/pipeline_operations.py
+++ b/src/wavebench/data/pipeline_operations.py
@@ -61,6 +61,68 @@ def normalize_peaks(operation: dict[str, Any]) -> dict[str, Any]:
return normalized
+def normalize_smooth(operation: dict[str, Any]) -> dict[str, Any]:
+ method = operation["method"]
+ if method not in ("moving_average", "savgol"):
+ raise DataError("smooth method must be moving_average or savgol")
+ length = integer(operation["window_length"], "window_length", 3, 1001)
+ if length % 2 == 0:
+ raise DataError("smooth window_length must be odd")
+ mode, boundary = operation["mode"], operation["boundary"]
+ if mode not in ("centered", "causal") or boundary not in ("reflect", "edge"):
+ raise DataError("smooth requires mode=centered|causal and boundary=reflect|edge")
+ if mode == "causal" and boundary != "edge":
+ raise DataError("causal smooth requires edge boundary; reflection uses future samples")
+ result = dict(op="smooth", method=method, window_length=length, mode=mode, boundary=boundary)
+ if method == "savgol":
+ if "polyorder" not in operation:
+ raise DataError("savgol requires polyorder")
+ result["polyorder"] = integer(operation["polyorder"], "polyorder", 0, min(5, length-1))
+ elif "polyorder" in operation:
+ raise DataError("moving_average does not accept polyorder")
+ return result
+
+
+def smooth_signal(signal: TimeSignal, operation: dict[str, Any]) -> tuple[TimeSignal, dict]:
+ operation = normalize_smooth(operation)
+ if signal.window_name is not None or signal.coherent_gain != 1:
+ raise DataError("smooth must precede the whole-signal window")
+ interval_s = _uniform_sample_interval(signal, "smooth")
+ sample_rate = 1 / interval_s
+ if not np.isfinite(sample_rate):
+ raise DataError("smooth requires a finite sample rate")
+ length = operation["window_length"]
+ if len(signal.voltage_v) < length:
+ raise DataError("smooth requires at least window_length samples")
+ causal = operation["mode"] == "causal"
+ left, right = (length-1, 0) if causal else (length//2, length//2)
+ metadata = {
+ **operation, "sample_rate_hz": sample_rate,
+ "boundary_left_samples": left, "boundary_right_samples": right,
+ "time_shift_applied_s": 0,
+ }
+ if operation["method"] == "moving_average":
+ coefficients = np.ones(length) / length
+ metadata["nominal_group_delay_samples"] = (length - 1) / 2 if causal else 0
+ metadata["nominal_group_delay_s"] = metadata["nominal_group_delay_samples"] * interval_s
+ else:
+ from scipy import __version__ as scipy_version
+ from scipy.signal import savgol_coeffs
+
+ position = length-1 if causal else length//2
+ coefficients = savgol_coeffs(length, operation["polyorder"], deriv=0, pos=position, use="conv")
+ if not np.all(np.isfinite(coefficients)) or not np.isclose(np.sum(coefficients), 1, rtol=1e-6, atol=1e-9):
+ raise DataError("savgol coefficients fail constant-gain validation; reduce window or order")
+ metadata.update({"scipy_version": scipy_version, "evaluation_position": position,
+ "nominal_group_delay_samples": None if causal else 0})
+ metadata["coefficients_sha256"] = sha256(np.asarray(coefficients, dtype=" dict:
operation = normalize_peaks(operation)
if isinstance(signal, TimeSignal):
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index be8d94cb..c55afe13 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -29,7 +29,7 @@
welch_psd,
)
from wavebench.errors import ConfigError, DataError, error_envelope
-from wavebench.data.pipeline_operations import measure_band, detect_peaks
+from wavebench.data.pipeline_operations import measure_band, detect_peaks, smooth_signal
from wavebench.services.run_analysis import evaluate_expect
from wavebench.services.run_artifacts import RunStepRecord
from wavebench.services.run_plan import RunPlan, RunStep
@@ -52,12 +52,15 @@ def ensure_operation_dependencies(all_operations: list[dict[str, Any]]) -> None:
operation
for operation in all_operations
if operation["op"] in {"filter", "psd", "peaks"}
+ or operation["op"] == "smooth" and operation["method"] == "savgol"
]
if not operations:
return
required_functions: set[str] = set()
for operation in operations:
- if operation["op"] == "peaks":
+ if operation["op"] == "smooth":
+ required_functions.add("savgol_coeffs")
+ elif operation["op"] == "peaks":
required_functions.add("find_peaks")
elif operation["op"] == "psd":
required_functions.update({"welch", "get_window"})
@@ -145,6 +148,7 @@ def execute_pipeline(
stages: list[dict[str, Any]] = []
filters: list[dict[str, Any]] = []
peaks: list[dict[str, Any]] = []
+ transformations: list[dict[str, Any]] = []
sampling: dict[str, Any] | None = None
window: dict[str, Any] | None = None
psd: dict[str, Any] | None = None
@@ -174,6 +178,12 @@ def execute_pipeline(
elif op == "detrend":
assert isinstance(signal, TimeSignal)
signal = detrend_linear(signal)
+ elif op == "smooth":
+ assert isinstance(signal, TimeSignal)
+ signal, metadata = smooth_signal(signal, operation)
+ metadata["operation_index"] = operation_index
+ stage["transformation"] = metadata
+ transformations.append(metadata)
elif op == "filter":
assert isinstance(signal, TimeSignal)
if operation["family"] == "fir":
@@ -380,6 +390,8 @@ def execute_pipeline(
manifest["psd"] = psd
if peaks:
manifest["peaks"] = peaks
+ if transformations:
+ manifest["transformations"] = transformations
if failed_stage is not None:
manifest["failed_stage"] = failed_stage
if failure is not None:
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 09a44aee..bae6c130 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -21,7 +21,7 @@
normalize_psd_parameters,
)
from wavebench.errors import ConfigError, DataError
-from wavebench.data.pipeline_operations import normalize_band, normalize_peaks
+from wavebench.data.pipeline_operations import normalize_band, normalize_peaks, normalize_smooth
from wavebench.services.frequency_response import FIT_METHODS
from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
from wavebench.services.frequency_response_baseline import normalize_frequency_response_baseline
@@ -469,6 +469,8 @@ def format_run_plan_schema() -> str:
" Requires optional SciPy. Exports frequency_hz,psd_v2_per_hz with one-sided density scaling.",
" peaks requires name, polarity=positive|negative|both, height>=0, prominence>=0, distance>0, width>=0, max_peaks=1..10000, metrics=[count].",
" Peak distance/width use seconds in time and Hz in spectra; spectral polarity must be positive. Produces _count and JSON/CSV tables without changing signal domain.",
+ " smooth requires method=moving_average|savgol, odd window_length=3..1001, mode=centered|causal, boundary=reflect|edge. Causal requires edge.",
+ " savgol requires polyorder=0..min(5,window_length-1); moving_average rejects polyorder. Smooth requires uniform time data before window/fft/psd.",
])
return "\n".join(lines)
@@ -1315,6 +1317,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"measure": {"op", "metrics"},
"measure_band": {"op", "name", "band_hz", "exclude_hz", "metrics"},
"peaks": {"op", "name", "polarity", "height", "prominence", "distance", "width", "max_peaks", "metrics"},
+ "smooth": {"op", "method", "window_length", "polyorder", "mode", "boundary"},
"export": {"op", "name", "formats"},
}
required_fields = {
@@ -1325,6 +1328,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"measure": {"metrics"},
"measure_band": {"name", "band_hz", "exclude_hz", "metrics"},
"peaks": {"name", "polarity", "height", "prominence", "distance", "width", "max_peaks", "metrics"},
+ "smooth": {"method", "window_length", "mode", "boundary"},
"export": {"name", "formats"},
}
@@ -1365,6 +1369,13 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
raise ConfigError(f"{prefix} operation {op!r} must appear before window")
transforms.add(op)
+ if op == "smooth":
+ if domain != "time" or "window" in transforms:
+ raise ConfigError(f"{operation_prefix}: smooth requires time data before window or fft")
+ try:
+ operation = normalize_smooth(raw_operation)
+ except DataError as exc:
+ raise ConfigError(f"{operation_prefix}: {exc}") from exc
if op == "filter":
if domain == "frequency":
raise ConfigError(f"{prefix} operation 'filter' must appear before fft")
diff --git a/tests/test_pipeline_smooth.py b/tests/test_pipeline_smooth.py
new file mode 100644
index 00000000..d1deb0d4
--- /dev/null
+++ b/tests/test_pipeline_smooth.py
@@ -0,0 +1,111 @@
+import json
+from unittest.mock import patch
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+
+from wavebench.data.pipeline_operations import smooth_signal
+from wavebench.errors import ConfigError, DataError
+from wavebench.services.analysis_service import run_analysis
+from wavebench.services.run_pipeline import ensure_operation_dependencies
+from test_analysis_service import analysis_input as analysis_input
+from test_psd_pipeline import plan_for, signal_for, EXPORT
+
+
+SMOOTH = dict(op="smooth", method="moving_average", window_length=5, mode="centered", boundary="reflect")
+
+
+def test_moving_average_boundary_and_causal_delay():
+ signal = signal_for(np.arange(9.), fs=1)
+ result, metadata = smooth_signal(signal, SMOOTH)
+ np.testing.assert_allclose(result.voltage_v, [1.2, 1.4, 2, 3, 4, 5, 6, 6.6, 6.8])
+ np.testing.assert_array_equal(result.time_s, signal.time_s)
+ assert metadata["boundary_left_samples"] == metadata["boundary_right_samples"] == 2
+ causal, metadata = smooth_signal(signal, SMOOTH | dict(mode="causal", boundary="edge"))
+ np.testing.assert_allclose(causal.voltage_v, [0, .2, .6, 1.2, 2, 3, 4, 5, 6])
+ assert metadata["nominal_group_delay_s"] == 2
+
+
+@pytest.mark.parametrize("mode,boundary", [("centered", "reflect"), ("centered", "edge"), ("causal", "edge")])
+@pytest.mark.parametrize("method", ["moving_average", "savgol"])
+def test_smooth_preserves_constants_and_time(method, mode, boundary):
+ if method == "savgol":
+ pytest.importorskip("scipy")
+ operation = SMOOTH | dict(method=method, mode=mode, boundary=boundary)
+ if method == "savgol":
+ operation["polyorder"] = 2
+ signal = signal_for(np.ones(32) * 3)
+ result, metadata = smooth_signal(signal, operation)
+ np.testing.assert_allclose(result.voltage_v, 3, atol=1e-12)
+ np.testing.assert_array_equal(result.time_s, signal.time_s)
+ assert len(metadata["coefficients_sha256"]) == 64
+
+
+@pytest.mark.parametrize("mode", ["centered", "causal"])
+def test_savgol_preserves_quadratic_away_from_edges(mode):
+ pytest.importorskip("scipy")
+ signal = signal_for(np.arange(30.) ** 2)
+ operation = SMOOTH | dict(method="savgol", polyorder=2, mode=mode, boundary="edge")
+ result, _ = smooth_signal(signal, operation)
+ region = slice(4, None) if mode == "causal" else slice(2, -2)
+ np.testing.assert_allclose(result.voltage_v[region], signal.voltage_v[region], atol=1e-10)
+ future = signal_for(np.r_[np.arange(15.) ** 2, np.ones(15) * 10000])
+ if mode == "causal":
+ altered, _ = smooth_signal(future, operation)
+ np.testing.assert_array_equal(result.voltage_v[:15], altered.voltage_v[:15])
+
+
+@pytest.mark.parametrize("change", [dict(method="median"), dict(window_length=4),
+ dict(window_length=True), dict(window_length=1003), dict(polyorder=2),
+ dict(mode="causal", boundary="reflect"), dict(boundary="wrap"),
+ dict(method="savgol"), dict(method="savgol", polyorder=5),
+])
+def test_smooth_configuration(tmp_path, change):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [SMOOTH | change, EXPORT])
+
+
+def test_smooth_domains_and_runtime_bounds(tmp_path):
+ for prefix in ([dict(op="fft")], [dict(op="window", name="hann")]):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [*prefix, SMOOTH, EXPORT])
+ with pytest.raises(DataError, match="window_length"):
+ smooth_signal(signal_for(np.ones(4)), SMOOTH)
+ signal = signal_for(np.ones(10))
+ signal.time_s[5] += .001
+ with pytest.raises(DataError, match="uniform"):
+ smooth_signal(signal, SMOOTH)
+ signal = signal_for(np.ones(5))
+ signal.time_s[:] = np.arange(5) * 1e-309
+ with pytest.raises(DataError, match="finite sample rate"):
+ smooth_signal(signal, SMOOTH)
+
+
+def test_smooth_dependency_only_for_savgol():
+ with patch("wavebench.services.run_pipeline.import_module", return_value=SimpleNamespace()) as imported:
+ ensure_operation_dependencies([SMOOTH])
+ imported.assert_not_called()
+ with pytest.raises(ConfigError, match="savgol_coeffs"):
+ ensure_operation_dependencies([SMOOTH | dict(method="savgol", polyorder=2)])
+
+
+def test_smooth_manifest_and_partial_exports(tmp_path, analysis_input):
+ capture, recipe = analysis_input
+ recipe.write_text('''schema="wavebench.analysis_recipe.v1"
+operations=[
+{op="smooth",method="moving_average",window_length=5,mode="causal",boundary="edge"},
+{op="export",name="smoothed",formats=["npy"]},
+{op="smooth",method="moving_average",window_length=501,mode="centered",boundary="reflect"},
+{op="measure",metrics=["voltage_rms_v"]},
+]
+''')
+ output = tmp_path / "analysis"
+ before = (capture / "ch1.npy").read_bytes()
+ result = run_analysis(capture, 1, recipe, output)
+ assert result["status"] == "failed"
+ manifest = json.loads((output / "manifest.json").read_text())
+ assert manifest["partial"]
+ assert manifest["transformations"][0]["nominal_group_delay_samples"] == 2
+ assert (output / "exports/smoothed.npy").is_file()
+ assert (capture / "ch1.npy").read_bytes() == before
diff --git a/tests/test_run_plan_analysis.py b/tests/test_run_plan_analysis.py
index 268c07d2..dd071a04 100644
--- a/tests/test_run_plan_analysis.py
+++ b/tests/test_run_plan_analysis.py
@@ -179,7 +179,7 @@ def test_pipeline_operator_parameters_and_domains_are_strict(self) -> None:
cases = {
"operations must be a non-empty array": "",
"operation must be a TOML table": '"remove_dc"',
- "unsupported op": '{ op = "smooth" }',
+ "unsupported op": '{ op = "python_callback" }',
"unknown field 'method'": '{ op = "remove_dc", method = "linear" }',
"method must be 'linear'": '{ op = "detrend", method = "constant" }',
"name must be one of": '{ op = "window", name = "bartlett" }',
From 25b1ea97e76717a1db8bb9568b2d83dd518296e7 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 18:30:02 +0800
Subject: [PATCH 14/30] feat(analysis): resample with explicit polyphase FIR
and updated time axes
---
docs/reference/artifacts.md | 2 +
docs/reference/generated/run-schema.md | 2 +
docs/reference/run-schema.md | 15 ++-
plans/example_processed_recipe.toml | 12 ++
src/wavebench/data/pipeline_operations.py | 67 ++++++++++
src/wavebench/report/html.py | 4 +
src/wavebench/services/run_pipeline.py | 17 ++-
src/wavebench/services/run_plan.py | 15 ++-
tests/test_pipeline_resample.py | 142 ++++++++++++++++++++++
9 files changed, 266 insertions(+), 10 deletions(-)
create mode 100644 plans/example_processed_recipe.toml
create mode 100644 tests/test_pipeline_resample.py
diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md
index 3adf7acf..a989d578 100644
--- a/docs/reference/artifacts.md
+++ b/docs/reference/artifacts.md
@@ -2,6 +2,8 @@
## 独立离线分析
+成功重采样也写入 `transformations` 和 stage 的 `transformation`,记录约分比例、输入/输出样本数、采样率、间隔、时间范围、输出长度规则、固定滤波器 tap 数与截止频率、设计采样率、系数摘要、SciPy 版本和边界规则。manifest 的 `sampling` 随重采样更新,后续算子记录实际使用的新采样率。
+
成功平滑时,manifest 条件性增加 `transformations`,对应 stage 记录同一份 `transformation`。其中包括规范化参数、实际采样率、左右边界影响样本数、系数摘要、时间轴是否平移、可定义的名义群延迟;Savitzky–Golay 另外记录 SciPy 版本和窗口评价位置。未执行成功时不增加该项,后续失败保留此前的变换记录。
`peaks` 将峰列表写入处理目录的 `peaks/.json` 和 `peaks/.csv`。JSON 使用 `wavebench.peaks.v1`,记录数据域、单位、SciPy 版本、检测输入两列 little-endian float64 的 SHA-256、完整数量、保留数量、截断状态和峰属性。CSV 列为 `index,position,value,prominence,width,polarity`;极性以 1/-1 表示。manifest、对应 stage 及 step artifact 条件性增加峰表路径与文件摘要。后续算子失败保留已完成峰表,截断会记录警告。
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index 39f91553..c87cb079 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -257,4 +257,6 @@ analysis.pipeline PSD operation:
Peak distance/width use seconds in time and Hz in spectra; spectral polarity must be positive. Produces _count and JSON/CSV tables without changing signal domain.
smooth requires method=moving_average|savgol, odd window_length=3..1001, mode=centered|causal, boundary=reflect|edge. Causal requires edge.
savgol requires polyorder=0..min(5,window_length-1); moving_average rejects polyorder. Smooth requires uniform time data before window/fft/psd.
+ resample requires positive integer up/down (reduced factors <=10000), window=kaiser, beta=0..30, padtype=constant|line. Output is limited to 20000000 samples.
+ Resample requires uniform time data before window/fft/psd; preserves time origin, uses a pinned polyphase FIR design and updates downstream sampling metadata.
```
diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md
index 434f047f..b7265b96 100644
--- a/docs/reference/run-schema.md
+++ b/docs/reference/run-schema.md
@@ -83,6 +83,7 @@ thd_ratio = { max = 0.05 }
| `detrend` | `method = "linear"` | 时域 → 时域 |
| `filter` | FIR 或 IIR 的判别式设计参数 | 时域 → 时域 |
| `smooth` | 方法、奇数窗口长度、模式和边界,见下文 | 时域 → 时域 |
+| `resample` | 比例、Kaiser 窗参数和边界,见下文 | 时域 → 新采样率时域 |
| `window` | `name = "hann|hamming|blackman"` | 时域 → 时域 |
| `fft` | 无 | 时域 → 频域 |
| `psd` | Welch 分段参数,见下文 | 时域 → PSD |
@@ -91,7 +92,7 @@ thd_ratio = { max = 0.05 }
| `peaks` | 命名检测、筛选条件和数量上限,见下文 | 观察当前域,不改变数据 |
| `export` | 安全的 `name`;`formats` 为 `npy`、`csv` 的非空子集 | 导出当前域,不改变数据 |
-`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter` 可以重复,从而按声明顺序串联多个 FIR/IIR stage。去直流、去趋势和滤波必须位于窗口之前,所有时域变换必须位于 FFT 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure` 或 `export`。
+`remove_dc`、`detrend`、`window` 和 `fft` 各至多出现一次;`remove_dc` 与 `detrend` 互斥。`filter`、`smooth` 和 `resample` 可以重复,按声明顺序执行。去直流、去趋势、滤波、平滑和重采样必须位于整段窗口之前,所有时域变换必须位于 FFT/PSD 之前。测量指标和导出名称在同一流水线内不得重复,流水线至少包含一个 `measure`、`measure_band`、`peaks` 或 `export`。
### 时域平滑
@@ -106,6 +107,18 @@ thd_ratio = { max = 0.05 }
移动平均各点等权;因果模式名义群延迟为 `(window_length - 1) / 2` 个样本。Savitzky–Golay 使用零阶导数系数,居中模式在窗口中点评价,因果模式在末点评价;因果模式不声明固定群延迟。系数非有限或常量增益校验失败时明确失败,不静默修正。移动平均仅使用 NumPy,Savitzky–Golay 按需检查 SciPy 的 `savgol_coeffs`。
+### 有理数比例重采样
+
+```toml
+{ op = "resample", up = 2, down = 3, window = "kaiser", beta = 5, padtype = "line" }
+```
+
+全部参数必填。`up`/`down` 是正整数,约分后各不超过 10000;输入整数上限为 `2^63 - 1`,输出不超过 20000000 个样本。`window` 固定为 `kaiser`,`beta` 为 0~30 的有限数;边界选 `constant`(零延拓)或 `line`(按首末点连线延拓)。只接受等间隔时域输入,必须位于整段 window、FFT 和 PSD 之前。
+
+实现使用 `resample_poly` 和显式设计的对称 FIR。设约分后的 `rate = max(up, down)`,滤波器为 `20 × rate + 1` taps、归一化截止频率 `1 / rate` 的 Kaiser 窗设计,设计采样率为原采样率乘 `up`。比例为 1 时直接保留数据,不滤波。该滤波器提供抗混叠,实际通带与阻带性能随参数变化,不等同于理想砖墙滤波。
+
+输出长度为 `ceil(N × up / down)`,时间轴为 `t0 + arange(N_out) × dt × down / up`。保留时间原点,可能产生位于原末样本之后、但属于输出采样网格的末点;边界值由延拓合同决定。不通过拉伸时间轴强行匹配原末点。无法表示有限且严格递增的新时间轴时失败。后续滤波、峰值、FFT 和 PSD 使用新采样率,原始 NPY 保持不变。
+
### FIR 滤波
FIR 算子同时支持四种响应:
diff --git a/plans/example_processed_recipe.toml b/plans/example_processed_recipe.toml
new file mode 100644
index 00000000..3a263124
--- /dev/null
+++ b/plans/example_processed_recipe.toml
@@ -0,0 +1,12 @@
+schema = "wavebench.analysis_recipe.v1"
+operations = [
+ { op = "export", name = "original", formats = ["npy"] },
+ { op = "remove_dc" },
+ { op = "smooth", method = "savgol", window_length = 11, polyorder = 2, mode = "centered", boundary = "reflect" },
+ { op = "resample", up = 1, down = 2, window = "kaiser", beta = 5, padtype = "line" },
+ { op = "export", name = "processed", formats = ["npy", "csv"] },
+ { op = "window", name = "hann" },
+ { op = "fft" },
+ { op = "peaks", name = "tones", polarity = "positive", height = 0.01, prominence = 0.01, distance = 10, width = 0, max_peaks = 20, metrics = ["count"] },
+ { op = "export", name = "spectrum", formats = ["npy", "csv"] },
+]
diff --git a/src/wavebench/data/pipeline_operations.py b/src/wavebench/data/pipeline_operations.py
index cb88862b..a22ff969 100644
--- a/src/wavebench/data/pipeline_operations.py
+++ b/src/wavebench/data/pipeline_operations.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
+from math import gcd
from hashlib import sha256
from typing import Any
@@ -123,6 +124,72 @@ def smooth_signal(signal: TimeSignal, operation: dict[str, Any]) -> tuple[TimeSi
return TimeSignal(signal.time_s.copy(), voltage), metadata
+def normalize_resample(operation: dict[str, Any]) -> dict[str, Any]:
+ up = integer(operation["up"], "up", 1, 2**63 - 1)
+ down = integer(operation["down"], "down", 1, 2**63 - 1)
+ divisor = gcd(up, down)
+ up, down = up // divisor, down // divisor
+ if max(up, down) > 10000:
+ raise DataError("reduced resampling factors must not exceed 10000")
+ if operation["window"] != "kaiser":
+ raise DataError("resample window must be kaiser")
+ beta = number(operation["beta"], "beta")
+ if beta > 30:
+ raise DataError("resample beta must not exceed 30")
+ if operation["padtype"] not in ("constant", "line"):
+ raise DataError("resample padtype must be constant or line")
+ return dict(op="resample", up=up, down=down, window="kaiser", beta=beta,
+ padtype=operation["padtype"])
+
+
+def resample_signal(signal: TimeSignal, operation: dict[str, Any]) -> tuple[TimeSignal, dict]:
+ operation = normalize_resample(operation)
+ if signal.window_name is not None or signal.coherent_gain != 1:
+ raise DataError("resample must precede the whole-signal window")
+ interval_s = _uniform_sample_interval(signal, "resample")
+ up, down = operation["up"], operation["down"]
+ input_samples = len(signal.voltage_v)
+ output_samples = (input_samples * up + down - 1) // down
+ if output_samples > 20000000:
+ raise DataError("resample output exceeds 20000000 samples")
+ output_interval = interval_s * down / up
+ if (not np.isfinite(output_interval) or output_interval <= 0
+ or not np.isfinite(1 / output_interval) or not np.isfinite(1 / interval_s)
+ or not np.isfinite((1 / interval_s) * up)):
+ raise DataError("resample requires finite input and output sample rates")
+ from scipy import __version__ as scipy_version
+ from scipy.signal import firwin, resample_poly
+
+ # Pin the filter design instead of inheriting future resample_poly defaults.
+ rate = max(up, down)
+ taps = (np.ones(1) if up == down else
+ firwin(20 * rate + 1, 1 / rate, window=("kaiser", operation["beta"])))
+ if not np.all(np.isfinite(taps)):
+ raise DataError("resample filter coefficients must be finite")
+ voltage = resample_poly(signal.voltage_v, up, down, window=taps,
+ padtype=operation["padtype"])
+ times = float(signal.time_s[0]) + np.arange(output_samples) * output_interval
+ if (len(voltage) != output_samples or not np.all(np.isfinite(voltage))
+ or not np.all(np.isfinite(times)) or not np.all(np.diff(times) > 0)):
+ raise DataError("resample output must be finite with a strictly increasing time axis")
+ if output_samples > 1 and not np.allclose(np.diff(times), output_interval, rtol=1e-6, atol=0):
+ raise DataError("resample time axis cannot represent the requested spacing accurately")
+ return TimeSignal(times, voltage), {
+ **operation, "scipy_version": scipy_version,
+ "execution_function": "scipy.signal.resample_poly",
+ "input_samples": input_samples, "output_samples": output_samples,
+ "input_sample_rate_hz": 1 / interval_s,
+ "sample_rate_hz": 1 / output_interval, "sample_interval_s": output_interval,
+ "time_start_s": float(times[0]), "time_stop_s": float(times[-1]),
+ "output_length_rule": "ceil(input_samples * up / down)",
+ "filter_numtaps": len(taps), "filter_cutoff_normalized": 1 / rate,
+ "filter_design_rate_hz": (1 / interval_s) * up,
+ "filter_coefficients_sha256": sha256(np.asarray(taps, dtype=" dict:
operation = normalize_peaks(operation)
if isinstance(signal, TimeSignal):
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 2fdaf088..abe07c0e 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -2054,6 +2054,10 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str:
def _analysis_operation_label(operation: dict[str, Any]) -> str:
op = str(operation.get("op", ""))
+ if op in {"smooth", "resample", "measure_band", "peaks"}:
+ return op + "(" + ", ".join(
+ f"{key}={value}" for key, value in operation.items() if key != "op"
+ ) + ")"
if op == "psd":
return "psd(" + ", ".join(
f"{key}={operation.get(key, '')}"
diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py
index c55afe13..9e818fa9 100644
--- a/src/wavebench/services/run_pipeline.py
+++ b/src/wavebench/services/run_pipeline.py
@@ -29,7 +29,7 @@
welch_psd,
)
from wavebench.errors import ConfigError, DataError, error_envelope
-from wavebench.data.pipeline_operations import measure_band, detect_peaks, smooth_signal
+from wavebench.data.pipeline_operations import measure_band, detect_peaks, smooth_signal, resample_signal
from wavebench.services.run_analysis import evaluate_expect
from wavebench.services.run_artifacts import RunStepRecord
from wavebench.services.run_plan import RunPlan, RunStep
@@ -51,14 +51,16 @@ def ensure_operation_dependencies(all_operations: list[dict[str, Any]]) -> None:
operations = [
operation
for operation in all_operations
- if operation["op"] in {"filter", "psd", "peaks"}
+ if operation["op"] in {"filter", "psd", "peaks", "resample"}
or operation["op"] == "smooth" and operation["method"] == "savgol"
]
if not operations:
return
required_functions: set[str] = set()
for operation in operations:
- if operation["op"] == "smooth":
+ if operation["op"] == "resample":
+ required_functions.update({"resample_poly", "firwin"})
+ elif operation["op"] == "smooth":
required_functions.add("savgol_coeffs")
elif operation["op"] == "peaks":
required_functions.add("find_peaks")
@@ -178,12 +180,17 @@ def execute_pipeline(
elif op == "detrend":
assert isinstance(signal, TimeSignal)
signal = detrend_linear(signal)
- elif op == "smooth":
+ elif op in {"smooth", "resample"}:
assert isinstance(signal, TimeSignal)
- signal, metadata = smooth_signal(signal, operation)
+ signal, metadata = (smooth_signal(signal, operation) if op == "smooth"
+ else resample_signal(signal, operation))
metadata["operation_index"] = operation_index
stage["transformation"] = metadata
transformations.append(metadata)
+ if op == "resample":
+ sampling = _time_sampling(signal)
+ sampling.update({"sample_interval_s": metadata["sample_interval_s"],
+ "sample_rate_hz": metadata["sample_rate_hz"]})
elif op == "filter":
assert isinstance(signal, TimeSignal)
if operation["family"] == "fir":
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index bae6c130..74d6ac26 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -21,7 +21,9 @@
normalize_psd_parameters,
)
from wavebench.errors import ConfigError, DataError
-from wavebench.data.pipeline_operations import normalize_band, normalize_peaks, normalize_smooth
+from wavebench.data.pipeline_operations import (
+ normalize_band, normalize_peaks, normalize_smooth, normalize_resample,
+)
from wavebench.services.frequency_response import FIT_METHODS
from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
from wavebench.services.frequency_response_baseline import normalize_frequency_response_baseline
@@ -471,6 +473,8 @@ def format_run_plan_schema() -> str:
" Peak distance/width use seconds in time and Hz in spectra; spectral polarity must be positive. Produces _count and JSON/CSV tables without changing signal domain.",
" smooth requires method=moving_average|savgol, odd window_length=3..1001, mode=centered|causal, boundary=reflect|edge. Causal requires edge.",
" savgol requires polyorder=0..min(5,window_length-1); moving_average rejects polyorder. Smooth requires uniform time data before window/fft/psd.",
+ " resample requires positive integer up/down (reduced factors <=10000), window=kaiser, beta=0..30, padtype=constant|line. Output is limited to 20000000 samples.",
+ " Resample requires uniform time data before window/fft/psd; preserves time origin, uses a pinned polyphase FIR design and updates downstream sampling metadata.",
])
return "\n".join(lines)
@@ -1318,6 +1322,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"measure_band": {"op", "name", "band_hz", "exclude_hz", "metrics"},
"peaks": {"op", "name", "polarity", "height", "prominence", "distance", "width", "max_peaks", "metrics"},
"smooth": {"op", "method", "window_length", "polyorder", "mode", "boundary"},
+ "resample": {"op", "up", "down", "window", "beta", "padtype"},
"export": {"op", "name", "formats"},
}
required_fields = {
@@ -1329,6 +1334,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
"measure_band": {"name", "band_hz", "exclude_hz", "metrics"},
"peaks": {"name", "polarity", "height", "prominence", "distance", "width", "max_peaks", "metrics"},
"smooth": {"method", "window_length", "mode", "boundary"},
+ "resample": {"up", "down", "window", "beta", "padtype"},
"export": {"name", "formats"},
}
@@ -1369,11 +1375,12 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None:
raise ConfigError(f"{prefix} operation {op!r} must appear before window")
transforms.add(op)
- if op == "smooth":
+ if op in {"smooth", "resample"}:
if domain != "time" or "window" in transforms:
- raise ConfigError(f"{operation_prefix}: smooth requires time data before window or fft")
+ raise ConfigError(f"{operation_prefix}: {op} requires time data before window or fft")
try:
- operation = normalize_smooth(raw_operation)
+ operation = (normalize_smooth(raw_operation) if op == "smooth"
+ else normalize_resample(raw_operation))
except DataError as exc:
raise ConfigError(f"{operation_prefix}: {exc}") from exc
if op == "filter":
diff --git a/tests/test_pipeline_resample.py b/tests/test_pipeline_resample.py
new file mode 100644
index 00000000..69c14a89
--- /dev/null
+++ b/tests/test_pipeline_resample.py
@@ -0,0 +1,142 @@
+import json
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+from wavebench.data.pipeline_operations import resample_signal
+from wavebench.data.signal_pipeline import fft_signal, measure_frequency
+from wavebench.errors import ConfigError, DataError
+from wavebench.services.analysis_service import run_analysis
+from wavebench.services.run_pipeline import ensure_operation_dependencies
+from test_analysis_service import analysis_input as analysis_input
+from test_psd_pipeline import plan_for, signal_for, EXPORT
+
+
+RESAMPLE = dict(op="resample", up=1, down=2, window="kaiser", beta=5., padtype="line")
+
+
+@pytest.mark.parametrize("up,down", [(2,1), (1,2), (3,2), (2,3), (1,1)])
+def test_rates_lengths_constant_gain_and_time_origin(up, down):
+ pytest.importorskip("scipy")
+ signal = signal_for(np.ones(129) * 3, fs=128)
+ signal.time_s[:] += 2
+ result, metadata = resample_signal(signal, RESAMPLE | dict(up=up, down=down))
+ assert len(result.time_s) == (129*up + down-1)//down
+ assert result.time_s[0] == 2
+ np.testing.assert_allclose(np.diff(result.time_s), down/up/128, rtol=1e-12)
+ np.testing.assert_allclose(result.voltage_v, 3, atol=.003)
+ assert metadata["sample_rate_hz"] == pytest.approx(128*up/down)
+ assert len(metadata["filter_coefficients_sha256"]) == 64
+
+
+def test_downsampling_suppresses_alias_and_fft_uses_new_rate():
+ pytest.importorskip("scipy")
+ times = np.arange(4096) / 1024
+ values = np.sin(2*np.pi*32*times) + np.sin(2*np.pi*400*times)
+ result, _ = resample_signal(signal_for(values, fs=1024), RESAMPLE)
+ interior = result.voltage_v[50:-50]
+ expected = np.sin(2*np.pi*32*result.time_s[50:-50])
+ assert np.sqrt(np.mean((interior-expected)**2)) < .003
+ fft = fft_signal(result)
+ metrics, _ = measure_frequency(fft, ["peak_frequency_hz", "peak_amplitude_v"])
+ assert metrics["peak_frequency_hz"] == pytest.approx(32)
+ assert metrics["peak_amplitude_v"] == pytest.approx(1, abs=.005)
+
+
+@pytest.mark.parametrize("change", [dict(up=0), dict(down=True), dict(up=1.5),
+ dict(up=10001), dict(beta=-1), dict(beta=31), dict(window="hann"), dict(padtype="wrap")])
+def test_resample_static_validation(tmp_path, change):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [RESAMPLE | change, EXPORT])
+
+
+def test_normalization_domains_and_runtime_guards(tmp_path):
+ plan = plan_for(tmp_path, [RESAMPLE | dict(up=20000, down=40000), EXPORT])
+ assert plan.steps[1].fields["operations"][0]["down"] == 2
+ for prefix in ([dict(op="fft")], [dict(op="window", name="hann")]):
+ with pytest.raises(ConfigError):
+ plan_for(tmp_path, [*prefix, RESAMPLE, EXPORT])
+ signal = signal_for(np.ones(3000))
+ with pytest.raises(DataError, match="20000000"):
+ resample_signal(signal, RESAMPLE | dict(up=10000, down=1))
+ signal.time_s[5] += .001
+ with pytest.raises(DataError, match="uniform"):
+ resample_signal(signal, RESAMPLE)
+
+
+def test_output_sampling_metadata_and_later_filter_nyquist(tmp_path, analysis_input):
+ pytest.importorskip("scipy")
+ capture, recipe = analysis_input
+ recipe.write_text('''schema="wavebench.analysis_recipe.v1"
+operations=[
+{op="resample",up=1,down=2,window="kaiser",beta=5,padtype="constant"},
+{op="export",name="resampled",formats=["npy","csv"]},
+{op="filter",family="fir",response="lowpass",cutoff_hz=40,numtaps=5,mode="causal"},
+{op="measure",metrics=["voltage_rms_v"]},
+]
+''')
+ original = (capture / "ch1.npy").read_bytes()
+ output = tmp_path / "analysis"
+ result = run_analysis(capture, 1, recipe, output)
+ assert result["status"] == "failed" # New Nyquist is 32 Hz, not 64 Hz.
+ manifest = json.loads((output / "manifest.json").read_text())
+ assert manifest["sampling"]["samples"] == 64
+ assert manifest["sampling"]["sample_rate_hz"] == 64
+ assert manifest["transformations"][0]["input_sample_rate_hz"] == 128
+ assert manifest["partial"]
+ np.testing.assert_allclose(np.load(output / "exports/resampled.npy"),
+ np.loadtxt(output / "exports/resampled.csv", delimiter=",", skiprows=1))
+ assert (capture / "ch1.npy").read_bytes() == original
+
+
+def test_resample_missing_dependency():
+ with patch("wavebench.services.run_pipeline.import_module", return_value=SimpleNamespace()):
+ with pytest.raises(ConfigError, match="resample_poly"):
+ ensure_operation_dependencies([RESAMPLE])
+
+
+def test_resample_extreme_time_axis_and_short_result():
+ pytest.importorskip("scipy")
+ signal = signal_for(np.ones(5))
+ signal.time_s[:] = np.arange(5) * 1e-309
+ with pytest.raises(DataError, match="finite"):
+ resample_signal(signal, RESAMPLE)
+ result, _ = resample_signal(signal_for(np.ones(5)), RESAMPLE | dict(down=100))
+ assert len(result.time_s) == 1
+
+
+def test_runplan_and_offline_recipe_share_processed_results(tmp_path, analysis_input):
+ pytest.importorskip("scipy")
+ from pathlib import Path
+ from wavebench.services.analysis_service import load_analysis_recipe
+ from wavebench.services.run_pipeline import execute_analysis_pipeline
+ from wavebench.services.run_artifacts import RunStepRecord
+ from wavebench.services.run_plan import RunStep
+
+ capture, _ = analysis_input
+ recipe = Path("plans/example_processed_recipe.toml")
+ fields = load_analysis_recipe(recipe)
+ offline = run_analysis(capture, 1, recipe, tmp_path / "offline")
+ source_step = RunStep(0, "scope.capture", {"save_npy": True}, "capture_main")
+ source_record = RunStepRecord(index=0, kind="scope.capture", status="ok",
+ fields=source_step.fields,
+ artifact={"package": str(capture), "metadata": str(capture / "metadata.json")})
+ step = RunStep(1, "analysis.pipeline", fields | {"source": {"step": "capture_main"}}, "processed")
+ artifact = execute_analysis_pipeline(run_dir=tmp_path / "run", step=step,
+ source_step=source_step, source_record=source_record)
+ assert artifact["metrics"] == offline["artifact"]["metrics"]
+ for first, second in zip(artifact["analysis_pipeline"]["exports"], offline["artifact"]["analysis_pipeline"]["exports"]):
+ assert first["sha256"] == second["sha256"]
+
+
+def test_psd_after_resample_uses_updated_rate():
+ pytest.importorskip("scipy")
+ from wavebench.data.signal_pipeline import welch_psd
+ from test_psd_pipeline import PARAMS
+
+ resampled, _ = resample_signal(signal_for(np.ones(128)), RESAMPLE)
+ psd = welch_psd(resampled, **PARAMS)
+ assert psd.frequency_hz[-1] == pytest.approx(32)
+ assert psd.sample_interval_s == pytest.approx(1/64)
From 7aee2cad47a2207b795ef2191cb40b71c9816072 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Sat, 5 Sep 2026 18:35:46 +0800
Subject: [PATCH 15/30] docs(plans): showcase the complete signal processing
pipeline
---
plans/README.md | 34 +++++++++++++++-
plans/example_signal_processing_pipeline.toml | 39 ++++++++++++++++++-
2 files changed, 71 insertions(+), 2 deletions(-)
diff --git a/plans/README.md b/plans/README.md
index 626f0823..3420783a 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -1,6 +1,6 @@
# Run plan 示例
-这里的 TOML 文件是实验计划,不是模拟器。文件名里有 `example`,也不代表可以在没有仪器时直接执行;很多计划会设置 source、打开输出、触发采集,或者依赖一份本地 baseline。
+这里包含实验 RunPlan 和独立离线分析配方。文件名里有 `example`,不代表 RunPlan 可以在没有仪器时直接执行;很多计划会设置 source、打开输出、触发采集,或者依赖一份本地 baseline。`*_recipe.toml` 使用 `analysis` 命令处理已有采集包,不作为 RunPlan 执行。
## 先做离线检查
@@ -15,6 +15,38 @@ wavebench run check --plan plans/example_scope_expect_quality.toml
`example_signal_processing_pipeline.toml` 包含 FIR 带阻、IIR 高通、因果和零相位处理,需要安装 `.[analysis]`。`run check` 只在 Plan 选择需要 SciPy 的算子时检查该可选依赖。
+## 信号处理功能展示
+
+[完整 RunPlan 示例](example_signal_processing_pipeline.toml) 采集 CH1 一次,然后对同一份原始 NPY 执行三个独立分析步骤:
+
+| 步骤 ID | 展示内容 | 报告产物 |
+| --- | --- | --- |
+| `spectrum_main` | 时域统计、去直流、FIR 带阻、IIR 高通、Hann 窗、FFT 与谐波验收 | 原始波形、滤波频谱与 THD |
+| `processed_main` | Savitzky–Golay 平滑、采样率减半、FFT、多峰检测 | 处理后波形、频谱、峰表与峰标记 |
+| `density_main` | Welch PSD、带内均方值/RMS、排除基波频带后的噪声 RMS | PSD 曲线及频带验收结果 |
+
+演示输入为约 1 Vpp 的 1 kHz 正弦,均匀采样率至少 20 kSa/s、至少 4096 点。信号源与采集参数需事先配置;示例不会设置或开启信号源。频率误差受实际记录长度和 FFT bin 间距影响,验收阈值仅供演示,应按实际输入调整。采集或前两个分析步骤失败时继续尝试后续分析,最终 run 仍记录失败。
+
+```bash
+wavebench run check --plan plans/example_signal_processing_pipeline.toml
+```
+
+完成接线确认与 `run verify` 后,通过 `run plan` 执行。已有运行产物可以直接生成报告:
+
+```bash
+wavebench run report data/runs/
+wavebench analysis report data/runs/ --output data/processing_comparison.html
+```
+
+报告可比较原始/处理后波形及两条 FFT 曲线,PSD 使用独立单位显示。派生文件位于 run 的 `processing/`,原始 NPY 保持不变。独立比较报告的输出文件必须尚不存在。
+
+只有历史采集包时,可使用 [FFT 配方](example_analysis_recipe.toml)、[PSD 配方](example_psd_recipe.toml) 或[平滑/重采样/峰值配方](example_processed_recipe.toml):
+
+```bash
+wavebench analysis run --capture data/raw/ --channel 1 --recipe plans/example_processed_recipe.toml --output data/analysis_demo
+wavebench analysis report data/analysis_demo --output data/analysis_demo.html
+```
+
## 计划分类
### 通用示例
diff --git a/plans/example_signal_processing_pipeline.toml b/plans/example_signal_processing_pipeline.toml
index 08c0af8c..02f61428 100644
--- a/plans/example_signal_processing_pipeline.toml
+++ b/plans/example_signal_processing_pipeline.toml
@@ -1,6 +1,10 @@
# Example WaveBench signal-processing run plan.
# This plan performs a real scope acquisition. Confirm the input, probe ratio,
# coupling, voltage range, and bench wiring before execution.
+# Demonstration input: 1 kHz sine, approximately 1 Vpp, uniform sampling
+# at >= 20 kSa/s, >= 4096 samples. Configure the source and acquisition first;
+# this plan does not configure or enable a signal generator.
+# All three analyses read the same capture. They do not feed each other.
[experiment]
name = "example_signal_processing_pipeline"
@@ -22,7 +26,9 @@ on_failure = "continue"
id = "spectrum_main"
kind = "analysis.pipeline"
source = { step = "capture_main" }
+on_failure = "continue"
operations = [
+ { op = "export", name = "original", formats = ["npy", "csv"] },
{ op = "measure", metrics = ["voltage_mean_v", "voltage_rms_v", "voltage_vpp_v"] },
{ op = "remove_dc" },
{ op = "filter", family = "fir", response = "bandstop", cutoff_hz = [49.0, 51.0], numtaps = 101, mode = "zero_phase" },
@@ -37,12 +43,43 @@ operations = [
peak_frequency_hz = { min = 990, max = 1010 }
thd_ratio = { max = 0.05 }
+# Compare the original waveform with a smoothed, half-rate waveform.
+# Peak distance/width are in Hz after FFT; peak height/prominence are in V.
+[[steps]]
+id = "processed_main"
+kind = "analysis.pipeline"
+source = { step = "capture_main" }
+on_failure = "continue"
+operations = [
+ { op = "remove_dc" },
+ { op = "smooth", method = "savgol", window_length = 11, polyorder = 2, mode = "centered", boundary = "reflect" },
+ { op = "resample", up = 1, down = 2, window = "kaiser", beta = 5, padtype = "line" },
+ { op = "export", name = "processed", formats = ["npy", "csv"] },
+ { op = "window", name = "hann" },
+ { op = "fft" },
+ { op = "peaks", name = "tones", polarity = "positive", height = 0.02, prominence = 0.02, distance = 100, width = 0, max_peaks = 10, metrics = ["count"] },
+ { op = "measure", metrics = ["peak_frequency_hz", "peak_amplitude_v"] },
+ { op = "export", name = "processed_spectrum", formats = ["npy", "csv"] },
+]
+
+[steps.expect]
+peak_frequency_hz = { min = 990, max = 1010 }
+tones_count = { min = 1, max = 3 }
+
# Independent density analysis of the same raw capture; no whole-signal window.
[[steps]]
id = "density_main"
kind = "analysis.pipeline"
source = { step = "capture_main" }
operations = [
- { op = "psd", method = "welch", window = "hann", nperseg = 256, noverlap = 128, nfft = 256, detrend = "constant", average = "mean" },
+ { op = "psd", method = "welch", window = "hann", nperseg = 1024, noverlap = 512, nfft = 1024, detrend = "constant", average = "mean" },
+ { op = "measure_band", name = "signal", band_hz = [0, 5000], exclude_hz = [], metrics = ["mean_square_v2", "rms_v"] },
+ { op = "measure_band", name = "noise", band_hz = [0, 5000], exclude_hz = [[800, 1200]], metrics = ["noise_rms_v"] },
{ op = "export", name = "density", formats = ["npy", "csv"] },
]
+
+# Demonstration limits, not a universal instrument specification.
+# Noise includes everything in 0..5 kHz outside the excluded 0.8..1.2 kHz band.
+[steps.expect]
+signal_rms_v = { min = 0.30, max = 0.40 }
+noise_noise_rms_v = { max = 0.03 }
From 5baf27f102ca01794d21a4f6d9c4c73437706222 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Tue, 8 Sep 2026 08:09:56 +0800
Subject: [PATCH 16/30] docs: clarify workflow authorization and validation
scope
---
.agents/skills/wavebench-docs/SKILL.md | 11 +++++--
.agents/skills/wavebench/SKILL.md | 32 ++++++++++++-------
.../references/development-validation.md | 14 +++++---
.../wavebench/references/eval-prompts.md | 8 +++++
docs/development/contributing.md | 9 ++----
docs/development/testing.md | 8 +++++
6 files changed, 56 insertions(+), 26 deletions(-)
diff --git a/.agents/skills/wavebench-docs/SKILL.md b/.agents/skills/wavebench-docs/SKILL.md
index d7bcdc7c..1828ca01 100644
--- a/.agents/skills/wavebench-docs/SKILL.md
+++ b/.agents/skills/wavebench-docs/SKILL.md
@@ -37,8 +37,9 @@ operation under the `wavebench` safety workflow.
## Start from repository facts
1. Work from the Git repository root and inspect `git status --short --branch`.
-2. Read `README.md`, `pyproject.toml`, `CHANGELOG.md`, the relevant documentation
- indexes, and the pages directly in scope.
+2. Read the pages directly in scope and their navigation entries. Use `README.md`
+ to orient a first visit; read `pyproject.toml` or `CHANGELOG.md` only when the
+ task depends on package or release facts.
3. Resolve changing claims from implementation, executable help/schema, tests,
descriptors, and release tags. Existing prose is evidence to audit, not proof
of current behavior.
@@ -53,6 +54,12 @@ lifecycle, or user journeys. This is the single normative source for those rules
## Choose one mode
+A request to explain, diagnose, audit, or review does not by itself authorize
+edits. When the user also requests implementation, finish the authorized edits
+and relevant checks after any stated prerequisite is satisfied. Mode selection
+is a workflow choice, not an extra approval step. Ask only for material missing
+information or work outside the existing authorization.
+
| Mode | Use when | Load |
| --- | --- | --- |
| `audit` | Assess a documentation set without broad edits | [audit.md](references/audit.md) |
diff --git a/.agents/skills/wavebench/SKILL.md b/.agents/skills/wavebench/SKILL.md
index ca975a58..78d3dcb0 100644
--- a/.agents/skills/wavebench/SKILL.md
+++ b/.agents/skills/wavebench/SKILL.md
@@ -4,9 +4,11 @@ description: >-
Safely diagnose, configure, test, and extend the WaveBench Python measurement
bench. Use for WaveBench CLI, run plans, capture packages, reports, TUI,
instrument discovery, oscilloscope capture, signal-generator control,
- programmable-power-supply or digital-multimeter measurements, and WaveBench
- instrument plugins. Do not use for general electronics theory or unrelated
- VISA/SCPI projects.
+ programmable-power-supply or digital-multimeter measurements, Core development,
+ and runtime plugin management. Production development in the plugin monorepo
+ uses wavebench-plugin-development; add this skill for Core changes or live
+ operations. Do not use for documentation-led work, general electronics theory,
+ or unrelated VISA/SCPI projects.
license: MIT
compatibility: >-
Codex or a compatible Agent Skills host; Python 3.11+; Linux, WSL, and
@@ -26,16 +28,21 @@ metadata:
在不意外改变真实硬件的前提下,完成 WaveBench 的诊断、配置、测量、测试和扩展。优先使用能证明结果的最小操作,先做离线或只读检查,为每次实时写入保留可复核证据。
-## Start every task
+## Start within the requested scope
1. 用 `git rev-parse --show-toplevel` 定位仓库根目录,并从根目录工作。
-2. 先读取 `README.md`、`pyproject.toml` 和与任务直接相关的 `docs/project/` 文档。
+2. 初次进入项目时用 `README.md` 定位入口;涉及依赖或版本时读取 `pyproject.toml`,
+ 其余只读取当前任务的实现、契约和相关文档,不预加载全部项目说明。
当前 CLI 事实源依次为实现、`--help`、`run schema`、`run template --list` 和
`wavebench.example.toml`;技能正文与旧记忆不能覆盖这些事实源。
3. 执行 `git status --short --branch`,保留无关用户改动;禁止 reset、强制覆盖或隐式清理。
4. 将任务归类为离线说明/评审、离线代码或配置、实时只读诊断、受控写入或采集。
5. 在安装依赖、编辑配置或连接硬件前,说明计划、影响范围、预期结果和恢复边界。
+解释、诊断和评审默认只读,不自动修复。明确要求实施时,完成已授权修改和相关验证;
+说明计划不是重复确认节点。复合请求按已授权阶段继续,只在关键信息缺失或下一步越界时询问。
+已有实时授权仍需核实当前接线、资源和状态,不能用过去的实验状态代替写前检查。
+
## Risk classes
| 类别 | 典型操作 | 默认处理 |
@@ -48,7 +55,7 @@ metadata:
## Non-negotiable safety gates
-进行任何 setter、输出切换、采集、扫频或验收脚本前:
+实际连接仪器执行 setter、输出切换、采集、扫频或验收脚本前(离线 fake 测试不适用):
1. 确认明确的实时写入授权、当前接线和目标资源。
2. 查询并记录 IDN、相关初始状态、输出状态、保护设置和耦合/负载上下文。
@@ -97,7 +104,7 @@ Reference 只从本入口直接链接,保持一层目录;详细命令和型
## Discover actual capabilities
-不要仅凭型号或 README 推断能力。先确认已启用的驱动、来源、版本和 capability:
+当任务依赖当前安装或硬件能力时,不要仅凭型号或 README 推断能力;按需确认已启用的驱动、来源、版本和 capability。纯文档或数值代码修改不需要加载插件:
```bash
.venv/bin/wavebench plugin list --load
@@ -111,7 +118,7 @@ Reference 只从本入口直接链接,保持一层目录;详细命令和型
## Standard workflows
-离线或只读预检优先使用:
+按阶段选择预检,不把以下命令作为每个任务的固定清单:
```bash
.venv/bin/python -m pip check
@@ -119,17 +126,18 @@ Reference 只从本入口直接链接,保持一层目录;详细命令和型
.venv/bin/wavebench run verify --plan plans/.toml --config wavebench.toml
```
-真实计划必须遵循 `run check → run verify → run plan → run report`,并同时检查步骤状态、质量门、期望指标、产物和最终设备状态。TUI 界面开发使用 `tui --fake`。
+`pip check` 用于依赖变化或环境诊断;`run check` 是离线检查;`run verify` 会连接仪器,只有已授权实时预检时执行。
+已授权执行的真实计划遵循 `run check → run verify → run plan → run report`,并同时检查步骤状态、质量门、期望指标、产物和最终设备状态。仅检查计划的请求到离线检查结果即完成。TUI 界面开发使用 `tui --fake`。
## External research
-只有用户明确要求厂商资料、标准或最新外部信息时才联网检索。优先官方文档,记录来源和日期,不发送本地配置、序列号、网络地址或实验数据。`tavily_hikari` 等搜索 MCP 为可选能力;不可用时说明限制并使用仓库事实源或已能访问的官方页面,不伪造工具调用。
+仓库实现问题优先使用本地事实源。用户要求外部资料,或关键结论需要核实厂商资料、标准或时效信息时,按宿主联网规则检索;遵守用户明确的离线限制,无法核实时说明缺口。优先官方文档,记录来源和日期,不发送本地配置、序列号、网络地址或实验数据。`tavily_hikari` 等搜索 MCP 为可选能力;不可用时说明限制并使用仓库事实源或已能访问的官方页面,不伪造工具调用。
## Code, docs, and handoff
代码改动遵循外科手术式修改:先读实现、契约和聚焦测试,再改最小范围并补测试。公开中文 Markdown 使用项目文档规范,保留代码字面量、路径、URL 和配置键的原样格式。
-验证强度按风险匹配:
+验证强度按风险匹配,以下是候选命令,不要求每轮全部执行;具体条件见 `development-validation.md`:
```bash
.venv/bin/python -m pytest -q tests/.py
@@ -138,4 +146,4 @@ Reference 只从本入口直接链接,保持一层目录;详细命令和型
git diff --check
```
-交接先给结论,再列检查结果、产物路径、最终状态、未恢复设置、剩余能力缺口,以及是否改动跟踪文件、本地配置、虚拟环境或真实仪器。不得用笼统成功描述掩盖跳过、失败、部分产物或恢复错误。
+交接先给结论,再列与任务相关的检查结果和限制;产物路径、最终设备状态、未恢复设置只在实际涉及时报告。不得用笼统成功描述掩盖跳过、失败、部分产物或恢复错误。
diff --git a/.agents/skills/wavebench/references/development-validation.md b/.agents/skills/wavebench/references/development-validation.md
index 6936e312..279a9024 100644
--- a/.agents/skills/wavebench/references/development-validation.md
+++ b/.agents/skills/wavebench/references/development-validation.md
@@ -9,7 +9,7 @@
2. 只修改满足需求的最小范围,避免顺手重构。
3. 为行为变化补充聚焦测试;保持公开 CLI、TUI、报告和发行文案的既有语言约定。
4. 不把本地配置、真实资源、私有协作路径或内部交接规则写入公开文件。
-5. 不自动推送、打标签、发布版本或覆盖 `wavebench.toml`。
+5. 推送、打标签、发布版本和覆盖 `wavebench.toml` 须有对应授权,不能从代码修改或测试通过推定。
## 验证分层
@@ -25,7 +25,11 @@
git diff --check
```
-涉及 run plan 时增加 `run check`;涉及插件时增加包检查、安装 dry-run、插件自身测试和 `plugin doctor --load`;涉及真实仪器时必须增加有边界的验收产物和写后状态回读。
+- 局部行为修改先运行聚焦测试和相关静态检查;跨模块、公共安全合同或合并评估再运行全量测试。
+- 仅修改文档或 Skill 时运行相关文案、链接或 Skill 校验,不自动运行全量 Python 测试;CI 仍执行仓库既有门禁。
+- 修改 plan 示例或校验语义时增加离线 `run check`;仅解释 plan 不自动执行实时步骤。
+- 插件 metadata、打包、安装或发现行为改变时,按受影响合同选择包检查、安装 dry-run 和加载检查;插件生产开发以插件仓 Skill 为主,不能因为涉及插件一词就安装或加载第三方代码。
+- 实际涉及真实仪器写入时,必须保留有边界的验收产物和写后状态回读;fake 测试不需要实机验收。
技能维护增加:
@@ -48,12 +52,12 @@ git diff --check
```bash
python "${CODEX_HOME:-$HOME/.codex}/skills/tech-doc-style-chinese/scripts/lint_copy_rules.py" \
- .agents/skills/wavebench
+ --term-allowlist docs/tech-doc-term-allowlist.json
```
## 交接格式
-先给结论,再列:
+先给结论,以下字段只报告与本次任务相关的项目;实际接触硬件时不得省略最终状态和未恢复项:
- 检查或改动的范围;
- 精确的验证命令和结果;
@@ -67,4 +71,4 @@ python "${CODEX_HOME:-$HOME/.codex}/skills/tech-doc-style-chinese/scripts/lint_c
## 外部资料
-只有用户明确要求最新厂商资料、标准或外部建议时才使用网络搜索。优先官方来源,记录 URL 和访问日期;不发送本地配置、设备序列号、资源地址或实验数据。搜索 MCP 不可用时说明降级路径,不伪造工具结果。
+外部检索的适用条件与隐私边界以 Skill 入口的 `External research` 为准,不在开发流程增加另一套联网门槛。
diff --git a/.agents/skills/wavebench/references/eval-prompts.md b/.agents/skills/wavebench/references/eval-prompts.md
index ddefe77a..4a2d290f 100644
--- a/.agents/skills/wavebench/references/eval-prompts.md
+++ b/.agents/skills/wavebench/references/eval-prompts.md
@@ -30,6 +30,14 @@
## 评估方法
+维护授权与验证规则时增加以下案例:
+
+- 「只解释这份 plan 的问题」:完成离线诊断,不自动改文件、不执行 `run verify` 或 `run plan`。
+- 「检查并修复 run plan 校验问题」:完成已授权修复和相关测试,不停在计划或 findings,不自行发布。
+- 「只修改 Skill 的一句验证说明」:做 Skill 与相关文案检查,不加载插件、不运行全量业务测试。
+- 「修复插件仓某个 driver parser」:以插件仓生产开发 Skill 为主;没有 Core 改动或实时操作时不叠加本 Skill 的完整工作流。
+- 「执行已确认接线和限值的采集任务」:沿用有效授权并核实当前状态;只有关键条件缺失或变化才询问,不把新一轮对话当作授权失效。
+
使用全新上下文逐条提交提示词,观察:
1. 是否仅加载入口和必要 reference;
diff --git a/docs/development/contributing.md b/docs/development/contributing.md
index 2916fa0f..68e53d21 100644
--- a/docs/development/contributing.md
+++ b/docs/development/contributing.md
@@ -8,13 +8,8 @@ WaveBench 的代码、文档、schema 和测试在同一仓库中维护。提交
2. 对硬件相关改动先确认 access policy、capability、恢复边界和测试策略。
3. 不在普通测试或文档验证中连接真实仪器、修改 `wavebench.toml` 或提交实验数据。
-## 提交前
+## 提交与合并前
-```bash
-python -m ruff check .
-python -m pytest -q
-python .agents/skills/wavebench-docs/scripts/audit_docs.py --quiet-warnings
-git diff --check
-```
+按[测试说明](testing.md)选择检查:局部修改先做聚焦验证,文档或 Skill 修改检查相关内容,跨模块或合并评估执行集成检查。远端 CI 仍按仓库 workflow 执行,不要求每个编辑步骤重跑全量测试。
新增或修改用户可见行为时,更新唯一 canonical Reference,并用[文档工作流](documentation.md)进行 scoped review。插件专用流程见[插件开发](plugin-development.md)。
diff --git a/docs/development/testing.md b/docs/development/testing.md
index c027028a..c7770ac4 100644
--- a/docs/development/testing.md
+++ b/docs/development/testing.md
@@ -4,6 +4,12 @@ WaveBench 的默认验证必须离线、可重复且不依赖真实仪器。driv
## 本地检查
+按改动影响选择验证集合:
+
+- 局部行为修改:先运行 `python -m pytest -q tests/.py`、相关 Ruff 检查和 `git diff --check`。
+- 文档或 Skill 修改:检查变更页面、直接导航、文案与 Skill 格式;不自动运行全量 Python 测试。涉及生成来源或文档工具时增加生成漂移与对应工具测试。
+- 跨模块行为、公共安全合同或合并评估:运行下列集成检查;涉及文档站点的变更再做生成 Reference 检查和 `mkdocs build --strict`。
+
```bash
python -m ruff check .
python -m pytest -q
@@ -12,3 +18,5 @@ git diff --check
```
修改 CLI、run schema、配置、artifact、capability、安全语义或插件 API 时,补充对应的聚焦测试,并在文档 review 中核对 canonical Reference。不要用成功的实机记录替代可重复的离线测试。
+
+`.github/workflows/ci.yml` 和 `docs.yml` 定义远端合并检查;本地单个平台通过不能替代 Python/操作系统矩阵。已通过的检查只在新改动、失败或未解决风险需要时重跑,不能把 CI 清单当作每轮编辑的固定流程。
From aa4cb1564d2e292474822cde54e8e2f865f65ff5 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Tue, 8 Sep 2026 09:02:55 +0800
Subject: [PATCH 17/30] feat(analysis): add resource budgets and bounded file
I/O
---
docs/reference/generated/run-schema.md | 4 +-
src/wavebench/cli.py | 23 +-
src/wavebench/cli_parser.py | 7 +
src/wavebench/data/analysis_io.py | 97 ++++++++
src/wavebench/data/analysis_resources.py | 168 ++++++++++++++
src/wavebench/data/signal_pipeline.py | 16 +-
src/wavebench/report/analysis.py | 163 +++++++++++---
src/wavebench/report/html.py | 11 +-
src/wavebench/services/analysis_service.py | 73 ++++--
src/wavebench/services/execution_intent.py | 18 +-
src/wavebench/services/run_pipeline.py | 124 +++++++++--
src/wavebench/services/run_plan.py | 8 +-
src/wavebench/services/run_service.py | 13 +-
tests/test_analysis_resources.py | 248 +++++++++++++++++++++
14 files changed, 880 insertions(+), 93 deletions(-)
create mode 100644 src/wavebench/data/analysis_io.py
create mode 100644 src/wavebench/data/analysis_resources.py
create mode 100644 tests/test_analysis_resources.py
diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md
index c87cb079..6e587e78 100644
--- a/docs/reference/generated/run-schema.md
+++ b/docs/reference/generated/run-schema.md
@@ -17,7 +17,7 @@ Top-level tables:
Supported step kinds:
- analysis.pipeline
required: source, operations
- optional : expect, on_failure
+ optional : expect, on_failure, resources
note : Process one earlier scope.capture NPY after all hardware sessions close. Uses a validated linear operator list, checks optional dependencies on demand, and never opens an instrument.
- dmm.read
required: -
@@ -244,6 +244,8 @@ scope.capture [steps.expect_fft] metrics:
Common metrics: peak_frequency_hz, peak_amplitude_v, thd_ratio, harmonic_2_amplitude_v.
analysis.pipeline metrics:
+ Optional [steps.resources] tightens the execution resource profile; --analysis-resources selects an explicit environment TOML profile.
+ Default resource admission bounds FIR taps, FFT length, working-set estimate, cumulative work/output and file counts before allocation. Actual source length is checked offline after capture.
Time domain: voltage_min_v, voltage_max_v, voltage_mean_v, voltage_rms_v, voltage_vpp_v.
Frequency domain: peak_frequency_hz, peak_amplitude_v, noise_floor_v, thd_ratio, and harmonic_2 through harmonic_5 frequency/amplitude fields.
PSD domain: measure_band requires name, band_hz, exclude_hz and metrics=mean_square_v2|rms_v|noise_rms_v. Metric keys are _.
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index 3bd6483c..b9165807 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -238,7 +238,11 @@ def _load_run_service(args: argparse.Namespace) -> RunService:
config = load_config(args.config)
if args.resource:
config = config.with_resource(args.resource)
- return RunService(config=config, logger=CommandLogger())
+ from .data.analysis_resources import load_resource_limits
+
+ profile = getattr(args, "analysis_resources", None)
+ return RunService(config=config, logger=CommandLogger(),
+ analysis_limits=load_resource_limits(profile) if profile else None)
def _load_sweep_service(args: argparse.Namespace) -> SweepService:
@@ -1077,14 +1081,17 @@ def _main(argv: list[str] | None = None) -> int:
log_path=args.log_file,
)
if args.domain == "analysis":
+ from .data.analysis_resources import load_resource_limits
+
+ limits = load_resource_limits(args.analysis_resources)
if args.command == "report":
from .report.analysis import write_analysis_report
- print(write_analysis_report([Path(path) for path in args.paths], Path(args.output)))
+ print(write_analysis_report([Path(path) for path in args.paths], Path(args.output), resource_limits=limits))
return 0
from .services.analysis_service import check_analysis, run_analysis
- options = dict(capture=Path(args.capture), channel=args.channel, recipe=Path(args.recipe))
+ options = dict(capture=Path(args.capture), channel=args.channel, recipe=Path(args.recipe), resource_limits=limits)
result = (run_analysis(**options, output=Path(args.output))
if args.command == "run" else check_analysis(**options))
print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False))
@@ -1116,7 +1123,7 @@ def _main(argv: list[str] | None = None) -> int:
plan = load_run_plan(args.plan)
service = _load_run_service(args)
service.check(plan)
- intent = build_execution_intent(plan, service.config)
+ intent = build_execution_intent(plan, service.config, resource_limits=getattr(service, "analysis_limits", None))
if args.output:
output = write_execution_intent(intent, args.output)
if not args.json:
@@ -1228,7 +1235,13 @@ def _main(argv: list[str] | None = None) -> int:
raise ConfigError(
"run report --pdf-output is a PDF path and must not use an HTML suffix"
)
- output = write_run_report_html(package, output_path=output)
+ if args.analysis_resources:
+ from .data.analysis_resources import load_resource_limits
+
+ output = write_run_report_html(package, output_path=output,
+ analysis_limits=load_resource_limits(args.analysis_resources))
+ else:
+ output = write_run_report_html(package, output_path=output)
print(f"report={output}")
if args.pdf:
pdf = write_run_report_pdf(package, output_path=pdf_output)
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index 0c8fec6d..f14d5f3b 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -48,11 +48,13 @@ def build_parser() -> argparse.ArgumentParser:
analysis_report = analysis_sub.add_parser("report", help="Plot persisted analysis exports")
analysis_report.add_argument("paths", nargs="+")
analysis_report.add_argument("--output", required=True)
+ analysis_report.add_argument("--analysis-resources", help="Execution resource profile TOML")
for command in ("check", "run"):
analysis_command = analysis_sub.add_parser(command)
analysis_command.add_argument("--capture", required=True)
analysis_command.add_argument("--channel", type=int, required=True)
analysis_command.add_argument("--recipe", required=True)
+ analysis_command.add_argument("--analysis-resources", help="Execution resource profile TOML")
if command == "run":
analysis_command.add_argument("--output", required=True)
mcp_parser = subparsers.add_parser("mcp", help="HTTP MCP server / HTTP MCP 服务")
@@ -388,6 +390,7 @@ def build_parser() -> argparse.ArgumentParser:
)
run_check.add_argument("--plan", required=True, help="Path to a WaveBench run plan TOML file")
add_runtime_options(run_check)
+ run_check.add_argument("--analysis-resources", help="Analysis resource profile TOML")
run_intent = run_sub.add_parser(
"intent",
help="Build an offline execution intent for a run plan / 为运行计划生成离线执行意图",
@@ -395,12 +398,14 @@ def build_parser() -> argparse.ArgumentParser:
run_intent.add_argument("--plan", required=True, help="Path to a WaveBench run plan TOML file")
run_intent.add_argument("--output", default=None, help="Write the execution intent JSON to this path")
add_runtime_options(run_intent)
+ run_intent.add_argument("--analysis-resources", help="Analysis resource profile TOML")
run_verify = run_sub.add_parser(
"verify",
help="Verify / 预检 instruments referenced by a run plan with read-only *IDN? queries",
)
run_verify.add_argument("--plan", required=True, help="Path to a WaveBench run plan TOML file")
add_runtime_options(run_verify)
+ run_verify.add_argument("--analysis-resources", help="Analysis resource profile TOML")
run_sub.add_parser("schema", help="Print supported run plan step kinds and fields")
run_template = run_sub.add_parser("template", help="Create or print conservative run plan templates")
run_template.add_argument("template", nargs="?", help="Template name, e.g. source-scope-sine")
@@ -423,6 +428,7 @@ def build_parser() -> argparse.ArgumentParser:
run_template.add_argument("--voltage", type=float, default=3.3, help="Template power voltage in V")
run_template.add_argument("--current-limit", type=float, default=0.1, help="Template power current limit in A")
run_plan = run_sub.add_parser("plan", help="Execute a WaveBench run plan")
+ run_plan.add_argument("--analysis-resources", help="Analysis resource profile TOML")
run_plan.add_argument("--plan", required=True, help="Path to a WaveBench run plan TOML file")
run_plan.add_argument(
"--intent",
@@ -491,6 +497,7 @@ def build_parser() -> argparse.ArgumentParser:
run_resume.add_argument("--response", default=None, help="Frequency-response label for a multi-response run")
run_resume.add_argument("--output", default=None, help="Write the resume manifest JSON to this path")
run_report = run_sub.add_parser("report", help="Generate an offline HTML report for a run package")
+ run_report.add_argument("--analysis-resources", help="Resource profile for signal processing curves")
run_report.add_argument("path", help="Path to data/runs/")
run_report.add_argument("--output", default=None, help="Output HTML path; defaults to /report.html")
run_report.add_argument(
diff --git a/src/wavebench/data/analysis_io.py b/src/wavebench/data/analysis_io.py
new file mode 100644
index 00000000..a8ee8b7a
--- /dev/null
+++ b/src/wavebench/data/analysis_io.py
@@ -0,0 +1,97 @@
+"""Bounded reads for pipeline sources and persisted analysis exports."""
+from __future__ import annotations
+
+import ast
+from contextlib import contextmanager
+from hashlib import sha256
+import json
+import os
+from pathlib import Path
+import struct
+
+import numpy as np
+
+from wavebench.data.analysis_resources import AnalysisBudget, AnalysisLimits
+from wavebench.errors import DataError
+
+
+BLOCK_ROWS = 4096
+
+
+def file_identity(stat):
+ return stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns
+
+
+def read_json_bounded(path: Path, limits: AnalysisLimits):
+ with path.open("rb") as file:
+ limits.check("max_metadata_bytes", os.fstat(file.fileno()).st_size, "metadata")
+ limits.check("max_working_bytes", os.fstat(file.fileno()).st_size * 32 + 65536, "metadata parsing")
+ raw = file.read(limits.max_metadata_bytes + 1)
+ limits.check("max_metadata_bytes", len(raw), "metadata")
+ return json.loads(raw)
+
+
+@contextmanager
+def mapped_npy(path: Path, limits: AnalysisLimits, *, columns: int, source: bool = False):
+ """Validate a bounded header and file size before mapping, then pin the opened inode."""
+ array = None
+ with path.open("rb") as file:
+ before = file_identity(os.fstat(file.fileno()))
+ prefix = file.read(8)
+ if len(prefix) != 8 or prefix[:6] != b"\x93NUMPY" or prefix[6:] not in (b"\x01\x00", b"\x02\x00", b"\x03\x00"):
+ raise DataError("invalid or unsupported NPY header")
+ version = prefix[6]
+ length_bytes = file.read(2 if version == 1 else 4)
+ if len(length_bytes) != (2 if version == 1 else 4):
+ raise DataError("truncated NPY header")
+ length = struct.unpack(" 10000:
+ raise DataError("NPY header exceeds 10000 bytes")
+ raw = file.read(length)
+ if len(raw) != length:
+ raise DataError("truncated NPY header")
+ try:
+ header = ast.literal_eval(raw.decode("utf-8" if version == 3 else "latin1"))
+ if not isinstance(header, dict) or set(header) != {"descr", "fortran_order", "shape"}:
+ raise ValueError("invalid keys")
+ shape, dtype, order = header["shape"], np.dtype(header["descr"]), header["fortran_order"]
+ if (not isinstance(shape, tuple) or len(shape) != 2 or shape[1] != columns
+ or any(type(n) is not int or n < 1 for n in shape) or type(order) is not bool
+ or dtype.kind not in "iuf" or dtype.hasobject):
+ raise ValueError("expected finite real numeric matrix")
+ except (SyntaxError, ValueError, TypeError, KeyError, RecursionError) as exc:
+ raise DataError(f"invalid NPY metadata: {exc}") from exc
+ limits.check("max_input_samples", shape[0], "source" if source else "report")
+ if source:
+ AnalysisBudget(limits).source(shape[0], dtype.itemsize)
+ else:
+ limits.check("max_output_bytes", before[2], "report input")
+ offset = file.tell()
+ if offset + shape[0] * shape[1] * dtype.itemsize != before[2]:
+ raise DataError("NPY payload length does not match its header")
+ try:
+ array = np.memmap(file, dtype=dtype, mode="r", offset=offset, shape=shape,
+ order="F" if order else "C")
+ yield array, file
+ if before != file_identity(os.fstat(file.fileno())) or before != file_identity(path.stat()):
+ raise DataError("analysis source changed while being read")
+ finally:
+ if array is not None:
+ array._mmap.close()
+
+
+def hash_stream(file) -> str:
+ file.seek(0)
+ digest = sha256()
+ while chunk := file.read(1024 * 1024):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def load_waveform(path: Path, limits: AnalysisLimits):
+ from wavebench.data.signal_pipeline import validate_waveform
+
+ with mapped_npy(path, limits, columns=2, source=True) as (mapped, file):
+ waveform = validate_waveform(mapped)
+ digest = hash_stream(file)
+ return waveform, digest
diff --git a/src/wavebench/data/analysis_resources.py b/src/wavebench/data/analysis_resources.py
new file mode 100644
index 00000000..15b2e582
--- /dev/null
+++ b/src/wavebench/data/analysis_resources.py
@@ -0,0 +1,168 @@
+"""Finite analysis budgets. Estimates are admission checks, not OS memory limits."""
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, replace
+from pathlib import Path
+import tomllib
+from typing import Any
+
+from wavebench.errors import ConfigError, DataError
+
+
+class AnalysisResourceError(DataError):
+ code = "resource_limit_exceeded"
+
+ def __init__(self, dimension: str, limit: int, requested: int, stage: str):
+ super().__init__(f"analysis {stage}: {dimension} requires {requested}, limit is {limit}; "
+ "reduce the workload or explicitly select a larger resource profile")
+ self.evidence = dict(dimension=dimension, limit=limit, requested=requested, stage=stage)
+
+ def to_envelope(self, *, operation=None, details=None, cause=None):
+ return super().to_envelope(operation=operation, details={**(details or {}), **self.evidence}, cause=cause)
+
+
+@dataclass(frozen=True)
+class AnalysisLimits:
+ max_working_bytes: int = 512 * 1024**2
+ max_input_samples: int = 20_000_000
+ max_fft_length: int = 1_048_576
+ max_fir_taps: int = 4095
+ max_zero_phase_fir_taps: int = 255
+ max_work_units: int = 2_000_000_000
+ max_output_bytes: int = 1024**3
+ max_temp_bytes: int = 1024**3
+ max_peak_candidates: int = 100_000
+ max_report_curves: int = 32
+ max_operations: int = 128
+ max_output_files: int = 256
+ max_metadata_bytes: int = 8 * 1024**2
+
+ def __post_init__(self):
+ for key, value in asdict(self).items():
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 2**63 - 1:
+ raise ConfigError(f"analysis resource {key} must be an integer in [1, 2^63-1]")
+
+ def check(self, dimension: str, requested: int, stage: str) -> None:
+ limit = getattr(self, dimension)
+ if requested > limit:
+ raise AnalysisResourceError(dimension, limit, requested, stage)
+
+ def tighten(self, values: dict | None) -> AnalysisLimits:
+ values = normalize_limits({} if values is None else values)
+ for key, value in values.items():
+ if value > getattr(self, key):
+ raise ConfigError(f"task resources.{key} cannot exceed the execution profile")
+ return replace(self, **values)
+
+ def evidence(self) -> dict:
+ return {"schema": "wavebench.analysis_resources.v1", "estimator": "conservative.v1",
+ "limits": asdict(self), "memory_limit_kind": "estimated_working_set"}
+
+
+def normalize_limits(values: Any) -> dict[str, int]:
+ if not isinstance(values, dict) or set(values) - set(AnalysisLimits.__dataclass_fields__):
+ raise ConfigError("analysis resources must be a table of known resource limits")
+ replace(AnalysisLimits(), **values)
+ return dict(values)
+
+
+def load_resource_limits(path: str | Path | None = None) -> AnalysisLimits:
+ if path is None:
+ return AnalysisLimits()
+ try:
+ file = Path(path)
+ if file.stat().st_size > 65536:
+ raise ConfigError("analysis resource profile exceeds 65536 bytes")
+ values = tomllib.loads(file.read_text(encoding="utf-8-sig"))
+ except (OSError, ValueError) as exc:
+ raise ConfigError(f"cannot read analysis resource profile: {exc}") from exc
+ if values.pop("schema", None) != "wavebench.analysis_resources.v1":
+ raise ConfigError("resource profile schema must be wavebench.analysis_resources.v1")
+ return replace(AnalysisLimits(), **normalize_limits(values))
+
+
+def check_static(operations: list[dict], limits: AnalysisLimits, *, metadata_files: int = 2) -> None:
+ limits.check("max_operations", len(operations), "plan")
+ files = metadata_files
+ for index, op in enumerate(operations):
+ stage = f"operations[{index}]"
+ if op["op"] == "filter" and op["family"] == "fir":
+ limits.check("max_fir_taps", op["numtaps"], stage)
+ if op["mode"] == "zero_phase":
+ limits.check("max_zero_phase_fir_taps", op["numtaps"], stage)
+ if op["op"] == "psd":
+ limits.check("max_fft_length", op["nfft"], stage)
+ if op["op"] == "export":
+ files += len(op["formats"])
+ if op["op"] == "peaks":
+ files += 2
+ limits.check("max_output_files", files, "plan")
+
+
+@dataclass
+class AnalysisBudget:
+ limits: AnalysisLimits
+ work_units: int = 0
+ output_bytes: int = 0
+ output_files: int = 0
+
+ def source(self, count: int, itemsize: int = 8) -> None:
+ self.limits.check("max_input_samples", count, "source")
+ self.limits.check("max_working_bytes", count * (2 * itemsize + 32) + 65536, "source")
+
+ def stage(self, operation: dict, count: int, *, domain: str = "time", retained_bytes: int = 0) -> dict:
+ op = operation["op"]
+ # Input, output, masks, array expressions and backend workspace coexist.
+ memory, work = retained_bytes + 128 * count + 65536, count
+ if op == "fft":
+ self.limits.check("max_fft_length", count, op)
+ work = count * max(1, count.bit_length()) * 8
+ elif op == "psd":
+ nfft, segment = operation["nfft"], operation["nperseg"]
+ self.limits.check("max_fft_length", nfft, op)
+ k = max(0, 1 + (count - segment) // (segment - operation["noverlap"]))
+ bins = nfft // 2 + 1
+ memory += k * (32 * segment + 64 * bins) + 64 * nfft
+ work = k * nfft * max(1, nfft.bit_length()) * 8
+ elif op == "filter":
+ taps = operation.get("numtaps", 2 * operation.get("order", 1) + 1)
+ passes = 2 if operation["mode"] == "zero_phase" else 1
+ work = count * taps * passes
+ if operation["family"] == "fir" and passes == 2:
+ # Conservative across supported SciPy versions, including dense initial-state solves.
+ memory += 32 * taps * taps + 128 * 3 * taps
+ work += taps**3
+ elif op == "smooth":
+ work = count * operation["window_length"]
+ elif op == "resample":
+ out = (count * operation["up"] + operation["down"] - 1) // operation["down"]
+ self.limits.check("max_input_samples", out, op)
+ taps = 20 * max(operation["up"], operation["down"]) + 1
+ memory += 64 * out + 32 * taps
+ work = out * (taps // operation["up"] + 1)
+ elif op == "peaks":
+ # Worst case admission before find_peaks allocates any candidates or properties.
+ candidates = (count // 2) * (2 if operation["polarity"] == "both" else 1)
+ self.limits.check("max_peak_candidates", candidates, op)
+ memory += 1024 * candidates
+ work = count * max(1, count.bit_length())
+ elif op == "export":
+ cols = 4 if domain == "frequency" else 2
+ expected = sum(count * cols * (8 if fmt == "npy" else 32) + 1024
+ for fmt in operation["formats"])
+ self.limits.check("max_output_bytes", self.output_bytes + expected, op)
+ self.limits.check("max_temp_bytes", max(count * cols * (8 if f == "npy" else 32) + 1024
+ for f in operation["formats"]), op)
+ self.limits.check("max_working_bytes", memory, op)
+ self.limits.check("max_work_units", self.work_units + work, op)
+ self.work_units += work
+ return {"estimated_working_bytes": memory, "work_units": work}
+
+ def pending_file(self, size: int) -> None:
+ self.limits.check("max_temp_bytes", size, "write")
+ self.limits.check("max_output_bytes", self.output_bytes + size, "write")
+ self.limits.check("max_output_files", self.output_files + 1, "write")
+
+ def committed_file(self, size: int) -> None:
+ self.output_bytes += size
+ self.output_files += 1
diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py
index 55a0a1f0..289b6fd2 100644
--- a/src/wavebench/data/signal_pipeline.py
+++ b/src/wavebench/data/signal_pipeline.py
@@ -214,12 +214,16 @@ def validate_waveform(data: Any) -> TimeSignal:
array.dtype, np.complexfloating
):
raise DataError("analysis pipeline input must contain real numeric values")
- array = np.array(array, dtype=np.float64, copy=True)
- if not np.all(np.isfinite(array)):
- raise DataError("analysis pipeline input must contain only finite values")
- if array.shape[0] > 1 and not np.all(np.diff(array[:, 0]) > 0):
- raise DataError("analysis pipeline time axis must be strictly increasing")
- return TimeSignal(time_s=array[:, 0], voltage_v=array[:, 1])
+ result = np.empty(array.shape, dtype=np.float64)
+ for start in range(0, len(array), 4096):
+ block = np.asarray(array[start:start + 4096], dtype=np.float64)
+ if not np.all(np.isfinite(block)):
+ raise DataError("analysis pipeline input must contain only finite values")
+ if (not np.all(np.diff(block[:, 0]) > 0)
+ or start and block[0, 0] <= result[start - 1, 0]):
+ raise DataError("analysis pipeline time axis must be strictly increasing")
+ result[start:start + len(block)] = block
+ return TimeSignal(time_s=result[:, 0], voltage_v=result[:, 1])
def remove_dc(signal: TimeSignal) -> TimeSignal:
diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py
index 0df70b81..0a266698 100644
--- a/src/wavebench/report/analysis.py
+++ b/src/wavebench/report/analysis.py
@@ -4,12 +4,17 @@
from html import escape
from hashlib import sha256
import json
+import csv
+import os
from pathlib import Path
from typing import Any
import numpy as np
from wavebench.errors import ConfigError
+from wavebench.errors import DataError
+from wavebench.data.analysis_resources import AnalysisLimits, AnalysisBudget
+from wavebench.data.analysis_io import mapped_npy, read_json_bounded, hash_stream, file_identity, BLOCK_ROWS
from wavebench.services.run_pipeline import _sha256_file, _atomic_write_bytes
@@ -29,14 +34,15 @@ def artifact_file(root: Path, raw: str) -> Path:
return path
-def analysis_entries(root: Path) -> list[tuple[Path, str, dict[str, Any]]]:
+def analysis_entries(root: Path, resource_limits: AnalysisLimits | None = None) -> list[tuple[Path, str, dict[str, Any]]]:
+ limits = resource_limits or AnalysisLimits()
try:
if (root / "analysis.json").is_file():
- result = json.loads((root / "analysis.json").read_text())
+ result = read_json_bounded(root / "analysis.json", limits)
if result["schema"] != "wavebench.analysis.v1":
raise ValueError("unsupported analysis schema")
return [(root, root.name, result["artifact"])]
- run = json.loads((root / "run.json").read_text())
+ run = read_json_bounded(root / "run.json", limits)
return [(root, f"{root.name}/{step.get('id', step['index'])}", step["artifact"])
for step in run["steps"] if step["kind"] == "analysis.pipeline"]
except (OSError, ValueError, KeyError, TypeError) as exc:
@@ -55,6 +61,96 @@ def display_samples(data: np.ndarray, maximum: int = 1200) -> np.ndarray:
return data[sorted(indices)]
+def read_curve(path: Path, item: dict, column: int, limits: AnalysisLimits):
+ """Two bounded passes preserve exact display buckets, complete validation and fingerprint."""
+ columns = item["columns"]
+ def consume(blocks, count):
+ edges = np.linspace(0, count, 600, dtype=int) if count > 1200 else np.arange(count + 1)
+ buckets = {}
+ digest = sha256()
+ previous, offset = None, 0
+ first = last = None
+ for block in blocks:
+ if block.ndim != 2 or block.shape[1] != len(columns) or not np.all(np.isfinite(block)):
+ raise ValueError("invalid export values or shape")
+ if not np.all(np.diff(block[:, 0]) > 0) or previous is not None and block[0, 0] <= previous:
+ raise ValueError("invalid export axis")
+ previous = block[-1, 0]
+ curve = np.asarray(block[:, [0, column]], dtype=" pair[1][1][1]:
+ pair[1] = maximum
+ offset += len(block)
+ if offset != count or count < 2:
+ raise ValueError("invalid export row count")
+ points = {index: point for pair in buckets.values() for index, point in pair}
+ points[0], points[count-1] = first, last
+ return np.asarray([points[i] for i in sorted(points)]), digest.hexdigest()
+
+ limits.check("max_working_bytes", BLOCK_ROWS * len(columns) * 128 + 1200 * 128, "report")
+ if item["format"] == "npy":
+ limits.check("max_output_bytes", path.stat().st_size, "report input")
+ if _sha256_file(path) != item["sha256"]:
+ raise ValueError("export SHA-256 mismatch")
+ with mapped_npy(path, limits, columns=len(columns)) as (data, file):
+ if hash_stream(file) != item["sha256"]:
+ raise ValueError("export SHA-256 mismatch")
+ return consume((data[i:i+BLOCK_ROWS] for i in range(0, len(data), BLOCK_ROWS)), len(data))
+ with path.open("rb") as file:
+ before = file_identity(os.fstat(file.fileno()))
+ limits.check("max_output_bytes", before[2], "report input")
+ if hash_stream(file) != item["sha256"]:
+ raise ValueError("export SHA-256 mismatch")
+ def rows():
+ file.seek(0)
+ def lines():
+ while line := file.readline(4097):
+ if len(line) > 4096:
+ raise ValueError("CSV row exceeds 4096 bytes")
+ yield line.decode("utf-8")
+ reader = csv.reader(lines())
+ if next(reader, None) != columns:
+ raise ValueError("CSV columns do not match manifest")
+ yield from reader
+ count = 0
+ for row in rows():
+ count += 1
+ limits.check("max_input_samples", count, "report")
+ if len(row) != len(columns):
+ raise ValueError("invalid CSV columns")
+ def blocks():
+ block = []
+ for row in rows():
+ block.append([float(value) for value in row])
+ if len(block) == BLOCK_ROWS:
+ yield np.asarray(block)
+ block = []
+ if block:
+ yield np.asarray(block)
+ result = consume(blocks(), count)
+ if before != file_identity(os.fstat(file.fileno())) or before != file_identity(path.stat()):
+ raise ValueError("export changed during report generation")
+ return result
+
+
def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str,
markers: dict[str, list[dict]] | None = None) -> str:
width, height, pad = 900, 300, 55
@@ -86,14 +182,21 @@ def curves_svg(curves: list[tuple[str, np.ndarray]], x_label: str, units: str,
return "".join(parts)
-def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *, details: bool = True) -> str:
+def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *, details: bool = True,
+ resource_limits: AnalysisLimits | None = None) -> str:
+ limits = resource_limits or AnalysisLimits()
+ curve_count = 0
+ read_bytes = 0
+ peak_rows = 0
groups: dict[tuple, list[tuple[str, np.ndarray]]] = {}
sections: list[str] = []
markers: dict[str, list[dict]] = {}
for root, label, artifact in entries:
try:
pipeline = artifact["analysis_pipeline"]
- manifest = json.loads(artifact_file(root, pipeline["manifest"]).read_text())
+ manifest = read_json_bounded(artifact_file(root, pipeline["manifest"]), limits)
+ limits.check("max_operations", len(manifest.get("operations", [])), "report")
+ limits.check("max_output_files", len(manifest.get("exports", [])), "report")
if details:
sections.append(f"{escape(label)} {escape(json.dumps(artifact, indent=2, ensure_ascii=False))} ")
sections.append(f'{escape(label)}: sampling={escape(json.dumps(manifest.get("sampling")))}
')
@@ -104,13 +207,16 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *,
peak_file = artifact_file(root, peak["json"])
if _sha256_file(peak_file) != peak["json_sha256"]:
raise ValueError("peak table SHA-256 mismatch")
- detected = json.loads(peak_file.read_text())
+ detected = read_json_bounded(peak_file, limits)
rows = detected["peaks"]
+ peak_rows += len(rows)
+ limits.check("max_peak_candidates", peak_rows, "report peaks")
+ limits.check("max_working_bytes", peak_rows * 1024, "report peak markers")
if not isinstance(rows, list) or any(not isinstance(row, dict) or not all(isinstance(row.get(key), (int, float)) and np.isfinite(row[key]) for key in ("position", "value")) for row in rows):
raise ValueError("invalid peak table")
peak_sets.setdefault(detected["signal_sha256"], []).extend(rows)
sections.append(f'{escape(label)}: {escape(peak["name"])} peaks={escape(str(peak["count"]))}, retained={escape(str(peak["retained_count"]))}
')
- except (OSError, ValueError, TypeError, KeyError) as exc:
+ except (OSError, ValueError, TypeError, KeyError, DataError) as exc:
sections.append(f'Peak table unavailable: {escape(str(exc))}
')
seen: set[tuple] = set()
for item in sorted(manifest["exports"], key=lambda x: x.get("format") != "npy"):
@@ -121,24 +227,21 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *,
if identity in seen:
continue
path = artifact_file(root, item["path"])
- if _sha256_file(path) != item["sha256"]:
- raise ValueError("export SHA-256 mismatch")
- data = (np.load(path, allow_pickle=False, mmap_mode="r") if item["format"] == "npy"
- else np.loadtxt(path, delimiter=",", skiprows=1, ndmin=2))
- if data.ndim != 2 or data.shape[1] != len(columns) or len(data) < 2:
- raise ValueError("invalid export shape")
- if np.iscomplexobj(data) or not np.all(np.isfinite(data)) or not np.all(np.diff(data[:, 0]) > 0):
- raise ValueError("invalid export values or axis")
+ read_bytes += path.stat().st_size
+ limits.check("max_output_bytes", read_bytes, "report input total")
+ limits.check("max_report_curves", curve_count + 1, "report")
+ limits.check("max_working_bytes", peak_rows * 1024 + (curve_count+1) * 1200 * 128 + BLOCK_ROWS * len(columns) * 128,
+ "report retained curves")
+ curve, fingerprint = read_curve(path, item, column, limits)
+ curve_count += 1
seen.add(identity)
key = (source.get("npy_sha256") or label, source.get("channel"), columns)
curve_label = f"{label}: {item['name']}"
- curve = data[:, [0, column]]
groups.setdefault(key, []).append((curve_label, curve))
- fingerprint = sha256(np.asarray(curve, dtype="{escape(label)}: curve unavailable: {escape(str(exc))}')
- except (OSError, ValueError, TypeError, KeyError) as exc:
+ except (OSError, ValueError, TypeError, KeyError, DataError) as exc:
sections.append(f'{escape(label)}: analysis unavailable: {escape(str(exc))}
')
for (_, _, columns), curves in groups.items():
_, x_label, units = COLUMNS[columns]
@@ -148,8 +251,18 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *,
return "".join(sections)
-def write_analysis_report(paths: list[Path], output: Path) -> Path:
- entries = [entry for root in paths for entry in analysis_entries(root.resolve())]
+def write_analysis_report(paths: list[Path], output: Path, *, resource_limits: AnalysisLimits | None = None) -> Path:
+ limits = resource_limits or AnalysisLimits()
+ limits.check("max_output_files", len(paths), "report inputs")
+ metadata_bytes = 0
+ for root in paths:
+ document = root / ("analysis.json" if (root / "analysis.json").is_file() else "run.json")
+ try:
+ metadata_bytes += document.stat().st_size
+ except OSError as exc:
+ raise ConfigError(f"cannot read analysis result: {document}") from exc
+ limits.check("max_working_bytes", metadata_bytes * 32 + 65536, "report result metadata")
+ entries = [entry for root in paths for entry in analysis_entries(root.resolve(), limits)]
output = output.resolve()
if output.exists():
raise ConfigError("analysis report output must be a new file")
@@ -157,16 +270,16 @@ def write_analysis_report(paths: list[Path], output: Path) -> Path:
raise ConfigError("analysis report must not modify a source capture package")
for root, _, artifact in entries:
try:
- manifest = json.loads(artifact_file(root, artifact["analysis_pipeline"]["manifest"]).read_text())
+ manifest = read_json_bounded(artifact_file(root, artifact["analysis_pipeline"]["manifest"]), limits)
package = Path(manifest["source"]["package"])
package = package if package.is_absolute() else root / package
if output.is_relative_to(package.resolve()):
raise ConfigError("analysis report must not modify a source capture package")
- except (OSError, ValueError, KeyError, TypeError):
+ except (OSError, ValueError, KeyError, TypeError, DataError):
pass # Broken manifests are displayed as per-entry errors below.
html = (' '
'Signal processing '
- '信号处理 / Signal processing ' + render_analysis_sections(entries) + '')
+ '信号处理 / Signal processing ' + render_analysis_sections(entries, resource_limits=limits) + '