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

+
+ + +{chr(10).join(rows)} + +
步骤 / Step状态 / Status来源 / Source算子 / Operations指标 / Metrics警告 / Warnings失败阶段 / Failed stage产物 / Artifacts
+""" + + +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

@@ -2042,6 +2048,7 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str: {chr(10).join(rows)}
步骤 / Step状态 / Status来源 / Source算子 / Operations指标 / Metrics警告 / Warnings失败阶段 / Failed stage产物 / Artifacts
+{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) + '') output.parent.mkdir(parents=True, exist_ok=True) - _atomic_write_bytes(output, html.encode("utf-8")) + _atomic_write_bytes(output, html.encode("utf-8"), budget=AnalysisBudget(limits)) return output diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py index abe07c0e..a29720f9 100644 --- a/src/wavebench/report/html.py +++ b/src/wavebench/report/html.py @@ -22,7 +22,7 @@ from wavebench.report.path_utils import artifact_url -def write_run_report_html(run: RunPackage, output_path: str | Path | None = None) -> Path: +def write_run_report_html(run: RunPackage, output_path: str | Path | None = None, *, analysis_limits=None) -> Path: path = Path(output_path) if output_path is not None else run.path / "report.html" path.parent.mkdir(parents=True, exist_ok=True) has_surface = any( @@ -32,7 +32,7 @@ def write_run_report_html(run: RunPackage, output_path: str | Path | None = None plotly_asset = write_plotly_asset(path.parent) if has_surface else None plotly_url = artifact_url(plotly_asset, path.parent) if plotly_asset is not None else None path.write_text( - render_run_report_html(run, output_dir=path.parent, plotly_url=plotly_url), encoding="utf-8" + render_run_report_html(run, output_dir=path.parent, plotly_url=plotly_url, analysis_limits=analysis_limits), encoding="utf-8" ) write_run_report_manifest( run, output_dir=path.parent, report_path=path, interactive_asset_path=plotly_asset @@ -202,6 +202,7 @@ def render_run_report_html( *, compact: bool = False, plotly_url: str | None = None, + analysis_limits=None, ) -> str: experiment = run.run.get("experiment", {}) if isinstance(run.run.get("experiment"), dict) else {} restore = run.run.get("restore", {}) if isinstance(run.run.get("restore"), dict) else {} @@ -233,7 +234,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) + signal_processing_block = "" if compact else _signal_processing_block(run, report_output_dir, analysis_limits=analysis_limits) 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) @@ -1971,7 +1972,7 @@ def _build_evidence_summary( ) -def _signal_processing_block(run: RunPackage, output_dir: Path) -> str: +def _signal_processing_block(run: RunPackage, output_dir: Path, *, analysis_limits=None) -> str: rows: list[str] = [] for step in run.steps: if step.get("kind") != "analysis.pipeline": @@ -2040,7 +2041,7 @@ def _signal_processing_block(run: RunPackage, output_dir: Path) -> str: 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) + ], details=False, resource_limits=analysis_limits) return f"""

信号处理 / Signal processing

diff --git a/src/wavebench/services/analysis_service.py b/src/wavebench/services/analysis_service.py index db4d701d..c3663da5 100644 --- a/src/wavebench/services/analysis_service.py +++ b/src/wavebench/services/analysis_service.py @@ -7,14 +7,14 @@ 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.data.packages import CapturePackage, _capture_channels +from wavebench.data.analysis_resources import AnalysisLimits, AnalysisBudget, AnalysisResourceError, check_static +from wavebench.data.analysis_io import load_waveform, read_json_bounded +from wavebench.errors import ConfigError, DataError, error_envelope from wavebench.services.run_pipeline import ( - _atomic_write_json, _resolve_package_member, _sha256_file, + _atomic_write_json, _resolve_package_member, ensure_operation_dependencies, execute_pipeline, ) from wavebench.services.run_plan import normalize_analysis_operations @@ -24,29 +24,37 @@ RESULT_SCHEMA = "wavebench.analysis.v1" -def load_analysis_recipe(path: str | Path) -> dict[str, Any]: +def load_analysis_recipe(path: str | Path, resource_limits: AnalysisLimits | None = None) -> dict[str, Any]: + environment = resource_limits or AnalysisLimits() try: + environment.check("max_metadata_bytes", Path(path).stat().st_size, "recipe") + environment.check("max_working_bytes", Path(path).stat().st_size * 32 + 65536, "recipe parsing") 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"}: + if set(fields) - {"schema", "operations", "expect", "resources"}: raise ConfigError("analysis recipe has unknown fields") if "operations" not in fields: raise ConfigError("analysis recipe requires operations") normalize_analysis_operations("recipe", fields) + check_static(fields["operations"], environment.tighten(fields.get("resources")), metadata_files=3) ensure_operation_dependencies(fields["operations"]) return fields -def load_analysis_source(capture: Path, channel: int) -> tuple[dict[str, Any], np.ndarray]: +def load_analysis_source(capture: Path, channel: int, resource_limits: AnalysisLimits | None = None): + limits = resource_limits or AnalysisLimits() 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) + metadata = read_json_bounded(package_path / "metadata.json", limits) + if not isinstance(metadata, dict): + raise ValueError("capture metadata must be an object") + package = CapturePackage(package_path, package_path / "metadata.json", metadata, _capture_channels(metadata)) 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] @@ -57,26 +65,39 @@ def load_analysis_source(capture: Path, channel: int) -> tuple[dict[str, Any], n 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) + waveform, source_hash = load_waveform(path, limits) 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), + "npy": path.relative_to(package_path).as_posix(), "npy_sha256": source_hash, "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) +def check_analysis(capture: Path, channel: int, recipe: Path, *, resource_limits: AnalysisLimits | None = None) -> dict[str, Any]: + fields = load_analysis_recipe(recipe, resource_limits) + limits = (resource_limits or AnalysisLimits()).tighten(fields.get("resources")) + source, data = load_analysis_source(capture, channel, limits) + budget = AnalysisBudget(limits) + count, domain = len(data.time_s), "time" + for operation in fields["operations"]: + budget.stage(operation, count, domain=domain) + if operation["op"] == "resample": + count = (count * operation["up"] + operation["down"] - 1) // operation["down"] + elif operation["op"] in {"fft", "psd"}: + count = (count if operation["op"] == "fft" else operation["nfft"]) // 2 + 1 + domain = "frequency" if operation["op"] == "fft" else "psd" + elif operation["op"] == "export": + cols = 4 if domain == "frequency" else 2 + budget.output_bytes += sum(count * cols * (8 if fmt == "npy" else 32) + 1024 for fmt in operation["formats"]) return {"schema": "wavebench.analysis_check.v1", "status": "ok", "source": source, - "samples": len(data), "recipe": fields} + "samples": len(data.time_s), "recipe": fields, "resources": limits.evidence()} -def run_analysis(capture: Path, channel: int, recipe: Path, output: Path) -> dict[str, Any]: - fields = load_analysis_recipe(recipe) +def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, resource_limits: AnalysisLimits | None = None) -> dict[str, Any]: + fields = load_analysis_recipe(recipe, resource_limits) + limits = (resource_limits or AnalysisLimits()).tighten(fields.get("resources")) capture = capture.resolve() output = output.resolve() if output.exists(): @@ -88,7 +109,7 @@ def run_analysis(capture: Path, channel: int, recipe: Path, output: Path) -> dic 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), + load_source=lambda: load_analysis_source(capture, channel, limits), resource_limits=limits, schema="wavebench.offline_pipeline.v1", ) failed = artifact["analysis_pipeline"]["status"] == "failed" @@ -100,5 +121,17 @@ def run_analysis(capture: Path, channel: int, recipe: Path, output: Path) -> dic "recipe_sha256": sha256(json.dumps(fields, sort_keys=True, allow_nan=False).encode()).hexdigest(), "artifact": artifact, } - _atomic_write_json(output / "analysis.json", result) + budget = AnalysisBudget(limits) + pipeline = artifact["analysis_pipeline"] + evidence = pipeline["resources"] + budget.output_bytes = evidence["data_output_bytes"] + sum( + (output / name).stat().st_size for name in (pipeline["manifest"], pipeline["metrics"]) + ) + budget.output_files = evidence["data_output_files"] + 2 + try: + _atomic_write_json(output / "analysis.json", result, budget=budget) + except AnalysisResourceError as exc: + result["status"] = "failed" + result["error"] = error_envelope(exc, operation="analysis.result_metadata") + _atomic_write_json(output / "analysis.json", result) return result diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 3aeeaf84..19731994 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -42,13 +42,15 @@ class ExecutionIntent: safety: Mapping[str, Any] restore: Mapping[str, Any] intent_digest: str + analysis_resources: Mapping[str, Any] | None = None @property def schema(self) -> str: - return INTENT_SCHEMA + return "wavebench.execution_intent.v2" if self.analysis_resources is not None else INTENT_SCHEMA def as_dict(self) -> dict[str, Any]: return { + **({"analysis_resources": dict(self.analysis_resources)} if self.analysis_resources is not None else {}), "schema": self.schema, "intent_digest": self.intent_digest, "plan_digest": self.plan_digest, @@ -60,7 +62,7 @@ def as_dict(self) -> dict[str, Any]: } -def build_execution_intent(plan: RunPlan, config: WaveBenchConfig) -> ExecutionIntent: +def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_limits=None) -> ExecutionIntent: plan_hash = plan_digest(plan) config_hash = digest(_config_semantics(config)) payloads: list[dict[str, Any]] = [] @@ -113,6 +115,9 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig) -> ExecutionI "safety": safety, "restore": restore, } + resources = resource_limits.evidence() if resource_limits is not None else None + if resources is not None: + body["analysis_resources"] = resources return ExecutionIntent( plan_digest=plan_hash, config_digest=config_hash, @@ -121,6 +126,7 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig) -> ExecutionI safety=safety, restore=restore, intent_digest=digest(body, length=32), + analysis_resources=resources, ) @@ -140,7 +146,7 @@ def load_execution_intent(path: str | Path) -> dict[str, Any]: payload = json.loads(intent_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ExecutionIntentError(f"failed to read execution intent: {intent_path}") from exc - if not isinstance(payload, dict) or payload.get("schema") != INTENT_SCHEMA: + if not isinstance(payload, dict) or payload.get("schema") not in {INTENT_SCHEMA, "wavebench.execution_intent.v2"}: raise ExecutionIntentError( f"execution intent must use schema {INTENT_SCHEMA}: {intent_path}" ) @@ -151,10 +157,12 @@ def verify_execution_intent( expected: Mapping[str, Any], plan: RunPlan, config: WaveBenchConfig, + *, resource_limits=None, ) -> ExecutionIntent: - current = build_execution_intent(plan, config) + current = build_execution_intent(plan, config, resource_limits=resource_limits) expected_digest = expected.get("intent_digest") - if expected_digest != current.intent_digest: + if (expected_digest != current.intent_digest or expected.get("schema") != current.schema + or expected.get("analysis_resources") != current.analysis_resources): raise ExecutionIntentError( "execution intent does not match the current plan, configuration, or payloads", expected_digest=str(expected_digest) if expected_digest is not None else None, diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py index 9e818fa9..dab3d9eb 100644 --- a/src/wavebench/services/run_pipeline.py +++ b/src/wavebench/services/run_pipeline.py @@ -10,6 +10,8 @@ from typing import Any, Callable, Iterator import numpy as np +from wavebench.data.analysis_resources import AnalysisBudget, AnalysisLimits, AnalysisResourceError, check_static +from wavebench.data.analysis_io import load_waveform, BLOCK_ROWS, read_json_bounded from wavebench.data.signal_pipeline import ( FrequencySignal, @@ -106,13 +108,16 @@ def execute_analysis_pipeline( step: RunStep, source_step: RunStep, source_record: RunStepRecord | None, + resource_limits: AnalysisLimits | None = None, ) -> dict[str, Any]: + limits = (resource_limits or AnalysisLimits()).tighten(step.fields.get("resources")) + check_static(step.fields["operations"], limits) 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, + run_dir=run_dir, source_step=source_step, source_record=source_record, resource_limits=limits, ) return details, waveform @@ -123,6 +128,7 @@ def load_source() -> tuple[dict[str, Any], np.ndarray]: "status": source_record.status if source_record is not None else "unavailable", }, load_source=load_source, + resource_limits=limits, ) @@ -130,7 +136,11 @@ 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, + resource_limits: AnalysisLimits | None = None, ) -> dict[str, Any]: + limits = (resource_limits or AnalysisLimits()).tighten(fields.get("resources")) + check_static(fields["operations"], limits, metadata_files=3 if schema == "wavebench.offline_pipeline.v1" else 2) + budget = AnalysisBudget(limits) processing_dir.mkdir(parents=True, exist_ok=False) metrics_path = processing_dir / "metrics.json" manifest_path = processing_dir / "manifest.json" @@ -160,7 +170,12 @@ def execute_pipeline( try: source_details, waveform = load_source() source.update(source_details) - signal: TimeSignal | FrequencySignal | PsdSignal = validate_waveform(waveform) + if isinstance(waveform, TimeSignal): + signal = waveform + else: + budget.source(len(waveform), np.asarray(waveform).dtype.itemsize) + signal = validate_waveform(waveform) + del waveform sampling = _time_sampling(signal) stages.append({"stage": "source", "status": "ok", "domain": "time"}) @@ -174,6 +189,9 @@ def execute_pipeline( } stages.append(stage) try: + count = len(signal.time_s) if isinstance(signal, TimeSignal) else len(signal.frequency_hz) + retained = sum(item.get("retained_count", 0) * 1024 for item in peaks) + stage["resources"] = budget.stage(operation, count, domain=_domain(signal), retained_bytes=retained) if op == "remove_dc": assert isinstance(signal, TimeSignal) signal = remove_dc(signal) @@ -225,6 +243,7 @@ def execute_pipeline( "sample_interval_s": result.sample_interval_s, "sample_rate_hz": result.sample_rate_hz, }) + del result elif op == "window": assert isinstance(signal, TimeSignal) signal = window_signal(signal, operation["name"]) @@ -285,11 +304,11 @@ def execute_pipeline( 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) + _atomic_write_json(json_path, detected, budget=budget) 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))) + ]).reshape(-1, len(columns)), budget=budget) metadata = {key: value for key, value in detected.items() if key != "peaks"} metadata.update({ "json": _derived_relative(json_path, run_dir), @@ -333,6 +352,7 @@ def execute_pipeline( signal=signal, name=operation["name"], formats=operation["formats"], + budget=budget, ): exported.append(item) exports.append(item) @@ -404,8 +424,20 @@ def execute_pipeline( if failure is not None: manifest["error"] = failure - _atomic_write_json(metrics_path, metrics_document) - _atomic_write_json(manifest_path, manifest) + manifest["resources"] = {**limits.evidence(), "work_units": budget.work_units, + "data_output_bytes": budget.output_bytes, + "data_output_files": budget.output_files} + + try: + _atomic_write_json(metrics_path, metrics_document, budget=budget) + _atomic_write_json(manifest_path, manifest, budget=budget) + except AnalysisResourceError as exc: + # Diagnostic metadata is permitted after exhaustion, never a successful oversized result. + status, failed_stage = "failed", "metadata" + failure = error_envelope(exc, operation="analysis.pipeline.metadata") + manifest.update(status=status, failed_stage=failed_stage, error=failure, partial=bool(exports or peaks)) + _atomic_write_json(metrics_path, metrics_document) + _atomic_write_json(manifest_path, manifest) pipeline_artifact: dict[str, Any] = { "schema": schema, @@ -413,6 +445,7 @@ def execute_pipeline( "manifest": _derived_relative(manifest_path, run_dir), "metrics": _derived_relative(metrics_path, run_dir), "source_step": source.get("step"), + "resources": manifest["resources"], "source_status": source["status"], "operations": operations, "warnings": warnings, @@ -557,7 +590,8 @@ def _load_source_waveform( run_dir: Path, source_step: RunStep, source_record: RunStepRecord | None, -) -> tuple[Path, dict[str, Any], np.ndarray]: + resource_limits: AnalysisLimits | None = None, +) -> tuple[Path, dict[str, Any], TimeSignal]: 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") @@ -572,7 +606,7 @@ def _load_source_waveform( 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")) + metadata = read_json_bounded(metadata_path, resource_limits or AnalysisLimits()) 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): @@ -584,16 +618,13 @@ def _load_source_waveform( 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 + waveform, source_hash = load_waveform(waveform_path, resource_limits or AnalysisLimits()) 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), + "npy_sha256": source_hash, }, waveform @@ -621,6 +652,7 @@ def _export_signal( signal: TimeSignal | FrequencySignal | PsdSignal, name: str, formats: list[str], + budget: AnalysisBudget | None = None, ) -> Iterator[dict[str, Any]]: exports_dir = processing_dir / "exports" exports_dir.mkdir(parents=True, exist_ok=True) @@ -630,15 +662,26 @@ def _export_signal( columns = ["frequency_hz", "psd_v2_per_hz"] else: columns = ["frequency_hz", "real_v", "imaginary_v", "amplitude_v"] - data = signal.as_array() + if isinstance(signal, TimeSignal): + arrays = (signal.time_s, signal.voltage_v) + elif isinstance(signal, PsdSignal): + arrays = (signal.frequency_hz, signal.psd_v2_per_hz) + else: + arrays = (signal.frequency_hz, signal.spectrum_v.real, signal.spectrum_v.imag) + def blocks(): + for start in range(0, len(arrays[0]), BLOCK_ROWS): + values = [array[start:start + BLOCK_ROWS] for array in arrays] + if isinstance(signal, FrequencySignal): + values.append(np.abs(signal.spectrum_v[start:start + BLOCK_ROWS])) + yield np.column_stack(values) 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) + _atomic_write_npy(target, None, blocks=blocks, shape=(len(arrays[0]), len(columns)), budget=budget) elif file_format == "csv": - _atomic_write_csv(target, columns, data) + _atomic_write_csv(target, columns, None, blocks=blocks, budget=budget) else: # pragma: no cover - RunPlan validation owns this invariant raise DataError(f"unsupported analysis export format: {file_format}") yield { @@ -650,40 +693,73 @@ def _export_signal( } -def _atomic_write_json(path: Path, value: dict[str, Any]) -> None: +def _atomic_write_json(path: Path, value: dict[str, Any], *, budget: AnalysisBudget | None = None) -> None: encoded = ( json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n" ).encode("utf-8") - _atomic_write_bytes(path, encoded) + if budget: + budget.limits.check("max_metadata_bytes", len(encoded), "metadata write") + _atomic_write_bytes(path, encoded, budget=budget) -def _atomic_write_npy(path: Path, data: np.ndarray) -> None: +def _atomic_write_npy(path: Path, data: np.ndarray | None, *, blocks=None, shape=None, + budget: AnalysisBudget | None = None) -> None: + if blocks is None: + shape = data.shape + def blocks(): + return (data[start:start + BLOCK_ROWS] for start in range(0, len(data), BLOCK_ROWS)) temporary = _temporary_path(path) try: with temporary.open("wb") as file: - np.save(file, data, allow_pickle=False) + np.lib.format.write_array_header_1_0(file, {"descr": np.dtype(float).str, + "fortran_order": False, "shape": shape}) + for block in blocks(): + encoded = np.asarray(block, dtype=float, order="C").tobytes() + if budget: + budget.pending_file(file.tell() + len(encoded)) + file.write(encoded) file.flush() os.fsync(file.fileno()) os.replace(temporary, path) + if budget: + budget.committed_file(path.stat().st_size) finally: temporary.unlink(missing_ok=True) -def _atomic_write_csv(path: Path, columns: list[str], data: np.ndarray) -> None: +def _atomic_write_csv(path: Path, columns: list[str], data: np.ndarray | None, *, blocks=None, + budget: AnalysisBudget | None = None) -> None: + if blocks is None: + def blocks(): + return (data[start:start + BLOCK_ROWS] for start in range(0, len(data), BLOCK_ROWS)) 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()) + for block in blocks(): + # Only this bounded block becomes Python objects; numeric formatting stays unchanged. + import io + buffer = io.StringIO(newline="") + csv.writer(buffer).writerows(block.tolist()) + encoded = buffer.getvalue() + if budget: + budget.pending_file(file.tell() + len(encoded.encode("utf-8"))) + file.write(encoded) + if budget: + budget.pending_file(file.tell()) file.flush() os.fsync(file.fileno()) os.replace(temporary, path) + if budget: + budget.committed_file(path.stat().st_size) finally: temporary.unlink(missing_ok=True) -def _atomic_write_bytes(path: Path, data: bytes) -> None: +def _atomic_write_bytes(path: Path, data: bytes, *, budget: AnalysisBudget | None = None) -> None: + if budget: + budget.pending_file(len(data)) temporary = _temporary_path(path) try: with temporary.open("xb") as file: @@ -691,6 +767,8 @@ def _atomic_write_bytes(path: Path, data: bytes) -> None: file.flush() os.fsync(file.fileno()) os.replace(temporary, path) + if budget: + budget.committed_file(len(data)) finally: temporary.unlink(missing_ok=True) diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 74d6ac26..ec805772 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -190,7 +190,7 @@ } _OPTIONAL_FIELDS = { - "analysis.pipeline": {"expect", "on_failure"}, + "analysis.pipeline": {"expect", "on_failure", "resources"}, "scope.auto": {"on_failure"}, "scope.capture": { "channel", @@ -460,6 +460,8 @@ def format_run_plan_schema() -> str: " 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 _.", @@ -1286,6 +1288,10 @@ def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) -> def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None: + if "resources" in fields: + from wavebench.data.analysis_resources import normalize_limits + + fields["resources"] = normalize_limits(fields["resources"]) raw_operations = fields["operations"] if not isinstance(raw_operations, list) or not raw_operations: diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 5b2bb805..2470a010 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -11,6 +11,7 @@ from typing import Any, Callable, Iterator, Mapping from wavebench.config import WaveBenchConfig +from wavebench.data.analysis_resources import AnalysisLimits from wavebench.data.package import new_package_dir, safe_label from wavebench.data.packages import load_run_package from wavebench.errors import ( @@ -248,6 +249,7 @@ class RunService: config: WaveBenchConfig logger: CommandLogger lease_manager: ResourceLeaseManager | None = None + analysis_limits: AnalysisLimits | None = None def verify(self, plan: RunPlan) -> list[RunPreflightRecord]: self.check(plan) @@ -309,6 +311,12 @@ def verify(self, plan: RunPlan) -> list[RunPreflightRecord]: return records def check(self, plan: RunPlan) -> None: + from wavebench.data.analysis_resources import AnalysisLimits, check_static + + for step in plan.steps: + if step.kind == "analysis.pipeline": + limits = (self.analysis_limits or AnalysisLimits()).tighten(step.fields.get("resources")) + check_static(step.fields["operations"], limits) check_run_plan_safety_limits(plan, self.config.safety_limits) reject_unsupported_steps(plan) ensure_analysis_pipeline_dependencies(plan) @@ -708,9 +716,9 @@ def run( execution_intent: Mapping[str, Any] | None = None, ) -> RunResult: self.check(plan) - intent = build_execution_intent(plan, self.config) + intent = build_execution_intent(plan, self.config, resource_limits=self.analysis_limits) if execution_intent is not None: - intent = verify_execution_intent(execution_intent, plan, self.config) + intent = verify_execution_intent(execution_intent, plan, self.config, resource_limits=self.analysis_limits) 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)] @@ -1108,6 +1116,7 @@ def report_close_errors() -> None: step=step, source_step=source_step, source_record=source_record, + resource_limits=self.analysis_limits, ) except Exception as exc: # noqa: BLE001 - preserve offline step failure payload = error_envelope( diff --git a/tests/test_analysis_resources.py b/tests/test_analysis_resources.py new file mode 100644 index 00000000..fd668ae1 --- /dev/null +++ b/tests/test_analysis_resources.py @@ -0,0 +1,248 @@ +import csv +from dataclasses import replace +from hashlib import sha256 +import io +from unittest.mock import patch + +import numpy as np +import pytest + +from wavebench.data.analysis_resources import ( + AnalysisLimits, AnalysisBudget, AnalysisResourceError, check_static, load_resource_limits, +) +from wavebench.data.analysis_io import load_waveform, mapped_npy, BLOCK_ROWS +from wavebench.data.signal_pipeline import TimeSignal, validate_waveform +from wavebench.errors import ConfigError, DataError, ExecutionIntentError, error_envelope +from wavebench.services.analysis_service import run_analysis, check_analysis +from wavebench.services.execution_intent import build_execution_intent, verify_execution_intent +from wavebench.services.run_pipeline import _atomic_write_csv, _export_signal +from wavebench.report.analysis import read_curve, write_analysis_report +from test_analysis_service import analysis_input as analysis_input +from test_psd_pipeline import PARAMS, plan_for, EXPORT +from test_run_service import make_config + + +def test_limit_config_and_tightening(tmp_path): + limits = AnalysisLimits() + for values in ({"bad": 1}, {"max_fft_length": True}, {"max_fft_length": 0}, {"max_fft_length": 1.5}): + with pytest.raises(ConfigError): + limits.tighten(values) + with pytest.raises(ConfigError, match="cannot exceed"): + limits.tighten({"max_fft_length": 2**21}) + assert limits.tighten({"max_fft_length": 64}).max_fft_length == 64 + profile = tmp_path / "resources.toml" + profile.write_text('schema="wavebench.analysis_resources.v1"\nmax_fft_length=2097152\n') + assert load_resource_limits(profile).max_fft_length == 2**21 + + +def test_static_and_joint_welch_budget(): + limits = AnalysisLimits() + with pytest.raises(AnalysisResourceError, match="max_zero_phase_fir_taps"): + check_static([dict(op="filter", family="fir", mode="zero_phase", numtaps=257)], limits) + operation = dict(op="psd", **(PARAMS | dict(nfft=2**20, nperseg=1024, noverlap=512))) + check_static([operation, EXPORT], limits) + with pytest.raises(AnalysisResourceError) as caught: + AnalysisBudget(limits).stage(operation, 2**20) + payload = error_envelope(caught.value) + assert payload["code"] == "resource_limit_exceeded" + assert payload["details"]["dimension"] == "max_working_bytes" + assert payload["details"]["requested"] > 8 * 1024**3 + + +def test_cumulative_work_and_export_budget(): + budget = AnalysisBudget(replace(AnalysisLimits(), max_work_units=150)) + budget.stage({"op": "remove_dc"}, 100) + with pytest.raises(AnalysisResourceError, match="max_work_units"): + budget.stage({"op": "remove_dc"}, 100) + budget = AnalysisBudget(replace(AnalysisLimits(), max_output_bytes=4000)) + budget.output_bytes = 2000 + with pytest.raises(AnalysisResourceError, match="max_output_bytes"): + budget.stage(EXPORT, 100) + + +def test_header_rejects_huge_claim_before_mapping(tmp_path): + path = tmp_path / "huge.npy" + with path.open("wb") as file: + np.lib.format.write_array_header_1_0(file, dict(descr="f8", " Date: Tue, 8 Sep 2026 09:03:02 +0800 Subject: [PATCH 18/30] docs(analysis): document resource limits and execution profiles --- docs/reference/artifacts.md | 8 ++++++ docs/reference/run-schema.md | 36 ++++++++++++++++++++++++++- plans/README.md | 2 ++ plans/example_analysis_resources.toml | 16 ++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 plans/example_analysis_resources.toml diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index a989d578..ef4bdc1b 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -2,6 +2,14 @@ ## 独立离线分析 +分析 manifest 的 `resources` 记录 `wavebench.analysis_resources.v1`、估算器 `conservative.v1`、有效限额、累计工作量,以及写入最终 metadata 前的派生数据字节/文件数。每个实际执行阶段的 `resources` 记录该阶段工作集与运算量估算。缺少这些字段的旧产物仍可读取。 + +NPY 来源先检查有界 header、实数 dtype、二维形状和文件长度,再只读映射;验证按块完成,跨块时间轴同样检查。处理结果使用独立工作数组,原始映射不写入。来源摘要使用同一打开文件计算,检测到文件替换或元数据变化时拒绝;这不是文件系统快照,不能保证识别所有外部并发修改。 + +时域/频域 NPY 与 CSV 按 4096 行块组装和原子写出,保持既有列、行序、浮点文本及文件摘要。报告按块验证全部数据与指纹,仅保存不超过 1200 个显示点;CSV 不再全量载入。预算不足或坏文件显示警告,仍可展示其它可用曲线。 + +默认环境继续生成 `wavebench.execution_intent.v1`,旧 Plan 的 digest 不变。显式 `--analysis-resources` 使用 `wavebench.execution_intent.v2`,把完整有效环境限额及估算器版本写入 `analysis_resources` 并绑定摘要,验证时必须提供同一资源配置。任务级收紧字段本身属于 Plan/配方内容,随既有摘要覆盖。默认 v1 不承诺绑定跨版本的默认预算。 + 成功重采样也写入 `transformations` 和 stage 的 `transformation`,记录约分比例、输入/输出样本数、采样率、间隔、时间范围、输出长度规则、固定滤波器 tap 数与截止频率、设计采样率、系数摘要、SciPy 版本和边界规则。manifest 的 `sampling` 随重采样更新,后续算子记录实际使用的新采样率。 成功平滑时,manifest 条件性增加 `transformations`,对应 stage 记录同一份 `transformation`。其中包括规范化参数、实际采样率、左右边界影响样本数、系数摘要、时间轴是否平移、可定义的名义群延迟;Savitzky–Golay 另外记录 SciPy 版本和窗口评价位置。未执行成功时不增加该项,后续失败保留此前的变换记录。 diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index b7265b96..348697c2 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -2,7 +2,41 @@ ## 独立离线配方 -`analysis` 命令直接处理历史 capture package,不需要仪器配置。显式选择一个通道,配方只包含 `schema = "wavebench.analysis_recipe.v1"`、`operations` 和可选 `[expect]`,共用下文的算子与验收合同。示例为 `plans/example_analysis_recipe.toml`。 +`analysis` 命令直接处理历史 capture package,不需要仪器配置。显式选择一个通道,配方包含 `schema = "wavebench.analysis_recipe.v1"`、`operations` 和可选 `[expect]`/`[resources]`,共用下文的算子与验收合同。示例为 `plans/example_analysis_recipe.toml`。 + +## 分析资源预算 + +本节为开发分支已实现、尚未发布的资源合同。分析使用有限的默认预算;超限时拒绝执行,不自动降低 taps、FFT 长度或采样率。旧的极大配方可能因此失败,正常预算内的数值参数与结果保持原样。 + +资源文件独立于仪器配置,以 `schema = "wavebench.analysis_resources.v1"` 开头,随后是限额字段。`--analysis-resources ` 可用于 `analysis check/run/report` 和 `run check/intent/verify/plan/report`。未提供的字段沿用默认值。独立离线分析仍不需要 `wavebench.toml`。 + +| 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `max_working_bytes` | 536870912 | 估算工作集,512 MiB | +| `max_input_samples` | 20000000 | 来源或重采样后的样本数 | +| `max_fft_length` | 1048576 | FFT/PSD FFT 长度 | +| `max_fir_taps` | 4095 | FIR taps | +| `max_zero_phase_fir_taps` | 255 | 零相位 FIR taps,同时受上一项约束 | +| `max_work_units` | 2000000000 | 累计运算量估算,不代表秒数 | +| `max_output_bytes` | 1073741824 | 每条分析输出总字节;报告用于累计导出读取及报告输出 | +| `max_temp_bytes` | 1073741824 | 当前临时输出文件字节 | +| `max_peak_candidates` | 100000 | 峰候选最坏数量/报告标记累计数量 | +| `max_report_curves` | 32 | 单份信号处理报告曲线数 | +| `max_operations` | 128 | 每条处理链算子数量 | +| `max_output_files` | 256 | 分析文件数;独立报告来源目录数 | +| `max_metadata_bytes` | 8388608 | 单个配方/元数据文档字节 | + +所有限额是 1~`2^63-1` 的整数,不接受布尔值或无限值。环境资源文件可以明确提高预算;配方 `[resources]` 和分析 step 的 `[steps.resources]` 只能收紧有效环境值,提高时拒绝配置。资源文件示例见 `plans/example_analysis_resources.toml`。 + +`run check` 在硬件会话之前检查操作数量、FIR taps、PSD `nfft` 和文件数量。实际采集长度此时未知,因此不能把静态通过视为全部资源检查通过。独立 `analysis check` 读取受限 NPY header、校验实际波形,并按重采样后的长度推演处理链。实际执行仍逐阶段检查,以保留此前成功导出和准确失败位置。 + +工作集估算计入数组副本、滤波器、PSD 重叠分段及复数工作区;运算量按 FIR 的样本数乘 taps、Welch 的段数乘 FFT 长度及对数阶等保守模型累计。峰检测在创建候选属性前按最坏峰数准入,平坦的大波形也可能被拒绝。mean/median Welch 都保守预算分段矩阵,本版本没有增加分段计算后端。 + +预算范围是单个分析 step/独立分析目录;多个 RunPlan 分析 step 串行执行,各自计账,尚无整个 run 的磁盘总配额。已有重采样比例、样本数等算子固有限制仍有效,资源文件不能解除它们。 + +超限错误为 `resource_limit_exceeded`,包含维度、限额、请求量和阶段。已开始的分析按 `on_failure` 处理,不重采集;当前临时文件清理,已完成文件保留。诊断 metadata 在配额耗尽后仍尽力写入并标记失败,这部分可能超过输出配额;磁盘完全耗尽时不保证诊断落成。 + +资源预算是保守准入估算,不是操作系统 RSS 硬限制或不可信代码沙箱。不同 SciPy/NumPy 版本的内部工作集可能不同;未知规模应先做受控基准。常规 `run report` 的资源选项只约束信号处理曲线区域,不覆盖旧截图、PDF 或频响报告的全部资源。 ```bash wavebench analysis check --capture data/capture --channel 1 --recipe plans/example_analysis_recipe.toml diff --git a/plans/README.md b/plans/README.md index 3420783a..588c305c 100644 --- a/plans/README.md +++ b/plans/README.md @@ -17,6 +17,8 @@ wavebench run check --plan plans/example_scope_expect_quality.toml ## 信号处理功能展示 +`example_analysis_resources.toml` 是执行资源配置,不是 RunPlan 或处理配方。可通过 `--analysis-resources plans/example_analysis_resources.toml` 显式选用;字段与兼容边界见[资源预算说明](../docs/reference/run-schema.md#分析资源预算)。 + [完整 RunPlan 示例](example_signal_processing_pipeline.toml) 采集 CH1 一次,然后对同一份原始 NPY 执行三个独立分析步骤: | 步骤 ID | 展示内容 | 报告产物 | diff --git a/plans/example_analysis_resources.toml b/plans/example_analysis_resources.toml new file mode 100644 index 00000000..05e648de --- /dev/null +++ b/plans/example_analysis_resources.toml @@ -0,0 +1,16 @@ +# Execution profile, not a RunPlan or an analysis recipe. +# Use --analysis-resources with analysis commands or run check/intent/plan/report. +schema = "wavebench.analysis_resources.v1" +max_working_bytes = 536870912 +max_input_samples = 20000000 +max_fft_length = 1048576 +max_fir_taps = 4095 +max_zero_phase_fir_taps = 255 +max_work_units = 2000000000 +max_output_bytes = 1073741824 +max_temp_bytes = 1073741824 +max_peak_candidates = 100000 +max_report_curves = 32 +max_operations = 128 +max_output_files = 256 +max_metadata_bytes = 8388608 From 945590b143b024374e06922917c2edeea1ac0600 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:13:26 +0800 Subject: [PATCH 19/30] perf(analysis): stream Welch mean and causal filter stages --- docs/reference/artifacts.md | 4 ++- docs/reference/run-schema.md | 2 +- src/wavebench/data/analysis_resources.py | 4 +-- src/wavebench/data/signal_pipeline.py | 40 ++++++++++++++++----- src/wavebench/services/run_pipeline.py | 7 ++++ tests/test_analysis_chunked.py | 45 ++++++++++++++++++++++++ tests/test_analysis_resources.py | 2 +- 7 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 tests/test_analysis_chunked.py diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index ef4bdc1b..e9911c2e 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -2,7 +2,7 @@ ## 独立离线分析 -分析 manifest 的 `resources` 记录 `wavebench.analysis_resources.v1`、估算器 `conservative.v1`、有效限额、累计工作量,以及写入最终 metadata 前的派生数据字节/文件数。每个实际执行阶段的 `resources` 记录该阶段工作集与运算量估算。缺少这些字段的旧产物仍可读取。 +分析 manifest 的 `resources` 记录 `wavebench.analysis_resources.v1`、估算器 `conservative.v2`、有效限额、累计工作量,以及写入最终 metadata 前的派生数据字节/文件数。每个实际执行阶段的 `resources` 记录该阶段工作集与运算量估算。缺少这些字段的旧产物仍可读取。 NPY 来源先检查有界 header、实数 dtype、二维形状和文件长度,再只读映射;验证按块完成,跨块时间轴同样检查。处理结果使用独立工作数组,原始映射不写入。来源摘要使用同一打开文件计算,检测到文件替换或元数据变化时拒绝;这不是文件系统快照,不能保证识别所有外部并发修改。 @@ -100,6 +100,8 @@ 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 元数据的 `algorithm` 区分 `welch_segment_mean.v1` 与 `scipy_welch_median.v1`;因果滤波记录 `causal_blocks.v1` 及 `block_samples=4096`。 + 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/run-schema.md b/docs/reference/run-schema.md index 348697c2..2ec3cdad 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -30,7 +30,7 @@ `run check` 在硬件会话之前检查操作数量、FIR taps、PSD `nfft` 和文件数量。实际采集长度此时未知,因此不能把静态通过视为全部资源检查通过。独立 `analysis check` 读取受限 NPY header、校验实际波形,并按重采样后的长度推演处理链。实际执行仍逐阶段检查,以保留此前成功导出和准确失败位置。 -工作集估算计入数组副本、滤波器、PSD 重叠分段及复数工作区;运算量按 FIR 的样本数乘 taps、Welch 的段数乘 FFT 长度及对数阶等保守模型累计。峰检测在创建候选属性前按最坏峰数准入,平坦的大波形也可能被拒绝。mean/median Welch 都保守预算分段矩阵,本版本没有增加分段计算后端。 +工作集估算计入数组副本、滤波器、PSD 重叠分段及复数工作区;运算量按 FIR 的样本数乘 taps、Welch 的段数乘 FFT 长度及对数阶等保守模型累计。峰检测在创建候选属性前按最坏峰数准入,平坦的大波形也可能被拒绝。mean Welch 按原段顺序逐段累计,预算单段工作区;median 继续预算完整分段矩阵。因果 FIR/IIR 按 4096 点分块并传递滤波状态,零初态和滤波器设计不变;零相位仍整段执行。分段累计与整段算法按数值容差兼容,不承诺浮点产物字节一致。 预算范围是单个分析 step/独立分析目录;多个 RunPlan 分析 step 串行执行,各自计账,尚无整个 run 的磁盘总配额。已有重采样比例、样本数等算子固有限制仍有效,资源文件不能解除它们。 diff --git a/src/wavebench/data/analysis_resources.py b/src/wavebench/data/analysis_resources.py index 15b2e582..863a3cd5 100644 --- a/src/wavebench/data/analysis_resources.py +++ b/src/wavebench/data/analysis_resources.py @@ -55,7 +55,7 @@ def tighten(self, values: dict | None) -> AnalysisLimits: return replace(self, **values) def evidence(self) -> dict: - return {"schema": "wavebench.analysis_resources.v1", "estimator": "conservative.v1", + return {"schema": "wavebench.analysis_resources.v1", "estimator": "conservative.v2", "limits": asdict(self), "memory_limit_kind": "estimated_working_set"} @@ -122,7 +122,7 @@ def stage(self, operation: dict, count: int, *, domain: str = "time", retained_b 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 + memory += (1 if operation["average"] == "mean" else 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) diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py index 289b6fd2..8ccab672 100644 --- a/src/wavebench/data/signal_pipeline.py +++ b/src/wavebench/data/signal_pipeline.py @@ -39,6 +39,7 @@ ANALYSIS_IIR_MAX_RIPPLE_DB = 20.0 ANALYSIS_IIR_MAX_ATTENUATION_DB = 200.0 SIGNIFICANT_PEAK_V = 1e-12 +FILTER_BLOCK_SAMPLES = 4096 @dataclass(frozen=True) @@ -166,12 +167,23 @@ def welch_psd( 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, - ) + kwargs = dict(fs=sample_rate, window=weights, nperseg=nperseg, nfft=nfft, + detrend=False if parameters["detrend"] == "none" else parameters["detrend"], + scaling="density", return_onesided=True, axis=-1) + if parameters["average"] == "mean": + density = np.zeros(nfft // 2 + 1, dtype=np.float64) + segments = 0 + for start in range(0, samples - nperseg + 1, nperseg - noverlap): + frequencies, segment_density = scipy_signal.welch( + signal.voltage_v[start:start + nperseg], noverlap=0, average="mean", **kwargs, + ) + density += segment_density + segments += 1 + density /= segments + else: + frequencies, density = scipy_signal.welch( + signal.voltage_v, noverlap=noverlap, average="median", **kwargs, + ) except (ValueError, FloatingPointError, OverflowError) as exc: raise DataError(f"analysis PSD failed: {exc}") from exc if ( @@ -323,7 +335,13 @@ def filter_fir( dtype=np.float64, ) if mode == "causal": - voltage = scipy_signal.lfilter(taps, [1.0], signal.voltage_v, axis=-1) + voltage = np.empty_like(signal.voltage_v) + state = np.zeros(numtaps - 1) + for start in range(0, voltage.size, FILTER_BLOCK_SAMPLES): + block, state = scipy_signal.lfilter( + taps, [1.0], signal.voltage_v[start:start + FILTER_BLOCK_SAMPLES], zi=state, + ) + voltage[start:start + len(block)] = block else: voltage = scipy_signal.filtfilt( taps, @@ -428,7 +446,13 @@ def filter_iir( try: if mode == "causal": - voltage = scipy_signal.sosfilt(sos, signal.voltage_v, axis=-1, zi=None) + voltage = np.empty_like(signal.voltage_v) + state = np.zeros((len(sos), 2)) + for start in range(0, voltage.size, FILTER_BLOCK_SAMPLES): + block, state = scipy_signal.sosfilt( + sos, signal.voltage_v[start:start + FILTER_BLOCK_SAMPLES], zi=state, + ) + voltage[start:start + len(block)] = block else: voltage = scipy_signal.sosfiltfilt( sos, diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py index dab3d9eb..25bedeaf 100644 --- a/src/wavebench/services/run_pipeline.py +++ b/src/wavebench/services/run_pipeline.py @@ -14,6 +14,7 @@ from wavebench.data.analysis_io import load_waveform, BLOCK_ROWS, read_json_bounded from wavebench.data.signal_pipeline import ( + FILTER_BLOCK_SAMPLES, FrequencySignal, FirFilterResult, IirFilterResult, @@ -269,6 +270,8 @@ def execute_pipeline( **signal.parameters, "operation_index": operation_index, "execution_function": "scipy.signal.welch", + "algorithm": ("welch_segment_mean.v1" if operation["average"] == "mean" + else "scipy_welch_median.v1"), "scipy_version": signal.scipy_version, "sample_rate_hz": rate, "window_periodic": True, @@ -505,6 +508,8 @@ def _fir_filter_metadata( metadata.update({ "boundary": "zero_initial_state", "initial_state": "zeros", + "algorithm": "causal_blocks.v1", + "block_samples": FILTER_BLOCK_SAMPLES, }) else: metadata.update({ @@ -575,6 +580,8 @@ def _iir_filter_metadata( metadata.update({ "boundary": "zero_initial_state", "initial_state": "zeros", + "algorithm": "causal_blocks.v1", + "block_samples": FILTER_BLOCK_SAMPLES, }) else: metadata.update({ diff --git a/tests/test_analysis_chunked.py b/tests/test_analysis_chunked.py new file mode 100644 index 00000000..c36317ca --- /dev/null +++ b/tests/test_analysis_chunked.py @@ -0,0 +1,45 @@ +import numpy as np +import pytest +from scipy import signal as scipy_signal + +from wavebench.data.signal_pipeline import filter_fir, filter_iir, validate_waveform, welch_psd +from wavebench.data.analysis_resources import AnalysisBudget, AnalysisLimits, AnalysisResourceError + + +@pytest.mark.parametrize('window', ['hann', 'hamming', 'blackman']) +@pytest.mark.parametrize('detrend', ['none', 'constant', 'linear']) +@pytest.mark.parametrize('nfft', [127, 256]) +def test_segment_mean_matches_full_welch(window, detrend, nfft): + values = np.random.default_rng(24).normal(size=10003) + waveform = validate_waveform(np.column_stack((np.arange(len(values)) / 4096, values))) + params = dict(method='welch', window=window, nperseg=127, noverlap=83, + nfft=nfft, detrend=detrend, average='mean') + actual = welch_psd(waveform, **params) + frequency, expected = scipy_signal.welch(values, fs=4096, + window=scipy_signal.get_window(window, 127, fftbins=True), nperseg=127, + noverlap=83, nfft=nfft, detrend=False if detrend == 'none' else detrend) + np.testing.assert_array_equal(actual.frequency_hz, frequency) + np.testing.assert_allclose(actual.psd_v2_per_hz, expected, rtol=2e-13, atol=1e-16) + assert actual.discarded_tail_samples == (len(values) - 127) % 44 + + +@pytest.mark.parametrize('family', ['fir', 'iir']) +def test_causal_filter_preserves_state_at_blocks(family): + values = np.random.default_rng(9).normal(size=9001) + values[4095:4098] += 10 + waveform = validate_waveform(np.column_stack((np.arange(len(values)) / 4096, values))) + if family == 'fir': + result = filter_fir(waveform, response='lowpass', cutoff_hz=200, numtaps=101, mode='causal') + expected = scipy_signal.lfilter(result.taps, [1.0], values) + else: + result = filter_iir(waveform, design='butterworth', response='lowpass', cutoff_hz=200, order=5, mode='causal') + expected = scipy_signal.sosfilt(result.sos, values) + np.testing.assert_allclose(result.signal.voltage_v, expected, rtol=1e-12, atol=1e-14) + np.testing.assert_array_equal(waveform.voltage_v, values) + + +def test_mean_budget_does_not_retain_segment_matrix(): + params = dict(op='psd', nperseg=1024, noverlap=512, nfft=32768, average='mean') + AnalysisBudget(AnalysisLimits(max_work_units=4_000_000_000)).stage(params, 262144) + with pytest.raises(AnalysisResourceError, match='max_working_bytes'): + AnalysisBudget(AnalysisLimits(max_work_units=4_000_000_000)).stage(params | {'average': 'median'}, 262144) diff --git a/tests/test_analysis_resources.py b/tests/test_analysis_resources.py index fd668ae1..d4204221 100644 --- a/tests/test_analysis_resources.py +++ b/tests/test_analysis_resources.py @@ -39,7 +39,7 @@ def test_static_and_joint_welch_budget(): limits = AnalysisLimits() with pytest.raises(AnalysisResourceError, match="max_zero_phase_fir_taps"): check_static([dict(op="filter", family="fir", mode="zero_phase", numtaps=257)], limits) - operation = dict(op="psd", **(PARAMS | dict(nfft=2**20, nperseg=1024, noverlap=512))) + operation = dict(op="psd", **(PARAMS | dict(nfft=2**20, nperseg=1024, noverlap=512, average="median"))) check_static([operation, EXPORT], limits) with pytest.raises(AnalysisResourceError) as caught: AnalysisBudget(limits).stage(operation, 2**20) From 0bd76048ff53e9230472368d0598095200f784fe Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:34:31 +0800 Subject: [PATCH 20/30] feat(analysis): supervise offline workers with platform memory limits --- src/wavebench/cli.py | 11 +- src/wavebench/cli_parser.py | 5 + src/wavebench/data/analysis_control.py | 17 + src/wavebench/data/analysis_io.py | 2 + src/wavebench/data/signal_pipeline.py | 5 + src/wavebench/report/analysis.py | 7 + src/wavebench/services/analysis_execution.py | 289 +++++++++++++++++ src/wavebench/services/analysis_platform.py | 151 +++++++++ src/wavebench/services/analysis_service.py | 32 +- src/wavebench/services/execution_intent.py | 19 +- src/wavebench/services/run_pipeline.py | 35 ++- src/wavebench/services/run_service.py | 12 +- tests/test_analysis_execution.py | 313 +++++++++++++++++++ 13 files changed, 878 insertions(+), 20 deletions(-) create mode 100644 src/wavebench/data/analysis_control.py create mode 100644 src/wavebench/services/analysis_execution.py create mode 100644 src/wavebench/services/analysis_platform.py create mode 100644 tests/test_analysis_execution.py diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index b9165807..6e80ddcd 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -240,9 +240,11 @@ def _load_run_service(args: argparse.Namespace) -> RunService: config = config.with_resource(args.resource) from .data.analysis_resources import load_resource_limits + from .services.analysis_execution import load_analysis_execution + execution = load_analysis_execution(getattr(args, "analysis_execution", None)) profile = getattr(args, "analysis_resources", None) return RunService(config=config, logger=CommandLogger(), - analysis_limits=load_resource_limits(profile) if profile else None) + analysis_limits=load_resource_limits(profile) if profile else None, analysis_execution=execution) def _load_sweep_service(args: argparse.Namespace) -> SweepService: @@ -1091,7 +1093,9 @@ def _main(argv: list[str] | None = None) -> int: return 0 from .services.analysis_service import check_analysis, run_analysis - options = dict(capture=Path(args.capture), channel=args.channel, recipe=Path(args.recipe), resource_limits=limits) + from .services.analysis_execution import load_analysis_execution + options = dict(capture=Path(args.capture), channel=args.channel, recipe=Path(args.recipe), resource_limits=limits, + execution_policy=load_analysis_execution(args.analysis_execution)) 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)) @@ -1123,7 +1127,8 @@ 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, resource_limits=getattr(service, "analysis_limits", None)) + intent = build_execution_intent(plan, service.config, resource_limits=getattr(service, "analysis_limits", None), + execution_policy=getattr(service, "analysis_execution", None)) if args.output: output = write_execution_intent(intent, args.output) if not args.json: diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index f14d5f3b..59efe10d 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -55,6 +55,7 @@ def build_parser() -> argparse.ArgumentParser: 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") + analysis_command.add_argument("--analysis-execution", help="Analysis execution profile TOML") if command == "run": analysis_command.add_argument("--output", required=True) mcp_parser = subparsers.add_parser("mcp", help="HTTP MCP server / HTTP MCP 服务") @@ -391,6 +392,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_check.add_argument("--analysis-execution", help="Analysis execution profile TOML") run_intent = run_sub.add_parser( "intent", help="Build an offline execution intent for a run plan / 为运行计划生成离线执行意图", @@ -399,6 +401,7 @@ def build_parser() -> argparse.ArgumentParser: 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_intent.add_argument("--analysis-execution", help="Analysis execution profile TOML") run_verify = run_sub.add_parser( "verify", help="Verify / 预检 instruments referenced by a run plan with read-only *IDN? queries", @@ -406,6 +409,7 @@ def build_parser() -> argparse.ArgumentParser: 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_verify.add_argument("--analysis-execution", help="Analysis execution 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") @@ -429,6 +433,7 @@ def build_parser() -> argparse.ArgumentParser: 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("--analysis-execution", help="Analysis execution profile TOML") run_plan.add_argument("--plan", required=True, help="Path to a WaveBench run plan TOML file") run_plan.add_argument( "--intent", diff --git a/src/wavebench/data/analysis_control.py b/src/wavebench/data/analysis_control.py new file mode 100644 index 00000000..194a5d97 --- /dev/null +++ b/src/wavebench/data/analysis_control.py @@ -0,0 +1,17 @@ +"""Cooperative checks installed only inside a supervised analysis worker.""" +from contextvars import ContextVar + +from wavebench.errors import DataError + + +cancel_signal = ContextVar('analysis_cancel_signal', default=None) + + +class AnalysisCancelled(DataError): + code = 'analysis_cancelled' + + +def checkpoint(): + event = cancel_signal.get() + if event is not None and event.is_set(): + raise AnalysisCancelled('analysis cancelled by supervisor') diff --git a/src/wavebench/data/analysis_io.py b/src/wavebench/data/analysis_io.py index a8ee8b7a..ff6b3d5b 100644 --- a/src/wavebench/data/analysis_io.py +++ b/src/wavebench/data/analysis_io.py @@ -13,6 +13,7 @@ from wavebench.data.analysis_resources import AnalysisBudget, AnalysisLimits from wavebench.errors import DataError +from wavebench.data.analysis_control import checkpoint BLOCK_ROWS = 4096 @@ -84,6 +85,7 @@ def hash_stream(file) -> str: file.seek(0) digest = sha256() while chunk := file.read(1024 * 1024): + checkpoint() digest.update(chunk) return digest.hexdigest() diff --git a/src/wavebench/data/signal_pipeline.py b/src/wavebench/data/signal_pipeline.py index 8ccab672..93b0a0e8 100644 --- a/src/wavebench/data/signal_pipeline.py +++ b/src/wavebench/data/signal_pipeline.py @@ -7,6 +7,7 @@ import numpy as np from wavebench.errors import DataError +from wavebench.data.analysis_control import checkpoint ANALYSIS_TIME_METRICS = frozenset({ @@ -174,6 +175,7 @@ def welch_psd( density = np.zeros(nfft // 2 + 1, dtype=np.float64) segments = 0 for start in range(0, samples - nperseg + 1, nperseg - noverlap): + checkpoint() frequencies, segment_density = scipy_signal.welch( signal.voltage_v[start:start + nperseg], noverlap=0, average="mean", **kwargs, ) @@ -228,6 +230,7 @@ def validate_waveform(data: Any) -> TimeSignal: raise DataError("analysis pipeline input must contain real numeric values") result = np.empty(array.shape, dtype=np.float64) for start in range(0, len(array), 4096): + checkpoint() 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") @@ -338,6 +341,7 @@ def filter_fir( voltage = np.empty_like(signal.voltage_v) state = np.zeros(numtaps - 1) for start in range(0, voltage.size, FILTER_BLOCK_SAMPLES): + checkpoint() block, state = scipy_signal.lfilter( taps, [1.0], signal.voltage_v[start:start + FILTER_BLOCK_SAMPLES], zi=state, ) @@ -449,6 +453,7 @@ def filter_iir( voltage = np.empty_like(signal.voltage_v) state = np.zeros((len(sos), 2)) for start in range(0, voltage.size, FILTER_BLOCK_SAMPLES): + checkpoint() block, state = scipy_signal.sosfilt( sos, signal.voltage_v[start:start + FILTER_BLOCK_SAMPLES], zi=state, ) diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py index 0a266698..dfd91c48 100644 --- a/src/wavebench/report/analysis.py +++ b/src/wavebench/report/analysis.py @@ -200,6 +200,13 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *, 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")))}

') + execution = manifest.get("execution") + if execution: + sections.append('

执行监督 / Execution supervision: ' + + escape(str(execution.get('reason') or manifest.get('status'))) + + '; timeout_s=' + escape(str(execution.get('timeout_s'))) + + '; memory_backend=' + escape(str(execution.get('memory_backend'))) + + '; forced=' + escape(str(execution.get('forced'))) + '

') source = manifest["source"] peak_sets = {} for peak in manifest.get("peaks", []): diff --git a/src/wavebench/services/analysis_execution.py b/src/wavebench/services/analysis_execution.py new file mode 100644 index 00000000..05b5a5d6 --- /dev/null +++ b/src/wavebench/services/analysis_execution.py @@ -0,0 +1,289 @@ +"""Opt-in, spawn-based supervision of offline analysis; never owns hardware.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +import json +import math +import multiprocessing +from pathlib import Path +import signal +import tempfile +import time +import tomllib + +from wavebench.errors import ConfigError, DataError, error_envelope + + +@dataclass(frozen=True) +class AnalysisExecution: + timeout_s: float = 300.0 + grace_s: float = 2.0 + memory_bytes: int | None = None + cgroup_root: str | None = None + + def __post_init__(self): + for name in ('timeout_s', 'grace_s'): + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value) or value <= 0: + raise ConfigError(f'analysis execution {name} must be finite and positive') + object.__setattr__(self, name, float(value)) + if self.memory_bytes is not None and (type(self.memory_bytes) is not int or not 1 <= self.memory_bytes <= 2**63 - 1): + raise ConfigError('analysis execution memory_bytes must be an integer in [1, 2^63-1]') + if self.cgroup_root is not None and (not isinstance(self.cgroup_root, str) or not self.cgroup_root): + raise ConfigError('analysis execution cgroup_root must be a nonempty path') + if self.cgroup_root is not None and self.memory_bytes is None: + raise ConfigError('cgroup_root requires memory_bytes') + + def evidence(self): + return {'schema': 'wavebench.analysis_execution.v1', 'start_method': 'spawn', **asdict(self)} + + def preflight(self): + from .analysis_platform import memory_scope + with memory_scope(self) as scope: + if self.memory_bytes is not None: + context = multiprocessing.get_context('spawn') + ready = context.Event() + probe = context.Process(target=_probe_worker, args=(ready,)) + try: + probe.start() + scope.attach(probe.pid) + ready.set() + probe.join(10) + if probe.is_alive() or probe.exitcode != 0: + raise ConfigError('analysis memory preflight worker failed') + except OSError as exc: + raise ConfigError(f'cannot attach analysis memory preflight worker: {exc}') from exc + finally: + if probe.pid is not None and probe.is_alive(): + probe.kill() + probe.join() + probe.close() + + +def load_analysis_execution(path): + if path is None: + return None + try: + with Path(path).open('rb') as file: + raw = file.read(65537) + if len(raw) > 65536: + raise ConfigError('analysis execution profile exceeds 65536 bytes') + values = tomllib.loads(raw.decode('utf-8-sig')) + if values.pop('schema', None) != 'wavebench.analysis_execution.v1': + raise ConfigError('execution profile schema must be wavebench.analysis_execution.v1') + return AnalysisExecution(**values) + except (OSError, ValueError, TypeError) as exc: + raise ConfigError(f'cannot read analysis execution profile: {exc}') from exc + + +def _probe_worker(ready): + signal.signal(signal.SIGINT, signal.SIG_IGN) + ready.wait(10) + + +def _worker(target, kwargs, gate, cancel, result_path, limits): + # The parent handles console interruption and records the reason. + signal.signal(signal.SIGINT, signal.SIG_IGN) + gate.wait() + from wavebench.data.analysis_control import cancel_signal + from .run_pipeline import _atomic_write_bytes + cancel_signal.set(cancel) + try: + artifact = target(**kwargs) + payload = {'artifact': artifact} + except BaseException as exc: + payload = {'error': error_envelope(exc, operation='analysis.worker')} + raw = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode('utf-8') + try: + limits.check('max_metadata_bytes', len(raw), 'worker result') + limits.check('max_temp_bytes', len(raw), 'worker result') + except DataError as exc: + # Small diagnostic envelopes use the same exhaustion exception as failure manifests. + raw = json.dumps({'error': error_envelope(exc, operation='analysis.worker_result')}).encode('utf-8') + _atomic_write_bytes(Path(result_path), raw) + + +def _read_document(path, limit): + if not path.exists(): + return {} + with path.open('rb') as file: + raw = file.read(limit + 1) + if len(raw) > limit: + raise DataError('analysis worker document exceeds metadata budget') + payload = json.loads(raw) + if not isinstance(payload, dict): + raise DataError('analysis worker document must be an object') + return payload + + +def supervise(target, kwargs, *, policy, run_dir, processing_dir, fields, source, limits, + schema='wavebench.analysis_pipeline.v1', cancel_event=None): + """Run one serializable analysis entry point, then own its terminal record.""" + from .analysis_platform import memory_scope + from .run_pipeline import _atomic_write_json + from .run_analysis import evaluate_expect + + context = multiprocessing.get_context('spawn') + gate, cancel = context.Event(), context.Event() + reason = None + forced = False + artifact = None + worker_error = None + started = time.monotonic() + run_dir, processing_dir = Path(run_dir), Path(processing_dir) + # The control directory is outside the worker-owned processing directory. + if processing_dir.exists(): + raise ConfigError('supervised analysis output must be a new directory') + control_parent = processing_dir.parent + control_parent.mkdir(parents=True, exist_ok=True) + exitcode = None + memory = {'memory_backend': 'unavailable'} + try: + with memory_scope(policy) as scope, tempfile.TemporaryDirectory(prefix='.analysis-control-', dir=control_parent) as control: + result_path = Path(control) / 'result.json' + process = context.Process(target=_worker, args=(target, kwargs, gate, cancel, str(result_path), limits)) + exitcode = None + memory = scope.evidence() + try: + process.start() + scope.attach(process.pid) + gate.set() + deadline = started + policy.timeout_s + while process.is_alive(): + try: + if reason is None: + if cancel_event is not None and cancel_event.is_set(): + reason = 'analysis_cancelled' + elif time.monotonic() >= deadline: + reason = 'analysis_timeout' + if reason: + cancel.set() + deadline = time.monotonic() + policy.grace_s + elif time.monotonic() >= deadline: + forced = True + process.terminate() + process.join(1) + if process.is_alive(): + process.kill() + break + process.join(0.05) + except KeyboardInterrupt: + if reason is None: + reason = 'analysis_cancelled' + cancel.set() + deadline = time.monotonic() + policy.grace_s + else: + deadline = time.monotonic() + process.join() + exitcode = process.exitcode + memory = scope.evidence() + try: + payload = _read_document(result_path, max(limits.max_metadata_bytes, 65536)) + artifact = payload.get('artifact') + if not isinstance(artifact, dict) or not isinstance(artifact.get('analysis_pipeline'), dict): + artifact = None + worker_error = payload.get('error') + except (OSError, ValueError, DataError) as exc: + worker_error = error_envelope(exc, operation='analysis.worker_result') + if artifact is None or exitcode != 0: + reason = reason or ('resource_limit_exceeded' if worker_error and worker_error.get('code') == 'resource_limit_exceeded' + else 'analysis_worker_failed') + except KeyboardInterrupt: + reason = 'analysis_cancelled' + forced = True + except Exception as exc: + reason = reason or 'analysis_worker_failed' + worker_error = error_envelope(exc, operation='analysis.worker_start') + finally: + if process.pid is not None and process.is_alive(): + process.kill() + process.join() + if process.pid is not None: + exitcode = process.exitcode + process.close() + + except KeyboardInterrupt: + reason, forced = 'analysis_cancelled', True + except Exception as exc: + reason = reason or 'analysis_worker_failed' + worker_error = error_envelope(exc, operation='analysis.supervisor_setup_or_cleanup') + artifact = None + + # No worker can touch these files after join and memory-scope cleanup. + processing_dir.mkdir(parents=True, exist_ok=True) + for directory in (processing_dir, processing_dir / 'exports', processing_dir / 'peaks'): + if directory.is_dir(): + for path in directory.glob('.*.tmp'): + path.unlink(missing_ok=True) + manifest_path = processing_dir / 'manifest.json' + metrics_path = processing_dir / 'metrics.json' + try: + manifest = _read_document(manifest_path, max(limits.max_metadata_bytes, 65536)) + metrics = _read_document(metrics_path, max(limits.max_metadata_bytes, 65536)).get('metrics', {}) + except (OSError, ValueError, DataError): + manifest, metrics = {}, {} + source.update(manifest.get('source', {})) + if reason == 'analysis_worker_failed' and exitcode == 0 and manifest.get('status') == 'failed' and manifest.get('error'): + worker_error = manifest['error'] + evidence = {**policy.evidence(), **memory, 'exitcode': exitcode, 'forced': forced, + 'elapsed_s': time.monotonic() - started, 'reason': reason} + if artifact is None: + manifest = {'schema': schema, 'source': source, 'operations': fields['operations'], + 'stages': [], 'warnings': [], 'exports': [], + 'metrics': metrics_path.relative_to(run_dir).as_posix(), + 'sampling': None, 'window': None, **manifest} + artifact = {'analysis_pipeline': { + 'schema': schema, 'source_step': source.get('step'), 'source_status': source.get('status'), + 'manifest': manifest_path.relative_to(run_dir).as_posix(), + 'metrics': metrics_path.relative_to(run_dir).as_posix(), + 'operations': fields['operations'], 'warnings': manifest.get('warnings', []), + 'exports': manifest.get('exports', []), + }, 'metrics': metrics} + if manifest.get('peaks'): + artifact['analysis_pipeline']['peaks'] = manifest['peaks'] + pipeline = artifact['analysis_pipeline'] + if reason: + error = error_envelope(DataError(reason.replace('_', ' ')), operation='analysis.supervisor', + details={'exitcode': exitcode, 'forced': forced}, cause=worker_error) + error['code'] = reason + stage = next((f"operations[{item['index']}]" for item in manifest.get('stages', []) + if item.get('status') == 'running' and 'index' in item), + manifest.get('failed_stage') or 'supervisor') + for item in manifest.get('stages', []): + if item.get('status') == 'running': + item['status'] = 'failed' + recorded = {item.get('index') for item in manifest.get('stages', [])} + for index, operation in enumerate(fields['operations']): + if index not in recorded: + manifest['stages'].append({'index': index, 'op': operation['op'], 'status': 'skipped'}) + manifest.update(status='failed', partial=bool(manifest.get('exports') or any(value is not None for value in metrics.values())), + failed_stage=stage, error=error) + pipeline.update(status='failed', failed_stage=stage, error=error) + if 'expect' in fields: + artifact['expect'] = evaluate_expect(artifact['metrics'], fields['expect']) + resources = manifest.setdefault('resources', {**limits.evidence(), 'work_units': 0, + 'data_output_bytes': 0, 'data_output_files': 0}) + # Charge every committed data file, including one replaced just before worker death. + data_files = [path for folder in ('exports', 'peaks') + for path in (processing_dir / folder).glob('*') if path.is_file() and not path.name.startswith('.')] + resources['data_output_bytes'] = sum(path.stat().st_size for path in data_files) + resources['data_output_files'] = len(data_files) + if reason: + manifest['committed_files'] = [path.relative_to(run_dir).as_posix() for path in sorted(data_files)] + pipeline['resources'] = resources + manifest['execution'] = pipeline['execution'] = evidence + from wavebench.data.analysis_resources import AnalysisBudget, AnalysisResourceError + budget = AnalysisBudget(limits) + budget.output_bytes = resources['data_output_bytes'] + budget.output_files = resources['data_output_files'] + try: + _atomic_write_json(metrics_path, {'schema': 'wavebench.analysis_metrics.v1', 'metrics': artifact['metrics']}, budget=budget) + _atomic_write_json(manifest_path, manifest, budget=budget) + except AnalysisResourceError as exc: + error = error_envelope(exc, operation='analysis.supervisor_metadata') + if not reason: + manifest.update(status='failed', failed_stage='metadata', error=error) + pipeline.update(status='failed', failed_stage='metadata', error=error) + _atomic_write_json(metrics_path, {'schema': 'wavebench.analysis_metrics.v1', 'metrics': artifact['metrics']}) + _atomic_write_json(manifest_path, manifest) + return artifact diff --git a/src/wavebench/services/analysis_platform.py b/src/wavebench/services/analysis_platform.py new file mode 100644 index 00000000..026a1931 --- /dev/null +++ b/src/wavebench/services/analysis_platform.py @@ -0,0 +1,151 @@ +"""Platform-specific hard memory scopes. Explicit requests never silently degrade.""" +from contextlib import contextmanager +import ctypes +import os +from pathlib import Path +import sys +import time +import uuid + +from wavebench.errors import ConfigError + + +class _NoMemoryScope: + def attach(self, pid): + pass + + def evidence(self): + return {'memory_backend': 'none', 'memory_accounting': 'estimated_working_set'} + + def close(self): + pass + + +class _LinuxMemoryScope: + def __init__(self, policy): + if not policy.cgroup_root: + raise ConfigError('Linux hard memory limit requires a delegated cgroup_root') + root = Path(policy.cgroup_root).resolve() + if not (root / 'cgroup.controllers').is_file(): + raise ConfigError('cgroup_root must be a delegated cgroup v2 directory') + self.path = root / f'wavebench-analysis-{uuid.uuid4().hex}' + self.path.mkdir() + try: + for name in ('memory.max', 'memory.swap.max', 'cgroup.procs', 'cgroup.kill', 'memory.events'): + if not (self.path / name).is_file(): + raise ConfigError(f'cgroup memory backend requires {name}') + (self.path / 'memory.max').write_text(str(policy.memory_bytes), encoding='ascii') + (self.path / 'memory.swap.max').write_text('0', encoding='ascii') + # Opening checks permission without moving any process or killing the group. + for name in ('cgroup.procs', 'cgroup.kill'): + descriptor = os.open(self.path / name, os.O_WRONLY) + os.close(descriptor) + self.limit = policy.memory_bytes + except BaseException: + self.path.rmdir() + raise + + def attach(self, pid): + (self.path / 'cgroup.procs').write_text(str(pid), encoding='ascii') + + def evidence(self): + events = dict(line.split() for line in (self.path / 'memory.events').read_text().splitlines()) + return {'memory_backend': 'linux_cgroup_v2', 'memory_accounting': 'cgroup_memory_swap_disabled', + 'memory_limit_bytes': self.limit, 'oom_kill_count': int(events.get('oom_kill', 0))} + + def close(self): + (self.path / 'cgroup.kill').write_text('1', encoding='ascii') + # Kernel removal may lag task exit briefly. Never remove or alter the delegated parent. + for attempt in range(100): + try: + self.path.rmdir() + return + except OSError: + if attempt == 99: + raise + time.sleep(0.01) + + +class _WindowsMemoryScope: + def __init__(self, policy): + if policy.cgroup_root is not None: + raise ConfigError('cgroup_root is only supported on Linux') + from ctypes import wintypes as w + size = ctypes.c_size_t + + class Basic(ctypes.Structure): + _fields_ = [('process_time', ctypes.c_int64), ('job_time', ctypes.c_int64), + ('flags', w.DWORD), ('working_min', size), ('working_max', size), + ('active_processes', w.DWORD), ('affinity', size), + ('priority', w.DWORD), ('scheduling', w.DWORD)] + + class Extended(ctypes.Structure): + _fields_ = [('basic', Basic), ('io', ctypes.c_uint64 * 6), + ('process_memory', size), ('job_memory', size), + ('peak_process_memory', size), ('peak_job_memory', size)] + + self.api = ctypes.WinDLL('kernel32', use_last_error=True) + declarations = { + 'CreateJobObjectW': ([ctypes.c_void_p, w.LPCWSTR], w.HANDLE), + 'SetInformationJobObject': ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD], w.BOOL), + 'AssignProcessToJobObject': ([w.HANDLE, w.HANDLE], w.BOOL), + 'OpenProcess': ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE), + 'CloseHandle': ([w.HANDLE], w.BOOL), + } + for name, (args, result) in declarations.items(): + function = getattr(self.api, name) + function.argtypes, function.restype = args, result + self.handle = self.api.CreateJobObjectW(None, None) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + limits = Extended() + limits.basic.flags = 0x200 | 0x2000 # JOB_MEMORY | KILL_ON_JOB_CLOSE + limits.job_memory = policy.memory_bytes + if not self.api.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)): + error = ctypes.WinError(ctypes.get_last_error()) + self.close() + raise error + self.limit = policy.memory_bytes + + def attach(self, pid): + handle = self.api.OpenProcess(0x100 | 0x1, False, pid) # SET_QUOTA | TERMINATE + if not handle: + raise ctypes.WinError(ctypes.get_last_error()) + try: + if not self.api.AssignProcessToJobObject(self.handle, handle): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + self.api.CloseHandle(handle) + + def evidence(self): + return {'memory_backend': 'windows_job_object', 'memory_accounting': 'job_committed_memory', + 'memory_limit_bytes': self.limit} + + def close(self): + if self.handle: + handle, self.handle = self.handle, None + if not self.api.CloseHandle(handle): + raise ctypes.WinError(ctypes.get_last_error()) + + +@contextmanager +def memory_scope(policy): + scope = None + try: + if policy.memory_bytes is None: + scope = _NoMemoryScope() + elif sys.platform == 'win32': + scope = _WindowsMemoryScope(policy) + elif sys.platform == 'linux': + scope = _LinuxMemoryScope(policy) + else: + raise ConfigError('hard analysis memory limits are unsupported on this platform') + yield scope + except OSError as exc: + raise ConfigError(f'cannot establish or clean up analysis memory scope: {exc}') from exc + finally: + if scope is not None: + try: + scope.close() + except OSError as exc: + raise ConfigError(f'cannot clean up analysis memory scope: {exc}') from exc diff --git a/src/wavebench/services/analysis_service.py b/src/wavebench/services/analysis_service.py index c3663da5..689b873b 100644 --- a/src/wavebench/services/analysis_service.py +++ b/src/wavebench/services/analysis_service.py @@ -75,7 +75,9 @@ def load_analysis_source(capture: Path, channel: int, resource_limits: AnalysisL }, waveform -def check_analysis(capture: Path, channel: int, recipe: Path, *, resource_limits: AnalysisLimits | None = None) -> dict[str, Any]: +def check_analysis(capture: Path, channel: int, recipe: Path, *, resource_limits: AnalysisLimits | None = None, execution_policy=None) -> dict[str, Any]: + if execution_policy is not None: + execution_policy.preflight() fields = load_analysis_recipe(recipe, resource_limits) limits = (resource_limits or AnalysisLimits()).tighten(fields.get("resources")) source, data = load_analysis_source(capture, channel, limits) @@ -92,10 +94,13 @@ def check_analysis(capture: Path, channel: int, recipe: Path, *, resource_limits cols = 4 if domain == "frequency" else 2 budget.output_bytes += sum(count * cols * (8 if fmt == "npy" else 32) + 1024 for fmt in operation["formats"]) return {"schema": "wavebench.analysis_check.v1", "status": "ok", "source": source, - "samples": len(data.time_s), "recipe": fields, "resources": limits.evidence()} + "samples": len(data.time_s), "recipe": fields, "resources": limits.evidence(), + **({"execution": execution_policy.evidence()} if execution_policy is not None else {})} -def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, resource_limits: AnalysisLimits | None = None) -> dict[str, Any]: +def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, resource_limits: AnalysisLimits | None = None, execution_policy=None, cancel_event=None) -> dict[str, Any]: + if execution_policy is not None: + execution_policy.preflight() fields = load_analysis_recipe(recipe, resource_limits) limits = (resource_limits or AnalysisLimits()).tighten(fields.get("resources")) capture = capture.resolve() @@ -107,11 +112,14 @@ def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, res 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, limits), resource_limits=limits, - schema="wavebench.offline_pipeline.v1", - ) + options = dict(output=output, capture=capture, channel=channel, fields=fields, source=source, limits=limits) + if execution_policy is not None: + from .analysis_execution import supervise + artifact = supervise(_execute_offline, options, policy=execution_policy, run_dir=output, + processing_dir=output, fields=fields, source=source, limits=limits, + schema="wavebench.offline_pipeline.v1", cancel_event=cancel_event) + else: + artifact = _execute_offline(**options) failed = artifact["analysis_pipeline"]["status"] == "failed" if "expect" in artifact: failed = failed or artifact["expect"]["status"] != "ok" @@ -135,3 +143,11 @@ def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, res result["error"] = error_envelope(exc, operation="analysis.result_metadata") _atomic_write_json(output / "analysis.json", result) return result + + +def _execute_offline(*, output, capture, channel, fields, source, limits): + return execute_pipeline( + run_dir=output, processing_dir=output, fields=fields, source=source, + load_source=lambda: load_analysis_source(capture, channel, limits), resource_limits=limits, + schema="wavebench.offline_pipeline.v1", + ) diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 19731994..f07622b2 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -43,14 +43,18 @@ class ExecutionIntent: restore: Mapping[str, Any] intent_digest: str analysis_resources: Mapping[str, Any] | None = None + analysis_execution: Mapping[str, Any] | None = None @property def schema(self) -> str: + if self.analysis_execution is not None: + return "wavebench.execution_intent.v3" return "wavebench.execution_intent.v2" if self.analysis_resources is not None else INTENT_SCHEMA def as_dict(self) -> dict[str, Any]: return { **({"analysis_resources": dict(self.analysis_resources)} if self.analysis_resources is not None else {}), + **({"analysis_execution": dict(self.analysis_execution)} if self.analysis_execution is not None else {}), "schema": self.schema, "intent_digest": self.intent_digest, "plan_digest": self.plan_digest, @@ -62,7 +66,7 @@ def as_dict(self) -> dict[str, Any]: } -def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_limits=None) -> ExecutionIntent: +def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_limits=None, execution_policy=None) -> ExecutionIntent: plan_hash = plan_digest(plan) config_hash = digest(_config_semantics(config)) payloads: list[dict[str, Any]] = [] @@ -118,6 +122,9 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_l resources = resource_limits.evidence() if resource_limits is not None else None if resources is not None: body["analysis_resources"] = resources + execution = execution_policy.evidence() if execution_policy is not None else None + if execution is not None: + body["analysis_execution"] = execution return ExecutionIntent( plan_digest=plan_hash, config_digest=config_hash, @@ -127,6 +134,7 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_l restore=restore, intent_digest=digest(body, length=32), analysis_resources=resources, + analysis_execution=execution, ) @@ -146,7 +154,7 @@ def load_execution_intent(path: str | Path) -> dict[str, Any]: payload = json.loads(intent_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ExecutionIntentError(f"failed to read execution intent: {intent_path}") from exc - if not isinstance(payload, dict) or payload.get("schema") not in {INTENT_SCHEMA, "wavebench.execution_intent.v2"}: + if not isinstance(payload, dict) or payload.get("schema") not in {INTENT_SCHEMA, "wavebench.execution_intent.v2", "wavebench.execution_intent.v3"}: raise ExecutionIntentError( f"execution intent must use schema {INTENT_SCHEMA}: {intent_path}" ) @@ -157,12 +165,13 @@ def verify_execution_intent( expected: Mapping[str, Any], plan: RunPlan, config: WaveBenchConfig, - *, resource_limits=None, + *, resource_limits=None, execution_policy=None, ) -> ExecutionIntent: - current = build_execution_intent(plan, config, resource_limits=resource_limits) + current = build_execution_intent(plan, config, resource_limits=resource_limits, execution_policy=execution_policy) expected_digest = expected.get("intent_digest") if (expected_digest != current.intent_digest or expected.get("schema") != current.schema - or expected.get("analysis_resources") != current.analysis_resources): + or expected.get("analysis_resources") != current.analysis_resources + or expected.get("analysis_execution") != current.analysis_execution): raise ExecutionIntentError( "execution intent does not match the current plan, configuration, or payloads", expected_digest=str(expected_digest) if expected_digest is not None else None, diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py index 25bedeaf..3f1e537d 100644 --- a/src/wavebench/services/run_pipeline.py +++ b/src/wavebench/services/run_pipeline.py @@ -11,6 +11,7 @@ import numpy as np from wavebench.data.analysis_resources import AnalysisBudget, AnalysisLimits, AnalysisResourceError, check_static +from wavebench.data.analysis_control import checkpoint, cancel_signal from wavebench.data.analysis_io import load_waveform, BLOCK_ROWS, read_json_bounded from wavebench.data.signal_pipeline import ( @@ -110,13 +111,23 @@ def execute_analysis_pipeline( source_step: RunStep, source_record: RunStepRecord | None, resource_limits: AnalysisLimits | None = None, + execution_policy=None, cancel_event=None, ) -> dict[str, Any]: limits = (resource_limits or AnalysisLimits()).tighten(step.fields.get("resources")) check_static(step.fields["operations"], limits) processing_dir = run_dir / "processing" / ( f"{step.index:02d}_{step.id or 'analysis_pipeline'}" ) - def load_source() -> tuple[dict[str, Any], np.ndarray]: + if execution_policy is not None: + from .analysis_execution import supervise + return supervise(execute_analysis_pipeline, + dict(run_dir=run_dir, step=step, source_step=source_step, source_record=source_record, + resource_limits=limits), policy=execution_policy, run_dir=run_dir, + processing_dir=processing_dir, fields=step.fields, limits=limits, cancel_event=cancel_event, + source={'step': source_step.id, 'step_index': source_step.index, + 'status': source_record.status if source_record else 'unavailable'}) + + def load_source() -> tuple[dict[str, Any], TimeSignal]: _, details, waveform = _load_source_waveform( run_dir=run_dir, source_step=source_step, source_record=source_record, resource_limits=limits, ) @@ -168,7 +179,22 @@ def execute_pipeline( failure: dict[str, Any] | None = None failed_stage: str | None = None + def save_progress(): + if cancel_signal.get() is None: + return + document = dict(schema=schema, status='running', partial=True, source=source, + operations=operations, stages=stages, sampling=sampling, window=window, + warnings=warnings, exports=exports, peaks=peaks, filters=filters, psd=psd, + transformations=transformations, + metrics=_derived_relative(metrics_path, run_dir), + resources={**limits.evidence(), 'work_units': budget.work_units, + 'data_output_bytes': budget.output_bytes, 'data_output_files': budget.output_files}) + _atomic_write_json(metrics_path, {'schema': ANALYSIS_METRICS_SCHEMA, 'metrics': metrics}) + _atomic_write_json(manifest_path, document) + try: + save_progress() + checkpoint() source_details, waveform = load_source() source.update(source_details) if isinstance(waveform, TimeSignal): @@ -190,6 +216,8 @@ def execute_pipeline( } stages.append(stage) try: + save_progress() + checkpoint() count = len(signal.time_s) if isinstance(signal, TimeSignal) else len(signal.frequency_hz) retained = sum(item.get("retained_count", 0) * 1024 for item in peaks) stage["resources"] = budget.stage(operation, count, domain=_domain(signal), retained_bytes=retained) @@ -359,6 +387,7 @@ def execute_pipeline( ): exported.append(item) exports.append(item) + save_progress() stage["exports"] = [item["path"] for item in exported] else: # pragma: no cover - RunPlan validation owns this invariant raise DataError(f"unsupported analysis operation: {op}") @@ -367,6 +396,7 @@ def execute_pipeline( raise stage["status"] = "ok" stage["output_domain"] = _domain(signal) + save_progress() except Exception as exc: # noqa: BLE001 - analysis failures are step artifacts failed_stage = _failed_stage(stages) failure = error_envelope( @@ -721,6 +751,7 @@ def blocks(): np.lib.format.write_array_header_1_0(file, {"descr": np.dtype(float).str, "fortran_order": False, "shape": shape}) for block in blocks(): + checkpoint() encoded = np.asarray(block, dtype=float, order="C").tobytes() if budget: budget.pending_file(file.tell() + len(encoded)) @@ -745,6 +776,7 @@ def blocks(): writer = csv.writer(file) writer.writerow(columns) for block in blocks(): + checkpoint() # Only this bounded block becomes Python objects; numeric formatting stays unchanged. import io buffer = io.StringIO(newline="") @@ -792,6 +824,7 @@ def _sha256_file(path: Path) -> str: digest = sha256() with path.open("rb") as file: while chunk := file.read(1024 * 1024): + checkpoint() digest.update(chunk) return digest.hexdigest() diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 2470a010..acfab315 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -250,6 +250,8 @@ class RunService: logger: CommandLogger lease_manager: ResourceLeaseManager | None = None analysis_limits: AnalysisLimits | None = None + analysis_execution: Any = None + analysis_cancel_event: Any = None def verify(self, plan: RunPlan) -> list[RunPreflightRecord]: self.check(plan) @@ -311,6 +313,8 @@ def verify(self, plan: RunPlan) -> list[RunPreflightRecord]: return records def check(self, plan: RunPlan) -> None: + if self.analysis_execution is not None: + self.analysis_execution.preflight() from wavebench.data.analysis_resources import AnalysisLimits, check_static for step in plan.steps: @@ -716,9 +720,9 @@ def run( execution_intent: Mapping[str, Any] | None = None, ) -> RunResult: self.check(plan) - intent = build_execution_intent(plan, self.config, resource_limits=self.analysis_limits) + intent = build_execution_intent(plan, self.config, resource_limits=self.analysis_limits, execution_policy=self.analysis_execution) if execution_intent is not None: - intent = verify_execution_intent(execution_intent, plan, self.config, resource_limits=self.analysis_limits) + intent = verify_execution_intent(execution_intent, plan, self.config, resource_limits=self.analysis_limits, execution_policy=self.analysis_execution) 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)] @@ -1117,6 +1121,7 @@ def report_close_errors() -> None: source_step=source_step, source_record=source_record, resource_limits=self.analysis_limits, + execution_policy=self.analysis_execution, cancel_event=self.analysis_cancel_event, ) except Exception as exc: # noqa: BLE001 - preserve offline step failure payload = error_envelope( @@ -1148,7 +1153,8 @@ def report_close_errors() -> None: ) records.append(record) write_step_record(steps_dir, record) - if record.status == "failed" and step.fields.get("on_failure", "stop") == "stop": + cancelled = artifact.get("analysis_pipeline", {}).get("error", {}).get("code") == "analysis_cancelled" + if record.status == "failed" and (cancelled or step.fields.get("on_failure", "stop") == "stop"): analysis_failure = { "type": "StepFailure", "code": "step_failed", diff --git a/tests/test_analysis_execution.py b/tests/test_analysis_execution.py new file mode 100644 index 00000000..ed89a4fd --- /dev/null +++ b/tests/test_analysis_execution.py @@ -0,0 +1,313 @@ +from dataclasses import replace +import json +import multiprocessing +import os +import sys +import threading +import time + +import pytest + +from wavebench.data.analysis_resources import AnalysisLimits +from wavebench.errors import ConfigError +from wavebench.services.analysis_execution import AnalysisExecution, load_analysis_execution, supervise +from wavebench.services.analysis_service import run_analysis +from test_analysis_service import analysis_input as analysis_input + + +def _slow_pipeline(*, output, cooperative=False): + import numpy as np + import wavebench.services.run_pipeline as pipeline + from wavebench.data.analysis_control import checkpoint + + def slow(signal): + (output / 'ready').write_text('ready') + while True: + if cooperative: + checkpoint() + time.sleep(0.01) + pipeline.remove_dc = slow + return pipeline.execute_pipeline(run_dir=output, processing_dir=output, fields=FIELDS, + source={'status': 'ok'}, load_source=lambda: ({}, np.column_stack((np.arange(16.), np.arange(16.))))) + + +FIELDS = {'operations': [{'op': 'export', 'name': 'before', 'formats': ['npy']}, {'op': 'remove_dc'}]} + + +def _crash(): + os._exit(7) + + +def test_execution_profile_strict(tmp_path): + for kwargs in ({'timeout_s': 0}, {'grace_s': True}, {'timeout_s': float('inf')}, + {'memory_bytes': False}, {'memory_bytes': -1}, {'cgroup_root': '/tmp'}): + with pytest.raises(ConfigError): + AnalysisExecution(**kwargs) + path = tmp_path / 'execution.toml' + path.write_text('schema="wavebench.analysis_execution.v1"\ntimeout_s=1.5\n') + assert load_analysis_execution(path).timeout_s == 1.5 + path.write_text('schema="wavebench.analysis_execution.v1"\nunknown=2\n') + with pytest.raises(ConfigError): + load_analysis_execution(path) + + +def test_spawn_offline_matches_inline(tmp_path, analysis_input): + capture, recipe = analysis_input + inline = run_analysis(capture, 1, recipe, tmp_path / 'inline') + spawned = run_analysis(capture, 1, recipe, tmp_path / 'spawn', execution_policy=AnalysisExecution()) + assert spawned['status'] == inline['status'] == 'ok' + assert spawned['artifact']['metrics'] == inline['artifact']['metrics'] + assert spawned['source']['npy_sha256'] == inline['source']['npy_sha256'] + info = spawned['artifact']['analysis_pipeline']['execution'] + assert info['start_method'] == 'spawn' and info['exitcode'] == 0 and not info['forced'] + assert not list(tmp_path.glob('.analysis-control-*')) + + +@pytest.mark.parametrize('cooperative', [False, True]) +def test_cancel_retains_completed_export_and_finalizes(tmp_path, cooperative): + output = tmp_path / 'output' + cancelled = threading.Event() + def request_cancel(): + for _ in range(2000): + if (output / 'ready').exists(): + cancelled.set() + return + time.sleep(0.01) + thread = threading.Thread(target=request_cancel, daemon=True) + thread.start() + artifact = supervise(_slow_pipeline, dict(output=output, cooperative=cooperative), + policy=AnalysisExecution(timeout_s=30, grace_s=0.5), run_dir=output, processing_dir=output, + fields=FIELDS, source={'status': 'ok'}, limits=AnalysisLimits(), cancel_event=cancelled) + thread.join(1) + info = artifact['analysis_pipeline'] + assert info['error']['code'] == 'analysis_cancelled' + assert info['execution']['forced'] is not cooperative + assert info['exports'][0]['path'] == 'exports/before.npy' + assert (output / 'exports/before.npy').exists() + manifest = json.loads((output / 'manifest.json').read_text()) + assert manifest['status'] == 'failed' and manifest['partial'] + assert not list(output.rglob('.*.tmp')) + assert not multiprocessing.active_children() + + +def test_timeout_and_abnormal_exit(tmp_path): + for target, kwargs, code, policy in ( + (_slow_pipeline, {'output': tmp_path / 'timeout'}, 'analysis_timeout', AnalysisExecution(timeout_s=3, grace_s=0.1)), + (_crash, {}, 'analysis_worker_failed', AnalysisExecution()), + ): + output = tmp_path / ('timeout' if target is _slow_pipeline else 'crash') + artifact = supervise(target, kwargs, policy=policy, run_dir=output, processing_dir=output, + fields=FIELDS, source={'status': 'ok'}, limits=AnalysisLimits()) + assert artifact['analysis_pipeline']['error']['code'] == code + assert json.loads((output / 'manifest.json').read_text())['status'] == 'failed' + + +def test_execution_intent_v3_binds_policy(tmp_path): + from wavebench.services.execution_intent import build_execution_intent, verify_execution_intent + from wavebench.services.run_plan import load_run_plan + from test_run_service import make_config + path = tmp_path / 'plan.toml' + path.write_text('[[steps]]\nkind="sleep"\nduration_s=0.01\n') + plan, config = load_run_plan(path), make_config(tmp_path) + policy = AnalysisExecution() + legacy = build_execution_intent(plan, config) + intent = build_execution_intent(plan, config, execution_policy=policy) + assert legacy.schema == 'wavebench.execution_intent.v1' + assert intent.schema == 'wavebench.execution_intent.v3' + verify_execution_intent(intent.as_dict(), plan, config, execution_policy=policy) + with pytest.raises(Exception, match='does not match'): + verify_execution_intent(intent.as_dict(), plan, config, execution_policy=replace(policy, timeout_s=42)) + + +def test_hard_limit_unsupported_before_output(tmp_path, analysis_input): + if sys.platform != 'linux': + pytest.skip('Linux preflight contract') + capture, recipe = analysis_input + with pytest.raises(ConfigError, match='delegated cgroup_root'): + run_analysis(capture, 1, recipe, tmp_path / 'output', execution_policy=AnalysisExecution(memory_bytes=1024**3)) + assert not (tmp_path / 'output').exists() + + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows native Job Object; PR workflow') +def test_windows_native_job_limit(tmp_path, analysis_input): + capture, recipe = analysis_input + result = run_analysis(capture, 1, recipe, tmp_path / 'job', + execution_policy=AnalysisExecution(memory_bytes=2 * 1024**3)) + assert result['status'] == 'ok' + assert result['artifact']['analysis_pipeline']['execution']['memory_backend'] == 'windows_job_object' + + +@pytest.mark.skipif(sys.platform != 'linux' or not os.getenv('WAVEBENCH_TEST_CGROUP_ROOT'), + reason='requires explicitly delegated test cgroup') +def test_linux_native_cgroup(tmp_path, analysis_input): + capture, recipe = analysis_input + result = run_analysis(capture, 1, recipe, tmp_path / 'cgroup', execution_policy=AnalysisExecution( + memory_bytes=1024**3, cgroup_root=os.environ['WAVEBENCH_TEST_CGROUP_ROOT'])) + assert result['status'] == 'ok' + assert result['artifact']['analysis_pipeline']['execution']['memory_backend'] == 'linux_cgroup_v2' + + +def test_spawn_starts_after_hardware_cleanup(tmp_path): + from unittest.mock import patch + from test_run_service_analysis import PhaseRunService, _capture_record, _plan + from test_run_service import make_config + from wavebench.logging import CommandLogger + import wavebench.services.analysis_execution as execution + events = [] + service = PhaseRunService(config=make_config(str(tmp_path)), logger=CommandLogger(), + capture_record=_capture_record(str(tmp_path)), events=events, analysis_execution=AnalysisExecution()) + original = execution.supervise + def tracked(*args, **kwargs): + assert events[-1] == 'session_and_lease_closed' + events.append('spawn') + return original(*args, **kwargs) + with patch.object(execution, 'supervise', side_effect=tracked): + result = service.run(_plan(str(tmp_path))) + assert result.steps[-1].status == 'ok' + assert result.steps[-1].artifact['analysis_pipeline']['execution']['exitcode'] == 0 + assert events.index('session_and_lease_closed') < events.index('spawn') + + +@pytest.mark.parametrize('cancelled', [False, True]) +def test_supervised_failure_continue_and_cancel_stop_suffix(tmp_path, cancelled): + from test_run_service_analysis import PhaseRunService, _capture_record, _plan + from test_run_service import make_config + from wavebench.logging import CommandLogger + plan = _plan(str(tmp_path), two_analyses=True) + plan.steps[1].fields['on_failure'] = 'continue' + cancel = threading.Event() + if cancelled: + cancel.set() + events = [] + service = PhaseRunService(config=make_config(str(tmp_path)), logger=CommandLogger(), + capture_record=_capture_record(str(tmp_path)), events=events, + analysis_execution=AnalysisExecution(timeout_s=0.001, grace_s=0.01), analysis_cancel_event=cancel) + result = service.run(plan) + assert len(result.steps) == (2 if cancelled else 3) + assert result.steps[1].status == 'failed' + assert events.count('hardware:capture_main') == 1 + expected = 'analysis_cancelled' if cancelled else 'analysis_timeout' + assert result.steps[1].artifact['analysis_pipeline']['error']['code'] == expected + + +def test_cgroup_contract_with_controlled_files(tmp_path, monkeypatch): + from pathlib import Path + from wavebench.services.analysis_platform import _LinuxMemoryScope + root = tmp_path / 'delegated' + root.mkdir() + (root / 'cgroup.controllers').write_text('memory') + original_mkdir, original_rmdir = Path.mkdir, Path.rmdir + def mkdir(path, *args, **kwargs): + original_mkdir(path, *args, **kwargs) + if path.parent == root: + for name in ('memory.max', 'memory.swap.max', 'cgroup.procs', 'cgroup.kill'): + (path / name).write_text('') + (path / 'memory.events').write_text('oom 1\noom_kill 1\n') + def rmdir(path): + if path.parent == root: + assert (path / 'cgroup.kill').read_text() == '1' + for file in path.iterdir(): + file.unlink() + original_rmdir(path) + monkeypatch.setattr(Path, 'mkdir', mkdir) + monkeypatch.setattr(Path, 'rmdir', rmdir) + scope = _LinuxMemoryScope(AnalysisExecution(memory_bytes=123456, cgroup_root=str(root))) + assert (scope.path / 'memory.max').read_text() == '123456' + assert (scope.path / 'memory.swap.max').read_text() == '0' + scope.attach(42) + assert (scope.path / 'cgroup.procs').read_text() == '42' + assert scope.evidence()['oom_kill_count'] == 1 + scope.close() + assert list(root.iterdir()) == [root / 'cgroup.controllers'] + + +def test_supervised_metadata_budget_is_not_bypassed(tmp_path, analysis_input): + capture, recipe = analysis_input + result = run_analysis(capture, 1, recipe, tmp_path / 'small', + resource_limits=AnalysisLimits(max_metadata_bytes=1024), execution_policy=AnalysisExecution()) + assert result['status'] == 'failed' + assert result['artifact']['analysis_pipeline']['error']['code'] == 'resource_limit_exceeded' + + +def test_cli_execution_profile(tmp_path, analysis_input): + from wavebench.cli import main + capture, recipe = analysis_input + profile = tmp_path / 'execution.toml' + profile.write_text('schema="wavebench.analysis_execution.v1"\ntimeout_s=10\n') + assert main(['analysis', 'run', '--capture', str(capture), '--channel', '1', + '--recipe', str(recipe), '--output', str(tmp_path / 'cli'), + '--analysis-execution', str(profile)]) == 0 + + +def _allocation_probe(): + bytearray(512 * 1024**2) + raise RuntimeError('allocation unexpectedly exceeded the hard limit') + + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows native memory enforcement; PR workflow') +def test_windows_job_rejects_allocation(tmp_path): + output = tmp_path / 'limit' + policy = AnalysisExecution(memory_bytes=384 * 1024**2) + policy.preflight() + artifact = supervise(_allocation_probe, {}, policy=policy, run_dir=output, + processing_dir=output, fields=FIELDS, source={'status': 'ok'}, limits=AnalysisLimits()) + error = artifact['analysis_pipeline']['error'] + assert error['code'] == 'analysis_worker_failed' + assert error['cause']['type'] == 'MemoryError' + + +def test_r2_segment_cancellation_before_next_backend_call(): + import numpy as np + from unittest.mock import patch + from wavebench.data.analysis_control import cancel_signal, AnalysisCancelled + from wavebench.data.signal_pipeline import validate_waveform, welch_psd + from scipy.signal import welch + signal = validate_waveform(np.column_stack((np.arange(1000.), np.ones(1000)))) + class CancelAfterTwoSegments: + calls = 0 + def is_set(self): + self.calls += 1 + return self.calls > 2 + token = cancel_signal.set(CancelAfterTwoSegments()) + try: + with patch('scipy.signal.welch', wraps=welch) as backend: + with pytest.raises(AnalysisCancelled): + welch_psd(signal, method='welch', window='hann', nperseg=32, + noverlap=16, nfft=32, detrend='none', average='mean') + assert backend.call_count == 2 + finally: + cancel_signal.reset(token) + + +def test_hard_limit_check_precedes_hardware(tmp_path): + if sys.platform != 'linux': + pytest.skip('Linux delegation preflight') + from unittest.mock import patch + from wavebench.services.run_service import RunService + from wavebench.logging import CommandLogger + from test_run_service_analysis import _plan + from test_run_service import make_config + service = RunService(make_config(str(tmp_path)), CommandLogger(), + analysis_execution=AnalysisExecution(memory_bytes=1024**3)) + with patch.object(service, '_run_instrument_services', side_effect=AssertionError('hardware reached')): + with pytest.raises(ConfigError, match='delegated cgroup_root'): + service.run(_plan(str(tmp_path))) + + +def test_worker_start_failure_has_terminal_artifact(tmp_path): + output = tmp_path / 'unpicklable' + artifact = supervise(lambda: None, {}, policy=AnalysisExecution(), run_dir=output, + processing_dir=output, fields=FIELDS, source={'status': 'ok'}, limits=AnalysisLimits()) + assert artifact['analysis_pipeline']['error']['code'] == 'analysis_worker_failed' + assert json.loads((output / 'manifest.json').read_text())['status'] == 'failed' + + +def test_existing_output_is_not_cleaned(tmp_path): + output = tmp_path / 'existing' + output.mkdir() + marker = output / '.do-not-touch.tmp' + marker.write_text('existing') + with pytest.raises(ConfigError, match='new directory'): + supervise(_crash, {}, policy=AnalysisExecution(), run_dir=output, + processing_dir=output, fields=FIELDS, source={'status': 'ok'}, limits=AnalysisLimits()) + assert marker.read_text() == 'existing' From de745d86720beb64f73d52409f7e816065ff99d9 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:34:41 +0800 Subject: [PATCH 21/30] docs(analysis): describe supervised execution and platform validation --- docs/reference/artifacts.md | 7 +++++++ docs/reference/run-schema.md | 19 +++++++++++++++++++ plans/README.md | 2 ++ plans/example_analysis_execution.toml | 10 ++++++++++ 4 files changed, 38 insertions(+) create mode 100644 plans/example_analysis_execution.toml diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index e9911c2e..511442c3 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -26,6 +26,13 @@ NPY 来源先检查有界 header、实数 dtype、二维形状和文件长度, 本页说明 `run plan` 写入的运行产物入口。字段的 machine source 是 `src/wavebench/services/run_artifacts.py` 和对应的 typed result;不要从旧 Guide 推断新增或可选字段。 + +显式 `--analysis-execution` 使用 `wavebench.execution_intent.v3`,以 `analysis_execution` 绑定规范化监督配置;同时指定资源文件时继续包含 `analysis_resources`。无执行配置时继续使用 v1/v2。估算器从 `conservative.v1` 升为 `conservative.v2` 后,绑定旧估算器的显式资源 intent 需要重新生成。 + +监督模式在 manifest 与 step artifact 中记录 `execution`:配置、启动方式、实际内存后端与口径、退出码、耗时、触发原因及是否强制终止。结构化错误使用 `analysis_cancelled`、`analysis_timeout`、`analysis_worker_failed`,外层状态仍为 `failed`。Linux 有明确证据时记录 `oom_kill_count`;未知退出不推断成 OOM。报告展示监督结果、超时值、内存后端及强制终止标记。 + +每个阶段和完成的导出保存检查点。强制终止后,父进程以最后一个完整检查点恢复指标及导出索引,保留所有已原子提交的数据文件;`committed_files` 列出终止时实际保留的文件,包含尚未来得及更新导出索引的文件。仅清理当前分析目录内的临时文件。检查点不代表阶段成功;运行中的阶段在终态中改为失败。检查点和最终元数据的磁盘占用按当前文件计,不按反复写入的累计流量计;失败诊断沿用配额外尽力保存规则。 + ## 输出 成功或失败的 run 在写入运行目录后会产生以下文件: diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index 2ec3cdad..cdfa19d6 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -4,6 +4,25 @@ `analysis` 命令直接处理历史 capture package,不需要仪器配置。显式选择一个通道,配方包含 `schema = "wavebench.analysis_recipe.v1"`、`operations` 和可选 `[expect]`/`[resources]`,共用下文的算子与验收合同。示例为 `plans/example_analysis_recipe.toml`。 +## 分析进程监督 + +本节为开发分支已实现、尚未发布的执行合同。`analysis check/run` 与 `run check/intent/verify/plan` 接受 `--analysis-execution `,文件使用 `wavebench.analysis_execution.v1`。示例见 `plans/example_analysis_execution.toml`。 + +| 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `timeout_s` | 300 秒 | 单条分析的墙钟超时,包含启动与读取 | +| `grace_s` | 2 秒 | 请求取消后等待协作退出的时间 | +| `memory_bytes` | 不设置 | 可选的平台硬内存限额 | +| `cgroup_root` | 不设置 | Linux 硬限额所需的已委派 cgroup v2 目录 | + +时间必须为有限正数,硬限额必须为 1~`2^63-1` 的整数;未知字段拒绝。执行配置属于环境,不加入 RunPlan step 或分析配方。未指定文件时保持同进程执行,R0 预算仍然有效。`check` 检查配置和平台能力,实际超时监督只作用于 `run`/`plan` 的分析阶段。 + +指定文件后,每条分析链在独立 `spawn` 子进程执行。RunPlan 在硬件恢复、会话关闭和租约释放后启动分析,不传递仪器句柄。Ctrl+C 或 Service 的取消事件先请求协作退出,超过宽限期后 terminate,仍未退出时 kill;父进程确认退出后整理产物。普通失败和超时按 `on_failure` 决定后续分析,用户取消停止整个分析后缀。数值块、读写块与算子边界可协作取消;单次不可中断的原生调用依靠进程终止处理。 + +Windows 硬限额使用 Job Object 的 job committed memory;Linux 使用 cgroup v2 的 `memory.max` 并设置 `memory.swap.max=0`,要求 memory controller 和 `cgroup.kill` 可用。两者统计口径不同,不称为等价的 RSS 配额。硬限额请求在预检时创建临时作用域并验证测试进程绑定,失败就拒绝;不自动提权或降级。父进程和绑定前的启动阶段不受该分析硬限额保护,分析进程也不是不可信代码沙箱。 + +本地验证仅覆盖 Linux。Windows 原生 Job Object 测试由后续 PR 的既有 Windows Python 3.11/3.12 workflow 执行;真实 Linux cgroup 集成测试要求显式提供 `WAVEBENCH_TEST_CGROUP_ROOT`,无可写委派时跳过。平台测试未通过前,不把实现状态写作跨平台验收通过。 + ## 分析资源预算 本节为开发分支已实现、尚未发布的资源合同。分析使用有限的默认预算;超限时拒绝执行,不自动降低 taps、FFT 长度或采样率。旧的极大配方可能因此失败,正常预算内的数值参数与结果保持原样。 diff --git a/plans/README.md b/plans/README.md index 588c305c..2e77e644 100644 --- a/plans/README.md +++ b/plans/README.md @@ -113,3 +113,5 @@ off_power_channels = [1] 安全门触发后会先对列出的信号源和电源通道执行 OFF,再停止 run;即使该 step 声明 `on_failure = "continue"` 也不会绕过安全门。若同时启用 source restore,恢复配置后会再次确认这些授权通道为 OFF,避免恢复操作重新打开输出。OFF 操作的结果、失败原因和授权通道会写入该 step 的 artifact 与 `run.json`。没有声明 OFF 目标时,安全门会拒绝继续并保留失败证据;它不会猜测或自动开启其他输出。 公开计划应使用保留地址、占位符和相对路径;不要把真实 IP、序列号、串口路径或 `data/` 下的实验产物写进仓库。 + +资源与执行环境配置可组合使用:`example_analysis_resources.toml` 设置预算,`example_analysis_execution.toml` 启用独立分析进程、超时和可选硬内存限制。两者都不是 RunPlan,不放入 `--plan`。 diff --git a/plans/example_analysis_execution.toml b/plans/example_analysis_execution.toml new file mode 100644 index 00000000..ae38a377 --- /dev/null +++ b/plans/example_analysis_execution.toml @@ -0,0 +1,10 @@ +# Execution profile, not a RunPlan or an analysis recipe. +# Use --analysis-execution with analysis check/run or run check/intent/verify/plan. +schema = "wavebench.analysis_execution.v1" +timeout_s = 300.0 +grace_s = 2.0 + +# Optional hard limit; unsupported environments fail preflight. +# Windows uses a Job Object. Linux also requires a delegated cgroup v2 root. +# memory_bytes = 1073741824 +# cgroup_root = "/path/to/delegated/cgroup" From 71e4cecd13999673cf187dfd29d4fd8cbf2740d7 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:09:52 +0800 Subject: [PATCH 22/30] feat(analysis): add spectral quality, resumable batches and evidence-gated pair analysis --- docs/reference/generated/run-schema.md | 10 +- src/wavebench/cli.py | 15 ++ src/wavebench/cli_parser.py | 14 ++ src/wavebench/data/analysis_resources.py | 7 +- src/wavebench/data/pair_analysis.py | 255 +++++++++++++++++++ src/wavebench/data/spectral_quality.py | 183 ++++++++++++++ src/wavebench/report/analysis.py | 99 +++++++- src/wavebench/report/html.py | 8 +- src/wavebench/services/analysis_batch.py | 225 +++++++++++++++++ src/wavebench/services/analysis_service.py | 11 +- src/wavebench/services/operation_specs.py | 1 + src/wavebench/services/pair_service.py | 278 +++++++++++++++++++++ src/wavebench/services/run_pipeline.py | 13 +- src/wavebench/services/run_plan.py | 42 +++- src/wavebench/services/run_safety.py | 1 + src/wavebench/services/run_service.py | 14 +- tests/test_analysis_batch.py | 107 ++++++++ tests/test_pair_analysis.py | 205 +++++++++++++++ tests/test_spectral_quality.py | 85 +++++++ 19 files changed, 1545 insertions(+), 28 deletions(-) create mode 100644 src/wavebench/data/pair_analysis.py create mode 100644 src/wavebench/data/spectral_quality.py create mode 100644 src/wavebench/services/analysis_batch.py create mode 100644 src/wavebench/services/pair_service.py create mode 100644 tests/test_analysis_batch.py create mode 100644 tests/test_pair_analysis.py create mode 100644 tests/test_spectral_quality.py diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md index 6e587e78..39b9238e 100644 --- a/docs/reference/generated/run-schema.md +++ b/docs/reference/generated/run-schema.md @@ -15,6 +15,10 @@ Top-level tables: [[steps]] optional structural field: id matching ^[a-z][a-z0-9_-]{0,63}$ Supported step kinds: + - analysis.pair + required: source, reference_channel, response_channel, operations + optional : expect, on_failure, resources + note : Analyze two evidence-validated channels from one earlier capture package after hardware cleanup. Currently accepts synthetic synchronization evidence only; real driver adaptation is not supported. - analysis.pipeline required: source, operations optional : expect, on_failure, resources @@ -250,10 +254,14 @@ analysis.pipeline metrics: 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 _. +analysis.pair: reference_channel and response_channel must be distinct; source uses one earlier scope.capture with explicit save_npy=true. + Pair operations: delay (integer lag), transfer (mean Welch H1/coherence), export. Only synthetic synchronization evidence is currently accepted. + spectral_quality requires explicit integration bands, fundamental mode, harmonic orders, detection thresholds and metrics; only mean Welch PSD is accepted. + Quality metrics: snr_db, sinad_db, sfdr_db, thdn_ratio, fundamental_frequency_hz, fundamental_power_v2, harmonic_power_v2, noise_power_v2, noise_bandwidth_hz, spur_frequency_hz, spur_power_v2, spur_dbc. 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, measure_band or peaks may follow psd; at least one PSD result is required. + Requires time data before window or fft. Only export, measure_band, spectral_quality 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/src/wavebench/cli.py b/src/wavebench/cli.py index 6e80ddcd..fdc280f0 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -1086,6 +1086,21 @@ def _main(argv: list[str] | None = None) -> int: from .data.analysis_resources import load_resource_limits limits = load_resource_limits(args.analysis_resources) + if args.command in {"pair-check", "pair-run"}: + from .services.pair_service import pair_check, pair_run + from .services.analysis_execution import load_analysis_execution + options = dict(capture=args.capture, recipe=args.recipe, resource_limits=limits, + execution_policy=load_analysis_execution(args.analysis_execution)) + result = pair_run(**options, output=args.output) if args.command == "pair-run" else pair_check(**options) + print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False)) + return 0 if result["status"] == "ok" else 1 + if args.command == "batch": + from .services.analysis_batch import run_batch + from .services.analysis_execution import load_analysis_execution + result = run_batch(args.manifest, args.output, resume=args.resume, resource_limits=limits, + execution_policy=load_analysis_execution(args.analysis_execution)) + print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False)) + return 0 if result["status"] == "ok" else 1 if args.command == "report": from .report.analysis import write_analysis_report diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 59efe10d..76bdc7c6 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -45,6 +45,20 @@ 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) + for command in ("pair-check", "pair-run"): + pair_command = analysis_sub.add_parser(command, help="Evidence-gated two-channel offline analysis") + pair_command.add_argument("--capture", required=True) + pair_command.add_argument("--recipe", required=True) + pair_command.add_argument("--analysis-resources") + pair_command.add_argument("--analysis-execution") + if command == "pair-run": + pair_command.add_argument("--output", required=True) + analysis_batch = analysis_sub.add_parser("batch", help="Run or resume an explicit serial analysis batch") + analysis_batch.add_argument("--manifest", required=True) + analysis_batch.add_argument("--output", required=True) + analysis_batch.add_argument("--resume", action="store_true") + analysis_batch.add_argument("--analysis-resources") + analysis_batch.add_argument("--analysis-execution") 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) diff --git a/src/wavebench/data/analysis_resources.py b/src/wavebench/data/analysis_resources.py index 863a3cd5..57997168 100644 --- a/src/wavebench/data/analysis_resources.py +++ b/src/wavebench/data/analysis_resources.py @@ -140,12 +140,15 @@ def stage(self, operation: dict, count: int, *, domain: str = "time", retained_b taps = 20 * max(operation["up"], operation["down"]) + 1 memory += 64 * out + 32 * taps work = out * (taps // operation["up"] + 1) - elif op == "peaks": + elif op in {"peaks", "spectral_quality"}: # Worst case admission before find_peaks allocates any candidates or properties. - candidates = (count // 2) * (2 if operation["polarity"] == "both" else 1) + candidates = (count // 2) * (2 if operation.get("polarity") == "both" else 1) self.limits.check("max_peak_candidates", candidates, op) memory += 1024 * candidates work = count * max(1, count.bit_length()) + if op == "spectral_quality": + # Explicit per-candidate integration masks are linear in spectrum length. + work += count * (candidates + len(operation['harmonic_orders']) + 8) elif op == "export": cols = 4 if domain == "frequency" else 2 expected = sum(count * cols * (8 if fmt == "npy" else 32) + 1024 diff --git a/src/wavebench/data/pair_analysis.py b/src/wavebench/data/pair_analysis.py new file mode 100644 index 00000000..d6ea3bd8 --- /dev/null +++ b/src/wavebench/data/pair_analysis.py @@ -0,0 +1,255 @@ +"""Evidence-gated pair estimators; positive delay means response arrives later.""" +from dataclasses import dataclass +import numpy as np + +from wavebench.data.analysis_control import checkpoint +from wavebench.data.pipeline_operations import integer, number, result_name +from wavebench.data.signal_pipeline import _uniform_sample_interval, normalize_psd_parameters +from wavebench.errors import DataError + + +DELAY_METRICS = {'response_delay_samples', 'response_delay_s', 'correlation', 'polarity', 'overlap_samples'} +TRANSFER_METRICS = {'mean_coherence', 'min_coherence', 'valid_bin_count', 'coherent_bin_count'} +PAIR_COLUMNS = ['frequency_hz', 'real_v', 'imaginary_v', 'gain_db', 'phase_rad', 'coherence', 'valid', 'coherent'] + + +def normalize_pair_operations(fields): + operations = fields.get('operations') + if not isinstance(operations, list) or not operations: + raise DataError('pair operations must be a nonempty array') + normalized, measured, names, exports = [], set(), set(), set() + seen_transfer = seen_delay = False + for raw in operations: + if not isinstance(raw, dict): + raise DataError('pair operation must be a table') + op = raw.get('op') + if op == 'delay': + required = {'op', 'name', 'max_lag_s', 'remove_mean', 'polarity', 'min_overlap_ratio', 'min_correlation', 'ambiguity_delta', 'metrics'} + if set(raw) != required or seen_delay or seen_transfer: + raise DataError('delay requires documented fields, appears once before transfer') + seen_delay = True + out = dict(raw, name=result_name(raw['name'])) + if type(raw['remove_mean']) is not bool or raw['polarity'] not in ('same', 'either'): + raise DataError('delay requires explicit boolean remove_mean and same/either polarity') + for key in ('max_lag_s', 'min_overlap_ratio', 'min_correlation', 'ambiguity_delta'): + out[key] = number(raw[key], key) + if not 0 < out['min_overlap_ratio'] <= 1 or not 0 < out['min_correlation'] <= 1 or not 0 <= out['ambiguity_delta'] < 1: + raise DataError('delay overlap/correlation/ambiguity thresholds outside [0,1]') + metrics = DELAY_METRICS + elif op == 'transfer': + required = {'op', 'name', 'window', 'nperseg', 'noverlap', 'nfft', 'detrend', 'min_reference_density', + 'min_response_density', 'min_coherence', 'unwrap_phase', 'metrics'} + if set(raw) != required or seen_transfer: + raise DataError('transfer requires documented fields and appears once') + seen_transfer = True + out = dict(raw, name=result_name(raw['name'])) + params = normalize_psd_parameters(method='welch', average='mean', **{key: raw[key] for key in ('window', 'nperseg', 'noverlap', 'nfft', 'detrend')}) + out.update({key: value for key, value in params.items() if key not in ('method', 'average')}) + for key in ('min_reference_density', 'min_response_density', 'min_coherence'): + out[key] = number(raw[key], key) + if out['min_reference_density'] <= 0 or out['min_response_density'] <= 0 or not 0 <= out['min_coherence'] <= 1 or type(raw['unwrap_phase']) is not bool: + raise DataError('transfer requires positive density gates, coherence in [0,1], and boolean unwrap_phase') + metrics = TRANSFER_METRICS + elif op == 'export': + if set(raw) != {'op', 'name', 'formats'} or not seen_transfer: + raise DataError('pair export must follow transfer') + name = result_name(raw['name']) + if name in exports or not isinstance(raw['formats'], list) or not raw['formats'] or any(fmt not in ('npy', 'csv') for fmt in raw['formats']) or len(set(raw['formats'])) != len(raw['formats']): + raise DataError('pair export names and formats must be unique npy/csv') + exports.add(name) + normalized.append(dict(raw)) + continue + else: + raise DataError('pair supports delay, transfer and export') + chosen = raw['metrics'] + if not isinstance(chosen, list) or not chosen or any(not isinstance(m, str) or m not in metrics for m in chosen) or len(set(chosen)) != len(chosen): + raise DataError('pair metrics must select distinct documented names') + if out['name'] in names: + raise DataError('pair measurement names must be unique') + names.add(out['name']) + measured.update(f"{out['name']}_{metric}" for metric in chosen) + normalized.append(out) + if not (seen_delay or seen_transfer): + raise DataError('pair requires delay or transfer') + if set(fields.get('expect', {})) - measured: + raise DataError('pair expect must select measured metrics') + return normalized + + +def check_pair_static(operations, limits, metadata_files=2): + limits.check('max_operations', len(operations), 'pair plan') + limits.check('max_output_files', metadata_files + sum(len(op['formats']) for op in operations if op['op'] == 'export'), 'pair plan') + for op in operations: + if op['op'] == 'transfer': + limits.check('max_fft_length', op['nfft'], 'pair plan') + + +def validate_sync(reference, response, evidence, channels): + if not isinstance(evidence, dict) or evidence.get('schema') != 'wavebench.capture_sync.v1': + raise DataError('pair source requires wavebench.capture_sync.v1 synchronization evidence') + if evidence.get('kind') != 'synthetic': + raise DataError('driver synchronization evidence is not yet supported; synthetic evidence only') + if evidence.get('status') != 'verified': + raise DataError('synchronization evidence must be verified') + producer, group, guarantees = evidence.get('producer', {}), evidence.get('acquisition_group', {}), evidence.get('guarantees', {}) + if (not isinstance(producer, dict) or not all(isinstance(producer.get(k), str) and producer[k] for k in ('name', 'version')) + or not isinstance(group, dict) or group.get('source') != 'synthetic' or not isinstance(group.get('id'), str) or not group['id'] + or not isinstance(guarantees, dict) or guarantees.get('single_record') is not True or guarantees.get('frozen_read') is not True + or any(not isinstance(evidence.get(k), str) or not evidence[k] for k in ('timebase_id', 'record_id'))): + raise DataError('incomplete synchronization provenance, timebase or acquisition guarantee') + if len(reference.time_s) != len(response.time_s): + raise DataError('pair sample counts differ; automatic alignment is not supported') + dt = _uniform_sample_interval(reference, 'pair reference') + other_dt = _uniform_sample_interval(response, 'pair response') + tolerance = dt * 1e-6 + if abs(dt - other_dt) > tolerance: + raise DataError('pair sample intervals differ') + channel_evidence = evidence.get('channels', {}) + if not isinstance(channel_evidence, dict): + raise DataError('synchronization channel evidence must be a table') + for channel, signal in zip(channels, (reference, response)): + item = channel_evidence.get(str(channel), {}) + if not isinstance(item, dict) or set(item) != {'time_start_s', 'sample_interval_s', 'samples', 'skew_s', 'uncertainty_s'}: + raise DataError('incomplete channel synchronization evidence') + if integer(item['samples'], 'sync samples', 1, 2**63-1) != len(signal.time_s): + raise DataError('synchronization sample count disagrees with NPY') + for key in ('time_start_s', 'sample_interval_s', 'skew_s', 'uncertainty_s'): + if type(item[key]) not in (int, float) or not np.isfinite(item[key]): + raise DataError('synchronization values must be finite') + if item['uncertainty_s'] < 0 or abs(item['sample_interval_s'] - dt) > tolerance or abs(item['time_start_s'] - signal.time_s[0]) > tolerance: + raise DataError('synchronization time origin, interval or uncertainty disagrees with NPY') + for start in range(0, len(reference.time_s), 4096): + checkpoint() + if np.any(np.abs(reference.time_s[start:start+4096] - response.time_s[start:start+4096]) > tolerance): + raise DataError('pair time axes differ beyond dt * 1e-6') + return {'samples': len(reference.time_s), 'sample_interval_s': dt, 'axis_atol_s': tolerance, + 'evidence_kind': 'synthetic', 'delay_scope': 'measurement_chain_uncalibrated'} + + +def delay_estimate(x, y, operation, budget): + from scipy.signal import correlate + n = len(x.time_s) + dt = _uniform_sample_interval(x, 'pair delay') + max_lag = int(min(n - 1, np.floor(operation['max_lag_s'] / dt))) + effective_lag = min(max_lag, int(np.floor(n*(1-operation['min_overlap_ratio'])))) + budget.limits.check('max_peak_candidates', 2*effective_lag+1, 'pair delay candidates') + fft_length = 1 << (2*n - 2).bit_length() + budget.limits.check('max_fft_length', fft_length, 'pair correlation') + budget.limits.check('max_working_bytes', 192*n + 64*fft_length, 'pair correlation') + budget.limits.check('max_working_bytes', 192*n + 64*fft_length + 256*(2*effective_lag+1), 'pair candidates') + work = 24*fft_length*fft_length.bit_length() + budget.limits.check('max_work_units', budget.work_units + work, 'pair correlation') + budget.work_units += work + values = {key: None for key in DELAY_METRICS} + evidence = {'schema': 'wavebench.pair_delay.v1', 'parameters': operation, 'searched_lag_samples': max_lag, + 'positive_delay': 'response_later', 'warnings': []} + def finish(): + return {f"{operation['name']}_{key}": values[key] for key in operation['metrics']}, evidence + if np.var(x.voltage_v) == 0 or np.var(y.voltage_v) == 0: + evidence['warnings'].append('zero variance; delay unavailable') + return finish() + xv = x.voltage_v - np.mean(x.voltage_v) if operation['remove_mean'] else x.voltage_v + yv = y.voltage_v - np.mean(y.voltage_v) if operation['remove_mean'] else y.voltage_v + cross = correlate(yv, xv, mode='full', method='fft') + ex, ey = np.r_[0., np.cumsum(xv*xv)], np.r_[0., np.cumsum(yv*yv)] + candidates = [] + for lag in range(-effective_lag, effective_lag+1): + if lag % 4096 == 0: + checkpoint() + overlap = n-abs(lag) + if overlap < n*operation['min_overlap_ratio']: + continue + lx, ly = max(0, -lag), max(0, lag) + energy = (ex[lx+overlap]-ex[lx])*(ey[ly+overlap]-ey[ly]) + if energy <= 0 or not np.isfinite(energy): + continue + coefficient = float(cross[n-1+lag]/np.sqrt(energy)) + if abs(coefficient) > 1 + 1e-9 or not np.isfinite(coefficient): + raise DataError('invalid normalized correlation') + coefficient = float(np.clip(coefficient, -1, 1)) + score = abs(coefficient) if operation['polarity'] == 'either' else coefficient + candidates.append((score, lag, coefficient, overlap)) + budget.limits.check('max_peak_candidates', len(candidates), 'pair delay candidates') + candidates.sort(reverse=True) + if not candidates or candidates[0][0] < operation['min_correlation']: + evidence['warnings'].append('no lag passes correlation/overlap gates') + return finish() + best = candidates[0] + second = candidates[1] if len(candidates) > 1 else None + evidence.update(best_score=best[0], second_score=second[0] if second else None) + if second and best[0] - second[0] <= operation['ambiguity_delta']: + evidence['warnings'].append('ambiguous correlation peaks; delay unavailable') + return finish() + values.update(response_delay_samples=best[1], response_delay_s=float(best[1]*dt), correlation=best[2], + polarity=1 if best[2] >= 0 else -1, overlap_samples=best[3]) + return finish() + + +@dataclass +class PairSpectrum: + data: np.ndarray + evidence: dict + metrics: dict + + +def transfer_estimate(x, y, op, budget): + from scipy.signal import get_window, detrend + from scipy.fft import rfft, rfftfreq + n, m, f = len(x.time_s), op['nperseg'], op['nfft'] + hop = m-op['noverlap'] + k = 1+(n-m)//hop + if k < 2: + raise DataError('pair transfer requires at least two complete Welch segments') + budget.stage(dict(op='psd', average='mean', nperseg=m, noverlap=op['noverlap'], nfft=f), n*2) + budget.limits.check('max_working_bytes', 128*n + 256*f + 128*m, 'pair transfer') + dt = _uniform_sample_interval(x, 'pair transfer') + window = get_window(op['window'], m, fftbins=True) + scale = dt/np.sum(window*window) + sxx, syy, sxy = np.zeros(f//2+1), np.zeros(f//2+1), np.zeros(f//2+1, complex) + for start in range(0, n-m+1, hop): + checkpoint() + a, b = x.voltage_v[start:start+m], y.voltage_v[start:start+m] + if op['detrend'] != 'none': + a, b = detrend(a, type=op['detrend']), detrend(b, type=op['detrend']) + X, Y = rfft(a*window, n=f), rfft(b*window, n=f) + sxx += np.abs(X)**2 + syy += np.abs(Y)**2 + sxy += np.conj(X)*Y + factor = np.full(len(sxx), 2*scale/k) + factor[0] /= 2 + if f % 2 == 0: + factor[-1] /= 2 + sxx *= factor + syy *= factor + sxy *= factor + if not all(np.all(np.isfinite(a)) for a in (sxx, syy, sxy)): + raise DataError('pair spectral overflow') + valid = (sxx >= op['min_reference_density']) & (syy >= op['min_response_density']) + h = np.full(len(sxx), complex(np.nan, np.nan)) + c = np.full(len(sxx), np.nan) + h[valid] = sxy[valid]/sxx[valid] + c[valid] = (np.abs(sxy[valid])/np.sqrt(sxx[valid])/np.sqrt(syy[valid]))**2 + if np.any(c[valid] > 1+1e-9) or not np.all(np.isfinite(c[valid])): + raise DataError('pair coherence outside rounding tolerance') + c[valid] = np.clip(c[valid], 0, 1) + valid &= np.isfinite(h) & (np.abs(h) > 0) + phase, gain = np.full(len(sxx), np.nan), np.full(len(sxx), np.nan) + phase[valid], gain[valid] = np.angle(h[valid]), 20*np.log10(np.abs(h[valid])) + if op['unwrap_phase']: + indices = np.flatnonzero(valid) + for part in np.split(indices, np.flatnonzero(np.diff(indices)>1)+1): + if len(part): + phase[part] = np.unwrap(phase[part]) + h[~valid], c[~valid] = complex(np.nan, np.nan), np.nan + coherent = valid & (c >= op['min_coherence']) + raw_metrics = {'valid_bin_count': int(valid.sum()), 'coherent_bin_count': int(coherent.sum()), + 'mean_coherence': float(np.mean(c[valid])) if valid.any() else None, + 'min_coherence': float(np.min(c[valid])) if valid.any() else None} + evidence = {'schema': 'wavebench.pair_transfer.v1', 'parameters': op, 'segments': k, + 'discarded_tail_samples': (n-m)%hop, 'direction': 'conj(reference_fft)*response_fft', + 'bin_spacing_hz': 1/dt/f, 'coherence_clip_atol': 1e-9, + 'warnings': ['weak excitation bins masked'] if not valid.all() else []} + if np.any(valid & ~coherent): + evidence['warnings'].append('low coherence bins retained with quality mask') + data = np.column_stack((rfftfreq(f, dt), h.real, h.imag, gain, phase, c, valid, coherent)) + return PairSpectrum(data, evidence, {f"{op['name']}_{key}": raw_metrics[key] for key in op['metrics']}) diff --git a/src/wavebench/data/spectral_quality.py b/src/wavebench/data/spectral_quality.py new file mode 100644 index 00000000..9d2949c3 --- /dev/null +++ b/src/wavebench/data/spectral_quality.py @@ -0,0 +1,183 @@ +"""Bandwidth-limited PSD integral estimates; never instrument-standard automatic SNR.""" +import numpy as np + +from wavebench.data.analysis_control import checkpoint +from wavebench.data.pipeline_operations import integer, interval, number, result_name +from wavebench.errors import DataError + + +QUALITY_METRICS = {'snr_db', 'sinad_db', 'sfdr_db', 'thdn_ratio', 'fundamental_frequency_hz', + 'fundamental_power_v2', 'harmonic_power_v2', 'noise_power_v2', + 'noise_bandwidth_hz', 'spur_frequency_hz', 'spur_power_v2', 'spur_dbc'} +QUALITY_FIELDS = {'op', 'name', 'metrics', 'band_hz', 'dc_exclude_hz', 'exclude_hz', 'fundamental', + 'fundamental_half_width_hz', 'harmonic_orders', 'harmonic_half_width_hz', + 'min_fundamental_v2', 'min_peak_density_v2_per_hz', 'min_prominence_v2_per_hz', + 'min_noise_bins', 'spur_search_hz', 'spur_half_width_hz', 'spur_distance_hz', + 'spur_min_density_v2_per_hz', 'spur_min_prominence_v2_per_hz'} + + +def normalize_quality(raw): + if not isinstance(raw, dict) or set(raw) != QUALITY_FIELDS: + raise DataError('spectral_quality requires exactly the documented fields') + out = dict(raw, name=result_name(raw['name'])) + metrics = raw['metrics'] + if (not isinstance(metrics, list) or not metrics or any(not isinstance(m, str) or m not in QUALITY_METRICS for m in metrics) + or len(set(metrics)) != len(metrics)): + raise DataError('spectral_quality metrics must be distinct documented names') + for key in ('band_hz', 'dc_exclude_hz', 'spur_search_hz'): + out[key] = interval(raw[key], key) + if out['dc_exclude_hz'][0] != 0: + raise DataError('dc_exclude_hz must start at zero') + if not isinstance(raw['exclude_hz'], list) or len(raw['exclude_hz']) > 32: + raise DataError('exclude_hz accepts at most 32 intervals') + out['exclude_hz'] = [interval(item, 'exclude_hz') for item in raw['exclude_hz']] + if any(lo < out['band_hz'][0] or hi > out['band_hz'][1] for lo, hi in out['exclude_hz'] + [out['spur_search_hz']]): + raise DataError('exclusions and spur search must lie inside band_hz') + fundamental = raw['fundamental'] + if not isinstance(fundamental, dict) or set(fundamental) not in ({'frequency_hz'}, {'search_hz'}): + raise DataError('fundamental selects frequency_hz or search_hz exclusively') + out['fundamental'] = ({'frequency_hz': number(fundamental['frequency_hz'], 'frequency_hz')} + if 'frequency_hz' in fundamental else {'search_hz': interval(fundamental['search_hz'], 'search_hz')}) + for key in ('fundamental_half_width_hz', 'harmonic_half_width_hz', 'min_fundamental_v2', + 'min_peak_density_v2_per_hz', 'min_prominence_v2_per_hz', 'spur_half_width_hz', + 'spur_distance_hz', 'spur_min_density_v2_per_hz', 'spur_min_prominence_v2_per_hz'): + out[key] = number(raw[key], key) + if out[key] <= 0: + raise DataError(f'{key} must be positive') + orders = raw['harmonic_orders'] + if not isinstance(orders, list) or len(orders) > 15: + raise DataError('harmonic_orders must select at most 15 distinct orders') + out['harmonic_orders'] = [integer(order, 'harmonic order', 2, 16) for order in orders] + if len(set(out['harmonic_orders'])) != len(orders): + raise DataError('harmonic_orders must be unique') + out['min_noise_bins'] = integer(raw['min_noise_bins'], 'min_noise_bins', 1, 1000000) + return out + + +def spectral_quality(signal, operation): + from scipy.signal import find_peaks + op = normalize_quality(operation) + if signal.parameters['average'] != 'mean': + raise DataError('spectral_quality requires mean Welch PSD') + f, p = signal.frequency_hz, signal.psd_v2_per_hz + df = float(f[1] - f[0]) + nyquist = .5 / signal.sample_interval_s + if op['band_hz'][1] > nyquist * (1 + 1e-12): + raise DataError('spectral_quality band exceeds Nyquist') + if min(op['fundamental_half_width_hz'], op['harmonic_half_width_hz'], op['spur_half_width_hz']) < df: + raise DataError('spectral_quality integration half-width must cover at least one bin interval') + if not np.all(np.isfinite(p)) or np.any(p < 0): + raise DataError('spectral_quality requires finite nonnegative density') + def mask(bounds): + return (f >= bounds[0]) & (f <= bounds[1]) + base = mask(op['band_hz']) + for bounds in [op['dc_exclude_hz'], *op['exclude_hz']]: + base &= ~mask(bounds) + def region(center, width): + lo, hi = center - width, center + width + selected = mask([lo, hi]) + if lo < op['band_hz'][0] or hi > min(op['band_hz'][1], nyquist) or not selected.any() or np.any(selected & ~base): + raise DataError('integration region is clipped by band, DC or excluded intervals') + return selected + def power(selected): + value = float(np.sum(p[selected]) * df) + if not np.isfinite(value): + raise DataError('spectral integral overflow') + return value + def describe(selected): + indices = np.flatnonzero(selected) + # Ranges, not an unbounded list of every bin. + boundaries = np.flatnonzero(np.diff(indices) > 1) + 1 + ranges = [[int(part[0]), int(part[-1])] for part in np.split(indices, boundaries) if len(part)] + return {'bin_ranges': ranges, 'bin_count': len(indices), 'bandwidth_hz': len(indices) * df, + 'power_v2': power(selected)} + values = {key: None for key in QUALITY_METRICS} + warnings = [] + evidence = {'schema': 'wavebench.spectral_quality.v1', 'parameters': op, 'psd': signal.parameters, + 'bin_spacing_hz': df, 'definition': 'band_limited_psd_integral_no_signal_noise_subtraction', + 'regions': {}, 'harmonics': [], 'reasons': warnings} + def finish(): + for key, value in values.items(): + if value is not None and not np.isfinite(value): + values[key] = None + warnings.append(f'{key}: non-finite result unavailable') + return {f"{op['name']}_{key}": values[key] for key in op['metrics']}, evidence, warnings + indices, _ = find_peaks(p, height=op['min_peak_density_v2_per_hz'], prominence=op['min_prominence_v2_per_hz']) + fundamental = op['fundamental'] + if 'search_hz' in fundamental: + if fundamental['search_hz'][0] < op['band_hz'][0] or fundamental['search_hz'][1] > op['band_hz'][1]: + raise DataError('fundamental search must lie inside band_hz') + selected = [int(i) for i in indices if base[i] and fundamental['search_hz'][0] <= f[i] <= fundamental['search_hz'][1]] + if not selected: + warnings.append('no fundamental peak passes explicit density and prominence thresholds') + return finish() + peak = max(selected, key=lambda i: (p[i], -i)) + frequency = float(f[peak]) + else: + frequency = fundamental['frequency_hz'] + peak = int(np.argmin(np.abs(f - frequency))) + if peak not in indices: + warnings.append('fixed fundamental bin fails density or prominence threshold') + return finish() + F = region(frequency, op['fundamental_half_width_hz']) + pf = power(F) + if pf < op['min_fundamental_v2']: + warnings.append('fundamental integral is below min_fundamental_v2') + return finish() + H = np.zeros(len(f), bool) + for order in op['harmonic_orders']: + checkpoint() + center, width = frequency * order, op['harmonic_half_width_hz'] + if center - width < op['band_hz'][0] or center + width > min(op['band_hz'][1], nyquist): + evidence['harmonics'].append({'order': order, 'covered': False, 'power_v2': None}) + warnings.append(f'harmonic {order} not fully covered') + continue + current = region(center, width) + if np.any(current & (F | H)): + raise DataError('fundamental and harmonic integration bins overlap') + H |= current + evidence['harmonics'].append({'order': order, 'covered': True, **describe(current)}) + N = base & ~(F | H) + ph, pn = power(H), power(N) + evidence['regions'] = {'fundamental': describe(F), 'harmonics': describe(H), 'noise': describe(N)} + values.update(fundamental_frequency_hz=frequency, fundamental_power_v2=pf, harmonic_power_v2=ph, + noise_power_v2=pn, noise_bandwidth_hz=float(np.count_nonzero(N) * df)) + enough_noise = np.count_nonzero(N) >= op['min_noise_bins'] + if enough_noise and pn > 0: + values['snr_db'] = float(10 * (np.log10(pf) - np.log10(pn))) + else: + warnings.append('noise denominator unavailable: too few bins or zero power') + if enough_noise and ph + pn > 0: + values['sinad_db'] = float(10 * (np.log10(pf) - np.log10(ph + pn))) + ratio = float(np.sqrt((ph + pn) / pf)) + values['thdn_ratio'] = ratio if np.isfinite(ratio) else None + else: + warnings.append('SINAD denominator unavailable') + spur_indices, _ = find_peaks(p, height=op['spur_min_density_v2_per_hz'], prominence=op['spur_min_prominence_v2_per_hz']) + search = mask(op['spur_search_hz']) & base & ~F + candidates, ambiguous, previous = [], False, None + for i in spur_indices: + if not search[i]: + continue + checkpoint() + lo, hi = float(f[i]) - op['spur_half_width_hz'], float(f[i]) + op['spur_half_width_hz'] + current = mask([lo, hi]) + if lo < op['spur_search_hz'][0] or hi > op['spur_search_hz'][1] or np.any(current & ~search): + ambiguous = True + continue + if previous is not None and (lo <= previous + op['spur_half_width_hz'] or f[i] - previous < op['spur_distance_hz']): + ambiguous = True + previous = float(f[i]) + candidates.append((power(current), int(i), bool(np.any(current & H)))) + if ambiguous: + warnings.append('spur windows clipped or unresolved; SFDR unavailable') + elif candidates: + ps, index, harmonic = max(candidates, key=lambda item: (item[0], -item[1])) + if ps > 0: + values.update(spur_frequency_hz=float(f[index]), spur_power_v2=ps, + sfdr_db=float(10 * (np.log10(pf) - np.log10(ps))), + spur_dbc=float(10 * (np.log10(ps) - np.log10(pf)))) + evidence['largest_spur'] = {'frequency_hz': float(f[index]), 'power_v2': ps, 'overlaps_harmonic': harmonic} + else: + warnings.append('no spur passes explicit detection thresholds; SFDR unavailable') + return finish() diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py index dfd91c48..d3126464 100644 --- a/src/wavebench/report/analysis.py +++ b/src/wavebench/report/analysis.py @@ -37,6 +37,17 @@ def artifact_file(root: Path, raw: str) -> Path: 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 / "batch.json").is_file(): + from wavebench.services.analysis_batch import _safe_attempt + batch = read_json_bounded(root / "batch.json", limits) + limits.check("max_output_files", len(batch["entries"]), "batch report") + entries = [] + for item in batch["entries"]: + if item.get("directory"): + directory = _safe_attempt(root, item["directory"]) + if (directory / "analysis.json").is_file(): + entries.extend(analysis_entries(directory, limits)) + return entries if (root / "analysis.json").is_file(): result = read_json_bounded(root / "analysis.json", limits) if result["schema"] != "wavebench.analysis.v1": @@ -44,7 +55,7 @@ def analysis_entries(root: Path, resource_limits: AnalysisLimits | None = None) return [(root, root.name, result["artifact"])] 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"] + for step in run["steps"] if step["kind"] in {"analysis.pipeline", "analysis.pair"}] except (OSError, ValueError, KeyError, TypeError) as exc: raise ConfigError(f"cannot read analysis results in {root}: {exc}") from exc @@ -207,8 +218,18 @@ def render_analysis_sections(entries: list[tuple[Path, str, dict[str, Any]]], *, + '; timeout_s=' + escape(str(execution.get('timeout_s'))) + '; memory_backend=' + escape(str(execution.get('memory_backend'))) + '; forced=' + escape(str(execution.get('forced'))) + '

') + for stage in manifest.get("stages", []): + measurement = stage.get("measurement", {}) + if measurement.get("schema") == "wavebench.spectral_quality.v1": + sections.append('
PSD 积分质量估计 / Spectral quality
'
+                                    + escape(json.dumps(measurement, ensure_ascii=False, indent=2)) + '
') source = manifest["source"] peak_sets = {} + if manifest.get("schema") == "wavebench.analysis_pair.v1": + curve_count += 3 * sum(item.get("format") == "npy" for item in manifest.get("exports", [])) + limits.check("max_report_curves", curve_count, "pair report") + sections.append(render_pair_curves(root, manifest, limits)) + continue for peak in manifest.get("peaks", []): try: peak_file = artifact_file(root, peak["json"]) @@ -263,7 +284,8 @@ def write_analysis_report(paths: list[Path], output: Path, *, resource_limits: A 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") + document = root / ("batch.json" if (root / "batch.json").is_file() else + "analysis.json" if (root / "analysis.json").is_file() else "run.json") try: metadata_bytes += document.stat().st_size except OSError as exc: @@ -290,3 +312,76 @@ def write_analysis_report(paths: list[Path], output: Path, *, resource_limits: A output.parent.mkdir(parents=True, exist_ok=True) _atomic_write_bytes(output, html.encode("utf-8"), budget=AnalysisBudget(limits)) return output + + +def render_pair_curves(root, manifest, limits): + """Validate all pair bins while retaining bounded per-bucket extrema and gap flags.""" + from wavebench.data.pair_analysis import PAIR_COLUMNS + exports = [item for item in manifest.get('exports', []) if item.get('format') == 'npy'] + sections = ['

双通道分析 / Pair analysis

同步证据 / Synchronization: ' + + escape(str(manifest.get('source', {}).get('synchronization', {}).get('kind', 'unavailable'))) + + ' · measurement-chain delay; no deskew calibration

'] + used = 0 + for item in exports: + limits.check('max_report_curves', 3 * (used+1), 'pair report') + path = artifact_file(root, item['path']) + if item.get('columns') != PAIR_COLUMNS or _sha256_file(path) != item['sha256']: + raise ValueError('pair export columns or SHA-256 mismatch') + buckets = [{}, {}, {}] + counts = {'valid': 0, 'coherent': 0, 'total': 0} + previous = None + with mapped_npy(path, limits, columns=8) as (data, _): + limits.check('max_working_bytes', 4096*128 + 600*1024, 'pair report') + for start in range(0, len(data), 4096): + block = np.asarray(data[start:start+4096]) + if (not np.all(np.isfinite(block[:, 0])) or not np.all(np.diff(block[:, 0]) > 0) + or previous is not None and block[0, 0] <= previous + or not np.all(np.isin(block[:, 6:], [0, 1]))): + raise ValueError('invalid pair frequency axis or masks') + previous = block[-1, 0] + valid = block[:, 6].astype(bool) + if (not np.all(np.isfinite(block[valid, 1:6])) or not np.all(np.isnan(block[~valid, 1:6])) + or np.any(block[:, 7] > block[:, 6]) or np.any((block[valid, 5] < 0) | (block[valid, 5] > 1))): + raise ValueError('invalid pair numeric values or coherence') + counts['total'] += len(block) + counts['valid'] += int(valid.sum()) + counts['coherent'] += int(block[:, 7].sum()) + indices = (np.arange(start, start+len(block))*600//len(data)).astype(int) + for bucket in np.unique(indices): + selected = block[indices == bucket] + for which, column in enumerate((3, 4, 5)): + current = buckets[which].setdefault(int(bucket), {'gap': False, 'min': None, 'max': None}) + current['gap'] |= bool(np.any(selected[:, 6] == 0)) + values = selected[selected[:, 6] == 1] + if not len(values): + continue + lo, hi = values[np.argmin(values[:, column])], values[np.argmax(values[:, column])] + for key, row in (('min', lo), ('max', hi)): + point = (float(row[0]), float(row[column])) + old = current[key] + if old is None or (point[1] < old[1] if key == 'min' else point[1] > old[1]): + current[key] = point + sections.append('

有效 bin / Valid bins: ' + escape(str(counts)) + '

') + for label, table in zip(('Gain (dB)', 'Phase (rad)', 'Coherence'), buckets): + points = [p for entry in table.values() for p in (entry['min'], entry['max']) if p] + if not points: + continue + xmin, xmax = min(p[0] for p in points), max(p[0] for p in points) + ymin, ymax = min(p[1] for p in points), max(p[1] for p in points) + commands, connected = [], False + for _, entry in sorted(table.items()): + if entry['gap'] or entry['min'] is None: + connected = False + continue + for x, y in sorted({entry['min'], entry['max']}): + px = 50 + 800*(x-xmin)/(xmax-xmin or 1) + py = 250 - 210*(y-ymin)/(ymax-ymin or 1) + commands.append(f"{'L' if connected else 'M'}{px:.2f},{py:.2f}") + connected = True + sections.append(f'

{label}

' + f'' + f'{xmin:.5g}–{xmax:.5g} Hz; {ymin:.5g}–{ymax:.5g}') + used += 1 + if not exports: + sections.append('

No NPY pair curves / 无 NPY 双通道曲线;CSV 可从导出链接下载

') + return ''.join(sections) diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py index a29720f9..fc4ef8c3 100644 --- a/src/wavebench/report/html.py +++ b/src/wavebench/report/html.py @@ -1975,7 +1975,7 @@ def _build_evidence_summary( def _signal_processing_block(run: RunPackage, output_dir: Path, *, analysis_limits=None) -> str: rows: list[str] = [] for step in run.steps: - if step.get("kind") != "analysis.pipeline": + if step.get("kind") not in {"analysis.pipeline", "analysis.pair"}: continue artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {} pipeline = ( @@ -2040,7 +2040,7 @@ def _signal_processing_block(run: RunPackage, output_dir: Path, *, analysis_limi 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" + for step in run.steps if step.get("kind") in {"analysis.pipeline", "analysis.pair"} ], details=False, resource_limits=analysis_limits) return f"""

信号处理 / Signal processing

步骤 / Step状态 / Status来源 / Source算子 / Operations指标 / Metrics警告 / Warnings失败阶段 / Failed stage产物 / Artifacts
@@ -2098,7 +2098,7 @@ def _analysis_file_link( 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": + if step.get("kind") not in {"analysis.pipeline", "analysis.pair"}: continue artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {} pipeline = ( @@ -2271,7 +2271,7 @@ def _collect_artifact_links( ) ) for step in run.steps: - if step.get("kind") != "analysis.pipeline": + if step.get("kind") not in {"analysis.pipeline", "analysis.pair"}: continue artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {} pipeline = ( diff --git a/src/wavebench/services/analysis_batch.py b/src/wavebench/services/analysis_batch.py new file mode 100644 index 00000000..7fd2172a --- /dev/null +++ b/src/wavebench/services/analysis_batch.py @@ -0,0 +1,225 @@ +"""Serial explicit batches with content-verified resume and aggregate disk admission.""" +from dataclasses import replace +from hashlib import sha256 +from importlib.metadata import version, PackageNotFoundError +import json +from pathlib import Path +import tomllib + +from wavebench import __version__ +from wavebench.data.analysis_io import mapped_npy, hash_stream, read_json_bounded +from wavebench.data.analysis_resources import AnalysisLimits +from wavebench.data.packages import _capture_channels +from wavebench.data.pipeline_operations import integer, result_name +from wavebench.errors import ConfigError, DataError, error_envelope +from .analysis_execution import AnalysisExecution +from .analysis_service import load_analysis_recipe, run_analysis +from .file_lock import FileLock, FileLockError +from .run_pipeline import _atomic_write_json, _atomic_write_csv, _resolve_package_member, _sha256_file + + +SCHEMA = 'wavebench.analysis_batch.v1' + + +def source_fingerprint(capture, channel, limits): + capture = Path(capture).resolve() + metadata_path = _resolve_package_member(capture, 'metadata.json', label='metadata') + metadata = read_json_bounded(metadata_path, limits) + metadata_hash = _sha256_file(metadata_path) + candidates = [entry for entry in _capture_channels(metadata) if entry.channel == channel] + if len(candidates) != 1 or not candidates[0].files.get('npy'): + raise DataError('batch source must contain exactly one requested NPY channel') + path = _resolve_package_member(capture, candidates[0].files['npy'], label='NPY') + with mapped_npy(path, limits, columns=2, source=True) as (_, file): + data_hash = hash_stream(file) + if metadata_hash != _sha256_file(metadata_path): + raise DataError('batch source metadata changed while fingerprinting') + return {'metadata_sha256': metadata_hash, 'npy_sha256': data_hash} + + +def load_batch(path, limits): + path = Path(path).resolve() + with path.open('rb') as file: + raw = file.read(limits.max_metadata_bytes + 1) + limits.check('max_metadata_bytes', len(raw), 'batch manifest') + limits.check('max_working_bytes', len(raw) * 32 + 65536, 'batch manifest') + try: + data = tomllib.loads(raw.decode('utf-8-sig')) + if set(data) != {'schema', 'recipe', 'entries', 'on_failure', 'duplicates', 'max_output_bytes'} or data['schema'] != SCHEMA: + raise ConfigError('batch manifest requires schema, recipe, entries, on_failure, duplicates, max_output_bytes') + if data['on_failure'] not in ('stop', 'continue') or data['duplicates'] not in ('reject', 'allow'): + raise ConfigError('invalid batch failure or duplicate policy') + data['max_output_bytes'] = integer(data['max_output_bytes'], 'batch max_output_bytes', 1, limits.max_output_bytes) + entries = data['entries'] + if not isinstance(entries, list) or not 1 <= len(entries) <= min(256, limits.max_output_files): + raise ConfigError('batch entries must contain 1..256 items within file budget') + names, sources = set(), set() + for entry in entries: + if not isinstance(entry, dict) or set(entry) != {'id', 'capture', 'channel'}: + raise ConfigError('batch entry requires id, capture and channel') + entry['id'] = result_name(entry['id']) + entry['channel'] = integer(entry['channel'], 'channel', 1, 65535) + entry['capture'] = str((path.parent / entry['capture']).resolve()) + key = (entry['capture'], entry['channel']) + if entry['id'] in names or key in sources and data['duplicates'] == 'reject': + raise ConfigError('duplicate batch id or source') + names.add(entry['id']) + sources.add(key) + data['recipe'] = str((path.parent / data['recipe']).resolve()) + return data + except (ValueError, TypeError, DataError) as exc: + raise ConfigError(f'invalid batch manifest: {exc}') from exc + + +def _inventory(root, limits): + files = {} + if root.exists(): + for path in sorted(root.rglob('*')): + if path.is_symlink(): + raise ConfigError('batch results must not contain symlinks') + if path.is_file(): + limits.check('max_output_files', len(files) + 1, 'batch inventory') + files[path.relative_to(root).as_posix()] = {'bytes': path.stat().st_size, 'sha256': _sha256_file(path)} + return files + + +def run_batch(manifest, output, *, resource_limits=None, execution_policy=None, resume=False, cancel_event=None): + limits = resource_limits or AnalysisLimits() + specification = load_batch(manifest, limits) + recipe = load_analysis_recipe(specification['recipe'], limits) + policy = execution_policy or AnalysisExecution() # Batch cancellation requires a supervised item. + policy.preflight() + output = Path(output).resolve() + for entry in specification['entries']: + source = Path(entry['capture']) + if output == source or source in output.parents or output in source.parents: + raise ConfigError('batch output must be separate from every capture package') + if any((parent / 'metadata.json').exists() or (parent / 'run.json').exists() for parent in output.parents): + raise ConfigError('batch output must not modify an existing capture or run') + if output.exists() != resume: + raise ConfigError('new batch requires a new directory; resume requires the existing batch directory') + fingerprints, source_errors = [], [] + for entry in specification['entries']: + try: + fingerprints.append(source_fingerprint(entry['capture'], entry['channel'], limits)) + source_errors.append(None) + except (OSError, ValueError, ConfigError, DataError) as exc: + fingerprints.append(None) + source_errors.append(error_envelope(exc, operation='analysis.batch.source')) + libraries = {'wavebench': __version__} + for name in ('numpy', 'scipy'): + try: + libraries[name] = version(name) + except PackageNotFoundError: + libraries[name] = None + binding = {'specification': specification, 'recipe': recipe, 'resources': limits.evidence(), + 'execution': policy.evidence(), 'backends': libraries, 'sources': fingerprints} + binding_hash = sha256(json.dumps(binding, sort_keys=True, allow_nan=False).encode()).hexdigest() + output.mkdir(exist_ok=resume, parents=True) + lock = FileLock(output / '.batch.lock') + try: + lock.acquire() + except FileLockError as exc: + raise ConfigError(f'batch is locked or file locking unavailable: {exc}') from exc + try: + index_path = output / 'batch.json' + if resume: + index = read_json_bounded(index_path, limits) + if index.get('schema') != SCHEMA or index.get('binding_sha256') != binding_hash: + raise ConfigError('batch inputs, recipe, resources or backend changed; use a new output directory') + for record in index['entries']: + for attempt in record.get('attempts', []): + folder = _safe_attempt(output, attempt['directory']) + if attempt['files'] != _inventory(folder, limits): + raise ConfigError('batch completed artifacts changed; use a new output directory') + else: + index = {'schema': SCHEMA, 'binding': binding, 'binding_sha256': binding_hash, + 'status': 'running', 'entries': [dict(id=item['id'], status='pending', attempts=[]) for item in specification['entries']]} + # Verify the full tree too, including attempts interrupted before index completion. + prior_inventory = _inventory(output, limits) + if resume and index.get('tree_files') is not None and not any(item['status'] == 'running' for item in index['entries']): + actual_tree = {k: v for k, v in prior_inventory.items() if k not in ('batch.json', 'summary.csv', '.batch.lock')} + if actual_tree != index['tree_files']: + raise ConfigError('batch output tree changed; use a new output directory') + index.pop('cancelled', None) + index.pop('error', None) + def save(): + index['tree_files'] = {k: v for k, v in _inventory(output, limits).items() + if k not in ('batch.json', 'summary.csv', '.batch.lock')} + _atomic_write_json(index_path, index) + rows = [[item['id'], item['status'], json.dumps(item.get('metrics', {}), sort_keys=True), + item.get('directory', ''), item.get('error', {}).get('code', '')] for item in index['entries']] + import numpy as np + _atomic_write_csv(output / 'summary.csv', ['id', 'status', 'metrics_json', 'directory', 'error_code'], np.array(rows, dtype=object)) + save() + # Reserve bounded room for the index and its CSV before admitting an item. + reserve = min(limits.max_metadata_bytes, max(65536, len(specification['entries']) * 8192)) + for position, (entry, record) in enumerate(zip(specification['entries'], index['entries'])): + if record['status'] == 'ok': + continue + if cancel_event is not None and cancel_event.is_set(): + index['cancelled'] = True + break + if source_errors[position]: + record.update(status='failed', error=source_errors[position]) + else: + all_files = _inventory(output, limits) + used = sum(item['bytes'] for name, item in all_files.items() if name not in ('batch.json', 'summary.csv')) + remaining = specification['max_output_bytes'] - used - reserve + remaining_files = limits.max_output_files - len(all_files) - 3 + attempt_no = len(record['attempts']) + 1 + directory = f"items/{entry['id']}/attempt_{attempt_no:03d}" + # Interrupted attempts are preserved; a new attempt never overwrites them. + while (output / directory).exists(): + attempt_no += 1 + directory = f"items/{entry['id']}/attempt_{attempt_no:03d}" + record.update(status='running', directory=directory) + save() + try: + if remaining <= 0 or remaining_files <= 0: + raise DataError('batch aggregate output budget exhausted') + item_limits = replace(limits, max_output_bytes=min(limits.max_output_bytes, remaining), + max_output_files=min(limits.max_output_files, remaining_files)) + if load_analysis_recipe(specification['recipe'], limits) != recipe: + raise DataError('batch recipe changed during execution; use a new output directory') + result = run_analysis(Path(entry['capture']), entry['channel'], Path(specification['recipe']), + output / directory, resource_limits=limits, _output_limits=item_limits, execution_policy=policy, + cancel_event=cancel_event) + if result['recipe'] != recipe: + raise DataError('batch recipe changed during item execution') + after = source_fingerprint(entry['capture'], entry['channel'], limits) + if after != fingerprints[position] or result['source'].get('npy_sha256', after['npy_sha256']) != after['npy_sha256']: + raise DataError('batch source changed during execution; result cannot be reused') + record.update(status=result['status'], metrics=result['artifact']['metrics']) + error = result.get('error') or result['artifact']['analysis_pipeline'].get('error') + record.pop('error', None) + if error: + record['error'] = error + if error and error.get('code') == 'analysis_cancelled': + index['cancelled'] = True + except KeyboardInterrupt: + index['cancelled'] = True + record.update(status='failed', error={'code': 'analysis_cancelled', 'message': 'batch cancelled'}) + except (OSError, ValueError, ConfigError, DataError) as exc: + record.update(status='failed', error=error_envelope(exc, operation='analysis.batch.item')) + if (output / directory).exists(): + record['attempts'].append({'directory': directory, 'files': _inventory(output / directory, limits)}) + save() + if index.get('cancelled') or record['status'] == 'failed' and specification['on_failure'] == 'stop': + break + index['status'] = 'ok' if all(item['status'] == 'ok' for item in index['entries']) else 'failed' + save() + actual = _inventory(output, limits) + if sum(item['bytes'] for item in actual.values()) > specification['max_output_bytes']: + index.update(status='failed', error={'code': 'resource_limit_exceeded', 'message': 'batch aggregate output budget exhausted'}) + save() + return index + finally: + lock.release() + + +def _safe_attempt(output, raw): + path = (output / raw).resolve() + if Path(raw).is_absolute() or not path.is_relative_to(output) or path == output: + raise ConfigError('batch attempt path escapes output') + return path diff --git a/src/wavebench/services/analysis_service.py b/src/wavebench/services/analysis_service.py index 689b873b..c5e79d08 100644 --- a/src/wavebench/services/analysis_service.py +++ b/src/wavebench/services/analysis_service.py @@ -98,11 +98,15 @@ def check_analysis(capture: Path, channel: int, recipe: Path, *, resource_limits **({"execution": execution_policy.evidence()} if execution_policy is not None else {})} -def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, resource_limits: AnalysisLimits | None = None, execution_policy=None, cancel_event=None) -> dict[str, Any]: +def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, resource_limits: AnalysisLimits | None = None, execution_policy=None, cancel_event=None, _output_limits=None) -> dict[str, Any]: if execution_policy is not None: execution_policy.preflight() fields = load_analysis_recipe(recipe, resource_limits) limits = (resource_limits or AnalysisLimits()).tighten(fields.get("resources")) + if _output_limits is not None: + from dataclasses import replace + limits = replace(limits, **{key: min(getattr(limits, key), getattr(_output_limits, key)) + for key in ('max_output_bytes', 'max_output_files', 'max_temp_bytes')}) capture = capture.resolve() output = output.resolve() if output.exists(): @@ -146,8 +150,11 @@ def run_analysis(capture: Path, channel: int, recipe: Path, output: Path, *, res def _execute_offline(*, output, capture, channel, fields, source, limits): + execution_fields = dict(fields) + if "resources" in fields: + execution_fields["resources"] = {key: min(value, getattr(limits, key)) for key, value in fields["resources"].items()} return execute_pipeline( - run_dir=output, processing_dir=output, fields=fields, source=source, + run_dir=output, processing_dir=output, fields=execution_fields, source=source, load_source=lambda: load_analysis_source(capture, channel, limits), resource_limits=limits, schema="wavebench.offline_pipeline.v1", ) diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index ee64420d..1ded6992 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -373,6 +373,7 @@ def _spec( _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("analysis.pair", 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/pair_service.py b/src/wavebench/services/pair_service.py new file mode 100644 index 00000000..7ee94c48 --- /dev/null +++ b/src/wavebench/services/pair_service.py @@ -0,0 +1,278 @@ +"""Independent pair entry points sharing RunPlan lifecycle and supervision artifacts.""" +from pathlib import Path +import json +import tomllib +from hashlib import sha256 + +import numpy as np + +from wavebench import __version__ +from wavebench.data.analysis_control import checkpoint, cancel_signal +from wavebench.data.analysis_io import load_waveform, read_json_bounded, mapped_npy +from wavebench.data.analysis_resources import AnalysisLimits, AnalysisBudget, AnalysisResourceError +from wavebench.data.packages import _capture_channels +from wavebench.data.pipeline_operations import integer +from wavebench.data.pair_analysis import (PAIR_COLUMNS, normalize_pair_operations, validate_sync, + delay_estimate, transfer_estimate, check_pair_static) +from wavebench.errors import ConfigError, DataError, error_envelope +from .run_analysis import evaluate_expect +from .run_pipeline import _atomic_write_json, _atomic_write_npy, _atomic_write_csv, _resolve_package_member, _sha256_file + + +PAIR_SCHEMA = 'wavebench.analysis_pair.v1' +PAIR_RECIPE_SCHEMA = 'wavebench.analysis_pair_recipe.v1' + + +def normalize_pair_fields(fields): + try: + for key in ('reference_channel', 'response_channel'): + fields[key] = integer(fields[key], key, 1, 65535) + if fields['reference_channel'] == fields['response_channel']: + raise DataError('reference and response must be distinct channels') + fields['operations'] = normalize_pair_operations(fields) + if 'resources' in fields: + from wavebench.data.analysis_resources import normalize_limits + fields['resources'] = normalize_limits(fields['resources']) + if 'expect' in fields: + from .run_plan import _parse_expect + fields['expect'] = _parse_expect(fields['expect'], 'pair.expect') + except (DataError, KeyError, TypeError) as exc: + raise ConfigError(f'invalid pair configuration: {exc}') from exc + + +def load_pair_recipe(path, limits): + with Path(path).open('rb') as file: + raw = file.read(limits.max_metadata_bytes + 1) + limits.check('max_metadata_bytes', len(raw), 'pair recipe') + limits.check('max_working_bytes', len(raw)*32 + 65536, 'pair recipe') + try: + fields = tomllib.loads(raw.decode('utf-8-sig')) + if fields.pop('schema', None) != PAIR_RECIPE_SCHEMA or set(fields) - {'reference_channel', 'response_channel', 'operations', 'resources', 'expect'}: + raise ConfigError('invalid pair recipe schema or fields') + normalize_pair_fields(fields) + check_pair_static(fields['operations'], limits.tighten(fields.get('resources')), metadata_files=3) + return fields + except (ValueError, TypeError) as exc: + raise ConfigError(f'invalid pair recipe: {exc}') from exc + + +def ensure_pair_dependencies(): + try: + from scipy.signal import correlate, get_window, detrend + from scipy.fft import rfft + if not all(callable(f) for f in (correlate, get_window, detrend, rfft)): + raise ImportError('missing pair analysis functions') + except ImportError as exc: + raise ConfigError('pair analysis requires SciPy; install .[analysis]') from exc + + +def load_pair_source(capture, fields, limits): + capture = Path(capture).resolve() + meta_path = _resolve_package_member(capture, 'metadata.json', label='metadata') + before = _sha256_file(meta_path) + metadata = read_json_bounded(meta_path, limits) + evidence = metadata.get('synchronization') + if not isinstance(evidence, dict) or evidence.get('schema') != 'wavebench.capture_sync.v1' or evidence.get('kind') != 'synthetic': + raise DataError('pair requires explicit synthetic synchronization evidence; real driver evidence is not yet supported') + channels = (fields['reference_channel'], fields['response_channel']) + entries = _capture_channels(metadata) + paths, total = [], 0 + for channel in channels: + matched = [entry for entry in entries if entry.channel == channel] + if len(matched) != 1 or not matched[0].files.get('npy'): + raise DataError('pair requires both distinct NPY channels in the same package') + path = _resolve_package_member(capture, matched[0].files['npy'], label='NPY') + with mapped_npy(path, limits, columns=2, source=True) as (mapped, _): + total += len(mapped) + limits.check('max_working_bytes', total*64+65536, 'pair sources') + paths.append(path) + if paths[0] == paths[1]: + raise DataError('pair channels cannot refer to the same NPY file') + x, hx = load_waveform(paths[0], limits) + y, hy = load_waveform(paths[1], limits) + axes = validate_sync(x, y, evidence, channels) + if before != _sha256_file(meta_path) or hx != _sha256_file(paths[0]) or hy != _sha256_file(paths[1]): + raise DataError('pair synchronization metadata changed during loading') + source = {'kind': 'capture_package_pair', 'package': str(capture), 'status': metadata.get('status'), + 'metadata_sha256': before, 'reference_channel': channels[0], 'response_channel': channels[1], + 'reference_npy': paths[0].relative_to(capture).as_posix(), 'reference_sha256': hx, + 'response_npy': paths[1].relative_to(capture).as_posix(), 'response_sha256': hy, + 'synchronization': evidence, 'axes': axes} + return source, x, y + + +def pair_check(capture, recipe, *, resource_limits=None, execution_policy=None): + limits = resource_limits or AnalysisLimits() + fields = load_pair_recipe(recipe, limits) + limits = limits.tighten(fields.get('resources')) + ensure_pair_dependencies() + if execution_policy: + execution_policy.preflight() + source, x, y = load_pair_source(capture, fields, limits) + # Source evidence is validated offline; estimator admission uses actual sample counts. + budget = AnalysisBudget(limits) + output_bins = None + for operation in fields['operations']: + if operation['op'] == 'transfer': + budget.stage(dict(op='psd', average='mean', **{k: operation[k] for k in ('nperseg','noverlap','nfft')}), len(x.time_s)*2) + if len(x.time_s) < 2*operation['nperseg']-operation['noverlap']: + raise DataError('pair transfer requires at least two complete Welch segments') + output_bins = operation['nfft']//2+1 + limits.check('max_working_bytes', 128*len(x.time_s)+256*operation['nfft']+128*operation['nperseg'], 'pair transfer') + elif operation['op'] == 'delay': + length = 1 << (2*len(x.time_s)-2).bit_length() + limits.check('max_fft_length', length, 'pair correlation') + limits.check('max_working_bytes', 192*len(x.time_s)+64*length, 'pair correlation') + lag = int(min(len(x.time_s)-1, operation['max_lag_s']/source['axes']['sample_interval_s'], + np.floor(len(x.time_s)*(1-operation['min_overlap_ratio'])))) + limits.check('max_peak_candidates', 2*lag+1, 'pair correlation') + limits.check('max_working_bytes', 192*len(x.time_s)+64*length+256*(2*lag+1), 'pair candidates') + work = 24*length*length.bit_length() + limits.check('max_work_units', budget.work_units+work, 'pair correlation') + budget.work_units += work + else: + for format in operation['formats']: + estimate = output_bins*8*(8 if format == 'npy' else 32)+1024 + budget.pending_file(estimate) + budget.committed_file(estimate) + return {'schema': 'wavebench.analysis_pair_check.v1', 'status': 'ok', 'source': source, 'recipe': fields, + 'resources': limits.evidence()} + + +def pair_run(capture, recipe, output, *, resource_limits=None, execution_policy=None, cancel_event=None): + limits = resource_limits or AnalysisLimits() + fields = load_pair_recipe(recipe, limits) + limits = limits.tighten(fields.get('resources')) + ensure_pair_dependencies() + if execution_policy: + execution_policy.preflight() + capture, output = Path(capture).resolve(), Path(output).resolve() + if output.exists() or output == capture or capture in output.parents or output in capture.parents: + raise ConfigError('pair output must be new and separate from source capture') + if any((parent/'run.json').exists() or (parent/'metadata.json').exists() for parent in output.parents): + raise ConfigError('pair output must not modify an existing capture or run') + source = {'kind': 'capture_package_pair', 'package': str(capture), 'status': None} + artifact = execute_pair(run_dir=output, processing_dir=output, capture=capture, fields=fields, + limits=limits, source=source, execution_policy=execution_policy, cancel_event=cancel_event) + result = {'schema': 'wavebench.analysis.v1', 'analysis_kind': 'pair', 'wavebench_version': __version__, + 'status': 'ok' if artifact['analysis_pipeline']['status'] == 'ok' and artifact.get('expect', {}).get('status', 'ok') == 'ok' else 'failed', + 'source': source, 'recipe': fields, 'recipe_sha256': sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest(), 'artifact': artifact} + budget = AnalysisBudget(limits) + resources = artifact['analysis_pipeline']['resources'] + budget.output_bytes = resources['data_output_bytes'] + sum((output/name).stat().st_size for name in ('manifest.json', 'metrics.json')) + budget.output_files = resources['data_output_files']+2 + try: + _atomic_write_json(output/'analysis.json', result, budget=budget) + except AnalysisResourceError as exc: + result.update(status='failed', error=error_envelope(exc, operation='pair.result')) + _atomic_write_json(output/'analysis.json', result) + return result + + +def execute_pair(*, run_dir, processing_dir, capture, fields, limits, source, execution_policy=None, cancel_event=None): + check_pair_static(fields['operations'], limits) + if execution_policy: + from .analysis_execution import supervise + return supervise(execute_pair, dict(run_dir=run_dir, processing_dir=processing_dir, capture=capture, + fields=fields, limits=limits, source=source), policy=execution_policy, run_dir=run_dir, + processing_dir=processing_dir, fields=fields, source=source, limits=limits, + schema=PAIR_SCHEMA, cancel_event=cancel_event) + processing_dir.mkdir(parents=True, exist_ok=False) + budget = AnalysisBudget(limits) + metrics = {f"{op['name']}_{key}": None for op in fields['operations'] if op['op'] != 'export' for key in op['metrics']} + manifest = {'schema': PAIR_SCHEMA, 'status': 'running', 'source': source, 'operations': fields['operations'], + 'stages': [], 'warnings': [], 'exports': [], 'metrics': (processing_dir/'metrics.json').relative_to(run_dir).as_posix()} + def save(budgeted=False): + manifest['resources'] = {**limits.evidence(), 'work_units': budget.work_units, + 'data_output_bytes': budget.output_bytes, 'data_output_files': budget.output_files} + _atomic_write_json(processing_dir/'metrics.json', {'schema': 'wavebench.analysis_metrics.v1', 'metrics': metrics}, budget=budget if budgeted else None) + _atomic_write_json(processing_dir/'manifest.json', manifest, budget=budget if budgeted else None) + def progress(): + if cancel_signal.get() is not None: + save() + stage, spectrum = None, None + try: + progress() + checkpoint() + details, x, y = load_pair_source(capture, fields, limits) + source_status = source.get('status') + source.update(details) + if 'step' in source: + source['status'] = source_status + manifest['sampling'] = details['axes'] + for index, operation in enumerate(fields['operations']): + stage = {'index': index, 'op': operation['op'], 'status': 'running'} + manifest['stages'].append(stage) + progress() + checkpoint() + if operation['op'] == 'delay': + measured, evidence = delay_estimate(x, y, operation, budget) + metrics.update(measured) + stage['measurement'] = evidence + elif operation['op'] == 'transfer': + spectrum = transfer_estimate(x, y, operation, budget) + metrics.update(spectrum.metrics) + stage['measurement'] = spectrum.evidence + else: + directory = processing_dir/'exports' + directory.mkdir(exist_ok=True) + for format in operation['formats']: + checkpoint() + path = directory/f"{operation['name']}.{format}" + if format == 'npy': + _atomic_write_npy(path, spectrum.data, budget=budget) + else: + def blocks(): + for start in range(0, len(spectrum.data), 4096): + block = spectrum.data[start:start+4096] + cells = block.astype(object) + cells[~np.isfinite(block)] = '' + yield cells + _atomic_write_csv(path, PAIR_COLUMNS, None, blocks=blocks, budget=budget) + manifest['exports'].append({'name': operation['name'], 'format': format, + 'path': path.relative_to(run_dir).as_posix(), 'sha256': _sha256_file(path), + 'columns': PAIR_COLUMNS, 'domain': 'pair', 'invalid_values': 'nan_with_valid_mask'}) + progress() + if 'measurement' in stage: + manifest['warnings'].extend(stage['measurement'].get('warnings', [])) + stage['status'] = 'ok' + progress() + manifest['status'] = 'ok' + except Exception as exc: + if stage: + stage['status'] = 'failed' + manifest.update(status='failed', failed_stage=f"operations[{stage['index']}]" if stage else 'source', + error=error_envelope(exc, operation='analysis.pair')) + manifest['partial'] = manifest['status'] == 'failed' and bool(manifest['exports']) + try: + save(budgeted=True) + except AnalysisResourceError as exc: + manifest.update(status='failed', failed_stage='metadata', error=error_envelope(exc, operation='pair.metadata')) + # Preserve data-only counters after an unsuccessful metadata write. + budget.output_bytes = manifest['resources']['data_output_bytes'] + budget.output_files = manifest['resources']['data_output_files'] + save() + pipeline = {key: manifest[key] for key in ('schema', 'status', 'operations', 'warnings', 'exports', 'resources')} + pipeline.update(manifest=(processing_dir/'manifest.json').relative_to(run_dir).as_posix(), + metrics=(processing_dir/'metrics.json').relative_to(run_dir).as_posix(), + source_step=source.get('step'), source_status=source.get('status')) + for key in ('error', 'failed_stage'): + if key in manifest: + pipeline[key] = manifest[key] + artifact = {'analysis_pipeline': pipeline, 'metrics': metrics} + if 'expect' in fields: + artifact['expect'] = evaluate_expect(metrics, fields['expect']) + return artifact + + +def execute_pair_step(*, run_dir, step, source_step, source_record, resource_limits=None, execution_policy=None, cancel_event=None): + if source_record is None or not source_record.artifact.get('package'): + raise DataError('pair source capture was not executed or has no package') + package = Path(source_record.artifact['package']) + if not package.is_absolute(): + package = run_dir/package + limits = (resource_limits or AnalysisLimits()).tighten(step.fields.get('resources')) + return execute_pair(run_dir=run_dir, processing_dir=run_dir/'processing'/f"{step.index:02d}_{step.id or 'analysis_pair'}", + capture=package, fields=step.fields, limits=limits, + source={'step': source_step.id, 'step_index': source_step.index, 'status': source_record.status}, + execution_policy=execution_policy, cancel_event=cancel_event) diff --git a/src/wavebench/services/run_pipeline.py b/src/wavebench/services/run_pipeline.py index 3f1e537d..de805092 100644 --- a/src/wavebench/services/run_pipeline.py +++ b/src/wavebench/services/run_pipeline.py @@ -55,7 +55,7 @@ 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", "resample"} + if operation["op"] in {"filter", "psd", "peaks", "resample", "spectral_quality"} or operation["op"] == "smooth" and operation["method"] == "savgol" ] if not operations: @@ -66,7 +66,7 @@ def ensure_operation_dependencies(all_operations: list[dict[str, Any]]) -> None: required_functions.update({"resample_poly", "firwin"}) elif operation["op"] == "smooth": required_functions.add("savgol_coeffs") - elif operation["op"] == "peaks": + elif operation["op"] in {"peaks", "spectral_quality"}: required_functions.add("find_peaks") elif operation["op"] == "psd": required_functions.update({"welch", "get_window"}) @@ -165,7 +165,7 @@ def execute_pipeline( for metric in operation["metrics"] } for operation in operations: - if operation["op"] in {"measure_band", "peaks"}: + if operation["op"] in {"measure_band", "peaks", "spectral_quality"}: metrics.update({f"{operation['name']}_{metric}": None for metric in operation["metrics"]}) warnings: list[str] = [] exports: list[dict[str, Any]] = [] @@ -353,6 +353,13 @@ def save_progress(): message = f"{operation['name']}: peak table truncated to {detected['retained_count']} rows" stage["warnings"] = [message] _extend_unique(warnings, [message]) + elif op == "spectral_quality": + from wavebench.data.spectral_quality import spectral_quality + measured, metadata, operation_warnings = spectral_quality(signal, operation) + metrics.update(measured) + stage["measurement"] = metadata + stage["warnings"] = operation_warnings + _extend_unique(warnings, operation_warnings) elif op == "measure_band": assert isinstance(signal, PsdSignal) measured, metadata, operation_warnings = measure_band(signal, operation) diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index ec805772..1f8f4839 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -32,6 +32,7 @@ ALLOWED_STEP_KINDS = { "analysis.pipeline", + "analysis.pair", "scope.auto", "scope.capture", "sweep.frequency_response", @@ -95,6 +96,7 @@ _REQUIRED_FIELDS = { "analysis.pipeline": ("source", "operations"), + "analysis.pair": ("source", "reference_channel", "response_channel", "operations"), "power.set": ("voltage_v", "current_limit_a"), "power.output": ("state",), "source.set_freq": ("frequency_hz",), @@ -191,6 +193,7 @@ _OPTIONAL_FIELDS = { "analysis.pipeline": {"expect", "on_failure", "resources"}, + "analysis.pair": {"expect", "on_failure", "resources"}, "scope.auto": {"on_failure"}, "scope.capture": { "channel", @@ -335,11 +338,12 @@ # in sync as new step kinds are added. for _step_kind, _step_fields in _OPTIONAL_FIELDS.items(): _step_fields.add("on_failure") - if _step_kind != "analysis.pipeline": + if _step_kind not in {"analysis.pipeline", "analysis.pair"}: _step_fields.add("safety_gate") _STEP_NOTES = { + "analysis.pair": "Analyze two evidence-validated channels from one earlier capture package after hardware cleanup. Currently accepts synthetic synchronization evidence only; real driver adaptation is not supported.", "analysis.pipeline": "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.", "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.", @@ -466,10 +470,14 @@ def format_run_plan_schema() -> str: " 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 _.", "", + "analysis.pair: reference_channel and response_channel must be distinct; source uses one earlier scope.capture with explicit save_npy=true.", + " Pair operations: delay (integer lag), transfer (mean Welch H1/coherence), export. Only synthetic synchronization evidence is currently accepted.", + " spectral_quality requires explicit integration bands, fundamental mode, harmonic orders, detection thresholds and metrics; only mean Welch PSD is accepted.", + " Quality metrics: snr_db, sinad_db, sfdr_db, thdn_ratio, fundamental_frequency_hz, fundamental_power_v2, harmonic_power_v2, noise_power_v2, noise_bandwidth_hz, spur_frequency_hz, spur_power_v2, spur_dbc.", "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, measure_band or peaks may follow psd; at least one PSD result is required.", + " Requires time data before window or fft. Only export, measure_band, spectral_quality 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.", @@ -714,8 +722,8 @@ 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 == "analysis.pipeline": - _normalize_analysis_pipeline_fields(prefix, fields) + if kind in {"analysis.pipeline", "analysis.pair"}: + _normalize_analysis_pipeline_fields(prefix, fields, pair=kind == "analysis.pair") elif kind == "scope.capture": if "label" in fields: fields["label"] = _non_empty_str(fields["label"], f"{prefix}.label") @@ -1241,7 +1249,7 @@ def _validate_analysis_steps(steps: list[RunStep]) -> None: by_id[step.id] = step for step in steps: - if step.kind != "analysis.pipeline": + if step.kind not in {"analysis.pipeline", "analysis.pair"}: continue source_id = step.fields["source"]["step"] source = by_id.get(source_id) @@ -1264,7 +1272,7 @@ def _validate_analysis_steps(steps: list[RunStep]) -> None: analysis_started = False for step in steps: - if step.kind == "analysis.pipeline": + if step.kind in {"analysis.pipeline", "analysis.pair"}: analysis_started = True elif analysis_started: raise ConfigError("analysis.pipeline steps must form a contiguous suffix of the plan") @@ -1276,7 +1284,7 @@ def _normalize_step_id(value: Any, name: str) -> str: return value -def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any]) -> None: +def _normalize_analysis_pipeline_fields(prefix: str, fields: dict[str, Any], *, pair=False) -> None: source = _table(fields["source"], f"{prefix}.source") _reject_unknown_keys(source, {"step"}, f"{prefix}.source") if "step" not in source: @@ -1284,10 +1292,15 @@ 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) + if pair: + from .pair_service import normalize_pair_fields + normalize_pair_fields(fields) + else: + normalize_analysis_operations(prefix, fields) def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None: + from wavebench.data.spectral_quality import QUALITY_FIELDS, normalize_quality if "resources" in fields: from wavebench.data.analysis_resources import normalize_limits @@ -1307,6 +1320,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None: psd_result = False allowed_fields = { + "spectral_quality": QUALITY_FIELDS, "remove_dc": {"op"}, "detrend": {"op", "method"}, "filter": { @@ -1332,6 +1346,7 @@ def normalize_analysis_operations(prefix: str, fields: dict[str, Any]) -> None: "export": {"op", "name", "formats"}, } required_fields = { + "spectral_quality": QUALITY_FIELDS - {"op"}, "detrend": {"method"}, "filter": {"family", "response", "cutoff_hz", "mode"}, "window": {"name"}, @@ -1365,7 +1380,7 @@ 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", "peaks"}: + if domain == "psd" and op not in {"export", "measure_band", "peaks", "spectral_quality"}: 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: @@ -1547,11 +1562,14 @@ 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 in {"measure_band", "peaks"}: - if op == "measure_band" and domain != "psd": + elif op in {"measure_band", "peaks", "spectral_quality"}: + if op in {"measure_band", "spectral_quality"} and domain != "psd": raise ConfigError(f"{operation_prefix}: measure_band requires PSD data") try: - operation = (normalize_band(raw_operation) if op == "measure_band" else normalize_peaks(raw_operation)) + operation = (normalize_quality(raw_operation) if op == "spectral_quality" else + normalize_band(raw_operation) if op == "measure_band" else normalize_peaks(raw_operation)) + if op == "spectral_quality" and next(item for item in reversed(normalized) if item["op"] == "psd")["average"] != "mean": + raise DataError("spectral_quality requires mean Welch PSD") except DataError as exc: raise ConfigError(f"{operation_prefix}: {exc}") from exc if op == "peaks" and domain != "time" and operation["polarity"] != "positive": diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index c03fe186..907cc696 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -15,6 +15,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> EXECUTABLE_STEP_KINDS = { "analysis.pipeline", + "analysis.pair", "power.status", "power.set", "power.output", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index acfab315..19f77661 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -321,6 +321,12 @@ def check(self, plan: RunPlan) -> None: if step.kind == "analysis.pipeline": limits = (self.analysis_limits or AnalysisLimits()).tighten(step.fields.get("resources")) check_static(step.fields["operations"], limits) + elif step.kind == "analysis.pair": + from .pair_service import ensure_pair_dependencies + from wavebench.data.pair_analysis import check_pair_static + limits = (self.analysis_limits or AnalysisLimits()).tighten(step.fields.get("resources")) + check_pair_static(step.fields["operations"], limits) + ensure_pair_dependencies() check_run_plan_safety_limits(plan, self.config.safety_limits) reject_unsupported_steps(plan) ensure_analysis_pipeline_dependencies(plan) @@ -724,7 +730,7 @@ def run( if execution_intent is not None: intent = verify_execution_intent(execution_intent, plan, self.config, resource_limits=self.analysis_limits, execution_policy=self.analysis_execution) plan_hash = intent.plan_digest - analysis_steps = [step for step in plan.steps if step.kind == "analysis.pipeline"] + analysis_steps = [step for step in plan.steps if step.kind in {"analysis.pipeline", "analysis.pair"}] 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) @@ -1115,7 +1121,11 @@ def report_close_errors() -> None: source_step = source_steps[step.fields["source"]["step"]] source_record = source_records.get(source_step.index) try: - artifact = execute_analysis_pipeline( + executor = execute_analysis_pipeline + if step.kind == "analysis.pair": + from .pair_service import execute_pair_step + executor = execute_pair_step + artifact = executor( run_dir=run_dir, step=step, source_step=source_step, diff --git a/tests/test_analysis_batch.py b/tests/test_analysis_batch.py new file mode 100644 index 00000000..93af9b7b --- /dev/null +++ b/tests/test_analysis_batch.py @@ -0,0 +1,107 @@ +import json +from unittest.mock import patch +import pytest + +from wavebench.services.analysis_batch import run_batch +from wavebench.errors import ConfigError +from test_analysis_service import analysis_input as analysis_input + + +def batch_file(tmp_path, capture, recipe, *, duplicates='reject', second=False, cap=1000000): + manifest = tmp_path / 'batch.toml' + text = f'''schema="wavebench.analysis_batch.v1" +recipe={json.dumps(str(recipe))} +on_failure="continue" +duplicates="{duplicates}" +max_output_bytes={cap} +[[entries]] +id="first" +capture={json.dumps(str(capture))} +channel=1 +''' + if second: + text += f'[[entries]]\nid="second"\ncapture={json.dumps(str(capture))}\nchannel=1\n' + manifest.write_text(text) + return manifest + + +def test_batch_success_verified_resume_and_tamper(tmp_path, analysis_input): + capture, recipe = analysis_input + manifest = batch_file(tmp_path, capture, recipe) + output = tmp_path / 'results' + result = run_batch(manifest, output) + assert result['status'] == 'ok' + assert result['entries'][0]['metrics']['voltage_mean_v'] == 1 + with patch('wavebench.services.analysis_batch.run_analysis', side_effect=AssertionError('reprocessed')): + resumed = run_batch(manifest, output, resume=True) + assert resumed['status'] == 'ok' + (output / result['entries'][0]['directory'] / 'metrics.json').write_text('{}') + with pytest.raises(ConfigError, match='artifacts changed'): + run_batch(manifest, output, resume=True) + + +def test_batch_source_change_rejects_resume(tmp_path, analysis_input): + capture, recipe = analysis_input + manifest = batch_file(tmp_path, capture, recipe) + output = tmp_path / 'results' + run_batch(manifest, output) + (capture / 'metadata.json').write_text((capture / 'metadata.json').read_text() + ' ') + with pytest.raises(ConfigError, match='changed'): + run_batch(manifest, output, resume=True) + + +def test_batch_duplicate_and_output_budget(tmp_path, analysis_input): + capture, recipe = analysis_input + manifest = batch_file(tmp_path, capture, recipe, second=True) + with pytest.raises(ConfigError, match='duplicate'): + run_batch(manifest, tmp_path / 'rejected') + manifest = batch_file(tmp_path, capture, recipe, second=True, duplicates='allow', cap=100) + result = run_batch(manifest, tmp_path / 'small') + assert result['status'] == 'failed' + assert all(item['status'] == 'failed' for item in result['entries']) + + +def test_batch_cancel_then_resume(tmp_path, analysis_input): + import threading + capture, recipe = analysis_input + manifest = batch_file(tmp_path, capture, recipe) + event = threading.Event() + event.set() + output = tmp_path / 'results' + first = run_batch(manifest, output, cancel_event=event) + assert first['cancelled'] and first['status'] == 'failed' + result = run_batch(manifest, output, resume=True) + assert result['status'] == 'ok' and not result.get('cancelled') + + +def test_batch_recipe_resources_are_tightened_by_aggregate(tmp_path, analysis_input): + capture, recipe = analysis_input + recipe.write_text(recipe.read_text()+'\n[resources]\nmax_output_bytes=1000000\n') + manifest = batch_file(tmp_path, capture, recipe) + result=run_batch(manifest,tmp_path/'results') + assert result['status']=='ok' + + +def test_batch_failed_attempt_is_preserved_and_retried(tmp_path, analysis_input): + capture,recipe=analysis_input + recipe.write_text(recipe.read_text().replace('min=0.9','min=1.1')) + manifest=batch_file(tmp_path,capture,recipe) + output=tmp_path/'results' + first=run_batch(manifest,output) + assert first['status']=='failed' + second=run_batch(manifest,output,resume=True) + assert second['status']=='failed' and len(second['entries'][0]['attempts'])==2 + assert (output/'items/first/attempt_001/analysis.json').exists() + assert (output/'items/first/attempt_002/analysis.json').exists() + + +def test_batch_continue_source_failure_and_report(tmp_path, analysis_input): + from wavebench.report.analysis import write_analysis_report + capture,recipe=analysis_input + manifest=batch_file(tmp_path,capture,recipe,second=True,duplicates='allow') + text=manifest.read_text().replace('id="first"','id="first"').replace('channel=1','channel=99',1) + manifest.write_text(text) + result=run_batch(manifest,tmp_path/'results') + assert [item['status'] for item in result['entries']]==['failed','ok'] + html=write_analysis_report([tmp_path/'results'],tmp_path/'report.html').read_text() + assert 'polyline' in html diff --git a/tests/test_pair_analysis.py b/tests/test_pair_analysis.py new file mode 100644 index 00000000..e02c9df1 --- /dev/null +++ b/tests/test_pair_analysis.py @@ -0,0 +1,205 @@ +from dataclasses import replace +import json +import numpy as np +import pytest +from scipy.signal import csd, welch, get_window + +from wavebench.data.analysis_resources import AnalysisBudget, AnalysisLimits +from wavebench.data.signal_pipeline import TimeSignal +from wavebench.data.pair_analysis import delay_estimate, transfer_estimate, validate_sync +from wavebench.errors import DataError, ConfigError +from wavebench.services.pair_service import pair_run, pair_check +from wavebench.services.analysis_execution import AnalysisExecution + + +DELAY = dict(op='delay', name='timing', max_lag_s=.05, remove_mean=True, polarity='either', + min_overlap_ratio=.8, min_correlation=.8, ambiguity_delta=.01, + metrics=['response_delay_samples', 'response_delay_s', 'correlation', 'polarity']) +TRANSFER = dict(op='transfer', name='system', window='hann', nperseg=128, noverlap=64, nfft=256, + detrend='constant', min_reference_density=1e-15, min_response_density=1e-15, + min_coherence=.8, unwrap_phase=True, metrics=['mean_coherence', 'min_coherence', 'valid_bin_count']) + + +def sync_evidence(n, dt): + return {'schema': 'wavebench.capture_sync.v1', 'kind': 'synthetic', 'status': 'verified', + 'producer': {'name': 'test-generator', 'version': '1'}, + 'acquisition_group': {'id': 'synthetic-test', 'source': 'synthetic'}, + 'timebase_id': 'shared-clock', 'record_id': 'record-1', + 'guarantees': {'single_record': True, 'frozen_read': True}, + 'channels': {str(ch): {'time_start_s': 0., 'sample_interval_s': dt, 'samples': n, + 'skew_s': 0., 'uncertainty_s': 0.} for ch in (1, 2)}} + + +def pair_package(tmp_path, n=2048): + capture = tmp_path/'capture' + capture.mkdir() + x = np.random.default_rng(17).normal(size=n) + t = np.arange(n)/1024 + np.save(capture/'ch1.npy', np.column_stack((t, x))) + np.save(capture/'ch2.npy', np.column_stack((t, 2*x))) + metadata = {'channels': {'1': {}, '2': {}}, 'files': {'1': {'npy': 'ch1.npy'}, '2': {'npy': 'ch2.npy'}}, + 'synchronization': sync_evidence(n, 1/1024)} + (capture/'metadata.json').write_text(json.dumps(metadata)) + operations = [DELAY, TRANSFER, {'op': 'export', 'name': 'transfer', 'formats': ['npy','csv']}] + def table(op): + return '{'+', '.join(f'{key}={json.dumps(value)}' for key,value in op.items())+'}' + recipe = tmp_path/'pair.toml' + recipe.write_text('schema="wavebench.analysis_pair_recipe.v1"\nreference_channel=1\nresponse_channel=2\noperations=[' + + ','.join(table(op) for op in operations)+']\n[expect]\nsystem_mean_coherence={min=0.99}\n') + return capture, recipe + + +@pytest.mark.parametrize('lag,sign', [(8,1),(-7,1),(9,-1)]) +def test_delay_sign_and_inversion(lag, sign): + rng = np.random.default_rng(14) + x = rng.normal(size=2048) + y = np.zeros_like(x) + if lag > 0: + y[lag:] = x[:-lag]*sign + else: + y[:lag] = x[-lag:]*sign + t = np.arange(len(x))/1024 + values, _ = delay_estimate(TimeSignal(t,x), TimeSignal(t,y), DELAY, AnalysisBudget(AnalysisLimits())) + assert values['timing_response_delay_samples'] == lag + assert values['timing_polarity'] == sign + assert values['timing_response_delay_s'] == lag/1024 + + +def test_periodic_and_zero_delay_unavailable(): + t = np.arange(2048)/1024 + x = np.sin(2*np.pi*64*t) + for samples in (x, np.zeros_like(x)): + metrics, evidence = delay_estimate(TimeSignal(t,samples), TimeSignal(t,samples), DELAY, AnalysisBudget(AnalysisLimits())) + assert metrics['timing_response_delay_s'] is None and evidence['warnings'] + + +@pytest.mark.parametrize('nfft', [255,256]) +def test_transfer_matches_independent_scipy_reference(nfft): + x = np.random.default_rng(2).normal(size=2048) + y = 2*np.roll(x,3) + .1*np.random.default_rng(3).normal(size=2048) + t = np.arange(len(x))/1024 + op = TRANSFER | {'nfft':nfft} + result = transfer_estimate(TimeSignal(t,x), TimeSignal(t,y), op, AnalysisBudget(AnalysisLimits())) + kwargs = dict(fs=1024, window=get_window('hann',128), nperseg=128, noverlap=64,nfft=nfft,detrend='constant') + f,sxy = csd(x,y,**kwargs) + _,sxx = welch(x,**kwargs) + _,syy = welch(y,**kwargs) + h = result.data[:,1] + 1j*result.data[:,2] + np.testing.assert_allclose(h,sxy/sxx,rtol=1e-12,atol=1e-12) + np.testing.assert_allclose(result.data[:,5],abs(sxy)**2/(sxx*syy),rtol=1e-12,atol=1e-12) + np.testing.assert_array_equal(result.data[:,0], f) + + +def test_sync_requires_evidence_and_dt_scaled_axis_match(): + x = TimeSignal(np.arange(100)/1024, np.ones(100)) + with pytest.raises(DataError, match='synchronization evidence'): + validate_sync(x,x,{},(1,2)) + evidence = sync_evidence(100,1/1024) + with pytest.raises(DataError, match='synthetic'): + validate_sync(x,x,evidence | {'kind':'driver'},(1,2)) + shifted = replace(x,time_s=x.time_s+1e-5) + with pytest.raises(DataError): + validate_sync(x,shifted,evidence,(1,2)) + assert validate_sync(x,x,evidence,(1,2))['evidence_kind'] == 'synthetic' + + +def test_pair_source_export_report_and_spawn(tmp_path): + from wavebench.report.analysis import write_analysis_report + capture,recipe = pair_package(tmp_path) + original = (capture/'ch1.npy').read_bytes() + assert pair_check(capture,recipe)['status']=='ok' + result = pair_run(capture,recipe,tmp_path/'result',execution_policy=AnalysisExecution()) + assert result['status']=='ok' + assert result['artifact']['metrics']['timing_response_delay_samples']==0 + array=np.load(tmp_path/'result/exports/transfer.npy') + np.testing.assert_allclose(array[:,3],20*np.log10(2),atol=1e-12) + html=write_analysis_report([tmp_path/'result'],tmp_path/'report.html').read_text() + assert 'Pair analysis' in html and 'Gain (dB)' in html and 'Coherence' in html + assert (capture/'ch1.npy').read_bytes()==original + + +def test_pair_invalid_masks_and_source_gate(tmp_path): + capture,recipe=pair_package(tmp_path) + raw=np.load(capture/'ch1.npy') + raw[:,1]=0 + np.save(capture/'ch2.npy',raw) + result=pair_run(capture,recipe,tmp_path/'masked') + assert result['status']=='failed' # expectation unavailable + array=np.load(tmp_path/'masked/exports/transfer.npy') + assert not array[:,6:].any() and np.isnan(array[:,1:6]).all() + text=(tmp_path/'masked/exports/transfer.csv').read_text() + assert ',,,,,,' in text + metadata=json.loads((capture/'metadata.json').read_text()) + metadata.pop('synchronization') + (capture/'metadata.json').write_text(json.dumps(metadata)) + with pytest.raises(DataError, match='synchronization'): + pair_check(capture,recipe) + + +def test_pair_runplan_schema_and_offline_intent(tmp_path): + from wavebench.services.run_plan import load_run_plan + from wavebench.services.execution_intent import build_execution_intent + from test_run_service import make_config + _, recipe = pair_package(tmp_path) + from wavebench.services.pair_service import load_pair_recipe + fields=load_pair_recipe(recipe,AnalysisLimits()) + operations='['+','.join('{'+','.join(f'{k}={json.dumps(v)}' for k,v in op.items())+'}' for op in fields['operations'])+']' + path=tmp_path/'plan.toml' + path.write_text('[[steps]]\nid="capture"\nkind="scope.capture"\nsave_npy=true\n[[steps]]\nkind="analysis.pair"\n' + 'source={step="capture"}\nreference_channel=1\nresponse_channel=2\noperations='+operations) + plan=load_run_plan(path) + intent=build_execution_intent(plan,make_config(str(tmp_path))) + assert intent.operations[-1]['effect']=='offline' and intent.operations[-1]['lease_mode']=='none' + path.write_text(path.read_text()+'\nsafety_gate={}\n') + with pytest.raises(ConfigError): + load_run_plan(path) + + +def test_pair_runplan_executes_after_hardware_close(tmp_path): + from test_run_service_analysis import PhaseRunService + from test_run_service import make_config + from wavebench.logging import CommandLogger + from wavebench.services.run_artifacts import RunStepRecord + from wavebench.services.run_plan import load_run_plan + from wavebench.services.pair_service import load_pair_recipe + capture,recipe=pair_package(tmp_path) + fields=load_pair_recipe(recipe,AnalysisLimits()) + operations='['+','.join('{'+','.join(f'{k}={json.dumps(v)}' for k,v in op.items())+'}' for op in fields['operations'])+']' + path=tmp_path/'plan.toml' + path.write_text('[[steps]]\nid="capture_main"\nkind="scope.capture"\nsave_npy=true\n[[steps]]\nid="pair_main"\nkind="analysis.pair"\n' + 'source={step="capture_main"}\nreference_channel=1\nresponse_channel=2\noperations='+operations) + record=RunStepRecord(index=0,kind='scope.capture',status='ok',fields={},artifact={'package':str(capture),'metadata':str(capture/'metadata.json')}) + events=[] + service=PhaseRunService(config=make_config(str(tmp_path)),logger=CommandLogger(),capture_record=record,events=events, + analysis_execution=AnalysisExecution()) + from unittest.mock import patch + from wavebench.services.pair_service import execute_pair_step + def checked(**kwargs): + assert events[-1]=='session_and_lease_closed' + return execute_pair_step(**kwargs) + with patch('wavebench.services.pair_service.execute_pair_step',side_effect=checked): + result=service.run(load_run_plan(path)) + assert result.steps[-1].status=='ok' + from wavebench.data.packages import load_run_package + from wavebench.report.html import write_run_report_html + html=write_run_report_html(load_run_package(result.run_dir)).read_text() + assert 'Pair analysis' in html and 'Gain (dB)' in html + + +def test_pair_supervised_cancel_preserves_terminal_state(tmp_path): + import threading + capture, recipe = pair_package(tmp_path) + event = threading.Event() + event.set() + result = pair_run(capture, recipe, tmp_path/'cancelled', execution_policy=AnalysisExecution(), cancel_event=event) + assert result['status'] == 'failed' + assert result['artifact']['analysis_pipeline']['error']['code'] == 'analysis_cancelled' + assert (tmp_path/'cancelled/manifest.json').exists() + + +def test_pair_zero_segment_and_resource_preflight(tmp_path): + capture, recipe = pair_package(tmp_path, n=128) + with pytest.raises(DataError, match='two complete'): + pair_check(capture, recipe) + with pytest.raises(DataError, match='max_fft_length'): + pair_check(capture, recipe, resource_limits=AnalysisLimits(max_fft_length=64)) diff --git a/tests/test_spectral_quality.py b/tests/test_spectral_quality.py new file mode 100644 index 00000000..d493827d --- /dev/null +++ b/tests/test_spectral_quality.py @@ -0,0 +1,85 @@ +from dataclasses import replace +import numpy as np +import pytest + +from wavebench.data.signal_pipeline import PsdSignal +from wavebench.data.spectral_quality import normalize_quality, spectral_quality, QUALITY_METRICS +from wavebench.errors import DataError, ConfigError +from wavebench.services.run_plan import normalize_analysis_operations + + +QUALITY = dict(op='spectral_quality', name='quality', metrics=sorted(QUALITY_METRICS), + band_hz=[0, 100], dc_exclude_hz=[0, 1], exclude_hz=[], fundamental={'frequency_hz': 10}, + fundamental_half_width_hz=1, harmonic_orders=[2, 3], harmonic_half_width_hz=1, + min_fundamental_v2=.1, min_peak_density_v2_per_hz=1, min_prominence_v2_per_hz=1, + min_noise_bins=10, spur_search_hz=[2, 100], spur_half_width_hz=1, spur_distance_hz=3, + spur_min_density_v2_per_hz=1, spur_min_prominence_v2_per_hz=1) + + +def spectrum(): + p = np.full(101, .01) + p[10], p[20], p[30], p[55] = 100, 4, 1, 2 + return PsdSignal(np.arange(101.), p, .005, 400, + {'average': 'mean', 'window': 'hann'}, 3, 0, .375, 'hash', 'test') + + +def test_integral_quality_has_traceable_numerators(): + values, evidence, warnings = spectral_quality(spectrum(), QUALITY) + pf, ph = 100.02, 5.04 + pn = 2 + (99 - 9 - 1) * .01 + assert values['quality_fundamental_power_v2'] == pytest.approx(pf) + assert values['quality_harmonic_power_v2'] == pytest.approx(ph) + assert values['quality_noise_power_v2'] == pytest.approx(pn) + assert values['quality_snr_db'] == pytest.approx(10*np.log10(pf/pn)) + assert values['quality_sinad_db'] == pytest.approx(10*np.log10(pf/(ph+pn))) + assert values['quality_sfdr_db'] == pytest.approx(10*np.log10(pf/4.02)) + assert evidence['largest_spur']['overlaps_harmonic'] + assert not warnings + + +def test_search_threshold_nulls_and_region_rejection(): + signal = spectrum() + values, _, _ = spectral_quality(signal, QUALITY | {'fundamental': {'search_hz': [5, 15]}}) + assert values['quality_fundamental_frequency_hz'] == 10 + zero = replace(signal, psd_v2_per_hz=np.zeros(101)) + values, _, warnings = spectral_quality(zero, QUALITY) + assert all(value is None for value in values.values()) and warnings + with pytest.raises(DataError, match='half-width'): + spectral_quality(signal, QUALITY | {'fundamental_half_width_hz': .5}) + with pytest.raises(DataError, match='overlap'): + spectral_quality(signal, QUALITY | {'harmonic_half_width_hz': 9}) + with pytest.raises(DataError, match='mean'): + spectral_quality(replace(signal, parameters={'average': 'median'}), QUALITY) + + +def test_zero_noise_and_uncovered_harmonics(): + signal = spectrum() + p = np.zeros(101) + p[10] = 100 + values, evidence, warnings = spectral_quality(replace(signal, psd_v2_per_hz=p), QUALITY | {'harmonic_orders': [10]}) + assert values['quality_snr_db'] is None and values['quality_sfdr_db'] is None + assert evidence['harmonics'][0]['covered'] is False and warnings + + +def test_unresolved_spurs_are_unavailable(): + signal = spectrum() + signal.psd_v2_per_hz[57] = 3 + values, _, warnings = spectral_quality(signal, QUALITY) + assert values['quality_sfdr_db'] is None and any('unresolved' in warning for warning in warnings) + + +@pytest.mark.parametrize('change', [{'fundamental': {}}, {'harmonic_orders': [2, 2]}, {'min_noise_bins': True}, + {'extra': 1}, {'metrics': ['snr_db', 'snr_db']}]) +def test_quality_schema_rejects_ambiguous_inputs(change): + with pytest.raises(DataError): + normalize_quality(QUALITY | change) + + +def test_runplan_domain_and_expect_contract(): + psd = dict(op='psd', method='welch', window='hann', nperseg=32, noverlap=16, nfft=32, detrend='none', average='mean') + fields = {'operations': [psd, QUALITY], 'expect': {'quality_snr_db': {'min': 10}}} + normalize_analysis_operations('test', fields) + with pytest.raises(ConfigError): + normalize_analysis_operations('test', {'operations': [psd | {'average': 'median'}, QUALITY]}) + with pytest.raises(ConfigError): + normalize_analysis_operations('test', {'operations': [QUALITY]}) From 9c9554d95059f7e2f8438306ed0a733c7cc39983 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:09:52 +0800 Subject: [PATCH 23/30] docs(analysis): document advanced measurements and synthetic pair examples --- docs/reference/artifacts.md | 12 ++++++ docs/reference/run-schema.md | 60 +++++++++++++++++++++++++++++ plans/README.md | 15 ++++++++ plans/example_pair_analysis.toml | 12 ++++++ plans/example_spectral_quality.toml | 7 ++++ plans/generate_pair_example.py | 47 ++++++++++++++++++++++ 6 files changed, 153 insertions(+) create mode 100644 plans/example_pair_analysis.toml create mode 100644 plans/example_spectral_quality.toml create mode 100644 plans/generate_pair_example.py diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 511442c3..2fd21552 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -138,3 +138,15 @@ recovered, expect_status, expect_failures, expect_fft_status, expect_fft_failure - [从模板到报告](../tutorials/from-template-to-report.md) - [run plan 排错](../how-to/troubleshooting.md) - [run plan Reference](run-schema.md) + +## 高级谱质量、批次与双通道产物 + +`spectral_quality` 的 stage measurement 使用 `wavebench.spectral_quality.v1`,记录完整参数、PSD 条件、定义、频率间隔、各区间的 bin 范围/数量/带宽/V²,以及每阶谐波覆盖状态和最大杂散位置。JSON 标量只保存有限数或 `null`,缺失值沿用 unavailable 验收。 + +批次目录中的 `batch.json` 使用 `wavebench.analysis_batch.v1`,保存 binding、binding_sha256、逐项状态、指标、错误及 attempts 文件摘要;`summary.csv` 列为 `id,status,metrics_json,directory,error_code`。结果位于 `items//attempt_/`,复用独立分析文件。`.batch.lock` 是进程互斥文件。已完成结果恢复前验证内容,未完成 attempt 保留在原位置并计入后续总预算。 + +双通道 manifest 使用 `wavebench.analysis_pair.v1`,外层仍复用 `analysis_pipeline` artifact 键,以共用 run 汇总、expectation 和 R3 监督。独立 `analysis.json` 标记 `analysis_kind="pair"`。来源记录 reference/response 两路 SHA-256、metadata SHA-256、同步证据及时间轴检查;stage measurement 分别使用 `wavebench.pair_delay.v1` 和 `wavebench.pair_transfer.v1`。 + +双通道频域 NPY/CSV 的列固定为 `frequency_hz,real_v,imaginary_v,gain_db,phase_rad,coherence,valid,coherent`。real_v/imaginary_v 为 H1 的实部/虚部,实际单位是 V/V;gain_db 为 20 log10 幅值,phase_rad 为弧度。频率始终有效;无效响应的五个数值列在 NPY 中为 NaN,CSV 中为空,valid/coherent 为 0/1。这个独立合同不改变单通道导出禁止非有限值的规则。JSON 指标继续使用 null。 + +报告将双通道增益、相位、相干性分图展示,保留无效区断点,并显示有效/高相干 bin 数、延迟指标及 synthetic 证据类型。当前曲线渲染读取 NPY;仅导出 CSV 时显示数据链接,不伪造图形。显示抽稀仍不参与测量或验收。 diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index cdfa19d6..4a574961 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -4,6 +4,66 @@ `analysis` 命令直接处理历史 capture package,不需要仪器配置。显式选择一个通道,配方包含 `schema = "wavebench.analysis_recipe.v1"`、`operations` 和可选 `[expect]`/`[resources]`,共用下文的算子与验收合同。示例为 `plans/example_analysis_recipe.toml`。 +## 高级谱质量估计 + +本节及下文批量/双通道接口为开发分支已实现、尚未发布的合同。`spectral_quality` 只接受 mean Welch PSD,沿用命名指标与 `[expect]`。完整配方见 `plans/example_spectral_quality.toml`;每个字段均显式声明。 + +| 字段 | 合同 | +| --- | --- | +| `name`、`metrics` | 安全名称;显式选择下文指标,不重复 | +| `band_hz` | 总测量闭区间,必须在 Nyquist 内 | +| `dc_exclude_hz`、`exclude_hz` | DC 排除区从 0 开始;其它排除区最多 32 个,位于总频带内 | +| `fundamental` | 仅 `{frequency_hz=...}` 或 `{search_hz=[low,high]}`;搜索模式在带内合格峰中选择最大密度峰,不插值 | +| `fundamental_half_width_hz`、`harmonic_half_width_hz` | 正的积分半宽,不小于实际 bin 间隔;不自动推断主瓣 | +| `harmonic_orders` | 显式选择 2~16 阶,最多 15 项且不重复;可为空 | +| `min_fundamental_v2` | 正的基波积分功率门 | +| `min_peak_density_v2_per_hz`、`min_prominence_v2_per_hz` | 正的基波峰密度及显著性门,固定频率也必须通过 | +| `min_noise_bins` | 至少 1 个有效噪声 bin,最大 1000000 | +| `spur_search_hz` | 总频带内的杂散搜索区,排除 DC、无效区及基波区,包含谐波 | +| `spur_half_width_hz`、`spur_distance_hz` | 正的杂散积分半宽和候选最小间距 | +| `spur_min_density_v2_per_hz`、`spur_min_prominence_v2_per_hz` | 正的杂散候选密度/显著性门 | + +按闭区间 bin 中心分配区域,`P(S)=sum(PSD[S])*df`。基波、谐波和剩余噪声区分别为 F、H、N;区域冲突或基波窗口被截断时失败。超出测量带界的谐波记录未覆盖;其结果只说明声明的带内估计。固定基频使用最近 bin 检查峰门,积分区域仍围绕声明频率。 + +- `snr_db = 10*log10(P(F)/P(N))`。 +- `sinad_db = 10*log10(P(F)/(P(H)+P(N)))`。 +- `thdn_ratio = sqrt((P(H)+P(N))/P(F))`,不覆盖旧 `thd_ratio`。 +- `sfdr_db = 10*log10(P(F)/P(spur))`,spur 为最大积分杂散区;相应 `spur_dbc` 为反号。 + +可选指标还包括 `fundamental_frequency_hz`、`fundamental_power_v2`、`harmonic_power_v2`、`noise_power_v2`、`noise_bandwidth_hz`、`spur_frequency_hz`、`spur_power_v2`。输出键为 `_`。有效信号不足、零分母或有效噪声 bin 太少时对应结果为 `null`,不加 epsilon;杂散窗口裁断、重叠或间距不足时 SFDR 不可用,不静默合并谱簇。谱峰端点沿用 SciPy `find_peaks` 的排除规则。 + +这些是带宽受限的 PSD 积分估计:基波区内噪声不扣除,剩余噪声区可能含非谐波杂散,不承诺等价于仪器标准自动 SNR。报告展示实际区域、积分功率及最大杂散位置。旧 FFT 幅值、每 bin 噪声底和 H2~H5 保持原算法。 + +## 串行批量分析 + +`analysis batch --manifest batch.toml --output ` 读取 `wavebench.analysis_batch.v1` 清单。字段为 `recipe`、`entries`、`on_failure="stop|continue"`、`duplicates="reject|allow"`、`max_output_bytes`;路径相对于清单目录解析。每个 entry 必须有唯一安全 `id`、`capture` 和正整数 `channel`。同包同通道重复仅在 `allow` 时接受;条目最多 256 个,并受环境文件数上限约束。 + +批次默认启用 R3 监督,每条分析的默认超时为 300 秒;可用 `--analysis-execution` 显式替换。一次只执行一个条目,取消停止整个批次,普通失败遵循清单的 stop/continue。`max_output_bytes` 不得超过环境总输出限额,历史 attempt、当前结果和索引都计入总额;索引预留空间可能使小配额提前耗尽。失败诊断可尽力超额保存,但批次状态为失败。 + +`--resume` 要求原批次目录,重新核对清单、配方、有效资源/执行配置、数值库版本、来源 metadata/NPY 摘要和已完成产物摘要。变化或损坏时拒绝复用,要求新的输出目录;未成功的条目写入新的 attempt 目录,保留旧文件。文件锁防止两个进程同时写同一批次。中断期间尚未完成的 attempt 不冒充已验证成功结果。 + +批次 JSON/CSV 逐项列出指标、状态、错误和目录,不自动对不同合同或频带的指标求平均。`analysis report --output report.html` 可以复用已保存的曲线,仍受报告资源预算约束。 + +## 双通道分析 + +独立入口为 `analysis pair-check/pair-run --capture --recipe `,`pair-run` 另需新的 `--output`。配方 schema 为 `wavebench.analysis_pair_recipe.v1`,包含 `reference_channel`、`response_channel`、`operations` 和可选 `expect`/`resources`。资源与执行配置选项同单通道接口。示例见 `plans/example_pair_analysis.toml`。 + +RunPlan 使用独立的 `analysis.pair`,同样要求 `source={step="earlier_capture"}` 指向更早且显式保存 NPY 的 `scope.capture`。它与 `analysis.pipeline` 共同组成离线后缀,不支持 safety_gate,在硬件恢复、会话关闭与租约释放后执行。当前真实采集不会生成足够的同步证据,因此该入口只完成 Core 离线集成与 synthetic 测试;真实插件适配与实机验收尚未完成。 + +同包两路必须不同且指向不同 NPY。metadata 的 `synchronization` 必须使用 `wavebench.capture_sync.v1`,当前仅接受 `kind="synthetic"`、`status="verified"`。还要求 `producer.name`/`producer.version`、来源为 synthetic 的 `acquisition_group.id`、`timebase_id`、`record_id`、`single_record`/`frozen_read` 保证,以及每路 time_start_s、sample_interval_s、samples、skew_s、uncertainty_s。示例生成器给出完整结构。普通 metadata 和 SHA-256 用于一致性追溯,并非防伪签名。 + +两路点数一致、各自等间隔,并逐块检查时间轴绝对差不超过 `dt*1e-6`;不使用绝对时间戳的相对容差放宽偏移。不自动裁剪、补零、重采样或 deskew;skew 信息仅记录,不应用补偿。旧包缺证据仍可单通道分析,不能通过同目录、同时间轴或主机 ID 推断同步。 + +| 算子 | 显式字段与约束 | +| --- | --- | +| `delay` | `name`、`max_lag_s>=0`、布尔 `remove_mean`、`polarity="same|either"`、`min_overlap_ratio`、`min_correlation` 在 (0,1]、`ambiguity_delta` 在 [0,1)、`metrics`;至多一次且在 transfer 前 | +| `transfer` | `name`、三种周期 `window`、`nperseg`、`noverlap`、`nfft`、`detrend="none|constant|linear"`、正的 `min_reference_density`/`min_response_density`、`min_coherence` 在 [0,1]、布尔 `unwrap_phase`、`metrics`;至多一次 | +| `export` | 安全 `name`、不重复的 `formats=["npy","csv"]`;必须在 transfer 后,导出名不重复 | + +时延为整数采样:`response_delay_s>0` 表示 response 较晚,去均值对整条记录显式应用,归一化能量只使用当前 lag 的实际重叠。超出记录的搜索范围按实际可重叠样本限制;能量为零、相关门不通过或最高两个 lag 分数差不超过歧义门时不可用。允许反相时按绝对相关值排序并单独报告极性。指标为 `response_delay_samples`、`response_delay_s`、`correlation`、`polarity`、`overlap_samples`,均加测量名称前缀。它描述测量链路总延迟,不是已校准 DUT 延迟。 + +transfer 至少需要两个完整 Welch 段,固定 mean,并共用两路分段谱:`Sxy=mean(conj(X)*Y)`、`H1=Sxy/Sxx`、`coherence=abs(Sxy)^2/(Sxx*Syy)`。弱参考/响应密度处掩码无效;相干性在 1 附近不超过 `1e-9` 的舍入误差可裁剪,明显越界失败。低相干估计保留并以 coherent 掩码区分;相位展开只在连续有效区进行。指标为 `mean_coherence`、`min_coherence`、`valid_bin_count`、`coherent_bin_count`,显式选择并加名称前缀。 + ## 分析进程监督 本节为开发分支已实现、尚未发布的执行合同。`analysis check/run` 与 `run check/intent/verify/plan` 接受 `--analysis-execution `,文件使用 `wavebench.analysis_execution.v1`。示例见 `plans/example_analysis_execution.toml`。 diff --git a/plans/README.md b/plans/README.md index 2e77e644..259d8bbc 100644 --- a/plans/README.md +++ b/plans/README.md @@ -115,3 +115,18 @@ off_power_channels = [1] 公开计划应使用保留地址、占位符和相对路径;不要把真实 IP、序列号、串口路径或 `data/` 下的实验产物写进仓库。 资源与执行环境配置可组合使用:`example_analysis_resources.toml` 设置预算,`example_analysis_execution.toml` 启用独立分析进程、超时和可选硬内存限制。两者都不是 RunPlan,不放入 `--plan`。 + +## 无硬件的高级指标、批量与双通道示例 + +`generate_pair_example.py` 只生成明确标记为 synthetic 的两路随机信号,以及单音加谐波/噪声的单通道包。输出目录必须不存在;双通道 response 为两倍增益、延迟 7 个采样点。示例不连接仪器。 + +```bash +python plans/generate_pair_example.py /tmp/wavebench-demo +python -m wavebench analysis run --capture /tmp/wavebench-demo/tone --channel 1 --recipe plans/example_spectral_quality.toml --output /tmp/wavebench-quality +python -m wavebench analysis batch --manifest /tmp/wavebench-demo/batch.toml --output /tmp/wavebench-batch +python -m wavebench analysis batch --manifest /tmp/wavebench-demo/batch.toml --output /tmp/wavebench-batch --resume +python -m wavebench analysis pair-run --capture /tmp/wavebench-demo --recipe plans/example_pair_analysis.toml --output /tmp/wavebench-pair --analysis-execution plans/example_analysis_execution.toml +python -m wavebench analysis report /tmp/wavebench-pair --output /tmp/wavebench-pair.html +``` + +Windows 可将 `/tmp/...` 替换为本地新目录。高级指标窗口按示例的 16384 Hz 采样率设计;换用历史包前需要按实际采样率和频率分辨率修改窗口及频带。缺少同步证据的真实旧包不适用于双通道示例,可用于单通道及批量流程验证。 diff --git a/plans/example_pair_analysis.toml b/plans/example_pair_analysis.toml new file mode 100644 index 00000000..1467a0d4 --- /dev/null +++ b/plans/example_pair_analysis.toml @@ -0,0 +1,12 @@ +# Offline pair recipe for generate_pair_example.py's synthetic package. +schema = "wavebench.analysis_pair_recipe.v1" +reference_channel = 1 +response_channel = 2 +operations = [ + { op = "delay", name = "timing", max_lag_s = 0.002, remove_mean = true, polarity = "either", min_overlap_ratio = 0.9, min_correlation = 0.8, ambiguity_delta = 0.01, metrics = ["response_delay_samples", "response_delay_s", "correlation", "polarity", "overlap_samples"] }, + { op = "transfer", name = "system", window = "hann", nperseg = 1024, noverlap = 512, nfft = 1024, detrend = "constant", min_reference_density = 0.000000000001, min_response_density = 0.000000000001, min_coherence = 0.8, unwrap_phase = true, metrics = ["mean_coherence", "min_coherence", "valid_bin_count", "coherent_bin_count"] }, + { op = "export", name = "transfer", formats = ["npy", "csv"] }, +] +[expect] +timing_response_delay_samples = { min = 7, max = 7 } +system_mean_coherence = { min = 0.95 } diff --git a/plans/example_spectral_quality.toml b/plans/example_spectral_quality.toml new file mode 100644 index 00000000..202ecc03 --- /dev/null +++ b/plans/example_spectral_quality.toml @@ -0,0 +1,7 @@ +# Offline recipe: use analysis check/run --recipe, not run plan --plan. +schema = "wavebench.analysis_recipe.v1" +operations = [ + { op = "psd", method = "welch", window = "hann", nperseg = 1024, noverlap = 512, nfft = 1024, detrend = "constant", average = "mean" }, + { op = "spectral_quality", name = "quality", metrics = ["snr_db", "sinad_db", "sfdr_db", "thdn_ratio", "fundamental_frequency_hz", "fundamental_power_v2", "harmonic_power_v2", "noise_power_v2", "noise_bandwidth_hz", "spur_frequency_hz", "spur_power_v2", "spur_dbc"], band_hz = [0, 8000], dc_exclude_hz = [0, 20], exclude_hz = [], fundamental = { search_hz = [900, 1100] }, fundamental_half_width_hz = 40, harmonic_orders = [2, 3, 4, 5], harmonic_half_width_hz = 40, min_fundamental_v2 = 0.001, min_peak_density_v2_per_hz = 0.00001, min_prominence_v2_per_hz = 0.00001, min_noise_bins = 10, spur_search_hz = [100, 7800], spur_half_width_hz = 40, spur_distance_hz = 100, spur_min_density_v2_per_hz = 0.0000001, spur_min_prominence_v2_per_hz = 0.0000001 }, + { op = "export", name = "density", formats = ["npy", "csv"] }, +] diff --git a/plans/generate_pair_example.py b/plans/generate_pair_example.py new file mode 100644 index 00000000..f3230ff4 --- /dev/null +++ b/plans/generate_pair_example.py @@ -0,0 +1,47 @@ +"""Generate a labeled synthetic capture and batch manifest; never access instruments.""" +import argparse +import json +from pathlib import Path + +import numpy as np + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', type=Path, help='New output directory') + args = parser.parse_args() + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + n, rate = 16384, 16384 + times = np.arange(n)/rate + rng = np.random.default_rng(2026) + reference = rng.normal(size=n) + response = np.zeros(n) + response[7:] = 2*reference[:-7] + for channel, voltage in ((1, reference), (2, response)): + np.save(root/f'ch{channel}.npy', np.column_stack((times,voltage))) + metadata = {'channels': {'1': {}, '2': {}}, + 'files': {'1': {'npy':'ch1.npy'}, '2': {'npy':'ch2.npy'}}, + 'synchronization': {'schema':'wavebench.capture_sync.v1', 'kind':'synthetic', 'status':'verified', + 'producer': {'name':'wavebench-example-generator', 'version':'1'}, + 'acquisition_group': {'id':'seed-2026-pair', 'source':'synthetic'}, + 'timebase_id':'synthetic-16384hz', 'record_id':'one-record', + 'guarantees': {'single_record':True, 'frozen_read':True}, + 'channels': {str(ch): {'time_start_s':0., 'sample_interval_s':1/rate, 'samples':n, + 'skew_s':0., 'uncertainty_s':0.} for ch in (1,2)}}} + (root/'metadata.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8') + # A separate single-tone capture illustrates PSD quality estimates. + tone = root/'tone' + tone.mkdir() + voltage = np.sin(2*np.pi*1000*times)+.03*np.sin(2*np.pi*2000*times)+.002*rng.normal(size=n) + np.save(tone/'ch1.npy',np.column_stack((times,voltage))) + (tone/'metadata.json').write_text(json.dumps({'waveform':{'summary':{'channel':1}},'files':{'npy':'ch1.npy'}}),encoding='utf-8') + recipe = Path(__file__).resolve().with_name('example_spectral_quality.toml') + (root/'batch.toml').write_text('schema="wavebench.analysis_batch.v1"\nrecipe='+json.dumps(str(recipe))+ + '\non_failure="continue"\nduplicates="allow"\nmax_output_bytes=10000000\n'+ + ''.join(f'[[entries]]\nid="tone_{i}"\ncapture="tone"\nchannel=1\n' for i in (1,2)),encoding='utf-8') + print(root) + + +if __name__ == '__main__': + main() From 625658d760c7a08cb4090e3f13f82279a0dc55fa Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:46:22 +0800 Subject: [PATCH 24/30] feat(scope): support driver-proven synchronized pair capture --- docs/reference/generated/run-schema.md | 7 +- src/wavebench/cli.py | 5 +- src/wavebench/cli_parser.py | 1 + src/wavebench/data/pair_analysis.py | 25 ++- src/wavebench/instruments/capabilities.py | 1 + .../instruments/synchronized_capture.py | 19 +++ src/wavebench/services/execution_intent.py | 2 + src/wavebench/services/operation_specs.py | 1 + src/wavebench/services/pair_service.py | 15 +- src/wavebench/services/run_plan.py | 18 ++- src/wavebench/services/run_safety.py | 6 +- src/wavebench/services/run_service.py | 6 +- src/wavebench/services/scope_service.py | 33 +++- .../transport/rsinstrument_transport.py | 4 +- tests/test_rsinstrument_transport.py | 8 + tests/test_synchronized_capture.py | 145 ++++++++++++++++++ 16 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 src/wavebench/instruments/synchronized_capture.py create mode 100644 tests/test_synchronized_capture.py diff --git a/docs/reference/generated/run-schema.md b/docs/reference/generated/run-schema.md index 39b9238e..e8ba3d9b 100644 --- a/docs/reference/generated/run-schema.md +++ b/docs/reference/generated/run-schema.md @@ -18,7 +18,7 @@ Supported step kinds: - analysis.pair required: source, reference_channel, response_channel, operations optional : expect, on_failure, resources - note : Analyze two evidence-validated channels from one earlier capture package after hardware cleanup. Currently accepts synthetic synchronization evidence only; real driver adaptation is not supported. + note : Analyze two evidence-validated channels from one earlier capture package after hardware cleanup. Accepts synthetic or driver-owned frozen-single synchronization evidence. - analysis.pipeline required: source, operations optional : expect, on_failure, resources @@ -95,7 +95,7 @@ Supported step kinds: note : Explicit RTM2032 AUToscale. It changes front-panel settings and is never inserted implicitly. - scope.capture required: - - optional : auto_recover, autoscale_before_capture, autoscale_settle_s, channel, expect, expect_fft, expect_frequency_hz, frequency_tolerance, label, on_failure, points, quality_gate, safety_gate, save_csv, save_npy, screenshot, target_cycles, target_vpp, time_range_s, vertical_scale_v_per_div, window_frequency_hz + optional : auto_recover, autoscale_before_capture, autoscale_settle_s, channel, channels, expect, expect_fft, expect_frequency_hz, frequency_tolerance, label, on_failure, points, quality_gate, safety_gate, save_csv, save_npy, screenshot, synchronized, target_cycles, target_vpp, time_range_s, vertical_scale_v_per_div, window_frequency_hz note : 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. - sleep required: duration_s @@ -254,8 +254,9 @@ analysis.pipeline metrics: 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 _. + scope.capture synchronized=true requires channels=[1,2], save_npy=true and DEF points; single-channel quality/auto-retry fields are not accepted. Requires scope.capture_synchronized capability. analysis.pair: reference_channel and response_channel must be distinct; source uses one earlier scope.capture with explicit save_npy=true. - Pair operations: delay (integer lag), transfer (mean Welch H1/coherence), export. Only synthetic synchronization evidence is currently accepted. + Pair operations: delay (integer lag), transfer (mean Welch H1/coherence), export. Synthetic and driver_frozen_single evidence are accepted. spectral_quality requires explicit integration bands, fundamental mode, harmonic orders, detection thresholds and metrics; only mean Welch PSD is accepted. Quality metrics: snr_db, sinad_db, sfdr_db, thdn_ratio, fundamental_frequency_hz, fundamental_power_v2, harmonic_power_v2, noise_power_v2, noise_bandwidth_hz, spur_frequency_hz, spur_power_v2, spur_dbc. analysis.pipeline PSD operation: diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index fdc280f0..dfd2e5d4 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -2254,6 +2254,8 @@ def _main(argv: list[str] | None = None) -> int: channels = args.channel or [service.config.scope.default_channel] for channel in channels: service.require_high_impedance(channel, allow_50ohm=args.allow_50ohm) + if args.synchronized and len(channels) != 2: + raise ConfigError("synchronized capture requires two channels") if len(channels) == 1: result = service.capture_waveform(channel=channels[0], label=args.label) _print_waveform_summary(result.waveform) @@ -2267,7 +2269,8 @@ def _main(argv: list[str] | None = None) -> int: if result.commands_log_path is not None: print(f"commands_log={result.commands_log_path}") return 0 - result = service.capture_waveforms(channels=channels, label=args.label) + result = service.capture_waveforms(channels=channels, label=args.label, + **({"synchronized": True} if args.synchronized else {})) for channel in channels: _print_waveform_summary(result.waveforms[channel]) files = result.files.get(str(channel), {}) diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 76bdc7c6..f7c60b25 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -1549,6 +1549,7 @@ def add_trace_reference(parser: argparse.ArgumentParser) -> None: capture = scope_sub.add_parser("capture", help="Capture waveform data into an acquisition package") capture.add_argument("--channel", type=int, action="append", default=None, help="Capture channel; repeat for multiple channels") capture.add_argument("--label", default="capture") + capture.add_argument("--synchronized", action="store_true", help="Require driver-proven single frozen multichannel capture") capture.add_argument("--points", default=None, help="Override waveform points: def, max, or dmax") capture.add_argument( "--time-range", diff --git a/src/wavebench/data/pair_analysis.py b/src/wavebench/data/pair_analysis.py index d6ea3bd8..280b0e9a 100644 --- a/src/wavebench/data/pair_analysis.py +++ b/src/wavebench/data/pair_analysis.py @@ -87,13 +87,26 @@ def check_pair_static(operations, limits, metadata_files=2): def validate_sync(reference, response, evidence, channels): if not isinstance(evidence, dict) or evidence.get('schema') != 'wavebench.capture_sync.v1': raise DataError('pair source requires wavebench.capture_sync.v1 synchronization evidence') - if evidence.get('kind') != 'synthetic': - raise DataError('driver synchronization evidence is not yet supported; synthetic evidence only') + kind = evidence.get('kind') + if kind not in ('synthetic', 'driver_frozen_single'): + raise DataError('unsupported synchronization kind; synthetic or driver_frozen_single required') + if kind == 'driver_frozen_single': + driver, procedure = evidence.get('driver'), evidence.get('procedure') + if (not isinstance(driver, dict) or not all(isinstance(driver.get(k), str) and driver[k] for k in ('id', 'model', 'firmware')) + or not isinstance(procedure, dict) or not isinstance(procedure.get('contract'), str) or not procedure['contract'] + or type(procedure.get('single_count')) is not int or procedure['single_count'] != 1 + or any(procedure.get(k) is not True for k in ('single_opc', 'stop_opc_before', 'stop_opc_after')) + or procedure.get('reads') != [{'channel': ch, 'configuration_unchanged': True} for ch in sorted(channels)] + or not isinstance(procedure.get('configuration'), dict) or not procedure['configuration']): + raise DataError('driver frozen-single proof is incomplete') + producer = evidence.get('producer', {}) + if not isinstance(producer, dict) or producer.get('name') != driver['id'] or producer.get('version') != procedure['contract']: + raise DataError('driver proof producer and procedure do not match') if evidence.get('status') != 'verified': raise DataError('synchronization evidence must be verified') producer, group, guarantees = evidence.get('producer', {}), evidence.get('acquisition_group', {}), evidence.get('guarantees', {}) if (not isinstance(producer, dict) or not all(isinstance(producer.get(k), str) and producer[k] for k in ('name', 'version')) - or not isinstance(group, dict) or group.get('source') != 'synthetic' or not isinstance(group.get('id'), str) or not group['id'] + or not isinstance(group, dict) or group.get('source') != kind or not isinstance(group.get('id'), str) or not group['id'] or not isinstance(guarantees, dict) or guarantees.get('single_record') is not True or guarantees.get('frozen_read') is not True or any(not isinstance(evidence.get(k), str) or not evidence[k] for k in ('timebase_id', 'record_id'))): raise DataError('incomplete synchronization provenance, timebase or acquisition guarantee') @@ -114,16 +127,18 @@ def validate_sync(reference, response, evidence, channels): if integer(item['samples'], 'sync samples', 1, 2**63-1) != len(signal.time_s): raise DataError('synchronization sample count disagrees with NPY') for key in ('time_start_s', 'sample_interval_s', 'skew_s', 'uncertainty_s'): + if kind == 'driver_frozen_single' and key in ('skew_s', 'uncertainty_s') and item[key] is None: + continue if type(item[key]) not in (int, float) or not np.isfinite(item[key]): raise DataError('synchronization values must be finite') - if item['uncertainty_s'] < 0 or abs(item['sample_interval_s'] - dt) > tolerance or abs(item['time_start_s'] - signal.time_s[0]) > tolerance: + if (item['uncertainty_s'] is not None and item['uncertainty_s'] < 0) or abs(item['sample_interval_s'] - dt) > tolerance or abs(item['time_start_s'] - signal.time_s[0]) > tolerance: raise DataError('synchronization time origin, interval or uncertainty disagrees with NPY') for start in range(0, len(reference.time_s), 4096): checkpoint() if np.any(np.abs(reference.time_s[start:start+4096] - response.time_s[start:start+4096]) > tolerance): raise DataError('pair time axes differ beyond dt * 1e-6') return {'samples': len(reference.time_s), 'sample_interval_s': dt, 'axis_atol_s': tolerance, - 'evidence_kind': 'synthetic', 'delay_scope': 'measurement_chain_uncalibrated'} + 'evidence_kind': kind, 'delay_scope': 'measurement_chain_uncalibrated'} def delay_estimate(x, y, operation, budget): diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index 653cc48b..e3436224 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -35,6 +35,7 @@ "scope.fetch_waveform": ("fetch_waveform",), "scope.capture_waveform": ("capture_waveform",), "scope.capture_waveforms": ("capture_waveforms",), + "scope.capture_synchronized": ("capture_synchronized",), "scope.screenshot": ("screenshot_png",), "scope.channel_coupling": ("channel_coupling",), "scope.snapshot": ("get_snapshot",), diff --git a/src/wavebench/instruments/synchronized_capture.py b/src/wavebench/instruments/synchronized_capture.py new file mode 100644 index 00000000..18b43ed8 --- /dev/null +++ b/src/wavebench/instruments/synchronized_capture.py @@ -0,0 +1,19 @@ +"""Portable proof returned by a driver-owned single, frozen multi-channel transaction.""" +from dataclasses import dataclass +from typing import Any, Callable, Protocol, runtime_checkable + +from .models import WaveformData + + +@dataclass(frozen=True) +class SynchronizedCapture: + waveforms: dict[int, WaveformData] + synchronization: dict[str, Any] + + +@runtime_checkable +class SynchronizedScopeDriver(Protocol): + def capture_synchronized(self, *, channels: list[int], points: str = 'DEF', + check_errors: bool = True, time_range_s: float | None = None, + vertical_scale_v_per_div: float | None = None, + on_waveform: Callable[[int, WaveformData], None] | None = None) -> SynchronizedCapture: ... diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index f07622b2..d366b09b 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -73,6 +73,8 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_l operations: list[dict[str, Any]] = [] for step in plan.steps: operation_name = _STEP_OPERATIONS.get(step.kind, step.kind) + if step.kind == 'scope.capture' and step.fields.get('synchronized'): + operation_name = 'scope.capture_synchronized' spec = get_operation_spec(operation_name) fields = dict(step.fields) payload_ref = _payload_reference( diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 1ded6992..c83267f6 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -435,6 +435,7 @@ def _spec( risk_flags=("trigger", "acquisition_state", "temporary_transfer_setup"), ), _spec("scope.capture_multiple", "scope", required_capabilities=("scope.capture_waveforms",), effect="acquire", changed_fields=_SCOPE_CAPTURE_CHANGED_FIELDS, restore_coverage="capture-baseline-only", required_verified_fields=("scope.identity",), verification_fields=_SCOPE_CAPTURE_VERIFICATION_FIELDS, risk_flags=("trigger", "acquisition_state", "temporary_transfer_setup")), + _spec("scope.capture_synchronized", "scope", required_capabilities=("scope.capture_synchronized",), effect="acquire", changed_fields=_SCOPE_CAPTURE_CHANGED_FIELDS, restore_coverage="capture-baseline-only", required_verified_fields=("scope.identity",), verification_fields=_SCOPE_CAPTURE_VERIFICATION_FIELDS, risk_flags=("trigger", "acquisition_state", "temporary_transfer_setup")), _spec("scope.fetch_waveform", "scope", required_capabilities=("scope.fetch_waveform",), effect="acquire", changed_fields=_SCOPE_CAPTURE_CHANGED_FIELDS, restore_coverage="capture-baseline-only", required_verified_fields=("scope.identity",), verification_fields=_SCOPE_CAPTURE_VERIFICATION_FIELDS, risk_flags=("acquisition_state", "temporary_transfer_setup")), _spec("scope.capture_average", "scope", required_capabilities=("scope.capture_average",), effect="acquire", changed_fields=("acquisition", "waveform_package"), risk_flags=("trigger", "acquisition_state")), _spec("scope.digital_status", "scope", required_capabilities=("scope.digital_status",), effect="stateful_read"), diff --git a/src/wavebench/services/pair_service.py b/src/wavebench/services/pair_service.py index 7ee94c48..2bd0bec7 100644 --- a/src/wavebench/services/pair_service.py +++ b/src/wavebench/services/pair_service.py @@ -72,8 +72,14 @@ def load_pair_source(capture, fields, limits): before = _sha256_file(meta_path) metadata = read_json_bounded(meta_path, limits) evidence = metadata.get('synchronization') - if not isinstance(evidence, dict) or evidence.get('schema') != 'wavebench.capture_sync.v1' or evidence.get('kind') != 'synthetic': - raise DataError('pair requires explicit synthetic synchronization evidence; real driver evidence is not yet supported') + if not isinstance(evidence, dict) or evidence.get('schema') != 'wavebench.capture_sync.v1' or evidence.get('kind') not in ('synthetic', 'driver_frozen_single'): + raise DataError('pair requires explicit supported synchronization evidence') + if evidence.get('kind') == 'driver_frozen_single': + driver = evidence.get('driver', {}) + identity = metadata.get('instrument', {}).get('idn', '') + parts = identity.split(',') if isinstance(identity, str) else [] + if len(parts) != 4 or parts[1].strip() != driver.get('model') or parts[3].strip() != driver.get('firmware'): + raise DataError('driver synchronization model/firmware differs from capture identity') channels = (fields['reference_channel'], fields['response_channel']) entries = _capture_channels(metadata) paths, total = [], 0 @@ -268,9 +274,8 @@ def blocks(): def execute_pair_step(*, run_dir, step, source_step, source_record, resource_limits=None, execution_policy=None, cancel_event=None): if source_record is None or not source_record.artifact.get('package'): raise DataError('pair source capture was not executed or has no package') - package = Path(source_record.artifact['package']) - if not package.is_absolute(): - package = run_dir/package + # Capture records historically store paths relative to the process working directory. + package = Path(source_record.artifact['package']).resolve() limits = (resource_limits or AnalysisLimits()).tighten(step.fields.get('resources')) return execute_pair(run_dir=run_dir, processing_dir=run_dir/'processing'/f"{step.index:02d}_{step.id or 'analysis_pair'}", capture=package, fields=step.fields, limits=limits, diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 1f8f4839..fe2dbd19 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -196,7 +196,7 @@ "analysis.pair": {"expect", "on_failure", "resources"}, "scope.auto": {"on_failure"}, "scope.capture": { - "channel", + "channel", "channels", "synchronized", "label", "points", "time_range_s", @@ -343,7 +343,7 @@ _STEP_NOTES = { - "analysis.pair": "Analyze two evidence-validated channels from one earlier capture package after hardware cleanup. Currently accepts synthetic synchronization evidence only; real driver adaptation is not supported.", + "analysis.pair": "Analyze two evidence-validated channels from one earlier capture package after hardware cleanup. Accepts synthetic or driver-owned frozen-single synchronization evidence.", "analysis.pipeline": "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.", "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.", @@ -470,8 +470,9 @@ def format_run_plan_schema() -> str: " 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 _.", "", + " scope.capture synchronized=true requires channels=[1,2], save_npy=true and DEF points; single-channel quality/auto-retry fields are not accepted. Requires scope.capture_synchronized capability.", "analysis.pair: reference_channel and response_channel must be distinct; source uses one earlier scope.capture with explicit save_npy=true.", - " Pair operations: delay (integer lag), transfer (mean Welch H1/coherence), export. Only synthetic synchronization evidence is currently accepted.", + " Pair operations: delay (integer lag), transfer (mean Welch H1/coherence), export. Synthetic and driver_frozen_single evidence are accepted.", " spectral_quality requires explicit integration bands, fundamental mode, harmonic orders, detection thresholds and metrics; only mean Welch PSD is accepted.", " Quality metrics: snr_db, sinad_db, sfdr_db, thdn_ratio, fundamental_frequency_hz, fundamental_power_v2, harmonic_power_v2, noise_power_v2, noise_bandwidth_hz, spur_frequency_hz, spur_power_v2, spur_dbc.", "analysis.pipeline PSD operation:", @@ -725,6 +726,17 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non if kind in {"analysis.pipeline", "analysis.pair"}: _normalize_analysis_pipeline_fields(prefix, fields, pair=kind == "analysis.pair") elif kind == "scope.capture": + if "synchronized" in fields and type(fields['synchronized']) is not bool: + raise ConfigError('scope.capture synchronized must be boolean') + if "channels" in fields or fields.get('synchronized'): + if fields.get('synchronized') is not True or fields.get('channels') != [1, 2] or any(type(ch) is not int for ch in fields['channels']): + raise ConfigError('synchronized scope.capture requires channels=[1,2]') + forbidden = {'channel', 'quality_gate', 'auto_recover', 'autoscale_before_capture', 'expect', 'expect_fft'} & set(fields) + if forbidden: + raise ConfigError('synchronized scope.capture does not accept single-channel quality/retry fields') + if fields.get('save_npy') is not True or not isinstance(fields.get('points', 'DEF'), str) or fields.get('points', 'DEF').upper() != 'DEF': + raise ConfigError('synchronized scope.capture requires save_npy=true and DEF points') + fields['points'] = 'DEF' if "label" in fields: fields["label"] = _non_empty_str(fields["label"], f"{prefix}.label") if "points" in fields: diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index 907cc696..5705f0e8 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -138,9 +138,9 @@ def plan_scope_guard_channels(plan: RunPlan, default_channel: int) -> list[int]: channels: list[int] = [] for step in plan.steps: if step.kind == "scope.capture": - channel = step.fields.get("channel") or default_channel - if channel not in channels: - channels.append(channel) + for channel in step.fields.get('channels', [step.fields.get("channel") or default_channel]): + if channel not in channels: + channels.append(channel) elif step.kind == "sweep.frequency_response": for field in ("reference_channel", "response_channel"): channel = step.fields[field] diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 19f77661..188bc156 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -492,7 +492,7 @@ def add_source_restore_capabilities() -> None: if self.config.autoscale.check_errors: add("scope", "scope.errors") elif step.kind == "scope.capture": - add("scope", "scope.idn", "scope.capture_waveform") + add("scope", "scope.idn", "scope.capture_synchronized" if step.fields.get('synchronized') else "scope.capture_waveform") if self.config.scope.check_errors: add("scope", "scope.errors") if step.fields.get("screenshot", self.config.output.save_screenshot): @@ -2874,6 +2874,10 @@ def _run_scope_capture_step( service = self._scope_service_for_capture(plan, step, services=services) channel = step.fields.get("channel", self.config.scope.default_channel) label = step.fields.get("label", f"{plan.label}_{step.index:02d}_capture") + if step.fields.get('synchronized'): + capture = service.capture_waveforms(channels=step.fields['channels'], label=label, synchronized=True) + return {'package': str(capture.package_dir), 'metadata': str(capture.metadata_path), + 'synchronization': {'status': 'verified'}, 'channels': step.fields['channels']} autoscale_before_capture = step.fields.get("autoscale_before_capture", False) autoscale_settle_s = step.fields.get("autoscale_settle_s", 0.0) autoscale_record: dict[str, Any] | None = None diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py index 8c5411da..a0d655e5 100644 --- a/src/wavebench/services/scope_service.py +++ b/src/wavebench/services/scope_service.py @@ -1547,20 +1547,25 @@ def capture_waveform(self, channel: int, label: str) -> CaptureResult: commands_log_path=commands_log_path, ) - def capture_waveforms(self, channels: list[int], label: str) -> MultiCaptureResult: + def capture_waveforms(self, channels: list[int], label: str, *, synchronized: bool = False) -> MultiCaptureResult: if not channels: raise ConfigError("at least one channel is required") if len(set(channels)) != len(channels): raise ConfigError("duplicate channels are not allowed") bounded_profile = self._waveform_binary_profile() - required = ["scope.idn", "scope.capture_waveforms"] + synchronization = None + if synchronized and (sorted(channels) != [1, 2] or self.config.waveform.points.upper() != "DEF"): + raise ConfigError("synchronized capture currently requires channels 1,2 and DEF points") + required = ["scope.idn", "scope.capture_synchronized" if synchronized else "scope.capture_waveforms"] if bounded_profile is not None and self.config.scope.check_errors: required.append("scope.error_drain_v1") elif self.config.scope.check_errors: required.append("scope.errors") if self.config.output.save_screenshot: required.append(self._legacy_capture_screenshot_capability()) - self._require("scope.capture_multiple", *required) + self._require("scope.capture_synchronized" if synchronized else "scope.capture_multiple", *required) + if synchronized and (not self.config.output.save_npy or not self.config.output.save_json): + raise ConfigError("synchronized capture requires NPY and JSON outputs") package_dir = new_package_dir(self.config.output.directory, label) package_dir.mkdir(parents=True, exist_ok=False) commands_log_path = package_dir / "commands.log" if self.config.output.save_commands_log else None @@ -1617,7 +1622,25 @@ def save_waveform(channel: int, waveform: WaveformData) -> None: stage = "acquire" failed_channel = None - if bounded_profile is not None: + if synchronized: + from wavebench.instruments.synchronized_capture import SynchronizedCapture + evidence = self._session_preflight("scope.capture_synchronized", scope) + instrument_idn = evidence.get("scope.identity") or scope.idn() + result = scope.capture_synchronized(channels=channels, points=self.config.waveform.points, + check_errors=self.config.scope.check_errors, time_range_s=self.config.waveform.time_range_s, + vertical_scale_v_per_div=self.config.waveform.vertical_scale_v_per_div, on_waveform=save_waveform) + if not isinstance(result, SynchronizedCapture): + raise DataError("driver returned invalid synchronized capture") + from wavebench.data.pair_analysis import validate_sync + from wavebench.data.signal_pipeline import TimeSignal + if set(result.waveforms) != set(channels): + raise DataError("synchronized capture returned incomplete channels") + left, right = (result.waveforms[ch] for ch in sorted(channels)) + validate_sync(TimeSignal(left.times_s, left.voltages_v), TimeSignal(right.times_s, right.voltages_v), + result.synchronization, sorted(channels)) + synchronization = result.synchronization + returned_waveforms = result.waveforms + elif bounded_profile is not None: result = self._bounded_waveform_executor(scope).capture_multiple( channels=channels, points=self.config.waveform.points, @@ -1695,6 +1718,8 @@ def save_waveform(channel: int, waveform: WaveformData) -> None: "channels": channel_metadata, "files": metadata_files, } + if synchronization is not None: + metadata["synchronization"] = synchronization if screenshot_error is not None: metadata["screenshot_error"] = screenshot_error metadata_path = package_dir / "metadata.json" diff --git a/src/wavebench/transport/rsinstrument_transport.py b/src/wavebench/transport/rsinstrument_transport.py index ea39337c..bddcb570 100644 --- a/src/wavebench/transport/rsinstrument_transport.py +++ b/src/wavebench/transport/rsinstrument_transport.py @@ -392,7 +392,9 @@ def query_opc( def read_once() -> str: nonlocal attempts attempts += 1 - return str(self.session.query_opc()).strip() + result = self.session.query_opc() + # RsInstrument versions return bool as well as the documented integer. + return ("1" if result else "0") if isinstance(result, bool) else str(result).strip() try: response = self._read_with_policy("query_opc", "*OPC?", replay, read_once) diff --git a/tests/test_rsinstrument_transport.py b/tests/test_rsinstrument_transport.py index 252d434d..da854b1d 100644 --- a/tests/test_rsinstrument_transport.py +++ b/tests/test_rsinstrument_transport.py @@ -208,3 +208,11 @@ def test_rsinstrument_close_reports_backend_failure(): transport.close() assert raised.value.failures == ({"component": "session", "type": "RuntimeError"},) + + +@pytest.mark.parametrize('value,expected', [(True,'1'), (False,'0'), (1,'1'), ('1','1')]) +def test_opc_normalizes_boolean_backend_response(value, expected): + session = FakeSession() + session.query_opc = lambda: value + transport = RsInstrumentTransport('TCPIP::example::INSTR', session, CommandLogger()) + assert transport.query_opc() == expected diff --git a/tests/test_synchronized_capture.py b/tests/test_synchronized_capture.py new file mode 100644 index 00000000..4e110004 --- /dev/null +++ b/tests/test_synchronized_capture.py @@ -0,0 +1,145 @@ +import json +from pathlib import Path +import pytest +from wavebench.errors import ConfigError, DataError +from wavebench.data.analysis_resources import AnalysisLimits +from wavebench.services.run_plan import load_run_plan +from wavebench.services.pair_service import load_pair_source, execute_pair_step +from wavebench.services.run_artifacts import RunStepRecord +from wavebench.services.execution_intent import build_execution_intent +from test_pair_analysis import pair_package +from test_run_service import make_config + + +def driver_evidence(capture): + path=capture/'metadata.json' + metadata=json.loads(path.read_text()) + evidence=metadata['synchronization'] + evidence.update(kind='driver_frozen_single', producer={'name':'example.scope','version':'single-stop.v1'}, + driver={'id':'example.scope','model':'EXAMPLE','firmware':'1.0'}) + evidence['acquisition_group']['source']='driver_frozen_single' + evidence['procedure']={'contract':'single-stop.v1','single_count':1,'single_opc':True, + 'stop_opc_before':True,'stop_opc_after':True, + 'reads':[{'channel':ch,'configuration_unchanged':True} for ch in (1,2)], + 'configuration':{'mode':'single'}} + for item in evidence['channels'].values(): + item.update(skew_s=None,uncertainty_s=None) + metadata['instrument']={'idn':'Example,EXAMPLE,REDACTED,1.0'} + path.write_text(json.dumps(metadata)) + return metadata + + +def plan_file(tmp_path): + path=tmp_path/'plan.toml' + path.write_text('''[[steps]] +id="capture" +kind="scope.capture" +channels=[1,2] +synchronized=true +save_npy=true +[[steps]] +id="pair" +kind="analysis.pair" +source={step="capture"} +reference_channel=1 +response_channel=2 +operations=[{op="delay",name="t",max_lag_s=0.001,remove_mean=true,polarity="same",min_overlap_ratio=0.9,min_correlation=0.8,ambiguity_delta=0.01,metrics=["response_delay_samples"]}] +''') + return path + + +def test_sync_plan_and_intent(tmp_path): + plan=load_run_plan(plan_file(tmp_path)) + assert plan.steps[0].fields['points']=='DEF' + intent=build_execution_intent(plan,make_config(str(tmp_path))) + assert intent.operations[0]['operation']=='scope.capture_synchronized' + assert intent.operations[0]['effect']=='acquire' + from wavebench.services.run_safety import plan_scope_guard_channels + assert plan_scope_guard_channels(plan,1)==[1,2] + + +@pytest.mark.parametrize('text,replacement', [('channels=[1,2]','channels=[1,1]'), + ('synchronized=true','synchronized="true"'),('save_npy=true','save_npy=false'), + ('synchronized=true','synchronized=true\nchannel=1'),('synchronized=true','synchronized=true\npoints=42'), + ('synchronized=true','synchronized=true\nauto_recover=true')]) +def test_sync_plan_rejects_ambiguous_or_retry_config(tmp_path,text,replacement): + path=plan_file(tmp_path) + path.write_text(path.read_text().replace(text,replacement)) + with pytest.raises(ConfigError): + load_run_plan(path) + + +def test_driver_proof_unknown_skew_and_identity_match(tmp_path): + capture,_=pair_package(tmp_path) + metadata=driver_evidence(capture) + fields={'reference_channel':1,'response_channel':2} + source,_,_=load_pair_source(capture,fields,AnalysisLimits()) + assert source['axes']['evidence_kind']=='driver_frozen_single' + metadata['instrument']['idn']='Example,DIFFERENT,REDACTED,1.0' + (capture/'metadata.json').write_text(json.dumps(metadata)) + with pytest.raises(DataError,match='identity'): + load_pair_source(capture,fields,AnalysisLimits()) + + +@pytest.mark.parametrize('field', ['single_opc','stop_opc_before','stop_opc_after','reads','single_count']) +def test_incomplete_driver_proof_rejected(tmp_path,field): + capture,_=pair_package(tmp_path) + metadata=driver_evidence(capture) + metadata['synchronization']['procedure'].pop(field) + (capture/'metadata.json').write_text(json.dumps(metadata)) + with pytest.raises(DataError,match='proof'): + load_pair_source(capture,{'reference_channel':1,'response_channel':2},AnalysisLimits()) + + +def test_pair_step_uses_legacy_cwd_relative_capture_path(tmp_path,monkeypatch): + capture,_=pair_package(tmp_path) + driver_evidence(capture) + monkeypatch.chdir(tmp_path) + plan=load_run_plan(plan_file(tmp_path)) + run_dir=tmp_path/'runs'/'example' + run_dir.mkdir(parents=True) + record=RunStepRecord(index=0,kind='scope.capture',status='ok',fields={},artifact={'package':'capture'}) + result=execute_pair_step(run_dir=run_dir,step=plan.steps[1],source_step=plan.steps[0],source_record=record) + assert result['analysis_pipeline']['status']=='ok' + assert result['metrics']['t_response_delay_samples']==0 + assert Path(result['analysis_pipeline']['manifest']).is_absolute() is False + + +@pytest.mark.parametrize('valid', [True,False]) +def test_service_persists_proof_only_after_validation(tmp_path,monkeypatch,valid): + from contextlib import contextmanager + from types import SimpleNamespace + import numpy as np + from wavebench.instruments.models import WaveformData,WaveformHeader + from wavebench.instruments.synchronized_capture import SynchronizedCapture + from wavebench.services.scope_service import ScopeService + from wavebench.logging import CommandLogger + capture,_=pair_package(tmp_path) + metadata=driver_evidence(capture) + proof=metadata['synchronization'] + waves={ch:WaveformData(ch,WaveformHeader(0,2047/1024,2048),np.ones(2048)) for ch in (1,2)} + if not valid: + proof['procedure']['single_opc']=False + def acquire(**kwargs): + for ch,waveform in waves.items(): + kwargs['on_waveform'](ch,waveform) + return SynchronizedCapture(waves,proof) + fake=SimpleNamespace(idn=lambda:'Example,EXAMPLE,REDACTED,1.0',capture_synchronized=acquire) + @contextmanager + def session(): + yield fake + config=make_config(str(tmp_path)).with_waveform_overrides(points='DEF').with_output_overrides(save_npy=True,save_screenshot=False) + service=ScopeService(config,CommandLogger()) + monkeypatch.setattr(service,'_scope_session',session) + monkeypatch.setattr(service,'_require',lambda *args:None) + monkeypatch.setattr(service,'_waveform_binary_profile',lambda:None) + monkeypatch.setattr(service,'_session_preflight',lambda *args:{'scope.identity':fake.idn()}) + if valid: + result=service.capture_waveforms([1,2],'sync',synchronized=True) + saved=json.loads(result.metadata_path.read_text()) + assert saved['synchronization']['kind']=='driver_frozen_single' + else: + with pytest.raises(DataError,match='proof'): + service.capture_waveforms([1,2],'sync',synchronized=True) + for path in Path(config.output.directory).glob('*/metadata.json'): + assert 'synchronization' not in json.loads(path.read_text()) From 7cd0a61888d5522af758f422d44d952f36711006 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:46:22 +0800 Subject: [PATCH 25/30] docs(scope): describe real synchronized capture and restoration limits --- docs/reference/artifacts.md | 8 +++++++- docs/reference/run-schema.md | 4 ++-- plans/README.md | 2 ++ plans/example_synchronized_pair.toml | 25 +++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 plans/example_synchronized_pair.toml diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 2fd21552..24b65dda 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -149,4 +149,10 @@ recovered, expect_status, expect_failures, expect_fft_status, expect_fft_failure 双通道频域 NPY/CSV 的列固定为 `frequency_hz,real_v,imaginary_v,gain_db,phase_rad,coherence,valid,coherent`。real_v/imaginary_v 为 H1 的实部/虚部,实际单位是 V/V;gain_db 为 20 log10 幅值,phase_rad 为弧度。频率始终有效;无效响应的五个数值列在 NPY 中为 NaN,CSV 中为空,valid/coherent 为 0/1。这个独立合同不改变单通道导出禁止非有限值的规则。JSON 指标继续使用 null。 -报告将双通道增益、相位、相干性分图展示,保留无效区断点,并显示有效/高相干 bin 数、延迟指标及 synthetic 证据类型。当前曲线渲染读取 NPY;仅导出 CSV 时显示数据链接,不伪造图形。显示抽稀仍不参与测量或验收。 +报告将双通道增益、相位、相干性分图展示,保留无效区断点,并显示有效/高相干 bin 数、延迟指标及同步证据类型。当前曲线渲染读取 NPY;仅导出 CSV 时显示数据链接,不伪造图形。显示抽稀仍不参与测量或验收。 + +## 驱动生成的同步证据 + +显式同步 capture 在 metadata 中写入 `synchronization`,使用 `wavebench.capture_sync.v1` 的 `driver_frozen_single` 类型。driver 字段记录驱动 ID、型号与固件;procedure 记录插件流程版本、一次采集设置、完成/冻结确认、逐通道配置检查和诊断配置。acquisition group 是主机事务标识,不是硬件采集序号;未提供硬件序号时为 null。任一通道读取或证据校验失败,部分波形可保留,但失败包不得带 verified 同步证明。 + +原始 NPY 与 metadata 不因离线分析被改写。RunPlan capture 的 package 路径沿用工作目录相对路径约定;pair 入口在执行前解析绝对位置,包内文件仍受路径越界检查。未校准的链路时延和相干结果不构成 DUT 精密延迟校准。 diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index 4a574961..c4a5817f 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -48,9 +48,9 @@ 独立入口为 `analysis pair-check/pair-run --capture --recipe `,`pair-run` 另需新的 `--output`。配方 schema 为 `wavebench.analysis_pair_recipe.v1`,包含 `reference_channel`、`response_channel`、`operations` 和可选 `expect`/`resources`。资源与执行配置选项同单通道接口。示例见 `plans/example_pair_analysis.toml`。 -RunPlan 使用独立的 `analysis.pair`,同样要求 `source={step="earlier_capture"}` 指向更早且显式保存 NPY 的 `scope.capture`。它与 `analysis.pipeline` 共同组成离线后缀,不支持 safety_gate,在硬件恢复、会话关闭与租约释放后执行。当前真实采集不会生成足够的同步证据,因此该入口只完成 Core 离线集成与 synthetic 测试;真实插件适配与实机验收尚未完成。 +RunPlan 使用独立的 `analysis.pair`,同样要求 `source={step="earlier_capture"}` 指向更早且显式保存 NPY 的 `scope.capture`。它与 `analysis.pipeline` 共同组成离线后缀,不支持 safety_gate,在硬件恢复、会话关闭与租约释放后执行。真实同步采集通过独立 `scope.capture_synchronized` capability 提供,缺少该 capability 时在触发前拒绝。`scope.capture` 显式设置 `channels=[1,2]`、`synchronized=true`、`save_npy=true` 和 `points="DEF"`;不接受单通道 `channel`、quality/expect/自动重试配置。CLI 等价入口为 `scope capture --channel 1 --channel 2 --points def --synchronized`。适配的具体型号、固件和采集模式由插件声明与验证;不会自动升级旧包。 -同包两路必须不同且指向不同 NPY。metadata 的 `synchronization` 必须使用 `wavebench.capture_sync.v1`,当前仅接受 `kind="synthetic"`、`status="verified"`。还要求 `producer.name`/`producer.version`、来源为 synthetic 的 `acquisition_group.id`、`timebase_id`、`record_id`、`single_record`/`frozen_read` 保证,以及每路 time_start_s、sample_interval_s、samples、skew_s、uncertainty_s。示例生成器给出完整结构。普通 metadata 和 SHA-256 用于一致性追溯,并非防伪签名。 +同包两路必须不同且指向不同 NPY。metadata 的 `synchronization` 必须使用 `wavebench.capture_sync.v1`,接受 `kind="synthetic"` 或 `kind="driver_frozen_single"`,状态均须为 `verified`。还要求 `producer.name`/`producer.version`、来源类型与 kind 一致的 `acquisition_group.id`、`timebase_id`、`record_id`、`single_record`/`frozen_read` 保证,以及每路 time_start_s、sample_interval_s、samples、skew_s、uncertainty_s。示例生成器给出 synthetic 结构。driver_frozen_single 还需 `driver.id`/`driver.model`/`driver.firmware`、producer 与 procedure 版本匹配、single_count=1、完成等待与前后冻结证明、每路配置未变检查。Core 校验通用结构及 metadata 身份,厂商专属完成语义由插件负责。未知模拟 skew/不确定度可为 null,不能据此推断为零。普通 metadata 和 SHA-256 用于一致性追溯,并非防伪签名,也不能排除前面板或不合作控制器的并发干预。 两路点数一致、各自等间隔,并逐块检查时间轴绝对差不超过 `dt*1e-6`;不使用绝对时间戳的相对容差放宽偏移。不自动裁剪、补零、重采样或 deskew;skew 信息仅记录,不应用补偿。旧包缺证据仍可单通道分析,不能通过同目录、同时间轴或主机 ID 推断同步。 diff --git a/plans/README.md b/plans/README.md index 259d8bbc..bd706d8d 100644 --- a/plans/README.md +++ b/plans/README.md @@ -130,3 +130,5 @@ python -m wavebench analysis report /tmp/wavebench-pair --output /tmp/wavebench- ``` Windows 可将 `/tmp/...` 替换为本地新目录。高级指标窗口按示例的 16384 Hz 采样率设计;换用历史包前需要按实际采样率和频率分辨率修改窗口及频带。缺少同步证据的真实旧包不适用于双通道示例,可用于单通道及批量流程验证。 + +`example_synchronized_pair.toml` 演示已有外部激励下的真实双通道冻结采集与分析,需要驱动声明 `scope.capture_synchronized`。计划本身不打开信号源;采集会修改示波器时基、垂直设置并保持 STOP。先按实际信号幅度与安全条件调整配置,再执行 `run check` 和实时预检。DG 易失性 ARB 上传后的恢复需要明确保存基础源快照;通用基础 setter 不能保证从 USER 状态恢复,更不能恢复已覆盖的 ARB 内存。 diff --git a/plans/example_synchronized_pair.toml b/plans/example_synchronized_pair.toml new file mode 100644 index 00000000..9d78f6ad --- /dev/null +++ b/plans/example_synchronized_pair.toml @@ -0,0 +1,25 @@ +# Requires an already connected, low-amplitude signal on both scope channels. +# No source output is enabled by this plan. Scope capture remains stopped afterwards. +# Requires a driver that declares scope.capture_synchronized. +[[steps]] +id = "capture_pair" +kind = "scope.capture" +channels = [1, 2] +synchronized = true +points = "DEF" +time_range_s = 0.01 +vertical_scale_v_per_div = 0.2 +save_npy = true +save_csv = true + +[[steps]] +id = "pair_result" +kind = "analysis.pair" +source = { step = "capture_pair" } +reference_channel = 1 +response_channel = 2 +operations = [ + { op = "delay", name = "timing", max_lag_s = 0.00002, remove_mean = true, polarity = "same", min_overlap_ratio = 0.95, min_correlation = 0.95, ambiguity_delta = 0.0001, metrics = ["response_delay_s", "response_delay_samples", "correlation"] }, + { op = "transfer", name = "system", window = "hann", nperseg = 1024, noverlap = 512, nfft = 1024, detrend = "constant", min_reference_density = 0.000000001, min_response_density = 0.000000001, min_coherence = 0.9, unwrap_phase = false, metrics = ["mean_coherence", "valid_bin_count"] }, + { op = "export", name = "transfer", formats = ["npy", "csv"] }, +] From 411898bd98861c80696f1d7e759fb0984e4daa1a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:00:23 +0800 Subject: [PATCH 26/30] docs(analysis): summarize completed scope and pending validation --- docs/development/signal-processing-status.md | 51 ++++++++++++++++++++ docs/index.md | 2 + mkdocs.yml | 1 + 3 files changed, 54 insertions(+) create mode 100644 docs/development/signal-processing-status.md diff --git a/docs/development/signal-processing-status.md b/docs/development/signal-processing-status.md new file mode 100644 index 00000000..26fb3d9c --- /dev/null +++ b/docs/development/signal-processing-status.md @@ -0,0 +1,51 @@ +# 信号处理流水线开发状态 + +本页供开发者核对信号处理的实施范围、验证边界与剩余交付工作。状态核对日期为 2026-09-08;对应 Core 开发提交 `7cd0a61` 及此前流水线提交。 + +**状态:Proposed / Future,开发分支已实现,尚未发布。** MVP 至 Phase 6、资源控制 R0~R3 的约定实现已完成;平台验收与发布准备尚未全部完成。 + +## 已实现范围 + +| 阶段 | 已实现内容 | 合同入口 | +| --- | --- | --- | +| MVP | 独立 `analysis.pipeline`、稳定 step ID、硬件释放后的分析后缀、去直流/去趋势/三种窗/FFT/测量/导出、独立派生产物 | [RunPlan 合同](../reference/run-schema.md) | +| Phase 2 | FIR、IIR、显式因果/零相位模式、Welch PSD;SciPy 按需检查 | [算子合同](../reference/run-schema.md) | +| Phase 3~4C | 历史采集包离线分析、曲线比较、PSD 频带验收、多峰检测、平滑、重采样 | [离线分析产物](../reference/artifacts.md) | +| R0/R1 | 统一预算、FIR tap/FFT 长度预检、受限 NPY 读取、分块导出/哈希/报告读取 | [资源预算](../reference/run-schema.md) | +| R2 | mean Welch 逐段累计、因果 FIR/IIR 跨块传递状态 | [资源预算及算法证据](../reference/run-schema.md) | +| R3A/R3B | 可选独立进程、取消/超时、部分产物保存、Linux cgroup v2 与 Windows Job Object 后端 | [进程监督](../reference/run-schema.md) | +| Phase 5 | 显式频带与门限的 SNR/SINAD/SFDR、串行批量分析、摘要校验恢复与累计预算 | [高级谱质量](../reference/run-schema.md)、[批量分析](../reference/run-schema.md) | +| Phase 6 | 独立 `analysis.pair`、同步证据校验、整数时延、H1/相干性、有效区掩码、报告与 RunPlan 集成 | [双通道分析](../reference/run-schema.md) | +| 真实同步采集适配 | 可选 `scope.capture_synchronized`、同次冻结采集证明、真实包双通道离线分析 | [同步证据](../reference/artifacts.md) | + +分析保持原始文件只读,派生数据写入独立目录。旧 `capture inspect --fft` 与 `expect_fft` 保持原算法;新流水线的前处理由配方显式声明。 + +## 示例与验证入口 + +[示例目录](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/README.md)包含完整说明,按用途选择: + +- [完整 RunPlan](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_signal_processing_pipeline.toml):一次采集后分别演示滤波 FFT、平滑/重采样/多峰和 PSD 频带验收。 +- [独立处理配方](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_processed_recipe.toml):对历史包重复分析,生成独立产物。 +- [高级指标、批量与双通道演示](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/README.md):包含合成数据生成、离线处理及报告命令。 +- [真实同步采集 RunPlan](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_synchronized_pair.toml):依赖支持同步采集的驱动和事先准备好的外部激励。 +- [资源配置](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_analysis_resources.toml)与[执行配置](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_analysis_execution.toml):分别设置预算和进程监督。 + +示例链接指向开发分支,需在对应提交推送后才能通过远端查看;本地检出可直接读取仓库中的 `plans/`。 + +真实采集示例会操作示波器。离线分析、报告和合成示例无需连接仪器;资源/执行配置与处理配方不是 RunPlan,须使用对应入口。 + +## 验证记录与边界 + +- 最近产品代码的 Linux 全量回归:2443 passed、4 skipped、221 subtests passed;RTM 插件包回归为 205 passed。生成文档、Ruff、文案检查、文档审计和 MkDocs 严格构建均通过。这是既有验收记录,不表示每次文档修改都重跑全量测试。 +- 历史实机包已验证单通道高级指标、批量恢复和报告;缺少同步证据的双通道包按合同拒绝。 +- 真实同步采集已完成代表性对照,并用保存的采集包完成双通道分析与报告,原始文件摘要不变。具体型号、固件、采集模式及验证范围由[插件同步采集文档](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/4a45880/packages/wavebench-rohde-schwarz-rtm2000/doc/RTM2000_SYNCHRONIZED_CAPTURE.md)维护;该提交的验证不构成其它型号或精密通道校准承诺。 +- R3 的 Windows 原生测试留待 PR workflow;当前本机没有可写 cgroup 委派,真实 cgroup 测试跳过,已有受控测试与拒绝路径验证。预算估算不等于操作系统硬内存保证;平台后端能力不足时拒绝启用硬限额。 +- median Welch、零相位滤波、全局峰属性和整段 FFT 仍按预算执行,未实现通用外存算法。这符合 R2 的范围,不承诺任意大数据都能处理。 + +## 剩余交付工作 + +1. 在后续 PR 执行 Windows CI,并在具备委派权限的 Linux 环境验证真实 cgroup 限制。 +2. 正式合并/发布前核对 Core 与插件版本、兼容关系及最终提交的检查结果。 +3. 扩展真实采集的固件、记录模式或型号时补充对应证据;通用源恢复在 USER 状态下的能力衔接问题另行处理,当前仍须遵守示例中的恢复限制。 + +处理图、任意 Python 回调、算子插件、跨设备时钟同步、自动 deskew、MIMO、实时流处理、GPU 和分布式执行未纳入本轮交付。是否扩展由后续需求决定。 diff --git a/docs/index.md b/docs/index.md index 7af9b76c..34fd7fa5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,8 @@ ## 分析与报告 +- [信号处理开发状态](development/signal-processing-status.md):开发分支的实施范围、示例入口及待验收事项。 + - [运行产物 Reference](reference/artifacts.md):`run.json`、`summary.csv` 和 step 记录的稳定入口。 - [频率响应与校准](how-to/frequency-response-and-calibration.md):从模板、离线检查到报告的专题操作入口。 diff --git a/mkdocs.yml b/mkdocs.yml index 55f0bfec..a4fd77df 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - 开发: - 贡献: development/contributing.md - 测试: development/testing.md + - 信号处理开发状态: development/signal-processing-status.md - 文档工作流: development/documentation.md - 插件开发: development/plugin-development.md - 新增仪器驱动: development/instrument-drivers.md From e0e0728cf2532a8369aef54cdf962dbd722eb2c1 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:05:25 +0800 Subject: [PATCH 27/30] docs(analysis): document pipeline usage and keep status internal --- docs/development/signal-processing-status.md | 51 -------- docs/how-to/signal-processing.md | 123 +++++++++++++++++++ docs/index.md | 3 +- docs/reference/run-schema.md | 8 +- mkdocs.yml | 2 +- plans/README.md | 2 + 6 files changed, 131 insertions(+), 58 deletions(-) delete mode 100644 docs/development/signal-processing-status.md create mode 100644 docs/how-to/signal-processing.md diff --git a/docs/development/signal-processing-status.md b/docs/development/signal-processing-status.md deleted file mode 100644 index 26fb3d9c..00000000 --- a/docs/development/signal-processing-status.md +++ /dev/null @@ -1,51 +0,0 @@ -# 信号处理流水线开发状态 - -本页供开发者核对信号处理的实施范围、验证边界与剩余交付工作。状态核对日期为 2026-09-08;对应 Core 开发提交 `7cd0a61` 及此前流水线提交。 - -**状态:Proposed / Future,开发分支已实现,尚未发布。** MVP 至 Phase 6、资源控制 R0~R3 的约定实现已完成;平台验收与发布准备尚未全部完成。 - -## 已实现范围 - -| 阶段 | 已实现内容 | 合同入口 | -| --- | --- | --- | -| MVP | 独立 `analysis.pipeline`、稳定 step ID、硬件释放后的分析后缀、去直流/去趋势/三种窗/FFT/测量/导出、独立派生产物 | [RunPlan 合同](../reference/run-schema.md) | -| Phase 2 | FIR、IIR、显式因果/零相位模式、Welch PSD;SciPy 按需检查 | [算子合同](../reference/run-schema.md) | -| Phase 3~4C | 历史采集包离线分析、曲线比较、PSD 频带验收、多峰检测、平滑、重采样 | [离线分析产物](../reference/artifacts.md) | -| R0/R1 | 统一预算、FIR tap/FFT 长度预检、受限 NPY 读取、分块导出/哈希/报告读取 | [资源预算](../reference/run-schema.md) | -| R2 | mean Welch 逐段累计、因果 FIR/IIR 跨块传递状态 | [资源预算及算法证据](../reference/run-schema.md) | -| R3A/R3B | 可选独立进程、取消/超时、部分产物保存、Linux cgroup v2 与 Windows Job Object 后端 | [进程监督](../reference/run-schema.md) | -| Phase 5 | 显式频带与门限的 SNR/SINAD/SFDR、串行批量分析、摘要校验恢复与累计预算 | [高级谱质量](../reference/run-schema.md)、[批量分析](../reference/run-schema.md) | -| Phase 6 | 独立 `analysis.pair`、同步证据校验、整数时延、H1/相干性、有效区掩码、报告与 RunPlan 集成 | [双通道分析](../reference/run-schema.md) | -| 真实同步采集适配 | 可选 `scope.capture_synchronized`、同次冻结采集证明、真实包双通道离线分析 | [同步证据](../reference/artifacts.md) | - -分析保持原始文件只读,派生数据写入独立目录。旧 `capture inspect --fft` 与 `expect_fft` 保持原算法;新流水线的前处理由配方显式声明。 - -## 示例与验证入口 - -[示例目录](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/README.md)包含完整说明,按用途选择: - -- [完整 RunPlan](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_signal_processing_pipeline.toml):一次采集后分别演示滤波 FFT、平滑/重采样/多峰和 PSD 频带验收。 -- [独立处理配方](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_processed_recipe.toml):对历史包重复分析,生成独立产物。 -- [高级指标、批量与双通道演示](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/README.md):包含合成数据生成、离线处理及报告命令。 -- [真实同步采集 RunPlan](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_synchronized_pair.toml):依赖支持同步采集的驱动和事先准备好的外部激励。 -- [资源配置](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_analysis_resources.toml)与[执行配置](https://github.com/Scaxlibur/wavebench/blob/Scaxlibur/feat/signal-processing-pipeline/plans/example_analysis_execution.toml):分别设置预算和进程监督。 - -示例链接指向开发分支,需在对应提交推送后才能通过远端查看;本地检出可直接读取仓库中的 `plans/`。 - -真实采集示例会操作示波器。离线分析、报告和合成示例无需连接仪器;资源/执行配置与处理配方不是 RunPlan,须使用对应入口。 - -## 验证记录与边界 - -- 最近产品代码的 Linux 全量回归:2443 passed、4 skipped、221 subtests passed;RTM 插件包回归为 205 passed。生成文档、Ruff、文案检查、文档审计和 MkDocs 严格构建均通过。这是既有验收记录,不表示每次文档修改都重跑全量测试。 -- 历史实机包已验证单通道高级指标、批量恢复和报告;缺少同步证据的双通道包按合同拒绝。 -- 真实同步采集已完成代表性对照,并用保存的采集包完成双通道分析与报告,原始文件摘要不变。具体型号、固件、采集模式及验证范围由[插件同步采集文档](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/4a45880/packages/wavebench-rohde-schwarz-rtm2000/doc/RTM2000_SYNCHRONIZED_CAPTURE.md)维护;该提交的验证不构成其它型号或精密通道校准承诺。 -- R3 的 Windows 原生测试留待 PR workflow;当前本机没有可写 cgroup 委派,真实 cgroup 测试跳过,已有受控测试与拒绝路径验证。预算估算不等于操作系统硬内存保证;平台后端能力不足时拒绝启用硬限额。 -- median Welch、零相位滤波、全局峰属性和整段 FFT 仍按预算执行,未实现通用外存算法。这符合 R2 的范围,不承诺任意大数据都能处理。 - -## 剩余交付工作 - -1. 在后续 PR 执行 Windows CI,并在具备委派权限的 Linux 环境验证真实 cgroup 限制。 -2. 正式合并/发布前核对 Core 与插件版本、兼容关系及最终提交的检查结果。 -3. 扩展真实采集的固件、记录模式或型号时补充对应证据;通用源恢复在 USER 状态下的能力衔接问题另行处理,当前仍须遵守示例中的恢复限制。 - -处理图、任意 Python 回调、算子插件、跨设备时钟同步、自动 deskew、MIMO、实时流处理、GPU 和分布式执行未纳入本轮交付。是否扩展由后续需求决定。 diff --git a/docs/how-to/signal-processing.md b/docs/how-to/signal-processing.md new file mode 100644 index 00000000..d9512ffe --- /dev/null +++ b/docs/how-to/signal-processing.md @@ -0,0 +1,123 @@ +# 使用信号处理流水线 + +信号处理流水线按显式配方处理采集波形,支持时域变换、频谱测量和结果导出。已有采集包可直接离线分析;RunPlan 可在采集后声明独立的分析步骤。原始数据保持只读,派生数据写入独立目录。 + +## 选择处理方式 + +| 目标 | 入口 | 输入 | +| --- | --- | --- | +| 重新处理历史波形 | `analysis check`、`analysis run` | 采集包、通道和处理配方 | +| 采集后自动分析 | RunPlan 的 `analysis.pipeline` | 更早的 `scope.capture` step ID | +| 对多个来源应用同一配方 | `analysis batch` | 显式批次清单 | +| 测量两路时延、频响和相干性 | `analysis pair-check`、`analysis pair-run` 或 `analysis.pair` | 有同步证据的双通道采集包 | +| 查看或比较处理结果 | `analysis report` | 已保存的分析、批次或 run 目录 | + +`analysis` 命令不连接仪器,也不需要 `wavebench.toml`。RunPlan 中的采集步骤会操作示波器,执行前须遵循[执行一次实验](run-an-experiment.md)的接线、预检和恢复要求。 + +## 准备输入与配方 + +在已安装 WaveBench 的环境中使用本页命令。基础去直流、去趋势、窗和 FFT 使用 NumPy;滤波、Welch PSD、峰值、平滑等扩展功能按需检查 SciPy。安装方法见[安装](../getting-started/installation.md),源码环境可安装 `.[analysis]`。 + +来源必须是包含 metadata 和 NPY 波形的采集包。单通道 NPY 为两列 `[time_s, voltage_v]`,数值有限、时间严格递增;FFT、滤波等要求等间隔采样。单个 NPY 文件不能直接作为 `--capture` 输入。 + +配方由有序的 `operations` 组成,按需要组合以下处理: + +| 用途 | 算子与测量 | +| --- | --- | +| 时域预处理 | 去直流、线性去趋势、移动平均、Savitzky–Golay 平滑、有理数比例重采样 | +| 滤波 | FIR/IIR 的低通、高通、带通和带阻;显式选择因果或零相位模式 | +| 频谱 | Hann/Hamming/Blackman 窗、单边 FFT、Welch PSD | +| 测量 | 时域统计、主峰/谐波/THD、PSD 频带积分、SNR/SINAD/SFDR、多峰检测 | +| 导出 | 命名 NPY/CSV 文件、峰表、标量指标与处理记录 | + +新 FFT 不隐式去直流或加窗;PSD 自行声明分段窗与去趋势,不能接在整段 window 或 FFT 后。FFT 幅度单位为 V,PSD 密度为 V²/Hz;高级谱质量指标还需明确频带、基波/谐波区域和有效性门限。参数、顺序和数据域约束见[RunPlan 与配方 Reference](../reference/run-schema.md)。 + +## 分析已有采集包 + +以下命令从仓库根目录执行,将 `data/raw/capture_example` 替换为已有采集包目录: + +```bash +wavebench analysis check --capture data/raw/capture_example --channel 1 --recipe plans/example_analysis_recipe.toml +wavebench analysis run --capture data/raw/capture_example --channel 1 --recipe plans/example_analysis_recipe.toml --output data/analysis_fft +wavebench analysis report data/analysis_fft --output data/analysis_fft.html +``` + +这个配方依次去直流、加 Hann 窗、计算 FFT,测量主峰频率与幅度并导出频谱。`check` 验证来源、配方和预算;实际结果以 `run` 的状态及产物为准。 + +输出目录和独立报告文件必须尚不存在,分析目录不能位于原始采集包或既有 run 内。尝试另一种窗或滤波参数时,修改配方并使用新的输出目录,保留两次结果供比较。 + +输出目录包含 `analysis.json`、`manifest.json`、`metrics.json` 和 `exports/`。检查总体状态、选中的指标、warning 和失败阶段;配置了 `[expect]` 时,还应检查验收是否通过。不可用指标为 `null`,相应 expectation 按失败处理。 + +报告读取已保存的导出,不重新计算指标。同来源、通道和数据域的曲线可以叠加;频谱与 PSD 使用各自的单位,图形抽稀不改变指标。缺少曲线时,先确认配方包含 `export`,再检查文件是否完整。详细字段见[运行产物 Reference](../reference/artifacts.md)。 + +## 在 RunPlan 中声明处理链 + +下面的片段展示一个采集步骤与其分析后缀,可加入实验计划: + +```toml +[[steps]] +id = "capture_main" +kind = "scope.capture" +channel = 1 +save_npy = true + +[[steps]] +id = "spectrum_main" +kind = "analysis.pipeline" +source = { step = "capture_main" } +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"] }, +] +``` + +来源必须是同一 Plan 中更早且显式保存 NPY 的 `scope.capture`。所有分析步骤组成连续末尾部分,在硬件恢复、会话关闭与租约释放后执行。分析失败只改变分析步骤状态,并按 `on_failure` 决定是否继续其它分析;不会触发重新采集。 + +仓库中的 `plans/example_signal_processing_pipeline.toml` 演示一次采集后的三条独立处理链:滤波 FFT、平滑/重采样/多峰和 PSD 频带验收。先完成离线检查: + +```bash +wavebench run check --plan plans/example_signal_processing_pipeline.toml +``` + +完整示例假定已有约 1 Vpp、1 kHz 的输入,采样率至少 20 kSa/s、至少 4096 点。按实际输入调整参数及阈值,再完成硬件预检和执行。它不会配置或开启信号源;派生文件写入 run 的 `processing/`,可通过 `run report` 查看。 + +## 批量与双通道分析 + +没有采集包时,可以用仓库生成器验证离线流程。以下目录均须尚不存在;生成的数据明确标记为 synthetic: + +```bash +python plans/generate_pair_example.py data/processing_demo +wavebench analysis batch --manifest data/processing_demo/batch.toml --output data/processing_batch +wavebench analysis batch --manifest data/processing_demo/batch.toml --output data/processing_batch --resume +wavebench analysis pair-check --capture data/processing_demo --recipe plans/example_pair_analysis.toml +wavebench analysis pair-run --capture data/processing_demo --recipe plans/example_pair_analysis.toml --output data/processing_pair +wavebench analysis report data/processing_pair --output data/processing_pair.html +``` + +批次对显式清单中的来源串行应用同一配方。`--resume` 核对输入、配方、环境配置和已完成文件的摘要;变化或损坏时拒绝原地恢复,失败条目重跑到新 attempt 目录。批次 JSON/CSV 保留逐项结果,不自动平均不同合同的指标。 + +双通道示例的 response 为 reference 的两倍,并延迟 7 个采样点。检查 `timing_response_delay_samples` 为 7、`system_mean_coherence` 通过配方阈值,以及报告中的增益、相位和相干性曲线。 + +真实双通道分析要求同次采集证据、共同时间基准和一致的样本轴;普通多通道包不能仅凭相同目录或时间轴推断同步。`plans/example_synchronized_pair.toml` 使用驱动的 `scope.capture_synchronized` capability 采集,再执行 `analysis.pair`。该示例需要事先准备外部激励,会修改示波器设置并保持 STOP;支持型号、固件和采集模式由插件声明。 + +时延结果描述测量连接中的总延迟,未自动校准探头或通道偏移。弱激励频段标为无效,低相干保留质量标记;不能把无效点当作零响应,也不会自动补偿时延或 deskew。 + +## 资源控制与常见失败 + +可在 `analysis check/run` 中附加 `--analysis-resources plans/example_analysis_resources.toml`,选择环境预算;配方中的 `[resources]` 只能进一步收紧。FIR tap 数、FFT 长度、工作集、运算量和输出均受约束,超预算时不会自动改变数值参数。 + +需要取消或超时控制时,附加 `--analysis-execution plans/example_analysis_execution.toml` 启用独立分析进程。批次默认启用进程监督。配置可以申请平台硬内存限制,但 Linux 需要已委派的 cgroup v2 目录,Windows 使用 Job Object;平台预检不满足要求时拒绝启用,不自动降级。预算估算本身不保证进程不会耗尽内存。 + +| 现象 | 处理方式 | +| --- | --- | +| 缺少 SciPy | 安装 analysis 可选依赖,重新执行离线检查 | +| 算子顺序或参数不合法 | 按当前数据域检查配方,查阅算子 Reference | +| 输入轴不均匀或缺少同步证据 | 核对采集方式;旧包可用于单通道分析,不手工补造同步证明 | +| 指标为 `null` 或 expectation 失败 | 查看有效性原因、频带和门限;保留失败记录 | +| 资源超限或执行超时 | 根据记录的限制和估算调整环境预算或实验参数,再写入新目录 | +| 只保留部分导出 | 检查 manifest 的失败阶段或取消记录,已完成文件保留用于诊断 | + +资源与执行字段、失败语义见[RunPlan Reference](../reference/run-schema.md),文件布局和摘要见[产物 Reference](../reference/artifacts.md),其它错误见[排错指南](troubleshooting.md)。 diff --git a/docs/index.md b/docs/index.md index 34fd7fa5..eacb32fc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,8 +29,7 @@ ## 分析与报告 -- [信号处理开发状态](development/signal-processing-status.md):开发分支的实施范围、示例入口及待验收事项。 - +- [使用信号处理流水线](how-to/signal-processing.md):处理采集包、声明 RunPlan 分析链,以及生成批量和双通道分析报告。 - [运行产物 Reference](reference/artifacts.md):`run.json`、`summary.csv` 和 step 记录的稳定入口。 - [频率响应与校准](how-to/frequency-response-and-calibration.md):从模板、离线检查到报告的专题操作入口。 diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index c4a5817f..82cb7dca 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -1,5 +1,7 @@ # run plan Reference +信号处理的操作步骤与示例见[使用信号处理流水线](../how-to/signal-processing.md)。 + ## 独立离线配方 `analysis` 命令直接处理历史 capture package,不需要仪器配置。显式选择一个通道,配方包含 `schema = "wavebench.analysis_recipe.v1"`、`operations` 和可选 `[expect]`/`[resources]`,共用下文的算子与验收合同。示例为 `plans/example_analysis_recipe.toml`。 @@ -38,7 +40,7 @@ `analysis batch --manifest batch.toml --output ` 读取 `wavebench.analysis_batch.v1` 清单。字段为 `recipe`、`entries`、`on_failure="stop|continue"`、`duplicates="reject|allow"`、`max_output_bytes`;路径相对于清单目录解析。每个 entry 必须有唯一安全 `id`、`capture` 和正整数 `channel`。同包同通道重复仅在 `allow` 时接受;条目最多 256 个,并受环境文件数上限约束。 -批次默认启用 R3 监督,每条分析的默认超时为 300 秒;可用 `--analysis-execution` 显式替换。一次只执行一个条目,取消停止整个批次,普通失败遵循清单的 stop/continue。`max_output_bytes` 不得超过环境总输出限额,历史 attempt、当前结果和索引都计入总额;索引预留空间可能使小配额提前耗尽。失败诊断可尽力超额保存,但批次状态为失败。 +批次默认启用独立分析进程监督,每条分析的默认超时为 300 秒;可用 `--analysis-execution` 显式替换。一次只执行一个条目,取消停止整个批次,普通失败遵循清单的 stop/continue。`max_output_bytes` 不得超过环境总输出限额,历史 attempt、当前结果和索引都计入总额;索引预留空间可能使小配额提前耗尽。失败诊断可尽力超额保存,但批次状态为失败。 `--resume` 要求原批次目录,重新核对清单、配方、有效资源/执行配置、数值库版本、来源 metadata/NPY 摘要和已完成产物摘要。变化或损坏时拒绝复用,要求新的输出目录;未成功的条目写入新的 attempt 目录,保留旧文件。文件锁防止两个进程同时写同一批次。中断期间尚未完成的 attempt 不冒充已验证成功结果。 @@ -75,14 +77,12 @@ transfer 至少需要两个完整 Welch 段,固定 mean,并共用两路分 | `memory_bytes` | 不设置 | 可选的平台硬内存限额 | | `cgroup_root` | 不设置 | Linux 硬限额所需的已委派 cgroup v2 目录 | -时间必须为有限正数,硬限额必须为 1~`2^63-1` 的整数;未知字段拒绝。执行配置属于环境,不加入 RunPlan step 或分析配方。未指定文件时保持同进程执行,R0 预算仍然有效。`check` 检查配置和平台能力,实际超时监督只作用于 `run`/`plan` 的分析阶段。 +时间必须为有限正数,硬限额必须为 1~`2^63-1` 的整数;未知字段拒绝。执行配置属于环境,不加入 RunPlan step 或分析配方。未指定文件时保持同进程执行,资源预算仍然有效。`check` 检查配置和平台能力,实际超时监督只作用于 `run`/`plan` 的分析阶段。 指定文件后,每条分析链在独立 `spawn` 子进程执行。RunPlan 在硬件恢复、会话关闭和租约释放后启动分析,不传递仪器句柄。Ctrl+C 或 Service 的取消事件先请求协作退出,超过宽限期后 terminate,仍未退出时 kill;父进程确认退出后整理产物。普通失败和超时按 `on_failure` 决定后续分析,用户取消停止整个分析后缀。数值块、读写块与算子边界可协作取消;单次不可中断的原生调用依靠进程终止处理。 Windows 硬限额使用 Job Object 的 job committed memory;Linux 使用 cgroup v2 的 `memory.max` 并设置 `memory.swap.max=0`,要求 memory controller 和 `cgroup.kill` 可用。两者统计口径不同,不称为等价的 RSS 配额。硬限额请求在预检时创建临时作用域并验证测试进程绑定,失败就拒绝;不自动提权或降级。父进程和绑定前的启动阶段不受该分析硬限额保护,分析进程也不是不可信代码沙箱。 -本地验证仅覆盖 Linux。Windows 原生 Job Object 测试由后续 PR 的既有 Windows Python 3.11/3.12 workflow 执行;真实 Linux cgroup 集成测试要求显式提供 `WAVEBENCH_TEST_CGROUP_ROOT`,无可写委派时跳过。平台测试未通过前,不把实现状态写作跨平台验收通过。 - ## 分析资源预算 本节为开发分支已实现、尚未发布的资源合同。分析使用有限的默认预算;超限时拒绝执行,不自动降低 taps、FFT 长度或采样率。旧的极大配方可能因此失败,正常预算内的数值参数与结果保持原样。 diff --git a/mkdocs.yml b/mkdocs.yml index a4fd77df..dd6489d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - 从模板到报告: tutorials/from-template-to-report.md - How-to: - 执行一次实验: how-to/run-an-experiment.md + - 使用信号处理流水线: how-to/signal-processing.md - 频率响应与校准: how-to/frequency-response-and-calibration.md - 使用 RF 信号源: how-to/use-rf-source.md - 管理仪器插件: how-to/manage-plugins.md @@ -69,7 +70,6 @@ nav: - 开发: - 贡献: development/contributing.md - 测试: development/testing.md - - 信号处理开发状态: development/signal-processing-status.md - 文档工作流: development/documentation.md - 插件开发: development/plugin-development.md - 新增仪器驱动: development/instrument-drivers.md diff --git a/plans/README.md b/plans/README.md index bd706d8d..c146cb37 100644 --- a/plans/README.md +++ b/plans/README.md @@ -17,6 +17,8 @@ wavebench run check --plan plans/example_scope_expect_quality.toml ## 信号处理功能展示 +操作步骤与结果判读见[使用信号处理流水线](../docs/how-to/signal-processing.md)。 + `example_analysis_resources.toml` 是执行资源配置,不是 RunPlan 或处理配方。可通过 `--analysis-resources plans/example_analysis_resources.toml` 显式选用;字段与兼容边界见[资源预算说明](../docs/reference/run-schema.md#分析资源预算)。 [完整 RunPlan 示例](example_signal_processing_pipeline.toml) 采集 CH1 一次,然后对同一份原始 NPY 执行三个独立分析步骤: From 3488c97ecb3bbfbfa2e3d9cb4b750d78902d35c7 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:46:19 +0800 Subject: [PATCH 28/30] fix(source): declare and verify snapshot-based restoration --- docs/development/plugin-development.md | 30 +++ docs/reference/artifacts.md | 6 + docs/reference/run-schema.md | 8 + plans/README.md | 2 +- pyproject.toml | 2 +- src/wavebench/cli_output.py | 3 + src/wavebench/drivers/dg4202.py | 61 +++++- src/wavebench/instruments/api.py | 11 ++ src/wavebench/instruments/builtin.py | 10 + src/wavebench/instruments/capabilities.py | 1 + src/wavebench/instruments/source_restore.py | 51 +++++ src/wavebench/plugins/builtin.py | 1 + src/wavebench/report/html.py | 19 ++ src/wavebench/services/execution_intent.py | 5 + src/wavebench/services/operation_specs.py | 2 + src/wavebench/services/run_artifacts.py | 3 + src/wavebench/services/run_restore.py | 44 ++++- src/wavebench/services/run_service.py | 22 +++ src/wavebench/services/source_service.py | 63 +++++++ src/wavebench/services/source_state.py | 14 +- tests/test_instrument_registry.py | 1 + tests/test_run_failure_policy.py | 4 +- tests/test_scope_extension_registry.py | 3 +- tests/test_source_extensions.py | 5 +- tests/test_source_restore_contract.py | 194 ++++++++++++++++++++ tests/test_source_v1_routes.py | 1 + 26 files changed, 557 insertions(+), 9 deletions(-) create mode 100644 src/wavebench/instruments/source_restore.py create mode 100644 tests/test_source_restore_contract.py diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 2317a213..c5c9045f 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -16,6 +16,36 @@ descriptor 导入不得进行仪器 I/O、端口扫描、文件写入或全局 4. 每新增一个写 capability,都补充前置条件、写后 readback、失败语义和离线测试。 5. 构建 wheel,执行包检查、临时 venv 安装/加载/卸载验证,再单独申请实机验收。 +## 声明基础源恢复接口 + +自 Core `0.8.27` 起,source 插件可通过 `InstrumentDescriptor.source_restore` 声明 `wavebench.instruments.source_restore.SourceRestoreProfile`。这是独立于 Source V2 波形配置接口的可选合同,不自动替代其它 V2 写入路径。 + +| 字段 | 约束 | +| --- | --- | +| `supported` | 严格布尔值;是否实现下述基础恢复方法 | +| `operations` | 唯一的 source capability 名称元组,列出能够恢复基础状态的操作;必须已在 descriptor 声明 | +| `fields` | 唯一的 `SourceStatus` 字段名元组;支持时至少包含 output、function、frequency_hz、amplitude、amplitude_unit、square_duty_cycle_percent | +| `excluded_fields` | 未覆盖的状态名称元组,例如 arbitrary_payload;不得与 fields 重叠 | + +`supported=True` 必须同时声明 `source.restore_state` capability,并实现公开 Protocol `SourceBasicRestoreDriver` 的两个方法: + +```python +def snapshot_basic_state(self, channel: int) -> SourceStatus: ... +def restore_basic_state(self, snapshot: SourceStatus) -> SourceStatus: ... +``` + +`snapshot_basic_state` 只读取状态,必须在返回前证明该快照可由当前驱动恢复;未知函数、不可读单位、未支持模式或非有限数值应抛出错误。Core 在执行任何实验步骤前收集全部快照,并保存 driver ID 与仪器身份。驱动不得返回 USER/任意波选择后便假定原内容仍可恢复。 + +`restore_basic_state` 使用传入的原快照,不通过普通 setter 重新要求当前状态可作为基线。驱动须校验目标、身份及会话状态,先关闭输出,逐项恢复并回读覆盖字段;只有参数验证完成后才能按目标恢复输出 ON。返回值必须是实际回读的 `SourceStatus`,Core 还会按声明字段比较;浮点容差为 `rtol=1e-6, atol=1e-6`。数值匹配不替代驱动侧对错误队列及物理模式的检查。 + +安全门可把恢复目标的 output 改为 OFF,原始快照继续保留。失败必须抛出错误并保留不确定性,禁止盲目重放、在损坏会话继续写入或把 OFF 当作全部恢复成功。未覆盖状态列入产物与报告。`source.restore_snapshot` 和 `source.restore_state` 两个 Core operation 分别使用 stateful_read 与 write 访问策略;后者需要写权限。 + +明确不支持时设置 `supported=False`,operations/fields 必须为空,不能声明恢复 capability。此时 RunPlan 请求基础恢复会在写入前拒绝。未提供 profile 的旧插件保持原基础恢复路径,但 `source.arb_load` 与基础恢复组合必须有显式支持声明。没有恢复要求的操作可以执行,产物仍记录未覆盖范围。 + +Core 内置 DG 回退驱动也提供该接口;内置版本随 Core 发布,外置 DG distribution 独立版本。两侧基础恢复逻辑同步时须保留 Core 现有的 NO_REPLAY 和结构化会话错误处理,不能以复制整个厂商文件覆盖这些约束。 + +采用新接口的插件可将依赖和 `wavebench_min_version` 提升到 `0.8.27`;若需兼容旧 Core,须在公共模块不可用时同时省略新 profile 字段和 capability,保留旧表面,不能只保留方法声明。声明会进入 execution intent 的恢复合同摘要;能力变化后须重新生成 intent。未声明插件不增加这些字段,保持旧摘要。插件作者应覆盖初态拒绝、不同当前波形、ON/OFF 顺序、读回不符、写入不确定、锁停和 Core Service 集成测试;不能以离线通过宣称新入口已经实机验收。 + ## 验证 ```bash diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 24b65dda..637649c2 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -156,3 +156,9 @@ recovered, expect_status, expect_failures, expect_fft_status, expect_fft_failure 显式同步 capture 在 metadata 中写入 `synchronization`,使用 `wavebench.capture_sync.v1` 的 `driver_frozen_single` 类型。driver 字段记录驱动 ID、型号与固件;procedure 记录插件流程版本、一次采集设置、完成/冻结确认、逐通道配置检查和诊断配置。acquisition group 是主机事务标识,不是硬件采集序号;未提供硬件序号时为 null。任一通道读取或证据校验失败,部分波形可保留,但失败包不得带 verified 同步证明。 原始 NPY 与 metadata 不因离线分析被改写。RunPlan capture 的 package 路径沿用工作目录相对路径约定;pair 入口在执行前解析绝对位置,包内文件仍受路径越界检查。未校准的链路时延和相干结果不构成 DUT 精密延迟校准。 + +## 源恢复声明与结果 + +声明恢复能力的插件在 `provenance.source_restore_coverage` 记录是否请求恢复、版本化声明及未覆盖项;任意波上传 step 还记录 `artifact.restore_coverage`。没有声明的上传记录 supported 为 null,不能推断为完整可恢复。未请求恢复不产生伪造的成功结果。 + +采用独立恢复入口时,`restore.results` 按通道记录声明、status、实际回读或结构化 error。状态包括 not_started、restoring、verified、failed;只有覆盖字段验证完成才标 verified。安全门覆盖输出目标时另记 `output_override="safety_gate_off"`,原快照保持。HTML 展示范围、未覆盖项和逐通道结果;整体恢复状态仅表示请求的基础范围。旧插件的记录形状保持不变。 diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index 82cb7dca..d12aade5 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -332,3 +332,11 @@ PSD 可以跟在去直流、去趋势或 FIR/IIR 之后,但不能跟在整 时域指标为 `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` 保持原有算法,不由新流水线重定义。 + +## 基础源恢复范围 + +`[restore] source_state = true` 要求基础状态恢复,不能解释为完整仪器备份。插件可声明覆盖字段、未覆盖字段和支持恢复的操作;不支持所请求恢复时,`run check` 在连接仪器前拒绝。执行阶段还会先读取并验证全部恢复快照,初态不能恢复时不执行实验步骤。 + +`source.arb_load` 与恢复组合要求插件明确支持,且上传通道必须包含在 source_channels(或默认恢复通道)中。易失任意波内容可以明确排除;基础参数恢复成功不表示旧任意波内容已恢复。没有恢复要求时可以执行上传,但报告仍展示覆盖范围。查看声明可用 `plugin info --load`。 + +恢复先关闭输出、恢复并验证参数,最后按目标处理输出;安全门要求 OFF 的通道不会因快照原来为 ON 而重新启用。恢复失败仍使 run 失败,并阻止离线分析后缀。驱动开发合同见[插件开发](../development/plugin-development.md),结果字段见[产物 Reference](artifacts.md)。 diff --git a/plans/README.md b/plans/README.md index c146cb37..a0e0e932 100644 --- a/plans/README.md +++ b/plans/README.md @@ -133,4 +133,4 @@ python -m wavebench analysis report /tmp/wavebench-pair --output /tmp/wavebench- Windows 可将 `/tmp/...` 替换为本地新目录。高级指标窗口按示例的 16384 Hz 采样率设计;换用历史包前需要按实际采样率和频率分辨率修改窗口及频带。缺少同步证据的真实旧包不适用于双通道示例,可用于单通道及批量流程验证。 -`example_synchronized_pair.toml` 演示已有外部激励下的真实双通道冻结采集与分析,需要驱动声明 `scope.capture_synchronized`。计划本身不打开信号源;采集会修改示波器时基、垂直设置并保持 STOP。先按实际信号幅度与安全条件调整配置,再执行 `run check` 和实时预检。DG 易失性 ARB 上传后的恢复需要明确保存基础源快照;通用基础 setter 不能保证从 USER 状态恢复,更不能恢复已覆盖的 ARB 内存。 +`example_synchronized_pair.toml` 演示已有外部激励下的真实双通道冻结采集与分析,需要驱动声明 `scope.capture_synchronized`。计划本身不打开信号源;采集会修改示波器时基、垂直设置并保持 STOP。先按实际信号幅度与安全条件调整配置,再执行 `run check` 和实时预检。DG 易失性 ARB 上传与 RunPlan 基础恢复组合需要插件声明独立恢复能力;Core 使用写入前的基础快照调用恢复入口。被覆盖的 ARB 内存不在恢复范围内。 diff --git a/pyproject.toml b/pyproject.toml index c5e6ae16..a3c9a84d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench" -version = "0.8.26" +version = "0.8.27" description = "Lightweight VISA/SCPI measurement bench for contest debugging" readme = "README.md" requires-python = ">=3.11" diff --git a/src/wavebench/cli_output.py b/src/wavebench/cli_output.py index aaa25727..2f73b29c 100644 --- a/src/wavebench/cli_output.py +++ b/src/wavebench/cli_output.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json from pathlib import Path from typing import Any @@ -215,6 +216,8 @@ def _print_instrument_descriptor(descriptor: InstrumentDescriptor) -> None: print(f"distribution_version={descriptor.version}") print(f"source={descriptor.source}") print("permissions=" + ", ".join(descriptor.permissions)) + if descriptor.source_restore is not None: + print("source_restore=" + json.dumps(descriptor.source_restore.as_dict(), ensure_ascii=False)) extensions = descriptor.scope_extensions if extensions is not None: profiles = [ diff --git a/src/wavebench/drivers/dg4202.py b/src/wavebench/drivers/dg4202.py index bdf8dff7..64e97d39 100644 --- a/src/wavebench/drivers/dg4202.py +++ b/src/wavebench/drivers/dg4202.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from math import isclose, isfinite from threading import RLock @@ -251,6 +251,65 @@ def _snapshot_basic_status(self, channel: int) -> SourceStatus: ) return snapshot + # Basic restore implementation synchronized with DG4000 plugin 0.8.0. + @staticmethod + def _validate_basic_restore_target(snapshot: SourceStatus) -> None: + if not isinstance(snapshot, SourceStatus): + raise DataError("DG4000 restore requires a SourceStatus snapshot") + _validate_channel(snapshot.channel) + if (snapshot.function not in _RESTORABLE_BASIC_FUNCTIONS + or snapshot.output not in {"ON", "OFF"} + or snapshot.amplitude_unit != "VPP" + or snapshot.frequency_mode != "FIX" or snapshot.sweep_enabled != "OFF"): + raise DataError("DG4000 basic restore requires a basic waveform, VPP, FIX and sweep OFF") + for name in ("frequency_hz", "amplitude", "offset_v", "square_duty_cycle_percent"): + value = getattr(snapshot, name) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise DataError(f"DG4000 restore requires finite {name}") + if snapshot.frequency_hz <= 0 or snapshot.amplitude < 0 or not 0 < snapshot.square_duty_cycle_percent < 100: + raise DataError("DG4000 restore snapshot values are out of range") + + def _require_basic_restore_modes(self, channel: int) -> None: + for suffix in ("BURS:STAT", "MOD:STAT", "SWE:STAT"): + if _normalize_enum(self.transport.query(f":SOUR{channel}:{suffix}?", replay=ReplayPolicy.NO_REPLAY), + field_name=suffix, aliases={"0": "OFF", "OFF": "OFF", "1": "ON", "ON": "ON"}) != "OFF": + raise DataError("DG4000 basic restore requires burst, modulation and sweep OFF") + + def snapshot_basic_state(self, channel: int) -> SourceStatus: + """Read a validated baseline without changing output or instrument state.""" + _validate_channel(channel) + with self._io_lock: + self._ensure_identity(write=True) + self._ensure_configuration_write_allowed() + snapshot = self._snapshot_basic_status(channel) + self._validate_basic_restore_target(snapshot) + self._require_basic_restore_modes(channel) + return snapshot + + def restore_basic_state(self, snapshot: SourceStatus) -> SourceStatus: + """Restore a validated baseline, including escape from USER; payload is excluded.""" + self._validate_basic_restore_target(snapshot) + with self._io_lock: + self._ensure_identity(write=True) + self._ensure_configuration_write_allowed() + self._require_basic_restore_modes(snapshot.channel) + # Verify every configured field while OFF before allowing output ON. + try: + restored = self._restore_basic_status(replace(snapshot, output="OFF")) + self.assert_no_errors() + if snapshot.output == "ON": + self._write(f":OUTP{snapshot.channel} ON") + restored = self.get_status(snapshot.channel) + if restored.output != "ON": + raise InstrumentError("DG4000 restored output ON readback mismatch") + self.assert_no_errors() + return restored + except Exception: + self._configuration_writes_blocked = True + # No retries or recovery writes on an uncertain session. Parameters + # were restored while OFF; a final ON failure remains uncertain. + raise + def _restore_basic_status(self, snapshot: SourceStatus) -> SourceStatus: channel = snapshot.channel self._force_output_off(channel) diff --git a/src/wavebench/instruments/api.py b/src/wavebench/instruments/api.py index 7d9a55b0..42a68fc8 100644 --- a/src/wavebench/instruments/api.py +++ b/src/wavebench/instruments/api.py @@ -11,6 +11,7 @@ from .scope_extensions import ScopeDescriptorExtensions from .source_extensions import SourceDescriptorExtensions +from .source_restore import SourceRestoreProfile from .rf_source_extensions import RfSourceDescriptorExtensions EXECUTABLE_PLUGIN_API_VERSION = "wavebench.instrument.v2" @@ -94,6 +95,7 @@ class InstrumentDescriptor: scope_extensions: ScopeDescriptorExtensions | None = None source_extensions: SourceDescriptorExtensions | None = None rf_source_extensions: RfSourceDescriptorExtensions | None = None + source_restore: SourceRestoreProfile | None = None def __post_init__(self) -> None: if not self.driver_id or self.driver_id.strip() != self.driver_id: @@ -131,6 +133,15 @@ def __post_init__(self) -> None: raise ValueError("source_extensions can only be declared by source descriptors") if not isinstance(self.source_extensions, SourceDescriptorExtensions): raise TypeError("source_extensions has an invalid type") + if self.source_restore is not None: + if self.kind != "source" or not isinstance(self.source_restore, SourceRestoreProfile): + raise ValueError("source_restore requires a source descriptor and SourceRestoreProfile") + if set(self.source_restore.operations) - set(self.capabilities): + raise ValueError("restore operations must be declared capabilities") + if self.source_restore.supported != ("source.restore_state" in self.capabilities): + raise ValueError("source_restore support must match source.restore_state capability") + elif "source.restore_state" in self.capabilities: + raise ValueError("source.restore_state requires a source_restore profile") if self.rf_source_extensions is None: if self.kind == "rf_source": raise ValueError("rf_source descriptors require rf_source_extensions") diff --git a/src/wavebench/instruments/builtin.py b/src/wavebench/instruments/builtin.py index bdf936e5..38501c5b 100644 --- a/src/wavebench/instruments/builtin.py +++ b/src/wavebench/instruments/builtin.py @@ -3,6 +3,7 @@ from wavebench import __version__ from .api import InstrumentDescriptor +from .source_restore import SourceRestoreProfile def _open_rtm2032(context): @@ -122,6 +123,7 @@ def _open_dm3000(context): "source.output", "source.arbitrary_probe", "source.arbitrary_upload", + "source.restore_state", ), idn_patterns=("RIGOL TECHNOLOGIES,DG4",), backends=("pyvisa",), @@ -131,6 +133,14 @@ def _open_dm3000(context): summary="RIGOL DG4000-series signal source driver for frequency, waveform, output, and ARB flows.", version=__version__, config_fields=("source.resource", "source.driver", "safety_limits.max_source_vpp"), + source_restore=SourceRestoreProfile( + supported=True, + operations=("source.set_frequency", "source.set_function", "source.set_amplitude_vpp", + "source.set_square_duty_cycle", "source.output", "source.arbitrary_upload"), + fields=("output", "function", "frequency_hz", "amplitude", "amplitude_unit", + "square_duty_cycle_percent", "offset_v", "frequency_mode", "sweep_enabled"), + excluded_fields=("arbitrary_payload", "phase_deg", "load", "burst", "modulation"), + ), ), InstrumentDescriptor( driver_id="rigol.dp800", diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index e3436224..c5d64d0d 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -52,6 +52,7 @@ "source.idn": ("idn",), "source.errors": ("errors", "assert_no_errors"), "source.status": ("get_status",), + "source.restore_state": ("snapshot_basic_state", "restore_basic_state"), "source.channel_profile": ("get_channel_profile",), "source.coupling_profile": ("get_coupling_profile",), "source.coupling_configure": ("configure_coupling",), diff --git a/src/wavebench/instruments/source_restore.py b/src/wavebench/instruments/source_restore.py new file mode 100644 index 00000000..471ad1ea --- /dev/null +++ b/src/wavebench/instruments/source_restore.py @@ -0,0 +1,51 @@ +"""Optional basic-state restoration contract for source plugins.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Protocol, runtime_checkable + +from .models import SourceStatus + + +BASIC_RESTORE_FIELDS = frozenset({ + "output", "function", "frequency_hz", "amplitude", "amplitude_unit", + "square_duty_cycle_percent", +}) + + +@dataclass(frozen=True) +class SourceRestoreProfile: + supported: bool + operations: tuple[str, ...] = () + fields: tuple[str, ...] = () + excluded_fields: tuple[str, ...] = () + + def __post_init__(self): + if type(self.supported) is not bool: + raise ValueError("restore supported must be boolean") + for name in ("operations", "fields", "excluded_fields"): + values = getattr(self, name) + if (not isinstance(values, tuple) or any(not isinstance(v, str) or not v or v.strip() != v for v in values) + or len(set(values)) != len(values)): + raise ValueError(f"restore {name} must contain unique nonempty strings") + if any(not op.startswith("source.") for op in self.operations): + raise ValueError("restore operations must be source operation names") + if set(self.fields) - SourceStatus.__dataclass_fields__.keys(): + raise ValueError("restore fields must name SourceStatus fields") + if set(self.fields) & set(self.excluded_fields): + raise ValueError("restored and excluded fields overlap") + if self.supported: + if not self.operations or not BASIC_RESTORE_FIELDS.issubset(self.fields): + raise ValueError("supported restore requires operations and all basic fields") + elif self.operations or self.fields: + raise ValueError("unsupported restore cannot promise operations or fields") + + def as_dict(self): + return {"schema": "wavebench.source_restore.v1", **asdict(self)} + + +@runtime_checkable +class SourceBasicRestoreDriver(Protocol): + def snapshot_basic_state(self, channel: int) -> SourceStatus: ... + + def restore_basic_state(self, snapshot: SourceStatus) -> SourceStatus: ... diff --git a/src/wavebench/plugins/builtin.py b/src/wavebench/plugins/builtin.py index 25f6f8f3..a20ccffb 100644 --- a/src/wavebench/plugins/builtin.py +++ b/src/wavebench/plugins/builtin.py @@ -60,6 +60,7 @@ "source.output", "source.arbitrary_probe", "source.arbitrary_upload", + "source.restore_state", ), summary="RIGOL DG4000-series signal source driver for frequency, waveform, output, and ARB flows.", idn_patterns=("RIGOL TECHNOLOGIES,DG4",), diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py index fc4ef8c3..f662c922 100644 --- a/src/wavebench/report/html.py +++ b/src/wavebench/report/html.py @@ -251,6 +251,25 @@ def render_run_report_html( restore_block = "" if restore: restore_block = f"

恢复 / Restore: {escape(str(restore.get('status', 'unknown')))}

" + provenance = run.run.get("provenance") + coverage = provenance.get("source_restore_coverage") if isinstance(provenance, dict) else None + if isinstance(coverage, dict): + declaration = coverage.get("declaration", {}) + restored_fields = ", ".join(declaration.get("fields", ())) + uncovered = ", ".join(coverage.get("uncovered", ())) + restore_block += ( + "

源恢复范围 / Source restoration coverage

" + f"

请求恢复 / Requested: {escape(str(coverage.get('requested')))}

" + f"

覆盖字段 / Covered: {escape(restored_fields or 'undeclared')}

" + f"

未覆盖 / Not covered: {escape(uncovered or 'none declared')}

" + ) + for result in restore.get("results", []): + restore_block += ( + f"

CH{escape(str(result.get('channel')))}: {escape(str(result.get('status')))}" + f" {escape(str(result.get('output_override', '')))}

" + ) + if result.get("error"): + restore_block += f"
{escape(str(result['error']))}
" return f""" diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index d366b09b..328ff558 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -113,6 +113,11 @@ def build_execution_intent(plan: RunPlan, config: WaveBenchConfig, *, resource_l safety = _safe_parameters(asdict(plan.safety)) restore = _safe_parameters(asdict(plan.restore)) + if config.source is not None and (plan.restore.source_state or any(s.kind.startswith("source.") for s in plan.steps)): + from wavebench.instruments.registry import resolve_instrument_descriptor + descriptor = resolve_instrument_descriptor(config.source.driver, expected_kind="source") + if getattr(descriptor, "source_restore", None) is not None: + restore["source_contract"] = _safe_parameters(descriptor.source_restore.as_dict()) body = { "plan_digest": plan_hash, "config_digest": config_hash, diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index c83267f6..56d1d1d1 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -1260,6 +1260,8 @@ def _spec( ), _spec("source.set_square_duty_cycle", "source", required_capabilities=("source.set_square_duty_cycle",), effect="write", changed_fields=("square_duty_cycle",), restore_coverage="basic", risk_flags=("signal_output", "state_drift")), _spec("source.arbitrary_probe", "source", required_capabilities=("source.arbitrary_probe",), effect="stateful_read"), + _spec("source.restore_snapshot", "source", required_capabilities=("source.restore_state", "source.idn"), effect="stateful_read"), + _spec("source.restore_state", "source", required_capabilities=("source.restore_state", "source.idn"), effect="write", changed_fields=("basic_state",), risk_flags=("signal_output",)), _spec("source.arbitrary_upload", "source", required_capabilities=("source.arbitrary_upload",), effect="write", changed_fields=("arbitrary_payload",), risk_flags=("signal_output", "volatile_payload")), _spec( "rf_source.idn", diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py index 868ee5d5..c227d099 100644 --- a/src/wavebench/services/run_artifacts.py +++ b/src/wavebench/services/run_artifacts.py @@ -104,6 +104,9 @@ def write_run_files( "snapshots": snapshots, "status": "failed" if restore_error is not None else "ok", } + results = [state.restore_evidence for state in restore_state if getattr(state, "restore_evidence", None) is not None] + if results: + run_data["restore"]["results"] = results if len(restore_state) == 1: run_data["restore"]["source_channel"] = restore_state[0].channel run_data["restore"]["snapshot"] = restore_state[0].as_dict() diff --git a/src/wavebench/services/run_restore.py b/src/wavebench/services/run_restore.py index 85e527ab..45f9fd56 100644 --- a/src/wavebench/services/run_restore.py +++ b/src/wavebench/services/run_restore.py @@ -32,6 +32,7 @@ def snapshot_source_state( def restore_source_state( states: list[RestorableSourceState] | None, *, + force_off_channels: tuple[int, ...] = (), source_service_factory: SourceServiceFactory, ) -> dict[str, Any] | None: if not states: @@ -40,7 +41,13 @@ def restore_source_state( service = source_service_factory() for state in states: try: - service.restore_restorable_state(state) + target = state + if state.channel in force_off_channels: + from dataclasses import replace + target = replace(state, output="OFF") + if getattr(state, "restore_evidence", None) is not None: + state.restore_evidence["output_override"] = "safety_gate_off" + service.restore_restorable_state(target) except (TransportIOError, SessionHealthError) as exc: # A gated/structured transport failure is authoritative. Do not # issue further restore writes on an uncertain or poisoned epoch. @@ -48,6 +55,8 @@ def restore_source_state( exc, operation=f"restore.source.{state.channel}", ) + if getattr(state, "restore_evidence", None) is not None: + state.restore_evidence.update(status="failed", error=envelope) errors.append( { "channel": state.channel, @@ -62,6 +71,8 @@ def restore_source_state( exc, operation=f"restore.source.{state.channel}", ) + if getattr(state, "restore_evidence", None) is not None: + state.restore_evidence.update(status="failed", error=envelope) errors.append( { "channel": state.channel, @@ -77,3 +88,34 @@ def restore_source_state( "errors": errors, } return None + + +def source_restore_coverage(plan, descriptor, default_channel): + """Offline declaration check; no session or instrument I/O.""" + from wavebench.errors import ConfigError + from wavebench.services.execution_intent import _STEP_OPERATIONS + from wavebench.services.operation_specs import get_operation_spec + + profile = getattr(descriptor, "source_restore", None) + source_steps = [step for step in plan.steps if step.kind.startswith("source.")] + arbitrary = any(step.kind == "source.arb_load" for step in source_steps) + if profile is None and not arbitrary: + return None + if plan.restore.source_state: + if profile is None or not profile.supported: + raise ConfigError("requested source restore is not supported by the plugin for this plan") + for step in source_steps: + operation = _STEP_OPERATIONS.get(step.kind, step.kind) + spec = get_operation_spec(operation) + if spec is not None and spec.effect == "write" and operation not in profile.operations: + raise ConfigError(f"source restore is not declared after {operation}") + if arbitrary: + restored_channels = set(plan.restore.source_channels or (default_channel,)) + if any(step.fields.get("channel", default_channel) not in restored_channels + for step in source_steps if step.kind == "source.arb_load"): + raise ConfigError("arbitrary upload channel must be included in source restore channels") + return { + "requested": plan.restore.source_state, + "declaration": profile.as_dict() if profile is not None else {"supported": None}, + "uncovered": list(profile.excluded_fields) if profile else ["arbitrary_payload"], + } diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 188bc156..c14f9290 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -334,6 +334,17 @@ def check(self, plan: RunPlan) -> None: self._check_frequency_response_resumes(plan) self._check_rf_source_access(plan) self._check_plan_capabilities(plan) + self._source_restore_coverage(plan) + + def _source_restore_coverage(self, plan): + from .run_restore import source_restore_coverage + if not plan.restore.source_state and not any(s.kind.startswith("source.") for s in plan.steps): + return None + cfg = self.config.source + if cfg is None: + return None + descriptor = resolve_instrument_descriptor(cfg.driver, expected_kind="source") + return source_restore_coverage(plan, descriptor, cfg.default_channel) def _check_rf_source_access(self, plan: RunPlan) -> None: """Reject RF operations by access policy before run lifecycle opens a session.""" @@ -468,6 +479,10 @@ def add_source_restore_capabilities() -> None: source.driver, expected_kind="source", ) + if getattr(descriptor, "source_restore", None) is not None: + if descriptor.source_restore.supported: + add("source", "source.restore_state", "source.idn") + return v2_restore = { "source.snapshot_v2", "source.basic_configure_v2", @@ -761,6 +776,10 @@ def run( }, } + coverage = self._source_restore_coverage(plan) + if coverage is not None: + provenance["source_restore_coverage"] = coverage + def append_source_operation_artifact(value: object) -> None: if isinstance(value, dict): source_operations.append(value) @@ -906,6 +925,8 @@ def report_close_errors() -> None: status="failed", artifact={**record.artifact, "safety_gate": gate_result}, ) + if step.kind == "source.arb_load" and coverage is not None: + record = replace(record, artifact={**record.artifact, "restore_coverage": coverage}) records.append(record) write_step_record(steps_dir, record) self._update_frequency_responses_manifest(run_dir, record) @@ -1032,6 +1053,7 @@ def report_close_errors() -> None: restore_error = restore_source_state( restore_state, + force_off_channels=tuple((safety_gate_config or {}).get("source_channels", ())), source_service_factory=lambda: self._source_service(services=services), ) if ( diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index 5fef9a64..66a40c2a 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +from wavebench.errors import error_envelope + from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass, field as dataclass_field @@ -10776,7 +10778,31 @@ def trigger_sweep(self, channel: int | None = None) -> None: check_errors=source_cfg.check_errors, ) + def _native_restore_profile(self): + self._declared_source_capabilities() + return getattr(self.descriptor, "source_restore", None) + def snapshot_restorable_state(self, channel: int | None = None) -> RestorableSourceState: + profile = self._native_restore_profile() + if profile is not None: + if not profile.supported: + raise ConfigError("source plugin does not support basic-state restoration") + from dataclasses import replace + self._require("source.restore_snapshot", "source.restore_state", "source.idn") + cfg = self._source_config() + target = cfg.default_channel if channel is None else channel + with self._source_session() as source: + identity = source.idn() + status = source.snapshot_basic_state(target) + state = RestorableSourceState.from_status(status) + if status.channel != target: + raise ConfigError("source restore snapshot channel mismatch") + self._check_source_vpp(state.amplitude_vpp, field="source restore amplitude") + if self.state_guard is not None: + self.state_guard.observe(status) + return replace(state, driver_snapshot=status, driver_id=self.descriptor.driver_id, + instrument_idn=identity, restore_evidence={ + "channel": target, "status": "not_started", **profile.as_dict()}) if self._declares_source_v2_basic_restore(): source_cfg = self._source_config() target_channel = source_cfg.default_channel if channel is None else channel @@ -10790,6 +10816,43 @@ def snapshot_restorable_state(self, channel: int | None = None) -> RestorableSou return RestorableSourceState.from_status(self.status(channel=channel)) def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus: + profile = self._native_restore_profile() + if profile is not None: + from dataclasses import replace + from math import isclose + self._require("source.restore_state", "source.restore_state", "source.idn") + if (not profile.supported or not isinstance(state, RestorableSourceState) + or state.driver_snapshot is None or state.driver_id != self.descriptor.driver_id + or state.channel != state.driver_snapshot.channel): + raise ConfigError("source restore requires a matching driver snapshot") + target = replace(state.driver_snapshot, output=state.output) + self._check_source_vpp(target.amplitude, field="source restore amplitude") + with self._source_session() as source: + if source.idn() != state.instrument_idn: + raise ConfigError("source identity changed since restore snapshot") + if state.restore_evidence is not None: + state.restore_evidence["status"] = "restoring" + try: + status = source.restore_basic_state(target) + if status.channel != target.channel: + raise ConfigError("source restore readback channel mismatch") + for name in profile.fields: + actual, expected = getattr(status, name), getattr(target, name) + matches = (isclose(actual, expected, rel_tol=1e-6, abs_tol=1e-6) + if type(actual) in (float, int) and type(expected) in (float, int) + else actual == expected) + if not matches: + raise ConfigError(f"source restore readback mismatch: {name}") + self._state_guard_after_write(status) + except Exception as exc: + if state.restore_evidence is not None: + state.restore_evidence.update(status="failed", error=error_envelope(exc)) + raise + if state.restore_evidence is not None: + state.restore_evidence.update(status="verified", observed=status.as_dict()) + return status + if getattr(state, "driver_snapshot", None) is not None: + raise ConfigError("source restore capability changed since snapshot") if self._declares_source_v2_basic_restore(): # Basic V2 MAIN phases permit exactly one bounded driver write. # Build every request before turning output OFF, then preserve the diff --git a/src/wavebench/services/source_state.py b/src/wavebench/services/source_state.py index c7ec8449..80c1a500 100644 --- a/src/wavebench/services/source_state.py +++ b/src/wavebench/services/source_state.py @@ -1,6 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from math import isfinite from wavebench.instruments.models import SourceStatus from wavebench.errors import DataError @@ -15,6 +16,10 @@ class RestorableSourceState: amplitude_vpp: float amplitude_unit: str square_duty_cycle_percent: float | None = None + driver_snapshot: SourceStatus | None = field(default=None, repr=False, compare=False) + driver_id: str | None = field(default=None, repr=False) + instrument_idn: str | None = field(default=None, repr=False) + restore_evidence: dict | None = field(default=None, repr=False, compare=False) @classmethod def from_status(cls, status: SourceStatus) -> "RestorableSourceState": @@ -26,6 +31,13 @@ def from_status(cls, status: SourceStatus) -> "RestorableSourceState": raise DataError("cannot snapshot source state: amplitude_unit is missing") if status.amplitude_unit.strip().upper() != "VPP": raise DataError("cannot snapshot source state: only VPP amplitude is restorable for now") + if type(status.channel) is not int or status.channel < 1 or status.output.strip().upper() not in {"ON", "OFF"}: + raise DataError("cannot snapshot source state: invalid channel or output") + for value in (status.frequency_hz, status.amplitude): + if type(value) not in (int, float) or not isfinite(value): + raise DataError("cannot snapshot source state: nonfinite numeric value") + if status.frequency_hz <= 0 or status.amplitude < 0 or not status.function.strip(): + raise DataError("cannot snapshot source state: invalid basic value") return cls( channel=status.channel, output=status.output.strip().upper(), diff --git a/tests/test_instrument_registry.py b/tests/test_instrument_registry.py index b8bb1e3b..a55fdb68 100644 --- a/tests/test_instrument_registry.py +++ b/tests/test_instrument_registry.py @@ -288,6 +288,7 @@ def test_migration_canonical_falls_back_to_builtin_when_external_is_absent(): def test_invalid_migration_plugin_does_not_remove_builtin_from_load_all(): descriptor = make_external_dg4202_descriptor( capabilities=("source.unknown",), + source_restore=None, ) entry_point = FakeEntryPoint("rigol.dg4202", descriptor) diff --git a/tests/test_run_failure_policy.py b/tests/test_run_failure_policy.py index 52d0343c..043979cd 100644 --- a/tests/test_run_failure_policy.py +++ b/tests/test_run_failure_policy.py @@ -376,7 +376,8 @@ def test_safety_gate_remains_off_after_source_restore() -> None: source = patch("wavebench.services.run_service.SourceService").start() try: source_instance = source.return_value - state = SimpleNamespace(channel=1, as_dict=lambda: {"channel": 1}) + from wavebench.services.source_state import RestorableSourceState + state = RestorableSourceState(1, "ON", "SIN", 1000., 1., "VPP") source_instance.snapshot_restorable_state.return_value = state source_instance.restore_restorable_state.return_value = SimpleNamespace(output="ON") source_instance.set_output.return_value = SimpleNamespace(output="OFF") @@ -391,6 +392,7 @@ def test_safety_gate_remains_off_after_source_restore() -> None: result = service.run(plan) assert source_instance.restore_restorable_state.call_count == 1 + assert source_instance.restore_restorable_state.call_args.args[0].output == "OFF" assert source_instance.set_output.call_count == 2 run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) assert run_data["error"]["safety_gate"]["post_restore"]["status"] == "ok" diff --git a/tests/test_scope_extension_registry.py b/tests/test_scope_extension_registry.py index c810302d..e442da99 100644 --- a/tests/test_scope_extension_registry.py +++ b/tests/test_scope_extension_registry.py @@ -170,12 +170,13 @@ def test_public_scope_capability_requires_new_core_floor() -> None: def test_scope_descriptor_extension_is_append_only_for_positional_compatibility() -> None: names = [field.name for field in fields(InstrumentDescriptor)] - assert names[-5:] == [ + assert names[-6:] == [ "config_fields", "resource_schemes", "scope_extensions", "source_extensions", "rf_source_extensions", + "source_restore", ] assert [field.name for field in fields(ScopeDescriptorExtensions)] == [ "screenshot_profile", diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index b6fccce5..ae6c371b 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -704,12 +704,13 @@ def test_source_descriptor_append_only_and_replace_compatible() -> None: descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) names = [item.name for item in fields(InstrumentDescriptor)] - assert names[-5:] == [ + assert names[-6:] == [ "config_fields", "resource_schemes", "scope_extensions", "source_extensions", "rf_source_extensions", + "source_restore", ] assert replace(descriptor, summary="changed").source_extensions is descriptor.source_extensions @@ -3444,7 +3445,7 @@ def test_source_v1_capability_mapping_is_unchanged() -> None: actual = { key: value for key, value in CAPABILITY_METHODS.items() - if key.startswith("source.") and not key.endswith("_v2") + if key.startswith("source.") and not key.endswith("_v2") and key != "source.restore_state" } assert actual == expected diff --git a/tests/test_source_restore_contract.py b/tests/test_source_restore_contract.py new file mode 100644 index 00000000..60f3e07b --- /dev/null +++ b/tests/test_source_restore_contract.py @@ -0,0 +1,194 @@ +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments.source_restore import BASIC_RESTORE_FIELDS, SourceRestoreProfile +from wavebench.logging import CommandLogger +from wavebench.services.run_restore import restore_source_state, source_restore_coverage +from wavebench.services.source_service import SourceService +from test_run_service import make_config, write_plan +from test_source_state import make_status +from wavebench.services.run_plan import load_run_plan +from wavebench.services.run_service import RunService +from wavebench.instruments.registry import resolve_instrument_descriptor + + +def profile(): + return SourceRestoreProfile(True, ("source.arbitrary_upload", "source.output"), + tuple(sorted(BASIC_RESTORE_FIELDS)), ("arbitrary_payload",)) + + +def test_restore_profile_rejects_false_promises(): + for kwargs in ({"supported": 1}, {"supported": True}, + {"supported": False, "fields": ("output",)}, + {"supported": True, "operations": ("scope.capture",), "fields": tuple(BASIC_RESTORE_FIELDS)}): + with pytest.raises(ValueError): + SourceRestoreProfile(**kwargs) + + +def test_plan_rejects_undeclared_arb_restore_before_lifecycle(tmp_path, monkeypatch): + plan = load_run_plan(write_plan(str(tmp_path), '''[restore] +source_state = true +source_channel = 1 +[[steps]] +kind = "source.arb_load" +file = "missing.npy" +frequency_hz = 1000 +amplitude_vpp = 1 +''')) + for declaration in (None, SourceRestoreProfile(False)): + descriptor = SimpleNamespace(source_restore=declaration) + with pytest.raises(ConfigError, match="not supported"): + source_restore_coverage(plan, descriptor, 1) + coverage = source_restore_coverage(plan, SimpleNamespace(source_restore=profile()), 1) + assert coverage["uncovered"] == ["arbitrary_payload"] + assert coverage["requested"] is True + wrong_channel = replace(plan, restore=replace(plan.restore, source_channels=(2,))) + with pytest.raises(ConfigError, match="channel"): + source_restore_coverage(wrong_channel, SimpleNamespace(source_restore=profile()), 1) + no_restore = replace(plan, restore=replace(plan.restore, source_state=False)) + assert not source_restore_coverage(no_restore, SimpleNamespace(source_restore=None), 1)["requested"] + service = RunService(make_config(str(tmp_path)), CommandLogger()) + monkeypatch.setattr(service, "_check_plan_capabilities", lambda plan: None) + monkeypatch.setattr("wavebench.services.run_service.resolve_instrument_descriptor", + lambda *a, **k: SimpleNamespace(source_restore=None)) + monkeypatch.setattr(service, "_run_instrument_lifecycle", lambda plan: pytest.fail("must not open sessions")) + with pytest.raises(ConfigError, match="not supported"): + service.run(plan) + + +class NativeDriver: + def __init__(self): + self.status = make_status(output="ON") + self.identity = "VENDOR,MODEL,SERIAL,1" + self.calls = [] + self.bad_readback = False + + def idn(self): + return self.identity + + def snapshot_basic_state(self, channel): + return replace(self.status, channel=channel) + + def restore_basic_state(self, snapshot): + self.calls.append(snapshot) + self.status = replace(snapshot, function="USER") if self.bad_readback else snapshot + return self.status + + +def native_service(tmp_path): + cfg = make_config(str(tmp_path)) + base = resolve_instrument_descriptor(cfg.source.driver, expected_kind="source") + desc = replace(base, capabilities=tuple(dict.fromkeys((*base.capabilities, "source.restore_state"))), source_restore=profile()) + driver = NativeDriver() + return SourceService(cfg, CommandLogger(), session=driver, descriptor=desc), driver + + +def test_native_restore_uses_saved_target_and_safety_gate_overrides_output(tmp_path): + service, driver = native_service(tmp_path) + state = service.snapshot_restorable_state(1) + driver.status = replace(driver.status, function="USER", amplitude=1.) + assert restore_source_state([state], source_service_factory=lambda: service, force_off_channels=(1,)) is None + assert driver.calls[0].function == "SIN" + assert driver.calls[0].amplitude == 5. + assert driver.calls[0].output == "OFF" + assert state.output == "ON" + assert state.restore_evidence["status"] == "verified" + assert state.restore_evidence["output_override"] == "safety_gate_off" + + +def test_native_restore_mismatch_and_identity_change_are_not_success(tmp_path): + service, driver = native_service(tmp_path) + state = service.snapshot_restorable_state(1) + driver.identity = "another instrument" + error = restore_source_state([state], source_service_factory=lambda: service) + assert error and not driver.calls + assert state.restore_evidence["status"] == "failed" + driver.identity = state.instrument_idn + driver.bad_readback = True + error = restore_source_state([state], source_service_factory=lambda: service) + assert error and state.restore_evidence["status"] == "failed" + + +@pytest.mark.parametrize("requested,bad_readback", [(True, False), (True, True), (False, False)]) +def test_run_artifact_and_report_distinguish_restore_from_uncovered_payload(tmp_path, monkeypatch, requested, bad_readback): + from contextlib import nullcontext + import json + import numpy as np + from wavebench.data.packages import load_run_package + from wavebench.report.html import render_run_report_html + from wavebench.services.run_service import RunInstrumentServices + + source, driver = native_service(tmp_path) + driver.status = replace(driver.status, output="OFF") + driver.bad_readback = bad_readback + def upload(**kwargs): + driver.status = replace(driver.status, function="USER", amplitude=kwargs["amplitude_vpp"]) + return driver.status + driver.upload_dg4000_dac14_block = upload + waveform = tmp_path / "arb.npy" + np.save(waveform, np.array([0., 1., 0., -1.])) + plan = load_run_plan(write_plan(str(tmp_path), f'''[restore] +source_state = {str(requested).lower()} +{'source_channel = 1' if requested else ''} +[[steps]] +kind = "source.arb_load" +file = "{waveform.as_posix()}" +channel = 1 +frequency_hz = 1000 +amplitude_vpp = 1 +''')) + service = RunService(source.config, CommandLogger()) + monkeypatch.setattr(service, "_run_safety_guards", lambda *a, **k: None) + monkeypatch.setattr(service, "_run_instrument_services", lambda plan: nullcontext(RunInstrumentServices(source=source))) + monkeypatch.setattr("wavebench.services.run_service.resolve_instrument_descriptor", lambda *a, **k: source.descriptor) + monkeypatch.setattr("wavebench.instruments.registry.resolve_instrument_descriptor", lambda *a, **k: source.descriptor) + if bad_readback: + with pytest.raises(ConfigError, match="restore failed"): + service.run(plan) + run_path = next(tmp_path.rglob("run.json")) + else: + run_path = service.run(plan).run_json_path + run = json.loads(run_path.read_text()) + assert run["steps"][0]["artifact"]["restore_coverage"]["uncovered"] == ["arbitrary_payload"] + assert run["status"] == ("failed" if bad_readback else "ok") + if requested: + assert run["restore"]["results"][0]["status"] == ("failed" if bad_readback else "verified") + else: + assert not driver.calls and "restore" not in run + html = render_run_report_html(load_run_package(run_path.parent)) + assert "Source restoration coverage" in html and "arbitrary_payload" in html + + +def test_builtin_fallback_restores_user_without_external_plugin(): + from wavebench.instruments.registry import InstrumentRegistry + from wavebench.drivers.dg4202 import DG4202Source + from test_dg4202 import FakeTransport + + class Transport(FakeTransport): + def query(self, command, **kwargs): + if command in {":SOUR2:BURS:STAT?", ":SOUR2:MOD:STAT?"}: + return "OFF" + return super().query(command, **kwargs) + + descriptor = InstrumentRegistry(external_entry_points=()).resolve("rigol.dg4202") + assert descriptor.origin == "builtin" and descriptor.source_restore.supported + transport = Transport() + transport.state.update(out="OFF", mode="FIX", swe="OFF") + driver = DG4202Source(transport) + snapshot = driver.snapshot_basic_state(2) + transport.state.update(func="USER", volt=1., offs=.2) + restored = driver.restore_basic_state(snapshot) + assert restored.function == "SIN" and restored.amplitude == 5. and restored.offset_v == 0. + assert restored.output == "OFF" + assert not transport.byte_writes + + +def test_native_restore_rejects_mismatched_snapshot_channel_before_write(tmp_path): + service, driver = native_service(tmp_path) + state = service.snapshot_restorable_state(1) + with pytest.raises(ConfigError, match="matching driver snapshot"): + service.restore_restorable_state(replace(state, channel=2)) + assert not driver.calls diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py index 777594f9..f412d9f2 100644 --- a/tests/test_source_v1_routes.py +++ b/tests/test_source_v1_routes.py @@ -30,6 +30,7 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs inventoried_operations = {item.operation for item in inventory if item.operation is not None} assert inventoried_operations <= source_write_operations assert source_write_operations - inventoried_operations == { + "source.restore_state", # Independent snapshot-based restore, not a V1 setter route. "source.basic_configure_v2", "source.basic_live_configure_v2", "source.output_enable_v2", From 387c9fc5498dd0a6f9e92cb3da941c50aa6ee736 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:34:25 +0800 Subject: [PATCH 29/30] fix(analysis): preserve Windows file identity and lock compatibility --- docs/reference/run-schema.md | 6 +++--- src/wavebench/data/analysis_io.py | 7 +++++- src/wavebench/report/analysis.py | 5 ++++- src/wavebench/services/analysis_batch.py | 2 ++ tests/test_analysis_batch.py | 14 ++++++++++++ tests/test_analysis_resources.py | 27 ++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/docs/reference/run-schema.md b/docs/reference/run-schema.md index d12aade5..7fbf4b50 100644 --- a/docs/reference/run-schema.md +++ b/docs/reference/run-schema.md @@ -8,7 +8,7 @@ ## 高级谱质量估计 -本节及下文批量/双通道接口为开发分支已实现、尚未发布的合同。`spectral_quality` 只接受 mean Welch PSD,沿用命名指标与 `[expect]`。完整配方见 `plans/example_spectral_quality.toml`;每个字段均显式声明。 +`spectral_quality` 只接受 mean Welch PSD,沿用命名指标与 `[expect]`。完整配方见 `plans/example_spectral_quality.toml`;每个字段均显式声明。 | 字段 | 合同 | | --- | --- | @@ -68,7 +68,7 @@ transfer 至少需要两个完整 Welch 段,固定 mean,并共用两路分 ## 分析进程监督 -本节为开发分支已实现、尚未发布的执行合同。`analysis check/run` 与 `run check/intent/verify/plan` 接受 `--analysis-execution `,文件使用 `wavebench.analysis_execution.v1`。示例见 `plans/example_analysis_execution.toml`。 +`analysis check/run` 与 `run check/intent/verify/plan` 接受 `--analysis-execution `,文件使用 `wavebench.analysis_execution.v1`。示例见 `plans/example_analysis_execution.toml`。 | 字段 | 默认值 | 含义 | | --- | --- | --- | @@ -85,7 +85,7 @@ Windows 硬限额使用 Job Object 的 job committed memory;Linux 使用 cgrou ## 分析资源预算 -本节为开发分支已实现、尚未发布的资源合同。分析使用有限的默认预算;超限时拒绝执行,不自动降低 taps、FFT 长度或采样率。旧的极大配方可能因此失败,正常预算内的数值参数与结果保持原样。 +分析使用有限的默认预算;超限时拒绝执行,不自动降低 taps、FFT 长度或采样率。旧的极大配方可能因此失败,正常预算内的数值参数与结果保持原样。 资源文件独立于仪器配置,以 `schema = "wavebench.analysis_resources.v1"` 开头,随后是限额字段。`--analysis-resources ` 可用于 `analysis check/run/report` 和 `run check/intent/verify/plan/report`。未提供的字段沿用默认值。独立离线分析仍不需要 `wavebench.toml`。 diff --git a/src/wavebench/data/analysis_io.py b/src/wavebench/data/analysis_io.py index ff6b3d5b..e6fa685b 100644 --- a/src/wavebench/data/analysis_io.py +++ b/src/wavebench/data/analysis_io.py @@ -20,6 +20,8 @@ def file_identity(stat): + # Compare snapshots made by the same API: Windows stat and fstat may use + # different file-ID representations (notably in Python 3.12). return stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns @@ -36,8 +38,11 @@ def read_json_bounded(path: Path, limits: AnalysisLimits): 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 + path_before = file_identity(path.stat()) with path.open("rb") as file: before = file_identity(os.fstat(file.fileno())) + if path_before != file_identity(path.stat()): + raise DataError("analysis source changed while being opened") 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") @@ -74,7 +79,7 @@ def mapped_npy(path: Path, limits: AnalysisLimits, *, columns: int, source: bool 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()): + if before != file_identity(os.fstat(file.fileno())) or path_before != file_identity(path.stat()): raise DataError("analysis source changed while being read") finally: if array is not None: diff --git a/src/wavebench/report/analysis.py b/src/wavebench/report/analysis.py index d3126464..f1a78356 100644 --- a/src/wavebench/report/analysis.py +++ b/src/wavebench/report/analysis.py @@ -125,8 +125,11 @@ def consume(blocks, count): 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)) + path_before = file_identity(path.stat()) with path.open("rb") as file: before = file_identity(os.fstat(file.fileno())) + if path_before != file_identity(path.stat()): + raise ValueError("export changed while being opened") limits.check("max_output_bytes", before[2], "report input") if hash_stream(file) != item["sha256"]: raise ValueError("export SHA-256 mismatch") @@ -157,7 +160,7 @@ def blocks(): if block: yield np.asarray(block) result = consume(blocks(), count) - if before != file_identity(os.fstat(file.fileno())) or before != file_identity(path.stat()): + if before != file_identity(os.fstat(file.fileno())) or path_before != file_identity(path.stat()): raise ValueError("export changed during report generation") return result diff --git a/src/wavebench/services/analysis_batch.py b/src/wavebench/services/analysis_batch.py index 7fd2172a..ed6c3b6e 100644 --- a/src/wavebench/services/analysis_batch.py +++ b/src/wavebench/services/analysis_batch.py @@ -77,6 +77,8 @@ def _inventory(root, limits): for path in sorted(root.rglob('*')): if path.is_symlink(): raise ConfigError('batch results must not contain symlinks') + if path == root / '.batch.lock': + continue # Windows denies reading the active lock through another handle. if path.is_file(): limits.check('max_output_files', len(files) + 1, 'batch inventory') files[path.relative_to(root).as_posix()] = {'bytes': path.stat().st_size, 'sha256': _sha256_file(path)} diff --git a/tests/test_analysis_batch.py b/tests/test_analysis_batch.py index 93af9b7b..d8cf5b01 100644 --- a/tests/test_analysis_batch.py +++ b/tests/test_analysis_batch.py @@ -105,3 +105,17 @@ def test_batch_continue_source_failure_and_report(tmp_path, analysis_input): assert [item['status'] for item in result['entries']]==['failed','ok'] html=write_analysis_report([tmp_path/'results'],tmp_path/'report.html').read_text() assert 'polyline' in html + + +def test_inventory_never_reads_active_lock(tmp_path, monkeypatch): + from wavebench.services import analysis_batch + from wavebench.data.analysis_resources import AnalysisLimits + (tmp_path / '.batch.lock').write_bytes(b'locked') + artifact = tmp_path / 'result.json' + artifact.write_text('{}') + original = analysis_batch._sha256_file + def hash_file(path): + assert path.name != '.batch.lock', 'cannot read a Windows locked file' + return original(path) + monkeypatch.setattr(analysis_batch, '_sha256_file', hash_file) + assert set(analysis_batch._inventory(tmp_path, AnalysisLimits())) == {'result.json'} diff --git a/tests/test_analysis_resources.py b/tests/test_analysis_resources.py index d4204221..9457461d 100644 --- a/tests/test_analysis_resources.py +++ b/tests/test_analysis_resources.py @@ -246,3 +246,30 @@ def test_report_curve_limit_is_warning(tmp_path, analysis_input): resource_limits=replace(AnalysisLimits(), max_report_curves=1)).read_text() assert html.count(" Date: Tue, 8 Sep 2026 20:50:00 +0800 Subject: [PATCH 30/30] test: wait for asynchronous completion on Windows CI --- tests/test_analysis_execution.py | 4 +++- tests/test_tui_dmm.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_analysis_execution.py b/tests/test_analysis_execution.py index ed89a4fd..09b69cdf 100644 --- a/tests/test_analysis_execution.py +++ b/tests/test_analysis_execution.py @@ -75,8 +75,10 @@ def request_cancel(): time.sleep(0.01) thread = threading.Thread(target=request_cancel, daemon=True) thread.start() + # Cooperative shutdown includes checkpoint writes and process teardown on Windows. + grace_s = 10 if cooperative else 0.5 artifact = supervise(_slow_pipeline, dict(output=output, cooperative=cooperative), - policy=AnalysisExecution(timeout_s=30, grace_s=0.5), run_dir=output, processing_dir=output, + policy=AnalysisExecution(timeout_s=30, grace_s=grace_s), run_dir=output, processing_dir=output, fields=FIELDS, source={'status': 'ok'}, limits=AnalysisLimits(), cancel_event=cancelled) thread.join(1) info = artifact['analysis_pipeline'] diff --git a/tests/test_tui_dmm.py b/tests/test_tui_dmm.py index 1b6333c4..6e60a3ed 100644 --- a/tests/test_tui_dmm.py +++ b/tests/test_tui_dmm.py @@ -378,10 +378,20 @@ def set_function(self, function: str): # type: ignore[override] refresh_interval_s=60.0, ) async with app.run_test() as pilot: - await pilot.pause(0.25) + # The initial refresh must finish before writes can be accepted. + for _ in range(100): + await pilot.pause(0.05) + if not app._dmm_read_in_flight: + break + self.assertFalse(app._dmm_read_in_flight) app._set_dmm_function("acv") + self.assertTrue(app._dmm_write_in_flight) app._set_dmm_function("acv") - await pilot.pause(0.5) + for _ in range(100): + await pilot.pause(0.05) + if not app._dmm_write_in_flight: + break + self.assertFalse(app._dmm_write_in_flight) self.assertEqual(adapter.apply_calls, ["acv"]) self.assertFalse(app.query_one("#dmm-func-acv", Button).disabled)