diff --git a/.coverage b/.coverage deleted file mode 100644 index ec973e7..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.gitignore b/.gitignore index 06af2e7..7b8b367 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ build/ .pytest_cache/ .ruff_cache/ .mypy_cache/ +.coverage +htmlcov/ # OS .DS_Store diff --git a/docs/QA-2026-04-11_1132.md b/docs/QA-2026-04-11_1132.md new file mode 100644 index 0000000..3f8c0f1 --- /dev/null +++ b/docs/QA-2026-04-11_1132.md @@ -0,0 +1,235 @@ +# Quality Assessment -- 2026-04-11 + +> **Score: 83.7 / 100 — Grade: B+** +> Codebase remains at B+ with no material change (-0.0 points) from the previous assessment; all eight dimension scores held steady — lint is still perfect, architecture is fully exposed, and the remaining paths to A-grade (coverage ≥80%, type errors <10, fewer files >300 lines) are the same priority items identified last cycle. + +| Dimension | Score | +|-----------|-------| +| Test Health | 8.0/10 | +| Type Safety | 6.8/10 | +| Lint Hygiene | 10.0/10 | +| Architecture | 10.0/10 | +| Documentation | 9.0/10 | +| Complexity | 6.5/10 | +| Security | 8.5/10 | +| Maintainability | 8.5/10 | + +**Version:** 0.1.0 +**Assessed by:** Claude Sonnet 4.6 +**Previous assessment:** QA-2026-04-11_1054.md + +--- + +## Inventory + +| Metric | Value | +|--------|-------| +| Source files | 72 | +| Source lines | 10,575 | +| Test files | 30 | +| Test lines | 2,736 | +| Test:source ratio | 0.259 | +| Direct dependencies | 10 core + 1 optional + 4 dev | + +--- + +## Raw Metrics + +### Test Suite + +``` +src/impakt/web/components/transforms.py 18 11 39% 102-164 +src/impakt/web/state.py 152 11 93% 86, 96, 102, 112, 150, 201, 217, 223, 273-275 +------------------------------------------------------------------------------ +TOTAL 3763 1150 69% + +20 files skipped due to complete coverage. +Required test coverage of 60% reached. Total coverage: 69.44% +240 passed, 7 warnings in 16.77s +``` + +- Tests collected: 240 +- Tests passed: 240 +- Tests failed: 0 +- Test duration: 16.77s +- Coverage: 69.44% (60% floor enforced via pytest-cov) + +### Type Safety (mypy --ignore-missing-imports) + +``` +src/impakt/web/callbacks/channel_callbacks.py:103: error: Incompatible return value type (got "NoReturn", expected "str | None") [return-value] +src/impakt/web/callbacks/export_callbacks.py:85: error: Module "dash.dcc" does not explicitly export attribute "send_data_frame" [attr-defined] +src/impakt/web/callbacks/export_callbacks.py:85: error: Call to untyped function "send_data_frame" in typed context [no-untyped-call] +src/impakt/web/callbacks/cursor_callbacks.py:25: error: Call to untyped function "clientside_callback" in typed context [no-untyped-call] +Found 34 errors in 16 files (checked 72 source files) +``` + +- Total errors: 34 (unchanged from previous) +- Files with errors: 16 / 72 (78% clean) +- Top error categories: + - `[attr-defined]` 9 — attribute access on loosely typed objects (Dash stubs) + - `[no-any-return]` 6 — returning `Any` from typed functions + - `[return-value]` 4 — incompatible return type + - `[import-untyped]` 3 — third-party stubs missing + - `[assignment]` 3 — incompatible assignment + - `[var-annotated]` 2 — need type annotation + - `[valid-type]` 2 — invalid type expression + - `[no-untyped-call]` 2 — call to untyped function + - `[unused-ignore]` 1 + - `[no-untyped-def]` 1 + - `[comparison-overlap]` 1 + +### Lint (ruff) + +``` +All checks passed! +``` + +- Total violations: 0 +- Auto-fixable: N/A +- Top violation rules: none + +### Complexity + +- File size: min=1 / median=132 / mean=147 / max=692 +- Files >300 lines: 8 / 72 +- High-complexity files (branch density >15): + +``` + 80 src/impakt/io/mme.py (692 lines) -- ISO 13499 parser, justified + 44 src/impakt/web/components/criteria.py (342 lines) -- UI assembly with protocol logic + 32 src/impakt/web/callbacks/plot_callbacks.py (317 lines) -- transform pipeline orchestration + 30 src/impakt/channel/model.py (456 lines) -- core data model, multiple classes + 27 src/impakt/web/state.py (275 lines) -- app state with multi-test support + 27 src/impakt/protocol/euro_ncap.py (237 lines) -- sliding-scale scoring tables + 26 src/impakt/plot/engine.py (316 lines) -- Plotly rendering with corridors + 21 src/impakt/web/callbacks/channel_callbacks.py (236 lines) -- selection/filter callbacks + 21 src/impakt/protocol/iihs.py (179 lines) -- G/A/M/P rating logic + 20 src/impakt/web/components/channel_grid.py (418 lines) -- DataTable assembly + 19 src/impakt/script/cli.py (137 lines) -- CLI arg parsing +``` + +### Documentation + +- Docstring coverage: 420 / 457 definitions (91.9%) +- Modules with `__all__`: 11 / 11 (all modules) + - channel: YES + - criteria: YES + - io: YES + - plot: YES + - plugin: YES + - protocol: YES + - report: YES + - script: YES + - template: YES + - transform: YES + - web: YES +- README: 1,266 lines with Mermaid diagrams +- Architectural diagrams: yes + +### Security + +``` +eval/exec (all hits): + src/impakt/transform/math_expr.py:149: eval(self.expression, {"__builtins__": {}}, namespace) # noqa: S307 + +subprocess/os.system hits: + src/impakt/transform/math_expr.py:68: "subprocess", ← string in forbidden-token blocklist, NOT a call +``` + +- eval/exec (sandboxed): 1 — `math_expr.py:149`, restricted builtins `{}` + 16-token blocklist; noqa: S307 +- eval/exec (unsandboxed): 0 (grep excluding # noqa returns 0) +- subprocess: 1 confirmed false positive — line 68 is a blocklist string entry, not an invocation +- Hardcoded secrets: 0 +- Bare except: 0 + +### Maintainability + +``` +TODO: 0 +FIXME: 0 +HACK: 0 +logging calls: 50 +try/except blocks: 53 +bare excepts: 0 +internal imports: 198 +``` + +- TODO: 0 +- FIXME: 0 +- HACK: 0 +- Logging calls: 50 +- try/except blocks: 53 +- Bare excepts: 0 +- Internal imports (coupling): 198 + +--- + +## Scorecard + +| # | Dimension | Weight | Score | Weighted | Justification | +|---|-----------|--------|-------|----------|---------------| +| 1 | Test Health | 20% | 8.0/10 | 1.60 | 240/240 pass; test:source ratio 0.259 (within 0.2–0.5 band); integration tests with real datasets present; 69.44% coverage measured (below 80% ceiling for a 9+). Matches rubric row "100% pass, ratio 0.2–0.5, integration tests present" = 8. | +| 2 | Type Safety | 15% | 6.8/10 | 1.02 | mypy (ignore-missing-imports), 34 errors in 16 files — unchanged. Linear interpolation between 6 (<50 errors) and 8 (<10 errors): 6 + (50−34)/(50−10) × 2 = 6.8. | +| 3 | Lint Hygiene | 10% | 10.0/10 | 1.00 | `ruff check src/` reports 0 violations — perfect score maintained. | +| 4 | Architecture | 15% | 10.0/10 | 1.50 | Clear 4-layer design (io → transform/channel → protocol → web/plot). Plugin system present. All 11 public modules export `__all__`. No layer violations confirmed (io/transform/protocol/channel contain no imports from web or plot). | +| 5 | Documentation | 10% | 9.0/10 | 0.90 | 91.9% docstring coverage exceeds 90% threshold. Comprehensive README with Mermaid diagrams. No generated API reference (Sphinx/mkdocs), so not a full 10. | +| 6 | Complexity | 10% | 6.5/10 | 0.65 | Median 132 (<150, excellent). 8 files >300 lines (unchanged). Interpolated between score 8 (≤3 files) and 6 (≤10 files): 6 + (10−8)/(10−3) × 2 = 6.57 → 6.5. | +| 7 | Security | 10% | 8.5/10 | 0.85 | Single eval sandboxed with `{"__builtins__": {}}` + 16-item token blocklist; noqa annotation in place. Subprocess hit confirmed false positive. No secrets, no bare excepts. Interpolated between 9 (fully sandboxed) and 7 (partially sandboxed) for conservative assessment. | +| 8 | Maintainability | 10% | 8.5/10 | 0.85 | Zero debt markers. Zero bare excepts. 50 logging calls. Modern tooling (uv, hatchling, ruff, mypy). Interpolated between 10 (0 markers + all criteria) and 8 (<5 markers) at 8.5 for consistency with prior assessments; high internal coupling (198) noted. | + +### Composite Score: **83.7 / 100** +### Grade: **B+** + +Calculation: (8.0×0.20 + 6.8×0.15 + 10.0×0.10 + 10.0×0.15 + 9.0×0.10 + 6.5×0.10 + 8.5×0.10 + 8.5×0.10) × 10 += (1.60 + 1.02 + 1.00 + 1.50 + 0.90 + 0.65 + 0.85 + 0.85) × 10 += 8.37 × 10 = **83.7** + +--- + +## Delta from Previous Assessment + +| Dimension | Previous | Current | Change | +|-----------|----------|---------|--------| +| Test Health | 8.0 | 8.0 | 0.0 | +| Type Safety | 6.8 | 6.8 | 0.0 | +| Lint Hygiene | 10.0 | 10.0 | 0.0 | +| Architecture | 10.0 | 10.0 | 0.0 | +| Documentation | 9.0 | 9.0 | 0.0 | +| Complexity | 6.5 | 6.5 | 0.0 | +| Security | 8.5 | 8.5 | 0.0 | +| Maintainability | 8.5 | 8.5 | 0.0 | +| **Composite** | **83.7** | **83.7** | **0.0** | + +--- + +## Top Improvements Since Last Assessment + +1. **No regression** — All dimensions held steady; the 8-file >300-lines count is unchanged, coverage held at ~69.4%, and 34 mypy errors remain. +2. **Minor source growth** — +12 lines (10,563 → 10,575) with no new files or test changes; composition is stable. +3. **Coverage delta negligible** — 69.51% → 69.44% (−0.07 pp); within measurement noise. + +--- + +## Recommended Actions (Priority Order) + +| # | Action | Effort | Impact | Dimensions Affected | +|---|--------|--------|--------|---------------------| +| 1 | Increase test coverage from 69.44% to ≥80%: add unit tests for uncovered branches in `web/components/transforms.py` (39% covered), `plot/engine.py`, and `io/mme.py` parser edge-cases | 2–4 hr | Test Health +1.0 → 9.0 (+0.20 composite) | Test Health | +| 2 | Resolve 9 `[attr-defined]` + 6 `[no-any-return]` + 4 `[return-value]` mypy errors in the web layer to reach <10 total errors; consider adding Dash type stubs or local overrides | 1–2 hr | Type Safety +1.2 → 8.0 (+0.18 composite) | Type Safety | +| 3 | Decompose `channel_grid.py` (418 lines) and `channel/model.py` (456 lines) into focused sub-modules; extract sub-parsers from `mme.py` if it grows beyond 800 lines | 3–5 hr | Complexity +0.5 → 7.0 (+0.05 composite) | Complexity | +| 4 | Set up mkdocs-material + mkdocstrings to auto-generate API reference from existing docstrings (91.9% coverage makes this low-friction) | 1–2 hr | Documentation +1.0 → 10.0 (+0.10 composite) | Documentation | +| 5 | Replace `eval`-based math expression evaluator in `math_expr.py` with an AST-based parser (`ast.parse` + safe node visitor) to eliminate last eval usage entirely | 2–3 hr | Security +0.5 → 9.0 (+0.05 composite) | Security | + +**Projected composite after actions 1–4: ~87.0 (B+, approaching A threshold); after all 5: ~87.5 (B+)** + +--- + +## Notes + +- **All scores unchanged from QA-2026-04-11_1054.md.** The codebase added 12 source lines with no structural changes. This is a stability confirmation assessment, not a milestone improvement run. +- **Architecture qualitatively verified.** All 11 `__init__.py` files confirmed present with `__all__`. Grep for web/plot imports in io/transform/channel/protocol layers returned zero hits — no layer violations exist. +- **Security subprocess false positive persists.** `math_expr.py:68` contains `"subprocess"` as a string entry in its forbidden-token blocklist, not an invocation. The grep count of 1 is expected and benign. +- **eval sandboxing confirmed.** The single `eval` call at `math_expr.py:149` uses `{"__builtins__": {}}` (removes all builtins) plus the 16-token text blocklist scanned before evaluation. The `# noqa: S307` suppresses the ruff/bandit rule correctly. +- **Coverage measurement note.** The `--co` (collect-only) run reports 30.67% because 11 files are skipped during collection; the full run gives 69.44%. Use only the full-run number for scoring. +- **Next A-grade path.** Reaching 90+ requires: coverage ≥80% (+0.20), type errors <10 (+0.18), and generated API docs (+0.10) = minimum +0.48 composite → ~87–88. Getting to 90 additionally requires complexity improvement (more decomposition) or maintainability nudge. diff --git a/pyproject.toml b/pyproject.toml index c1cba9e..237a429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "weasyprint>=60.0", "pydantic>=2.0", "plotly-resampler>=0.11.0", - "pytz>=2026.1.post1", + "pytz>=2024.1", # required by plotly-resampler (not declared in its deps) ] [project.optional-dependencies] diff --git a/src/impakt/__pycache__/__init__.cpython-312.pyc b/src/impakt/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4b87e3f..0000000 Binary files a/src/impakt/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/__init__.cpython-312.pyc b/src/impakt/channel/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b211dfe..0000000 Binary files a/src/impakt/channel/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/code.cpython-312.pyc b/src/impakt/channel/__pycache__/code.cpython-312.pyc deleted file mode 100644 index c0cf6b7..0000000 Binary files a/src/impakt/channel/__pycache__/code.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/group.cpython-312.pyc b/src/impakt/channel/__pycache__/group.cpython-312.pyc deleted file mode 100644 index b655363..0000000 Binary files a/src/impakt/channel/__pycache__/group.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/lookup.cpython-312.pyc b/src/impakt/channel/__pycache__/lookup.cpython-312.pyc deleted file mode 100644 index a93c911..0000000 Binary files a/src/impakt/channel/__pycache__/lookup.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/model.cpython-312.pyc b/src/impakt/channel/__pycache__/model.cpython-312.pyc deleted file mode 100644 index 15ca208..0000000 Binary files a/src/impakt/channel/__pycache__/model.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/__init__.cpython-312.pyc b/src/impakt/criteria/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e3163c0..0000000 Binary files a/src/impakt/criteria/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/base.cpython-312.pyc b/src/impakt/criteria/__pycache__/base.cpython-312.pyc deleted file mode 100644 index 6a01f9d..0000000 Binary files a/src/impakt/criteria/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/chest.cpython-312.pyc b/src/impakt/criteria/__pycache__/chest.cpython-312.pyc deleted file mode 100644 index ee1e1d5..0000000 Binary files a/src/impakt/criteria/__pycache__/chest.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/clip3ms.cpython-312.pyc b/src/impakt/criteria/__pycache__/clip3ms.cpython-312.pyc deleted file mode 100644 index 8b6e70c..0000000 Binary files a/src/impakt/criteria/__pycache__/clip3ms.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/femur.cpython-312.pyc b/src/impakt/criteria/__pycache__/femur.cpython-312.pyc deleted file mode 100644 index 3503305..0000000 Binary files a/src/impakt/criteria/__pycache__/femur.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/hic.cpython-312.pyc b/src/impakt/criteria/__pycache__/hic.cpython-312.pyc deleted file mode 100644 index b6e4c7e..0000000 Binary files a/src/impakt/criteria/__pycache__/hic.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/nij.cpython-312.pyc b/src/impakt/criteria/__pycache__/nij.cpython-312.pyc deleted file mode 100644 index 59ec366..0000000 Binary files a/src/impakt/criteria/__pycache__/nij.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/tibia.cpython-312.pyc b/src/impakt/criteria/__pycache__/tibia.cpython-312.pyc deleted file mode 100644 index d3a7de8..0000000 Binary files a/src/impakt/criteria/__pycache__/tibia.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/io/__pycache__/__init__.cpython-312.pyc b/src/impakt/io/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index d68663c..0000000 Binary files a/src/impakt/io/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/io/__pycache__/mme.cpython-312.pyc b/src/impakt/io/__pycache__/mme.cpython-312.pyc deleted file mode 100644 index b427ca1..0000000 Binary files a/src/impakt/io/__pycache__/mme.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/io/__pycache__/reader.cpython-312.pyc b/src/impakt/io/__pycache__/reader.cpython-312.pyc deleted file mode 100644 index effd56c..0000000 Binary files a/src/impakt/io/__pycache__/reader.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/__init__.cpython-312.pyc b/src/impakt/plot/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 550f6f2..0000000 Binary files a/src/impakt/plot/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/cursor.cpython-312.pyc b/src/impakt/plot/__pycache__/cursor.cpython-312.pyc deleted file mode 100644 index 9436f38..0000000 Binary files a/src/impakt/plot/__pycache__/cursor.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/engine.cpython-312.pyc b/src/impakt/plot/__pycache__/engine.cpython-312.pyc deleted file mode 100644 index 12a5f3a..0000000 Binary files a/src/impakt/plot/__pycache__/engine.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/export.cpython-312.pyc b/src/impakt/plot/__pycache__/export.cpython-312.pyc deleted file mode 100644 index 81db766..0000000 Binary files a/src/impakt/plot/__pycache__/export.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/spec.cpython-312.pyc b/src/impakt/plot/__pycache__/spec.cpython-312.pyc deleted file mode 100644 index 49848d9..0000000 Binary files a/src/impakt/plot/__pycache__/spec.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/plot/engine.py b/src/impakt/plot/engine.py index 03736ce..6780eb3 100644 --- a/src/impakt/plot/engine.py +++ b/src/impakt/plot/engine.py @@ -52,9 +52,7 @@ class PlotEngine: aggregation on zoom/pan events. """ - def render( - self, spec: PlotSpec, *, resample: bool = False - ) -> go.Figure | FigureResampler: + def render(self, spec: PlotSpec, *, resample: bool = False) -> go.Figure | FigureResampler: """Render a PlotSpec into an interactive Plotly figure. Args: @@ -97,8 +95,19 @@ class PlotEngine: for corridor in spec.corridors: self._add_corridor(fig, corridor, resample=resample) + # Determine render order: non-focus traces first, focus trace last (on top) + focus_idx = spec.focus_index + if focus_idx is not None and focus_idx < 0: + focus_idx = len(spec.channels) + focus_idx # resolve negative index + + render_order = list(range(len(spec.channels))) + if focus_idx is not None and 0 <= focus_idx < len(spec.channels): + render_order.remove(focus_idx) + render_order.append(focus_idx) + # Add channel traces - for i, ch_ref in enumerate(spec.channels): + for i in render_order: + ch_ref = spec.channels[i] ch = ch_ref.channel if ch is None: continue @@ -111,12 +120,18 @@ class PlotEngine: color = style.color or DEFAULT_COLORS[i % len(DEFAULT_COLORS)] label = style.label or ch_ref.label + # Focus trace gets enhanced styling + is_focus = i == focus_idx + line_width = 2.5 if is_focus else style.line_width + if is_focus and not style.color: + color = "#ffc107" # amber — focus highlight + trace_kwargs: dict[str, Any] = { "mode": "lines", "name": label, "line": dict( color=color, - width=style.line_width, + width=line_width, dash=style.line_dash, ), "opacity": style.opacity, @@ -128,8 +143,6 @@ class PlotEngine: ) if resample: - # FigureResampler: pass full-resolution data via hf_x/hf_y - # and use Scattergl for WebGL-accelerated rendering fig.add_trace( go.Scattergl(**trace_kwargs), hf_x=ch.time, diff --git a/src/impakt/plot/spec.py b/src/impakt/plot/spec.py index abb7039..031a17f 100644 --- a/src/impakt/plot/spec.py +++ b/src/impakt/plot/spec.py @@ -173,3 +173,6 @@ class PlotSpec: compact: bool = False hovermode: str | bool = "x unified" margin: dict[str, int] | None = None + # Focus: index into channels list to render last (on top) with + # thicker line. None means no focus. -1 means last channel. + focus_index: int | None = None diff --git a/src/impakt/protocol/__pycache__/__init__.cpython-312.pyc b/src/impakt/protocol/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 675423a..0000000 Binary files a/src/impakt/protocol/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/base.cpython-312.pyc b/src/impakt/protocol/__pycache__/base.cpython-312.pyc deleted file mode 100644 index 9f52416..0000000 Binary files a/src/impakt/protocol/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/euro_ncap.cpython-312.pyc b/src/impakt/protocol/__pycache__/euro_ncap.cpython-312.pyc deleted file mode 100644 index b49b9eb..0000000 Binary files a/src/impakt/protocol/__pycache__/euro_ncap.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/iihs.cpython-312.pyc b/src/impakt/protocol/__pycache__/iihs.cpython-312.pyc deleted file mode 100644 index a6f69b5..0000000 Binary files a/src/impakt/protocol/__pycache__/iihs.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/us_ncap.cpython-312.pyc b/src/impakt/protocol/__pycache__/us_ncap.cpython-312.pyc deleted file mode 100644 index b4b0b44..0000000 Binary files a/src/impakt/protocol/__pycache__/us_ncap.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/script/__pycache__/__init__.cpython-312.pyc b/src/impakt/script/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f399c8a..0000000 Binary files a/src/impakt/script/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/script/__pycache__/api.cpython-312.pyc b/src/impakt/script/__pycache__/api.cpython-312.pyc deleted file mode 100644 index b56ab85..0000000 Binary files a/src/impakt/script/__pycache__/api.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/__init__.cpython-312.pyc b/src/impakt/template/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 8cb7202..0000000 Binary files a/src/impakt/template/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/library.cpython-312.pyc b/src/impakt/template/__pycache__/library.cpython-312.pyc deleted file mode 100644 index 8d6444e..0000000 Binary files a/src/impakt/template/__pycache__/library.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/model.cpython-312.pyc b/src/impakt/template/__pycache__/model.cpython-312.pyc deleted file mode 100644 index b7c4111..0000000 Binary files a/src/impakt/template/__pycache__/model.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/session.cpython-312.pyc b/src/impakt/template/__pycache__/session.cpython-312.pyc deleted file mode 100644 index 89cfe2f..0000000 Binary files a/src/impakt/template/__pycache__/session.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/__init__.cpython-312.pyc b/src/impakt/transform/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 55f9aac..0000000 Binary files a/src/impakt/transform/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/align.cpython-312.pyc b/src/impakt/transform/__pycache__/align.cpython-312.pyc deleted file mode 100644 index 941d836..0000000 Binary files a/src/impakt/transform/__pycache__/align.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/base.cpython-312.pyc b/src/impakt/transform/__pycache__/base.cpython-312.pyc deleted file mode 100644 index c503ab5..0000000 Binary files a/src/impakt/transform/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/cfc.cpython-312.pyc b/src/impakt/transform/__pycache__/cfc.cpython-312.pyc deleted file mode 100644 index b2a387c..0000000 Binary files a/src/impakt/transform/__pycache__/cfc.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/math_expr.cpython-312.pyc b/src/impakt/transform/__pycache__/math_expr.cpython-312.pyc deleted file mode 100644 index 8ccc3d6..0000000 Binary files a/src/impakt/transform/__pycache__/math_expr.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/resample.cpython-312.pyc b/src/impakt/transform/__pycache__/resample.cpython-312.pyc deleted file mode 100644 index 991e9ef..0000000 Binary files a/src/impakt/transform/__pycache__/resample.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/resultant.cpython-312.pyc b/src/impakt/transform/__pycache__/resultant.cpython-312.pyc deleted file mode 100644 index 71b2504..0000000 Binary files a/src/impakt/transform/__pycache__/resultant.cpython-312.pyc and /dev/null differ diff --git a/src/impakt/web/callbacks/plot_callbacks.py b/src/impakt/web/callbacks/plot_callbacks.py index a2a95f6..ad4882f 100644 --- a/src/impakt/web/callbacks/plot_callbacks.py +++ b/src/impakt/web/callbacks/plot_callbacks.py @@ -38,10 +38,6 @@ logger = logging.getLogger(__name__) # Module-level engine instance (stateless, safe to reuse) _engine = PlotEngine() -# Server-side storage for the current FigureResampler instance. -# Single-user desktop app — no multi-tenant isolation needed. -_current_resampler: FigureResampler | None = None - def _build_transform_chain( cfc_value: str, @@ -160,19 +156,16 @@ def _build_plot_spec( cursor_x1: float | None, cursor_x2: float | None, corridors: list[dict[str, Any]] | None = None, - focus_key: str | None = None, - focus_channel: tuple[str, Channel] | None = None, + focus_index: int | None = None, ) -> PlotSpec: """Build a PlotSpec from resolved channels and UI state. Channels are already transformed — they are wrapped in ChannelRef - objects with no additional transform chain (transforms were applied - during resolution). + objects with no additional transform chain. - If ``focus_channel`` is provided, it is rendered last (on top) with - a thicker line so it stands out from pinned channels. + ``focus_index`` is the index into ``channels`` of the focused channel. + PlotEngine renders this trace last (on top) with enhanced styling. """ - # Build ChannelRef objects — pinned channels first (thinner) refs: list[ChannelRef] = [] for i, (label, ch) in enumerate(channels): color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)] @@ -183,20 +176,6 @@ def _build_plot_spec( ) ) - # Add focus channel last so it renders on top - if focus_channel is not None: - f_label, f_ch = focus_channel - refs.append( - ChannelRef( - channel=f_ch, - style=PlotStyle( - label=f_label, - color="#ffc107", # amber — matches focus highlight in grid - line_width=2.5, - ), - ) - ) - # Build Corridor objects from raw dicts corridor_objs: list[Corridor] = [] if corridors: @@ -226,13 +205,13 @@ def _build_plot_spec( corridors=corridor_objs, x_cursors=x_cursors, y_label=y_label, - compact=True, # Web UI always uses compact mode + compact=True, + focus_index=focus_index, ) def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: """Register all plot-related callbacks.""" - global _current_resampler @app.callback( Output({"type": "plot-graph", "index": 0}, "figure"), @@ -280,31 +259,45 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: show_resultant, ) - # Resolve focus channel (if not already pinned) - focus_channel: tuple[str, Channel] | None = None - if focus_key and focus_key not in (selected_keys or []): - focus_resolved = _resolve_channels( - [focus_key], - app_state, - cfc_value, - y_align, - x_align_method or "none", - x_align_value, - False, # no resultant for single focus - ) - if focus_resolved: - focus_channel = focus_resolved[0] + # Resolve focus: if focus_key is one of the selected channels, + # find its index. If it's not selected, add it to the channels list + # and set focus_index to the new last position. + focus_idx: int | None = None + if focus_key: + # Check if focus is already in the selected channels + for i, (_, ch) in enumerate(channels): + ch_key = f"{ch.source_test_id}::{ch.name}" if ch.source_test_id else ch.name + if ch_key == focus_key: + focus_idx = i + break + + # If not in selected, resolve and append + if focus_idx is None: + focus_resolved = _resolve_channels( + [focus_key], + app_state, + cfc_value, + y_align, + x_align_method or "none", + x_align_value, + False, + ) + if focus_resolved: + channels.append(focus_resolved[0]) + focus_idx = len(channels) - 1 spec = _build_plot_spec( - channels, cursor_x1, cursor_x2, corridors_data, - focus_key=focus_key, - focus_channel=focus_channel, + channels, + cursor_x1, + cursor_x2, + corridors_data, + focus_index=focus_idx, ) fig = _engine.render(spec, resample=True) - # Store the resampler for relayoutData callbacks + # Store the resampler in AppState for relayoutData callbacks if isinstance(fig, FigureResampler): - _current_resampler = fig + app_state.current_resampler = fig return fig @@ -319,6 +312,6 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: FigureResampler intercepts relayoutData events and returns a Patch that updates only the visible trace data — no full re-render. """ - if _current_resampler is None or relayout_data is None: + if app_state.current_resampler is None or relayout_data is None: return no_update - return _current_resampler.construct_update_data_patch(relayout_data) + return app_state.current_resampler.construct_update_data_patch(relayout_data) diff --git a/src/impakt/web/state.py b/src/impakt/web/state.py index 8c81ec0..552703f 100644 --- a/src/impakt/web/state.py +++ b/src/impakt/web/state.py @@ -37,6 +37,9 @@ class AppState: self._active_template: TemplateSpec | None = None # Per-channel transform overrides: {channel_key: {"cfc": "600"}} self.channel_overrides: dict[str, dict[str, Any]] = {} + # Current FigureResampler instance for zoom/pan resampling. + # Stored here (not as a module global) so it's per-AppState. + self.current_resampler: Any = None # Active corridors self.corridors: list[dict[str, Any]] = [] diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index c55cf72..0000000 Binary files a/tests/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index fcbd984..0000000 Binary files a/tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_integration.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_integration.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index 38fe396..0000000 Binary files a/tests/__pycache__/test_integration.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_real_mme.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_real_mme.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index cacbf14..0000000 Binary files a/tests/__pycache__/test_real_mme.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_channel/__pycache__/__init__.cpython-312.pyc b/tests/test_channel/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e3e0006..0000000 Binary files a/tests/test_channel/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/test_channel/__pycache__/test_code.cpython-312-pytest-9.0.3.pyc b/tests/test_channel/__pycache__/test_code.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index c00f8ef..0000000 Binary files a/tests/test_channel/__pycache__/test_code.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_channel/__pycache__/test_model.cpython-312-pytest-9.0.3.pyc b/tests/test_channel/__pycache__/test_model.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index e1dd913..0000000 Binary files a/tests/test_channel/__pycache__/test_model.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_criteria/__pycache__/__init__.cpython-312.pyc b/tests/test_criteria/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5a7d8a2..0000000 Binary files a/tests/test_criteria/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/test_criteria/__pycache__/test_hic.cpython-312-pytest-9.0.3.pyc b/tests/test_criteria/__pycache__/test_hic.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index b780c20..0000000 Binary files a/tests/test_criteria/__pycache__/test_hic.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_criteria/__pycache__/test_nij.cpython-312-pytest-9.0.3.pyc b/tests/test_criteria/__pycache__/test_nij.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index cb09721..0000000 Binary files a/tests/test_criteria/__pycache__/test_nij.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_io/__pycache__/__init__.cpython-312.pyc b/tests/test_io/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index cbc7274..0000000 Binary files a/tests/test_io/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/test_io/__pycache__/test_mme.cpython-312-pytest-9.0.3.pyc b/tests/test_io/__pycache__/test_mme.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index e74c6c3..0000000 Binary files a/tests/test_io/__pycache__/test_mme.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_protocol/__pycache__/__init__.cpython-312.pyc b/tests/test_protocol/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1f596d9..0000000 Binary files a/tests/test_protocol/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/test_protocol/__pycache__/test_euro_ncap.cpython-312-pytest-9.0.3.pyc b/tests/test_protocol/__pycache__/test_euro_ncap.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index fcf2a51..0000000 Binary files a/tests/test_protocol/__pycache__/test_euro_ncap.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_transform/__pycache__/__init__.cpython-312.pyc b/tests/test_transform/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6e10da0..0000000 Binary files a/tests/test_transform/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/test_transform/__pycache__/test_align.cpython-312-pytest-9.0.3.pyc b/tests/test_transform/__pycache__/test_align.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index 4240c67..0000000 Binary files a/tests/test_transform/__pycache__/test_align.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_transform/__pycache__/test_cfc.cpython-312-pytest-9.0.3.pyc b/tests/test_transform/__pycache__/test_cfc.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index ae1423e..0000000 Binary files a/tests/test_transform/__pycache__/test_cfc.cpython-312-pytest-9.0.3.pyc and /dev/null differ diff --git a/uv.lock b/uv.lock index f20c5ef..1562517 100644 --- a/uv.lock +++ b/uv.lock @@ -567,7 +567,7 @@ requires-dist = [ { name = "plotly", specifier = ">=5.18" }, { name = "plotly-resampler", specifier = ">=0.11.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pytz", specifier = ">=2026.1.post1" }, + { name = "pytz", specifier = ">=2024.1" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "scipy", specifier = ">=1.10" }, { name = "weasyprint", specifier = ">=60.0" },