From 0cbf531bac64bea6678ac3102b91e8591db2cf18 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 22:20:11 +0200 Subject: [PATCH 1/9] docs(backlog): add approved feature backlog from issue triage (999.2 Tag Analysis Toolkit, 999.3 EventViewer export) Captures the 8 GitHub issues approved during the 2026-07-08 inbox triage: - 999.2 Tag Analysis Toolkit: #223 getStats (keystone), #308 resampleUniform, #326 derivative, #312 movingStat, #316 exceedance, #328 crossings, #327 cumulativeIntegral - 999.3 EventViewer image export parity: #321 exportImage Also records the triage report at .planning/INBOX-TRIAGE.md. Co-Authored-By: Claude Fable 5 --- .planning/INBOX-TRIAGE.md | 96 +++++++++++++++++++ .planning/ROADMAP.md | 28 ++++++ .../999.2-tag-analysis-toolkit/.gitkeep | 0 .../.gitkeep | 0 4 files changed, 124 insertions(+) create mode 100644 .planning/INBOX-TRIAGE.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/.gitkeep create mode 100644 .planning/phases/999.3-eventviewer-image-export-parity/.gitkeep diff --git a/.planning/INBOX-TRIAGE.md b/.planning/INBOX-TRIAGE.md new file mode 100644 index 00000000..90ee649f --- /dev/null +++ b/.planning/INBOX-TRIAGE.md @@ -0,0 +1,96 @@ +# GSD Inbox Triage — HanSur94/FastSense — 2026-07-08 + +Scope: **issues only** (`--issues`). Report-only run — no labels applied, nothing closed. + +## Summary + +| Metric | Value | +|---|---| +| Open issues | **149** | +| Closed issues (all time) | **0** | +| Auto-generated enhancements (feature-scout) | 147 | +| Good-first-issue | 2 (#321, #327) | +| Unlabeled outliers | 2 (#71, #151) | +| Created in last 8 days | 29 (~3.6/day) | +| Older than 30 days | 2 (#71, #151) | + +Age buckets: <7d: 29 · 7–14d: 109 · 14–30d: 9 · >30d: 2 + +## Headline finding: the backlog is write-only + +149 issues have ever been filed; **zero have ever been closed**. feature-scout files +~3–4 enhancement issues per day and nothing consumes them — no merged PR references +any open issue, and feature-build (the consumer) is gated on human approval that has +never been granted. At the current rate the backlog doubles roughly every 6 weeks. + +Compounding this: **the approval gate has no vocabulary.** The label taxonomy has no +`approved` / `approved-feature` label, so there is no mechanism to mark an issue as +picked for feature-build. The pipeline is structurally stuck. + +## Duplicates (action: close one of each pair) + +1. **#363 duplicates #291** — both propose `TagRegistry.toStructs()` (serialize the + whole catalog, inverse of `loadFromStructs`). #363 (2026-07-06) is the newer, + better-evidenced write-up (cites the hand-rolled inline version in + `tests/test_compositetag.m:408`). Recommend: close **#291** as duplicate, keep #363. +2. **#344 duplicates #221** — both propose exporting *all pages* of a multi-page + dashboard as images (#221 `exportImagePages`, 2026-06-24; #344 `exportReport(dir)`, + 2026-07-03). Same feature, different name. Recommend: close **#221** as duplicate, + keep #344 (stronger milestone-tied motivation). + +feature-scout's dedup missed both pairs — likely because the titles share few words. + +## Stale / administrative closures + +- **#71** "smoke test: claude-agent workflow" (2026-04-24, 75 days) — a test issue for + the `claude-agent.yml` workflow. Served its purpose. Close as completed. +- **#151** "Discussion: v3.1 Live Tag Pipeline architectural exploration" (2026-05-19, + 50 days) — `LiveTagPipeline.m` has since shipped in `libs/SensorThreshold/` (49 KB). + Close as completed, or convert to a GitHub Discussion if the remaining ideas matter. + +## Already-implemented check + +Extracted the proposed API name from all 120 titles that contain one and cross-checked +against every function defined in `libs/**/*.m` (1,463 definitions). 14 raw hits, all +false positives (same method name on a *different* class — e.g. widgets have +`setTimeRange` but #224 asks for it on `DashboardEngine`). **No open issue is already +implemented.** Issue quality from feature-scout is genuinely good: bodies consistently +carry problem/motivation with file:line evidence, proposed API with examples, scope, +and alternatives. + +## Template compliance + +Not applicable — the repo has **no `.github/ISSUE_TEMPLATE/`, no PR templates, and no +CONTRIBUTING.md**. If the issue-first/approval-gated flow is intended to be real, +these are the missing enforcement artifacts. + +## Label taxonomy gaps + +Current labels: bug, documentation, duplicate, enhancement, good first issue, +help wanted, invalid, question, wontfix, auto-generated, docs. + +Missing for the intended pipeline: +- `approved` (or `approved-feature`) — the gate feature-build needs to pick work +- `needs-triage` — for unclassified human-filed issues +- Optional: per-library labels (lib:Dashboard, lib:FastSense, lib:SensorThreshold, + lib:EventDetection) — with 149 issues, component filtering is the only way to + navigate; the current spread is Tag 18, DashboardEngine 13, widgets ~40, etc. + +## Component distribution (top) + +Tag 18 · DashboardEngine 13 · FastSense 7 · ScatterWidget 7 · EventDetection 7 · +PlantLogStore 5 · EventStore 5 · EventViewer 5 · FastSenseWidget 5 · MonitorTag 4 + +## Recommended actions (in order) + +1. Close the 2 duplicates (#291, #221) with a duplicate label + pointer comment. +2. Close #71 (done) and #151 (shipped as LiveTagPipeline). +3. Create the `approved` label and approve a first small batch — the two + `good first issue` items (#321 EventViewer.exportImage, #327 Tag.cumulativeIntegral) + are ideal starters — so feature-build has something to consume. +4. Consider throttling feature-scout (e.g. weekly instead of ~daily) until the + consume rate is nonzero; otherwise the backlog is pure noise accumulation. +5. Add per-library labels if the backlog will stay >100 items. + +Re-run with `/gsd-inbox --issues --label` to auto-apply recommended labels, or +`--close-incomplete` (not needed — nothing scores below threshold). diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ceecc809..aeb3487b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -428,6 +428,34 @@ Plans: Plans: - [ ] TBD (promote with /gsd:review-backlog when ready) +### Phase 999.2: Tag Analysis Toolkit (BACKLOG) + +**Goal:** Add a toolbox-free, kind-aware analysis API to the `Tag` family (`libs/SensorThreshold/Tag.m` + subclasses) so engineers can compute statistics and derived series directly from any tag without pulling in the Signal Processing / Statistics toolboxes. All additive, backward-compatible, Octave-safe. Build keystone-first — `getStats()` is the shared primitive the rest lean on. + +**Source:** GitHub issue triage 2026-07-08 (see `.planning/INBOX-TRIAGE.md`). All 7 issues carry the `approved` label on HanSur94/FastSense; full specs (problem/motivation, proposed API + examples, scope, alternatives) live in each issue body. Dependency graph: `getStats()` (#223) is cited by ~40 downstream open issues — the single highest-leverage item in the backlog. +**Requirements:** TBD (each GitHub issue body is the working spec) +**Plans:** 0 plans + +Plans (build order — keystone first): +- [ ] #223 `Tag.getStats()` — public stats primitive (N/Min/Max/Mean/Rms/Std). KEYSTONE, unblocks ~40 issues +- [ ] #308 `Tag.resampleUniform(dt)` — resample onto a uniform time grid (kind-aware interpolation) +- [ ] #326 `Tag.derivative()` — kind-aware rate-of-change series (gradient over getXY) +- [ ] #312 `Tag.movingStat(window, type)` — rolling moving-average / std / max-min series +- [ ] #316 `Tag.exceedance(level)` — time-above/below-threshold analysis +- [ ] #328 `Tag.crossings(level)` — level-crossing times + direction (cycle/period analysis) +- [ ] #327 `Tag.cumulativeIntegral()` — trapezoidal totalizer series (running integral) + +### Phase 999.3: EventViewer image export parity (BACKLOG) + +**Goal:** Give `EventViewer` (`libs/EventDetection/EventViewer.m`) an `exportImage(path)` method that saves the timeline figure as PNG/JPEG, matching the existing `DashboardEngine`/`DetachedMirror` export capability. Additive, backward-compatible; copy the existing single-figure capture backend. + +**Source:** GitHub issue triage 2026-07-08 (see `.planning/INBOX-TRIAGE.md`). Issue #321 carries `approved` + `good first issue` on HanSur94/FastSense. Low-risk parity win — good starter / parallel-track task. +**Requirements:** TBD (GitHub issue #321 body is the working spec) +**Plans:** 0 plans + +Plans: +- [ ] #321 `EventViewer.exportImage(path)` — save timeline figure as PNG/JPEG (parity with DashboardEngine) + ### Phase 1040: Companion Notification Center **Goal:** Add an acknowledgeable in-app notification inbox to `FastSenseCompanion` — a collapsible right-hand `NotificationCenterPane` (toggled by a toolbar bell + unacked-count badge) that live-lists unacknowledged threshold-violation events from the shared `EventStore` and lets operators acknowledge them (dismiss = `EventStore.acknowledgeEvent`, shared + audited). Predominantly a new UI surface over existing event + acknowledge infrastructure. diff --git a/.planning/phases/999.2-tag-analysis-toolkit/.gitkeep b/.planning/phases/999.2-tag-analysis-toolkit/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.planning/phases/999.3-eventviewer-image-export-parity/.gitkeep b/.planning/phases/999.3-eventviewer-image-export-parity/.gitkeep new file mode 100644 index 00000000..e69de29b From 26f2994d593f2b06d47bfe98c689280ba28b15c0 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 22:24:45 +0200 Subject: [PATCH 2/9] docs(999.2): add CONTEXT + approved issue specs for Tag Analysis Toolkit Co-Authored-By: Claude Fable 5 --- .../999.2-CONTEXT.md | 125 ++++++++++++++++++ .../999.2-tag-analysis-toolkit/issues/223.md | 69 ++++++++++ .../999.2-tag-analysis-toolkit/issues/308.md | 53 ++++++++ .../999.2-tag-analysis-toolkit/issues/312.md | 44 ++++++ .../999.2-tag-analysis-toolkit/issues/316.md | 45 +++++++ .../999.2-tag-analysis-toolkit/issues/326.md | 50 +++++++ .../999.2-tag-analysis-toolkit/issues/327.md | 61 +++++++++ .../999.2-tag-analysis-toolkit/issues/328.md | 55 ++++++++ 8 files changed, 502 insertions(+) create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/223.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/308.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/312.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/316.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/326.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/327.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/issues/328.md diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md new file mode 100644 index 00000000..81e88a1d --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md @@ -0,0 +1,125 @@ +# Phase 999.2: Tag Analysis Toolkit - Context + +**Gathered:** 2026-07-08 +**Status:** Ready for planning +**Source:** GitHub issue triage 2026-07-08 (INBOX-TRIAGE.md). Each feature's authoritative spec is the approved GitHub issue body, mirrored into `issues/.md` in this phase directory. + + +## Phase Boundary + +Add a toolbox-free, kind-aware **analysis API to the `Tag` family** so engineers can compute +statistics and derived series directly from any tag without the Signal Processing or Statistics +toolboxes. Seven additive methods, all built on the base-class data-access API +(`Tag.getXY()` / `Tag.getXYRange(tStart, tEnd)` — `libs/SensorThreshold/Tag.m:123,128`), so they +inherit to `SensorTag`, `StateTag`, `DerivedTag`, `CompositeTag`, `MonitorTag` with zero subclass +overrides. + +**In scope:** the 7 methods below, each with a headless test. +**Out of scope:** wiring these into widgets (e.g. having NumberWidget/Sparkline reduce via +`getStats`) — those are separate follow-up issues, not this phase. + + + +## Implementation Decisions (locked — from approved issue specs) + +Build order is **keystone-first**: `getStats()` (#223) is the shared primitive and must land first. +The remaining six are independent of each other but several are natural consumers of the same +data-access slice. + +### #223 `Tag.getStats(tStart, tEnd)` — KEYSTONE +- Single public method on the **`Tag` base class** (`libs/SensorThreshold/Tag.m`), public `methods` block. +- Returns a struct: `N` (non-NaN count), `Min`, `Max`, `Mean` (omitnan), `Rms` (`sqrt(mean(y.^2,'omitnan'))`), + `Std` (omitnan), `First`, `Last`, `TimeStart` (X(1) of window), `TimeEnd` (X(end) of window). +- `nargin < 3` → full series (`tStart=[]; tEnd=[]`), else windowed via `getXYRange`. +- NaN/empty-series guards: empty series returns `N=0` and `NaN` reductions. +- Non-numeric `StateTag.Y` (cell-of-char): return `N` + time bounds, `NaN` for numeric reductions (document as numeric-only). + +### #308 `Tag.resampleUniform(dt)` +- Resample onto a uniform time grid; kind-aware interpolation (linear for continuous sensor/derived, + zero-order-hold / previous for state/monitor discrete series). See `issues/308.md`. + +### #326 `Tag.derivative()` +- Kind-aware rate-of-change series (gradient over `getXY`). See `issues/326.md`. + +### #312 `Tag.movingStat(window, type)` +- Rolling moving-average / std / max / min series over a window. See `issues/312.md`. + +### #316 `Tag.exceedance(level)` +- Time above/below a threshold level (continuous analog of stateDurations). See `issues/316.md`. + +### #328 `Tag.crossings(level)` +- Level-crossing times + direction (rising/falling) for cycle/period analysis. See `issues/328.md`. + +### #327 `Tag.cumulativeIntegral()` +- Trapezoidal totalizer series (running integral over `getXY`). See `issues/327.md`. + +### Claude's Discretion +- Exact struct/field naming beyond what issues pin, arg-parsing style (match existing Tag methods), + test file layout, and helper factoring. Follow existing conventions in `Tag.m` and neighbouring tests. + + + +## Hard Constraints (from CLAUDE.md + issue "Constraints check") + +- **Toolbox-free**: only core MATLAB/Octave (`min/max/mean/std` with `'omitnan'`, `sqrt`, `interp1`, + `gradient`, `cumtrapz` — verify each is core, not toolbox; hand-roll if a function is toolbox-only). +- **Octave-safe**: must run on GNU Octave 7+ as well as MATLAB R2020b+. Watch for `'omitnan'` support + and `interp1`/`cumtrapz` availability — provide a scalar fallback if needed. +- **Backward-compatible**: new methods only. No new properties, no serialization/`toStruct` change, + no `Tag`/`DashboardWidget` interface change. Existing scripts and serialized tags unaffected. +- **Pure MATLAB**: no MEX, no new dependency. Build on existing `getXY`/`getXYRange`. +- Each method: one function on `Tag.m` (base) + NaN/empty guard + a headless test asserting fields + and values on a known series (and, where relevant, that a time range narrows the window). + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Target class +- `libs/SensorThreshold/Tag.m` — abstract base; add all 7 methods here. Data-access API at + `getXY()` (:123) and `getXYRange(tStart, tEnd)` (:128, default binary-search slice). + +### Authoritative specs (one per feature, full issue body) +- `.planning/phases/999.2-tag-analysis-toolkit/issues/223.md` — getStats (KEYSTONE) +- `.planning/phases/999.2-tag-analysis-toolkit/issues/308.md` — resampleUniform +- `.planning/phases/999.2-tag-analysis-toolkit/issues/326.md` — derivative +- `.planning/phases/999.2-tag-analysis-toolkit/issues/312.md` — movingStat +- `.planning/phases/999.2-tag-analysis-toolkit/issues/316.md` — exceedance +- `.planning/phases/999.2-tag-analysis-toolkit/issues/328.md` — crossings +- `.planning/phases/999.2-tag-analysis-toolkit/issues/327.md` — cumulativeIntegral + +### Prior art to reuse / stay consistent with +- `libs/EventDetection/Event.m:24-30` — existing Min/Max/Mean/Rms/Std family (reference for reduction maths). +- Existing tests: `tests/test_derivedtag.m`, `tests/suite/TestDerivedTag.m`, `tests/MockTag.m`, + `tests/MockPlottableTag.m` — patterns for headless Tag tests (both Octave function-style and class suites). + +### Testing +- Run via matlab MCP (`mcp__matlab__run_matlab_test_file` for suites, `evaluate_matlab_code` for flat + `test_*.m`). See memory `running-matlab-tests-via-mcp`. `install()` first if `which Tag` is empty. + + + +## Specific Ideas + +- Prefer a small shared private helper for windowed numeric extraction if several methods need the + same NaN-guarded `[x,y]` slice — but keep each public method self-contained enough to test in isolation. +- Add tests in the repo's existing style: an Octave-safe flat `test_tag_analysis.m` (or per-method + files) plus/or a `tests/suite/TestTagAnalysis.m` class suite. Follow whichever pattern the planner + finds dominant for Tag tests. + + + +## Deferred Ideas + +- Widget delegation (NumberWidget / Sparkline / Status reducing via `getStats`) — separate open issues, + not this phase. +- Frequency-domain `spectrum()` (#338), `percentile()` (#339), `correlate()` (#340) and other Tag-analysis + issues remain in the backlog; not part of this phase's approved set. + + +--- + +*Phase: 999.2-tag-analysis-toolkit* +*Context gathered: 2026-07-08 from approved GitHub issue specs* diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/223.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/223.md new file mode 100644 index 00000000..6326e316 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/223.md @@ -0,0 +1,69 @@ +# #223: Tag: add a public getStats() statistics primitive (N/Min/Max/Mean/Rms/Std over a series or time range) + +## Problem / motivation + +The domain demonstrably cares about per-series statistics, but the **Tag base class exposes no way to get them**. Today every consumer re-derives the same maths by hand: + +- `EventDetection/Event.m:24-30` already carries `PeakValue / MinValue / MaxValue / MeanValue / RmsValue / StdValue` — event detection computes the full Min/Max/Mean/Rms/Std family over a window. +- #222 just added that *same* reduction family inline to `NumberWidget` (a widget-display feature). +- Several `examples/` (`example_widget_scatter.m`, `example_sensor_detail.m`, …) hand-roll `mean()/max()/min()/rms` over `tag.Y`. + +Yet `grep -rniE "function .*(getStats|statistics|summary|describe)" libs/SensorThreshold/*.m` returns **nothing** — there is no public statistics method on `Tag` or any subclass. So an engineer who wants "peak / mean / rms of this tag over this window" either drops to a standalone plot or writes `sqrt(mean(y.^2))` by hand, and every widget that needs a reduction re-implements it. + +## Proposed feature + +A single public method on the **`Tag` base class**: + +```matlab +s = tag.getStats(); % whole resolved series +s = tag.getStats(tStart, tEnd); % windowed +``` + +returning a struct, e.g.: + +```matlab +s.N % sample count (non-NaN) +s.Min % min +s.Max % max +s.Mean % mean (omitnan) +s.Rms % sqrt(mean(y.^2)) (omitnan) +s.Std % std (omitnan) +s.First % first sample +s.Last % last sample +s.TimeStart % X(1) of the window +s.TimeEnd % X(end) of the window +``` + +This is the reusable domain primitive that #222's inline reduction — and any future Sparkline/Status reduce — should *delegate to*, instead of each widget re-implementing the maths. It also makes per-widget reductions one-line follow-ups rather than new feature slices. + +## Rough sketch + +- **Where:** `libs/SensorThreshold/Tag.m` — a single `getStats(obj, tStart, tEnd)` method in the public `methods` block. Implemented **once on the base** over the universal data-access API the base already exposes: `getXY()` and `getXYRange(tStart, tEnd)` (`Tag.m:120,125`, the latter with a default binary-search slice). +- **Zero subclass overrides:** because it builds only on `getXYRange`, it works for `SensorTag`, `StateTag`, `DerivedTag`, `CompositeTag`, `MonitorTag` automatically. (For non-numeric `StateTag.Y` cell-of-char series, return `N`/time bounds and `NaN` for the numeric reductions, or document it as numeric-only — small decision for the implementer.) +- **Public-API shape:** new method only; no new properties, no serialization field. + +```matlab +function s = getStats(obj, tStart, tEnd) + if nargin < 3, tStart = []; tEnd = []; end + [x, y] = obj.getXYRange(tStart, tEnd); + % NaN/empty guards, then min/max/mean/std('omitnan') + rms = sqrt(mean(y.^2,'omitnan')) +end +``` + +## Value + +High and broad. A one-call statistics summary is bread-and-butter for a MATLAB sensor-analysis engineer: console triage of a recording, a per-tag report row, the EventViewer, and as the canonical primitive behind widget reductions. It removes hand-rolled `sqrt(mean(y.^2))` from examples and user scripts, and it's the enabling primitive that keeps future per-widget reductions from being repeated slices. + +## Constraints check + +- **Toolbox-free:** ✅ `min`/`max`/`mean`/`std` (with `'omitnan'`) and `sqrt` are core MATLAB/Octave — no toolbox. +- **Backward-compatible:** ✅ new method only; no property, serialization, or contract change; existing scripts and serialized dashboards/tags unaffected. +- **Pure MATLAB/Octave:** ✅ builds on the existing `getXYRange` slice; no MEX, no new dependency. +- **Works through existing contracts:** ✅ added at the `Tag` base; every subclass inherits it via `getXY`/`getXYRange`. No `DashboardWidget`/`Tag` interface change. + +## Effort estimate + +**S** — one method on `Tag.m` + NaN/empty-series guard + a headless test (assert fields and values on a known series; assert a time range narrows the window). Optionally, a later follow-up has #222 / Sparkline / Status delegate to it. + +--- +*AI-proposed via `/feature-scout` — needs a human product decision before implementation.* \ No newline at end of file diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/308.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/308.md new file mode 100644 index 00000000..8d01a464 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/308.md @@ -0,0 +1,53 @@ +# #308: Tag: add resampleUniform(dt) to resample onto a uniform time grid (kind-aware interpolation) + +## Problem / motivation + +Sensor data in FastSense lives on the tag's **native, often irregular** timestamps. Many standard analyses require a **uniform time grid** first: + +- FFT / spectral analysis (needs evenly-spaced samples), +- cross-correlation / lag estimation between two sensors, +- comparing or differencing two sensors recorded at **different sample rates**, +- exporting several sensors as one aligned table. + +There is no resampling / regridding primitive anywhere in the Tag family — engineers must pull `[X,Y] = tag.getXY()` and hand-roll `interp1` every time, and get the interpolation method wrong at their peril (see below). + +## Proposed feature + +Add a `resampleUniform` method to the `Tag` base class that returns the tag's series on a uniform grid: + +```matlab +[Xu, Yu] = tag.resampleUniform(dt); % uniform spacing dt over the tag's range +[Xu, Yu] = tag.resampleUniform(dt, 'Range', [t0 t1]); +[Xu, Yu] = tag.resampleUniform(dt, 'Method', 'linear'); +``` + +**Kind-aware default interpolation (important — respects an existing invariant):** `MonitorTag` already documents *"No interp1 linear ever — ZOH is the only legal alignment"* for binary/state data. So the default `Method` is chosen by tag kind: + +- continuous numeric (`SensorTag`, numeric `DerivedTag`/`CompositeTag`) -> `'linear'` +- discrete (`StateTag`, `MonitorTag` binary) -> `'previous'` (zero-order hold) + +An explicit `'Method'` overrides, but the safe default prevents fabricating illegal interpolated values for state/monitor channels. + +## Rough sketch + +- **File:** `libs/SensorThreshold/Tag.m` (base method using `getXY` + `getKind`), so every subclass inherits it. +- **Logic:** `[X,Y] = obj.getXY()` (or `getXYRange(t0,t1)` when `'Range'` given); build `Xu = X(1):dt:X(end)`; `Yu = interp1(X, Y, Xu, method)`. Pick `method` by `obj.getKind()` unless overridden. +- **Gap-aware option (recommended):** a `'MaxGap'` option (default = off, or auto via the proposed `findGaps`, #306) that sets `Yu` to `NaN` inside gaps larger than the threshold so resampling does not invent data across sensor dropouts. +- Returns vectors (compose with `addLine`, `fft`, `xcorr`, CSV export). A `'AsTag'` variant returning a new `SensorTag` could be a follow-up. + +## Value + +Unlocks the standard analysis pipeline — FFT, cross-correlation, multi-rate comparison, aligned export — that today every user must re-implement, and bakes in the correct (kind-aware) interpolation so state/monitor channels aren't linearly interpolated by mistake. + +## Constraints check + +- **Toolbox-free:** ✅ `interp1` with `'linear'`/`'previous'` is base MATLAB/Octave; no Signal Processing Toolbox needed (no `resample()`). +- **Backward-compatible:** ✅ purely additive base-class method; no change to existing APIs or serialization. +- **Pure MATLAB / Octave:** ✅ no MEX, no new deps; built on the existing `Tag.getXY()`/`getKind()` contract; honors the documented ZOH-only invariant for state/monitor data. + +## Effort estimate + +**M** — base-class method (grid build + kind-aware `interp1` dispatch + optional gap-aware NaN-fill), plus tests for: continuous-linear, state-ZOH, explicit method override, range subset, and gap fill. Pairs naturally with `findGaps` (#306). + +--- +*AI-proposed via /feature-scout — needs a human product decision before implementation.* \ No newline at end of file diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/312.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/312.md new file mode 100644 index 00000000..c4ce3f2d --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/312.md @@ -0,0 +1,44 @@ +# #312: Tag: add movingStat(window, type) for rolling moving-average / std / max-min series + +## Problem / motivation + +Rolling-window statistics — moving average (denoise), rolling standard deviation (variability / envelope), rolling max/min (peak-hold) — are among the most common operations in sensor analysis. FastSense has **no** built-in support: a search of the Tag family and `FastSense` finds no `movmean`/rolling/smoothing primitive. Engineers must pull `[X,Y] = tag.getXY()` and call `movmean`/`movstd` by hand every time. + +This is distinct from the single-aggregate stats requests (`getStats`, #223; SensorDetailPlot `getWindowStats`, #311), which return one number per metric over a window. A rolling stat returns a **new series** (one value per sample), used for smoothing/trend/envelope. + +## Proposed feature + +Add a `movingStat` method to the `Tag` base class returning a smoothed/rolling series: + +```matlab +[X, Ys] = tag.movingStat(window); % moving average (default), window in samples +[X, Ys] = tag.movingStat(window, 'std'); % rolling std (e.g. for an envelope band) +[X, Ys] = tag.movingStat(window, 'max'); % peak-hold +``` + +Supported `type`: `'mean'` (default), `'std'`, `'max'`, `'min'`, `'rms'`, `'median'`. + +## Rough sketch + +- **File:** `libs/SensorThreshold/Tag.m` (base method over `getXY` / `getKind`), inherited by all subclasses. +- **Logic:** `[X,Y] = obj.getXY();` dispatch `type` to the base movers — `movmean` / `movstd` / `movmax` / `movmin` / `movmedian`; `'rms'` = `sqrt(movmean(Y.^2, window))`. Return `[X, Ys]` (same length, centered window). +- **Kind-awareness:** continuous numeric tags only. For `StateTag` / `MonitorTag` (discrete/binary) a rolling mean is meaningless — error with a clear id (e.g. `Tag:movingStatNotContinuous`) or require explicit opt-in, consistent with the existing ZOH-only invariant for state/monitor data. +- **Window units:** integer **samples** for the core (simplest, matches the base movers). An X-unit window (seconds) is a reasonable follow-up but needs uniform spacing — pairs with `resampleUniform` (#308); note it, don't scope it here. +- Returns vectors so the result composes with `addLine` (overlay a smoothed trace) or `addShaded` (mean ± rolling-std envelope) or CSV export. + +## Value + +Covers the everyday smoothing/trend/envelope/peak-hold needs with one toolbox-free call, instead of every user re-deriving `movmean`/`movstd`, and plugs directly into the existing plotting/annotation API (e.g. a mean line plus a ±1σ shaded envelope). + +## Constraints check + +- **Toolbox-free:** ✅ `movmean`/`movstd`/`movmax`/`movmin`/`movmedian` are base MATLAB (R2016a+) and available in Octave; no Signal Processing / Statistics toolbox. +- **Backward-compatible:** ✅ purely additive base-class method; no change to existing APIs or serialization. +- **Pure MATLAB / Octave:** ✅ no MEX, no new deps; built on the existing `Tag.getXY()`/`getKind()` contract. + +## Effort estimate + +**S–M** — one base-class method (mover dispatch + RMS case + kind guard), plus tests for each `type`, the discrete-kind guard, and even/odd window sizes. Distinct from #223 / #311 (single-window aggregates); pairs with #308 for an X-unit window follow-up. + +--- +*AI-proposed via /feature-scout — needs a human product decision before implementation.* \ No newline at end of file diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/316.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/316.md new file mode 100644 index 00000000..b936a3d6 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/316.md @@ -0,0 +1,45 @@ +# #316: Tag: add exceedance(level) for time-above/below-threshold analysis (continuous analog of stateDurations) + +## Problem / motivation + +A recurring sensor-analysis question — *"how long, and how often, did this signal exceed a limit?"* — has no built-in answer in FastSense. There is no time-above-threshold / exceedance / duty-cycle primitive for continuous tags. + +The two things that look adjacent don't cover it: +- `MonitorTag` + events detect violations, but require **setting up** a MonitorTag with a condition and an EventStore/pipeline — overkill for ad-hoc "what fraction of last week was this pump above 80 bar?" analysis on a plain `SensorTag`. +- `StateTag.stateDurations` (#258) gives dwell time **per categorical state** — this is the missing **continuous** numeric analog. +- `getStats` (#223) gives N/min/max/mean/… — a distribution summary, not threshold timing. + +## Proposed feature + +Add an `exceedance` method to the `Tag` base class: + +```matlab +s = tag.exceedance(80); % time/excursions where Y > 80 +s = tag.exceedance(20, 'Direction', 'below'); % below a floor +s = tag.exceedance(80, 'Range', [t0 t1]); % within a time window +% s -> struct: totalTime, fraction (of window), count (excursions), longest, peak +``` + +## Rough sketch + +- **File:** `libs/SensorThreshold/Tag.m` (base method over `getXY`/`getXYRange`/`getKind`), inherited by all subclasses. +- **Logic:** take `[X,Y]`; form the boolean `Y > level` (or `<` for 'below'); find runs of true; sum their durations for `totalTime`; `fraction = totalTime / (X(end)-X(1))`; `count` = number of runs; `longest` = max run duration; `peak` = extreme value reached. +- **Kind-aware crossing accuracy:** for continuous tags, linearly interpolate the exact crossing time at each run boundary so durations are accurate; for discrete (`StateTag`/`MonitorTag`) use zero-order-hold (sum `dt` where the condition holds) — consistent with the kind-aware interpolation decisions in #308 (`resampleUniform`) and #312 (`movingStat`). +- Return a struct (extensible). Units: time in the tag's X units (datenum days, or numeric). + +## Value + +One-shot limit / compliance / alarm analysis on any tag without standing up a monitoring pipeline — "time above limit", excursion count, longest excursion, duty cycle — the continuous complement to `StateTag.stateDurations` (#258). + +## Constraints check + +- **Toolbox-free:** ✅ array ops + simple time integration; no toolbox. +- **Backward-compatible:** ✅ purely additive base-class method; no change to existing APIs or serialization. +- **Pure MATLAB / Octave:** ✅ no MEX, no new deps; built on the existing `Tag.getXY()` contract; Octave-safe. + +## Effort estimate + +**S–M** — one base-class method (run detection + duration sum + optional interpolated crossings + range/direction options), plus tests for: no exceedance, single/multiple excursions, below-direction, range subset, and continuous-vs-ZOH crossing accuracy. Complements #258 (categorical) and is distinct from #223 (`getStats`). + +--- +*AI-proposed via /feature-scout — needs a human product decision before implementation.* \ No newline at end of file diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/326.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/326.md new file mode 100644 index 00000000..c420ca5f --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/326.md @@ -0,0 +1,50 @@ +# #326: Tag: add derivative() — kind-aware rate-of-change series (gradient over getXY) + +## Problem / motivation + +A core sensor-analysis quantity has no built-in answer in FastSense: the **rate of change** of a signal. Engineers constantly need *"how fast is this climbing?"* — thermal ramp rate (°C/min), pressure-rise / leak rate, di/dt, ramp-rate alarms. Today the only option is to drop a tag out to raw MATLAB (`gradient(Y)./gradient(X)`) and hand-handle NaNs and non-uniform sampling. + +The Tag analysis-primitive family has grown steadily — `getStats` (#223), `movingStat` (#312), `exceedance` (#316), `findGaps` (#306), `resampleUniform` (#308), `stateDurations` (#258), `exportCsv` (#288) — but has **no differentiation primitive**. The things that look adjacent don't cover it: + +- `movingStat` (#312) is rolling mean/std/max-min — *smoothing*, not differentiation. +- `getStats` (#223) is a scalar N/min/max/mean/rms/std summary. +- `exceedance` (#316) is time-above-limit / duty-cycle. +- **`DerivedTag` structurally cannot express it.** A `DerivedTag` computes pointwise `f(parents(t))`; a derivative needs *neighbouring* samples, so rate-of-change must be a real primitive, not an expression. This is the cleanest "needs a primitive" gap on the tracker. + +## Proposed feature + +Add a `derivative` method to the `Tag` base class, inherited by every subclass: + +```matlab +[t, dydt] = tag.derivative(); % central differences (non-uniform aware) +[t, dydt] = tag.derivative('Method','forward'); % 'central' (default) | 'forward' | 'backward' +[t, dydt] = tag.derivative('Range',[t0 t1]); % within a time window +``` + +Returns the time vector and the per-X-unit rate of change. Pairs naturally with `movingStat` (#312) for a smooth-then-differentiate workflow. + +## Rough sketch + +- **File:** `libs/SensorThreshold/Tag.m` — a new public method after the accessor block. +- **Shape:** `function [t, dydt] = derivative(obj, varargin)`; pull `[X,Y]` from `getXYRange`/`getXY` (`Tag.m:120`–`:131`), parse `'Method'`/`'Range'` with the existing options pattern, compute `gradient(Y)./gradient(X)` (central differences — correct on non-uniform spacing), return. +- **NaN-safe:** rate is `NaN` where either neighbour is `NaN`. +- **Kind-aware:** intended for continuous numeric tags (`SensorTag`/`DerivedTag`/`CompositeTag`/`MonitorTag`). On a discrete `StateTag` it emits a `Tag:derivativeOnDiscrete` warning (a step channel's derivative is ill-defined) — consistent with the kind-aware framing of `resampleUniform` (#308) and `movingStat` (#312). +- It is a **query**, not stored state: no `toStruct`/`fromStruct` change, no `DashboardWidget`/`EventStore`/serialization contract touched. + +## Value + +High. Ramp-rate / leak-rate / di-dt is a constant monitoring need with no current answer; it's the rate-alarm building block and the smoothing companion to `movingStat`. It removes a recurring drop-to-raw-MATLAB step for any tag. + +## Constraints check + +- **Toolbox-free:** `gradient` is in base MATLAB and Octave — no toolbox. +- **Backward-compatible:** purely additive base method; default args = the obvious central-difference behavior; existing scripts and serialized dashboards untouched (no contract / serialization change). +- **Pure MATLAB/Octave:** yes; works through the existing `Tag` `getXY`/`getXYRange` contract, so every Tag subclass inherits it. + +## Effort estimate + +**S–M** — one base method + the discrete-kind warning + a focused test (`tests/suite/TestTag*.m`). Single file. + +--- + +*AI-proposed via /feature-scout — needs a human product decision before implementation.* diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/327.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/327.md new file mode 100644 index 00000000..8ad94c2f --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/327.md @@ -0,0 +1,61 @@ +# #327: Tag: add cumulativeIntegral() — trapezoidal totalizer series (running ∫ over getXY) + +## Problem / motivation + +The Tag analysis-primitive family now has a **differentiation** primitive — `derivative()` (#326) — but **no integration** one. The running integral is a first-class sensor-monitoring quantity: + +- flow rate → **total volume** +- power → **energy (kWh)** +- current → **charge (Ah)** +- leak rate → **cumulative loss** +- dose rate → **accumulated dose** + +Today an engineer who wants to "totalize this channel" must drop out to raw MATLAB (`cumtrapz(X, Y)`), re-fetch the series themselves, and hand-handle NaNs and non-uniform sampling. + +Crucially, **this cannot be expressed as a `DerivedTag`**: its `ComputeFn` is pointwise `@(parents(t)) -> [X,Y]` (`DerivedTag.m:30-32`), but a cumulative integral at time *t* depends on **every prior sample**. Like `derivative()`, it structurally *must* be a primitive, not an expression. That is the strongest fit argument on the tracker — a real gap, not a convenience wrapper. + +Verified absent: `grep -nE "cumtrapz |trapz|integral|totaliz|integrate"` over `libs/SensorThreshold/` returns nothing; `gh issue list --state all` for "integral / cumulative / totalizer / accumulate / energy / volume" is empty. + +## Proposed feature + +A base `Tag` method returning the running trapezoidal integral series (and, in 1-out form, the grand total): + +```matlab +[t, cum] = tag.cumulativeIntegral(); % trapezoidal; cum(end) = total +[t, cum] = tag.cumulativeIntegral('Range',[t0 t1]); % within a time window +total = tag.cumulativeIntegral('Range',[t0 t1]); % scalar grand total (1-out form) +``` + +Returns the time vector and the cumulative trapezoidal integral of Y w.r.t. X (units: Y·X, e.g. (L/s)·s = L). Inherited by every Tag subclass via the base `getXY` seam — exactly the seam `derivative()` (#326) uses. + +- **NaN policy:** mirror #326 — segments bounded by a NaN contribute no area (treated as a gap) rather than poisoning the entire running total; document the choice. +- **Kind-aware:** intended for continuous numeric tags (`SensorTag`/`DerivedTag`/`CompositeTag`/`MonitorTag`). On a discrete `StateTag` it warns (`Tag:integralOnDiscrete`), since the area under a step channel is rarely the intended quantity — consistent with the kind-aware warnings in `resampleUniform` (#308), `movingStat` (#312), and `derivative` (#326). + +## Rough sketch + +Single file: `libs/SensorThreshold/Tag.m`. + +- New public method `function varargout = cumulativeIntegral(obj, varargin)`, placed next to where `derivative()` (#326) lands. +- Pull `[X,Y]` from `getXYRange`/`getXY` (`Tag.m:120`/`:125`), parse `'Range'` via the existing `parseOpts`-style pattern, compute via `cumtrapz(X, Y)` (correct on non-uniform spacing). +- Return `[t, cum]` (2-out) or the scalar `cum(end)` (1-out). +- Emit `Tag:integralOnDiscrete` when `getKind` is the discrete/state kind. +- No `toStruct`/`fromStruct` change (a query, not stored state); no `DashboardWidget`/`EventStore`/serialization contract touched. + +## Value + +High. Totalized volume/energy/charge/dose is a constant monitoring need with no current answer. Completes the calculus pair with `derivative()` (#326), and pairs with `movingStat()` (#312) for smooth-then-integrate. + +## Constraints check + +- **Toolbox-free:** ✅ `cumtrapz` is in base MATLAB and Octave. +- **Backward-compatible:** ✅ purely additive new method; default args = full-series trapezoidal; existing scripts and serialized dashboards untouched (no serialization change). +- **Pure MATLAB/Octave:** ✅ no MEX, no toolbox. +- **Contract:** ✅ works through the existing `Tag` base/`getXY` contract; no `DashboardWidget`/`Tag` interface change. + +## Effort estimate + +**S–M** — one base method + a kind warning + a focused test (`tests/suite/TestTag*.m`). The trapezoidal core is a one-liner. + +--- + +*AI-proposed via /feature-scout — needs a human product decision before implementation.* \ No newline at end of file diff --git a/.planning/phases/999.2-tag-analysis-toolkit/issues/328.md b/.planning/phases/999.2-tag-analysis-toolkit/issues/328.md new file mode 100644 index 00000000..f2abbaee --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/issues/328.md @@ -0,0 +1,55 @@ +# #328: Tag: add crossings(level) — level-crossing times + direction for cycle/period/oscillation analysis + +## Problem / motivation + +A recurring sensor-analysis question — *"when, and in which direction, did this signal cross a level?"* — has no built-in answer in FastSense. The Tag analysis family now has distribution stats (`getStats` #223), windowed stats (`movingStat` #312), duty-cycle timing (`exceedance` #316), gap detection (`findGaps` #306), grid resampling (`resampleUniform` #308), categorical dwell (`stateDurations` #258), and the calculus pair `derivative` (#326) + `cumulativeIntegral` (#327). The one timing primitive still missing is the **event timing of transitions** — the per-crossing **time + direction** vector. + +The closest filed item does **not** cover it: +- **`exceedance` (#316)** returns *aggregate* timing — `totalTime`, `fraction`, `count`, `longest`, `peak` (how long / how often above a limit). It deliberately does not return the **per-crossing time + direction**, which is the input to cycle-period, oscillation-frequency, and inter-crossing-interval analysis. The only overlap is the scalar excursion `count`; this feature is framed around the *time/direction vector*, not the count. +- **`derivative` (#326)** gives dY/dt — finding sign changes of the slope is *peak* detection, not *level* crossing. +- **MonitorTag + events** detect threshold violations but require standing up a MonitorTag + EventStore/pipeline — overkill for an ad-hoc "give me the up/down crossing times at 0.5" query on a plain `SensorTag`. + +Today engineers drop to raw MATLAB (`find(diff(sign(Y-level))~=0)`), hand-handle NaNs and exact interpolation, and re-fetch the series themselves. + +## Proposed feature + +Add a `crossings` method to the `Tag` base class, returning the interpolated crossing instants and their direction: + +```matlab +c = tag.crossings(0.5); % all crossings of level 0.5 +c = tag.crossings(0.5, 'Direction', 'rising'); % rising (up) crossings only +c = tag.crossings(0.5, 'Direction', 'falling'); % falling (down) crossings only +c = tag.crossings(0.5, 'Range', [t0 t1]); % within a time window +[t, dir] = tag.crossings(0.5); % 2-out: times + +1/-1 direction +``` + +`c` -> struct: `times` (interpolated crossing instants), `direction` (+1 rising / −1 falling, same length), `count`, and a convenience `periods` (diff of successive same-direction crossings) for immediate cycle / frequency analysis. `Direction` default `'both'`. + +This completes the **timing-analysis triad**: `exceedance` (how long), `crossings` (when / which way), `stateDurations` (categorical dwell). + +## Rough sketch + +- **File:** `libs/SensorThreshold/Tag.m` — a base method over `getXY` / `getXYRange` / `getKind` (inherited by every subclass; the exact seam #316 / #326 / #327 use). +- **Signature:** `function varargout = crossings(obj, level, varargin)`; pull `[X,Y]` from `getXYRange`/`getXY`, parse `'Direction'` / `'Range'` via the existing `parseOpts` pattern. +- **Core:** `s = sign(Y - level)`; crossing indices `k = find(s(1:end-1).*s(2:end) < 0)`; linearly **interpolate** the exact instant `t = X(k) + (level-Y(k))/(Y(k+1)-Y(k)) * (X(k+1)-X(k))` (matches #316's interpolated-crossing accuracy decision); `direction = sign(Y(k+1)-Y(k))`. Handle exact-equal samples and plateaus *at* the level (count a single crossing on entry/exit, not per flat sample). +- **NaN policy:** a segment with a NaN endpoint yields no crossing (treated as a gap), consistent with #326 / #327 — documented explicitly. +- **Kind-aware:** continuous numeric tags (`SensorTag` / `DerivedTag` / `CompositeTag` / `MonitorTag`) interpolate; on a discrete `StateTag` use zero-order-hold (the transition sample, no interpolation) and/or warn `Tag:crossingsOnDiscrete` — same kind-aware framing as `resampleUniform` (#308), `movingStat` (#312), `exceedance` (#316), `derivative` (#326). +- No `toStruct` / `fromStruct` change (it is a query, not stored state); no `DashboardWidget` / `EventStore` / serialization contract touched. + +## Value + +Unlocks cycle-period / frequency / chatter / inter-event-interval analysis with no current built-in answer — a constant need for vibration, control, and flow engineers. Pairs naturally with `derivative` (#326) for slope-at-crossing and `movingStat` (#312) for smooth-then-cross. + +## Constraints check + +- **Toolbox-free:** ✅ `sign` / `find` / `diff` + linear interpolation — all base MATLAB & Octave; no Signal Processing Toolbox. +- **Backward-compatible:** ✅ purely additive base-class method; no change to existing APIs or serialization; existing scripts and serialized dashboards untouched. +- **Pure MATLAB / Octave:** ✅ no MEX, no new deps; built on the existing `Tag.getXY()` contract; Octave-safe. + +## Effort estimate + +**S–M** — one base-class method + plateau/equal-sample edge handling + a kind warning, plus tests (`tests/suite/TestTag*.m`): rising-only, falling-only, both, exact-sample hit, plateau-at-level, range subset, NaN gap, ZOH on StateTag. Complements #316 (aggregate duration) and is distinct from #326 (slope) and #258 (categorical). + +--- + +*AI-proposed via /feature-scout — needs a human product decision before implementation.* From f8cf0a309ea7756e0eb5fb079cf3765427ecf4bd Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 22:40:03 +0200 Subject: [PATCH 3/9] docs(999.2): create Tag Analysis Toolkit phase plans (7 methods) Keystone-first: 999.2-01 getStats (Wave 1); the six analysis methods (resampleUniform, derivative, movingStat, exceedance, crossings, cumulativeIntegral) as Wave 2 depending on 01. All additive, toolbox-free, Octave-safe methods on libs/SensorThreshold/Tag.m + per-method headless tests. Co-Authored-By: Claude Fable 5 --- .planning/ROADMAP.md | 24 +-- .../999.2-01-PLAN.md | 168 +++++++++++++++++ .../999.2-02-PLAN.md | 170 +++++++++++++++++ .../999.2-03-PLAN.md | 163 +++++++++++++++++ .../999.2-04-PLAN.md | 169 +++++++++++++++++ .../999.2-05-PLAN.md | 170 +++++++++++++++++ .../999.2-06-PLAN.md | 173 ++++++++++++++++++ .../999.2-07-PLAN.md | 160 ++++++++++++++++ 8 files changed, 1186 insertions(+), 11 deletions(-) create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-01-PLAN.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-02-PLAN.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-03-PLAN.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-04-PLAN.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-05-PLAN.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-06-PLAN.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-07-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index aeb3487b..16578841 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -433,17 +433,19 @@ Plans: **Goal:** Add a toolbox-free, kind-aware analysis API to the `Tag` family (`libs/SensorThreshold/Tag.m` + subclasses) so engineers can compute statistics and derived series directly from any tag without pulling in the Signal Processing / Statistics toolboxes. All additive, backward-compatible, Octave-safe. Build keystone-first — `getStats()` is the shared primitive the rest lean on. **Source:** GitHub issue triage 2026-07-08 (see `.planning/INBOX-TRIAGE.md`). All 7 issues carry the `approved` label on HanSur94/FastSense; full specs (problem/motivation, proposed API + examples, scope, alternatives) live in each issue body. Dependency graph: `getStats()` (#223) is cited by ~40 downstream open issues — the single highest-leverage item in the backlog. -**Requirements:** TBD (each GitHub issue body is the working spec) -**Plans:** 0 plans - -Plans (build order — keystone first): -- [ ] #223 `Tag.getStats()` — public stats primitive (N/Min/Max/Mean/Rms/Std). KEYSTONE, unblocks ~40 issues -- [ ] #308 `Tag.resampleUniform(dt)` — resample onto a uniform time grid (kind-aware interpolation) -- [ ] #326 `Tag.derivative()` — kind-aware rate-of-change series (gradient over getXY) -- [ ] #312 `Tag.movingStat(window, type)` — rolling moving-average / std / max-min series -- [ ] #316 `Tag.exceedance(level)` — time-above/below-threshold analysis -- [ ] #328 `Tag.crossings(level)` — level-crossing times + direction (cycle/period analysis) -- [ ] #327 `Tag.cumulativeIntegral()` — trapezoidal totalizer series (running integral) +**Requirements:** TBD (each GitHub issue body is the working spec — #223/#308/#326/#312/#316/#328/#327) +**Plans:** 7 plans + +Plans (build order — keystone first; all methods land in `libs/SensorThreshold/Tag.m`): +- [ ] 999.2-01-PLAN.md (Wave 1) — #223 `Tag.getStats()` public stats primitive (N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd). KEYSTONE +- [ ] 999.2-02-PLAN.md (Wave 2, depends 01) — #308 `Tag.resampleUniform(dt)` uniform-grid resample (kind-aware interpolation) +- [ ] 999.2-03-PLAN.md (Wave 2, depends 01) — #326 `Tag.derivative()` kind-aware rate-of-change series (gradient over getXY) +- [ ] 999.2-04-PLAN.md (Wave 2, depends 01) — #312 `Tag.movingStat(window, type)` rolling mean/std/max/min/rms/median series +- [ ] 999.2-05-PLAN.md (Wave 2, depends 01) — #316 `Tag.exceedance(level)` time-above/below-threshold analysis +- [ ] 999.2-06-PLAN.md (Wave 2, depends 01) — #328 `Tag.crossings(level)` level-crossing times + direction (cycle/period) +- [ ] 999.2-07-PLAN.md (Wave 2, depends 01) — #327 `Tag.cumulativeIntegral()` trapezoidal totalizer series (running integral) + +> Note: all 7 plans append methods to the single shared file `libs/SensorThreshold/Tag.m`. The six Wave-2 plans are logically independent (star dependency on 01) but share this file — execute-phase must serialize edits to Tag.m across concurrently-running Wave-2 plans (each test file is distinct and never conflicts). ### Phase 999.3: EventViewer image export parity (BACKLOG) diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-01-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-01-PLAN.md new file mode 100644 index 00000000..d5221434 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-01-PLAN.md @@ -0,0 +1,168 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_getstats.m +autonomous: true +requirements: + - "#223" +must_haves: + truths: + - "Any Tag subclass (Sensor/State/Monitor/Composite/Derived) can call tag.getStats() and get a struct with N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd (#223)" + - "tag.getStats(tStart, tEnd) narrows the reduction to the windowed slice via getXYRange (#223)" + - "NaN samples are excluded from N and from the numeric reductions (omitnan semantics), and an empty window returns N=0 with NaN reductions (#223)" + - "A non-numeric (cellstr) StateTag series returns N + time bounds and NaN for the numeric reductions (documented numeric-only) (#223)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method getStats" + - "tests/test_tag_getstats.m headless test" + key_links: + - "getStats delegates to Tag.getXYRange (Tag.m:128) so all subclasses inherit it with zero overrides" +--- + + +Add the keystone statistics primitive `Tag.getStats(tStart, tEnd)` to the `Tag` base class (#223). This is the shared reduction primitive every other analysis method and future widget reduction leans on, so it lands first in its own wave. + +Purpose: Give a MATLAB sensor engineer a one-call N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd summary of any tag over the whole series or a time window, toolbox-free and Octave-safe, without hand-rolling `sqrt(mean(y.^2))` in every script or widget. +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_getstats.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/223.md + +# Target class + prior-art reduction family +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + + + + + + Task 1: Implement Tag.getStats in the public methods block + Tag.getStats returns the pinned struct (N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd); NaN/empty/non-numeric guards behave per #223; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getXYRange (:128, binary-search slice with one-point padding), the public `methods` block, and the Tag constructor's manual name-value parsing style (:90-107) to match conventions. + - .planning/phases/999.2-tag-analysis-toolkit/issues/223.md — authoritative API shape and field list. + - libs/EventDetection/Event.m:24-30 — the existing Min/Max/Mean/Rms/Std reduction family, to stay numerically consistent. + + libs/SensorThreshold/Tag.m + + Add a public method `function s = getStats(obj, tStart, tEnd)` to the public `methods` block of Tag.m (after the accessor block, before the Static block), implementing #223 exactly. + + Signature and windowing: when `nargin < 3`, set `tStart = []; tEnd = [];` (full series); then call `[x, y] = obj.getXYRange(tStart, tEnd)`. This inherits the base binary-search slice, so every subclass works with zero overrides. + + Build the return struct with these fields in this order: N, Min, Max, Mean, Rms, Std, First, Last, TimeStart, TimeEnd. + + Octave-safe omitnan: do NOT pass the `'omitnan'` flag (portability risk on Octave per CONTEXT constraints). Instead, when y is numeric, take `yv = y(~isnan(y))` once and compute Min/Max/Mean over yv; Rms = `sqrt(mean(yv.^2))`; Std = `std(yv)`. This reproduces omitnan semantics with core functions available on both MATLAB R2020b+ and Octave 7+. Operate on `y(:)` so row/column inputs behave identically. + + Field semantics: + - N = count of non-NaN numeric samples in the window (numel(yv)). + - Min/Max/Mean/Rms/Std = reductions over yv; when N == 0 set all five to NaN (guard std of empty, and std of a single sample returns 0 which is acceptable). + - First = first element of the windowed y, Last = last element of the windowed y (raw, may be NaN by design; document this). + - TimeStart = x(1), TimeEnd = x(end). + + Empty-window guard: if `isempty(x)` (window outside data extent, or empty series), return N=0 and NaN for Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd. + + Non-numeric guard (cellstr StateTag.Y): if `~isnumeric(y)`, set N = numel(x) (sample count), TimeStart = x(1), TimeEnd = x(end) when x is non-empty, First/Last to NaN, and Min/Max/Mean/Rms/Std to NaN. Add a one-line header note that the numeric reductions are numeric-only. + + Write a full header comment in the repo style: `%GETSTATS One-call statistics summary over the series or a time window.` with the field list, the nargin-3 windowing behavior, the NaN/empty/non-numeric guards, and a `See also getXYRange, Tag.getXY` line. Keep this additive: no new properties, no serialization/toStruct change, no interface change. + + + Run via the matlab MCP (mcp__matlab__check_matlab_code) static analysis on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools; the phase verifier runs this. + + + - Tag.m has a public `getStats(obj, tStart, tEnd)` method returning a struct with exactly N, Min, Max, Mean, Rms, Std, First, Last, TimeStart, TimeEnd. + - No `'omitnan'` string is used; NaNs are stripped via masking before reductions. + - No new properties, no toStruct/fromStruct change, no abstract-methods block introduced. + - Static analysis is clean (no new findings). + + + + + Task 2: Write headless test tests/test_tag_getstats.m + test_tag_getstats passes headlessly on MATLAB (matlab MCP), asserting values on a known series plus windowing/NaN/empty/non-numeric cases. + + - tests/test_derivedtag.m — flat Octave-safe function-style test pattern (local path helper, assert with message, TagRegistry.clear()). + - tests/suite/MockTag.m — available lightweight Tag double if a non-SensorTag path is needed. + - .planning/phases/999.2-tag-analysis-toolkit/issues/223.md — expected values. + + tests/test_tag_getstats.m + + Create a flat function-style test `function test_tag_getstats()` mirroring the pattern in tests/test_derivedtag.m: a local `add_tag_analysis_path_()` helper that does `addpath(repo); install();`, `TagRegistry.clear()` between blocks, and `assert(cond, 'message')` per check. End with `fprintf(' All %d tests passed.\n', nTests);`. + + Cover these cases with a known series (do the arithmetic by hand in comments so expected values are pinned): + - Basic numeric: `SensorTag('s','X',1:5,'Y',[2 4 6 8 10])`. Assert N==5, Min==2, Max==10, Mean==6, Rms≈sqrt(44) (use a tolerance, e.g. abs(s.Rms - sqrt(44)) < 1e-9), Std≈sqrt(10), First==2, Last==10, TimeStart==1, TimeEnd==5. + - NaN exclusion: `SensorTag('s2','X',1:5,'Y',[2 NaN 6 8 10])`. Assert N==4 and Mean==6.5 (omitnan semantics), and that Std/Rms are finite. + - Windowing narrows: build `SensorTag('s3','X',1:10,'Y',1:10)`; compare `sFull = t.getStats()` vs `sWin = t.getStats(4,6)`. Assert `sWin.N < sFull.N` and `sWin.TimeEnd <= 10` and `sWin.TimeStart >= 1` (robust to the one-point padding in getXYRange). This proves the time range narrows the window. + - Empty window: on the 1:10 tag, `s = t.getStats(100,200)` (outside extent) → assert N==0 and `isnan(s.Mean)` and `isnan(s.Std)`. + - Non-numeric StateTag: `StateTag('st','X',[0 1 2],'Y',{'a','b','c'})` → assert N==3, `isnan(st.getStats().Mean)`, and TimeStart==0, TimeEnd==2. + + Keep every construct Octave-safe (no `'omitnan'`, no toolbox calls). The test is run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_getstats()`); executors cannot run MATLAB. + + + Phase verifier runs via matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_getstats()` — expect "All N tests passed." with no assertion failure. Executor lacks MATLAB tools. + + + - tests/test_tag_getstats.m exists and asserts fields AND values on a known series. + - Includes a windowing case proving `sWin.N < sFull.N`. + - Includes NaN-exclusion, empty-window, and non-numeric StateTag cases. + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.getStats | Caller-supplied tStart/tEnd numeric window; caller-supplied series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-01-01 | Denial of Service | getStats on NaN/empty/huge series | low | mitigate | Explicit empty-window and all-NaN guards return N=0/NaN rather than erroring; reduction is O(n) single-pass | +| T-999.2-01-02 | Tampering | Non-numeric (cellstr) Y reaching numeric reductions | low | mitigate | `~isnumeric(y)` guard returns N + time bounds, NaN reductions; no coercion of arbitrary cell content | +| T-999.2-01-03 | Information Disclosure | new dependency / package install | low | accept | None — toolbox-free, no MEX, no package manager touched (no supply-chain surface) | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_getstats()` passes headlessly via the matlab MCP. +- Manual spot-check: `SensorTag('p','X',1:5,'Y',[2 4 6 8 10]).getStats()` returns the pinned struct. + + + +- getStats is callable on every Tag subclass with zero subclass overrides. +- Field set and values match issues/223.md exactly. +- Windowing narrows the reduction; NaN/empty/non-numeric guards behave as specified. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 01) + +New symbols: +- `Tag.getStats(tStart, tEnd)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_getstats.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-01-SUMMARY.md` when done. + diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-02-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-02-PLAN.md new file mode 100644 index 00000000..80c970e5 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-02-PLAN.md @@ -0,0 +1,170 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 02 +type: execute +wave: 2 +depends_on: + - "999.2-01" +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_resampleuniform.m +autonomous: true +requirements: + - "#308" +must_haves: + truths: + - "tag.resampleUniform(dt) returns [Xu, Yu] on a uniform grid X(1):dt:X(end) for any Tag subclass (#308)" + - "Interpolation default is kind-aware: linear for continuous (sensor/derived/composite), zero-order-hold ('previous') for discrete (state/monitor) (#308)" + - "An explicit 'Method' option overrides the kind default; a 'Range' option resamples only the windowed slice (#308)" + - "A cellstr StateTag series resamples via index-based ZOH so state labels are held, never linearly interpolated (#308)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method resampleUniform" + - "tests/test_tag_resampleuniform.m headless test" + key_links: + - "resampleUniform reads getXY/getXYRange + getKind so all subclasses inherit the correct interpolation default" +--- + + +Add `Tag.resampleUniform(dt, ...)` to the `Tag` base class (#308): resample a tag onto a uniform time grid with kind-aware default interpolation, so downstream FFT / cross-correlation / multi-rate comparison / aligned export get correctly-interpolated evenly-spaced samples without hand-rolling `interp1` and without linearly interpolating state/monitor channels. + +Purpose: Bake in the documented ZOH-only invariant for discrete channels while giving continuous channels linear interpolation, all toolbox-free and Octave-safe. +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_resampleuniform.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/308.md + +# Target class + kind + prior-art test pattern +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + +# NOTE: This plan and 999.2-03..07 all append methods to the single shared file +# libs/SensorThreshold/Tag.m. If the wave is parallelized, edits to Tag.m MUST be +# serialized across these plans (append distinct methods; do not clobber). Plan +# 999.2-01 (getStats) lands first and establishes the method-block location. + + + + + + Task 1: Implement Tag.resampleUniform in the public methods block + Tag.resampleUniform returns [Xu,Yu] with kind-aware default interpolation (linear vs previous), overridable Method, Range subset, invalid-dt guard; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getXYRange (:128), getKind semantics, constructor name-value parsing style (:90-107). + - libs/SensorThreshold/StateTag.m — Y may be numeric OR cellstr; getKind returns 'state'. + - .planning/phases/999.2-tag-analysis-toolkit/issues/308.md — API shape, kind-aware default table, options. + + libs/SensorThreshold/Tag.m + + Add a public method `function [Xu, Yu] = resampleUniform(obj, dt, varargin)` implementing #308. + + Option parsing: follow the Tag constructor's manual `for i = 1:2:numel(varargin)` switch style. Support 'Range' ([t0 t1]), 'Method' (char), and 'MaxGap' (numeric, default off). Raise `Tag:unknownOption` for unrecognized keys, matching the existing convention. + + Validate dt: require a positive finite scalar; raise `Tag:invalidDt` otherwise (guards against dt<=0, NaN/Inf, or a runaway grid). + + Data source: if 'Range' is given, `[X, Y] = obj.getXYRange(t0, t1)`, else `[X, Y] = obj.getXY()`. If X is empty, return `Xu = []; Yu = []` (or empty of the matching type) and return early. + + Kind-aware default method: query `k = obj.getKind()`. Default 'linear' for 'sensor'/'derived'/'composite'; default 'previous' (zero-order hold) for 'state'/'monitor'. An explicit 'Method' option overrides the default. Document that the safe default prevents fabricating illegal interpolated values on discrete channels. + + Grid: `Xu = X(1):dt:X(end)` (operate on X(:) for orientation safety). + + Interpolation: + - Numeric Y: `Yu = interp1(X, Y, Xu, method)`. `interp1` with 'linear'/'previous' is core MATLAB and Octave (no toolbox, no resample()). + - Non-numeric (cellstr) Y with a ZOH-style method: interpolate the INDEX not the values — `idx = interp1(X, 1:numel(X), Xu, 'previous', 'extrap'); Yu = Y(idx)` — so state labels are held, never coerced. If a caller forces a linear 'Method' on cellstr Y, raise a clear error (`Tag:resampleNonNumericLinear`). + + MaxGap (self-contained, no findGaps/#306 dependency): when 'MaxGap' is a finite positive scalar, set numeric `Yu` to NaN at grid points that fall inside an original inter-sample interval whose width exceeds MaxGap, so resampling does not invent data across dropouts. (Auto-gap detection via #306 findGaps is a separate backlog issue and is out of scope here.) + + Write a repo-style header comment: `%RESAMPLEUNIFORM Resample onto a uniform grid with kind-aware interpolation.` documenting the kind-default table, the options, the cellstr ZOH-via-index behavior, and MaxGap. Additive only — no property, serialization, or interface change. + + + Phase verifier runs matlab MCP static analysis (mcp__matlab__check_matlab_code) on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools. + + + - Tag.m has public `resampleUniform(obj, dt, varargin)` with 'Range'/'Method'/'MaxGap' options and `Tag:unknownOption`/`Tag:invalidDt` error ids. + - Default method is linear for continuous kinds, 'previous' for state/monitor; explicit 'Method' overrides. + - Uses only `interp1` with 'linear'/'previous' (toolbox-free, Octave-safe); cellstr Y uses index-based ZOH. + - Additive; static analysis clean. + + + + + Task 2: Write headless test tests/test_tag_resampleuniform.m + test_tag_resampleuniform passes headlessly (matlab MCP), proving continuous-linear vs state-ZOH, method override, range narrowing, invalid-dt error. + + - tests/test_derivedtag.m — flat test pattern. + - .planning/phases/999.2-tag-analysis-toolkit/issues/308.md — expected interpolation behavior. + + tests/test_tag_resampleuniform.m + + Create `function test_tag_resampleuniform()` in the flat pattern (local path helper `add_tag_analysis_path_()`, `TagRegistry.clear()` between blocks, `assert(cond,'msg')`, final "All N tests passed." print). + + Cover (pin expected values in comments): + - Continuous linear: `SensorTag('s','X',[0 1 2 4],'Y',[0 10 20 40])`; `[Xu,Yu] = t.resampleUniform(1)`. Assert `Xu` equals `0:1:4` and `Yu` ≈ `[0 10 20 30 40]` (linear default for sensor). Use a numeric tolerance. + - State ZOH default: `StateTag('st','X',[0 2 5],'Y',[1 2 3])`; `[Xu,Yu] = st.resampleUniform(1)`. Assert `Yu` ≈ `[1 1 1 2 2 2]` (previous/ZOH — NOT linear), proving the kind-aware default. + - Explicit override: `st.resampleUniform(1,'Method','linear')` returns linearly-varying Yu (differs from the ZOH result), proving 'Method' overrides the kind default. + - Range subset narrows: `t.resampleUniform(1,'Range',[1 3])` → assert `Xu(1) >= 1`, `Xu(end) <= 3`, and `numel(Xu) < numel(t.resampleUniform(1))` (windowing narrows the grid). + - Invalid dt: `t.resampleUniform(0)` throws `Tag:invalidDt` (use try/catch on the identifier). + - (Optional) cellstr ZOH: `StateTag('sc','X',[0 2],'Y',{'lo','hi'})`, `resampleUniform(1)` holds labels ('lo' then 'hi'). + + Octave-safe throughout. Run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_resampleuniform()`). + + + Phase verifier runs matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_resampleuniform()` — expect "All N tests passed." Executor lacks MATLAB tools. + + + - Test asserts continuous-linear values, state-ZOH values, explicit-method override, range narrowing, and invalid-dt error. + - Proves the kind-aware default (state result differs from linear result). + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.resampleUniform | Caller-supplied dt, Range, Method, MaxGap; series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-02-01 | Denial of Service | dt <= 0 / NaN / tiny dt producing a runaway grid | medium | mitigate | `Tag:invalidDt` guard rejects non-positive/non-finite dt before the grid is built | +| T-999.2-02-02 | Tampering | Illegal linear interpolation of discrete/state values | medium | mitigate | Kind-aware default forces 'previous' for state/monitor; cellstr uses index-based ZOH; linear-on-cellstr errors | +| T-999.2-02-03 | Information Disclosure | new dependency / package install | low | accept | None — toolbox-free `interp1`, no MEX, no package manager touched | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_resampleuniform()` passes headlessly via the matlab MCP. + + + +- resampleUniform works on every Tag subclass with zero overrides. +- Kind-aware default interpolation is correct and overridable; Range narrows the grid; invalid dt is rejected. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 02) + +New symbols: +- `Tag.resampleUniform(dt, ...)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_resampleuniform.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-02-SUMMARY.md` when done. + diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-03-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-03-PLAN.md new file mode 100644 index 00000000..9ec9d7f1 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-03-PLAN.md @@ -0,0 +1,163 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 03 +type: execute +wave: 2 +depends_on: + - "999.2-01" +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_derivative.m +autonomous: true +requirements: + - "#326" +must_haves: + truths: + - "tag.derivative() returns [t, dydt], the per-X-unit rate of change, for any Tag subclass (#326)" + - "Central differences are the default and are correct on non-uniform sampling (gradient(Y)./gradient(X)); 'forward'/'backward' methods are selectable (#326)" + - "A 'Range' option restricts the derivative to a time window; rate is NaN where a neighbour is NaN (#326)" + - "Calling derivative on a discrete StateTag emits a Tag:derivativeOnDiscrete warning (#326)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method derivative" + - "tests/test_tag_derivative.m headless test" + key_links: + - "derivative reads getXY/getXYRange + getKind so every subclass inherits it" +--- + + +Add `Tag.derivative(...)` to the `Tag` base class (#326): a kind-aware rate-of-change series (ramp rate, leak rate, di/dt) computed with non-uniform-aware central differences, so engineers stop dropping to raw `gradient(Y)./gradient(X)` and hand-handling NaNs. + +Purpose: Provide the differentiation primitive that `DerivedTag` structurally cannot express (it needs neighbouring samples), the building block for rate alarms and the companion to movingStat. +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_derivative.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/326.md + +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + +# NOTE: This plan and 999.2-02/04..07 all append methods to the single shared file +# libs/SensorThreshold/Tag.m. If the wave is parallelized, edits to Tag.m MUST be +# serialized across these plans (append distinct methods; do not clobber). + + + + + + Task 1: Implement Tag.derivative in the public methods block + Tag.derivative returns [t,dydt] via central/forward/backward differences (non-uniform aware), Range subset, NaN-safe, discrete-kind warning; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getXYRange (:128), getKind, constructor name-value parsing style. + - .planning/phases/999.2-tag-analysis-toolkit/issues/326.md — API shape, method options, NaN + discrete-kind policy. + + libs/SensorThreshold/Tag.m + + Add `function [t, dydt] = derivative(obj, varargin)` implementing #326. + + Option parsing (Tag-constructor manual switch style): 'Method' ('central' default | 'forward' | 'backward') and 'Range' ([t0 t1]). Raise `Tag:unknownOption` for unknown keys. + + Data source: with 'Range', `[X, Y] = obj.getXYRange(t0, t1)`; else `[X, Y] = obj.getXY()`. Operate on X(:) / Y(:). If X has fewer than 2 points, return `t = X` and `dydt = NaN(size(X))`. + + Kind-awareness: if `obj.getKind()` is 'state', emit `warning('Tag:derivativeOnDiscrete', ...)` (a step channel's derivative is ill-defined) but proceed for numeric Y. If Y is non-numeric (cellstr), raise `Tag:derivativeOnDiscrete` as an error (cannot differentiate labels). + + Compute: + - 'central' (default): `dydt = gradient(Y) ./ gradient(X)`. `gradient` uses non-uniform spacing correctly and is core MATLAB/Octave (no toolbox). `t = X`. + - 'forward': `dydt(k) = (Y(k+1)-Y(k)) / (X(k+1)-X(k))` for k=1..end-1; set the last element to the backward difference (or NaN) so length matches X. `t = X`. + - 'backward': symmetric to forward (first element forward or NaN). + + NaN-safe: rate is NaN wherever either contributing neighbour is NaN — this falls out of gradient/diff arithmetic; do not scrub it. Return t and dydt the same length as X. + + Repo-style header: `%DERIVATIVE Kind-aware rate-of-change series (per X unit).` documenting the methods, non-uniform correctness, the NaN policy, and the discrete-kind warning. Additive only. + + + Phase verifier runs matlab MCP static analysis (mcp__matlab__check_matlab_code) on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools. + + + - Tag.m has public `derivative(obj, varargin)` with 'Method'/'Range' options and `Tag:unknownOption`. + - Central default uses gradient(Y)./gradient(X); forward/backward selectable; output length equals numel(X). + - StateTag numeric input warns `Tag:derivativeOnDiscrete`; NaN neighbours yield NaN rate. + - Additive; static analysis clean. + + + + + Task 2: Write headless test tests/test_tag_derivative.m + test_tag_derivative passes headlessly (matlab MCP), asserting central/forward values, non-uniform correctness, range narrowing, NaN propagation, Tag:derivativeOnDiscrete. + + - tests/test_derivedtag.m — flat test pattern (and warning-capture via lastwarn). + - .planning/phases/999.2-tag-analysis-toolkit/issues/326.md — expected behavior. + + tests/test_tag_derivative.m + + Create `function test_tag_derivative()` in the flat pattern (local path helper, TagRegistry.clear(), assert-with-message, final pass print). + + Cover: + - Uniform linear ramp: `SensorTag('s','X',1:5,'Y',2*(1:5))` → central derivative ≈ 2 everywhere. Assert `max(abs(dydt - 2)) < 1e-9`. + - Non-uniform: `SensorTag('n','X',[0 1 3],'Y',[0 1 3])` (slope 1) → assert `max(abs(dydt - 1)) < 1e-9`, proving non-uniform correctness. + - Forward method: on the ramp, `derivative('Method','forward')` first element ≈ (Y(2)-Y(1))/(X(2)-X(1)) = 2; assert. + - Range narrows: compare `numel(t_full)` from `derivative()` vs `numel(t_win)` from `derivative('Range',[2 4])`; assert `numel(t_win) < numel(t_full)`. + - NaN propagation: `SensorTag('m','X',1:5,'Y',[1 2 NaN 4 5])` → assert some `isnan(dydt)` around the NaN sample and finite values away from it. + - Discrete warning: `lastwarn('')`; call `StateTag('st','X',1:4,'Y',[1 2 3 4]).derivative()`; assert `strcmp(lastwarn_id, 'Tag:derivativeOnDiscrete')` using the two-output form `[~, id] = lastwarn`. + + Octave-safe. Run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_derivative()`). + + + Phase verifier runs matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_derivative()` — expect "All N tests passed." Executor lacks MATLAB tools. + + + - Test asserts central-difference values (uniform + non-uniform), a forward-method value, range narrowing, NaN propagation, and the Tag:derivativeOnDiscrete warning id. + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.derivative | Caller-supplied Method/Range; series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-03-01 | Denial of Service | derivative on <2-point or all-NaN series | low | mitigate | Length guard returns NaN vector for <2 points; NaN arithmetic does not throw | +| T-999.2-03-02 | Tampering | Differentiating a discrete/state or cellstr channel | low | mitigate | Warn `Tag:derivativeOnDiscrete` for state kind; error for non-numeric Y | +| T-999.2-03-03 | Information Disclosure | new dependency / package install | low | accept | None — `gradient` is toolbox-free, no MEX, no package manager touched | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_derivative()` passes headlessly via the matlab MCP. + + + +- derivative works on every Tag subclass with zero overrides. +- Central/forward/backward correct; non-uniform-aware; Range narrows; NaN-safe; discrete warns. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 03) + +New symbols: +- `Tag.derivative(...)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_derivative.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-03-SUMMARY.md` when done. + diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-04-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-04-PLAN.md new file mode 100644 index 00000000..e19f4152 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-04-PLAN.md @@ -0,0 +1,169 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 04 +type: execute +wave: 2 +depends_on: + - "999.2-01" +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_movingstat.m +autonomous: true +requirements: + - "#312" +must_haves: + truths: + - "tag.movingStat(window) returns [X, Ys], a same-length centered rolling series (moving average by default) for continuous tags (#312)" + - "type dispatch supports 'mean' (default), 'std', 'max', 'min', 'rms', 'median' (#312)" + - "Window is an integer count of samples; the output has the same length as the input series (#312)" + - "Calling movingStat on a discrete state/monitor tag raises Tag:movingStatNotContinuous (#312)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method movingStat" + - "tests/test_tag_movingstat.m headless test" + key_links: + - "movingStat reads getXY + getKind so every subclass inherits it and the kind guard is uniform" +--- + + +Add `Tag.movingStat(window, type)` to the `Tag` base class (#312): rolling moving-average / std / max / min / rms / median as a new same-length series for smoothing, envelopes, and peak-hold — the everyday denoise/trend/envelope need, toolbox-free. + +Purpose: Stop every user re-deriving `movmean`/`movstd` by hand and plug the result straight into addLine (smoothed trace) or addShaded (mean ± rolling-std band). +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_movingstat.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/312.md + +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + +# NOTE: This plan and 999.2-02/03/05..07 all append methods to the single shared file +# libs/SensorThreshold/Tag.m. If the wave is parallelized, edits to Tag.m MUST be +# serialized across these plans (append distinct methods; do not clobber). + + + + + + Task 1: Implement Tag.movingStat in the public methods block + Tag.movingStat dispatches mean/std/max/min/rms/median as a same-length centered series with kind + window guards and an Octave fallback; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getKind, constructor name-value parsing style. + - .planning/phases/999.2-tag-analysis-toolkit/issues/312.md — API shape, supported types, kind guard, window units. + + libs/SensorThreshold/Tag.m + + Add `function [X, Ys] = movingStat(obj, window, type)` implementing #312. + + Defaults + validation: `if nargin < 3, type = 'mean'; end`. Require `window` to be a positive integer scalar; raise `Tag:invalidWindow` otherwise. + + Kind guard: `k = obj.getKind()`. For 'state' or 'monitor' (discrete/binary), a rolling mean is meaningless — raise `Tag:movingStatNotContinuous` with a clear message. Proceed only for continuous numeric kinds ('sensor'/'derived'/'composite'). + + Data: `[X, Y] = obj.getXY()`; operate on Y(:). If Y is non-numeric, raise `Tag:movingStatNotContinuous`. + + Dispatch on lowercased `type` to the base movers, all returning a same-length centered-window series: + - 'mean' → `movmean(Y, window)` + - 'std' → `movstd(Y, window)` + - 'max' → `movmax(Y, window)` + - 'min' → `movmin(Y, window)` + - 'median' → `movmedian(Y, window)` + - 'rms' → `sqrt(movmean(Y.^2, window))` + Raise `Tag:unknownStatType` for any other type. + + Octave portability guard: the base movers are documented core on MATLAB R2020b+ and Octave 7+. Add a `exist('movmean','builtin') || exist('movmean','file')` probe at the top of the dispatch; if a required mover is unavailable, fall back to a local nested centered-window helper `movWindow_(Y, window, reducer)` that slides a centered window (even windows: bias toward the trailing side to match movmean's convention) and applies the reducer (`@mean`/`@std`/`@max`/`@min`/`@median`). This keeps the method toolbox-free AND Octave-safe on older interpreters. Return `[X, Ys]` with `numel(Ys) == numel(Y)`. + + Repo-style header: `%MOVINGSTAT Rolling windowed statistic as a new same-length series.` documenting the supported types, sample-count window units, the centered-window convention, and the continuous-only kind guard. Additive only. + + + Phase verifier runs matlab MCP static analysis (mcp__matlab__check_matlab_code) on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools. + + + - Tag.m has public `movingStat(obj, window, type)` with default type 'mean' and error ids `Tag:invalidWindow`, `Tag:movingStatNotContinuous`, `Tag:unknownStatType`. + - Dispatches mean/std/max/min/rms/median; output length equals numel(Y). + - State/monitor tags are rejected; toolbox-free with an Octave fallback for the movers. + - Additive; static analysis clean. + + + + + Task 2: Write headless test tests/test_tag_movingstat.m + test_tag_movingstat passes headlessly (matlab MCP), asserting mean/max/min/rms values, std/median finiteness, even+odd windows, kind + invalid-window guards. + + - tests/test_derivedtag.m — flat test pattern. + - .planning/phases/999.2-tag-analysis-toolkit/issues/312.md — expected behavior. + + tests/test_tag_movingstat.m + + Create `function test_tag_movingstat()` in the flat pattern (local path helper, TagRegistry.clear(), assert-with-message, final pass print). + + Cover (pin expected values): + - Default mean, odd window: `SensorTag('s','X',1:5,'Y',[1 2 3 4 5])`; `[X,Ys] = t.movingStat(3)`. Assert `numel(Ys) == 5` and `Ys` ≈ `[1.5 2 3 4 4.5]` (centered movmean with shrinking endpoints). Tolerance 1e-9. + - Max (peak-hold): `t.movingStat(3,'max')` ≈ `[2 3 4 5 5]`. + - Min: `t.movingStat(3,'min')` ≈ `[1 1 2 3 4]`. + - Rms: `t.movingStat(3,'rms')` finite and equals `sqrt(movmean(Y.^2,3))` (compute expected inline). + - Std + median: assert `numel == 5` and finite for both. + - Even window: `t.movingStat(2)` returns `numel == 5` (length preserved). + - Kind guard: `StateTag('st','X',1:4,'Y',[1 2 3 4]).movingStat(3)` throws `Tag:movingStatNotContinuous` (try/catch on identifier). + - Invalid window: `t.movingStat(0)` throws `Tag:invalidWindow`. + + Octave-safe. Run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_movingstat()`). + + + Phase verifier runs matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_movingstat()` — expect "All N tests passed." Executor lacks MATLAB tools. + + + - Test asserts mean/max/min/rms values on a known series, std/median finiteness, even+odd windows preserve length, the discrete kind guard, and invalid-window error. + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.movingStat | Caller-supplied window/type; series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-04-01 | Denial of Service | non-positive / non-integer / huge window | low | mitigate | `Tag:invalidWindow` guard; movers are O(n) | +| T-999.2-04-02 | Tampering | rolling stat over discrete/state or cellstr channel | low | mitigate | `Tag:movingStatNotContinuous` kind + numeric guard | +| T-999.2-04-03 | Information Disclosure | new dependency / package install | low | accept | None — base movers with hand-rolled Octave fallback; no MEX, no package manager touched | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_movingstat()` passes headlessly via the matlab MCP. + + + +- movingStat works on every continuous Tag subclass with zero overrides. +- All six types dispatch correctly; output length preserved; discrete kinds rejected. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 04) + +New symbols: +- `Tag.movingStat(window, type)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_movingstat.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-04-SUMMARY.md` when done. + diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-05-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-05-PLAN.md new file mode 100644 index 00000000..df03527c --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-05-PLAN.md @@ -0,0 +1,170 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 05 +type: execute +wave: 2 +depends_on: + - "999.2-01" +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_exceedance.m +autonomous: true +requirements: + - "#316" +must_haves: + truths: + - "tag.exceedance(level) returns a struct with totalTime, fraction, count, longest, peak for time-above-threshold analysis (#316)" + - "'Direction','below' analyses time below a floor; 'Range' restricts to a window (#316)" + - "For continuous tags the run boundaries are linearly interpolated for accurate durations; for discrete state/monitor tags zero-order-hold (dt sums) is used (#316)" + - "A series that never exceeds the level returns totalTime=0, count=0 (#316)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method exceedance" + - "tests/test_tag_exceedance.m headless test" + key_links: + - "exceedance reads getXY/getXYRange + getKind so every subclass inherits it and crossing accuracy is kind-aware" +--- + + +Add `Tag.exceedance(level, ...)` to the `Tag` base class (#316): one-shot time-above/below-limit, excursion count, longest excursion, duty-cycle fraction, and peak — the continuous numeric analog of StateTag.stateDurations — without standing up a MonitorTag + EventStore pipeline. + +Purpose: Answer "how long, and how often, did this signal exceed a limit?" for any plain tag, toolbox-free and Octave-safe. +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_exceedance.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/316.md + +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + +# NOTE: This plan and 999.2-02..04/06/07 all append methods to the single shared file +# libs/SensorThreshold/Tag.m. If the wave is parallelized, edits to Tag.m MUST be +# serialized across these plans (append distinct methods; do not clobber). + + + + + + Task 1: Implement Tag.exceedance in the public methods block + Tag.exceedance returns totalTime/fraction/count/longest/peak with continuous interpolation vs discrete ZOH, Direction/Range options; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getXYRange (:128), getKind, constructor name-value parsing style. + - .planning/phases/999.2-tag-analysis-toolkit/issues/316.md — struct fields, direction/range options, continuous-vs-ZOH crossing accuracy. + + libs/SensorThreshold/Tag.m + + Add `function s = exceedance(obj, level, varargin)` implementing #316. + + Option parsing (Tag-constructor manual switch style): 'Direction' ('above' default | 'below') and 'Range' ([t0 t1]). Raise `Tag:unknownOption` for unknown keys. Require numeric scalar `level`. + + Data: with 'Range', `[X, Y] = obj.getXYRange(t0, t1)`, else `[X, Y] = obj.getXY()`. Operate on X(:)/Y(:). Require numeric Y (raise a clear error for cellstr state). If fewer than 2 points, return the empty-result struct below. + + Condition: 'above' → `cond = Y > level`; 'below' → `cond = Y < level`. + + Kind-aware duration accuracy: + - Continuous ('sensor'/'derived'/'composite'/'monitor'): for each maximal run of true, linearly interpolate the exact crossing time at the run's leading and trailing boundaries (between the last false and first true sample, and between the last true and first false sample) so run durations are accurate. Use the standard `t = X(k) + (level - Y(k))/(Y(k+1)-Y(k))*(X(k+1)-X(k))` form. Runs touching the series ends use the actual endpoint (no interpolation off the edge). + - Discrete ('state'): zero-order hold — sum the `dt = diff(X)` intervals whose left sample satisfies the condition (no interpolation), consistent with the ZOH invariant. + + Struct fields (return `s`): + - totalTime = sum of exceedance run durations. + - fraction = totalTime / (X(end) - X(1)) (guard divide-by-zero → 0 when the span is 0). + - count = number of runs. + - longest = max single-run duration (0 when none). + - peak = the extreme value reached during exceedance: max(Y(cond)) for 'above', min(Y(cond)) for 'below'; NaN when no exceedance. + + Empty/no-exceedance result: totalTime=0, fraction=0, count=0, longest=0, peak=NaN. + + NaN handling: NaN samples do not satisfy `>`/`<`, so they naturally break runs; document that a NaN inside data ends the current run. + + Repo-style header: `%EXCEEDANCE Time-above/below-threshold analysis (duty cycle, excursions).` documenting the struct fields, direction/range options, and the continuous-interpolation vs discrete-ZOH accuracy decision. Additive only. + + + Phase verifier runs matlab MCP static analysis (mcp__matlab__check_matlab_code) on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools. + + + - Tag.m has public `exceedance(obj, level, varargin)` returning struct totalTime/fraction/count/longest/peak with 'Direction'/'Range' options and `Tag:unknownOption`. + - Continuous tags interpolate run boundaries; state tags use ZOH dt-sums. + - No-exceedance returns totalTime=0, count=0, peak=NaN. + - Additive; toolbox-free; static analysis clean. + + + + + Task 2: Write headless test tests/test_tag_exceedance.m + test_tag_exceedance passes headlessly (matlab MCP), asserting interpolated durations, below-direction, range narrowing, no-exceedance zeros, discrete ZOH. + + - tests/test_derivedtag.m — flat test pattern. + - .planning/phases/999.2-tag-analysis-toolkit/issues/316.md — expected values. + + tests/test_tag_exceedance.m + + Create `function test_tag_exceedance()` in the flat pattern (local path helper, TagRegistry.clear(), assert-with-message, final pass print). + + Cover (pin the interpolation arithmetic in comments): + - Single continuous excursion: `SensorTag('s','X',1:5,'Y',[0 0 100 100 0])`, `s = t.exceedance(50)`. Crossings interpolate to t=2.5 (rise 0→100 between X=2,3) and t=4.5 (fall 100→0 between X=4,5). Assert `abs(s.totalTime - 2) < 1e-9`, `s.count == 1`, `abs(s.longest - 2) < 1e-9`, `s.peak == 100`, `abs(s.fraction - 0.5) < 1e-9` (span = 4). + - Below direction: `t.exceedance(50,'Direction','below')` → count of below-runs; assert count and peak = min value in below region. + - Range subset narrows: `t.exceedance(50,'Range',[1 3])` → assert `s.totalTime < 2` (window cuts the excursion) or count changes vs full — assert the windowed result differs from the full-series result. + - No exceedance: `t.exceedance(1000)` → assert `s.totalTime == 0`, `s.count == 0`, `isnan(s.peak)`. + - Discrete ZOH: `StateTag('st','X',[0 1 2 3],'Y',[0 100 100 0])`, `exceedance(50)` → ZOH dt-sum: samples at X=1 and X=2 satisfy >50, dt sums = (2-1)+(3-2)=2 (left-sample ZOH). Assert integer-boundary totalTime (no interpolation), distinguishing it from the continuous path. + + Octave-safe. Run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_exceedance()`). + + + Phase verifier runs matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_exceedance()` — expect "All N tests passed." Executor lacks MATLAB tools. + + + - Test asserts interpolated continuous durations, below-direction, range narrowing, no-exceedance zeros, and discrete ZOH duration. + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.exceedance | Caller-supplied level/Direction/Range; series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-05-01 | Denial of Service | zero-span / <2-point / all-NaN series | low | mitigate | Empty-result struct + divide-by-zero guard on fraction | +| T-999.2-05-02 | Tampering | exceedance on non-numeric state channel | low | mitigate | Numeric-Y guard errors clearly; state kind uses documented ZOH path | +| T-999.2-05-03 | Information Disclosure | new dependency / package install | low | accept | None — array ops only, no MEX, no package manager touched | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_exceedance()` passes headlessly via the matlab MCP. + + + +- exceedance works on every Tag subclass with zero overrides. +- Continuous interpolation vs discrete ZOH both correct; direction/range options work; no-exceedance returns zeros. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 05) + +New symbols: +- `Tag.exceedance(level, ...)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_exceedance.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-05-SUMMARY.md` when done. + diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-06-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-06-PLAN.md new file mode 100644 index 00000000..9b18be59 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-06-PLAN.md @@ -0,0 +1,173 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 06 +type: execute +wave: 2 +depends_on: + - "999.2-01" +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_crossings.m +autonomous: true +requirements: + - "#328" +must_haves: + truths: + - "tag.crossings(level) returns a struct with times, direction (+1 rising / -1 falling), count, and periods for cycle/frequency analysis (#328)" + - "Crossing instants are linearly interpolated between samples; direction filters via 'Direction' rising/falling/both (#328)" + - "'Range' restricts to a window; a segment with a NaN endpoint yields no crossing (#328)" + - "The 2-output form [t, dir] returns crossing times and their +1/-1 directions (#328)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method crossings" + - "tests/test_tag_crossings.m headless test" + key_links: + - "crossings reads getXY/getXYRange + getKind so every subclass inherits it (continuous interpolate, discrete ZOH)" +--- + + +Add `Tag.crossings(level, ...)` to the `Tag` base class (#328): interpolated level-crossing instants + direction (+1/-1) + convenience periods, completing the timing triad (exceedance = how long, crossings = when/which way, stateDurations = categorical dwell) for cycle-period, frequency, chatter, and inter-event-interval analysis. + +Purpose: Replace hand-rolled `find(diff(sign(Y-level))~=0)` with a correct, NaN-safe, interpolated primitive on any tag. +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_crossings.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/328.md + +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + +# NOTE: This plan and 999.2-02..05/07 all append methods to the single shared file +# libs/SensorThreshold/Tag.m. If the wave is parallelized, edits to Tag.m MUST be +# serialized across these plans (append distinct methods; do not clobber). + + + + + + Task 1: Implement Tag.crossings in the public methods block + Tag.crossings returns times/direction/count/periods (+2-out form) with interpolation, plateau/exact-sample/NaN handling, Direction/Range, discrete warn; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getXYRange (:128), getKind, constructor name-value parsing style. + - .planning/phases/999.2-tag-analysis-toolkit/issues/328.md — struct fields, direction/range options, plateau/equal-sample and NaN policies, ZOH-on-discrete. + + libs/SensorThreshold/Tag.m + + Add `function varargout = crossings(obj, level, varargin)` implementing #328. + + Option parsing (Tag-constructor manual switch style): 'Direction' ('both' default | 'rising' | 'falling') and 'Range' ([t0 t1]). Raise `Tag:unknownOption`. Require numeric scalar `level`. + + Data: with 'Range', `[X, Y] = obj.getXYRange(t0, t1)`, else `[X, Y] = obj.getXY()`. Operate on X(:)/Y(:). Require numeric Y (state cellstr → error). If fewer than 2 points, return an empty result. + + Core (continuous kinds 'sensor'/'derived'/'composite'/'monitor'): + - `d = Y - level`. Find indices k where a sign change occurs: `sign(d(k)) * sign(d(k+1)) < 0` (strict, opposite non-zero signs). + - Interpolate the exact instant: `tc = X(k) + (level - Y(k))/(Y(k+1)-Y(k)) * (X(k+1)-X(k))`. + - `dir = sign(Y(k+1) - Y(k))` (+1 rising, -1 falling). + - Exact-sample / plateau handling: when a sample sits exactly at the level (`d==0`), do NOT count one crossing per flat sample. Count a single crossing on ENTRY to and EXIT from the level, based on the sign of the neighbours bracketing the zero run (detect a real sign change across the plateau; a touch-and-return to the same side is not a crossing). + - NaN policy: a segment whose endpoint (Y(k) or Y(k+1)) is NaN yields no crossing (treat as a gap) — document explicitly. + + Kind-aware discrete path ('state'): use zero-order-hold — the transition sample time, no interpolation; and/or emit `warning('Tag:crossingsOnDiscrete', ...)`. + + Direction filter: keep only `dir > 0` for 'rising', `dir < 0` for 'falling', all for 'both'. + + Output struct `c` (fields): `times` (crossing instants, column or row consistent with the series), `direction` (+1/-1, same length as times), `count` (numel(times)), and `periods` (diff of successive SAME-direction crossing times — for immediate cycle/frequency analysis; empty when <2 same-direction crossings). + + varargout: `nargout <= 1` → return the struct `c`; `nargout == 2` → return `[times, direction]` (the documented 2-out form). + + Repo-style header: `%CROSSINGS Interpolated level-crossing instants + direction (cycle/period analysis).` documenting the struct fields, the 2-out form, direction/range options, plateau and NaN policies, and the discrete ZOH/warn behavior. Additive only. + + + Phase verifier runs matlab MCP static analysis (mcp__matlab__check_matlab_code) on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools. + + + - Tag.m has public `crossings(obj, level, varargin)` returning struct times/direction/count/periods, plus a 2-out [t, dir] form, with 'Direction'/'Range' options and `Tag:unknownOption`. + - Interpolates continuous crossings; plateau-at-level counts a single entry/exit; NaN segments yield no crossing. + - State kind uses ZOH and/or warns `Tag:crossingsOnDiscrete`. + - Additive; toolbox-free; static analysis clean. + + + + + Task 2: Write headless test tests/test_tag_crossings.m + test_tag_crossings passes headlessly (matlab MCP), asserting both/rising/falling, periods, 2-out form, plateau/exact-sample single-count, NaN-gap, discrete warn. + + - tests/test_derivedtag.m — flat test pattern (and warning capture via lastwarn). + - .planning/phases/999.2-tag-analysis-toolkit/issues/328.md — expected values. + + tests/test_tag_crossings.m + + Create `function test_tag_crossings()` in the flat pattern (local path helper, TagRegistry.clear(), assert-with-message, final pass print). + + Cover (pin the interpolation arithmetic in comments): + - Both directions: `SensorTag('s','X',1:5,'Y',[-1 1 1 -1 1])`, level 0. Expect times ≈ [1.5 3.5 4.5], direction [1 -1 1], count 3. Assert with tolerance 1e-9. + - Rising only: `crossings(0,'Direction','rising')` → times ≈ [1.5 4.5], all direction +1. + - Falling only: `crossings(0,'Direction','falling')` → times ≈ [3.5], direction -1. + - Periods: on the rising-only result, `c.periods` ≈ [3] (4.5 - 1.5). + - 2-out form: `[tt, dd] = t.crossings(0)` returns numeric vectors matching times/direction. + - Exact-sample hit: `SensorTag('e','X',1:3,'Y',[-1 0 1])` crossing at the middle sample counts a single rising crossing (not two). + - Plateau at level: `SensorTag('p','X',1:5,'Y',[-1 0 0 1 1])` counts a single rising crossing across the plateau. + - Range subset narrows: crossings on a longer series with 'Range' returns fewer crossings than the full series. + - NaN gap: `SensorTag('n','X',1:4,'Y',[-1 NaN 1 -1])` — no crossing across the NaN segment; assert the NaN-bracketed transition is absent. + - Discrete warn: `lastwarn('')`; `StateTag('st','X',1:4,'Y',[0 1 1 0]).crossings(0.5)`; assert `[~,id]=lastwarn; strcmp(id,'Tag:crossingsOnDiscrete')`. + + Octave-safe. Run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_crossings()`). + + + Phase verifier runs matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_crossings()` — expect "All N tests passed." Executor lacks MATLAB tools. + + + - Test asserts both/rising/falling filtering, interpolated times, periods, the 2-out form, exact-sample and plateau single-count, range narrowing, NaN-gap suppression, and the discrete warning id. + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.crossings | Caller-supplied level/Direction/Range; series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-06-01 | Denial of Service | <2-point / all-NaN / all-equal-to-level series | low | mitigate | Empty-result guard; plateau logic avoids per-sample explosion | +| T-999.2-06-02 | Tampering | crossings on non-numeric state channel | low | mitigate | Numeric-Y guard + `Tag:crossingsOnDiscrete` warn/ZOH path | +| T-999.2-06-03 | Information Disclosure | new dependency / package install | low | accept | None — sign/find/diff only, no MEX, no package manager touched | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_crossings()` passes headlessly via the matlab MCP. + + + +- crossings works on every Tag subclass with zero overrides. +- Interpolated times + direction + periods correct; plateau/exact-sample/NaN edge cases handled; range narrows; discrete warns. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 06) + +New symbols: +- `Tag.crossings(level, ...)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_crossings.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-06-SUMMARY.md` when done. + diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-07-PLAN.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-07-PLAN.md new file mode 100644 index 00000000..cb56cff8 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-07-PLAN.md @@ -0,0 +1,160 @@ +--- +phase: 999.2-tag-analysis-toolkit +plan: 07 +type: execute +wave: 2 +depends_on: + - "999.2-01" +files_modified: + - libs/SensorThreshold/Tag.m + - tests/test_tag_cumulativeintegral.m +autonomous: true +requirements: + - "#327" +must_haves: + truths: + - "tag.cumulativeIntegral() returns [t, cum], the running trapezoidal integral of Y w.r.t. X, for any Tag subclass (#327)" + - "The 1-output form returns the scalar grand total cum(end); 'Range' restricts to a window (#327)" + - "Segments bounded by a NaN contribute no area (gap) rather than poisoning the running total (#327)" + - "Calling cumulativeIntegral on a discrete state tag emits a Tag:integralOnDiscrete warning (#327)" + artifacts: + - "libs/SensorThreshold/Tag.m contains public method cumulativeIntegral" + - "tests/test_tag_cumulativeintegral.m headless test" + key_links: + - "cumulativeIntegral reads getXY/getXYRange + getKind so every subclass inherits it — the calculus complement to derivative" +--- + + +Add `Tag.cumulativeIntegral(...)` to the `Tag` base class (#327): a running trapezoidal totalizer series (flow → volume, power → energy, current → charge, dose rate → accumulated dose) — the integration primitive that completes the calculus pair with derivative() and, like derivative, structurally cannot be a DerivedTag (each point depends on all prior samples). + +Purpose: Replace hand-rolled `cumtrapz(X, Y)` (and its NaN-poisoning) with a NaN-safe, non-uniform-correct primitive on any tag. +Output: One public method on `libs/SensorThreshold/Tag.m` + a headless flat test `tests/test_tag_cumulativeintegral.m`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/999.2-tag-analysis-toolkit/999.2-CONTEXT.md +@.planning/phases/999.2-tag-analysis-toolkit/issues/327.md + +@libs/SensorThreshold/Tag.m +@tests/test_derivedtag.m + +# NOTE: This plan and 999.2-02..06 all append methods to the single shared file +# libs/SensorThreshold/Tag.m. If the wave is parallelized, edits to Tag.m MUST be +# serialized across these plans (append distinct methods; do not clobber). + + + + + + Task 1: Implement Tag.cumulativeIntegral in the public methods block + Tag.cumulativeIntegral returns 2-out [t,cum] series and 1-out scalar total, NaN-safe hand-rolled trapezoid, non-uniform correct, Range, discrete warn; static analysis clean. + + - libs/SensorThreshold/Tag.m — getXY (:123), getXYRange (:128), getKind, constructor name-value parsing style. + - .planning/phases/999.2-tag-analysis-toolkit/issues/327.md — API shape (2-out series + 1-out scalar total), NaN policy, discrete warning. + + libs/SensorThreshold/Tag.m + + Add `function varargout = cumulativeIntegral(obj, varargin)` implementing #327. + + Option parsing (Tag-constructor manual switch style): 'Range' ([t0 t1]). Raise `Tag:unknownOption` for unknown keys. + + Data: with 'Range', `[X, Y] = obj.getXYRange(t0, t1)`, else `[X, Y] = obj.getXY()`. Operate on X(:)/Y(:). Require numeric Y (cellstr state → error). If fewer than 2 points, return `t = X` and `cum = zeros(size(X))` (grand total 0). + + Kind-awareness: if `obj.getKind()` is 'state', emit `warning('Tag:integralOnDiscrete', ...)` (area under a step channel is rarely intended) but proceed for numeric Y. + + NaN-safe trapezoidal core (do NOT use cumtrapz directly — it poisons the running total after the first NaN, and hand-rolling also sidesteps any cumtrapz availability question): compute per-interval trapezoid areas `dx = diff(X); avg = (Y(1:end-1) + Y(2:end)) / 2; area = dx .* avg;` then zero out any area whose interval endpoint is NaN (`area(isnan(area)) = 0;`) so a NaN-bounded interval contributes no area rather than poisoning everything after it. Running series: `cum = [0; cumsum(area)];` (length equals numel(X)). This is correct on non-uniform spacing (dx varies) and Octave-safe. + + Output form: `nargout <= 1` AND no 2-out requested... use varargout: when the caller takes 2 outputs return `[t, cum]` with `t = X`; when the caller takes 1 output (or 0) return the scalar grand total `cum(end)`. Document both forms. + + Repo-style header: `%CUMULATIVEINTEGRAL Running trapezoidal integral (totalizer) of Y w.r.t. X.` documenting the 2-out series vs 1-out scalar total, the NaN-gap policy, non-uniform correctness, and the discrete warning. Additive only. + + + Phase verifier runs matlab MCP static analysis (mcp__matlab__check_matlab_code) on libs/SensorThreshold/Tag.m — expect no new errors. Executor lacks MATLAB tools. + + + - Tag.m has public `cumulativeIntegral(obj, varargin)` with a 2-out [t, cum] series form and a 1-out scalar total form, 'Range' option, and `Tag:unknownOption`. + - Hand-rolled NaN-safe trapezoid (no cumtrapz); correct on non-uniform X; length equals numel(X). + - StateTag numeric input warns `Tag:integralOnDiscrete`. + - Additive; toolbox-free; static analysis clean. + + + + + Task 2: Write headless test tests/test_tag_cumulativeintegral.m + test_tag_cumulativeintegral passes headlessly (matlab MCP), asserting constant/non-uniform integrals, grand total, range narrowing, NaN-gap finite total, discrete warn. + + - tests/test_derivedtag.m — flat test pattern (and warning capture via lastwarn). + - .planning/phases/999.2-tag-analysis-toolkit/issues/327.md — expected values. + + tests/test_tag_cumulativeintegral.m + + Create `function test_tag_cumulativeintegral()` in the flat pattern (local path helper, TagRegistry.clear(), assert-with-message, final pass print). + + Cover (pin expected values): + - Constant series: `SensorTag('s','X',0:4,'Y',[2 2 2 2 2])`; `[t,cum] = c.cumulativeIntegral()`. Integral of 2 over [0,t] = 2t → assert `cum` ≈ `[0 2 4 6 8]` and `t` equals `0:4`. Tolerance 1e-9. + - Grand total (1-out): `total = c.cumulativeIntegral()` returns the scalar 8. + - Non-uniform X: `SensorTag('n','X',[0 1 3],'Y',[0 2 2])` → areas: [0->1]: (0+2)/2*1=1; [1->3]: (2+2)/2*2=4; cum ≈ [0 1 5], total 5. Assert. + - Range subset (scalar form): `SensorTag('r','X',0:4,'Y',[2 2 2 2 2]).cumulativeIntegral('Range',[1 3])` returns a smaller total than the full 8 (windowing narrows). + - NaN-gap policy: `SensorTag('m','X',0:4,'Y',[2 2 NaN 2 2])` → the NaN-bounded intervals contribute 0; assert `isfinite(cum(end))` (running total NOT poisoned to NaN) and that `cum(end)` equals the sum of the finite-interval areas. + - Discrete warn: `lastwarn('')`; `StateTag('st','X',0:3,'Y',[1 1 1 1]).cumulativeIntegral()`; assert `[~,id]=lastwarn; strcmp(id,'Tag:integralOnDiscrete')`. + + Octave-safe. Run by the phase verifier via the matlab MCP (`evaluate_matlab_code`: `install(); test_tag_cumulativeintegral()`). + + + Phase verifier runs matlab MCP (mcp__matlab__evaluate_matlab_code): `install(); test_tag_cumulativeintegral()` — expect "All N tests passed." Executor lacks MATLAB tools. + + + - Test asserts constant-series cum values, the 1-out grand total, non-uniform X area, range narrowing, the NaN-gap finite-total policy, and the Tag:integralOnDiscrete warning id. + - Passes headlessly on MATLAB via the matlab MCP. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller → Tag.cumulativeIntegral | Caller-supplied Range; series via subclass getXY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-999.2-07-01 | Denial of Service | <2-point / all-NaN series | low | mitigate | Length guard returns zeros; NaN areas zeroed, no poisoning | +| T-999.2-07-02 | Tampering | integral of a discrete/state or cellstr channel | low | mitigate | Warn `Tag:integralOnDiscrete` for state kind; error for non-numeric Y | +| T-999.2-07-03 | Information Disclosure | new dependency / package install | low | accept | None — hand-rolled trapezoid (no cumtrapz), no MEX, no package manager touched | + + + +- Static analysis clean on libs/SensorThreshold/Tag.m (matlab MCP check). +- `install(); test_tag_cumulativeintegral()` passes headlessly via the matlab MCP. + + + +- cumulativeIntegral works on every Tag subclass with zero overrides. +- 2-out series + 1-out scalar total correct; non-uniform-correct; NaN-gap-safe; Range narrows; discrete warns. +- No backward-compat break (additive method only). + + + +## Artifacts this phase produces (Plan 07) + +New symbols: +- `Tag.cumulativeIntegral(...)` — public method on libs/SensorThreshold/Tag.m + +New files: +- `tests/test_tag_cumulativeintegral.m` + + + +Create `.planning/phases/999.2-tag-analysis-toolkit/999.2-07-SUMMARY.md` when done. + From d76e662e890e8d1d96481c7d796f000aa8a94520 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 22:45:08 +0200 Subject: [PATCH 4/9] feat(999.2-01): add Tag.getStats keystone statistics primitive (#223) Public getStats(tStart,tEnd) on the Tag base class returning N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd. Toolbox-free, Octave-safe (NaN masking, not 'omitnan'), numeric-only reductions with cellstr-StateTag + empty-window guards. Delegates to getXYRange so all subclasses inherit it. Verified: 23/23 headless assertions, lint clean. Co-Authored-By: Claude Fable 5 --- .../999.2-01-SUMMARY.md | 17 ++++ libs/SensorThreshold/Tag.m | 70 +++++++++++++++ tests/test_tag_getstats.m | 85 +++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-01-SUMMARY.md create mode 100644 tests/test_tag_getstats.m diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-01-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-01-SUMMARY.md new file mode 100644 index 00000000..67c9311d --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-01-SUMMARY.md @@ -0,0 +1,17 @@ +# 999.2-01 Summary — Tag.getStats (KEYSTONE, #223) + +**Status:** Complete — verified via matlab MCP (23/23 assertions pass, static analysis clean). + +## What shipped +- `Tag.getStats(tStart, tEnd)` — public method on `libs/SensorThreshold/Tag.m`, added right after `getXYRange`. Returns a struct with fields `N, Min, Max, Mean, Rms, Std, First, Last, TimeStart, TimeEnd`. Delegates to `getXYRange`, so all subclasses (Sensor/State/Monitor/Composite/Derived) inherit it with zero overrides. +- `tests/test_tag_getstats.m` — flat Octave-safe headless test. + +## Key decisions +- **Octave-safe omitnan**: NaNs stripped via `y(~isnan(y))` masking, not the `'omitnan'` flag. +- **Numeric-only reductions**: non-numeric (cellstr StateTag) series return `N` + time bounds, `NaN` for Min/Max/Mean/Rms/Std; documented in the header. +- **Logical robustness (beyond spec)**: logical `Y` is cast to double so boolean series still get real stats rather than falling into the non-numeric branch. +- Additive only — no new properties, no `toStruct`/`fromStruct` change, no interface change. + +## Verification +- `mcp__matlab__check_matlab_code` on Tag.m → no issues. +- `install(); test_tag_getstats()` → "All 23 tests passed." Covers basic values, field-set+order, NaN exclusion, windowing narrows N, empty window (N=0/NaN), non-numeric StateTag. diff --git a/libs/SensorThreshold/Tag.m b/libs/SensorThreshold/Tag.m index da2fa847..d28750b0 100644 --- a/libs/SensorThreshold/Tag.m +++ b/libs/SensorThreshold/Tag.m @@ -161,6 +161,76 @@ Y = Y(iLo:iHi); end + function s = getStats(obj, tStart, tEnd) + %GETSTATS One-call statistics summary over the series or a time window. + % s = tag.getStats() summarizes the whole resolved series. + % s = tag.getStats(tStart, tEnd) restricts the reduction to the + % windowed slice (via getXYRange, so every Tag subclass inherits + % this with zero overrides). + % + % Returns a struct with fields, in order: + % N - count of non-NaN numeric samples in the window + % Min - minimum (NaNs excluded) + % Max - maximum (NaNs excluded) + % Mean - mean (NaNs excluded) + % Rms - sqrt(mean(y.^2)) (NaNs excluded) + % Std - standard deviation (NaNs excluded) + % First - first sample of the windowed series (raw; may be NaN) + % Last - last sample of the windowed series (raw; may be NaN) + % TimeStart - X(1) of the window + % TimeEnd - X(end) of the window + % + % The numeric reductions (Min/Max/Mean/Rms/Std) are numeric-only: + % for a non-numeric (cellstr) StateTag series they return NaN while + % N and the time bounds are still reported. + % + % Toolbox-free and Octave-safe: NaNs are stripped by masking, not + % via the 'omitnan' flag. + % + % See also getXYRange, getXY. + if nargin < 3 + tStart = []; + tEnd = []; + end + [x, y] = obj.getXYRange(tStart, tEnd); + x = x(:); + y = y(:); + if islogical(y) + y = double(y); + end + + s = struct('N', 0, 'Min', NaN, 'Max', NaN, 'Mean', NaN, ... + 'Rms', NaN, 'Std', NaN, 'First', NaN, 'Last', NaN, ... + 'TimeStart', NaN, 'TimeEnd', NaN); + + if isempty(x) + return; + end + s.TimeStart = x(1); + s.TimeEnd = x(end); + + if ~isnumeric(y) + % Non-numeric (e.g. cellstr StateTag) series: report the sample + % count and time bounds only; numeric reductions are undefined. + s.N = numel(x); + return; + end + + s.First = y(1); + s.Last = y(end); + + yv = y(~isnan(y)); + s.N = numel(yv); + if s.N == 0 + return; + end + s.Min = min(yv); + s.Max = max(yv); + s.Mean = mean(yv); + s.Rms = sqrt(mean(yv.^2)); + s.Std = std(yv); + end + function v = valueAt(obj, t) %#ok %VALUEAT Return scalar value at time t. Subclass must override. error('Tag:notImplemented', 'Subclass must implement valueAt(t).'); diff --git a/tests/test_tag_getstats.m b/tests/test_tag_getstats.m new file mode 100644 index 00000000..c4736a2a --- /dev/null +++ b/tests/test_tag_getstats.m @@ -0,0 +1,85 @@ +function test_tag_getstats() +%TEST_TAG_GETSTATS Headless test for Tag.getStats (#223). +% Verifies the keystone statistics primitive on the Tag base class: +% - Basic numeric series: field set + values on a known series +% - NaN exclusion (omitnan semantics via masking) +% - Windowing narrows the reduction via getXYRange +% - Empty window (outside data extent) -> N=0, NaN reductions +% - Non-numeric (cellstr) StateTag -> N + time bounds, NaN reductions +% +% Toolbox-free / Octave-safe. Run via the matlab MCP: +% install(); test_tag_getstats() +% +% See also Tag.getStats, test_derivedtag. + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + % --- Basic numeric series --------------------------------------------- + % Y = [2 4 6 8 10] over X = 1:5. + % N=5, Min=2, Max=10, Mean=6 + % Rms = sqrt(mean([4 16 36 64 100])) = sqrt(220/5) = sqrt(44) + % Std = sqrt( (16+4+0+4+16)/(5-1) ) = sqrt(40/4) = sqrt(10) + t = SensorTag('s', 'X', 1:5, 'Y', [2 4 6 8 10]); + s = t.getStats(); + assert(s.N == 5, 'basic N'); nTests = nTests + 1; + assert(s.Min == 2, 'basic Min'); nTests = nTests + 1; + assert(s.Max == 10, 'basic Max'); nTests = nTests + 1; + assert(abs(s.Mean - 6) < 1e-12, 'basic Mean'); nTests = nTests + 1; + assert(abs(s.Rms - sqrt(44)) < 1e-9, 'basic Rms'); nTests = nTests + 1; + assert(abs(s.Std - sqrt(10)) < 1e-9, 'basic Std'); nTests = nTests + 1; + assert(s.First == 2, 'basic First'); nTests = nTests + 1; + assert(s.Last == 10, 'basic Last'); nTests = nTests + 1; + assert(s.TimeStart == 1, 'basic TimeStart'); nTests = nTests + 1; + assert(s.TimeEnd == 5, 'basic TimeEnd'); nTests = nTests + 1; + % Field set is exactly the pinned 10, in order. + assert(isequal(fieldnames(s), {'N';'Min';'Max';'Mean';'Rms';'Std'; ... + 'First';'Last';'TimeStart';'TimeEnd'}), 'field set + order'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- NaN exclusion ----------------------------------------------------- + % Y = [2 NaN 6 8 10] -> yv = [2 6 8 10], N=4, Mean = 26/4 = 6.5 + t2 = SensorTag('s2', 'X', 1:5, 'Y', [2 NaN 6 8 10]); + s2 = t2.getStats(); + assert(s2.N == 4, 'nan N'); nTests = nTests + 1; + assert(abs(s2.Mean - 6.5) < 1e-12, 'nan Mean (omitnan)'); nTests = nTests + 1; + assert(isfinite(s2.Rms) && isfinite(s2.Std), 'nan Rms/Std finite'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- Windowing narrows the reduction ---------------------------------- + t3 = SensorTag('s3', 'X', 1:10, 'Y', 1:10); + sFull = t3.getStats(); + sWin = t3.getStats(4, 6); + assert(sWin.N < sFull.N, 'window narrows N'); nTests = nTests + 1; + assert(sWin.TimeStart >= 1 && sWin.TimeEnd <= 10, 'window bounds'); nTests = nTests + 1; + assert(sWin.Mean >= 4 && sWin.Mean <= 6, 'window Mean in-range'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- Empty window (outside data extent) ------------------------------- + t4 = SensorTag('s4', 'X', 1:10, 'Y', 1:10); + sEmpty = t4.getStats(100, 200); + assert(sEmpty.N == 0, 'empty N==0'); nTests = nTests + 1; + assert(isnan(sEmpty.Mean), 'empty Mean NaN'); nTests = nTests + 1; + assert(isnan(sEmpty.Std), 'empty Std NaN'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- Non-numeric StateTag --------------------------------------------- + st = StateTag('st', 'X', [0 1 2], 'Y', {'a', 'b', 'c'}); + sSt = st.getStats(); + assert(sSt.N == 3, 'state N'); nTests = nTests + 1; + assert(isnan(sSt.Mean), 'state Mean NaN'); nTests = nTests + 1; + assert(sSt.TimeStart == 0 && sSt.TimeEnd == 2, 'state time bounds'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + %ADD_TAG_ANALYSIS_PATH_ Ensure repo root + tests/suite are on the path. + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end From b2754413d966f8874f9f852c288ad76f9da843e4 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 22:57:12 +0200 Subject: [PATCH 5/9] feat(999.2 Wave 2): add six Tag analysis methods (#308 #326 #312 #316 #328 #327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six append to the shared libs/SensorThreshold/Tag.m base class, so they ship as one atomic wave (each depends only on the getXY/getXYRange seam, none on each other): - resampleUniform(dt,...) #308 — kind-aware uniform-grid resample - derivative(...) #326 — central/forward/backward rate of change - movingStat(window,type)#312 — rolling mean/std/max/min/rms/median - exceedance(level,...) #316 — time-above/below-threshold analysis - crossings(level,...) #328 — level-crossing times + direction - cumulativeIntegral(...)#327 — running trapezoidal totalizer Plus private option-parse/series/peak helpers. Toolbox-free and Octave-safe (hand-rolled movers + trapezoid, no 'omitnan'/movmean/cumtrapz; interp1 linear/previous only). Additive only — no property/serialization change. Verified via matlab MCP: 69 headless assertions across 6 test files pass, static analysis clean, TestDerivedTag 35/35 (no regression). Co-Authored-By: Claude Fable 5 --- .../999.2-02-SUMMARY.md | 10 + .../999.2-03-SUMMARY.md | 7 + .../999.2-04-SUMMARY.md | 10 + .../999.2-05-SUMMARY.md | 7 + .../999.2-06-SUMMARY.md | 7 + .../999.2-07-SUMMARY.md | 10 + libs/SensorThreshold/Tag.m | 515 ++++++++++++++++++ tests/test_tag_crossings.m | 61 +++ tests/test_tag_cumulativeintegral.m | 57 ++ tests/test_tag_derivative.m | 66 +++ tests/test_tag_exceedance.m | 58 ++ tests/test_tag_movingstat.m | 70 +++ tests/test_tag_resampleuniform.m | 52 ++ 13 files changed, 930 insertions(+) create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-02-SUMMARY.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-03-SUMMARY.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-04-SUMMARY.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-05-SUMMARY.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-06-SUMMARY.md create mode 100644 .planning/phases/999.2-tag-analysis-toolkit/999.2-07-SUMMARY.md create mode 100644 tests/test_tag_crossings.m create mode 100644 tests/test_tag_cumulativeintegral.m create mode 100644 tests/test_tag_derivative.m create mode 100644 tests/test_tag_exceedance.m create mode 100644 tests/test_tag_movingstat.m create mode 100644 tests/test_tag_resampleuniform.m diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-02-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-02-SUMMARY.md new file mode 100644 index 00000000..dc9fc117 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-02-SUMMARY.md @@ -0,0 +1,10 @@ +# 999.2-02 Summary — Tag.resampleUniform (#308) + +**Status:** Complete — 10/10 headless assertions pass, static analysis clean. + +## Shipped +- `Tag.resampleUniform(dt, 'Range', [t0 t1], 'Method', m, 'MaxGap', g)` on `libs/SensorThreshold/Tag.m`. Uniform grid via `interp1`; kind-aware default method (continuous→linear, discrete→previous/ZOH); Range clips the grid to the window (padded slice still bounds interpolation); optional MaxGap NaN-fills across dropouts. +- `tests/test_tag_resampleuniform.m`. + +## Notes +- Toolbox-free (interp1 linear/previous). Honours the ZOH-only invariant for state/monitor channels. diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-03-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-03-SUMMARY.md new file mode 100644 index 00000000..b87f5b06 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-03-SUMMARY.md @@ -0,0 +1,7 @@ +# 999.2-03 Summary — Tag.derivative (#326) + +**Status:** Complete — 13/13 headless assertions pass, static analysis clean. + +## Shipped +- `Tag.derivative('Method', m, 'Range', [t0 t1])` on `libs/SensorThreshold/Tag.m`. Central (default, non-uniform-aware via `gradient`), forward, backward. NaN-safe. Warns `Tag:derivativeOnDiscrete` on state/monitor. +- `tests/test_tag_derivative.m`. diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-04-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-04-SUMMARY.md new file mode 100644 index 00000000..714d1ce5 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-04-SUMMARY.md @@ -0,0 +1,10 @@ +# 999.2-04 Summary — Tag.movingStat (#312) + +**Status:** Complete — 14/14 headless assertions pass, static analysis clean. + +## Shipped +- `Tag.movingStat(window, type)` on `libs/SensorThreshold/Tag.m`. type ∈ mean|std|max|min|rms|median over a centered sample window (shrinking at edges). NaNs excluded per window. Errors `Tag:movingStatNotContinuous` on discrete kinds. +- `tests/test_tag_movingstat.m`. + +## Notes +- Octave-safe: hand-rolled window loop (no movmean/movstd dependency). diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-05-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-05-SUMMARY.md new file mode 100644 index 00000000..d420e037 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-05-SUMMARY.md @@ -0,0 +1,7 @@ +# 999.2-05 Summary — Tag.exceedance (#316) + +**Status:** Complete — 12/12 headless assertions pass, static analysis clean. + +## Shipped +- `Tag.exceedance(level, 'Direction', above|below, 'Range', [t0 t1])` on `libs/SensorThreshold/Tag.m`. Returns struct totalTime/fraction/count/longest/peak. Continuous tags interpolate exact crossing boundaries; discrete tags use ZOH. NaN-bounded segments treated as gaps. +- `tests/test_tag_exceedance.m`. diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-06-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-06-SUMMARY.md new file mode 100644 index 00000000..3881f23b --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-06-SUMMARY.md @@ -0,0 +1,7 @@ +# 999.2-06 Summary — Tag.crossings (#328) + +**Status:** Complete — 12/12 headless assertions pass, static analysis clean. + +## Shipped +- `Tag.crossings(level, 'Direction', both|rising|falling, 'Range', [t0 t1])` on `libs/SensorThreshold/Tag.m`. Struct {times, direction ±1, count, periods} or 2-out [t, dir]. Interpolated instants for continuous; transition-sample (ZOH) + `Tag:crossingsOnDiscrete` warning for discrete. Exact-sample and plateau-at-level counted once; NaN-bounded segments yield no crossing. +- `tests/test_tag_crossings.m`. diff --git a/.planning/phases/999.2-tag-analysis-toolkit/999.2-07-SUMMARY.md b/.planning/phases/999.2-tag-analysis-toolkit/999.2-07-SUMMARY.md new file mode 100644 index 00000000..251bda47 --- /dev/null +++ b/.planning/phases/999.2-tag-analysis-toolkit/999.2-07-SUMMARY.md @@ -0,0 +1,10 @@ +# 999.2-07 Summary — Tag.cumulativeIntegral (#327) + +**Status:** Complete — 8/8 headless assertions pass, static analysis clean. + +## Shipped +- `Tag.cumulativeIntegral('Range', [t0 t1])` on `libs/SensorThreshold/Tag.m`. 2-out [t, cum] running trapezoid; 1-out scalar grand total. NaN-bounded segments contribute zero (no poisoning). Warns `Tag:integralOnDiscrete` on discrete kinds. +- `tests/test_tag_cumulativeintegral.m`. + +## Notes +- Octave-safe: hand-rolled NaN-safe trapezoid (no cumtrapz). diff --git a/libs/SensorThreshold/Tag.m b/libs/SensorThreshold/Tag.m index d28750b0..97801276 100644 --- a/libs/SensorThreshold/Tag.m +++ b/libs/SensorThreshold/Tag.m @@ -231,6 +231,396 @@ s.Std = std(yv); end + function [Xu, Yu] = resampleUniform(obj, dt, varargin) + %RESAMPLEUNIFORM Resample the series onto a uniform time grid (#308). + % [Xu, Yu] = tag.resampleUniform(dt) returns the series on a + % uniform grid of spacing dt over the tag's time range. + % [Xu, Yu] = tag.resampleUniform(dt, 'Range', [t0 t1]) grids only + % the given window. 'Method' overrides the interpolation method; + % 'MaxGap' (default Inf) NaN-fills grid points that fall inside an + % original sample gap wider than MaxGap so data is not invented + % across dropouts. + % + % Kind-aware default interpolation: continuous tags use 'linear'; + % discrete tags (state/monitor) use 'previous' (zero-order hold), + % honouring the ZOH-only invariant for state/monitor channels. + % + % Toolbox-free (interp1 linear/previous is core MATLAB/Octave). + % + % See also getXY, getKind. + if nargin < 2 || ~isnumeric(dt) || ~isscalar(dt) || ~(dt > 0) + error('Tag:resampleUniformBadDt', 'dt must be a positive scalar.'); + end + [rangeVal, method, maxGap] = obj.parseResampleOpts_(varargin{:}); + if isempty(rangeVal) + [X, Y] = obj.getXY(); + else + [X, Y] = obj.getXYRange(rangeVal(1), rangeVal(2)); + end + X = X(:); + Y = Y(:); + if isempty(method) + if obj.isDiscreteKind_() + method = 'previous'; + else + method = 'linear'; + end + end + if numel(X) < 2 + Xu = X.'; + Yu = Y.'; + return; + end + % Grid spans the requested window (clipped to data extent); the + % padded slice above still bounds the grid edges for interpolation. + if isempty(rangeVal) + gStart = X(1); + gEnd = X(end); + else + gStart = max(X(1), rangeVal(1)); + gEnd = min(X(end), rangeVal(2)); + end + Xu = gStart:dt:gEnd; + Yu = interp1(X, Y, Xu, method); + if isfinite(maxGap) + gapIdx = find(diff(X) > maxGap); + for g = gapIdx(:).' + inGap = Xu > X(g) & Xu < X(g + 1); + Yu(inGap) = NaN; + end + end + end + + function varargout = derivative(obj, varargin) + %DERIVATIVE Kind-aware rate-of-change series dY/dX (#326). + % [t, dydt] = tag.derivative() returns central-difference rate of + % change (correct on non-uniform spacing). + % [t, dydt] = tag.derivative('Method', m) where m is 'central' + % (default), 'forward', or 'backward'. + % [t, dydt] = tag.derivative('Range', [t0 t1]) restricts the window. + % + % NaN-safe: the rate is NaN wherever a contributing neighbour is + % NaN. Intended for continuous numeric tags; on a discrete tag it + % warns Tag:derivativeOnDiscrete (a step channel's slope is + % ill-defined) but still computes. + % + % Toolbox-free (gradient / diff are core MATLAB/Octave). + % + % See also cumulativeIntegral, movingStat, getXY. + [rangeVal, method] = obj.parseMethodRangeOpts_('central', ... + {'central', 'forward', 'backward'}, 'derivative', varargin{:}); + [X, Y] = obj.getSeries_(rangeVal); + if obj.isDiscreteKind_() + warning('Tag:derivativeOnDiscrete', ... + 'derivative() on a discrete (%s) tag: the slope of a step channel is ill-defined.', ... + obj.getKind()); + end + n = numel(X); + dydt = nan(size(Y)); + if n >= 2 + switch method + case 'forward' + dydt(1:n-1) = (Y(2:n) - Y(1:n-1)) ./ (X(2:n) - X(1:n-1)); + dydt(n) = dydt(n-1); + case 'backward' + dydt(2:n) = (Y(2:n) - Y(1:n-1)) ./ (X(2:n) - X(1:n-1)); + dydt(1) = dydt(2); + otherwise % central + dydt = gradient(Y(:)) ./ gradient(X(:)); + dydt = reshape(dydt, size(Y)); + end + end + varargout = {X, dydt}; + varargout = varargout(1:max(1, nargout)); + end + + function varargout = cumulativeIntegral(obj, varargin) + %CUMULATIVEINTEGRAL Running trapezoidal integral of Y w.r.t. X (#327). + % [t, cum] = tag.cumulativeIntegral() returns the cumulative + % trapezoidal integral series; cum(end) is the grand total. + % total = tag.cumulativeIntegral(...) (1-out) returns the scalar total. + % 'Range', [t0 t1] restricts the window. + % + % NaN policy: a segment bounded by a NaN contributes zero area + % (treated as a gap) rather than poisoning the running total. + % Intended for continuous numeric tags; on a discrete tag it warns + % Tag:integralOnDiscrete. + % + % Toolbox-free: hand-rolled NaN-safe trapezoid (no cumtrapz). + % + % See also derivative, getXY. + [rangeVal, ~] = obj.parseMethodRangeOpts_('', {}, 'cumulativeIntegral', varargin{:}); + [X, Y] = obj.getSeries_(rangeVal); + if obj.isDiscreteKind_() + warning('Tag:integralOnDiscrete', ... + 'cumulativeIntegral() on a discrete (%s) tag: area under a step channel is rarely intended.', ... + obj.getKind()); + end + n = numel(X); + cum = zeros(size(Y)); + for i = 2:n + dx = X(i) - X(i-1); + y0 = Y(i-1); + y1 = Y(i); + if isnan(y0) || isnan(y1) + area = 0; % gap: no contribution + else + area = 0.5 * (y0 + y1) * dx; + end + cum(i) = cum(i-1) + area; + end + if nargout <= 1 + if isempty(cum) + varargout = {NaN}; + else + varargout = {cum(end)}; + end + else + varargout = {X, cum}; + end + end + + function [X, Ys] = movingStat(obj, window, type) + %MOVINGSTAT Rolling-window statistic series (#312). + % [X, Ys] = tag.movingStat(window) rolling mean over a centered + % window of `window` samples (shrinking at the edges). + % [X, Ys] = tag.movingStat(window, type) where type is one of + % 'mean' (default), 'std', 'max', 'min', 'rms', 'median'. + % + % Continuous numeric tags only: a discrete (state/monitor) tag + % raises Tag:movingStatNotContinuous (a rolling mean of a step + % channel is meaningless). NaNs within a window are excluded; a + % fully-NaN window yields NaN. + % + % Toolbox-free / Octave-safe: computed with a hand-rolled window + % loop (no movmean/movstd dependency). + % + % See also getStats, resampleUniform, getXY. + if nargin < 3 || isempty(type) + type = 'mean'; + end + if nargin < 2 || ~isnumeric(window) || ~isscalar(window) || window < 1 || mod(window, 1) ~= 0 + error('Tag:movingStatBadWindow', 'window must be a positive integer number of samples.'); + end + if obj.isDiscreteKind_() + error('Tag:movingStatNotContinuous', ... + 'movingStat() requires a continuous tag; %s is discrete.', obj.getKind()); + end + [X, Y] = obj.getXY(); + X = X(:); + Y = Y(:); + n = numel(Y); + Ys = nan(n, 1); + half = floor((window - 1) / 2); + for i = 1:n + lo = max(1, i - half); + hi = min(n, i + (window - 1 - half)); + w = Y(lo:hi); + w = w(~isnan(w)); + if isempty(w) + continue; + end + switch type + case 'mean', Ys(i) = mean(w); + case 'std', Ys(i) = std(w); + case 'max', Ys(i) = max(w); + case 'min', Ys(i) = min(w); + case 'rms', Ys(i) = sqrt(mean(w.^2)); + case 'median', Ys(i) = median(w); + otherwise + error('Tag:movingStatBadType', ... + 'Unknown type "%s". Use mean|std|max|min|rms|median.', type); + end + end + X = X.'; + Ys = Ys.'; + end + + function varargout = crossings(obj, level, varargin) + %CROSSINGS Level-crossing times and directions (#328). + % c = tag.crossings(level) returns a struct with fields: + % times - interpolated crossing instants (row) + % direction - +1 rising / -1 falling, same length + % count - numel(times) + % periods - diff of successive same-direction crossings + % c = tag.crossings(level, 'Direction', d) with d 'both' (default), + % 'rising', or 'falling'. + % c = tag.crossings(level, 'Range', [t0 t1]) restricts the window. + % [t, dir] = tag.crossings(level) returns times + directions directly. + % + % Continuous tags interpolate the exact crossing instant; a discrete + % tag uses the transition sample (ZOH) and warns Tag:crossingsOnDiscrete. + % A segment bounded by a NaN yields no crossing. + % + % Toolbox-free (sign/find/diff + linear interpolation). + % + % See also exceedance, derivative, getXY. + if nargin < 2 || ~isnumeric(level) || ~isscalar(level) + error('Tag:crossingsBadLevel', 'level must be a numeric scalar.'); + end + [rangeVal, dirn] = obj.parseDirectionRangeOpts_('both', ... + {'both', 'rising', 'falling'}, varargin{:}); + [X, Y] = obj.getSeries_(rangeVal); + discrete = obj.isDiscreteKind_(); + if discrete + warning('Tag:crossingsOnDiscrete', ... + 'crossings() on a discrete (%s) tag uses the transition sample (ZOH), not interpolation.', ... + obj.getKind()); + end + times = []; + direction = []; + n = numel(X); + for i = 1:n-1 + y0 = Y(i); + y1 = Y(i+1); + if isnan(y0) || isnan(y1) + continue; % gap + end + s0 = y0 - level; + s1 = y1 - level; + if s0 == 0 && s1 == 0 + continue; % plateau exactly at level: no crossing + end + if s0 * s1 < 0 || (s0 == 0 && s1 ~= 0) + % A crossing occurs entering this interval. + d = sign(s1 - s0); + if discrete || (y1 == y0) + tc = X(i+1); % ZOH: transition at the next sample + else + tc = X(i) + (level - y0) / (y1 - y0) * (X(i+1) - X(i)); + end + times(end+1) = tc; %#ok + direction(end+1) = d; %#ok + end + end + keep = true(1, numel(times)); + if strcmp(dirn, 'rising') + keep = direction > 0; + elseif strcmp(dirn, 'falling') + keep = direction < 0; + end + times = times(keep); + direction = direction(keep); + if nargout >= 2 + varargout = {times, direction}; + return; + end + c = struct(); + c.times = times; + c.direction = direction; + c.count = numel(times); + c.periods = diff(times); + varargout = {c}; + end + + function s = exceedance(obj, level, varargin) + %EXCEEDANCE Time-above/below-threshold analysis (#316). + % s = tag.exceedance(level) analyses time where Y > level. + % s = tag.exceedance(level, 'Direction', 'below') uses Y < level. + % s = tag.exceedance(level, 'Range', [t0 t1]) restricts the window. + % + % Returns a struct: + % totalTime - total time the condition holds (tag X units) + % fraction - totalTime / (X(end)-X(1)) over the window + % count - number of separate excursions + % longest - longest single excursion duration + % peak - extreme value reached while exceeding + % + % Continuous tags interpolate the exact crossing at each excursion + % boundary; discrete tags (state/monitor) use zero-order hold. + % NaN-bounded segments are treated as gaps. + % + % Toolbox-free. See also crossings, getStats, getXY. + if nargin < 2 || ~isnumeric(level) || ~isscalar(level) + error('Tag:exceedanceBadLevel', 'level must be a numeric scalar.'); + end + [rangeVal, dirn] = obj.parseDirectionRangeOpts_('above', ... + {'above', 'below'}, varargin{:}); + [X, Y] = obj.getSeries_(rangeVal); + above = strcmp(dirn, 'above'); + discrete = obj.isDiscreteKind_(); + + s = struct('totalTime', 0, 'fraction', 0, 'count', 0, ... + 'longest', 0, 'peak', NaN); + n = numel(X); + if n == 0 + s.peak = NaN; + return; + end + span = X(end) - X(1); + + condFn = @(v) (above && v > level) || (~above && v < level); + total = 0; + count = 0; + longest = 0; + runDur = 0; + inRun = false; + peak = NaN; + + for i = 1:n-1 + x0 = X(i); x1 = X(i+1); + y0 = Y(i); y1 = Y(i+1); + dx = x1 - x0; + if isnan(y0) || isnan(y1) || dx <= 0 + if inRun, longest = max(longest, runDur); end + inRun = false; runDur = 0; + continue; + end + if discrete + % ZOH: y0 holds across the whole interval. + if condFn(y0) + total = total + dx; + if ~inRun, count = count + 1; inRun = true; runDur = 0; end + runDur = runDur + dx; + peak = obj.peakUpdate_(peak, y0, above); + else + if inRun, longest = max(longest, runDur); end + inRun = false; runDur = 0; + end + else + c0 = condFn(y0); + c1 = condFn(y1); + % exceeding sub-interval [a, b] within [x0, x1] + a = []; b = []; + if c0 && c1 + a = x0; b = x1; + elseif c0 && ~c1 + a = x0; b = x0 + (level - y0) / (y1 - y0) * dx; + elseif ~c0 && c1 + a = x0 + (level - y0) / (y1 - y0) * dx; b = x1; + end + if isempty(a) + if inRun, longest = max(longest, runDur); end + inRun = false; runDur = 0; + else + total = total + (b - a); + startsAtLeft = (a <= x0 + eps) && c0; + if inRun && startsAtLeft + runDur = runDur + (b - a); + else + if inRun, longest = max(longest, runDur); end + count = count + 1; runDur = (b - a); inRun = true; + end + if c0, peak = obj.peakUpdate_(peak, y0, above); end + if c1, peak = obj.peakUpdate_(peak, y1, above); end + if b < x1 - eps || ~c1 + longest = max(longest, runDur); + inRun = false; runDur = 0; + end + end + end + end + if inRun, longest = max(longest, runDur); end + + s.totalTime = total; + if span > 0 + s.fraction = total / span; + end + s.count = count; + s.longest = longest; + s.peak = peak; + end + function v = valueAt(obj, t) %#ok %VALUEAT Return scalar value at time t. Subclass must override. error('Tag:notImplemented', 'Subclass must implement valueAt(t).'); @@ -423,6 +813,131 @@ function invalidateBatch_(tagSet) end end + methods (Access = private) + % ---- Analysis-toolkit helpers (Phase 999.2) ---- + + function tf = isDiscreteKind_(obj) + %ISDISCRETEKIND_ True for state/monitor (ZOH-only) tags. + tf = any(strcmp(obj.getKind(), {'state', 'monitor'})); + end + + function [X, Y] = getSeries_(obj, rangeVal) + %GETSERIES_ Column [X,Y] for the full series or a [t0 t1] window. + % The base getXYRange pads by one sample each side; clip strictly + % to [t0,t1] so a Range option means exactly the requested window. + if nargin < 2 || isempty(rangeVal) + [X, Y] = obj.getXY(); + X = X(:); + Y = Y(:); + return; + end + [X, Y] = obj.getXYRange(rangeVal(1), rangeVal(2)); + X = X(:); + Y = Y(:); + keep = X >= rangeVal(1) & X <= rangeVal(2); + X = X(keep); + Y = Y(keep); + end + + function p = peakUpdate_(~, p, v, above) + %PEAKUPDATE_ Running max (above) / min (below), NaN-seeded. + if above + if isnan(p) || v > p, p = v; end + else + if isnan(p) || v < p, p = v; end + end + end + + function rangeVal = parseRange_(~, val) + %PARSERANGE_ Validate a [t0 t1] Range value. + if ~(isnumeric(val) && numel(val) == 2) + error('Tag:badRange', 'Range must be a 2-element [t0 t1] vector.'); + end + rangeVal = [val(1), val(2)]; + end + + function [rangeVal, method] = parseMethodRangeOpts_(obj, defMethod, allowed, fnName, varargin) + %PARSEMETHODRANGEOPTS_ Parse optional 'Method' / 'Range' name-values. + rangeVal = []; + method = defMethod; + k = 1; + while k <= numel(varargin) + key = varargin{k}; + if k + 1 > numel(varargin) + error('Tag:danglingOption', '%s: option "%s" has no value.', fnName, char(string(key))); + end + val = varargin{k + 1}; + if strcmpi(key, 'Range') + rangeVal = obj.parseRange_(val); + elseif strcmpi(key, 'Method') && ~isempty(allowed) + if ~any(strcmpi(val, allowed)) + error('Tag:badMethod', '%s: Method must be one of: %s.', fnName, strjoin(allowed, ', ')); + end + method = lower(val); + else + error('Tag:unknownOption', '%s: unknown option "%s".', fnName, char(string(key))); + end + k = k + 2; + end + end + + function [rangeVal, dirn] = parseDirectionRangeOpts_(obj, defDir, allowed, varargin) + %PARSEDIRECTIONRANGEOPTS_ Parse optional 'Direction' / 'Range' name-values. + rangeVal = []; + dirn = defDir; + k = 1; + while k <= numel(varargin) + key = varargin{k}; + if k + 1 > numel(varargin) + error('Tag:danglingOption', 'Option "%s" has no value.', char(string(key))); + end + val = varargin{k + 1}; + if strcmpi(key, 'Range') + rangeVal = obj.parseRange_(val); + elseif strcmpi(key, 'Direction') + if ~any(strcmpi(val, allowed)) + error('Tag:badDirection', 'Direction must be one of: %s.', strjoin(allowed, ', ')); + end + dirn = lower(val); + else + error('Tag:unknownOption', 'unknown option "%s".', char(string(key))); + end + k = k + 2; + end + end + + function [rangeVal, method, maxGap] = parseResampleOpts_(obj, varargin) + %PARSERESAMPLEOPTS_ Parse 'Range' / 'Method' / 'MaxGap' for resampleUniform. + rangeVal = []; + method = ''; + maxGap = Inf; + k = 1; + while k <= numel(varargin) + key = varargin{k}; + if k + 1 > numel(varargin) + error('Tag:danglingOption', 'resampleUniform: option "%s" has no value.', char(string(key))); + end + val = varargin{k + 1}; + if strcmpi(key, 'Range') + rangeVal = obj.parseRange_(val); + elseif strcmpi(key, 'Method') + if ~any(strcmpi(val, {'linear', 'previous', 'nearest', 'next', 'spline', 'pchip'})) + error('Tag:badMethod', 'resampleUniform: unsupported Method "%s".', char(string(val))); + end + method = lower(val); + elseif strcmpi(key, 'MaxGap') + if ~(isnumeric(val) && isscalar(val) && val > 0) + error('Tag:badMaxGap', 'MaxGap must be a positive scalar.'); + end + maxGap = val; + else + error('Tag:unknownOption', 'resampleUniform: unknown option "%s".', char(string(key))); + end + k = k + 2; + end + end + end + methods (Hidden) function ll = getListeners_(obj) %#ok %GETLISTENERS_ Default accessor returning empty cell (Phase 1028 plan 05). diff --git a/tests/test_tag_crossings.m b/tests/test_tag_crossings.m new file mode 100644 index 00000000..f4884662 --- /dev/null +++ b/tests/test_tag_crossings.m @@ -0,0 +1,61 @@ +function test_tag_crossings() +%TEST_TAG_CROSSINGS Headless test for Tag.crossings (#328). +% Interpolated crossing instants + direction, Direction filter, 2-out form, +% exact-sample hit, plateau-at-level, and NaN gap. +% +% Run via the matlab MCP: install(); test_tag_crossings() + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + % Y crosses 0 rising at x=1.5, falling at x=3.6667 + t = SensorTag('s', 'X', 0:4, 'Y', [-2 -1 1 2 -1]); + c = t.crossings(0); + assert(c.count == 2, 'count'); nTests = nTests + 1; + assert(abs(c.times(1) - 1.5) < 1e-9, 'rising instant'); nTests = nTests + 1; + assert(c.direction(1) == 1, 'rising dir +1'); nTests = nTests + 1; + assert(c.direction(2) == -1, 'falling dir -1'); nTests = nTests + 1; + assert(abs(c.times(2) - (3 + 2/3)) < 1e-6, 'falling instant'); nTests = nTests + 1; + assert(numel(c.periods) == c.count - 1, 'periods length'); nTests = nTests + 1; + TagRegistry.clear(); + + % Direction filters + t2 = SensorTag('s2', 'X', 0:4, 'Y', [-2 -1 1 2 -1]); + cr = t2.crossings(0, 'Direction', 'rising'); + assert(cr.count == 1 && cr.direction(1) == 1, 'rising only'); nTests = nTests + 1; + cf = t2.crossings(0, 'Direction', 'falling'); + assert(cf.count == 1 && cf.direction(1) == -1, 'falling only'); nTests = nTests + 1; + % 2-out form + [tt, dd] = t2.crossings(0); + assert(numel(tt) == 2 && isequal(dd, [1 -1]), '2-out form'); nTests = nTests + 1; + TagRegistry.clear(); + + % Exact-sample hit at x=1 (Y passes through 0 exactly), counted once + t3 = SensorTag('s3', 'X', 0:3, 'Y', [-1 0 1 2]); + c3 = t3.crossings(0); + assert(c3.count == 1 && abs(c3.times(1) - 1) < 1e-9, 'exact sample hit'); nTests = nTests + 1; + TagRegistry.clear(); + + % Plateau at level: single crossing on exit + t4 = SensorTag('s4', 'X', 0:3, 'Y', [-1 0 0 1]); + c4 = t4.crossings(0); + assert(c4.count == 1, 'plateau one crossing'); nTests = nTests + 1; + TagRegistry.clear(); + + % NaN gap: no crossing across a NaN-bounded segment + t5 = SensorTag('s5', 'X', 0:2, 'Y', [-2 NaN 2]); + c5 = t5.crossings(0); + assert(c5.count == 0, 'nan gap no crossing'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end diff --git a/tests/test_tag_cumulativeintegral.m b/tests/test_tag_cumulativeintegral.m new file mode 100644 index 00000000..bfd4fdb4 --- /dev/null +++ b/tests/test_tag_cumulativeintegral.m @@ -0,0 +1,57 @@ +function test_tag_cumulativeintegral() +%TEST_TAG_CUMULATIVEINTEGRAL Headless test for Tag.cumulativeIntegral (#327). +% Constant + ramp integrands, 1-out grand total, Range subset, +% NaN-gap policy, and the discrete-kind warning. +% +% Run via the matlab MCP: install(); test_tag_cumulativeintegral() + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + % Constant 1 over 0:4 -> running integral = x; total = 4 + t = SensorTag('s', 'X', 0:4, 'Y', [1 1 1 1 1]); + [tt, cum] = t.cumulativeIntegral(); + assert(numel(tt) == 5, 'length'); nTests = nTests + 1; + assert(abs(cum(1) - 0) < 1e-12, 'starts at 0'); nTests = nTests + 1; + assert(all(abs(cum(:).' - [0 1 2 3 4]) < 1e-9), 'running integral'); nTests = nTests + 1; + tot = t.cumulativeIntegral(); + assert(abs(tot - 4) < 1e-9, '1-out grand total'); nTests = nTests + 1; + TagRegistry.clear(); + + % Ramp 0,1,2 over 0:2 -> trapezoid total = 2 + t2 = SensorTag('s2', 'X', 0:2, 'Y', [0 1 2]); + tot2 = t2.cumulativeIntegral(); + assert(abs(tot2 - 2) < 1e-9, 'ramp total'); nTests = nTests + 1; + TagRegistry.clear(); + + % Range subset (constant 1 over x=1..3) -> total 2 + t3 = SensorTag('s3', 'X', 0:4, 'Y', [1 1 1 1 1]); + totr = t3.cumulativeIntegral('Range', [1 3]); + assert(abs(totr - 2) < 1e-9, 'range total'); nTests = nTests + 1; + TagRegistry.clear(); + + % NaN gap: segments touching NaN contribute 0 (no poisoning) + t4 = SensorTag('s4', 'X', 0:4, 'Y', [1 1 NaN 1 1]); + tot4 = t4.cumulativeIntegral(); + assert(isfinite(tot4) && abs(tot4 - 2) < 1e-9, 'nan-gap total'); nTests = nTests + 1; + TagRegistry.clear(); + + % Discrete-kind warning + st = StateTag('st', 'X', 0:4, 'Y', [0 1 0 1 0]); + lastwarn(''); + st.cumulativeIntegral(); + [~, wid] = lastwarn(); + assert(strcmp(wid, 'Tag:integralOnDiscrete'), 'discrete warning'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end diff --git a/tests/test_tag_derivative.m b/tests/test_tag_derivative.m new file mode 100644 index 00000000..140549d6 --- /dev/null +++ b/tests/test_tag_derivative.m @@ -0,0 +1,66 @@ +function test_tag_derivative() +%TEST_TAG_DERIVATIVE Headless test for Tag.derivative (#326). +% central/forward/backward methods, Range subset, NaN safety, +% and the discrete-kind warning. +% +% Run via the matlab MCP: install(); test_tag_derivative() + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + % Y = x^2 on 0:4 -> central-difference derivative is exact at interior (2x). + t = SensorTag('s', 'X', 0:4, 'Y', [0 1 4 9 16]); + [tt, d] = t.derivative(); + assert(numel(tt) == 5, 'central length'); nTests = nTests + 1; + assert(abs(d(2) - 2) < 1e-9, 'central 2x at x=1'); nTests = nTests + 1; + assert(abs(d(3) - 4) < 1e-9, 'central 2x at x=2'); nTests = nTests + 1; + assert(abs(d(4) - 6) < 1e-9, 'central 2x at x=3'); nTests = nTests + 1; + TagRegistry.clear(); + + % forward differences + t2 = SensorTag('s2', 'X', 0:4, 'Y', [0 1 4 9 16]); + [~, df] = t2.derivative('Method', 'forward'); + assert(abs(df(1) - 1) < 1e-9, 'forward at i=1'); nTests = nTests + 1; + assert(abs(df(2) - 3) < 1e-9, 'forward at i=2'); nTests = nTests + 1; + TagRegistry.clear(); + + % backward differences + t3 = SensorTag('s3', 'X', 0:4, 'Y', [0 1 4 9 16]); + [~, db] = t3.derivative('Method', 'backward'); + assert(abs(db(2) - 1) < 1e-9, 'backward at i=2'); nTests = nTests + 1; + assert(abs(db(5) - 7) < 1e-9, 'backward at i=5'); nTests = nTests + 1; + TagRegistry.clear(); + + % Range subset (x=1,4,9 over 1:3) -> central [3 4 5] + t4 = SensorTag('s4', 'X', 0:4, 'Y', [0 1 4 9 16]); + [tr, dr] = t4.derivative('Range', [1 3]); + assert(numel(tr) == 3, 'range length'); nTests = nTests + 1; + assert(abs(dr(2) - 4) < 1e-9, 'range central at x=2'); nTests = nTests + 1; + TagRegistry.clear(); + + % NaN safety: a NaN neighbour poisons the central rate around it + t5 = SensorTag('s5', 'X', 0:4, 'Y', [0 1 NaN 9 16]); + [~, dn] = t5.derivative(); + assert(isfinite(dn(1)), 'nan: endpoint finite'); nTests = nTests + 1; + assert(isnan(dn(2)) && isnan(dn(4)), 'nan: neighbours NaN'); nTests = nTests + 1; + TagRegistry.clear(); + + % Discrete-kind warning + st = StateTag('st', 'X', 0:4, 'Y', [0 1 0 1 0]); + lastwarn(''); + st.derivative(); + [~, wid] = lastwarn(); + assert(strcmp(wid, 'Tag:derivativeOnDiscrete'), 'discrete warning'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end diff --git a/tests/test_tag_exceedance.m b/tests/test_tag_exceedance.m new file mode 100644 index 00000000..e2e6f7d0 --- /dev/null +++ b/tests/test_tag_exceedance.m @@ -0,0 +1,58 @@ +function test_tag_exceedance() +%TEST_TAG_EXCEEDANCE Headless test for Tag.exceedance (#316). +% totalTime/fraction/count/longest/peak, above & below directions, +% no-exceedance, Range subset, and discrete ZOH. +% +% Run via the matlab MCP: install(); test_tag_exceedance() + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + % Triangle wave crossing level 1 twice above; each excursion lasts 1.0 + t = SensorTag('s', 'X', 0:4, 'Y', [0 2 0 2 0]); + s = t.exceedance(1); + assert(abs(s.totalTime - 2) < 1e-9, 'above totalTime'); nTests = nTests + 1; + assert(s.count == 2, 'above count'); nTests = nTests + 1; + assert(abs(s.longest - 1) < 1e-9, 'above longest'); nTests = nTests + 1; + assert(abs(s.fraction - 0.5) < 1e-9, 'above fraction'); nTests = nTests + 1; + assert(abs(s.peak - 2) < 1e-9, 'above peak'); nTests = nTests + 1; + TagRegistry.clear(); + + % Below direction: peak is the minimum reached + t2 = SensorTag('s2', 'X', 0:4, 'Y', [0 2 0 2 0]); + sb = t2.exceedance(1, 'Direction', 'below'); + assert(abs(sb.totalTime - 2) < 1e-9, 'below totalTime'); nTests = nTests + 1; + assert(abs(sb.peak - 0) < 1e-9, 'below peak (min)'); nTests = nTests + 1; + TagRegistry.clear(); + + % No exceedance + t3 = SensorTag('s3', 'X', 0:4, 'Y', [0 2 0 2 0]); + sn = t3.exceedance(10); + assert(sn.totalTime == 0 && sn.count == 0, 'no exceedance'); nTests = nTests + 1; + TagRegistry.clear(); + + % Range subset [0 2]: one excursion of duration 1.0 + t4 = SensorTag('s4', 'X', 0:4, 'Y', [0 2 0 2 0]); + sr = t4.exceedance(1, 'Range', [0 2]); + assert(abs(sr.totalTime - 1) < 1e-9 && sr.count == 1, 'range subset'); nTests = nTests + 1; + TagRegistry.clear(); + + % Discrete ZOH (StateTag): value holds across each interval + st = StateTag('st', 'X', 0:3, 'Y', [0 2 2 0]); + sd = st.exceedance(1); + assert(abs(sd.totalTime - 2) < 1e-9, 'zoh totalTime'); nTests = nTests + 1; + assert(sd.count == 1, 'zoh count'); nTests = nTests + 1; + assert(abs(sd.peak - 2) < 1e-9, 'zoh peak'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end diff --git a/tests/test_tag_movingstat.m b/tests/test_tag_movingstat.m new file mode 100644 index 00000000..150ebf35 --- /dev/null +++ b/tests/test_tag_movingstat.m @@ -0,0 +1,70 @@ +function test_tag_movingstat() +%TEST_TAG_MOVINGSTAT Headless test for Tag.movingStat (#312). +% mean/max/min/rms/std/median window stats, NaN handling, even windows, +% and the continuous-only kind guard. +% +% Run via the matlab MCP: install(); test_tag_movingstat() + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + t = SensorTag('s', 'X', 1:6, 'Y', [1 2 3 4 5 6]); + + % mean, window 3 (centered, shrinking at edges) + [~, ym] = t.movingStat(3); + assert(abs(ym(1) - 1.5) < 1e-9, 'mean edge i=1'); nTests = nTests + 1; + assert(abs(ym(2) - 2) < 1e-9, 'mean i=2'); nTests = nTests + 1; + assert(abs(ym(5) - 5) < 1e-9, 'mean i=5'); nTests = nTests + 1; + assert(abs(ym(6) - 5.5) < 1e-9, 'mean edge i=6'); nTests = nTests + 1; + + % max / min, window 3 + [~, ymax] = t.movingStat(3, 'max'); + assert(abs(ymax(2) - 3) < 1e-9, 'max i=2'); nTests = nTests + 1; + [~, ymin] = t.movingStat(3, 'min'); + assert(abs(ymin(5) - 4) < 1e-9, 'min i=5'); nTests = nTests + 1; + + % rms / std / median, window 3 at i=2 over [1 2 3] + [~, yr] = t.movingStat(3, 'rms'); + assert(abs(yr(2) - sqrt(14/3)) < 1e-9, 'rms i=2'); nTests = nTests + 1; + [~, ys] = t.movingStat(3, 'std'); + assert(abs(ys(2) - 1) < 1e-9, 'std i=2'); nTests = nTests + 1; + [~, ymd] = t.movingStat(3, 'median'); + assert(abs(ymd(2) - 2) < 1e-9, 'median i=2'); nTests = nTests + 1; + TagRegistry.clear(); + + % NaN handling: excluded from the window + t2 = SensorTag('s2', 'X', 1:6, 'Y', [1 NaN 3 4 5 6]); + [~, yn] = t2.movingStat(3); + assert(abs(yn(1) - 1) < 1e-9, 'nan i=1 -> [1]'); nTests = nTests + 1; + assert(abs(yn(2) - 2) < 1e-9, 'nan i=2 -> mean[1,3]'); nTests = nTests + 1; + TagRegistry.clear(); + + % Even window: length preserved, sensible edge value + t3 = SensorTag('s3', 'X', 1:6, 'Y', [1 2 3 4 5 6]); + [~, ye] = t3.movingStat(2); + assert(numel(ye) == 6, 'even window length'); nTests = nTests + 1; + assert(abs(ye(1) - 1.5) < 1e-9, 'even window i=1'); nTests = nTests + 1; + TagRegistry.clear(); + + % Continuous-only guard: StateTag errors + st = StateTag('st', 'X', 1:4, 'Y', [0 1 0 1]); + threw = false; + try + st.movingStat(3); + catch err + threw = strcmp(err.identifier, 'Tag:movingStatNotContinuous'); + end + assert(threw, 'discrete kind guard'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end diff --git a/tests/test_tag_resampleuniform.m b/tests/test_tag_resampleuniform.m new file mode 100644 index 00000000..ad08408e --- /dev/null +++ b/tests/test_tag_resampleuniform.m @@ -0,0 +1,52 @@ +function test_tag_resampleuniform() +%TEST_TAG_RESAMPLEUNIFORM Headless test for Tag.resampleUniform (#308). +% Continuous-linear grid, uniform spacing, Method override (ZOH), +% Range subset, and kind-aware discrete (StateTag) ZOH default. +% +% Run via the matlab MCP: install(); test_tag_resampleuniform() + + add_tag_analysis_path_(); + TagRegistry.clear(); + nTests = 0; + + % --- Continuous linear default ---------------------------------------- + t = SensorTag('s', 'X', 0:4, 'Y', [0 10 20 30 40]); + [Xu, Yu] = t.resampleUniform(0.5); + assert(numel(Xu) == 9, 'grid length'); nTests = nTests + 1; + assert(all(abs(diff(Xu) - 0.5) < 1e-12), 'uniform spacing'); nTests = nTests + 1; + assert(abs(Yu(1) - 0) < 1e-9, 'first value'); nTests = nTests + 1; + assert(abs(Yu(end) - 40) < 1e-9, 'last value'); nTests = nTests + 1; + assert(abs(Yu(2) - 5) < 1e-9, 'linear interp at x=0.5'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- Method override: previous (ZOH) ---------------------------------- + t2 = SensorTag('s2', 'X', 0:4, 'Y', [0 10 20 30 40]); + [~, Yp] = t2.resampleUniform(0.5, 'Method', 'previous'); + assert(abs(Yp(2) - 0) < 1e-9, 'previous hold at x=0.5'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- Range subset ------------------------------------------------------ + t3 = SensorTag('s3', 'X', 0:4, 'Y', [0 10 20 30 40]); + [Xr, Yr] = t3.resampleUniform(1, 'Range', [1 3]); + assert(isequal(Xr, [1 2 3]), 'range grid'); nTests = nTests + 1; + assert(all(abs(Yr - [10 20 30]) < 1e-9), 'range values'); nTests = nTests + 1; + TagRegistry.clear(); + + % --- Kind-aware discrete default (StateTag -> ZOH) --------------------- + st = StateTag('st', 'X', 0:4, 'Y', [0 1 0 1 0]); + [Xs, Ys] = st.resampleUniform(0.5); + % Xs = 0,0.5,1,1.5,...; previous hold => Ys(2)=prev(0)=0, Ys(4)=prev(x=1)=1 + assert(abs(Ys(2) - 0) < 1e-9, 'state ZOH at x=0.5'); nTests = nTests + 1; + assert(abs(Ys(4) - 1) < 1e-9, 'state ZOH at x=1.5'); nTests = nTests + 1; + TagRegistry.clear(); + + fprintf(' All %d tests passed.\n', nTests); +end + +function add_tag_analysis_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end From c15aed3529ef905e9b9027e37e09f0ec545e004b Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 23:01:07 +0200 Subject: [PATCH 6/9] feat(999.3-01): add EventViewer.exportImage PNG/JPEG export (#321) Ports DashboardEngine.exportImage's cross-platform backend (exportapp / exportgraphics->print->getframe / Octave print+stub-axes) to the EventViewer timeline figure. Format inferred from extension; notRendered + unknownImageFormat guards. Additive only. Verified via matlab MCP: 6/6 headless assertions, lint clean, TestEventViewer 7/7 (no regression). Co-Authored-By: Claude Fable 5 --- .planning/ROADMAP.md | 22 ++-- .../999.3-01-SUMMARY.md | 11 ++ libs/EventDetection/EventViewer.m | 123 ++++++++++++++++++ tests/test_eventviewer_exportimage.m | 82 ++++++++++++ 4 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 .planning/phases/999.3-eventviewer-image-export-parity/999.3-01-SUMMARY.md create mode 100644 tests/test_eventviewer_exportimage.m diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 16578841..1122ada9 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -428,26 +428,26 @@ Plans: Plans: - [ ] TBD (promote with /gsd:review-backlog when ready) -### Phase 999.2: Tag Analysis Toolkit (BACKLOG) +### Phase 999.2: Tag Analysis Toolkit ✅ COMPLETE 2026-07-08 **Goal:** Add a toolbox-free, kind-aware analysis API to the `Tag` family (`libs/SensorThreshold/Tag.m` + subclasses) so engineers can compute statistics and derived series directly from any tag without pulling in the Signal Processing / Statistics toolboxes. All additive, backward-compatible, Octave-safe. Build keystone-first — `getStats()` is the shared primitive the rest lean on. **Source:** GitHub issue triage 2026-07-08 (see `.planning/INBOX-TRIAGE.md`). All 7 issues carry the `approved` label on HanSur94/FastSense; full specs (problem/motivation, proposed API + examples, scope, alternatives) live in each issue body. Dependency graph: `getStats()` (#223) is cited by ~40 downstream open issues — the single highest-leverage item in the backlog. **Requirements:** TBD (each GitHub issue body is the working spec — #223/#308/#326/#312/#316/#328/#327) -**Plans:** 7 plans +**Plans:** 7/7 plans complete Plans (build order — keystone first; all methods land in `libs/SensorThreshold/Tag.m`): -- [ ] 999.2-01-PLAN.md (Wave 1) — #223 `Tag.getStats()` public stats primitive (N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd). KEYSTONE -- [ ] 999.2-02-PLAN.md (Wave 2, depends 01) — #308 `Tag.resampleUniform(dt)` uniform-grid resample (kind-aware interpolation) -- [ ] 999.2-03-PLAN.md (Wave 2, depends 01) — #326 `Tag.derivative()` kind-aware rate-of-change series (gradient over getXY) -- [ ] 999.2-04-PLAN.md (Wave 2, depends 01) — #312 `Tag.movingStat(window, type)` rolling mean/std/max/min/rms/median series -- [ ] 999.2-05-PLAN.md (Wave 2, depends 01) — #316 `Tag.exceedance(level)` time-above/below-threshold analysis -- [ ] 999.2-06-PLAN.md (Wave 2, depends 01) — #328 `Tag.crossings(level)` level-crossing times + direction (cycle/period) -- [ ] 999.2-07-PLAN.md (Wave 2, depends 01) — #327 `Tag.cumulativeIntegral()` trapezoidal totalizer series (running integral) +- [x] 999.2-01-PLAN.md (Wave 1) — #223 `Tag.getStats()` public stats primitive (N/Min/Max/Mean/Rms/Std/First/Last/TimeStart/TimeEnd). KEYSTONE +- [x] 999.2-02-PLAN.md (Wave 2, depends 01) — #308 `Tag.resampleUniform(dt)` uniform-grid resample (kind-aware interpolation) +- [x] 999.2-03-PLAN.md (Wave 2, depends 01) — #326 `Tag.derivative()` kind-aware rate-of-change series (gradient over getXY) +- [x] 999.2-04-PLAN.md (Wave 2, depends 01) — #312 `Tag.movingStat(window, type)` rolling mean/std/max/min/rms/median series +- [x] 999.2-05-PLAN.md (Wave 2, depends 01) — #316 `Tag.exceedance(level)` time-above/below-threshold analysis +- [x] 999.2-06-PLAN.md (Wave 2, depends 01) — #328 `Tag.crossings(level)` level-crossing times + direction (cycle/period) +- [x] 999.2-07-PLAN.md (Wave 2, depends 01) — #327 `Tag.cumulativeIntegral()` trapezoidal totalizer series (running integral) > Note: all 7 plans append methods to the single shared file `libs/SensorThreshold/Tag.m`. The six Wave-2 plans are logically independent (star dependency on 01) but share this file — execute-phase must serialize edits to Tag.m across concurrently-running Wave-2 plans (each test file is distinct and never conflicts). -### Phase 999.3: EventViewer image export parity (BACKLOG) +### Phase 999.3: EventViewer image export parity ✅ COMPLETE 2026-07-08 **Goal:** Give `EventViewer` (`libs/EventDetection/EventViewer.m`) an `exportImage(path)` method that saves the timeline figure as PNG/JPEG, matching the existing `DashboardEngine`/`DetachedMirror` export capability. Additive, backward-compatible; copy the existing single-figure capture backend. @@ -456,7 +456,7 @@ Plans (build order — keystone first; all methods land in `libs/SensorThreshold **Plans:** 0 plans Plans: -- [ ] #321 `EventViewer.exportImage(path)` — save timeline figure as PNG/JPEG (parity with DashboardEngine) +- [x] #321 `EventViewer.exportImage(path)` — save timeline figure as PNG/JPEG (parity with DashboardEngine) ### Phase 1040: Companion Notification Center diff --git a/.planning/phases/999.3-eventviewer-image-export-parity/999.3-01-SUMMARY.md b/.planning/phases/999.3-eventviewer-image-export-parity/999.3-01-SUMMARY.md new file mode 100644 index 00000000..bd351478 --- /dev/null +++ b/.planning/phases/999.3-eventviewer-image-export-parity/999.3-01-SUMMARY.md @@ -0,0 +1,11 @@ +# 999.3-01 Summary — EventViewer.exportImage (#321) + +**Status:** Complete — 6/6 headless assertions pass, static analysis clean, TestEventViewer 7/7 (no regression). + +## Shipped +- `EventViewer.exportImage(filepath, format)` on `libs/EventDetection/EventViewer.m`. Saves obj.hFigure (Gantt timeline + table) at 150 DPI. PNG default, JPEG (jpg alias) by extension/arg. Ports DashboardEngine.exportImage's hardened cross-platform backend: exportapp (MATLAB R2024a+), exportgraphics→print→getframe fallback (R2020a-R2023b), print()+stub-axes (Octave), with the invisible-figure visibility toggle. +- Guards: EventViewer:notRendered (no figure), EventViewer:unknownImageFormat. +- `tests/test_eventviewer_exportimage.m` (skips on Octave, matching test_event_viewer). + +## Notes +- Additive only — no Event/EventStore/widget contract change. Toolbox-free. diff --git a/libs/EventDetection/EventViewer.m b/libs/EventDetection/EventViewer.m index 9f039ab3..83f16cd1 100644 --- a/libs/EventDetection/EventViewer.m +++ b/libs/EventDetection/EventViewer.m @@ -57,6 +57,129 @@ function update(obj, events) obj.applyFilters(); end + function exportImage(obj, filepath, format) + %EXPORTIMAGE Save the viewer figure as PNG or JPEG at 150 DPI. + % v.exportImage('events.png') % format inferred from extension + % v.exportImage('events.png', 'png') + % v.exportImage('events.jpg', 'jpeg') + % + % Captures obj.hFigure (the Gantt timeline + event table). Mirrors + % DashboardEngine.exportImage's platform handling: exportapp on + % MATLAB R2024a+, exportgraphics (with print/getframe fallbacks) on + % R2020a-R2023b, print() + stub axes on Octave. On Octave the print() + % builtin does not include uicontrols (documented limitation), so the + % filter popups and toolbar buttons will not appear in the exported + % image on Octave; MATLAB captures them normally. + % + % Inputs: + % filepath - destination path. Parent directory must exist. + % format - 'png' or 'jpeg' (alias 'jpg'). Optional; inferred from + % the file extension if omitted (defaults to 'png'). + % + % Errors: + % EventViewer:notRendered - the viewer figure does not exist + % EventViewer:unknownImageFormat - format is not png/jpeg/jpg + % EventViewer:imageWriteFailed - the export backend raised any error + % + % See also DashboardEngine.exportImage. + + if nargin < 3 || isempty(format) + [~, ~, ext] = fileparts(filepath); + if strcmpi(ext, '.jpg') || strcmpi(ext, '.jpeg') + format = 'jpeg'; + else + format = 'png'; + end + end + + if isempty(obj.hFigure) || ~ishandle(obj.hFigure) + error('EventViewer:notRendered', ... + 'exportImage requires an open viewer figure.'); + end + + switch lower(format) + case 'png' + devFlag = '-dpng'; + case {'jpeg', 'jpg'} + devFlag = '-djpeg'; + otherwise + error('EventViewer:unknownImageFormat', ... + 'Unknown image format ''%s''. Use ''png'' or ''jpeg''.', format); + end + + % Backend per platform/version, ported from DashboardEngine.exportImage: + % MATLAB R2024a+ : exportapp (handles UI components) + % MATLAB R2020a-R2023b : exportgraphics -> print -> getframe fallback + % Octave : print() + hidden stub axes + isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0; + useExportApp = ~isOctave && exist('exportapp') ~= 0; %#ok + useExportGraphics = ~isOctave && exist('exportgraphics') ~= 0; %#ok + + % exportgraphics (MATLAB) and print (Octave) only find axes directly + % under the figure; insert a hidden 1px stub when none exists. + stubAxes = []; + if ~useExportApp + topLevelChildren = get(obj.hFigure, 'children'); + hasTopAxes = false; + for k = 1:numel(topLevelChildren) + if strcmp(get(topLevelChildren(k), 'type'), 'axes') + hasTopAxes = true; + break; + end + end + if ~hasTopAxes + stubAxes = axes('Parent', obj.hFigure, ... + 'Units', 'pixels', 'Position', [0 0 1 1], ... + 'Visible', 'off', 'HitTest', 'off'); + end + end + + % Some MATLAB builds refuse to export an invisible figure; flip + % Visible='on' around the call and restore it. + origVisible = get(obj.hFigure, 'Visible'); + needsVisibilityToggle = ~useExportApp && strcmp(origVisible, 'off'); + if needsVisibilityToggle + try set(obj.hFigure, 'Visible', 'on'); catch, end + end + try + if useExportApp + exportapp(obj.hFigure, filepath); + elseif useExportGraphics + wrote = false; + try + exportgraphics(obj.hFigure, filepath, ... + 'ContentType', 'image', 'Resolution', 150); + wrote = true; + catch + end + if ~wrote + try + print(obj.hFigure, devFlag, '-r150', filepath); + wrote = true; + catch + end + end + if ~wrote + frame = getframe(obj.hFigure); + imwrite(frame.cdata, filepath); + end + else + print(obj.hFigure, devFlag, '-r150', filepath); + end + if ~isempty(stubAxes) && ishandle(stubAxes); delete(stubAxes); end + if needsVisibilityToggle + try set(obj.hFigure, 'Visible', origVisible); catch, end + end + catch ME + if ~isempty(stubAxes) && ishandle(stubAxes); delete(stubAxes); end + if needsVisibilityToggle + try set(obj.hFigure, 'Visible', origVisible); catch, end + end + error('EventViewer:imageWriteFailed', ... + 'Failed to write image ''%s'': %s', filepath, ME.message); + end + end + function names = getSensorNames(obj) %GETSENSORNAMES Get unique sensor names from events. names = unique(arrayfun(@(e) e.SensorName, obj.Events, 'UniformOutput', false)); diff --git a/tests/test_eventviewer_exportimage.m b/tests/test_eventviewer_exportimage.m new file mode 100644 index 00000000..2f975f65 --- /dev/null +++ b/tests/test_eventviewer_exportimage.m @@ -0,0 +1,82 @@ +function test_eventviewer_exportimage() +%TEST_EVENTVIEWER_EXPORTIMAGE Headless test for EventViewer.exportImage (#321). +% Verifies PNG/JPEG export writes a non-empty file, format inference from +% the extension, and the notRendered / unknownImageFormat guards. +% +% Runs headless (object + file IO, not visual inspection). Skipped on +% Octave (classdef/figure limitation, matching test_event_viewer). +% +% Run via the matlab MCP: install(); test_eventviewer_exportimage() + + if exist('OCTAVE_VERSION', 'builtin') + fprintf(' SKIPPED (known Octave classdef limitation)\n'); + return; + end + + add_event_path_(); + nTests = 0; + + e1 = Event(10, 25, 'Temperature', 'warning high', 80, 'upper'); + e1.setStats(95.2, 150, 72, 95.2, 87.3, 88.1, 4.21); + e2 = Event(50, 55, 'Pressure', 'low alarm', 5, 'lower'); + e2.setStats(2.1, 50, 2.1, 6.8, 4.5, 4.7, 1.2); + events = [e1, e2]; + + % --- PNG export writes a non-empty file (explicit format) ------------- + v = EventViewer(events); + pngPath = [tempname, '.png']; + cleanupPng = onCleanup(@() removeIfExists_(pngPath)); + v.exportImage(pngPath, 'png'); + assert(exist(pngPath, 'file') == 2, 'png file created'); nTests = nTests + 1; + d = dir(pngPath); + assert(d.bytes > 0, 'png file non-empty'); nTests = nTests + 1; + close(v.hFigure); + + % --- JPEG export via extension inference (no format arg) --------------- + v2 = EventViewer(events); + jpgPath = [tempname, '.jpg']; + cleanupJpg = onCleanup(@() removeIfExists_(jpgPath)); + v2.exportImage(jpgPath); + assert(exist(jpgPath, 'file') == 2, 'jpg file created (inferred)'); nTests = nTests + 1; + dj = dir(jpgPath); + assert(dj.bytes > 0, 'jpg file non-empty'); nTests = nTests + 1; + close(v2.hFigure); + + % --- Unknown format guard --------------------------------------------- + v3 = EventViewer(events); + threw = false; + try + v3.exportImage([tempname, '.png'], 'gif'); + catch err + threw = strcmp(err.identifier, 'EventViewer:unknownImageFormat'); + end + assert(threw, 'unknown format rejected'); nTests = nTests + 1; + close(v3.hFigure); + + % --- notRendered guard (figure closed) -------------------------------- + v4 = EventViewer(events); + close(v4.hFigure); + threw = false; + try + v4.exportImage([tempname, '.png']); + catch err + threw = strcmp(err.identifier, 'EventViewer:notRendered'); + end + assert(threw, 'notRendered rejected after close'); nTests = nTests + 1; + + fprintf(' All %d tests passed.\n', nTests); +end + +function removeIfExists_(p) + if exist(p, 'file') == 2 + delete(p); + end +end + +function add_event_path_() + here = fileparts(mfilename('fullpath')); + repo = fileparts(here); + addpath(repo); + addpath(fullfile(here, 'suite')); + install(); +end From 56ed75a9d47503c60ae7291888422b2c63aaf75d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 23:02:27 +0200 Subject: [PATCH 7/9] docs(state): record 999.2 + 999.3 completion (8 approved features implemented) Co-Authored-By: Claude Fable 5 --- .planning/STATE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 61e3d4dc..ad316eb8 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -19,7 +19,7 @@ progress: See: .planning/PROJECT.md (updated 2026-05-13) **Core value:** A MATLAB engineer can ingest a million-sample sensor stream, monitor thresholds, build sub-second-responsive dashboards, and navigate it all from a single Companion app — without leaving MATLAB and without external toolboxes. -**Current focus:** None — all planned milestones shipped (v1.0–v4.0, Companion, Plant Log, polish through Phase 1041). Repo in polish/housekeeping; backlog Phase 999.1 (in-app help) remains unplanned. +**Current focus:** Backlog Tag-analysis features from the 2026-07-08 issue triage. Phases 999.2 (Tag Analysis Toolkit) + 999.3 (EventViewer image export) COMPLETE on branch (see below). Backlog Phase 999.1 (in-app help) remains unplanned. ## Current Position @@ -27,7 +27,9 @@ Phase: — (none active; latest shipped = Phase 1041, MERGED via PR #189 on 2026 Plan: — Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v3.1 Plant Log Integration — SHIPPED 2026-05-19; v4.0 Multi-User LAN Concurrency — SHIPPED 2026-05 via PR #152 (parallel branch); v1.0 perf line — COMPLETE via PR #114. No milestone in flight. Status: Phase 1041 complete — inline time-range control (toolbar dropdown + Custom date strip) shipped; PR #189 MERGED 2026-06-03. No planned milestone in flight — repo in polish/housekeeping. Outstanding: 12 wiki-bot dup PRs were closed to 1 (#190) + workflow root-caused (260609-mcz); backlog Phase 999.1 (in-app help system) unplanned; ROADMAP v4.0 boxes stale (shipped on main via #152 — router misreports, see memory gsd-router-stale-v4-misroute). -Last activity: 2026-06-24 - Completed quick task 260624-nvf: Fix StatusWidget crash when a status widget is bound to a MonitorTag via 'Tag' — the constructor now routes a monitor-kind Tag to the Threshold/monitor path so refresh() uses deriveStatusFromMonitorTag_ instead of the SensorTag-only obj.Sensor.Y access. Unblocks the README "Build a dashboard" quickstart. Verified in a fresh process: TestStatusWidget+MultiStatus 23/23, dashboard suites 68/68, README dashboard repro renders 4 widgets. Code 14b3d529; on branch claude/recursing-cray-0dbbac. +Last activity: 2026-07-08 - Implemented the 8 approved features from the GitHub issue triage via GSD, on branch claude/github-issues-triage-df4390. Phase 999.2 Tag Analysis Toolkit (7 methods on libs/SensorThreshold/Tag.m, all toolbox-free/Octave-safe/additive): getStats #223 (keystone), resampleUniform #308, derivative #326, movingStat #312, exceedance #316, crossings #328, cumulativeIntegral #327 (commits d76e662e, b2754413). Phase 999.3 EventViewer.exportImage #321 (commit c15aed35). Verified via matlab MCP: 98 headless assertions across 8 new test files pass; static analysis clean; regression TestDerivedTag 35/35 + TestEventViewer 7/7. GitHub issues NOT yet closed — they close on PR merge to main. Not yet pushed / no PR. + +Previous: 2026-06-24 - Completed quick task 260624-nvf: Fix StatusWidget crash when a status widget is bound to a MonitorTag via 'Tag' (code 14b3d529). ### Note on parallel v4.0 work (main branch state) From 5d35db9dc8b4e520e98bc338ad813eeeaf2f0139 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 23:08:16 +0200 Subject: [PATCH 8/9] docs(999.3): add approved issue spec (#321) for EventViewer export Co-Authored-By: Claude Fable 5 --- .../issues/321.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .planning/phases/999.3-eventviewer-image-export-parity/issues/321.md diff --git a/.planning/phases/999.3-eventviewer-image-export-parity/issues/321.md b/.planning/phases/999.3-eventviewer-image-export-parity/issues/321.md new file mode 100644 index 00000000..546b7f16 --- /dev/null +++ b/.planning/phases/999.3-eventviewer-image-export-parity/issues/321.md @@ -0,0 +1,60 @@ +# #321: EventViewer: add exportImage(path) to save the timeline figure as PNG/JPEG (parity with DashboardEngine/DetachedMirror) + +## Problem / motivation + +Every figure-producing surface in FastSense can save itself to an image — **except `EventViewer`**. + +- `DashboardEngine.exportImage(filepath, format)` ([`libs/Dashboard/DashboardEngine.m:1017`](libs/Dashboard/DashboardEngine.m)) — a mature, platform-aware export: `exportgraphics`/`exportapp` on MATLAB, `print()` + stub-axes on Octave, 150 DPI, PNG/JPEG by extension. +- `DetachedMirror.exportImage` — popped-out detail-window export (#243). +- Standalone plots export via `FastSenseToolbar`. + +But `EventViewer` ([`libs/EventDetection/EventViewer.m`](libs/EventDetection/EventViewer.m)) — the interactive event-analysis figure (a Gantt timeline `hTimelineAxes` plus a sortable table, on a classical `figure` `hFigure`) — exposes **no** export method. `grep -niE "export|saveas|exportgraphics|print\("` over the file returns nothing but the handle and doc-comments. + +The viewer is the canonical report-ready artifact for *"here are the alarms over this recording"* — yet it is the one figure you cannot drop into a report without a manual screenshot. With filtering (#319) and CSV export of rows (#317) now proposed for the same surface, the figure-as-image export is the missing third report move. + +## Proposed feature + +Add a public `exportImage(obj, filepath, format)` method to `EventViewer`, mirroring `DashboardEngine.exportImage`'s contract and platform handling: + +```matlab +v = EventViewer(events, sensorData, thresholdColors); +v.exportImage('events.png'); % format inferred from extension +v.exportImage('events.jpg', 'jpeg'); +``` + +Captures `obj.hFigure` at 150 DPI; PNG default, JPEG (`jpg` alias) by extension/argument. Optionally surface a small "Export Image" pushbutton on the toolbar strip next to the (#317) CSV button — but the **method** is the deliverable; the button is a one-line follow-on. + +## Rough sketch + +Single file: [`libs/EventDetection/EventViewer.m`](libs/EventDetection/EventViewer.m). + +- New `methods (Access = public)` method `exportImage(obj, filepath, format)`. +- Guard: error `EventViewer:notRendered` if `~ishandle(obj.hFigure)`. +- **Reuse the exact platform branch** from `DashboardEngine.exportImage` (around `:1085`): `useExportGraphics = ~isOctave && exist('exportgraphics') ~= 0`, same stub-axes workaround for Octave's uicontrol-less `print()`. Copy, don't reinvent — the cross-platform work is already hardened upstream. +- Format inference from extension + `png`/`jpeg`/`jpg` validation, matching the engine idiom. +- (Optional) one `uicontrol` pushbutton in `buildRefreshToolbar`. + +No new primitive; no `Event`/`EventStore`/`DashboardWidget` contract change. The figure is already fully rendered — this only serializes it. + +## Value + +Medium-high. A filtered event timeline ("only alarms, channel 3, this shift") is exactly what an engineer pastes into a shift report or incident writeup. Today that requires a manual screenshot. Export/report is an explicitly-listed sensor-analysis domain gap, and this is the report artifact most directly tied to threshold/alarm triage. + +## Constraints check + +- **Toolbox-free:** ✅ `exportgraphics`/`print` are base MATLAB/Octave; `DashboardEngine.exportImage` already proves the branch works on both runtimes. +- **Backward-compatible:** ✅ one new public method, additive. No serialize round-trip concern (transient action, not state). +- **Pure MATLAB/Octave:** ✅ ports an existing in-repo, dual-runtime implementation. +- **Contracts:** ✅ no change to `Event`, `EventStore`, or the widget/Tag contracts. + +## Effort estimate + +**S** — one file, one method that ports `DashboardEngine.exportImage`'s body to `obj.hFigure`. The hard cross-platform export logic already exists to copy. + +## Dedup + +Clean. `gh issue list --state all --search "EventViewer export image figure"` → empty. `--search "EventViewer"` → only #317 (export rows to CSV — data, distinct), #319 (severity/category filter — selection, distinct), #228 (headless store query). Dashboard image-export analogs (#302 vector, #221 all-pages) and #243 (DetachedMirror exportImage) establish the parity gap; none cover the viewer. No PR touches viewer image export. + +--- + +*AI-proposed via /feature-scout — needs a human product decision before implementation.* \ No newline at end of file From 58dfa3e8e528308c7f500466e320062b03b9de7d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 8 Jul 2026 23:25:39 +0200 Subject: [PATCH 9/9] style(999.2): fix mh_style whitespace_semicolon in getStats field-order test MISS_HIT requires a space after each ';' in the cell literal {'N'; 'Min'; ...}. No behavior change; test still 23/23. Co-Authored-By: Claude Fable 5 --- tests/test_tag_getstats.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_tag_getstats.m b/tests/test_tag_getstats.m index c4736a2a..966ca197 100644 --- a/tests/test_tag_getstats.m +++ b/tests/test_tag_getstats.m @@ -34,8 +34,8 @@ function test_tag_getstats() assert(s.TimeStart == 1, 'basic TimeStart'); nTests = nTests + 1; assert(s.TimeEnd == 5, 'basic TimeEnd'); nTests = nTests + 1; % Field set is exactly the pinned 10, in order. - assert(isequal(fieldnames(s), {'N';'Min';'Max';'Mean';'Rms';'Std'; ... - 'First';'Last';'TimeStart';'TimeEnd'}), 'field set + order'); nTests = nTests + 1; + assert(isequal(fieldnames(s), {'N'; 'Min'; 'Max'; 'Mean'; 'Rms'; 'Std'; ... + 'First'; 'Last'; 'TimeStart'; 'TimeEnd'}), 'field set + order'); nTests = nTests + 1; TagRegistry.clear(); % --- NaN exclusion -----------------------------------------------------