diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md new file mode 100644 index 00000000..46c60843 --- /dev/null +++ b/.ai/AGENTS.md @@ -0,0 +1,86 @@ +# Agent Workflow Guide + +This repository uses a context-first vibecoding workflow. + +## Primary operating model + +- Prefer one primary agent per task. +- Do not simulate a company org chart such as PM -> Architect -> Developer -> QA handoffs. +- If parallel exploration is needed, use it only to gather alternatives or inspect different parts of the codebase, then merge the findings back into one implementation path. +- Preserve continuity through repository files, not chat memory. + +## Files to read before making changes + +Read these before planning or editing code: + +1. `README.md` +2. `AGENTS.md` +3. `.ai/SESSION.md` +4. `.ai/TASK.md` +5. `.ai/DECISIONS.md` +6. `Cursor.md` + +If one of these files is missing or clearly stale, say so explicitly. + +## Required pre-change behavior + +Before changing code: + +1. Read the relevant context files. +2. Inspect the files most likely involved in the task. +3. Summarize your understanding in 5 bullets. +4. State assumptions, risks, and missing information. +5. Propose the smallest useful next step. + +Do not start with a broad rewrite when a smaller validated slice is possible. + +## Implementation rules + +- Prefer small, testable patches over large rewrites. +- Reuse existing patterns before inventing new abstractions. +- Keep functions and modules focused. +- Avoid touching unrelated modalities unless the task requires it. +- Explain why a structural change is necessary before making it. +- Preserve working flows while extending unfinished ones. + +## Verification rules + +After a meaningful code change: + +- Check for import errors. +- Check the directly affected execution path. +- Note likely regressions or edge cases. +- Report what was verified and what was not verified. + +## Handoff rules + +At the end of a meaningful work session, update: + +- `.ai/SESSION.md` with current status, blockers, next step, and touched files. +- `.ai/DECISIONS.md` with any durable technical decision and the reason for it. + +Keep those files short, current, and specific. + +## Task sizing + +Break work into slices with clear acceptance criteria. + +Good: +- add DTA page shell +- wire upload flow +- render first valid plot +- add invalid-file error path + +Bad: +- finish entire Dash migration + +## Review mode + +When asked to review, critique, or verify: + +- look for broken assumptions +- identify edge cases +- name regression risks +- suggest the smallest correction first + +Do not default to rewriting everything. diff --git a/.ai/BUGS.md b/.ai/BUGS.md new file mode 100644 index 00000000..5942cde4 --- /dev/null +++ b/.ai/BUGS.md @@ -0,0 +1,285 @@ +# Bugs — MaterialScope + +Tracked defects and suspected problems. **Root cause is never “proven” here without evidence**—update fields as facts change. + +## Status definitions (use exactly these meanings) + + +| Status | Meaning | +| ------------- | -------------------------------------------------------------------------------------------- | +| **Suspected** | Not yet reproduced or otherwise confirmed; hypothesis or report only. | +| **Open** | Reproduced **or** otherwise confirmed; **not fixed** yet. | +| **Fixed** | A fix **was applied**; still **needs verification** (tests and/or agreed manual check). | +| **Closed** | Fix **verified**; no further work on this entry unless it regresses (then open a new entry). | + + +Do **not** use alternate labels (e.g. “mitigated”, “in progress”) in new entries—fold that into **Open** or **Fixed** per the table. + +**Workflow / verification discipline:** `**.cursor/rules/00-workflow.mdc`**. + +--- + +## Entry template (copy below the line) + +``` +### Title + + +### Status +Suspected | Open | Fixed | Closed + +### Symptoms +Observable facts only. + +### Repro steps +Numbered steps; note branch/environment/data when relevant. + +### Likely cause +Hypotheses; update after evidence. + +### Files involved +Known paths, or “unknown until traced”. + +### Next check +Smallest test, log, or experiment to advance status. +``` + +--- + +## BUG-006 — i18n key namespace leakage across DSC, DTA, TGA Dash pages + +### Title + +DSC/DTA processing history labels borrowed TGA i18n keys; TGA quality/metadata/summary labels borrowed DSC keys + +### Status + +**Closed** + +### Symptoms + +- DSC processing history card showed TGA-namespaced keys (`dash.analysis.tga.processing.*`) for history title, undo button, status text. +- DTA processing history card used the same TGA-namespaced keys. +- TGA quality card, raw metadata panel, and analysis summary used DSC-namespaced keys (`dash.analysis.dsc.quality.*`, `dash.analysis.dsc.raw_metadata.*`, `dash.analysis.dsc.summary.*`). +- TR locale users would see correct translations only if the borrowed namespace happened to have the same values; semantically incorrect ownership. + +### Repro steps + +1. Open DSC analysis page; switch locale to TR; observe processing history labels. +2. Check source: `rg "dash.analysis.tga.processing." dash_app/pages/dsc.py dash_app/pages/dta.py`. +3. Open TGA analysis page; observe quality card and metadata labels. +4. Check source: `rg "dash.analysis.dsc.quality." dash_app/pages/tga.py`. + +### Likely cause + +During initial DSC/DTA history card implementation, TGA processing history i18n keys were reused as a shortcut. During TGA page buildout, DSC quality/metadata/summary keys were reused similarly. No regression tests existed to catch cross-namespace borrowing. + +### Files involved + +- `utils/i18n.py` — 28 new keys added (7 DSC, 7 DTA, 5 TGA quality, 3 TGA raw metadata, 6 TGA summary) +- `dash_app/pages/dsc.py` — 7 key references swapped +- `dash_app/pages/dta.py` — 7 key references swapped +- `dash_app/pages/tga.py` — 18 key references swapped +- `tests/test_dsc_dash_page.py` — 2 new regression tests +- `tests/test_dta_dash_page.py` — 2 new regression tests +- `tests/test_tga_dash_page.py` — 2 new regression tests + +### Next check + +Regression tests now guard against re-introduction: source-grep tests assert no leaked namespace literals; monkeypatch tests assert correct namespace in rendered output. + +--- + +## BUG-001 — Possible parity gaps during Streamlit → Dash migration + +### Title + +Possible parity / behavior gaps between legacy Streamlit flows and Dash + Plotly surfaces + +### Status + +**Suspected** + +### Symptoms + +Users **may** see mismatches (missing views, different defaults, divergent plots/metrics, inconsistent file/workspace handling) **if** Streamlit and Dash paths diverge. No specific workflow is confirmed in this entry. + +### Repro steps + +1. Pick a workflow that **actually exists** on both paths (verify in app/docs). +2. Same inputs (project, files, settings); compare outputs/affordances; record differences. + +*(If no dual path exists, note that—do not force a repro.)* + +### Likely cause + +**Unknown** until a concrete case exists; candidates include UI duplication, incomplete callbacks, API differences, or intentionally deferred features. + +### Files involved + +**Unknown** until traced; usual suspects live under `dash_app/`, `backend/`, `core/`, and any remaining Streamlit entrypoints—confirm, do not guess. + +### Next check + +One workflow: document parity with evidence, **or** promote a **narrow** child entry to **Open** with a confirmed repro and file pointers. + +--- + +## BUG-002 — Saved analysis figures missing in exports/project persistence + +### Title + +Saved results intermittently lacked persisted analysis figures in shared state + +### Status + +**Closed** + +### Symptoms + +- Graph visible in Dash analysis page but missing in exported PDF/DOCX. +- Saved result artifacts lacked reliable figure linkage. +- Project save/load did not consistently carry figure payloads forward. + +### Repro steps + +1. Run real Dash app (`python -m dash_app.server`) on branch `web-dash-plotly-migration`. +2. Execute analysis and save result. +3. Export report (DOCX/PDF) and inspect figure presence. +4. Save/load project and check `figure_count` + result artifacts linkage. + +### Likely cause + +Shared figure persistence depended too heavily on UI capture callback success; backend save path did not guarantee figure registration at result-save time. + +### Files involved + +- `backend/app.py` +- `core/figure_render.py` +- `dash_app/components/analysis_page.py` +- `dash_app/pages/dta.py` + +### Next check + +Monitor for regressions in additional modalities during future Dash slices. + +--- + +## BUG-003 — Branding logo upload lacked immediate pre-save feedback + +### Title + +No immediate UI confirmation after logo selection before Save Branding + +### Status + +**Closed** + +### Symptoms + +- Selecting a logo file showed no visible uploaded/selected state until Save Branding completed. + +### Repro steps + +1. Open Export page branding panel. +2. Select a logo file in upload control. +3. Observe no pending selection indicator before saving. + +### Likely cause + +Preview area was populated only by backend-loaded persisted branding state; no callback rendered pending upload contents. + +### Files involved + +- `dash_app/pages/export.py` + +### Next check + +Keep pending/saved branding distinction consistent if additional branding fields get staged UI behavior. + +--- + +## BUG-004 — FTIR science chain produced misleading baseline, flat normalized trace, and silent zero peaks + +### Title + +FTIR preprocessing / peak detection / matching produced scientifically misleading results without diagnostic context + +### Status + +**Closed** + +### Symptoms + +- Baseline was a straight line between first and last data points even for `asls`/`rubberband` methods, producing obviously wrong sloped baselines. +- Normalization could collapse into a near-flat line around zero with no explanation. +- Peak count was 0 for transmittance data because troughs were never inverted. +- Query / smoothed / normalized traces were semantically misaligned (peak detection ran on normalized even when it was broken). +- No diagnostic context when preprocessing failed; "No Match" hid deeper science problems. + +### Repro steps + +1. Import FTIR dataset with transmittance unit or with strong spectral features. +2. Run analysis with default `ftir.general` template. +3. Observe baseline overlay, normalized trace, and peak count in results. + +### Likely cause + +- `_estimate_spectral_baseline` claimed to support `asls`/`rubberband` but only drew a linear line. +- `_normalize_spectral_signal` had no guard-rails for zero-range or near-flat input. +- No absorbance/transmittance role detection; pipeline always looked for positive maxima. +- `_detect_spectral_peaks` used a hand-rolled local-maximum scanner instead of `scipy.signal.find_peaks`. +- Failure modes were silent; no diagnostics propagated to validation or UI. + +### Files involved + +- `core/batch_runner.py` +- `backend/library_cloud_service.py` +- `backend/models.py` +- `backend/app.py` +- `dash_app/pages/ftir.py` + +### Next check + +Monitor for regressions in Raman (shares `_execute_spectral_batch`) and future peak-detector extensions. + +--- + +## BUG-005 — Raman Dash inherited generic literature/reasoning path and FTIR warning wording + +### Title + +Raman used generic scientific-context path and could surface FTIR-labelled spectral warnings + +### Status + +**Closed** + +### Symptoms + +- Raman literature compare path did not use Raman-specialized query/compare semantics. +- Raman scientific context could fall back to generic placeholder reasoning. +- Shared spectral warnings could contain FTIR-prefixed wording on Raman runs. + +### Repro steps + +1. Run Raman analysis and inspect literature context/reasoning payload outputs. +2. Trigger warning-producing Raman spectral conditions (e.g., baseline suppression / normalization skip). +3. Observe terminology and dispatch behavior. + +### Likely cause + +Raman was still wired through generic literature/reasoning branches in core logic, and shared spectral warning strings were hardcoded with FTIR label text. + +### Files involved + +- `core/raman_literature_query_builder.py` +- `core/literature_compare.py` +- `core/scientific_reasoning.py` +- `core/batch_runner.py` +- `dash_app/pages/raman.py` +- `utils/i18n.py` + +### Next check + +Covered by targeted tests for Raman Dash page, literature compare, and scientific reasoning; watch for regressions when shared spectral helpers change. diff --git a/.ai/Cursor.md b/.ai/Cursor.md new file mode 100644 index 00000000..7abda084 --- /dev/null +++ b/.ai/Cursor.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. \ No newline at end of file diff --git a/.ai/DECISIONS.md b/.ai/DECISIONS.md new file mode 100644 index 00000000..3df0f88a --- /dev/null +++ b/.ai/DECISIONS.md @@ -0,0 +1,479 @@ +# Decisions — MaterialScope + +**This file is the only durable log for design, architecture, and workflow commitments.** Session notes belong in **`.ai/SESSION.md`**; slice completion in **`.ai/TASK.md`**; defects in **`.ai/BUGS.md`**. Process details: **`.cursor/rules/00-workflow.mdc`**. + +--- + +## 2026-04-24 — Post-parity runtime/library stabilization stays test-local; production strictness is preserved + +**Decision:** + +1. The remaining post-parity failures in backend API, batch, and reference-library tests are treated as **test isolation problems first**, not as evidence that production runtime/library behavior should be relaxed. +2. Test modules that exercise library/runtime configuration must clear **both** MaterialScope and legacy ThermoAnalyzer env vars and use tmp-path scoped homes so ambient `.env` or developer-machine state cannot change expectations. +3. Where tests depend on env re-reads, they must reset the managed cloud client singleton instead of relying on stale process-global state. +4. Tests that expect fallback-library semantics (for example FTIR `no_match`) must explicitly provision the fallback library state they depend on; they must not rely on whatever library availability the local machine happens to expose. +5. Production strict cloud behavior remains unchanged: no broadening of dev override behavior, no weakening of cloud auth requirements, and no scientific matching threshold changes were made in this stabilization slice. + +**Reason:** The last 6 failures after Dash parity closeout were caused by test environment leakage and an under-specified fallback-library test case. Changing runtime behavior would have risked weakening strict production/cloud semantics to compensate for non-deterministic tests. + +**Consequence / future:** Future runtime/library tests should treat environment, local storage roots, and singleton reset behavior as part of the test fixture contract. If a real production runtime bug appears later, it should be fixed directly with a narrowly scoped repro rather than folded into general-purpose test bootstrapping. + +--- + +## 2026-04-24 — P2 completed: spectral display polish stays UI-only + +**Decision:** + +1. FTIR/Raman raw-quality panels use shared spectral exploration helpers, but retain modality-owned i18n prefixes and Setup-tab placement. +2. FTIR/Raman plot settings are **UI-only** display state. They do not enter processing drafts, presets, `/analysis/run` payloads, saved result processing, or backend schemas. +3. The existing `-result-figure` slot remains the stable figure display/capture contract; plot settings only alter the Plotly figure rendered inside that slot. +4. DSC preset dirty tracking is implemented as loaded-preset UI snapshots for the existing apply/save flow, not as a preset API/schema change. + +**Reason:** The remaining P2 gaps were consistency and regression-coverage issues, not backend behavior gaps. Keeping spectral display settings client-side closes Streamlit/Dash UX parity without polluting scientific processing provenance. + +**Consequence / future:** Future display-only chart controls should remain separate from processing drafts unless they change analysis output. If plot settings later need persistence, add an explicit UI preference surface rather than reusing analysis presets. + +--- + +## 2026-04-23 — P1-2 completed: figure artifact toolbar is shared UI while result slots remain stable + +**Decision:** + +1. Manual figure artifact controls are standardized across all six Dash modalities as **Snapshot** and **Report figure** actions. +2. The existing `-result-figure` slot remains the stable display/capture contract; toolbar surfaces wrap the slot instead of renaming or replacing it. +3. Shared code for figure artifacts lives in `dash_app/components/figure_artifacts.py` and is limited to pure UI/metadata helpers. Page modules keep Dash callbacks, API calls, and result-specific orchestration. +4. Artifact panels refresh only on latest-result changes and successful explicit snapshot/report actions; failed/skipped explicit actions update inline status without refetching artifacts. +5. Figure extraction from Dash component trees now follows visual order (parent before descendants, children left-to-right/top-to-bottom) so primary result graphs are captured before later debug/secondary graphs. + +**Reason:** XRD had the clearest user-facing figure artifact controls, but other modalities only had background auto-capture. Standardizing the toolbar improves report UX while preserving existing auto-capture resilience and avoiding a backend API/schema change. + +**Consequence / future:** New analysis pages should use the shared figure artifact surface and keep their result figure slot stable. Any future debug-export feature should be explicit; default report figures remain primary/result-oriented. + +--- + +## 2026-04-24 — P1-3/P1-4 shared boilerplate extraction stays pure UI/helper first + +**Decision:** + +1. Shared boilerplate extraction in this pass is limited to exact duplicated numeric coercion helpers and pure Dash UI builders. +2. New shared modules are `dash_app/components/processing_inputs.py` for exact coercion helpers and `dash_app/components/analysis_boilerplate.py` for reusable history cards, preset cards, collapsible details, compatible quality cards, and compatible split raw metadata panels. +3. Public Dash contracts remain page-owned: component IDs, callback Inputs/Outputs/States, result ordering, existing i18n keys, persistence semantics, and backend payloads must not change as part of this extraction. +4. Callback orchestration, dirty-state logic, preset lifecycle callbacks, run callbacks, figure behavior, and modality-specific quality/raw metadata variants stay page-local until a later, separately planned pass. +5. Helpers that render quality or metadata must receive modality-specific i18n prefixes/keys, user-facing metadata key sets, and formatter behavior as arguments; shared helpers must not hardcode modality wording. + +**Reason:** The six Dash modality pages had substantial repeated layout and coercion code, but callback flows and some result panels differ enough that broad extraction would increase regression risk. Pure helper extraction reduces maintenance burden while preserving behavior. + +**Consequence / future:** Future boilerplate cleanup should only extract callback or lifecycle logic after proving identical contracts across pages. DTA's `_coerce_float_non_negative(..., minimum=...)` remains local because it is a semantic variant, not an exact duplicate. + +--- + +## 2026-04-22 — P0-5 completed: DSC mass normalization is a first-class processing step with default-ON backward compatibility + +**Decision:** + +1. DSC mass normalization is implemented as a first-class **`normalization`** processing section, not as ad-hoc UI state or `method_context`. +2. The Dash DSC page exposes the control in the **Setup** tab, but the selected value persists end-to-end through **processing draft defaults, hydration, preset save/load, undo/redo/reset history, and `/analysis/run` processing overrides**. +3. Backend DSC batch execution in `core/batch_runner.py` honors **`signal_pipeline.normalization.enabled`** explicitly and records the resolved normalization state in saved processing payloads. +4. The default remains **enabled / on** so that introducing the control does not silently change existing scientific behavior for current templates and saved flows. + +**Reason:** The missing Dash control was a real parity gap, but the pre-existing backend behavior already normalized DSC by mass whenever sample mass was available. Making normalization explicit while preserving the old effective default closes the parity gap without introducing silent output drift. + +**Consequence / future:** Any future DSC UX/report/export work should treat normalization state as part of the authoritative processing trace. If product later wants to change the default, that must be treated as an intentional behavior change and called out separately. + +--- + +## 2026-04-22 — P0-5 planning: DSC mass normalization will be user-controlled but default ON + +**Decision:** + +1. DSC Dash will gain an explicit **Normalize by mass** control, exposed in the **Setup** flow, but the effective normalization state must persist end-to-end through **draft/default state, preset save/load, undo/redo/reset history, run payloads, and backend execution**. +2. The default for the new control is **enabled / on** unless an existing saved draft, preset, or other persisted processing payload explicitly records a different value. +3. Backend DSC execution must **honor the selected value explicitly** instead of always normalizing whenever sample mass exists. +4. This slice is intended to **close the Dash control gap without introducing a silent scientific behavior regression** for existing datasets and saved flows that already depend on today's effective always-normalized behavior. + +**Reason:** Users requested the missing Dash control parity, but switching the default to opt-in would silently change current DSC results for datasets with recorded sample mass. Preserving the current effective default while making the behavior explicit is the smallest safe P0 fix. + +**Consequence / future:** DSC processing and saved-result traces should record whether mass normalization was enabled so later UX/reporting work can surface the basis clearly. A future product decision may still change the default, but that would be a deliberate behavior change rather than an incidental side-effect of adding the control. + +--- + +## 2026-04-22 — P0-4 completed: FTIR/Raman similarity metric is template-first and backend-honored end-to-end + +**Decision:** + +1. FTIR and Raman Dash pages expose a similarity metric selector with exactly **`cosine`** and **`pearson`** options; no additional spectral matching metrics are introduced in this slice. +2. Metric defaults are **template-first**, with **`cosine`** as the fallback when a template does not specify one. Raman template defaults may intentionally differ by workflow (for example, polymorph-oriented Raman templates may default to `pearson`). +3. The selected metric is part of the persisted processing state and must flow through **processing drafts, hydration, dirty tracking, preset save/load, undo/redo/reset, and `/analysis/run` processing overrides**. +4. Backend spectral matching must honor the selected metric in **both** local ranking (`core/batch_runner.py::_rank_spectral_matches`) and cloud spectral search (`backend/library_cloud_service.py::search_spectral` + `backend/models.py::SpectralLibrarySearchRequest`). UI exposure without backend propagation is not considered complete. + +**Reason:** Streamlit already exposed cosine/pearson selection for spectral matching, while Dash FTIR/Raman lacked that control and previously relied on backend behavior that effectively hardcoded the metric path. End-to-end parity required both UI persistence and backend enforcement. + +**Consequence / future:** Future spectral-matching work must treat `metric` as a first-class processing parameter for FTIR/Raman. If new metrics are added later, they must be reflected consistently in templates, preset payloads, batch/cloud ranking paths, and targeted regression tests. + +--- + +## 2026-04-22 — Repo-wide Dash vs Streamlit parity audit: remediation backlog agreed + +**Decision:** + +1. All 6 analysis modalities (DSC, TGA, DTA, FTIR, Raman, XRD) are at **near parity** vs Streamlit. No modality is regressed or first-slice only. +2. Prioritized remediation backlog agreed in execution order: + - **P0-2 + P0-3 (done):** i18n key namespace leakage — DSC/DTA borrowing TGA processing history keys; TGA borrowing DSC quality/metadata/summary keys. 28 new keys, 32 reference swaps, 6 regression tests. + - **P1-1 (done):** CSS class namespace cleanup — 5 modalities use `dsc-result-*` CSS classes; only DTA uses own-prefixed classes. Prerequisite for per-modality styling. + - **P0-1 (done):** Baseline method gap — DSC/DTA now expose the full core-supported baseline set; DTA callback wiring and DSC/DTA baseline i18n were completed. + - **P0-4 (done):** Similarity metric selector — FTIR/Raman now expose cosine/pearson metric selection with template-first defaults and backend-honored local/cloud matching. + - **P0-5:** DSC mass normalization — missing "Normalize by mass" control present in Streamlit. + - **P1-2:** Figure capture toolbar — only XRD has snapshot/report toolbar; port to other modalities. + - **P1-3 + P1-4:** Shared boilerplate extraction — duplicated coercion helpers, preset cards, quality cards, metadata panels across 6 pages. + - **P2 items:** Polish, naming, and remaining consistency. + +**Reason:** Systematic modality-by-modality audit across 18 dimensions (page shell, processing controls, presets, undo/redo, run gating, results, quality, figure, literature, i18n, CSS) revealed no blocking gaps but several high-visibility consistency issues, especially for TR locale users seeing wrong-modality labels. + +**Consequence / future:** P0-2+P0-3, P1-1, and P0-1 are landed; remaining items follow the agreed order. CSS cleanup (P1-1) unblocks per-modality styling work, and the baseline parity fix establishes the full thermal baseline-method surface as the Dash default. + +--- + +## 2026-04-21 — Raman Dash page promoted to full product-grade analysis shell + +**Decision:** + +1. Raman Dash page is now structured to match the mature modality pages (FTIR/DSC/TGA/DTA): explicit **Setup / Processing / Run** tabs on the left, and standardized results surface ordering on the right (summary → metrics → quality → figure → top-match → peak cards → table → processing → raw metadata → literature). +2. Raman processing state follows the shared draft lifecycle pattern: normalized draft store, control hydration, undo/redo/reset history, preset load/save/save-as/delete, and `processing_overrides` forwarding through `/analysis/run`. +3. Raman page strings are sourced from **`dash.analysis.raman.*`** namespace so modality-specific UX text does not depend on TGA/FTIR copy. + +**Reason:** Raman had a first-slice page shape and incomplete parity versus the product-grade analysis pages, creating UX inconsistency and feature gaps during the Streamlit → Dash migration. + +**Consequence / future:** Raman now shares the same operational model as other mature pages, so future enhancements (new controls, richer cards, capture/export polish) can follow the established cross-modality callback/store patterns. + +--- + +## 2026-04-21 — Raman-specific literature + scientific reasoning paths (no generic fallback in normal Raman flow) + +**Decision:** + +1. Add deterministic Raman query payload builder in [`core/raman_literature_query_builder.py`](core/raman_literature_query_builder.py), including modality-first Raman terminology, evidence snapshot, display terms, and “query-too-narrow” guard. +2. Route `analysis_type == "RAMAN"` to dedicated `_compare_raman_result_to_literature` in [`core/literature_compare.py`](core/literature_compare.py), mirroring FTIR-level traceability (`executed_queries`, provider state, surfaced comparison ranking) with Raman-specific relevance/posture/evidence-scope logic. +3. Add `_build_raman_reasoning` and dispatcher branch in [`core/scientific_reasoning.py`](core/scientific_reasoning.py) so Raman records avoid generic “not specialized” reasoning output. +4. In shared spectral batch flow ([`core/batch_runner.py`](core/batch_runner.py)), warning strings are modality-aware (`FTIR` vs `RAMAN`) and Raman method-context signal flags are persisted to prevent cross-modality labeling leakage. + +**Reason:** Raman previously inherited generic literature/reasoning behavior and could surface FTIR wording in shared spectral warnings, reducing interpretability and modality trust. + +**Consequence / future:** Raman literature/reasoning semantics now evolve independently of generic fallback paths; FTIR and Raman can share architecture while preserving modality-specific language and scoring behavior. + +--- + +## 2026-04-21 — Combined Dash server: library cloud URL bootstrap + POSIX Windows-path env sanitation + +**Decision:** + +1. **`python -m dash_app.server`** runs **`sanitize_library_path_env_vars`** then **`apply_combined_dash_server_library_env`** (see [`core/library_combined_bootstrap.py`](core/library_combined_bootstrap.py)) **before** importing [`backend/app.py`](backend/app.py), so the first `ManagedLibraryCloudService` sees corrected `MATERIALSCOPE_LIBRARY_CLOUD_URL`. Opt out with **`MATERIALSCOPE_LIBRARY_DISABLE_COMBINED_BOOTSTRAP=1`**. +2. **Defaults:** unset cloud URL → `http://:` (loopback when listening on `0.0.0.0`); loopback URL stuck on **port 8000** while the combined server listens on another port → rewrite to the listen port (Docker `.env` vs Dash dev mismatch). +3. **POSIX safety:** hosted/mirror env values that look like pasted Windows paths are **removed at startup** (sanitize) and **ignored in resolution** ([`core/path_env.py`](core/path_env.py), [`core/hosted_library.py`](core/hosted_library.py), [`core/reference_library.py`](core/reference_library.py)) with logging. +4. **Diagnostics:** [`core/spectral_library_diagnostics.py`](core/spectral_library_diagnostics.py) is the shared snapshot builder; [`tools/ftir_library_diagnostics.py`](tools/ftir_library_diagnostics.py) wraps it for CLI/NDJSON. + +**Reason:** WSL/Linux dev failed FTIR matching with `library_unavailable` due to cloud client pointing at **8000** while the combined app served on **8050**, plus malformed **Windows-derived hosted root** paths; fixes belonged in startup and path resolution, not the FTIR UI. + +**Consequence / future:** Standalone **`python -m backend.main`** on 8000 is unchanged. Split-process / production layouts should set **`MATERIALSCOPE_LIBRARY_CLOUD_URL`** explicitly to the real API origin. + +--- + +## 2026-04-21 — Dash: validation warning counts from list payloads; FTIR page i18n under `dash.analysis.ftir.*` + +**Decision:** + +1. **Counts:** UI warning and issue **counts** (saved-run banner from `interpret_run_result`, FTIR validation/quality panel badges and numeric lines) use **`len(validation["warnings"])`** and **`len(validation["issues"])`** via **`finalized_validation_warning_issue_counts`** in [`dash_app/components/analysis_page.py`](dash_app/components/analysis_page.py). Stale **`warning_count`** / **`issue_count`** integers must not override the lists when they disagree. +2. **FTIR Dash copy:** All user-visible FTIR processing/preset/quality/raw-metadata/baseline chrome on [`dash_app/pages/ftir.py`](dash_app/pages/ftir.py) reads **`dash.analysis.ftir.*`** keys in [`utils/i18n.py`](utils/i18n.py), not **`dash.analysis.tga.*`** or DSC thermal baseline strings, so the page stays modality-correct without scattering one-off string edits. + +**Reason:** Users saw TGA/thermal wording and °C on FTIR; run banner and quality panel could disagree on warning totals; library-off states read like chemistry failures. + +**Consequence / future:** Other modality pages can reuse **`finalized_validation_warning_issue_counts`** in their quality builders for the same guarantee. Raman (or others) still borrowing TGA preset keys should get their own **`dash.analysis.raman.*`** (or shared neutral) namespace when touched. + +--- + +## 2026-04-21 — FTIR literature compare: dedicated query builder + thermal-style compare path + +**Decision:** + +1. **Routing:** `analysis_type == "FTIR"` uses **`_compare_ftir_result_to_literature`**, not the generic per-claim path — same structural guarantees as DSC/DTA/TGA (single search pool, ranked surfacing, merged surfaced comparisons, rich `LiteratureContext` including **`executed_queries`**). +2. **Queries:** **`core/ftir_literature_query_builder.build_ftir_literature_query`** builds modality-first text from real summary/row evidence (including **`matched_peak_pairs`** → wavenumber display terms when present). When **`match_status == library_unavailable`**, queries target **FTIR methodology / library practice**, not a fabricated top identification. +3. **Relevance:** FTIR uses explicit **modality** phrases plus **distractor penalties** (off-topic domains) so irrelevant hits are less likely to dominate; **no IR modality mention in source text → non_validating** posture. +4. **Scientific reasoning:** **`_build_ftir_reasoning`** supplies FTIR-native claims (library unavailable vs matched vs no_match); the generic “not specialized for this analysis type yet” branch is **not** used for FTIR. +5. **Context normalization:** **`LiteratureContext.executed_queries`** is passed through **`normalize_literature_context`** and normalized in **`to_dict`** so technical-details UI stays truthful for all modalities using executed query lists. + +**Reason:** FTIR previously fell through generic literature + generic reasoning, producing placeholder copy and weak retention semantics. End-to-end FTIR-specific inputs restore product trust without a new FTIR literature card layout. + +**Consequence / future:** **RAMAN** still uses the generic literature path today; a small follow-up can reuse the FTIR builder/compare with modality strings swapped. Raw-quality exploration was removed from the FTIR page; undo/redo helpers remain in `ftir_explore.py`. + +--- + +## 2026-04-21 — FTIR follow-up: adaptive prominence, normalized plot gating, `library_unavailable` match status + +**Decision:** + +1. **Spectral peak prominence** (`_detect_spectral_peaks`): combine the configured absolute prominence with a **data-driven floor** from `ptp(signal)` so the same nominal threshold does not over-filter when the working Y scale is smaller (e.g. normalized basis). If the first `find_peaks` pass returns nothing, a **second pass** uses a lowered prominence derived from `min(effective×fraction, ptp×fraction, cfg×fraction)` so visible features can still be retained without unbounded sensitivity on the first pass. +2. **Normalized on the main figure:** The batch runner records **`normalized_axis_ratio_vs_corrected`** and **`plot_normalized_primary_axis`** (true only if normalization is informative *and* normalized peak-to-peak is at least ~**2.2%** of corrected peak-to-peak). The Dash FTIR figure **omits** the normalized trace when the flag is explicitly false; peak marker Y positions use the **corrected** (display) trace at the nearest wavenumber index, not the detection-basis intensity alone. +3. **Match status vocabulary:** Add **`library_unavailable`** when **`ranked_matches` is empty** and library **source/mode** indicates the reference corpus was **not configured or unavailable** — distinct from **`no_match`**, which means candidates were ranked but none met the acceptance threshold. Serialization and validation emit **`spectral_library_unavailable`** caution semantics for the former. +4. **Default overlays:** When **corrected** exists, **hide smoothed** on the FTIR figure (intermediate only if corrected is absent); show **baseline** only alongside corrected; keep imported + query as the primary interpretive traces. +5. **Literature technical headings:** Collapsible section titles use **`literature_t`** with a human fallback so missing per-modality keys cannot render as raw key strings; FTIR gains explicit **`dash.analysis.ftir.literature.technical_*`** strings. + +**Reason:** Users saw under-detection, a flat normalized line dominating scale/marker logic, crowded overlays, “No match” read as chemistry when the library was missing, and leaked i18n keys in FTIR literature technical blocks. Backend remains source of truth; UI reflects diagnostics and summary semantics. + +**Consequence / future:** Raman shares `_execute_spectral_batch` and inherits prominence + normalized diagnostics; only the FTIR Dash figure policy was specialized. Reports or exports that branch on `match_status` should treat **`library_unavailable`** as a **provenance/tooling** outcome, not a spectral similarity failure. + +--- + +## 2026-04-21 — FTIR science chain: signal-role-aware pipeline with baseline validation and normalization guards + +**Decision:** The spectral batch runner (`core/batch_runner.py::_execute_spectral_batch`) now: +1. Infers FTIR signal role from `dataset.units["signal"]` / `dataset.metadata["inferred_signal_unit"]` (*absorbance* / *transmittance* / *unknown*). +2. Inverts transmittance signals (`max – signal`) before smoothing/baseline so troughs become peaks; records `ftir_inverted_for_transmittance` in processing context. +3. Uses real `pybaselines` ASLS/rubberband with optional region weights instead of the previous fake linear-through-endpoints implementation. +4. Validates the baseline fit: rejects it if variance increases >50% or corrected range collapses to <2% of original range; falls back to zero baseline and suppresses the trace. +5. Guards normalization: skips it when signal has zero range, zero norm, or the result would be near-flat; falls back to showing the corrected spectrum. +6. Replaces the hand-rolled peak scanner with `scipy.signal.find_peaks`; auto-fallback to 20% prominence when strict threshold yields nothing. +7. Surfaces all failure modes as FTIR-specific warnings in validation + a `diagnostics` dict in analysis state (exposed via `AnalysisStateCurvesResponse`). +8. The Dash page reads diagnostics and suppresses invalid traces instead of plotting them; legend labels append “(inverted)” when applicable. + +**Reason:** The previous pipeline produced visibly absurd baselines, misleading flat normalized traces, and zero peaks with no explanation. Signal-role ignorance caused transmittance troughs to be discarded. Fixing the backend chain and making the frontend reflect backend truth is more robust than client-side rescue logic. + +**Consequence / future:** Raman shares the same `_execute_spectral_batch` path and benefits automatically. Future peak controls (width, SNR) should extend the new `find_peaks`-based detector. Cloud search endpoint (`backend/library_cloud_service.py`) was updated to call the new signatures. + +--- + +## 2026-04-20 — FTIR analysis page: backend-truth peaks via `analysis_state_curves`; deferred overlay preview + +**Decision:** Extend `AnalysisStateCurvesResponse` and `analysis_state_curves` endpoint to return `normalized` signal and `peaks` for FTIR, with safe `_peak_to_dict` conversion so DSC/DTA `ThermalPeak` dataclass objects do not cause Pydantic serialization errors. The Dash page renders backend-truth peaks directly from this endpoint instead of re-detecting client-side. The top-match overlay preview graph (candidate signal plotted over sample signal) is explicitly deferred because the backend does not expose a candidate/reference signal endpoint today; the Dash page ships a strong text hero summary instead. + +**Reason:** Keeps peak display consistent with backend analysis; avoids client/server drift. Overlay preview requires a new backend API for candidate spectral signals — out of scope for this slice. + +**Consequence / future:** When a candidate signal endpoint exists, the overlay preview can be added with minimal frontend-only changes. Other spectral modalities (Raman, XRD) should follow the same backend-truth curves contract. + +--- + +## 2026-04-20 — FTIR preset payload: workflow template + full processing draft + +**Decision:** FTIR preset save/load stores both `workflow_template_id` and the complete FTIR processing draft (baseline, normalization, smoothing, peak detection, similarity matching) inside the `processing` envelope, matching the existing preset API contract. On load, the page hydrates template, controls, draft, snapshot baseline, and dirty state. On run, `processing_overrides` is built only from the UI draft store. + +**Reason:** Presets must be meaningful for FTIR; storing only the workflow template would lose all processing context. Reuses the TGA pattern (unit mode in `method_context`) but applies it to the full FTIR draft. + +**Consequence / future:** Any new analysis page with a processing draft should follow the same preset envelope pattern: template id + full draft in `processing`. + +--- + +## 2026-04-20 — FTIR controls scope: only expose backend-supported parameters + +**Decision:** The FTIR Dash page exposes only `prominence`, `distance`, `max_peaks` for peak detection and `top_n`, `minimum_score` for similarity matching — the exact parameters the backend `processing_schema` and batch runner support today. Width, threshold, and other advanced peak controls are omitted until the backend detector is extended. + +**Reason:** Prevents user confusion from controls that would be ignored or would fail validation on the backend. Keeps the UI honest about backend capability. + +**Consequence / future:** When the backend adds new processing parameters (e.g., peak width, SNR threshold), the Dash page can expand its controls and preset draft model incrementally. + +--- + +## 2026-04-20 — Thermal Dash pages: Processing history card + neutral Reset styling + +**Decision:** DSC and DTA expose a **Processing history** card on the Processing tab (before presets) with Undo, Redo, and Reset to defaults, mirroring TGA: one merged callback per page driven by `dash.callback_context.triggered_id`, updating `processing-draft`, undo/redo stacks, and a small `*-history-status` line. Smoothing (and other section) chrome callbacks no longer own undo/redo/reset **button label** outputs—those live in dedicated `render_*_processing_history_chrome` callbacks. **Reset to defaults** uses Bootstrap **`secondary` outline** (same as Undo/Redo), not **`warning`**, so reset is not confused with validation severity. + +**Reason:** Parity across TGA/DSC/DTA; yellow/warning is reserved for validation and alerts. + +**Consequence / future:** Any new analysis page with a processing draft + undo stack should reuse this card + merged handler pattern for predictable wiring and theming. + +--- + +## 2026-04-20 — TGA Dash presets: unit mode inside `processing.method_context`; overrides-only run + +**Decision:** On the TGA Dash page, persist **declared unit mode** in preset `processing.method_context` (`tga_unit_mode_declared` / `tga_unit_mode_label`) together with `smoothing` and `step_detection`, because the SQLite preset envelope only stores `workflow_template_id` + `processing` (no separate `unit_mode` column). On **Run**, continue sending `unit_mode` as the existing `/analysis/run` field when not `auto`, and send **`processing_overrides`** built only from smoothing + step_detection (normalized from the UI draft store). + +**Reason:** Matches the backend preset store contract and `processing_overrides` merge rules (`update_processing_step` / `method_context`); avoids a backend migration while still making presets meaningful for TGA. + +**Consequence / future:** DSC/DTA preset pages can follow the same pattern for any “run-time” field not in the preset envelope. Reuse `_apply_processing_overrides` semantics: Dash should only emit override sections the backend accepts for that modality. + +--- + +## 2026-04-20 — TGA processing draft sync: `prevent_initial_call="initial_duplicate"` + +**Decision:** The callback that writes `tga-processing-draft` from control `Input`s uses `Output(..., allow_duplicate=True)` with `prevent_initial_call="initial_duplicate"` so it can coexist with layout-provided initial store data and preset-load writers without `DuplicateCallback` registration errors on Dash ≥2.18. + +**Reason:** Dash requires either `prevent_initial_call=True` or `initial_duplicate` when combining `allow_duplicate` with an initial fire; we need the first client pass to align store + controls without ordering races. + +**Consequence / future:** Other pages adding a similar “controls → draft store” mirror should use the same pattern when the store is also written by load/reset callbacks. + +--- + +## 2026-04-20 — Dash literature compare: shared DOI/URL resolution and linked titles + +**Decision:** Implement DOI normalization (`https://doi.org/...`), optional HTTP URL fallback, and `resolve_literature_href` (direct DOI → direct URL → first linked citation DOI → first linked citation URL) inside `dash_app/components/literature_compare_ui.py`. Render retained evidence titles as `html.A` when a URL exists; link DOI text in citation meta and linkify bare DOI tokens in comparison rationale strings. No per-modality Dash page changes. + +**Reason:** DSC/DTA/TGA all use `render_literature_output`; centralizing avoids drift and restores Streamlit-like “open paper in new tab” behavior. + +**Consequence / future:** Any new analysis page that reuses this renderer gets links automatically; edge cases (non-DOI URLs in free-text rationale) remain plain unless matched as DOI tokens. + +--- + +## 2026-04-20 — TGA quality panel: `validation.checks` under nested technical details + +**Decision:** Keep status, warning/issue counts, warning/issue lists, and calibration/reference in the main TGA quality alert; render `validation.checks` only inside a collapsed “Technical validation details” `
` block. + +**Reason:** The flat checks list read like a backend inspection dump and obscured user-meaningful validation content. + +**Consequence / future:** Power users expand one nested section for import/inference keys; empty checks omit the block. + +--- + +## 2026-04-20 — TGA figure markers use the same curated step rows as key-step cards + +**Decision:** Add `_tga_curated_step_rows_for_ui` and use it for both `_build_step_cards` midpoint/card data and `_build_figure` midpoint marker traces so the annotated set always matches the curated ranked subset (including ordering by significance when all steps fit the cap). + +**Reason:** Cards and plot could diverge (e.g. raw row order vs ranked cap), which confused interpretation on high-step datasets. + +**Consequence / future:** Any change to capping or ranking logic should touch this helper once. + +--- + +## 2026-04-20 — TGA literature compare uses optional preview limits on shared `render_literature_output` + +**Decision:** Add optional `evidence_preview_limit` and `alternative_preview_limit` to `dash_app/components/literature_compare_ui.py::render_literature_output` (defaults `None` = unchanged full layout). TGA passes small limits (2 / 1) so retained references collapse behind a `
` “show N more” block with lighter row chrome; DSC/DTA call sites unchanged. + +**Reason:** TGA literature output was visually heavy on the page; other modalities did not request a denser default. + +**Consequence / future:** Any page may opt in with the same kwargs; keep defaults `None` so existing tests and layouts stay stable. + +--- + +## 2026-04-19 — Literature: opt-in fixture fallback when OpenAlex env is missing + +**Decision:** When the default provider list is only `openalex_like_provider` and `build_openalex_like_client_from_env()` would return `None`, optionally expand to `["openalex_like_provider", "fixture_provider"]` if `MATERIALSCOPE_LITERATURE_FIXTURE_FALLBACK` (or `THERMOANALYZER_LITERATURE_FIXTURE_FALLBACK`) is truthy (`1`/`true`/`yes`/`on`), and set `filters["allow_fixture_fallback"] = True` for traceability. + +**Reason:** Recall/query improvements do not help if the live HTTP client is never configured; local dev and demos need an explicit, safe path without silently pretending fixture data is live OpenAlex. + +**Consequence / future:** Production should set `MATERIALSCOPE_OPENALEX_EMAIL` or API key; fixture mode remains dev/demo-only and must stay opt-in. + +--- + +## 2026-04-19 — DSC peak detection defaults use auto-derivation (None) instead of explicit 0.0/1 + +**Decision:** Set `_DSC_PEAK_DETECTION_DEFAULTS` to `prominence=None, distance=None` and convert user-input 0.0/1 to `None` in `_normalize_peak_detection_values`. Add a batch_runner guard for the DSC path (same pattern as DTA at lines 624-627). + +**Reason:** Explicit `prominence=0.0` bypassed the auto-derivation in `find_thermal_peaks` (which only activates when prominence is `None`), causing every tiny signal fluctuation to register as a peak on simple single-event DSC traces. + +**Consequence / future:** DTA already uses this pattern. TGA migration should follow the same approach. The `peak_analysis.py` auto-derivation (10% of signal range, n//20 distance) becomes the effective floor for all thermal modalities. + +--- + +## 2026-04-19 — DSC result layout promotes analysis figure above raw metadata + +**Decision:** Reorder DSC right-column layout so the main figure appears immediately after quality/validation, with raw metadata demoted to the second-to-last position before literature compare. + +**Reason:** The main DSC figure is the primary analysis artifact; burying it below raw metadata made the results surface feel debug-centric rather than analysis-first. + +**Consequence / future:** Other modalities (TGA, XRD, FTIR, Raman) should follow the same analysis-first ordering when they get full Dash surfaces. + +--- + +## 2026-04-19 — Raw metadata split into user-facing and technical subsections + +**Decision:** Define `_DSC_USER_FACING_METADATA_KEYS` (sample_name, display_name, sample_mass, heating_rate, instrument, vendor, file_name, source_data_hash). Show those directly; demote all other keys into a nested collapsible "Technical details" section. + +**Reason:** All-metadata-equal rendering exposed internal/debug fields alongside user-relevant ones, making the panel noisy without adding analytical value. + +**Consequence / future:** Same pattern can be applied to DTA, TGA, and other modality pages. The key set should be reviewed when new metadata fields are added. + +--- + +## 2026-04-19 — DSC behavior-first literature fallback queries expanded with broader vocabulary + +**Decision:** Expand DSC behavior-first fallback queries from 2-3 to 4+ variants, including "differential scanning calorimetry", direction-specific terms ("endotherm/endothermic", "exotherm/exothermic/crystallization"), and Tg-window variants ("DSC glass transition X C polymer"). + +**Reason:** When sample_name is absent/generic, the original 2-3 fallback queries had narrow vocabulary and poor recall. `_thermal_search_queries` caps at 5, so ordering by relevance matters. + +**Consequence / future:** The 5-query cap means the most relevant queries are used automatically. Future modalities should define similarly broad fallback sets. + +--- + +## 2026-04-19 — Literature compare technical diagnostics include search_mode, subject_trust, and executed queries + +**Decision:** Add `search_mode`, `subject_trust`, `query_display_terms`, and `executed_queries` to the collapsible technical details section in `literature_compare_ui.py`. Add `executed_queries: list[str]` field to `LiteratureContext` dataclass. + +**Reason:** "No literature found" was a dead end without diagnostic context. Showing which queries were executed and why (search_mode, subject_trust) makes no-result cases actionable instead of opaque. + +**Consequence / future:** All modalities using `render_literature_output` benefit automatically. No per-page changes needed. + +--- + +## 2026-04-18 — Figure persistence moved to shared backend save path + +**Decision:** Register a result snapshot figure in shared backend state during saved `/analysis/run` and batch save flows, instead of relying solely on page-specific Dash capture callbacks. + +**Reason:** Real app behavior showed visible graphs could still fail to persist for export/project flows when UI capture callbacks were skipped or failed at runtime. + +**Consequence / future:** Figure persistence is now modality-agnostic and resilient for saved results. Page-level capture remains useful for richer figure overrides but is no longer the single point of failure. + +--- + +## 2026-04-18 — Shared figure rendering helper with fallback + +**Decision:** Introduce `core/figure_render.py` and route Dash capture paths through `render_plotly_figure_png`, with fallback rendering when primary Plotly static export fails. + +**Reason:** Runtime renderer availability differs across environments; hard dependency on one render path caused missed registrations. + +**Consequence / future:** Capture reliability improves across environments. Future work can tune fallback quality without touching every modality page. + +--- + +## 2026-04-18 — Branding upload gets explicit pre-save pending state + +**Decision:** Add a dedicated Dash callback that renders pending logo feedback (`branding-logo-selection`) immediately from upload contents, independent of saved branding state. + +**Reason:** Previously the UI only showed backend-persisted logo; users received no confirmation after file selection before clicking Save Branding. + +**Consequence / future:** UX now clearly distinguishes "selected but not saved yet" from "currently saved logo". Save flow remains unchanged. + +--- + +## 2026-04-18 — DTA Dash figure view_mode contract + +**Decision:** Introduce an explicit `view_mode` parameter (`"result" | "debug"`) on `_build_dta_go_figure` and `_build_figure` in `dash_app/pages/dta.py`, defaulting to `"result"`. The mode controls trace hierarchy, annotation density, and hover detail. Capture/report paths always force `"result"` regardless of interactive state. + +**Reason:** The current DTA figure builder has no mode concept — all overlays render identically whether the user is inspecting analysis or exporting a publication figure. This makes result charts cluttered and debug charts no richer than necessary. An explicit mode contract separates concerns cleanly without touching the analysis pipeline. + +**Consequence / future:** Other modalities (DSC, TGA) can adopt the same `view_mode` pattern when they migrate to Dash. The `dta-figure-view-mode` selector pattern should be reused. + +--- + +## 2026-04-18 — Result mode as default for all saved/exported figures + +**Decision:** All figure capture (`_capture_dta_figure_png`, `capture_dta_figure`) and report registration paths use `view_mode="result"` unconditionally. The interactive `dta-figure-view-mode` selector only affects the live `dcc.Graph` in the result panel. + +**Reason:** Report Center exports and PNG captures must be publication-quality by default. Debug overlays should never leak into saved artifacts unless explicitly requested in a future slice. + +**Consequence / future:** If a future slice adds a "debug export" button, it can pass `view_mode="debug"` to the builder, but the default capture path remains result-only. + +--- + +## 2026-04-18 — Annotation strategy: result mode minimal, debug mode rich + +**Decision:** In `result` mode, onset/endset vertical guide lines and their text annotations are suppressed; only primary-event peak temperature labels appear on-chart. All onset/endset/area/height detail moves to `hovertemplate` on peak markers and remains in the event detail table/cards. In `debug` mode, the current annotation richness (guide lines with labels, all peak text) is preserved. + +**Reason:** Overlapping onset/endset labels are the primary source of chart clutter in dense DTA results. Hover and table already carry this information. Result mode should be clean enough for publication export. + +**Consequence / future:** The `_ANNOTATION_MIN_SEP` and `_PRIMARY_EVENT_LIMIT` constants remain relevant for debug mode. Result mode uses a simpler threshold (primary events only, no onset/endset vlines). + +--- + +## 2026-04-19 — Shared Dash literature compare rendering + +**Decision:** Move literature compare output + status alert rendering into `dash_app/components/literature_compare_ui.py`, parameterized by an `i18n_prefix` (e.g. `dash.analysis.dta.literature` / `dash.analysis.dsc.literature`). DTA page delegates to the shared module; DSC uses the same contract with DSC-specific keys. + +**Reason:** Avoid duplicating large rendering trees and keep DTA/DSC behavior aligned. + +**Consequence / future:** New modalities can reuse the helper by supplying a matching key namespace in `utils/i18n.py`. + +--- + +## 2026-04-19 — DSC analysis state includes `dtg` (derivative) curve + +**Decision:** After baseline correction in `_execute_dsc_batch`, compute a first-derivative curve vs temperature with `core.preprocessing.compute_derivative` and store it in modality state as `dtg`, exposed via existing `analysis_state_curves`. + +**Reason:** Enables a compact derivative helper in Dash without a second full “debug” surface; aligns with backend-driven curves contract. + +**Consequence / future:** Downstream UIs should treat `dtg` as optional (may be empty if insufficient points). diff --git a/.ai/SESSION.md b/.ai/SESSION.md new file mode 100644 index 00000000..dd19524e --- /dev/null +++ b/.ai/SESSION.md @@ -0,0 +1,80 @@ +# Session — MaterialScope + +**Purpose:** **Current working state**, **carryover**, and **next step** only. + +## Carryover + +- **Project:** MaterialScope +- **Branch:** `web-dash-plotly-migration` + +## What was done this session + +- **Post-parity stabilization — runtime/library test isolation (completed 2026-04-24):** + - Traced the remaining 6 full-suite failures to test-only runtime/library env leakage plus one under-provisioned FTIR fallback case, not to production runtime behavior or scientific-analysis drift. + - Added small local isolation fixtures in `tests/test_backend_api.py`, `tests/test_reference_library.py`, and `tests/test_backend_batch.py` that clear both primary and legacy library/runtime env vars and use tmp-path scoped `MATERIALSCOPE_HOME`. + - Reset the managed cloud client singleton in tests that rely on env re-reads so library cloud configuration changes are honored deterministically. + - Made `test_batch_run_ftir_similarity_path_returns_no_match_as_saved` explicitly sync fallback library state from `sample_data/reference_library_mirror` before asserting `no_match` / `spectral_no_match`, and replaced its synthetic signal with a deterministic low-similarity FTIR shape. + - Updated `.ai/TASK.md`, `.ai/SESSION.md`, and `.ai/DECISIONS.md` to record parity completion plus the test-only stabilization policy. + +- **P0-4 — Similarity metric selector for FTIR/Raman (completed 2026-04-22):** + - Added `cosine` / `pearson` similarity metric selectors to `dash_app/pages/ftir.py` and `dash_app/pages/raman.py`. + - Made metric defaults template-first with `cosine` fallback; Raman polymorph-oriented template defaults remain able to prefer `pearson`. + - Persisted metric through processing drafts, hydration, presets, dirty tracking, undo/redo/reset, and run payload overrides. + - Updated backend local ranking and cloud search propagation so `core/batch_runner.py`, `backend/models.py`, and `backend/library_cloud_service.py` honor the selected metric end-to-end. + - Added targeted regression coverage in `tests/test_ftir_dash_page.py`, `tests/test_raman_dash_page.py`, and `tests/test_batch_runner.py`. + - Updated `.ai/TASK.md` and `.ai/DECISIONS.md` to reflect slice completion and the durable metric-default policy. + +- **P0-5 — DSC mass normalization control parity (completed 2026-04-22):** + - Added a **Normalize by mass** control to the DSC Setup tab in `dash_app/pages/dsc.py`. + - Promoted DSC `normalization` to a first-class processing-draft section with normalized defaults and persistence through draft hydration, preset save/load, undo/redo/reset, and `/analysis/run` overrides. + - Updated DSC result processing summaries so saved runs explicitly show whether mass normalization was enabled. + - Kept the default **enabled** to preserve existing scientific behavior and backward compatibility. + - Added TR/EN i18n keys for the DSC normalization control and processing summary in `utils/i18n.py`. + - Added targeted regression coverage in `tests/test_dsc_dash_page.py` and `tests/test_batch_runner.py` for draft defaults, setup syncing, preset persistence, run payload forwarding, and backend honoring. + +- **P1-2 — Figure capture toolbar standardization (completed 2026-04-23):** + - Added shared pure figure-artifact helpers in `dash_app/components/figure_artifacts.py`. + - Wrapped each modality's existing `-result-figure` slot with Snapshot / Report figure toolbar controls and artifact disclosure without renaming the figure slot. + - Preserved existing automatic capture callbacks; DTA keeps its result-mode auto-capture path. + - Refactored XRD onto shared helpers while preserving XRD toolbar IDs, overlay-control slot, and artifact action behavior. + - Updated graph extraction to traverse rendered components in visual order so DTA's result graph is selected before its debug graph. + - Added generic figure artifact i18n/CSS and regression coverage for helper behavior, explicit action replace semantics, auto-capture, and layout IDs. + +- **P1-3 + P1-4 — Shared boilerplate extraction, UI/helper pass (completed 2026-04-24):** + - Added shared numeric coercion helpers in `dash_app/components/processing_inputs.py` and replaced exact duplicates in DSC/DTA/TGA/FTIR/Raman plus XRD processing-draft normalization. + - Added pure shared UI builders in `dash_app/components/analysis_boilerplate.py` for processing history cards, apply-style preset cards, load/save-as preset cards, collapsible details, compatible validation quality cards, and split raw metadata panels. + - Refactored DSC/DTA/TGA/FTIR/Raman/XRD page card builders to use shared helpers while preserving component IDs, callback declarations, result ordering, i18n keys, and backend payload behavior. + - Kept callback orchestration, dirty-state logic, preset lifecycle callbacks, run callbacks, and modality-specific quality/raw metadata variants page-local. + +- **P2 — Polish and remaining consistency (completed 2026-04-24):** + - Added shared spectral raw-quality helpers and wired setup-tab raw-quality panels into FTIR and Raman. + - Added shared UI-only spectral plot settings and wired FTIR/Raman figures to respect legend, compact view, grid/crosshair, line/marker/export scale, trace visibility, reversed X axis, and locked ranges without changing processing drafts, run payloads, or figure artifact slots. + - Added DSC loaded-preset dirty tracking with saved/applied preset snapshots. + - Added focused regression coverage for spectral raw-quality/settings, TGA literature compare callback paths, and DSC dirty-flag states. + +## What was verified + +- `python -m pytest -p no:cacheprovider tests/test_backend_api.py::test_library_status_stays_limited_when_hosted_catalog_is_empty tests/test_backend_api.py::test_runtime_cloud_client_stays_strict_without_dev_override tests/test_backend_api.py::test_runtime_cloud_client_production_error_remains_strict_without_dev_hint tests/test_backend_batch.py::test_batch_run_ftir_similarity_path_returns_no_match_as_saved tests/test_reference_library.py::test_reference_library_manager_reports_not_configured_without_feed_source tests/test_reference_library.py::test_reference_library_manager_requires_explicit_feed_configuration -q` — 6 passed. +- `python -m pytest -p no:cacheprovider` — 1116 passed, 9 skipped, warnings only. +- `python -m pytest tests/test_ftir_dash_page.py tests/test_raman_dash_page.py tests/test_batch_runner.py -q` — 116 passed, 4 deprecation warnings from Dash `dash_table.DataTable`. +- `python -m pytest -p no:cacheprovider tests/test_dsc_dash_page.py tests/test_batch_runner.py -q` — 67 passed, 2 deprecation warnings from Dash `dash_table.DataTable`. +- `python -m pytest -p no:cacheprovider tests/test_analysis_page_components.py tests/test_xrd_dash_page.py tests/test_dsc_dash_page.py tests/test_dta_dash_page.py tests/test_tga_dash_page.py tests/test_ftir_dash_page.py tests/test_raman_dash_page.py -q` — 303 passed, 16 deprecation warnings from Plotly/Kaleido and Dash `dash_table.DataTable`. +- `python -m pytest -p no:cacheprovider tests/test_analysis_page_components.py tests/test_dsc_dash_page.py tests/test_dta_dash_page.py tests/test_tga_dash_page.py tests/test_ftir_dash_page.py tests/test_raman_dash_page.py tests/test_xrd_dash_page.py -q` — 309 passed, 16 deprecation warnings from Plotly/Kaleido and Dash `dash_table.DataTable`. +- `python -m pytest -p no:cacheprovider tests/test_analysis_page_components.py tests/test_ftir_dash_page.py tests/test_raman_dash_page.py tests/test_tga_dash_page.py tests/test_dsc_dash_page.py -q` — 189 passed, 6 deprecation warnings from Dash `dash_table.DataTable`. +- Protected-ID render check for preset controls, history controls, result quality panels, and raw metadata panels across DSC/DTA/TGA/FTIR/Raman/XRD — passed. +- Temporary Dash app import check for all six touched page modules — passed. + +## Next step + +- Dash parity remediation and the follow-on runtime/library stabilization slice are complete. The next task should be a new scoped product or platform slice, not more parity cleanup. + +## Touched files + +- `tests/test_backend_api.py` +- `tests/test_backend_batch.py` +- `tests/test_reference_library.py` +- `.ai/TASK.md` +- `.ai/SESSION.md` +- `.ai/DECISIONS.md` + +**Process defaults:** **`00-workflow.mdc`**. diff --git a/.ai/TASK.md b/.ai/TASK.md new file mode 100644 index 00000000..41b6c144 --- /dev/null +++ b/.ai/TASK.md @@ -0,0 +1,91 @@ +# Task — MaterialScope + +**Purpose:** One active migration slice — scope, goal, and acceptance only. + +## Status: completed — Dash parity remediation + runtime/library stabilization (2026-04-24) + +### Completed: P0-1 — Baseline method gap for DSC/DTA + +**Done (2026-04-22).** DSC and DTA Dash pages now expose the full baseline method set already supported by core (`asls`, `airpls`, `modpoly`, `imodpoly`, `snip`, `rubberband`, `linear`, `spline`). DTA baseline callbacks now persist method-specific parameters for `airpls`, `modpoly`, `imodpoly`, `snip`, and `spline`; both modalities gained missing TR/EN i18n keys for extended baseline controls and targeted regression coverage. + +### Completed: P0-2 + P0-3 — i18n key namespace leakage fix + +**Done (2026-04-22).** DSC/DTA processing history labels and TGA quality/metadata/summary labels now use modality-native i18n keys instead of borrowing from TGA/DSC. 28 new keys, 32 reference swaps, 6 regression tests. Zero cross-namespace references remain. + +### Completed: P0-4 — Similarity matching metric selector for FTIR/Raman + +**Done (2026-04-22).** FTIR and Raman Dash pages now expose `cosine` / `pearson` similarity metric selectors and persist the chosen metric through processing drafts, presets, dirty tracking, undo/redo/reset, and run payload overrides. Metric defaults are template-first with `cosine` fallback, and backend local ranking plus cloud spectral search now honor the selected metric end-to-end. Focused verification passed: `pytest tests/test_ftir_dash_page.py tests/test_raman_dash_page.py tests/test_batch_runner.py -q` → 116 passed. + +### Completed: P0-5 — DSC mass normalization control + backend honoring + +**Done (2026-04-22).** DSC Dash now exposes a Setup-tab **Normalize by mass** control, persists it as a first-class `normalization` processing section through draft/default state, preset save/load, undo/redo/reset, and run payload overrides, and the backend DSC batch path explicitly honors `signal_pipeline.normalization.enabled` instead of always normalizing. The control defaults to **enabled** for backward compatibility. Focused verification passed: `python -m pytest -p no:cacheprovider tests/test_dsc_dash_page.py tests/test_batch_runner.py -q` → 67 passed. + +### Completed: P1-1 — CSS class namespace cleanup + +**Done (2026-04-22).** Shared structural result-role classes migrated from modality-specific `dsc-*`/`dta-*` prefixes to generic `ms-*` prefix across all 6 pages + CSS. Per-modality root page hooks (`{modality}-page`) added for future styling flexibility. TGA derivative class leakage fixed (`dsc-derivative-*` → `tga-derivative-*`). DTA-only debug classes preserved. 260/261 tests pass (1 pre-existing unrelated failure). + +### Completed: P1-2 — Figure capture toolbar standardization + +**Done (2026-04-23).** DSC, DTA, TGA, FTIR, and Raman now expose XRD-style manual **Snapshot** and **Report figure** controls while preserving existing `-result-figure` display/capture contracts and automatic capture callbacks. XRD was refactored onto shared pure artifact helpers without changing its toolbar IDs or overlay slot. Artifact panels refresh on latest-result changes and successful explicit figure actions only. Focused verification passed: `python -m pytest -p no:cacheprovider tests/test_analysis_page_components.py tests/test_xrd_dash_page.py tests/test_dsc_dash_page.py tests/test_dta_dash_page.py tests/test_tga_dash_page.py tests/test_ftir_dash_page.py tests/test_raman_dash_page.py -q` → 303 passed. + +### Completed: P2 — Polish and remaining consistency + +**Done (2026-04-24).** FTIR/Raman Dash pages now include setup-tab raw data quality panels and full UI-only spectral plot settings for legend, compact view, grid/crosshair, line/marker/export scale, trace visibility, reversed X axis, and locked X/Y ranges. TGA gained direct literature compare callback regression coverage. DSC gained loaded-preset dirty tracking and tests. Focused verification passed: `python -m pytest -p no:cacheprovider tests/test_analysis_page_components.py tests/test_ftir_dash_page.py tests/test_raman_dash_page.py tests/test_tga_dash_page.py tests/test_dsc_dash_page.py -q` → 189 passed. + +### Completed: Post-parity stabilization — runtime/library test isolation + +**Done (2026-04-24).** The remaining 6 full-suite failures after Dash parity closeout were traced to test-only runtime/library configuration leakage plus one under-provisioned FTIR fallback case, not to production behavior regressions. `tests/test_backend_api.py`, `tests/test_reference_library.py`, and `tests/test_backend_batch.py` now clear both primary and legacy library/runtime env vars, use tmp-path scoped `MATERIALSCOPE_HOME`, and reset the cloud client singleton where env changes must be re-read. The FTIR batch `no_match` regression now explicitly syncs fallback library state before asserting `spectral_no_match`. Verification passed: targeted 6-test slice green; `python -m pytest -p no:cacheprovider` → 1116 passed, 9 skipped. + +--- + +### Remaining prioritized remediation backlog + +Ordered by priority per the repo-wide parity audit: + +#### P0 — user-facing parity / regression fixes + +| # | Issue | Modalities | Effort | Key files | +|---|---|---|---|---| + +**P0 backlog status:** cleared for the current parity remediation set. + +#### P1 — maturity and consistency fixes + +| # | Issue | Modalities | Effort | Key files | +|---|---|---|---|---| +| P1-3 | Shared boilerplate extraction UI/helper pass: history cards, preset cards, collapsible details, compatible quality/metadata helpers extracted; callback orchestration and incompatible modality-specific panels intentionally deferred | All | Done | `dash_app/components/analysis_boilerplate.py` | +| P1-4 | Exact duplicated coercion helpers extracted; DTA's non-negative helper variant with a `minimum` argument remains local because it is not an exact duplicate | All | Done | `dash_app/components/processing_inputs.py` | +| P1-7 | Regression tests for i18n leakage (done as part of P0-2+P0-3) | All | Done | | + +#### P2 — cleanup, naming, polish + +| # | Issue | Modalities | Effort | +|---|---|---|---| +| P2-3 | Spectral modalities lack raw quality panel (intentional but inconsistent) | FTIR, Raman | Done | +| P2-4 | TGA missing literature compare callback test | TGA | Done | +| P2-5 | DSC missing preset dirty-flag test | DSC | Done | +| P2-6 | Streamlit spectral_page has 12+ plot setting toggles; Dash spectral pages have fewer | FTIR, Raman | Done | + +### Recommended execution order + +1. ~~P1-1 — CSS class namespace cleanup (done)~~ +2. ~~P0-4 — Similarity metric selector (done)~~ +3. ~~P0-5 — DSC mass normalization (done)~~ +4. ~~P1-2 — Figure capture toolbar standardization (done)~~ +5. ~~P1-3 + P1-4 — Shared boilerplate extraction UI/helper pass (done 2026-04-24)~~ +6. ~~P2 items — polish and consistency (done 2026-04-24)~~ + +--- + +### Audit findings summary (2026-04-22) + +| Modality | Streamlit lines | Dash lines | Parity | Key gaps | +|---|---|---|---|---| +| DSC | 818 | 2674 | Near | No remaining P0 gap | +| TGA | 886 | 2360 | Near | Was borrowing DSC i18n keys (fixed); CSS class leakage | +| DTA | 818 | 2973 | Near | Cleanest CSS scoping | +| FTIR | 12 (delegates) | 2625 | Near | Fewer spectral plot toggles than Streamlit | +| Raman | 12 (delegates) | 2630 | Near | Literature compare is Dash improvement; fewer spectral plot toggles than Streamlit | +| XRD | 2432 | 2697 | Near | Most complete figure toolbar; template for others | + +**Cross-cutting issues:** Boilerplate duplication (~40% of each page). diff --git a/.cursor/rules/00-workflow.mdc b/.cursor/rules/00-workflow.mdc new file mode 100644 index 00000000..c9c2342a --- /dev/null +++ b/.cursor/rules/00-workflow.mdc @@ -0,0 +1,38 @@ +--- +description: Repository-wide agent workflow (continuity, context-first, safe diffs) +globs: [] +alwaysApply: true +--- + +# Workflow (existing codebase) + +## Operating mode + +- Treat this as a **live production codebase**: preserve behavior unless the task explicitly changes it. +- Prefer **one main implementation thread** per task: short beats, in order, no parallel “tracks” or competing refactors. +- **Do not** use fake multi-persona workflows (no PM / architect / QA roleplay, no theatrical standups). One technical contributor stance. + +## Continuity across sessions + +- Start from **current repo state** (git, files), not chat memory. +- **`.ai/SESSION.md`** — **carryover only**: branch/WIP, blockers, questions, **next step**. Not durable design history. +- **`.ai/TASK.md`** — **active slice**: goal, in/out of scope, acceptance. +- **`.ai/DECISIONS.md`** — **only** durable decision log (design / architecture / agreed workflow). Do not let equivalent commitments live only in chat or SESSION. +- **`.ai/BUGS.md`** — tracked issues; statuses **Suspected / Open / Fixed / Closed** exactly as defined **in that file**. +- After **meaningful** progress, update the **one** `.ai` file that owns that information in the same session when practical. + +## Read before write + +- **Inspect** real participants (call sites, tests, config, shared helpers) before editing; do not guess APIs or shapes. +- **Context order** (when applicable): `.cursor/rules/*.mdc` → `README` / linked docs → `.ai/*` (SESSION → TASK → DECISIONS / BUGS as needed) → target code. + +## Change discipline + +- Default to **small, safe diffs**; avoid drive-by refactors and unrelated formatting. +- Before **large** or cross-cutting work: **brief** summary (intent, files, risk, how you will verify), then **slices**. +- **Explicit verification**: run agreed tests/checks and record command + outcome; if impossible here, state what a human should run and what passes/fails means. + +## Communication + +- **Concise, technical**: paths, commands, outcomes—minimal narrative. +- Completion reports tie to **observable behavior** + **verification** (or say why none ran). diff --git a/.cursor/rules/10-rtk.mdc b/.cursor/rules/10-rtk.mdc new file mode 100644 index 00000000..de0f45e0 --- /dev/null +++ b/.cursor/rules/10-rtk.mdc @@ -0,0 +1,27 @@ +--- +description: Prefer RTK for verbose shell commands +alwaysApply: true +--- + +Use RTK for verbose shell commands in this project. + +Prefer: +- `rtk git status` +- `rtk git diff` +- `rtk pytest` +- `rtk ruff check` +- `rtk read ` +- `rtk grep "" .` + +Avoid raw: +- `git status` +- `git diff` +- `pytest` +- `ruff check` +- `cat` +- `grep` + +Use raw commands only when: +- RTK does not apply +- exact unfiltered output is explicitly needed +- the RTK command is unavailable or fails diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 00000000..ac4b44db --- /dev/null +++ b/.cursorignore @@ -0,0 +1,74 @@ +# Keep Cursor focused on source code, docs, tests, and migration-relevant context. +# Deliberately keep visible: +# - backend/ +# - core/ +# - dash_app/ +# - tests/ +# - ui/ +# - sample_data/ +# - test_data/ +# - app.py +# - README.md + +# Python caches and packaging artifacts +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +*.egg + +# Virtual environments and secrets +.env +.env.* +.venv/ +venv/ +env/ +.streamlit/secrets.toml + +# Build, release, and generated outputs +dist/ +build/ +release/ +packaging/windows/build/ +packaging/windows/dist/ + +# Test and coverage artifacts +.pytest_cache/ +.pytest-temp/ +.coverage +.coverage.* +htmlcov/ +*.log + +# Repo-local temp / agent artifact folders +.artifacts/ +.bg-shell/ +.codex/ +.gsd/ +.planning/ +.tmp/ +.tmp_*/ +.tmp_demo_review/ +.ptmp/ +.ptmp_full_*/ +pytest_temp/ + +# Dependency and indexing-heavy generated folders +.git/ +**/node_modules/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.tox/ +.nox/ +.cache/ +.ipynb_checkpoints/ + +# Local OS/editor noise +.DS_Store +Thumbs.db +.idea/ + +# Local stray binary / tool artifacts +python3 diff --git a/.dockerignore b/.dockerignore index 61f85868..e2dc07fc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,6 +32,7 @@ pytest_temp support_logs *.log *.zip +*.scopezip *.thermozip .DS_Store Thumbs.db diff --git a/.env.example b/.env.example index 0c3f0244..c2734a60 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,49 @@ -THERMOANALYZER_LIBRARY_MIRROR_ROOT=C:\thermoanalyzer\build\reference_library_mirror_live -THERMOANALYZER_LIBRARY_HOSTED_ROOT=C:\thermoanalyzer\build\reference_library_hosted -THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 -THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true -THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH=true -THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +# ============================================================================= +# MaterialScope — .env.example +# Copy to ".env" at the repo root. Do not commit real secrets or machine-specific paths. +# ============================================================================= +# +# --- Pick ONE local layout --- +# +# (1) COMBINED Dash + FastAPI — default for Linux / WSL day-to-day +# Start: python -m dash_app.server +# URL: http://127.0.0.1:8050 (default; use --port to change) +# +# Cloud library calls use MATERIALSCOPE_LIBRARY_CLOUD_URL. If you leave it unset, +# combined startup sets it to this server's listen port. If you set it manually, +# use the SAME host:port as this process (usually 8050, not 8000). +# +# (2) SPLIT backend + separate UI — Docker / Coolify, or Streamlit + backend.main +# API: python -m backend.main → http://127.0.0.1:8000 +# Use the commented (2) block below for cloud URL :8000. +# +# Linux / WSL: Do NOT paste Windows paths (C:\...) into *_HOSTED_ROOT or *_MIRROR_ROOT. +# Use POSIX paths, or omit those variables so the repo uses defaults under build/. +# ============================================================================= + +# ----- (1) Combined Dash + FastAPI (preferred MATERIALSCOPE_* names) ----- +MATERIALSCOPE_LIBRARY_DEV_CLOUD_AUTH=true +MATERIALSCOPE_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +# MATERIALSCOPE_LIBRARY_CLOUD_URL=http://127.0.0.1:8050 +# MATERIALSCOPE_LIBRARY_CLOUD_ENABLED=true +# Optional POSIX paths (uncomment only if you maintain custom roots): +# MATERIALSCOPE_LIBRARY_HOSTED_ROOT=/home/you/materialscope/build/reference_library_hosted +# MATERIALSCOPE_LIBRARY_MIRROR_ROOT=/home/you/materialscope/build/reference_library_mirror_live +# MATERIALSCOPE_LIBRARY_DISABLE_COMBINED_BOOTSTRAP=1 + MATERIALSCOPE_ENABLE_PREVIEW_MODULES=false MATERIALSCOPE_OPENALEX_EMAIL= MATERIALSCOPE_OPENALEX_API_KEY= +# ----- (2) Split stack — backend on :8000 (uncomment when using this layout, not combined Dash) ----- +# MATERIALSCOPE_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 +# MATERIALSCOPE_LIBRARY_CLOUD_ENABLED=true + +# ----- Legacy THERMOANALYZER_* names (still read by code; prefer MATERIALSCOPE_* above) ----- +# THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 +# THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true +# THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH=true +# THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +# Windows-only examples — do not use on Linux/WSL: +# THERMOANALYZER_LIBRARY_MIRROR_ROOT=C:\thermoanalyzer\build\reference_library_mirror_live +# THERMOANALYZER_LIBRARY_HOSTED_ROOT=C:\thermoanalyzer\build\reference_library_hosted diff --git a/.gitignore b/.gitignore index d2ba3dc0..862726d2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ env/ .tmp_*/ .tmp_demo_review/ .pytest-temp/ +.pytest_temp/ +pytest_temp/ .ptmp/ .ptmp_full_*/ .artifacts/ @@ -33,3 +35,4 @@ release/ packaging/windows/build/ packaging/windows/dist/ desktop/electron/node_modules/ +python3 diff --git a/README.md b/README.md index 500bfe28..8c87f3d1 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,24 @@ MaterialScope is a desktop analysis platform for thermal and broader materials c The product is designed so users stay inside MaterialScope instead of switching between vendor software, spreadsheet cleanup, library viewers, and separate reporting tools. +The current primary application surface is a Dash + Plotly UI mounted into FastAPI (`python -m dash_app.server`). The Streamlit entrypoint remains available as a legacy/transition path during migration. + +### Version (current release line) + +| Item | Value | +|------|--------| +| **MaterialScope application version** | `2.0` (see `utils/license_manager.APP_VERSION`) | +| **Bundled FastAPI / backend API version** | `0.1.0` (see `backend.BACKEND_API_VERSION`; exposed on `/version` and health endpoints) | +| **Active development branch** | `web-dash-plotly-migration` (Dash + Plotly migration and backend integration) | + +Use these when reporting bugs, support tickets, or export diagnostics. The API version tracks the HTTP surface; the application version tracks the product build users see in licensing and support workflows. + --- ## What MaterialScope Covers ### Stable workflows + - DSC - TGA - DTA @@ -17,9 +30,10 @@ The product is designed so users stay inside MaterialScope instead of switching - XRD - Compare workspace - Report/export center -- Project save/load with `.thermozip` +- Project save/load with `.scopezip` (legacy `.thermozip` still imports) ### Preview workflows + - Kinetics - Peak deconvolution @@ -27,7 +41,9 @@ The product is designed so users stay inside MaterialScope instead of switching ## Product Direction -- Thin-client desktop application with a Python analysis and backend layer +- Dash + Plotly frontend mounted on FastAPI as the primary app stack +- Thin-client desktop application with an Electron wrapper and Python backend +- Streamlit retained as a legacy/transition surface while modality migration completes - Cloud-first library access for FTIR, Raman, and XRD - Limited local fallback cache for degraded operation - No full provider-scale libraries shipped permanently to the client @@ -35,9 +51,19 @@ The product is designed so users stay inside MaterialScope instead of switching --- +## Dash migration status (latest) + +- Dash + Plotly is the **default** surface for stable modalities; migration continues modality-by-modality to keep verification explicit. +- **DTA** — Phase 4 polish: quality and raw-metadata cards, expandable processing summary, preset flow, keyboard shortcuts. +- **DSC** — Full Dash analysis surface aligned with DTA patterns: literature compare, figure capture for reports, export-oriented figure registration with diagnostics (`report_figure_status` / export warnings when figures are missing). +- Streamlit remains supported for legacy flows until remaining surfaces reach parity. + +--- + ## Core Capabilities ### Import and preprocessing + - CSV, TXT, TSV, XLSX, and XLS import - Automatic delimiter, decimal, header-row, and column-role inference - Ambiguity-aware import confidence and review prompts @@ -45,24 +71,27 @@ The product is designed so users stay inside MaterialScope instead of switching - XRD-specific import handling for axis role, unit, and wavelength provenance ### Analysis workflows + - DSC: baseline correction, peak detection, Tg handling, enthalpy, sign-aware interpretation - TGA: DTG, step detection, residue and mass-loss interpretation, class-aware reasoning -- DTA: stable report/export path aligned with the main product surface +- DTA: Dash Phase 4 polish shipped (quality + raw metadata cards, expandable processing summary, keyboard shortcuts, and preset-to-run tab flow) - FTIR and Raman: cloud-backed qualitative library search with provider provenance - XRD: qualitative phase screening with cloud-backed candidate ranking, scientific naming, reference dossiers, and caution-safe no-match handling ### Reporting and traceability + - DOCX, PDF, XLSX, and CSV outputs - Compact report-style main body with appendix-level technical evidence - Scientific reasoning sections by modality -- Publication-grade figures for UI and export -- Figure snapshots and report-primary figure selection +- Publication-grade figures for UI and export; server-side snapshot figures aligned with processed thermal axes +- Report-primary figure keys, optional **Figure export notes** when PNGs are missing, and per-result capture status for troubleshooting - Preserved validation warnings, processing context, and provenance metadata ### Project workflow + - Compare workspace for cross-run review - Batch-oriented stable analysis flows -- Session persistence plus `.thermozip` project archives +- Session persistence plus `.scopezip` project archives (legacy `.thermozip` import supported) --- @@ -76,6 +105,7 @@ MaterialScope uses a managed cloud-library architecture: - fallback mode is explicitly reduced-capability and not equivalent to cloud full access ### Current provider direction + - FTIR: OpenSpecy - Raman: OpenSpecy + ROD - XRD: COD + Materials Project @@ -109,14 +139,15 @@ XRD is handled as qualitative phase screening, not definitive phase confirmation ## Installation ### Prerequisites + - Python 3.8+ - `pip` ### Setup ```bash -git clone https://github.com/utkuvibing/thermoanalyzer.git -cd thermoanalyzer +git clone https://github.com/utkuvibing/MaterialScope-web-dash-plotly-migration.git +cd MaterialScope-web-dash-plotly-migration python -m venv venv source venv/bin/activate # Linux/macOS @@ -129,15 +160,33 @@ pip install -r requirements.txt ## Running -### Streamlit UI +### Dash + FastAPI (Primary) ```bash -streamlit run app.py +python -m dash_app.server ``` -Default URL: `http://localhost:8501` +Default URL: `http://127.0.0.1:8050` -### Backend API +Optional run flags: + +```bash +python -m dash_app.server --host 0.0.0.0 --port 8050 --token +``` + +This starts a combined FastAPI app with Dash mounted at `/` and backend routes served from the same process. + +On startup, `dash_app.server` applies a small **library env bootstrap** (unless +`MATERIALSCOPE_LIBRARY_DISABLE_COMBINED_BOOTSTRAP=1`): + +- If `MATERIALSCOPE_LIBRARY_CLOUD_URL` is unset, it defaults to `http://:` (loopback + adjusted when you bind `0.0.0.0`), so the managed-library HTTP client targets this same process. +- If the URL is the common Docker-style `http://127.0.0.1:8000` but the server listens on another port (the + default `8050`), the URL is rewritten to match the listen port. +- Windows-style `MATERIALSCOPE_LIBRARY_HOSTED_ROOT` / `MATERIALSCOPE_LIBRARY_MIRROR_ROOT` values are dropped on + Linux/WSL so a copied `.env` does not silently point at non-existent paths. + +### Backend API (standalone) ```bash python -m backend.main @@ -145,18 +194,30 @@ python -m backend.main Default URL: `http://localhost:8000` -For local development, start the backend before using library-backed workflows if you expect `cloud_full_access`. +### Streamlit UI (Legacy / Transition) + +```bash +streamlit run app.py +``` + +Default URL: `http://localhost:8501` + +For local development, start the backend before using Streamlit workflows that require `cloud_full_access`. ### Docker / Coolify deployment This repo includes a production `Dockerfile` for Coolify-style deployments. -The container starts: +The current container profile starts: + - the FastAPI backend on `127.0.0.1:8000` -- the Streamlit UI on `0.0.0.0:8501` +- the Streamlit UI (legacy path) on `0.0.0.0:8501` - Streamlit waits for backend health before the UI process starts +Dash-first container startup remains a follow-up even though the combined local Dash server now covers all six analysis pages. + For web deployment: + - deploy with `Dockerfile` - expose port `8501` - set runtime secrets in Coolify instead of committing `.env` @@ -164,14 +225,18 @@ For web deployment: Recommended runtime environment variables: ```dotenv -THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 -THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true -THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +MATERIALSCOPE_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 +MATERIALSCOPE_LIBRARY_CLOUD_ENABLED=true +MATERIALSCOPE_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false MATERIALSCOPE_ENABLE_PREVIEW_MODULES=false MATERIALSCOPE_OPENALEX_EMAIL= MATERIALSCOPE_OPENALEX_API_KEY= +# Optional: when live OpenAlex is not configured, also search bundled demo fixtures (dev/demo only). +# MATERIALSCOPE_LITERATURE_FIXTURE_FALLBACK=1 ``` +Live literature compare (DSC, DTA, TGA, FTIR, XRD) uses the OpenAlex-backed provider by default. Set at least `MATERIALSCOPE_OPENALEX_EMAIL` (OpenAlex polite-pool `mailto`) or `MATERIALSCOPE_OPENALEX_API_KEY` so the backend can run real metadata queries. Without that, the API reports `provider_query_status=not_configured` unless `MATERIALSCOPE_LITERATURE_FIXTURE_FALLBACK=1` is enabled to merge the local fixture catalog. + Set `MATERIALSCOPE_ENABLE_PREVIEW_MODULES=true` only in builds where kinetics and deconvolution should be exposed. Optional runtime tuning: @@ -184,20 +249,40 @@ BACKEND_STARTUP_TIMEOUT_SECONDS=30 ## Local Cloud-Library Development -Use the same repo-root `.env` for both Streamlit (`app.py`) and the backend (`backend/app.py`). +Use the same repo-root `.env` for Dash (`dash_app/server.py`), Streamlit (`app.py`), and backend (`backend/app.py`). +**Start from [`.env.example`](.env.example)** — it lists the combined-Dash (8050) case first, then split-backend (8000), then legacy names. + +**Combined Dash + FastAPI (typical Linux / WSL dev):** the managed-library client must target the same HTTP +origin as `/v1/library/*`. With `python -m dash_app.server` you usually want port **8050** (or omit +`MATERIALSCOPE_LIBRARY_CLOUD_URL` and let the startup bootstrap set it). + +```dotenv +MATERIALSCOPE_LIBRARY_CLOUD_URL=http://127.0.0.1:8050 +MATERIALSCOPE_LIBRARY_CLOUD_ENABLED=true +MATERIALSCOPE_LIBRARY_DEV_CLOUD_AUTH=true +MATERIALSCOPE_LIBRARY_MIRROR_ROOT=/home/you/materialscope/build/reference_library_mirror_live +MATERIALSCOPE_LIBRARY_HOSTED_ROOT=/home/you/materialscope/build/reference_library_hosted +MATERIALSCOPE_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +``` + +**Docker / split processes (backend on 8000, UI elsewhere):** keep the cloud URL on **8000**. ```dotenv -THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 -THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true -THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH=true -THERMOANALYZER_LIBRARY_MIRROR_ROOT=C:\thermoanalyzer\build\reference_library_mirror_live -THERMOANALYZER_LIBRARY_HOSTED_ROOT=C:\thermoanalyzer\build\reference_library_hosted -THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +MATERIALSCOPE_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 +MATERIALSCOPE_LIBRARY_CLOUD_ENABLED=true +MATERIALSCOPE_LIBRARY_DEV_CLOUD_AUTH=true +MATERIALSCOPE_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +# Optional mirror/hosted: omit on Linux/WSL (repo defaults), or use POSIX paths. +# MATERIALSCOPE_LIBRARY_MIRROR_ROOT=C:\materialscope\build\reference_library_mirror_live +# MATERIALSCOPE_LIBRARY_HOSTED_ROOT=C:\materialscope\build\reference_library_hosted ``` Notes: -- `THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false` preserves the limited-fallback policy. -- `THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH=true` is a dev-only shortcut for local cloud testing. + +- `MATERIALSCOPE_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false` preserves the limited-fallback policy. +- `MATERIALSCOPE_LIBRARY_DEV_CLOUD_AUTH=true` is a dev-only shortcut for local cloud testing. +- On Linux/WSL, do **not** paste Windows `C:\...` paths for hosted/mirror roots; leave those variables unset to + use repo defaults under `build/`, or use POSIX paths. Malformed values are ignored with a warning. - hosted XRD coverage warnings remain visible even when the cloud path is healthy. ### Publish hosted library data locally @@ -209,10 +294,20 @@ python tools/publish_hosted_library.py --output-root build/reference_library_hos ### Local cloud smoke test ```bash -python tools/library_cloud_smoke.py --base-url http://127.0.0.1:8000 +python tools/library_cloud_smoke.py --base-url http://127.0.0.1:8050 +``` + +For a standalone API on port 8000, use `http://127.0.0.1:8000` instead. + +### Spectral library diagnostics (FTIR / Raman / XRD) + +```bash +python tools/ftir_library_diagnostics.py +python tools/ftir_library_diagnostics.py --json ``` Expected local/dev result: + - `Library Mode = Cloud Full Access` - `Cloud Access = Enabled` @@ -225,17 +320,18 @@ Expected local/dev result: 3. Run the relevant workflow: DSC, TGA, DTA, FTIR, Raman, or XRD. 4. Use Compare Workspace for cross-run review when needed. 5. Export results or generate a report. -6. Save the session as a `.thermozip` project archive. +6. Save the session as a `.scopezip` project archive. --- ## Repository Layout ```text -thermoanalyzer/ -├── app.py +MaterialScope/ +├── app.py # Streamlit legacy entrypoint +├── dash_app/ # primary Dash + Plotly frontend and combined server ├── core/ # analysis engine, scientific/report logic, library handling -├── ui/ # Streamlit pages and shared UI components +├── ui/ # Streamlit pages/components kept during migration ├── backend/ # FastAPI backend and managed cloud-library routes ├── desktop/ # desktop wrapper and bundling assets ├── tools/ # ingest, publish, smoke, packaging helpers @@ -246,10 +342,20 @@ thermoanalyzer/ ``` Local Windows release notes: + - [Local Windows Release Prep](packaging/windows/RELEASE_PREP_LOCAL.md) --- +## Forward-looking work (Dash-first) + +- Dash analysis-page parity is complete across DSC, DTA, FTIR, Raman, TGA, and XRD; remaining work is deployment/runtime cleanup where Dash is not yet primary. +- Reuse the shared Dash result-surface patterns (quality cards, raw metadata, processing summaries, literature compare, figure capture) across modalities. +- Converge desktop and container runtimes toward Dash-first defaults while keeping Streamlit available as a legacy path. +- Expand managed cloud-library provider coverage and provenance quality. + +--- + ## License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. +This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. \ No newline at end of file diff --git a/app.py b/app.py index 7248caaa..6301cbcf 100644 --- a/app.py +++ b/app.py @@ -854,8 +854,8 @@ def _theme_option_label(mode: str) -> str: unsafe_allow_html=True, ) - lang_col, theme_col = st.columns([1, 1], gap="small") - with lang_col: + header_meta_col, header_lang_col = st.columns([1.15, 0.95], gap="small") + with header_meta_col: st.segmented_control( "language", options=list(SUPPORTED_LANGUAGES.keys()), @@ -864,7 +864,7 @@ def _theme_option_label(mode: str) -> str: selection_mode="single", label_visibility="collapsed", ) - with theme_col: + with header_lang_col: st.segmented_control( "theme", options=["light", "dark"], @@ -936,15 +936,10 @@ def _render_items() -> None: else: show_preview_tools = False +# Kinetik ve dekonvolüsyon modülleri önizleme anahtarı arkasında kalır. primary_pages = [ ( - st.Page( - home_render, - title=t("nav.import"), - icon="📂", - default=True, - url_path="import", - ), + st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), t("nav.import"), "📂", "import", @@ -982,12 +977,7 @@ def _render_items() -> None: "tga", ), ( - st.Page( - dta_render, - title=tx("DTA Analizi", "DTA Analysis"), - icon="📊", - url_path="dta", - ), + st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), tx("DTA Analizi", "DTA Analysis"), "📊", "dta", @@ -1013,12 +1003,7 @@ def _render_items() -> None: ] management_pages = [ ( - st.Page( - library_render, - title=tx("Kütüphane", "Library"), - icon="🗃️", - url_path="library", - ), + st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), tx("Kütüphane", "Library"), "🗃️", "library", diff --git a/backend/__init__.py b/backend/__init__.py index 031f059d..6489941c 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -1,4 +1,4 @@ -"""ThermoAnalyzer backend service package.""" +"""MaterialScope backend service package.""" BACKEND_API_VERSION = "0.1.0" diff --git a/backend/app.py b/backend/app.py index 89aec4be..aca9d939 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,11 +11,14 @@ from typing import Any import httpx +import plotly.graph_objects as go from dotenv import load_dotenv -from fastapi import FastAPI, Header, HTTPException +from fastapi import FastAPI, Header, HTTPException, Query, Request +from fastapi.responses import Response from backend import BACKEND_API_VERSION from backend.detail import ( + build_dataset_data, build_dataset_detail, build_result_detail, normalize_compare_workspace, @@ -25,6 +28,8 @@ build_export_preparation, generate_report_docx_artifact, generate_results_csv_artifact, + generate_results_xlsx_artifact, + generate_report_pdf_artifact, ) from backend.library_cloud_service import ManagedLibraryCloudService from backend.models import ( @@ -32,12 +37,14 @@ ActiveDatasetUpdateRequest, AnalysisRunRequest, AnalysisRunResponse, + AnalysisStateCurvesResponse, BatchRunRequest, BatchRunResponse, CompareSelectionResponse, CompareSelectionUpdateRequest, CompareWorkspaceResponse, CompareWorkspaceUpdateRequest, + DatasetDataResponse, DatasetDetailResponse, DatasetImportRequest, DatasetImportResponse, @@ -58,17 +65,27 @@ LibrarySyncResponse, LiteratureCompareRequest, LiteratureCompareResponse, + PresetDeleteResponse, + PresetListResponse, + PresetLoadResponse, + PresetSaveRequest, + PresetSaveResponse, + PresetSummary, ProjectLoadRequest, ProjectLoadResponse, ProjectSaveRequest, ProjectSaveResponse, ProjectSummary, ResultDetailResponse, + ResultFigureRegisterRequest, + ResultFigureRegisterResponse, ResultsListResponse, SpectralLibrarySearchRequest, ValidationSummary, VersionResponse, WorkspaceContextResponse, + WorkspaceBrandingResponse, + WorkspaceBrandingUpdateRequest, WorkspaceCreateResponse, WorkspaceSummaryResponse, XRDLibrarySearchRequest, @@ -76,7 +93,9 @@ from backend.store import ProjectStore from backend.workspace import ( add_history_event, + normalize_branding_payload, normalize_workspace_state, + remove_dataset_from_workspace, summarize_dataset, summarize_result, unique_dataset_key, @@ -84,18 +103,23 @@ from backend.workspace_context import build_workspace_context, set_active_dataset, update_compare_selection from core.data_io import read_thermal_data from core.execution_engine import run_batch_analysis, run_single_analysis -from core.modalities import stable_analysis_types +from core.modalities import analysis_state_key, stable_analysis_types from core.literature_compare import attach_literature_package, compare_result_to_literature +from core.processing_schema import update_method_context, update_processing_step +from core.figure_render import render_plotly_figure_png from core.literature_provider import ( LiteratureProvider, MultiLiteratureProviderAggregator, default_literature_provider_registry, + literature_fixture_fallback_enabled, + openalex_literature_env_configured, resolve_literature_providers, ) from core.project_io import PROJECT_EXTENSION, load_project_archive, save_project_archive from core.reference_library import ReferenceLibraryManager, get_reference_library_manager from core.result_serialization import split_valid_results from core.validation import validate_thermal_dataset +from utils.diagnostics import get_default_log_file, serialize_support_snapshot from utils.license_manager import APP_VERSION, commercial_mode_enabled, load_license_state load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env", override=False) @@ -122,10 +146,11 @@ def _model_payload(model: Any) -> dict[str, Any]: def _project_summary(project_state: dict) -> ProjectSummary: + valid_results, _issues = split_valid_results(project_state.get("results", {}) or {}) return ProjectSummary( active_dataset=project_state.get("active_dataset"), dataset_count=len(project_state.get("datasets", {}) or {}), - result_count=len(project_state.get("results", {}) or {}), + result_count=len(valid_results), figure_count=len(project_state.get("figures", {}) or {}), analysis_history_count=len(project_state.get("analysis_history", []) or []), ) @@ -138,6 +163,281 @@ def _normalize_stable_analysis_error(detail: str) -> str: return token +def _finite_float_list(values: Any) -> list[float]: + if values is None: + return [] + out: list[float] = [] + for item in list(values): + try: + parsed = float(item) + except (TypeError, ValueError): + return [] + if parsed != parsed or parsed in (float("inf"), float("-inf")): + return [] + out.append(parsed) + return out + + +def _dataset_axis_and_signal(dataset: Any) -> tuple[list[float], list[float]]: + frame = getattr(dataset, "data", None) + if frame is None or "temperature" not in frame.columns or "signal" not in frame.columns: + return [], [] + try: + import numpy as np + + axis = np.asarray(frame["temperature"], dtype=float) + signal = np.asarray(frame["signal"], dtype=float) + except Exception: + return [], [] + if axis.size == 0 or signal.size == 0: + return [], [] + finite_mask = np.isfinite(axis) & np.isfinite(signal) + axis = axis[finite_mask] + signal = signal[finite_mask] + if axis.size == 0: + return [], [] + order = np.argsort(axis) + axis = axis[order] + signal = signal[order] + unique_axis, unique_idx = np.unique(axis, return_index=True) + return unique_axis.tolist(), signal[unique_idx].tolist() + + +def _axis_title_for_analysis(analysis_type: str) -> str: + token = str(analysis_type or "").strip().upper() + if token == "XRD": + return "2theta (deg)" + if token == "FTIR": + return "Wavenumber" + if token == "RAMAN": + return "Raman Shift" + return "Temperature (°C)" + + +def _y_title_for_analysis(analysis_type: str) -> str: + token = str(analysis_type or "").strip().upper() + if token == "XRD": + return "Intensity (a.u.)" + return "Signal (a.u.)" + + +def _build_result_snapshot_figure( + *, + analysis_type: str, + dataset_key: str, + dataset: Any, + state_payload: dict[str, Any], +) -> go.Figure | None: + payload = state_payload or {} + state_axis = _finite_float_list(payload.get("axis")) + if not state_axis: + state_axis = _finite_float_list(payload.get("temperature")) + raw_axis, raw_signal = _dataset_axis_and_signal(dataset) + axis = state_axis or raw_axis + if not axis: + return None + + def _series(name: str) -> list[float]: + values = _finite_float_list((state_payload or {}).get(name)) + if values and len(values) == len(axis): + return values + return [] + + smoothed = _series("smoothed") + baseline = _series("baseline") + corrected = _series("corrected") + dtg = _series("dtg") + if raw_signal and len(raw_signal) != len(axis): + raw_signal = [] + + primary = corrected or smoothed or raw_signal + if not primary: + return None + + fig = go.Figure() + has_overlay = bool(corrected or smoothed) + if raw_signal: + fig.add_trace( + go.Scatter( + x=axis, + y=raw_signal, + mode="lines", + name="Raw Signal", + line=dict(color="#9CA3AF", width=1.2), + opacity=0.32 if has_overlay else 0.9, + ) + ) + if smoothed: + fig.add_trace( + go.Scatter( + x=axis, + y=smoothed, + mode="lines", + name="Smoothed", + line=dict(color="#2563EB", width=1.8), + opacity=0.9 if corrected else 1.0, + ) + ) + if baseline: + fig.add_trace( + go.Scatter( + x=axis, + y=baseline, + mode="lines", + name="Baseline", + line=dict(color="#64748B", width=1.1, dash="dash"), + opacity=0.75, + ) + ) + if corrected: + fig.add_trace( + go.Scatter( + x=axis, + y=corrected, + mode="lines", + name="Corrected", + line=dict(color="#111827", width=2.6), + ) + ) + if dtg: + fig.add_trace( + go.Scatter( + x=axis, + y=dtg, + mode="lines", + name="DTG", + line=dict(color="#059669", width=1.4, dash="dot"), + opacity=0.8, + ) + ) + + fig.update_layout( + title=f"{str(analysis_type or '').upper()} Analysis - {dataset_key}", + xaxis_title=_axis_title_for_analysis(analysis_type), + yaxis_title=_y_title_for_analysis(analysis_type), + template="plotly_white", + hovermode="x unified", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0), + margin=dict(l=64, r=28, t=72, b=58), + width=1200, + height=620, + ) + return fig + + +def _merge_result_artifacts( + state: dict[str, Any], + result_id: str, + record: dict[str, Any], + *, + artifacts_update: dict[str, Any], +) -> dict[str, Any]: + """Merge artifact fields into the saved result and persist into state.""" + artifacts = dict(record.get("artifacts") or {}) + artifacts.update(artifacts_update) + patched = dict(record) + patched["artifacts"] = artifacts + state.setdefault("results", {})[result_id] = patched + return patched + + +def _auto_register_result_snapshot( + state: dict[str, Any], + *, + result_id: str, + record: dict[str, Any], + state_payload: dict[str, Any], + analysis_type: str, +) -> dict[str, Any] | None: + dataset_key = str(record.get("dataset_key") or "").strip() + if not dataset_key: + _merge_result_artifacts( + state, + result_id, + record, + artifacts_update={ + "report_figure_status": "failed", + "report_figure_error": "missing_dataset_key", + }, + ) + return None + datasets = state.get("datasets") or {} + dataset = datasets.get(dataset_key) + if dataset is None: + _merge_result_artifacts( + state, + result_id, + record, + artifacts_update={ + "report_figure_status": "failed", + "report_figure_error": f"dataset_not_in_workspace:{dataset_key}", + }, + ) + return None + + record = (state.get("results") or {}).get(result_id) or record + figures = state.setdefault("figures", {}) + artifacts = dict(record.get("artifacts") or {}) + + existing_keys = artifacts.get("figure_keys") + keys: list[str] = [] + if isinstance(existing_keys, list): + for item in existing_keys: + if isinstance(item, str) and item and item not in keys: + keys.append(item) + for key in keys: + if key in figures: + if not artifacts.get("report_figure_key"): + artifacts["report_figure_key"] = key + artifacts["report_figure_status"] = "captured" + artifacts["report_figure_error"] = "" + _merge_result_artifacts(state, result_id, record, artifacts_update=artifacts) + return {"figure_key": key, "reused": True, "status": "captured"} + + label = f"{str(analysis_type or '').upper()} Analysis - {dataset_key}" + figure = _build_result_snapshot_figure( + analysis_type=analysis_type, + dataset_key=dataset_key, + dataset=dataset, + state_payload=state_payload, + ) + if figure is None: + _merge_result_artifacts( + state, + result_id, + record, + artifacts_update={ + "report_figure_status": "failed", + "report_figure_error": "snapshot_figure_unavailable: no plottable series for axis/primary", + }, + ) + return None + + png_bytes, render_meta = render_plotly_figure_png(figure, width=1200, height=620) + if not png_bytes: + _merge_result_artifacts( + state, + result_id, + record, + artifacts_update={ + "report_figure_status": "failed", + "report_figure_error": f"png_render_failed:{render_meta or 'unknown'}", + }, + ) + return None + + figures[label] = png_bytes + if label not in keys: + keys.append(label) + artifacts["figure_keys"] = keys + artifacts["report_figure_key"] = label + artifacts["report_figure_status"] = "captured" + artifacts["report_figure_error"] = "" + + _merge_result_artifacts(state, result_id, record, artifacts_update=artifacts) + return {"figure_key": label, "render_mode": render_meta or "kaleido", "status": "captured"} + + def _require_project_state(project_store: ProjectStore, project_id: str) -> dict: project_state = project_store.get(project_id) if project_state is None: @@ -145,6 +445,50 @@ def _require_project_state(project_store: ProjectStore, project_id: str) -> dict return project_state +def _apply_processing_overrides( + state: dict, + *, + analysis_type: str, + dataset_key: str, + overrides: Mapping[str, Any], +) -> None: + """Merge caller-supplied per-step overrides into the workspace analysis-state. + + Overrides are written to ``state[analysis_state_key(...)]["processing"]`` using + the canonical ``update_processing_step`` / ``update_method_context`` helpers so + that ``core._build_processing_payload`` picks them up as user-tuned parameters + that win over the template defaults. + """ + if not overrides: + return + normalized_type = (analysis_type or "").strip().upper() + if not normalized_type: + raise ValueError("analysis_type is required to apply processing overrides.") + skey = analysis_state_key(normalized_type, dataset_key) + existing_state = state.get(skey) or {} + processing = dict(existing_state.get("processing") or {}) + for section_name, values in overrides.items(): + if not isinstance(values, Mapping): + raise ValueError( + f"processing_overrides[{section_name!r}] must be an object of parameter values." + ) + if section_name == "method_context": + processing = update_method_context( + processing, dict(values), analysis_type=normalized_type + ) + continue + try: + processing = update_processing_step( + processing, section_name, dict(values), analysis_type=normalized_type + ) + except ValueError as exc: + raise ValueError( + f"Unsupported processing_overrides section '{section_name}' for {normalized_type}." + ) from exc + existing_state["processing"] = processing + state[skey] = existing_state + + def _library_status_payload(manager: ReferenceLibraryManager) -> dict: license_state = _backend_license_state() try: @@ -188,6 +532,20 @@ def create_app( literature_provider_registry = dict(literature_provider_registry or default_literature_provider_registry()) app.state.cloud_library_bootstrap_status = dict(cloud_library_service.bootstrap_status or {}) + @app.middleware("http") + async def _compatibility_header_aliases(request: Request, call_next): + # Accept MaterialScope header names while preserving legacy backend contracts. + headers = list(request.scope.get("headers") or []) + header_lookup = {key.lower(): value for key, value in headers} + materialscope_token = header_lookup.get(b"x-materialscope-token") + materialscope_license = header_lookup.get(b"x-materialscope-license") + if materialscope_token and b"x-ta-token" not in header_lookup: + headers.append((b"x-ta-token", materialscope_token)) + if materialscope_license and b"x-ta-license" not in header_lookup: + headers.append((b"x-ta-license", materialscope_license)) + request.scope["headers"] = headers + return await call_next(request) + def _record_cloud_lookup_success(payload: dict[str, Any]) -> None: access_mode = str(payload.get("library_access_mode") or "").strip() modality = str(payload.get("analysis_type") or "").strip().upper() or None @@ -472,10 +830,10 @@ def workspace_results( ) -> ResultsListResponse: _require_token(api_token, x_ta_token) state = _require_project_state(project_store, project_id) - valid_results, _issues = split_valid_results(state.get("results", {})) + valid_results, issues = split_valid_results(state.get("results", {})) items = [summarize_result(record) for record in valid_results.values()] items.sort(key=lambda item: item.id) - return ResultsListResponse(project_id=project_id, results=items) + return ResultsListResponse(project_id=project_id, results=items, issues=issues) @app.get("/workspace/{project_id}/datasets/{dataset_key}", response_model=DatasetDetailResponse) def dataset_detail( @@ -491,6 +849,20 @@ def dataset_detail( raise HTTPException(status_code=404, detail=str(exc)) from exc return DatasetDetailResponse(project_id=project_id, **payload) + @app.get("/workspace/{project_id}/datasets/{dataset_key}/data", response_model=DatasetDataResponse) + def dataset_data( + project_id: str, + dataset_key: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> DatasetDataResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + try: + payload = build_dataset_data(state, dataset_key) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return DatasetDataResponse(project_id=project_id, **payload) + @app.get("/workspace/{project_id}/results/{result_id}", response_model=ResultDetailResponse) def result_detail( project_id: str, @@ -507,6 +879,106 @@ def result_detail( raise HTTPException(status_code=400, detail=str(exc)) from exc return ResultDetailResponse(project_id=project_id, **payload) + @app.get("/workspace/{project_id}/analysis-state/{analysis_type}/{dataset_key}", response_model=AnalysisStateCurvesResponse) + def analysis_state_curves( + project_id: str, + analysis_type: str, + dataset_key: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> AnalysisStateCurvesResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + datasets = state.get("datasets", {}) or {} + dataset = datasets.get(dataset_key) + if dataset is None: + raise HTTPException(status_code=404, detail=f"Unknown dataset_key: {dataset_key}") + + try: + skey = analysis_state_key(analysis_type, dataset_key) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + analysis_state = state.get(skey) + if analysis_state is None: + return AnalysisStateCurvesResponse( + project_id=project_id, dataset_key=dataset_key, + analysis_type=analysis_type.upper(), + ) + + import numpy as np + + def _to_list(arr: Any) -> list[float]: + if arr is None: + return [] + a = np.asarray(arr, dtype=float) + a = np.where(np.isfinite(a), a, None) + return a.tolist() + + frame = getattr(dataset, "data", None) + temperature = [] + raw_signal = [] + if frame is not None and "temperature" in frame.columns and "signal" in frame.columns: + raw_axis = np.asarray(frame["temperature"], dtype=float) + raw_values = np.asarray(frame["signal"], dtype=float) + finite_mask = np.isfinite(raw_axis) & np.isfinite(raw_values) + raw_axis = raw_axis[finite_mask] + raw_values = raw_values[finite_mask] + if raw_axis.size: + order = np.argsort(raw_axis) + raw_axis = raw_axis[order] + raw_values = raw_values[order] + unique_axis, unique_idx = np.unique(raw_axis, return_index=True) + temperature = unique_axis.tolist() + raw_signal = raw_values[unique_idx].tolist() + + state_axis = _to_list(analysis_state.get("axis")) + if state_axis: + temperature = state_axis + if raw_signal and len(raw_signal) != len(temperature): + raw_signal = [] + + smoothed = _to_list(analysis_state.get("smoothed")) + baseline = _to_list(analysis_state.get("baseline")) + corrected = _to_list(analysis_state.get("corrected")) + normalized = _to_list(analysis_state.get("normalized")) + dtg = _to_list(analysis_state.get("dtg")) + raw_peaks = analysis_state.get("peaks") or [] + if not isinstance(raw_peaks, list): + raw_peaks = [] + + def _peak_to_dict(peak: Any) -> dict[str, Any]: + if isinstance(peak, dict): + return peak + if hasattr(peak, "__dict__"): + return vars(peak) + if hasattr(peak, "_asdict"): + return peak._asdict() + return {} + + peaks = [_peak_to_dict(p) for p in raw_peaks] + diagnostics = analysis_state.get("diagnostics") or {} + + return AnalysisStateCurvesResponse( + project_id=project_id, + dataset_key=dataset_key, + analysis_type=analysis_type.upper(), + temperature=temperature, + raw_signal=raw_signal, + smoothed=smoothed, + baseline=baseline, + corrected=corrected, + normalized=normalized, + dtg=dtg, + peaks=peaks, + has_smoothed=bool(smoothed), + has_baseline=bool(baseline), + has_corrected=bool(corrected), + has_normalized=bool(normalized), + has_dtg=bool(dtg), + has_peaks=bool(peaks), + diagnostics=diagnostics, + ) + @app.post("/workspace/{project_id}/results/{result_id}/literature/compare", response_model=LiteratureCompareResponse) def result_literature_compare( project_id: str, @@ -525,8 +997,25 @@ def result_literature_compare( raise HTTPException(status_code=404, detail=f"Unknown result_id: {result_id}") provider_ids = [str(item).strip() for item in (request.provider_ids or []) if str(item).strip()] - if not provider_ids and str(record.get("analysis_type") or "").upper() in {"XRD", "DSC", "DTA", "TGA", "FTIR"}: + if not provider_ids and str(record.get("analysis_type") or "").upper() in { + "XRD", + "DSC", + "DTA", + "TGA", + "FTIR", + "RAMAN", + }: provider_ids = ["openalex_like_provider"] + use_fixture_fallback = ( + provider_ids == ["openalex_like_provider"] + and not openalex_literature_env_configured() + and literature_fixture_fallback_enabled() + ) + if use_fixture_fallback: + provider_ids = ["openalex_like_provider", "fixture_provider"] + compare_filters: dict[str, Any] = dict(request.filters or {}) + if use_fixture_fallback: + compare_filters["allow_fixture_fallback"] = True try: providers, provider_scope = resolve_literature_providers( provider_ids, @@ -541,7 +1030,7 @@ def result_literature_compare( provider=provider, provider_scope=provider_scope, max_claims=request.max_claims, - filters=request.filters, + filters=compare_filters, user_documents=[_model_payload(item) for item in request.user_documents], ) @@ -571,6 +1060,227 @@ def result_literature_compare( detail=ResultDetailResponse(project_id=project_id, **detail_payload) if detail_payload else None, ) + @app.post( + "/workspace/{project_id}/results/{result_id}/figure", + response_model=ResultFigureRegisterResponse, + ) + def result_register_figure( + project_id: str, + result_id: str, + request: ResultFigureRegisterRequest, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> ResultFigureRegisterResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + + label = (request.figure_label or "").strip() + if not label: + raise HTTPException(status_code=400, detail="figure_label must be non-empty.") + + results = state.get("results") or {} + record = results.get(result_id) + if record is None: + raise HTTPException(status_code=404, detail=f"Unknown result_id: {result_id}") + + png_bytes = _decode_base64_field(request.figure_png_base64, field_name="figure_png_base64") + if not png_bytes: + raise HTTPException(status_code=400, detail="figure_png_base64 decoded to empty bytes.") + + figures = state.setdefault("figures", {}) + if label in figures and not request.replace: + raise HTTPException( + status_code=409, + detail=f"figure_label '{label}' already exists; pass replace=true to overwrite.", + ) + figures[label] = png_bytes + + artifacts = dict(record.get("artifacts") or {}) + existing = artifacts.get("figure_keys") + keys: list[str] = [] + if isinstance(existing, list): + for key in existing: + if isinstance(key, str) and key and key not in keys: + keys.append(key) + if label not in keys: + keys.append(label) + artifacts["figure_keys"] = keys + current_primary = artifacts.get("report_figure_key") + if request.replace or not (isinstance(current_primary, str) and current_primary): + artifacts["report_figure_key"] = label + artifacts["report_figure_status"] = "captured" + artifacts["report_figure_error"] = "" + record = dict(record) + record["artifacts"] = artifacts + state.setdefault("results", {})[result_id] = record + + add_history_event( + state, + action="Figure Captured", + details=f"Figure '{label}' registered for {result_id}", + dataset_key=record.get("dataset_key"), + result_id=result_id, + ) + project_store.set(project_id, state) + + return ResultFigureRegisterResponse( + project_id=project_id, + result_id=result_id, + figure_key=label, + figure_keys=keys, + ) + + @app.get("/workspace/{project_id}/results/{result_id}/figure") + def result_get_figure_png( + project_id: str, + result_id: str, + figure_key: str = Query(..., min_length=1, description="Registered figure label for this result"), + max_edge: int | None = Query( + default=None, + ge=16, + le=4096, + description="Optional: downscale so max(width,height) <= max_edge (Slice 6 previews).", + ), + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> Response: + """Return stored PNG bytes for a figure key linked to this result (Slice 5 previews).""" + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + results = state.get("results") or {} + record = results.get(result_id) + if record is None: + raise HTTPException(status_code=404, detail=f"Unknown result_id: {result_id}") + + label = str(figure_key or "").strip() + if not label: + raise HTTPException(status_code=400, detail="figure_key must be non-empty.") + + artifacts = dict(record.get("artifacts") or {}) + allowed: set[str] = set() + raw_keys = artifacts.get("figure_keys") + if isinstance(raw_keys, list): + for item in raw_keys: + if isinstance(item, str) and item.strip(): + allowed.add(item.strip()) + pk = artifacts.get("report_figure_key") + if isinstance(pk, str) and pk.strip(): + allowed.add(pk.strip()) + + if label not in allowed: + raise HTTPException( + status_code=404, + detail="figure_key is not registered for this result.", + ) + + figures = state.get("figures") or {} + png_bytes = figures.get(label) + if not isinstance(png_bytes, (bytes, bytearray)) or len(png_bytes) == 0: + raise HTTPException(status_code=404, detail="Figure bytes are not available in the workspace store.") + + body = bytes(png_bytes) + if max_edge is not None: + from core.figure_preview_resize import maybe_downscale_png_to_max_edge + + body = maybe_downscale_png_to_max_edge(body, int(max_edge)) + + return Response( + content=body, + media_type="image/png", + headers={"Cache-Control": "private, max-age=60"}, + ) + + @app.get("/presets/{analysis_type}", response_model=PresetListResponse) + def presets_list( + analysis_type: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> PresetListResponse: + _require_token(api_token, x_ta_token) + from core.preset_store import ( + MAX_PRESETS_PER_ANALYSIS, + PresetStoreError, + count_presets, + list_presets, + ) + + try: + items = list_presets(analysis_type) + count = count_presets(analysis_type) + except PresetStoreError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return PresetListResponse( + analysis_type=analysis_type.strip().upper(), + count=count, + max_count=MAX_PRESETS_PER_ANALYSIS, + presets=[PresetSummary(**row) for row in items], + ) + + @app.post("/presets/{analysis_type}", response_model=PresetSaveResponse) + def presets_save( + analysis_type: str, + request: PresetSaveRequest, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> PresetSaveResponse: + _require_token(api_token, x_ta_token) + from core.preset_store import PresetLimitError, PresetStoreError, save_preset + + payload = { + "workflow_template_id": request.workflow_template_id, + "processing": dict(request.processing or {}), + } + try: + result = save_preset(analysis_type, request.preset_name, payload) + except PresetLimitError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except PresetStoreError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return PresetSaveResponse(**result) + + @app.get("/presets/{analysis_type}/{preset_name}", response_model=PresetLoadResponse) + def presets_load( + analysis_type: str, + preset_name: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> PresetLoadResponse: + _require_token(api_token, x_ta_token) + from core.preset_store import PresetStoreError, load_preset + + try: + payload = load_preset(analysis_type, preset_name) + except PresetStoreError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if payload is None: + raise HTTPException(status_code=404, detail=f"Preset not found: {preset_name}") + + return PresetLoadResponse( + analysis_type=analysis_type.strip().upper(), + preset_name=preset_name.strip(), + workflow_template_id=str(payload.get("workflow_template_id") or ""), + processing=dict(payload.get("processing") or {}), + ) + + @app.delete("/presets/{analysis_type}/{preset_name}", response_model=PresetDeleteResponse) + def presets_delete( + analysis_type: str, + preset_name: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> PresetDeleteResponse: + _require_token(api_token, x_ta_token) + from core.preset_store import PresetStoreError, delete_preset + + try: + deleted = delete_preset(analysis_type, preset_name) + except PresetStoreError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not deleted: + raise HTTPException(status_code=404, detail=f"Preset not found: {preset_name}") + + return PresetDeleteResponse( + analysis_type=analysis_type.strip().upper(), + preset_name=preset_name.strip(), + deleted=True, + ) + @app.get("/workspace/{project_id}/compare", response_model=CompareWorkspaceResponse) def compare_workspace_get( project_id: str, @@ -645,6 +1355,20 @@ def workspace_active_dataset_set( active_dataset=active_dataset, ) + @app.delete("/workspace/{project_id}/datasets/{dataset_key}", response_model=WorkspaceSummaryResponse) + def workspace_dataset_delete( + project_id: str, + dataset_key: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> WorkspaceSummaryResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + removed = remove_dataset_from_workspace(state, dataset_key) + if not removed: + raise HTTPException(status_code=404, detail=f"Unknown dataset_key: {dataset_key}") + project_store.set(project_id, state) + return WorkspaceSummaryResponse(project_id=project_id, summary=_project_summary(state)) + @app.post("/workspace/{project_id}/batch/run", response_model=BatchRunResponse) def batch_run( project_id: str, @@ -673,6 +1397,35 @@ def batch_run( outcomes = execution["outcomes"] saved_result_ids = execution["saved_result_ids"] + if saved_result_ids: + for saved_result_id in saved_result_ids: + record = (state.get("results") or {}).get(saved_result_id) + if not isinstance(record, dict): + continue + dataset_key = str(record.get("dataset_key") or "").strip() + if not dataset_key: + continue + state_payload = state.get(analysis_state_key(analysis_type, dataset_key)) or {} + try: + _auto_register_result_snapshot( + state, + result_id=saved_result_id, + record=record, + state_payload=state_payload if isinstance(state_payload, dict) else {}, + analysis_type=analysis_type, + ) + except Exception as exc: + rec = (state.get("results") or {}).get(saved_result_id) or record + _merge_result_artifacts( + state, + saved_result_id, + rec, + artifacts_update={ + "report_figure_status": "failed", + "report_figure_error": f"exception:{exc.__class__.__name__}:{exc}", + }, + ) + completed_at = datetime.now().isoformat(timespec="seconds") compare_workspace = state.setdefault("comparison_workspace", {}) @@ -720,6 +1473,92 @@ def export_preparation( payload = build_export_preparation(state) return ExportPreparationResponse(project_id=project_id, summary=_project_summary(state), **payload) + @app.get("/workspace/{project_id}/exports/support-snapshot") + def export_support_snapshot( + project_id: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> Response: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + snapshot_state = dict(state) + snapshot_state["license_state"] = _backend_license_state() + log_path = snapshot_state.get("diagnostics_log_path") or get_default_log_file() + try: + body = serialize_support_snapshot(snapshot_state, app_version=APP_VERSION, log_file=log_path) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Support snapshot failed: {exc}") from exc + return Response( + content=body, + media_type="application/json", + headers={"Content-Disposition": 'attachment; filename="materialscope_support_snapshot.json"'}, + ) + + @app.get("/workspace/{project_id}/branding", response_model=WorkspaceBrandingResponse) + def workspace_branding_get( + project_id: str, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> WorkspaceBrandingResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + branding = normalize_branding_payload(state.get("branding")) + return WorkspaceBrandingResponse( + project_id=project_id, + summary=_project_summary(state), + branding={ + "report_title": branding.get("report_title") or "MaterialScope Professional Report", + "company_name": branding.get("company_name") or "", + "lab_name": branding.get("lab_name") or "", + "analyst_name": branding.get("analyst_name") or "", + "report_notes": branding.get("report_notes") or "", + "logo_name": branding.get("logo_name") or "", + "logo_base64": ( + base64.b64encode(branding["logo_bytes"]).decode("ascii") + if branding.get("logo_bytes") + else None + ), + }, + ) + + @app.put("/workspace/{project_id}/branding", response_model=WorkspaceBrandingResponse) + def workspace_branding_put( + project_id: str, + request: WorkspaceBrandingUpdateRequest, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> WorkspaceBrandingResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + branding = normalize_branding_payload(state.get("branding")) + request_payload = _model_payload(request) + for key in ("report_title", "company_name", "lab_name", "analyst_name", "report_notes", "logo_name"): + value = request_payload.get(key) + if value is not None: + branding[key] = str(value) + if request.clear_logo: + branding["logo_bytes"] = None + branding["logo_name"] = "" + elif request.logo_base64 is not None: + branding["logo_bytes"] = _decode_base64_field(request.logo_base64, field_name="logo_base64") + branding["logo_name"] = str(request.logo_name or branding.get("logo_name") or "branding_logo") + state["branding"] = branding + project_store.set(project_id, state) + return WorkspaceBrandingResponse( + project_id=project_id, + summary=_project_summary(state), + branding={ + "report_title": branding.get("report_title") or "MaterialScope Professional Report", + "company_name": branding.get("company_name") or "", + "lab_name": branding.get("lab_name") or "", + "analyst_name": branding.get("analyst_name") or "", + "report_notes": branding.get("report_notes") or "", + "logo_name": branding.get("logo_name") or "", + "logo_base64": ( + base64.b64encode(branding["logo_bytes"]).decode("ascii") + if branding.get("logo_bytes") + else None + ), + }, + ) + @app.post("/workspace/{project_id}/exports/results-csv", response_model=ExportArtifactResponse) def export_results_csv( project_id: str, @@ -734,6 +1573,20 @@ def export_results_csv( raise HTTPException(status_code=400, detail=str(exc)) from exc return ExportArtifactResponse(project_id=project_id, **payload) + @app.post("/workspace/{project_id}/exports/results-xlsx", response_model=ExportArtifactResponse) + def export_results_xlsx( + project_id: str, + request: ExportGenerateRequest, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> ExportArtifactResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + try: + payload = generate_results_xlsx_artifact(state, selected_result_ids=request.selected_result_ids) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return ExportArtifactResponse(project_id=project_id, **payload) + @app.post("/workspace/{project_id}/exports/report-docx", response_model=ExportArtifactResponse) def export_report_docx( project_id: str, @@ -743,7 +1596,29 @@ def export_report_docx( _require_token(api_token, x_ta_token) state = _require_project_state(project_store, project_id) try: - payload = generate_report_docx_artifact(state, selected_result_ids=request.selected_result_ids) + payload = generate_report_docx_artifact( + state, + selected_result_ids=request.selected_result_ids, + include_figures=bool(request.include_figures), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return ExportArtifactResponse(project_id=project_id, **payload) + + @app.post("/workspace/{project_id}/exports/report-pdf", response_model=ExportArtifactResponse) + def export_report_pdf( + project_id: str, + request: ExportGenerateRequest, + x_ta_token: str | None = Header(default=None, alias="X-TA-Token"), + ) -> ExportArtifactResponse: + _require_token(api_token, x_ta_token) + state = _require_project_state(project_store, project_id) + try: + payload = generate_report_pdf_artifact( + state, + selected_result_ids=request.selected_result_ids, + include_figures=bool(request.include_figures), + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return ExportArtifactResponse(project_id=project_id, **payload) @@ -761,7 +1636,12 @@ def dataset_import( source.name = request.file_name try: - dataset = read_thermal_data(source, data_type=request.data_type, metadata=request.metadata) + dataset = read_thermal_data( + source, + column_mapping=request.column_mapping or None, + data_type=request.data_type, + metadata=request.metadata, + ) except Exception as exc: raise HTTPException(status_code=400, detail=f"Dataset import failed: {exc}") from exc @@ -810,6 +1690,16 @@ def analysis_run( ) -> AnalysisRunResponse: _require_token(api_token, x_ta_token) state = _require_project_state(project_store, request.project_id) + if request.processing_overrides: + try: + _apply_processing_overrides( + state, + analysis_type=request.analysis_type, + dataset_key=request.dataset_key, + overrides=request.processing_overrides, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc try: execution = run_single_analysis( state=state, @@ -817,6 +1707,7 @@ def analysis_run( analysis_type=request.analysis_type, workflow_template_id=request.workflow_template_id, app_version=APP_VERSION, + unit_mode=request.unit_mode, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -830,10 +1721,30 @@ def analysis_run( validation = execution["validation"] record = execution["record"] or {} state_key = execution["state_key"] + state_payload = execution["state_payload"] or {} if execution_status == "saved" and result_id: state.setdefault("results", {})[result_id] = record - state[state_key] = execution["state_payload"] or {} + state[state_key] = state_payload + try: + _auto_register_result_snapshot( + state, + result_id=result_id, + record=record, + state_payload=state_payload if isinstance(state_payload, dict) else {}, + analysis_type=analysis_type, + ) + except Exception as exc: + rec = (state.get("results") or {}).get(result_id) or record + _merge_result_artifacts( + state, + result_id, + rec, + artifacts_update={ + "report_figure_status": "failed", + "report_figure_error": f"exception:{exc.__class__.__name__}:{exc}", + }, + ) add_history_event( state, action="Analysis Saved", diff --git a/backend/detail.py b/backend/detail.py index 1df9c7c0..27b12911 100644 --- a/backend/detail.py +++ b/backend/detail.py @@ -15,10 +15,27 @@ from core.validation import validate_thermal_dataset -def _records_preview(frame: pd.DataFrame, *, limit: int = 20) -> list[dict[str, Any]]: - preview = frame.head(limit).copy() - preview = preview.where(pd.notna(preview), None) - return preview.to_dict(orient="records") +def _figure_artifacts_meta(artifacts: Any) -> dict[str, Any]: + """Expose figure registration metadata without binary payloads.""" + art = artifacts if isinstance(artifacts, dict) else {} + raw_keys = art.get("figure_keys") + keys: list[str] = [] + if isinstance(raw_keys, list): + for item in raw_keys: + if isinstance(item, str) and item.strip() and item not in keys: + keys.append(item) + return { + "figure_keys": keys, + "report_figure_key": art.get("report_figure_key"), + "report_figure_status": art.get("report_figure_status"), + "report_figure_error": art.get("report_figure_error"), + } + + +def _records_payload(frame: pd.DataFrame, *, limit: int | None = None) -> list[dict[str, Any]]: + payload = frame.head(limit).copy() if limit is not None else frame.copy() + payload = payload.where(pd.notna(payload), None) + return payload.to_dict(orient="records") def _dataset_matches_analysis(dataset: Any, analysis_type: str) -> bool: @@ -60,11 +77,25 @@ def build_dataset_detail(state: dict[str, Any], dataset_key: str) -> dict[str, A "metadata": copy.deepcopy(getattr(dataset, "metadata", {}) or {}), "units": copy.deepcopy(getattr(dataset, "units", {}) or {}), "original_columns": copy.deepcopy(getattr(dataset, "original_columns", {}) or {}), - "data_preview": _records_preview(getattr(dataset, "data")), + "data_preview": _records_payload(getattr(dataset, "data"), limit=20), "compare_selected": dataset_key in selected_datasets, } +def build_dataset_data(state: dict[str, Any], dataset_key: str) -> dict[str, Any]: + datasets = state.get("datasets", {}) or {} + dataset = datasets.get(dataset_key) + if dataset is None: + raise KeyError(f"Unknown dataset_key: {dataset_key}") + + frame = getattr(dataset, "data") + return { + "dataset_key": dataset_key, + "columns": [str(column) for column in frame.columns], + "rows": _records_payload(frame), + } + + def build_result_detail(state: dict[str, Any], result_id: str) -> dict[str, Any]: results = state.get("results", {}) or {} valid_results, issues = split_valid_results(results) @@ -88,8 +119,10 @@ def build_result_detail(state: dict[str, Any], result_id: str) -> dict[str, Any] "literature_claims": copy.deepcopy(record.get("literature_claims") or []), "literature_comparisons": copy.deepcopy(record.get("literature_comparisons") or []), "citations": copy.deepcopy(record.get("citations") or []), - "rows_preview": _records_preview(frame) if not frame.empty else [], + "rows": copy.deepcopy(rows), + "rows_preview": _records_payload(frame, limit=20) if not frame.empty else [], "row_count": len(rows), + "figure_artifacts": _figure_artifacts_meta(record.get("artifacts")), } diff --git a/backend/exports.py b/backend/exports.py index 76a31320..529654f4 100644 --- a/backend/exports.py +++ b/backend/exports.py @@ -4,11 +4,15 @@ import base64 import copy +import io from typing import Any from backend.detail import normalize_compare_workspace -from backend.workspace import summarize_result -from core.report_generator import generate_csv_summary, generate_docx_report +import pandas as pd + +from backend.workspace import normalize_branding_payload, summarize_result +from core.report_generator import generate_csv_summary, generate_docx_report, generate_pdf_report +from core.result_serialization import collect_figure_keys from core.result_serialization import split_valid_results @@ -42,16 +46,24 @@ def build_export_preparation(state: dict[str, Any]) -> dict[str, Any]: valid_results, issues = split_valid_results((state.get("results") or {})) items = [summarize_result(record) for record in valid_results.values()] items.sort(key=lambda item: item.id) - branding = copy.deepcopy(state.get("branding") or {}) + branding = normalize_branding_payload(state.get("branding")) return { "exportable_results": items, "skipped_record_issues": issues, - "supported_outputs": ["results_csv", "report_docx"], + "supported_outputs": ["results_csv", "results_xlsx", "report_docx", "report_pdf"], "branding": { "report_title": branding.get("report_title") or "MaterialScope Professional Report", "company_name": branding.get("company_name") or "", "lab_name": branding.get("lab_name") or "", "analyst_name": branding.get("analyst_name") or "", + "report_notes": branding.get("report_notes") or "", + "logo_name": branding.get("logo_name") or "", + "has_logo": bool(branding.get("logo_bytes")), + "logo_base64": ( + base64.b64encode(branding["logo_bytes"]).decode("ascii") + if branding.get("logo_bytes") + else None + ), }, "compare_workspace": normalize_compare_workspace(state), } @@ -72,7 +84,110 @@ def generate_results_csv_artifact(state: dict[str, Any], *, selected_result_ids: } -def generate_report_docx_artifact(state: dict[str, Any], *, selected_result_ids: list[str] | None) -> dict[str, Any]: +def _results_to_xlsx_bytes(results: dict[str, dict[str, Any]], issues: list[str]) -> bytes: + buffer = io.BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + summary_rows = [] + for record in results.values(): + row = { + "result_id": record["id"], + "status": record["status"], + "analysis_type": record["analysis_type"], + "dataset_key": record.get("dataset_key"), + "workflow_template": (record.get("processing") or {}).get("workflow_template"), + "validation_status": (record.get("validation") or {}).get("status"), + "saved_at_utc": (record.get("provenance") or {}).get("saved_at_utc"), + } + row.update(record.get("summary", {})) + summary_rows.append(row) + + summary_df = pd.DataFrame(summary_rows) if summary_rows else pd.DataFrame([{"message": "No valid results"}]) + summary_df.to_excel(writer, sheet_name="Results", index=False) + + for record in results.values(): + if not record.get("rows"): + continue + sheet_name = f"{record['analysis_type']}_{record['id']}"[:31] + pd.DataFrame(record["rows"]).to_excel(writer, sheet_name=sheet_name, index=False) + + if issues: + pd.DataFrame({"issue": issues}).to_excel(writer, sheet_name="Skipped", index=False) + + buffer.seek(0) + return buffer.getvalue() + + +def generate_results_xlsx_artifact(state: dict[str, Any], *, selected_result_ids: list[str] | None) -> dict[str, Any]: + valid_results, issues = split_valid_results((state.get("results") or {})) + selected = _selected_records(valid_results, selected_result_ids) + xlsx_bytes = _results_to_xlsx_bytes(selected, issues) + xlsx_base64 = base64.b64encode(xlsx_bytes).decode("ascii") + return { + "output_type": "results_xlsx", + "file_name": "materialscope_results.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "included_result_ids": list(selected.keys()), + "skipped_record_issues": issues, + "artifact_base64": xlsx_base64, + } + + +def _selected_figures( + state: dict[str, Any], + selected_results: dict[str, dict[str, Any]], + *, + include_figures: bool, +) -> dict[str, bytes] | None: + if not include_figures: + return None + + figure_keys = collect_figure_keys(selected_results) + compare_figure = ((state.get("comparison_workspace") or {}).get("figure_key")) + if compare_figure and compare_figure not in figure_keys: + figure_keys.append(compare_figure) + if not figure_keys: + return None + + stored_figures = state.get("figures") or {} + figures = {key: stored_figures[key] for key in figure_keys if key in stored_figures} + return figures or None + + +def collect_figure_export_warnings( + state: dict[str, Any], + selected_results: dict[str, dict[str, Any]], + *, + include_figures: bool, + figures_bundle: dict[str, bytes] | None, +) -> list[str]: + """Surface missing PNG bytes or failed server-side figure capture for exports.""" + warnings: list[str] = [] + if not include_figures: + return warnings + bundle = figures_bundle or {} + stored = state.get("figures") or {} + for rid, rec in selected_results.items(): + art = rec.get("artifacts") or {} + atype = str(rec.get("analysis_type") or "").upper() + primary = art.get("report_figure_key") + status = art.get("report_figure_status") + err = str(art.get("report_figure_error") or "").strip() + if status == "failed" and err: + warnings.append(f"{atype} result {rid}: figure capture failed ({err}).") + if primary and primary not in bundle: + where = "project workspace" if primary not in stored else "export selection" + warnings.append( + f"{atype} result {rid}: primary figure '{primary}' was not embedded ({where} missing PNG bytes)." + ) + return warnings + + +def generate_report_docx_artifact( + state: dict[str, Any], + *, + selected_result_ids: list[str] | None, + include_figures: bool = True, +) -> dict[str, Any]: valid_results, issues = split_valid_results((state.get("results") or {})) selected = _selected_records(valid_results, selected_result_ids) normalized_workspace = normalize_compare_workspace(state) @@ -80,13 +195,21 @@ def generate_report_docx_artifact(state: dict[str, Any], *, selected_result_ids: comparison_workspace = normalized_workspace.model_dump() else: # pragma: no cover - pydantic v1 compatibility comparison_workspace = normalized_workspace.dict() + figures = _selected_figures(state, selected, include_figures=include_figures) + export_warnings = collect_figure_export_warnings( + state, + selected, + include_figures=include_figures, + figures_bundle=figures, + ) docx_bytes = generate_docx_report( results=selected, datasets=state.get("datasets") or {}, - figures=state.get("figures") or {}, + figures=figures, branding=state.get("branding") or {}, comparison_workspace=comparison_workspace, license_state=state.get("license_state") or {}, + figure_export_warnings=export_warnings, ) docx_base64 = base64.b64encode(docx_bytes).decode("ascii") return { @@ -96,4 +219,46 @@ def generate_report_docx_artifact(state: dict[str, Any], *, selected_result_ids: "included_result_ids": list(selected.keys()), "skipped_record_issues": issues, "artifact_base64": docx_base64, + "export_warnings": export_warnings, + } + + +def generate_report_pdf_artifact( + state: dict[str, Any], + *, + selected_result_ids: list[str] | None, + include_figures: bool = True, +) -> dict[str, Any]: + valid_results, issues = split_valid_results((state.get("results") or {})) + selected = _selected_records(valid_results, selected_result_ids) + normalized_workspace = normalize_compare_workspace(state) + if hasattr(normalized_workspace, "model_dump"): + comparison_workspace = normalized_workspace.model_dump() + else: # pragma: no cover - pydantic v1 compatibility + comparison_workspace = normalized_workspace.dict() + figures = _selected_figures(state, selected, include_figures=include_figures) + export_warnings = collect_figure_export_warnings( + state, + selected, + include_figures=include_figures, + figures_bundle=figures, + ) + pdf_bytes = generate_pdf_report( + results=selected, + datasets=state.get("datasets") or {}, + figures=figures, + branding=state.get("branding") or {}, + comparison_workspace=comparison_workspace, + license_state=state.get("license_state") or {}, + figure_export_warnings=export_warnings, + ) + pdf_base64 = base64.b64encode(pdf_bytes).decode("ascii") + return { + "output_type": "report_pdf", + "file_name": "materialscope_report.pdf", + "mime_type": "application/pdf", + "included_result_ids": list(selected.keys()), + "skipped_record_issues": issues, + "artifact_base64": pdf_base64, + "export_warnings": export_warnings, } diff --git a/backend/library_cloud_service.py b/backend/library_cloud_service.py index a77f5bb6..c1ea8663 100644 --- a/backend/library_cloud_service.py +++ b/backend/library_cloud_service.py @@ -33,9 +33,12 @@ _TOKEN_TTL_SECONDS = 15 * 60 _DEFAULT_RATE_LIMIT_PER_MINUTE = 60 -_TOKEN_SECRET_ENV = "THERMOANALYZER_LIBRARY_CLOUD_TOKEN_SECRET" -_TOKEN_TTL_ENV = "THERMOANALYZER_LIBRARY_CLOUD_TOKEN_TTL_SECONDS" -_RATE_LIMIT_ENV = "THERMOANALYZER_LIBRARY_CLOUD_RATE_LIMIT_PER_MINUTE" +_TOKEN_SECRET_ENV = "MATERIALSCOPE_LIBRARY_CLOUD_TOKEN_SECRET" +_TOKEN_SECRET_ENV_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_TOKEN_SECRET" +_TOKEN_TTL_ENV = "MATERIALSCOPE_LIBRARY_CLOUD_TOKEN_TTL_SECONDS" +_TOKEN_TTL_ENV_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_TOKEN_TTL_SECONDS" +_RATE_LIMIT_ENV = "MATERIALSCOPE_LIBRARY_CLOUD_RATE_LIMIT_PER_MINUTE" +_RATE_LIMIT_ENV_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_RATE_LIMIT_PER_MINUTE" _AUDIT_FILE_NAME = "cloud_library_audit.jsonl" _ALLOWED_STATUSES = {"trial", "activated"} _REQUIRED_CLOUD_MODALITIES = ("FTIR", "RAMAN", "XRD") @@ -66,12 +69,16 @@ def _b64url_decode(token: str) -> bytes: def _token_secret() -> bytes: - secret = str(os.getenv(_TOKEN_SECRET_ENV, "thermoanalyzer-cloud-dev-secret")).strip() + secret = str( + os.getenv(_TOKEN_SECRET_ENV, "") + or os.getenv(_TOKEN_SECRET_ENV_LEGACY, "") + or "materialscope-cloud-dev-secret" + ).strip() return secret.encode("utf-8") def _token_ttl_seconds() -> int: - raw = str(os.getenv(_TOKEN_TTL_ENV, _TOKEN_TTL_SECONDS)).strip() + raw = str(os.getenv(_TOKEN_TTL_ENV, "") or os.getenv(_TOKEN_TTL_ENV_LEGACY, _TOKEN_TTL_SECONDS)).strip() try: return max(60, int(raw)) except (TypeError, ValueError): @@ -79,7 +86,10 @@ def _token_ttl_seconds() -> int: def _rate_limit_per_minute() -> int: - raw = str(os.getenv(_RATE_LIMIT_ENV, _DEFAULT_RATE_LIMIT_PER_MINUTE)).strip() + raw = str( + os.getenv(_RATE_LIMIT_ENV, "") + or os.getenv(_RATE_LIMIT_ENV_LEGACY, _DEFAULT_RATE_LIMIT_PER_MINUTE) + ).strip() try: return max(1, int(raw)) except (TypeError, ValueError): @@ -115,7 +125,10 @@ def __init__( self.manager = manager or get_reference_library_manager() if hosted_catalog is None: bootstrap_status = ensure_local_dev_hosted_catalog( - dev_mode=_truthy(os.getenv("THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH", "")), + dev_mode=_truthy( + os.getenv("MATERIALSCOPE_LIBRARY_DEV_CLOUD_AUTH", "") + or os.getenv("THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH", "") + ), ) self.hosted_catalog = HostedLibraryCatalog() if str(bootstrap_status.get("state")) in {"upgraded", "published"}: @@ -176,7 +189,10 @@ def _issue_access_token(self, *, encoded_license: str, state: Mapping[str, Any]) def issue_token(self, *, x_ta_license: str | None) -> dict[str, Any]: if not x_ta_license: - raise HTTPException(status_code=401, detail="Missing X-TA-License header.") + raise HTTPException( + status_code=401, + detail="Missing X-MaterialScope-License (or legacy X-TA-License) header.", + ) try: state = validate_encoded_license_key( x_ta_license, @@ -630,20 +646,21 @@ def search_spectral( raise HTTPException(status_code=400, detail="axis and signal arrays are required and must have equal length.") axis, signal = _sorted_axis_signal(axis, signal) smoothed = _apply_spectral_smoothing(signal, {"method": "none"}) - normalized_signal = _normalize_spectral_signal(smoothed, {"method": "vector"}) + normalized_signal, _, _ = _normalize_spectral_signal(smoothed, {"method": "vector"}) top_n = max(1, int(request_payload.get("top_n") or 5)) minimum_score = float(request_payload.get("minimum_score") or 0.45) + metric = str(request_payload.get("metric") or "cosine").strip().lower() or "cosine" peak_config = {"prominence": 0.05, "min_distance": 6, "max_peaks": 12} - observed_peaks = _detect_spectral_peaks(axis, normalized_signal, peak_config) + observed_peaks, _, _ = _detect_spectral_peaks(axis, normalized_signal, peak_config) top_n_internal = top_n if token != "RAMAN" else max(top_n * 5, 10) references = self.hosted_catalog.load_entries(token) reference_lookup = self._reference_lookup(references) rows = _rank_spectral_matches( axis=axis, - normalized_signal=normalized_signal, + query_signal=normalized_signal, observed_peaks=observed_peaks, references=references, - matching_config={"top_n": top_n_internal, "minimum_score": minimum_score}, + matching_config={"metric": metric, "top_n": top_n_internal, "minimum_score": minimum_score}, peak_config=peak_config, ) rows = self._attach_hosted_row_provenance(rows, reference_lookup=reference_lookup) diff --git a/backend/library_feed.py b/backend/library_feed.py index b64fe308..90c71317 100644 --- a/backend/library_feed.py +++ b/backend/library_feed.py @@ -26,7 +26,7 @@ def _load_manifest(root: Path) -> dict: def _require_feed_license(x_ta_license: str | None) -> dict: if not x_ta_license: - raise HTTPException(status_code=401, detail="Missing X-TA-License header.") + raise HTTPException(status_code=401, detail="Missing X-MaterialScope-License (or legacy X-TA-License) header.") try: state = validate_encoded_license_key( x_ta_license, @@ -43,18 +43,19 @@ def _require_feed_license(x_ta_license: str | None) -> dict: def create_library_feed_app(*, mirror_root: str | Path | None = None) -> FastAPI: root = _mirror_root(mirror_root) - app = FastAPI(title="ThermoAnalyzer Reference Library Feed", version="1") + app = FastAPI(title="MaterialScope Reference Library Feed", version="1") @app.get("/health") def health() -> dict[str, str]: - return {"status": "ok", "service": "thermoanalyzer-library-feed"} + return {"status": "ok", "service": "materialscope-library-feed"} @app.get("/v1/library/manifest") def manifest( if_none_match: str | None = Header(default=None, alias="If-None-Match"), x_ta_license: str | None = Header(default=None, alias="X-TA-License"), + x_materialscope_license: str | None = Header(default=None, alias="X-MaterialScope-License"), ) -> Response: - _require_feed_license(x_ta_license) + _require_feed_license(x_materialscope_license or x_ta_license) payload = _load_manifest(root) body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") etag = str(payload.get("etag") or "") @@ -66,8 +67,9 @@ def manifest( def package_download( package_id: str, x_ta_license: str | None = Header(default=None, alias="X-TA-License"), + x_materialscope_license: str | None = Header(default=None, alias="X-MaterialScope-License"), ) -> Response: - _require_feed_license(x_ta_license) + _require_feed_license(x_materialscope_license or x_ta_license) manifest = _load_manifest(root) packages = manifest.get("packages") or [] package = next((item for item in packages if str(item.get("package_id")) == str(package_id)), None) diff --git a/backend/models.py b/backend/models.py index e057d483..b91c3a08 100644 --- a/backend/models.py +++ b/backend/models.py @@ -121,6 +121,7 @@ class SpectralLibrarySearchRequest(BaseModel): preprocessing_metadata: dict[str, Any] = Field(default_factory=dict) sample_metadata: dict[str, Any] = Field(default_factory=dict) import_metadata: dict[str, Any] = Field(default_factory=dict) + metric: str | None = None top_n: int | None = None minimum_score: float | None = None @@ -264,6 +265,7 @@ class DatasetImportRequest(BaseModel): file_name: str = Field(..., min_length=1) file_base64: str = Field(..., min_length=1) data_type: str | None = None + column_mapping: dict[str, str] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict) @@ -285,6 +287,13 @@ class AnalysisRunRequest(BaseModel): dataset_key: str = Field(..., min_length=1) analysis_type: str = Field(..., min_length=1) workflow_template_id: str | None = None + unit_mode: str | None = None + # Optional per-step parameter overrides keyed by section name + # (e.g. "smoothing", "baseline", "peak_detection", or "method_context"). + # Merged into workspace analysis-state processing payload before the + # template defaults are applied, so user-tuned parameters win over + # template defaults inside core._build_processing_payload. + processing_overrides: dict[str, Any] | None = None class AnalysisRunResponse(BaseModel): @@ -310,6 +319,13 @@ class DatasetDetailResponse(BaseModel): compare_selected: bool = False +class DatasetDataResponse(BaseModel): + project_id: str + dataset_key: str + columns: list[str] + rows: list[dict[str, Any]] + + class ResultDetailResponse(BaseModel): project_id: str result: ResultSummary @@ -322,8 +338,11 @@ class ResultDetailResponse(BaseModel): literature_claims: list[dict[str, Any]] = Field(default_factory=list) literature_comparisons: list[dict[str, Any]] = Field(default_factory=list) citations: list[dict[str, Any]] = Field(default_factory=list) + rows: list[dict[str, Any]] = Field(default_factory=list) rows_preview: list[dict[str, Any]] row_count: int + # PNG bytes live in workspace ``figures``; this is metadata only for UIs (exports, Dash gallery). + figure_artifacts: dict[str, Any] = Field(default_factory=dict) class LiteratureUserDocumentInput(BaseModel): @@ -361,6 +380,60 @@ class LiteratureCompareResponse(BaseModel): detail: ResultDetailResponse | None = None +class ResultFigureRegisterRequest(BaseModel): + figure_png_base64: str + figure_label: str + replace: bool = False + + +class ResultFigureRegisterResponse(BaseModel): + project_id: str + result_id: str + figure_key: str + figure_keys: list[str] = Field(default_factory=list) + + +class PresetSummary(BaseModel): + analysis_type: str + preset_name: str + workflow_template_id: str = "" + created_at: str + updated_at: str + + +class PresetListResponse(BaseModel): + analysis_type: str + count: int + max_count: int + presets: list[PresetSummary] = Field(default_factory=list) + + +class PresetSaveRequest(BaseModel): + preset_name: str + workflow_template_id: str | None = None + processing: dict[str, Any] = Field(default_factory=dict) + + +class PresetSaveResponse(BaseModel): + analysis_type: str + preset_name: str + workflow_template_id: str + updated_at: str + + +class PresetLoadResponse(BaseModel): + analysis_type: str + preset_name: str + workflow_template_id: str + processing: dict[str, Any] = Field(default_factory=dict) + + +class PresetDeleteResponse(BaseModel): + analysis_type: str + preset_name: str + deleted: bool + + class CompareWorkspacePayload(BaseModel): analysis_type: str = "DSC" selected_datasets: list[str] = Field(default_factory=list) @@ -399,6 +472,7 @@ class ExportPreparationResponse(BaseModel): class ExportGenerateRequest(BaseModel): selected_result_ids: list[str] | None = None + include_figures: bool = True class ExportArtifactResponse(BaseModel): @@ -409,6 +483,7 @@ class ExportArtifactResponse(BaseModel): included_result_ids: list[str] skipped_record_issues: list[str] artifact_base64: str + export_warnings: list[str] = Field(default_factory=list) class WorkspaceContextResponse(BaseModel): @@ -433,6 +508,23 @@ class ActiveDatasetResponse(BaseModel): active_dataset: DatasetSummary | None = None +class WorkspaceBrandingUpdateRequest(BaseModel): + report_title: str | None = None + company_name: str | None = None + lab_name: str | None = None + analyst_name: str | None = None + report_notes: str | None = None + logo_name: str | None = None + logo_base64: str | None = None + clear_logo: bool = False + + +class WorkspaceBrandingResponse(BaseModel): + project_id: str + summary: ProjectSummary + branding: dict[str, Any] + + class CompareSelectionUpdateRequest(BaseModel): operation: str = Field(..., min_length=1) dataset_keys: list[str] | None = None @@ -451,6 +543,27 @@ class BatchRunRequest(BaseModel): dataset_keys: list[str] | None = None +class AnalysisStateCurvesResponse(BaseModel): + project_id: str + dataset_key: str + analysis_type: str + temperature: list[float] = Field(default_factory=list) + raw_signal: list[float] = Field(default_factory=list) + smoothed: list[float] = Field(default_factory=list) + baseline: list[float] = Field(default_factory=list) + corrected: list[float] = Field(default_factory=list) + normalized: list[float] = Field(default_factory=list) + dtg: list[float] = Field(default_factory=list) + peaks: list[dict[str, Any]] = Field(default_factory=list) + has_smoothed: bool = False + has_baseline: bool = False + has_corrected: bool = False + has_normalized: bool = False + has_dtg: bool = False + has_peaks: bool = False + diagnostics: dict[str, Any] = Field(default_factory=dict) + + class BatchRunResponse(BaseModel): project_id: str analysis_type: str diff --git a/backend/workspace.py b/backend/workspace.py index df59129b..89b4db3e 100644 --- a/backend/workspace.py +++ b/backend/workspace.py @@ -8,6 +8,7 @@ from typing import Any from backend.models import DatasetSummary, ResultSummary +from core.modalities import analysis_state_key, stable_analysis_types from core.validation import validate_thermal_dataset @@ -59,6 +60,12 @@ def normalize_workspace_state(state: dict[str, Any] | None = None) -> dict[str, return normalized +def normalize_branding_payload(branding: dict[str, Any] | None = None) -> dict[str, Any]: + payload = copy.deepcopy(WORKSPACE_DEFAULTS["branding"]) + payload.update(copy.deepcopy(branding or {})) + return payload + + def unique_dataset_key(existing: dict[str, Any], requested_name: str) -> str: """Return a collision-safe dataset key.""" token = re.sub(r"[\\/]+", "_", requested_name.strip()) or "dataset" @@ -97,6 +104,46 @@ def add_history_event( ) +def remove_dataset_from_workspace(state: dict[str, Any], dataset_key: str) -> bool: + datasets = state.get("datasets", {}) or {} + if dataset_key not in datasets: + return False + + del datasets[dataset_key] + + for analysis_type in stable_analysis_types(): + try: + state.pop(analysis_state_key(analysis_type, dataset_key), None) + except ValueError: + continue + + results = state.get("results", {}) or {} + for result_key in list(results.keys()): + if (results.get(result_key) or {}).get("dataset_key") == dataset_key: + del results[result_key] + + workspace = state.setdefault("comparison_workspace", {}) + selected = [ + key for key in (workspace.get("selected_datasets") or []) + if key != dataset_key + ] + workspace["selected_datasets"] = selected + if not selected: + workspace["figure_key"] = None + + if state.get("active_dataset") == dataset_key: + state["active_dataset"] = None + + add_history_event( + state, + action="Dataset Removed", + details=f"{dataset_key} removed from workspace", + dataset_key=dataset_key, + status="warning", + ) + return True + + def summarize_dataset(dataset_key: str, dataset) -> DatasetSummary: """Build a dataset summary with validation rollup for desktop list views.""" validation = validate_thermal_dataset(dataset, analysis_type=getattr(dataset, "data_type", "unknown")) diff --git a/build/reference_library_ingest_live/cod/cod_xrd_seed/entries.jsonl b/build/reference_library_ingest_live/cod/cod_xrd_seed/entries.jsonl new file mode 100644 index 00000000..321db7f3 --- /dev/null +++ b/build/reference_library_ingest_live/cod/cod_xrd_seed/entries.jsonl @@ -0,0 +1,4 @@ +{"candidate_id":"cod_seed_1001","candidate_name":"COD Seed 1001","provider":"COD","source_id":"1001","source_url":"https://www.crystallography.net/cod/1001.cif","peaks":[{"position":18.2,"intensity":1.0,"d_spacing":4.87},{"position":31.4,"intensity":0.62,"d_spacing":2.85}],"generated_at":"2026-03-01T00:00:00Z","provider_dataset_version":"2026.03.01-seed","builder_version":"seed","normalized_schema_version":1} +{"candidate_id":"cod_seed_1002","candidate_name":"COD Seed 1002","provider":"COD","source_id":"1002","source_url":"https://www.crystallography.net/cod/1002.cif","peaks":[{"position":20.1,"intensity":1.0,"d_spacing":4.41},{"position":29.8,"intensity":0.51,"d_spacing":2.99}],"generated_at":"2026-03-01T00:00:00Z","provider_dataset_version":"2026.03.01-seed","builder_version":"seed","normalized_schema_version":1} +{"candidate_id":"cod_seed_1003","candidate_name":"COD Seed 1003","provider":"COD","source_id":"1003","source_url":"https://www.crystallography.net/cod/1003.cif","peaks":[{"position":16.7,"intensity":1.0,"d_spacing":5.30},{"position":27.2,"intensity":0.58,"d_spacing":3.27}],"generated_at":"2026-03-01T00:00:00Z","provider_dataset_version":"2026.03.01-seed","builder_version":"seed","normalized_schema_version":1} +{"candidate_id":"cod_seed_1004","candidate_name":"COD Seed 1004","provider":"COD","source_id":"1004","source_url":"https://www.crystallography.net/cod/1004.cif","peaks":[{"position":22.0,"intensity":1.0,"d_spacing":4.04},{"position":35.8,"intensity":0.47,"d_spacing":2.50}],"generated_at":"2026-03-01T00:00:00Z","provider_dataset_version":"2026.03.01-seed","builder_version":"seed","normalized_schema_version":1} diff --git a/build/reference_library_ingest_live/cod/cod_xrd_seed/package_spec.json b/build/reference_library_ingest_live/cod/cod_xrd_seed/package_spec.json new file mode 100644 index 00000000..6fabdbb4 --- /dev/null +++ b/build/reference_library_ingest_live/cod/cod_xrd_seed/package_spec.json @@ -0,0 +1,16 @@ +{ + "package_id": "cod_xrd_seed", + "analysis_type": "XRD", + "provider": "COD", + "version": "2026.03.01-seed", + "source_url": "https://www.crystallography.net/cod/", + "license_name": "CC0-1.0", + "license_text": "", + "attribution": "Seed COD powder-pattern references.", + "priority": 100, + "published_at": "2026-03-01T00:00:00Z", + "generated_at": "2026-03-01T00:00:00Z", + "provider_dataset_version": "2026.03.01-seed", + "builder_version": "seed", + "normalized_schema_version": 1 +} diff --git a/build/reference_library_ingest_live/materials_project/materials_project_xrd_seed/entries.jsonl b/build/reference_library_ingest_live/materials_project/materials_project_xrd_seed/entries.jsonl new file mode 100644 index 00000000..f46d0649 --- /dev/null +++ b/build/reference_library_ingest_live/materials_project/materials_project_xrd_seed/entries.jsonl @@ -0,0 +1,2 @@ +{"candidate_id":"mp_seed_2001","candidate_name":"MP Seed 2001","provider":"Materials Project","source_id":"mp-2001","source_url":"https://materialsproject.org/materials/mp-2001","peaks":[{"position":24.3,"intensity":1.0,"d_spacing":3.66},{"position":33.1,"intensity":0.55,"d_spacing":2.70}],"generated_at":"2026-03-01T00:00:00Z","provider_dataset_version":"2026.03.01-seed","builder_version":"seed","normalized_schema_version":1} +{"candidate_id":"mp_seed_2002","candidate_name":"MP Seed 2002","provider":"Materials Project","source_id":"mp-2002","source_url":"https://materialsproject.org/materials/mp-2002","peaks":[{"position":19.4,"intensity":1.0,"d_spacing":4.57},{"position":28.7,"intensity":0.49,"d_spacing":3.11}],"generated_at":"2026-03-01T00:00:00Z","provider_dataset_version":"2026.03.01-seed","builder_version":"seed","normalized_schema_version":1} diff --git a/build/reference_library_ingest_live/materials_project/materials_project_xrd_seed/package_spec.json b/build/reference_library_ingest_live/materials_project/materials_project_xrd_seed/package_spec.json new file mode 100644 index 00000000..2634ad80 --- /dev/null +++ b/build/reference_library_ingest_live/materials_project/materials_project_xrd_seed/package_spec.json @@ -0,0 +1,16 @@ +{ + "package_id": "materials_project_xrd_seed", + "analysis_type": "XRD", + "provider": "Materials Project", + "version": "2026.03.01-seed", + "source_url": "https://materialsproject.org/", + "license_name": "Materials Project Terms", + "license_text": "", + "attribution": "Seed Materials Project powder-pattern references.", + "priority": 90, + "published_at": "2026-03-01T00:00:00Z", + "generated_at": "2026-03-01T00:00:00Z", + "provider_dataset_version": "2026.03.01-seed", + "builder_version": "seed", + "normalized_schema_version": 1 +} diff --git a/core/batch_runner.py b/core/batch_runner.py index 08b696d1..142a58a8 100644 --- a/core/batch_runner.py +++ b/core/batch_runner.py @@ -12,9 +12,11 @@ from core.dsc_processor import DSCProcessor from core.library_cloud_client import get_library_cloud_client from core.peak_analysis import characterize_peaks +from core.preprocessing import compute_derivative from core.processing_schema import ( ensure_processing_payload, get_workflow_templates, + set_tga_unit_mode, update_method_context, update_processing_step, update_tga_unit_context, @@ -39,18 +41,21 @@ "dsc.general": { "smoothing": {"method": "savgol", "window_length": 11, "polyorder": 3}, "baseline": {"method": "asls"}, + "normalization": {"enabled": True}, "peak_detection": {"direction": "both"}, "glass_transition": {"mode": "auto", "region": None}, }, "dsc.polymer_tg": { "smoothing": {"method": "savgol", "window_length": 15, "polyorder": 3}, "baseline": {"method": "asls"}, + "normalization": {"enabled": True}, "peak_detection": {"direction": "both"}, "glass_transition": {"mode": "auto", "region": None}, }, "dsc.polymer_melting_crystallization": { "smoothing": {"method": "savgol", "window_length": 11, "polyorder": 3}, "baseline": {"method": "asls"}, + "normalization": {"enabled": True}, "peak_detection": {"direction": "both"}, "glass_transition": {"mode": "auto", "region": None}, }, @@ -95,7 +100,7 @@ "smoothing": {"method": "moving_average", "window_length": 11}, "baseline": {"method": "linear"}, "normalization": {"method": "vector"}, - "peak_detection": {"prominence": 0.05, "min_distance": 6, "max_peaks": 12}, + "peak_detection": {"prominence": 0.035, "min_distance": 5, "max_peaks": 14}, "similarity_matching": {"metric": "cosine", "top_n": 3, "minimum_score": 0.45}, }, "ftir.functional_groups": { @@ -126,7 +131,7 @@ "xrd.general": { "axis_normalization": {"sort_axis": True, "deduplicate": "first", "axis_min": None, "axis_max": None}, "smoothing": {"method": "savgol", "window_length": 11, "polyorder": 3}, - "baseline": {"method": "rolling_minimum", "window_length": 31}, + "baseline": {"method": "rolling_minimum", "window_length": 31, "smoothing_window": 9}, "peak_detection": {"method": "scipy_find_peaks", "prominence": 0.08, "distance": 6, "width": 2, "max_peaks": 12}, "method_context": { "xrd_match_metric": "peak_overlap_weighted", @@ -140,7 +145,7 @@ "xrd.phase_screening": { "axis_normalization": {"sort_axis": True, "deduplicate": "first", "axis_min": 5.0, "axis_max": 90.0}, "smoothing": {"method": "savgol", "window_length": 15, "polyorder": 3}, - "baseline": {"method": "rolling_minimum", "window_length": 41}, + "baseline": {"method": "rolling_minimum", "window_length": 41, "smoothing_window": 9}, "peak_detection": {"method": "scipy_find_peaks", "prominence": 0.12, "distance": 8, "width": 3, "max_peaks": 16}, "method_context": { "xrd_match_metric": "peak_overlap_weighted", @@ -177,6 +182,7 @@ def execute_batch_template( analyst_name: str | None = None, app_version: str | None = None, batch_run_id: str | None = None, + unit_mode: str | None = None, ) -> dict[str, Any]: """Execute one batch template against one dataset without UI dependencies.""" normalized_type = (analysis_type or "UNKNOWN").upper() @@ -227,6 +233,7 @@ def execute_batch_template( analyst_name=analyst_name, app_version=app_version, batch_run_id=batch_run_id, + unit_mode=unit_mode, ) if normalized_type == "DTA": return _execute_dta_batch( @@ -324,8 +331,20 @@ def _execute_dsc_batch( smoothing = copy.deepcopy((processing.get("signal_pipeline") or {}).get("smoothing") or {}) baseline = copy.deepcopy((processing.get("signal_pipeline") or {}).get("baseline") or {}) + normalization = copy.deepcopy((processing.get("signal_pipeline") or {}).get("normalization") or {}) peak_detection = copy.deepcopy((processing.get("analysis_steps") or {}).get("peak_detection") or {}) glass_transition = copy.deepcopy((processing.get("analysis_steps") or {}).get("glass_transition") or {}) + normalization_enabled = bool(normalization.get("enabled", True)) + processing = update_processing_step( + processing, + "normalization", + {"enabled": normalization_enabled}, + analysis_type="DSC", + ) + if peak_detection.get("prominence") in ("", 0, 0.0, None): + peak_detection["prominence"] = None + if peak_detection.get("distance") in ("", 0, 0.0, 1, None): + peak_detection["distance"] = None processor = DSCProcessor( temperature, @@ -336,13 +355,24 @@ def _execute_dsc_batch( smooth_method = smoothing.pop("method", "savgol") processor.smooth(method=smooth_method, **smoothing) - processor.normalize() + if normalization_enabled: + processor.normalize() smoothed_signal = processor.get_result().smoothed_signal.copy() baseline_method = baseline.pop("method", "asls") processor.correct_baseline(method=baseline_method, **baseline) corrected_signal = processor.get_result().smoothed_signal.copy() + t_arr = np.asarray(temperature, dtype=float) + corr_arr = np.asarray(corrected_signal, dtype=float) + if t_arr.shape == corr_arr.shape and t_arr.size >= 3: + try: + dtg_signal = compute_derivative(t_arr, corr_arr, order=1, smooth_first=True) + except Exception: + dtg_signal = np.array([]) + else: + dtg_signal = np.array([]) + processor.find_peaks(**peak_detection) tg_region = glass_transition.get("region") processor.detect_glass_transition(region=tuple(tg_region) if isinstance(tg_region, (list, tuple)) and len(tg_region) == 2 else None) @@ -381,10 +411,14 @@ def _execute_dsc_batch( validation=validation, review={"commercial_scope": "stable_dsc", "batch_runner": "compare_workspace"}, ) + axis_list = np.asarray(temperature, dtype=float).tolist() state = { + "axis": axis_list, + "temperature": axis_list, "smoothed": smoothed_signal, "baseline": result.baseline, "corrected": corrected_signal, + "dtg": dtg_signal, "peaks": result.peaks, "glass_transitions": result.glass_transitions, "processor": None, @@ -420,10 +454,13 @@ def _execute_tga_batch( analyst_name: str | None, app_version: str | None, batch_run_id: str | None, + unit_mode: str | None = None, ) -> dict[str, Any]: temperature = dataset.data["temperature"].values signal = dataset.data["signal"].values initial_mass_mg = dataset.metadata.get("sample_mass") + if unit_mode: + processing = set_tga_unit_mode(processing, unit_mode) processing, unit_context = _resolve_batch_tga_processing(processing, dataset) smoothing = copy.deepcopy((processing.get("signal_pipeline") or {}).get("smoothing") or {}) @@ -486,7 +523,10 @@ def _execute_tga_batch( validation=validation, review={"commercial_scope": "stable_tga", "batch_runner": "compare_workspace"}, ) + axis_list = np.asarray(temperature, dtype=float).tolist() state = { + "axis": axis_list, + "temperature": axis_list, "smoothed": result.smoothed_signal, "dtg": result.dtg_signal, "tga_result": result, @@ -656,7 +696,10 @@ def _execute_dta_batch( validation=validation, review={"commercial_scope": "stable_dta", "batch_runner": "compare_workspace"}, ) + axis_list = np.asarray(temperature, dtype=float).tolist() state = { + "axis": axis_list, + "temperature": axis_list, "smoothed": result.smoothed_signal, "baseline": result.baseline, "corrected": result.smoothed_signal, @@ -734,58 +777,171 @@ def _apply_spectral_smoothing(signal: np.ndarray, config: Mapping[str, Any]) -> return np.convolve(padded, kernel, mode="valid") +def _infer_spectral_signal_role(dataset) -> str: + unit = str(getattr(dataset, "units", {}).get("signal") or getattr(dataset, "metadata", {}).get("inferred_signal_unit") or "").strip().lower() + if unit in {"absorbance"}: + return "absorbance" + if unit in {"transmittance", "%t"}: + return "transmittance" + return "unknown" + + +def _maybe_invert_spectral_signal(signal: np.ndarray, role: str) -> tuple[np.ndarray, bool]: + if role == "transmittance": + max_val = float(np.max(signal)) + return max_val - signal, True + return signal.copy(), False + + def _estimate_spectral_baseline(axis: np.ndarray, signal: np.ndarray, config: Mapping[str, Any]) -> np.ndarray: method = str(config.get("method") or "linear").strip().lower() if method in {"none", "off"}: return np.zeros_like(signal) - if method in {"linear", "asls", "rubberband"}: + + region = config.get("region") + weights = None + if isinstance(region, (list, tuple)) and len(region) == 2: + rmin, rmax = float(region[0]), float(region[1]) + weights = ((axis >= rmin) & (axis <= rmax)).astype(float) + + if method == "linear": start = float(signal[0]) end = float(signal[-1]) if float(axis[-1]) == float(axis[0]): return np.full_like(signal, start) slope = (end - start) / (float(axis[-1]) - float(axis[0])) return start + slope * (axis - float(axis[0])) - offset = float(np.min(signal)) - return np.full_like(signal, offset) - -def _normalize_spectral_signal(signal: np.ndarray, config: Mapping[str, Any]) -> np.ndarray: + try: + from pybaselines import Baseline + bl = Baseline(x_data=axis) + if method == "asls": + lam = float(config.get("lam", 1e6)) + p = float(config.get("p", 0.01)) + baseline, _ = bl.asls(signal, lam=lam, p=p, weights=weights) + return baseline + if method == "rubberband": + baseline, _ = bl.rubberband(signal, segments=1, weights=weights) + return baseline + except Exception: + pass + + start = float(signal[0]) + end = float(signal[-1]) + if float(axis[-1]) == float(axis[0]): + return np.full_like(signal, start) + slope = (end - start) / (float(axis[-1]) - float(axis[0])) + return start + slope * (axis - float(axis[0])) + + +def _validate_spectral_baseline(axis: np.ndarray, signal: np.ndarray, baseline: np.ndarray) -> tuple[bool, str]: + if baseline.shape != signal.shape: + return False, "Baseline shape mismatch." + if not np.isfinite(baseline).all(): + return False, "Baseline contains non-finite values." + corrected = signal - baseline + var_signal = float(np.var(signal)) + var_corrected = float(np.var(corrected)) + if var_signal > 0 and var_corrected > var_signal * 1.5: + return False, "Baseline fit increases signal variance; fit is implausible." + sig_range = float(np.ptp(signal)) + corr_range = float(np.ptp(corrected)) + if sig_range > 0 and corr_range < 0.02 * sig_range: + return False, "Baseline correction collapses signal to near-flat line." + return True, "" + + +def _normalize_spectral_signal(signal: np.ndarray, config: Mapping[str, Any]) -> tuple[np.ndarray, bool, str]: method = str(config.get("method") or "vector").strip().lower() - centered = signal - float(np.mean(signal)) + sig_range = float(np.ptp(signal)) + if sig_range <= 0: + return signal.copy(), False, "Signal has zero range; normalization skipped." + + result = signal.copy() if method == "snv": + centered = signal - float(np.mean(signal)) std = float(np.std(centered)) - return centered / std if std > 0 else centered - if method == "max": + if std > 0: + result = centered / std + else: + return signal.copy(), False, "Signal standard deviation is zero; SNV normalization skipped." + elif method == "max": scale = float(np.max(np.abs(signal))) - return signal / scale if scale > 0 else signal.copy() - norm = float(np.linalg.norm(signal)) - return signal / norm if norm > 0 else signal.copy() + if scale > 0: + result = signal / scale + else: + return signal.copy(), False, "Signal maximum absolute value is zero; max normalization skipped." + else: # vector + norm = float(np.linalg.norm(signal)) + if norm > 0: + result = signal / norm + else: + return signal.copy(), False, "Signal vector norm is zero; vector normalization skipped." + + norm_range = float(np.ptp(result)) + if norm_range < 1e-4: + return signal.copy(), False, "Normalization produced a near-flat result; displaying corrected spectrum instead." + + return result, True, "" -def _detect_spectral_peaks(axis: np.ndarray, signal: np.ndarray, config: Mapping[str, Any]) -> list[dict[str, float]]: - prominence = float(config.get("prominence") or 0.05) - min_distance = int(config.get("min_distance") or 5) +def _detect_spectral_peaks(axis: np.ndarray, signal: np.ndarray, config: Mapping[str, Any]) -> tuple[list[dict[str, float]], bool, str]: + signal_arr = np.asarray(signal, dtype=float) + sig_ptp = float(np.ptp(signal_arr)) + prominence_cfg = float(config.get("prominence") or 0.05) + distance = int(config.get("distance") or config.get("min_distance") or 5) max_peaks = int(config.get("max_peaks") or 10) - candidate_indices: list[int] = [] - for idx in range(1, signal.size - 1): - if signal[idx] < prominence: - continue - if signal[idx] >= signal[idx - 1] and signal[idx] >= signal[idx + 1]: - candidate_indices.append(idx) + if not np.isfinite(sig_ptp) or sig_ptp <= 0: + return [], False, "Signal has zero or non-finite range; peak detection skipped." - selected: list[int] = [] - for idx in sorted(candidate_indices, key=lambda item: float(signal[item]), reverse=True): - if any(abs(idx - prev) < min_distance for prev in selected): - continue - selected.append(idx) - if len(selected) >= max_peaks: - break + # Absolute prominence from workflow, blended with a data-driven floor so units/scale changes + # (e.g. normalized vs corrected absorbance) do not discard visible features too aggressively. + relative_floor = max(sig_ptp * 0.01, 1e-9) + effective_prominence = min(prominence_cfg, max(relative_floor, prominence_cfg * 0.35)) - return [ - {"position": float(axis[idx]), "intensity": float(signal[idx])} - for idx in sorted(selected) - ] + peak_indices, properties = find_peaks(signal_arr, prominence=effective_prominence, distance=distance) + + fallback = False + reason = "" + + if peak_indices.size == 0: + fallback_prominence = max( + 1e-9, + min( + effective_prominence * 0.25, + sig_ptp * 0.12, + max(prominence_cfg * 0.2, sig_ptp * 0.03), + ), + ) + peak_indices, properties = find_peaks(signal_arr, prominence=fallback_prominence, distance=distance) + if peak_indices.size == 0: + reason = f"No peaks found with prominence down to {fallback_prominence:.6f}; signal may be too noisy or flat." + return [], fallback, reason + fallback = True + reason = ( + f"No peaks met the configured prominence ({prominence_cfg}); " + f"relaxed prominence ({fallback_prominence:.6f}) was used after scaling to the signal range." + ) + + prominences = np.asarray(properties.get("prominences", []), dtype=float) + ranking = sorted( + range(len(peak_indices)), + key=lambda idx: (-float(prominences[idx]), float(axis[peak_indices[idx]])), + ) + selected = ranking[:max_peaks] + peaks: list[dict[str, float]] = [] + for rank, index in enumerate(selected, start=1): + peak_index = int(peak_indices[index]) + peaks.append( + { + "rank": rank, + "position": float(axis[peak_index]), + "intensity": float(signal_arr[peak_index]), + "prominence": float(prominences[index]) if index < len(prominences) else 0.0, + } + ) + return peaks, fallback, reason def _first_defined_value(*values: Any) -> Any: @@ -1013,10 +1169,17 @@ def _shared_peak_count(observed_peaks: list[dict[str, float]], reference_peaks: return shared +def _resolve_spectral_matching_metric(matching_config: Mapping[str, Any]) -> str: + token = str((matching_config or {}).get("metric") or "").strip().lower() + if token in {"cosine", "pearson", "cosine_prerank_then_pearson_peak_overlap"}: + return token + return "cosine" + + def _rank_spectral_matches( *, axis: np.ndarray, - normalized_signal: np.ndarray, + query_signal: np.ndarray, observed_peaks: list[dict[str, float]], references: list[dict[str, Any]], matching_config: Mapping[str, Any], @@ -1024,30 +1187,32 @@ def _rank_spectral_matches( ) -> list[dict[str, Any]]: top_n = int(matching_config.get("top_n") or 3) minimum_score = float(matching_config.get("minimum_score") or 0.45) + metric = _resolve_spectral_matching_metric(matching_config) if top_n < 1: top_n = 1 + prerank_metric = "cosine" if metric == "cosine_prerank_then_pearson_peak_overlap" else metric preranked: list[dict[str, Any]] = [] for reference in references: reference_axis = reference["axis"] reference_signal = reference["signal"] interpolated = np.interp(axis, reference_axis, reference_signal) reference_smoothed = _apply_spectral_smoothing(interpolated, {"method": "none"}) - reference_normalized = _normalize_spectral_signal(reference_smoothed, {"method": "vector"}) - reference_peaks = _detect_spectral_peaks(axis, reference_normalized, peak_config) - cosine_score = _spectral_similarity(normalized_signal, reference_normalized, "cosine") + reference_normalized, _, _ = _normalize_spectral_signal(reference_smoothed, {"method": "vector"}) + reference_peaks, _, _ = _detect_spectral_peaks(axis, reference_normalized, peak_config) + prerank_score = _spectral_similarity(query_signal, reference_normalized, prerank_metric) preranked.append( { "reference": reference, "reference_normalized": reference_normalized, "reference_peaks": reference_peaks, - "cosine_score": cosine_score, + "prerank_score": prerank_score, } ) preranked.sort( key=lambda item: ( - -float(item["cosine_score"]), + -float(item["prerank_score"]), -int((item["reference"] or {}).get("priority") or 0), str((item["reference"] or {}).get("candidate_id") or ""), ) @@ -1060,8 +1225,12 @@ def _rank_spectral_matches( reference_peaks = item["reference_peaks"] shared = _shared_peak_count(observed_peaks, reference_peaks) overlap_ratio = float(shared / max(len(observed_peaks), len(reference_peaks), 1)) - pearson_score = _spectral_similarity(normalized_signal, reference_normalized, "pearson") - score = float(max(0.0, min(1.0, (0.7 * pearson_score) + (0.3 * overlap_ratio)))) + cosine_score = _spectral_similarity(query_signal, reference_normalized, "cosine") + pearson_score = _spectral_similarity(query_signal, reference_normalized, "pearson") + if metric == "cosine_prerank_then_pearson_peak_overlap": + score = float(max(0.0, min(1.0, (0.7 * pearson_score) + (0.3 * overlap_ratio)))) + else: + score = float(_spectral_similarity(query_signal, reference_normalized, metric)) confidence_band = _confidence_band(score, minimum_score) ranked.append( { @@ -1073,12 +1242,14 @@ def _rank_spectral_matches( "library_package": reference.get("package_id") or "", "library_version": reference.get("package_version") or "", "evidence": { - "metric": "cosine_prerank_then_pearson_peak_overlap", + "metric": metric, "observed_peak_count": len(observed_peaks), "reference_peak_count": len(reference_peaks), "shared_peak_count": shared, "peak_overlap_ratio": round(overlap_ratio, 4), - "cosine_prerank_score": round(float(item["cosine_score"]), 4), + "prerank_metric": prerank_metric, + "prerank_score": round(float(item["prerank_score"]), 4), + "cosine_score": round(cosine_score, 4), "pearson_score": round(pearson_score, 4), "library_provider": reference.get("provider") or "", "library_package": reference.get("package_id") or "", @@ -1121,11 +1292,26 @@ def _execute_spectral_batch( peak_detection = copy.deepcopy((processing.get("analysis_steps") or {}).get("peak_detection") or {}) similarity_matching = copy.deepcopy((processing.get("analysis_steps") or {}).get("similarity_matching") or {}) - smoothed = _apply_spectral_smoothing(signal, smoothing) + signal_role = _infer_spectral_signal_role(dataset) + working_signal, was_inverted = _maybe_invert_spectral_signal(signal, signal_role) + + smoothed = _apply_spectral_smoothing(working_signal, smoothing) baseline_curve = _estimate_spectral_baseline(axis, smoothed, baseline) + baseline_valid, baseline_reason = _validate_spectral_baseline(axis, smoothed, baseline_curve) + baseline_suppressed = False + if not baseline_valid: + baseline_curve = np.zeros_like(smoothed) + baseline_suppressed = True + corrected = smoothed - baseline_curve - normalized_signal = _normalize_spectral_signal(corrected, normalization) - observed_peaks = _detect_spectral_peaks(axis, normalized_signal, peak_detection) + + normalized_signal, norm_informative, norm_reason = _normalize_spectral_signal(corrected, normalization) + normalization_skipped = not norm_informative + + peak_basis = normalized_signal if norm_informative else corrected + observed_peaks, peak_fallback, peak_reason = _detect_spectral_peaks(axis, peak_basis, peak_detection) + + match_basis = normalized_signal if norm_informative else corrected manager = get_reference_library_manager() library_context = manager.library_context(analysis_type) @@ -1144,7 +1330,7 @@ def _execute_spectral_batch( analysis_type=analysis_type, payload={ "axis": axis.tolist(), - "signal": normalized_signal.tolist(), + "signal": match_basis.tolist(), "preprocessing_metadata": { "peak_detection": peak_detection, "smoothing": smoothing, @@ -1159,6 +1345,7 @@ def _execute_spectral_batch( "import_metadata": { "import_review_required": bool((dataset.metadata or {}).get("import_review_required")), }, + "metric": str(similarity_matching.get("metric") or "cosine"), "top_n": int(similarity_matching.get("top_n") or 3), "minimum_score": float(similarity_matching.get("minimum_score") or 0.45), }, @@ -1185,7 +1372,7 @@ def _execute_spectral_batch( references = _resolve_spectral_references(dataset=dataset, analysis_type=analysis_type, processing=processing) ranked_matches = _rank_spectral_matches( axis=axis, - normalized_signal=normalized_signal, + query_signal=match_basis, observed_peaks=observed_peaks, references=references, matching_config=similarity_matching, @@ -1216,7 +1403,17 @@ def _execute_spectral_batch( minimum_score = float(similarity_matching.get("minimum_score") or 0.45) top_match = ranked_matches[0] if ranked_matches else None matched = bool(top_match) and float(top_match["normalized_score"]) >= minimum_score - match_status = "matched" if matched else "no_match" + if matched: + match_status = "matched" + elif not ranked_matches: + if library_result_source in ("unavailable", "not_configured") or ( + library_access_mode == "not_configured" and library_result_source != "dataset_embedded" + ): + match_status = "library_unavailable" + else: + match_status = "no_match" + else: + match_status = "no_match" top_score = float(top_match["normalized_score"]) if top_match else 0.0 confidence_band = top_match["confidence_band"] if matched and top_match else "no_match" top_provider = str(top_match.get("library_provider") or "") if top_match else "" @@ -1224,24 +1421,35 @@ def _execute_spectral_batch( top_version = str(top_match.get("library_version") or "") if top_match else "" cloud_caution_code = str((cloud_payload or {}).get("caution_code") or "") cloud_caution_message = str((cloud_payload or {}).get("caution_message") or "") - caution_payload = ( - {} - if matched - else { + if matched: + caution_payload: dict[str, Any] = {} + elif match_status == "library_unavailable": + caution_payload = { + "code": cloud_caution_code or "spectral_library_unavailable", + "message": cloud_caution_message + or ( + "Reference spectral library matching was unavailable or not configured for this run. " + "Absence of ranked candidates reflects library access limits, not a spectroscopic no-match conclusion." + ), + "minimum_score": minimum_score, + "top_candidate_score": round(top_score, 4), + } + else: + caution_payload = { "code": cloud_caution_code or "spectral_no_match", "message": cloud_caution_message or "No reference candidate met the minimum similarity threshold.", "minimum_score": minimum_score, "top_candidate_score": round(top_score, 4), } - ) + modality_label = "FTIR" if analysis_type == "FTIR" else "RAMAN" processing = update_method_context( processing, { "batch_run_id": batch_run_id or "", "batch_template_runner": "compare_workspace", "reference_candidate_count": len(references), - "matching_metric": "cosine_prerank_then_pearson_peak_overlap", + "matching_metric": str(similarity_matching.get("metric") or "cosine"), "matching_top_n": int(similarity_matching.get("top_n") or 3), "matching_minimum_score": minimum_score, "library_sync_mode": library_context["library_sync_mode"], @@ -1253,10 +1461,37 @@ def _execute_spectral_batch( "library_result_source": library_result_source, "library_provider_scope": library_provider_scope, "library_offline_limited_mode": bool(library_offline_limited_mode), + "ftir_signal_role": signal_role, + "ftir_inverted_for_transmittance": was_inverted, + "raman_signal_role": signal_role if analysis_type == "RAMAN" else "", + "raman_inverted_for_transmittance": was_inverted if analysis_type == "RAMAN" else False, }, analysis_type=analysis_type, ) validation = validate_thermal_dataset(dataset, analysis_type=analysis_type, processing=processing) + spectral_warnings: list[str] = [] + if signal_role == "unknown": + spectral_warnings.append( + f"{modality_label} signal role is uncertain (absorbance vs transmittance); review unit metadata and interpret results with caution." + ) + if was_inverted: + spectral_warnings.append( + f"{modality_label} signal was interpreted as transmittance and inverted for analysis; peak positions are accurate but intensities are on an inverted scale." + ) + if baseline_suppressed: + spectral_warnings.append(f"{modality_label} baseline estimation was suppressed because the fit was implausible: {baseline_reason}") + if normalization_skipped: + spectral_warnings.append(f"{modality_label} normalization was skipped: {norm_reason}") + if peak_fallback: + spectral_warnings.append(f"{modality_label} peak detection used fallback logic: {peak_reason}") + elif not observed_peaks: + spectral_warnings.append(f"{modality_label} peak detection returned no peaks: {peak_reason}") + + for w in spectral_warnings: + if w not in validation.setdefault("warnings", []): + validation["warnings"].append(w) + validation["warning_count"] = len(validation.get("warnings", [])) + provenance = build_result_provenance( dataset=dataset, dataset_key=dataset_key, @@ -1347,15 +1582,40 @@ def _execute_spectral_batch( "caution": caution_payload, }, ) + + diagnostics: dict[str, Any] = { + "signal_role": signal_role, + "inverted_for_transmittance": was_inverted, + } + if baseline_suppressed: + diagnostics["baseline_suppressed"] = True + diagnostics["baseline_suppression_reason"] = baseline_reason + if normalization_skipped: + diagnostics["normalization_skipped"] = True + diagnostics["normalization_skip_reason"] = norm_reason + if peak_fallback: + diagnostics["peak_detection_fallback"] = True + diagnostics["peak_detection_reason"] = peak_reason + if not observed_peaks and peak_reason: + diagnostics["peak_detection_no_peaks"] = True + diagnostics["peak_detection_reason"] = peak_reason + + corr_ptp = float(np.ptp(corrected)) + norm_ptp = float(np.ptp(normalized_signal)) if norm_informative else 0.0 + norm_axis_ratio = (norm_ptp / corr_ptp) if corr_ptp > 1e-12 else 0.0 + diagnostics["plot_normalized_primary_axis"] = bool(norm_informative and norm_axis_ratio >= 0.022) + diagnostics["normalized_axis_ratio_vs_corrected"] = round(float(norm_axis_ratio), 6) if norm_informative else 0.0 + state = { "axis": axis.tolist(), "smoothed": smoothed.tolist(), - "baseline": baseline_curve.tolist(), - "corrected": corrected.tolist(), - "normalized": normalized_signal.tolist(), + "baseline": baseline_curve.tolist() if not baseline_suppressed else [], + "corrected": corrected.tolist() if not baseline_suppressed else [], + "normalized": normalized_signal.tolist() if norm_informative else [], "peaks": observed_peaks, "matches": ranked_matches, "processing": processing, + "diagnostics": diagnostics, } return { @@ -2300,6 +2560,7 @@ def _execute_xrd_batch( smoothing = copy.deepcopy((processing.get("signal_pipeline") or {}).get("smoothing") or {}) baseline = copy.deepcopy((processing.get("signal_pipeline") or {}).get("baseline") or {}) peak_detection = copy.deepcopy((processing.get("analysis_steps") or {}).get("peak_detection") or {}) + method_ctx_early = (processing.get("method_context") or {}) if isinstance(processing, Mapping) else {} axis_min = _coerce_optional_float(axis_normalization.get("axis_min")) axis_max = _coerce_optional_float(axis_normalization.get("axis_max")) @@ -2321,16 +2582,19 @@ def _execute_xrd_batch( library_context = manager.library_context("XRD") matching_config = _resolve_xrd_matching_config(processing) observed_space = _resolve_xrd_observed_space(dataset) - wavelength_angstrom = _coerce_optional_float(dataset.metadata.get("xrd_wavelength_angstrom")) - xrd_provenance_state = str( - (dataset.metadata or {}).get("xrd_provenance_state") - or ("complete" if wavelength_angstrom is not None else "incomplete") - ).strip() - xrd_provenance_warning = str((dataset.metadata or {}).get("xrd_provenance_warning") or "").strip() - if not xrd_provenance_warning and xrd_provenance_state.lower() != "complete": - xrd_provenance_warning = ( - "XRD wavelength is not recorded; qualitative phase matching provenance remains incomplete." - ) + wavelength_angstrom = _coerce_optional_float((dataset.metadata or {}).get("xrd_wavelength_angstrom")) + if wavelength_angstrom is None: + wavelength_angstrom = _coerce_optional_float(method_ctx_early.get("xrd_wavelength_angstrom")) + if wavelength_angstrom is not None: + xrd_provenance_state = "complete" + xrd_provenance_warning = "" + else: + xrd_provenance_state = str((dataset.metadata or {}).get("xrd_provenance_state") or "incomplete").strip() + xrd_provenance_warning = str((dataset.metadata or {}).get("xrd_provenance_warning") or "").strip() + if not xrd_provenance_warning: + xrd_provenance_warning = ( + "XRD wavelength is not recorded; qualitative phase matching provenance remains incomplete." + ) cloud_client = get_library_cloud_client() cloud_payload: Mapping[str, Any] | None = None references: list[dict[str, Any]] = [] @@ -2364,9 +2628,11 @@ def _execute_xrd_batch( ], "axis": axis.tolist(), "signal": corrected.tolist(), - "xrd_axis_role": dataset.metadata.get("xrd_axis_role"), - "xrd_axis_unit": dataset.metadata.get("xrd_axis_unit") or (dataset.units or {}).get("temperature"), - "xrd_wavelength_angstrom": dataset.metadata.get("xrd_wavelength_angstrom"), + "xrd_axis_role": method_ctx_early.get("xrd_axis_role") or dataset.metadata.get("xrd_axis_role"), + "xrd_axis_unit": method_ctx_early.get("xrd_axis_unit") + or dataset.metadata.get("xrd_axis_unit") + or (dataset.units or {}).get("temperature"), + "xrd_wavelength_angstrom": wavelength_angstrom, "preprocessing_metadata": { "axis_normalization": axis_normalization, "smoothing": smoothing, @@ -2595,9 +2861,14 @@ def _execute_xrd_batch( { "batch_run_id": batch_run_id or "", "batch_template_runner": "compare_workspace", - "xrd_axis_role": dataset.metadata.get("xrd_axis_role") or "two_theta", - "xrd_axis_unit": (dataset.metadata.get("xrd_axis_unit") or (dataset.units or {}).get("temperature") or "degree_2theta"), - "xrd_wavelength_angstrom": dataset.metadata.get("xrd_wavelength_angstrom"), + "xrd_axis_role": method_ctx_early.get("xrd_axis_role") or dataset.metadata.get("xrd_axis_role") or "two_theta", + "xrd_axis_unit": ( + method_ctx_early.get("xrd_axis_unit") + or dataset.metadata.get("xrd_axis_unit") + or (dataset.units or {}).get("temperature") + or "degree_2theta" + ), + "xrd_wavelength_angstrom": wavelength_angstrom, "xrd_provenance_state": xrd_provenance_state, "xrd_provenance_warning": xrd_provenance_warning, "xrd_comparison_space": observed_space, diff --git a/core/data_io.py b/core/data_io.py index fc482b7e..af316a7a 100644 --- a/core/data_io.py +++ b/core/data_io.py @@ -427,6 +427,32 @@ def _xrd_source_hint_score(source_name: str | None) -> int: return score +_SPECTRAL_SOURCE_HINTS = ( + "ftir", + "ir_", + "infrared", + "raman", + "cnt", + "nanotube", + "graphene", +) + + +def _spectral_source_hint_score(source_name: str | None) -> tuple[int, str | None]: + """Return (bonus, suggested_type) based on source file name for spectral modalities.""" + token = str(source_name or "").strip().lower() + if not token: + return 0, None + if "ftir" in token or "ir_" in token or "infrared" in token: + return 10, "FTIR" + if "raman" in token: + return 10, "RAMAN" + for hint in _SPECTRAL_SOURCE_HINTS: + if hint in token: + return 4, None + return 0, None + + def _looks_like_jcamp(source_name: str, text: str) -> bool: source_lower = str(source_name or "").lower() if source_lower.endswith(_JCAMP_EXTENSIONS): @@ -751,6 +777,7 @@ def _parse_numeric(val: str) -> bool: except ValueError: return False + first_data_row_index = None for i, line in enumerate(lines): if not line.strip(): continue @@ -766,15 +793,21 @@ def _parse_numeric(val: str) -> bool: header_row = i else: # First predominantly numeric row is where data starts + first_data_row_index = i data_start_row = i break else: # All lines appear non-numeric — keep defaults pass - # Ensure data_start_row > header_row - if data_start_row <= header_row: - data_start_row = header_row + 1 + # If the very first line is all-numeric, there is no header row + if first_data_row_index is not None and first_data_row_index == 0: + header_row = None + + # Ensure data_start_row > (header_row or 0) + effective_header = header_row if header_row is not None else -1 + if data_start_row <= effective_header: + data_start_row = effective_header + 1 return header_row, data_start_row @@ -784,13 +817,19 @@ def _parse_numeric(val: str) -> bool: # --------------------------------------------------------------------------- -def guess_columns(df: pd.DataFrame, source_name: str | None = None) -> dict: +def guess_columns(df: pd.DataFrame, source_name: str | None = None, modality: str | None = None) -> dict: """Guess the role of each column using regex pattern matching. Parameters ---------- df : pd.DataFrame Raw DataFrame whose column names have been stripped of whitespace. + source_name : str or None + File name for source-name heuristics. + modality : str or None + When provided, restricts signal detection to this modality's patterns + and narrows unit validation. One of 'DSC', 'TGA', 'DTA', 'FTIR', + 'RAMAN', 'XRD'. Returns ------- @@ -802,25 +841,39 @@ def guess_columns(df: pd.DataFrame, source_name: str | None = None) -> dict: """ cols = list(df.columns) numeric_cols = {col for col in cols if _is_mostly_numeric(df[col])} + + # Resolve modality spec when available + _modality_upper = str(modality).upper() if modality else None + _modality_spec = None + if _modality_upper: + try: + from core.modality_specs import get_modality_spec + _modality_spec = get_modality_spec(_modality_upper) + except ImportError: + pass + result: Dict[str, Optional[str] | dict | list] = { "temperature": None, "time": None, "signal": None, - "data_type": "unknown", + "data_type": _modality_upper or "unknown", "warnings": [], "confidence": {}, "candidates": {}, "inferred_signal_unit": "unknown", - "inferred_analysis_type": "unknown", + "inferred_analysis_type": _modality_upper or "unknown", } warnings_list: list[str] = [] confidence: dict[str, str] = {} - def _rank_role(pattern_key: str, *, prefer_monotonic: bool = False) -> list[dict[str, object]]: + def _rank_role(pattern_key: str, *, prefer_monotonic: bool = False, aliases: tuple[str, ...] | None = None) -> list[dict[str, object]]: ranked: list[dict[str, object]] = [] + patterns_to_use = list(_PATTERNS.get(pattern_key, [])) + if aliases: + patterns_to_use = list(aliases) for col in cols: score = 0 - pattern_hits = _count_pattern_hits(col, _PATTERNS[pattern_key]) + pattern_hits = sum(1 for pat in patterns_to_use if re.search(pat, str(col))) if pattern_hits: score += 10 + pattern_hits * 2 header_lower = str(col).lower() @@ -837,8 +890,11 @@ def _rank_role(pattern_key: str, *, prefer_monotonic: bool = False) -> list[dict return ranked xrd_source_bonus = _xrd_source_hint_score(source_name) + spectral_source_bonus, spectral_source_suggested_type = _spectral_source_hint_score(source_name) - temp_ranked = _rank_role("temperature", prefer_monotonic=True) + # Use modality-specific x_aliases for axis detection when available + _axis_aliases = _modality_spec["x_aliases"] if _modality_spec else None + temp_ranked = _rank_role("temperature", prefer_monotonic=True, aliases=_axis_aliases) time_ranked = _rank_role("time", prefer_monotonic=True) result["candidates"]["temperature"] = temp_ranked[:3] result["candidates"]["time"] = time_ranked[:3] @@ -883,8 +939,11 @@ def _rank_role(pattern_key: str, *, prefer_monotonic: bool = False) -> list[dict confidence["time"] = "review" if any(int(item["score"]) > 0 for item in time_ranked) else "medium" assigned = {result["temperature"], result["time"]} - signal_candidates: dict[str, list[dict[str, object]]] = {analysis_type: [] for analysis_type in _ANALYSIS_TYPE_KEYS} - for analysis_type in _ANALYSIS_TYPE_KEYS: + + # When modality is specified, only evaluate that modality's signal candidates. + _eval_types = [_modality_upper] if _modality_upper else list(_ANALYSIS_TYPE_KEYS) + signal_candidates: dict[str, list[dict[str, object]]] = {analysis_type: [] for analysis_type in _eval_types} + for analysis_type in _eval_types: pattern_key = _TYPE_PATTERN_KEYS[analysis_type] for col in cols: if col in assigned: @@ -946,14 +1005,54 @@ def _rank_role(pattern_key: str, *, prefer_monotonic: bool = False) -> list[dict if ranked: top = ranked[0] score = int(top["score"]) - if analysis_type == "XRD": - score += xrd_source_bonus - if _is_xrd_axis_hint(str(result.get("temperature") or "")): - score += 12 + # Skip source bonuses when modality is explicitly set -- the user already decided. + if not _modality_upper: + if analysis_type == "XRD": + score += xrd_source_bonus + if _is_xrd_axis_hint(str(result.get("temperature") or "")): + score += 12 + if analysis_type in {"FTIR", "RAMAN"} and spectral_source_bonus > 0: + if spectral_source_suggested_type and analysis_type == spectral_source_suggested_type: + score += spectral_source_bonus + elif not spectral_source_suggested_type: + score += spectral_source_bonus type_scores.append((analysis_type, score, str(top["column"]), str(top["signal_unit"]))) type_scores.sort(key=lambda item: (item[1], item[0]), reverse=True) - if type_scores and type_scores[0][1] > 0: + # When modality is explicit, skip ambiguity detection across types. + if _modality_upper: + if type_scores and type_scores[0][1] > 0: + best_type, best_score, best_col, best_unit = type_scores[0] + result["signal"] = best_col + result["inferred_signal_unit"] = best_unit or "unknown" + confidence["signal"] = "high" if best_score >= 10 else "medium" + if best_score < 10: + warnings_list.append( + f"{_modality_upper} signal column '{best_col}' had low pattern match score ({best_score}); " + "verify the column mapping." + ) + else: + # Fallback: pick best numeric column not yet assigned + fallback_signal = None + for col in cols: + if col not in assigned and col in numeric_cols: + fallback_signal = col + break + result["signal"] = fallback_signal + result["inferred_signal_unit"] = _extract_unit(fallback_signal, "signal") if fallback_signal else "unknown" + confidence["signal"] = "review" + if fallback_signal is not None: + warnings_list.append( + f"{_modality_upper} signal column was inferred by numeric fallback as '{fallback_signal}'; verify the column mapping." + ) + else: + warnings_list.append( + f"No numeric column could be identified for {_modality_upper} signal; manual column mapping required." + ) + result["inferred_analysis_type"] = _modality_upper + result["data_type"] = _modality_upper + confidence["data_type"] = "high" + elif type_scores and type_scores[0][1] > 0: best_type, best_score, best_col, best_unit = type_scores[0] ambiguous_type = False ambiguity_margin = 4 @@ -961,6 +1060,8 @@ def _rank_role(pattern_key: str, *, prefer_monotonic: bool = False) -> list[dict _is_xrd_axis_hint(str(result.get("temperature") or "")) or xrd_source_bonus >= 8 ): ambiguity_margin = 1 + if best_type in {"FTIR", "RAMAN"} and spectral_source_bonus >= 10: + ambiguity_margin = 1 if len(type_scores) > 1 and (type_scores[1][1] >= best_score - ambiguity_margin and type_scores[1][1] > 0): ambiguous_type = True warnings_list.append( @@ -1268,7 +1369,7 @@ def read_thermal_data( import_confidence = "high" inferred_analysis_type = "unknown" if column_mapping is None: - guessed = guess_columns(raw_df, source_name=source_name) + guessed = guess_columns(raw_df, source_name=source_name, modality=data_type) col_map = { k: v for k, v in guessed.items() @@ -1307,6 +1408,15 @@ def read_thermal_data( if "time" in col_map: keep_cols["time"] = col_map["time"] + # Validate that mapped columns actually exist in the DataFrame + actual_columns = set(raw_df.columns) + missing = [v for v in keep_cols.values() if v not in actual_columns] + if missing: + raise ValueError( + f"Column mapping references column(s) {missing} that do not exist in the file. " + f"Available columns: {list(actual_columns)}." + ) + # Build output DataFrame preserving all original columns out_df = raw_df.copy() @@ -1502,11 +1612,12 @@ def _load_text( # pandas read_csv from StringIO try: - # Try with detected header row + # When header_row is None the file has no header row + pd_header = header_row if header_row is not None else None df = pd.read_csv( string_buf, sep=delimiter, - header=header_row, + header=pd_header, decimal=decimal_sep, engine="python", skip_blank_lines=True, @@ -1519,7 +1630,7 @@ def _load_text( df_alt = pd.read_csv( string_buf, sep=r"\s+", - header=header_row, + header=pd_header, decimal=decimal_sep, engine="python", skip_blank_lines=True, @@ -1531,6 +1642,11 @@ def _load_text( except Exception as exc: raise ValueError(f"Failed to parse text file: {exc}") from exc + # Rename integer column indices to human-readable "Column N" names, + # consistent with the import preview in the Dash UI. + if all(isinstance(col, int) for col in df.columns): + df.columns = [f"Column {index + 1}" for index in range(len(df.columns))] + return df, source_name diff --git a/core/dsc_processor.py b/core/dsc_processor.py index 1262a359..47ce96f4 100644 --- a/core/dsc_processor.py +++ b/core/dsc_processor.py @@ -6,7 +6,7 @@ detection into a single, reproducible workflow. The final state is exported as a DSCResult dataclass. -Imports from sibling modules (run from the thermoanalyzer/ directory): +Imports from sibling modules (run from the materialscope/ directory): core.preprocessing - smooth_signal, compute_derivative, normalize_by_mass core.baseline - correct_baseline core.peak_analysis - find_thermal_peaks, characterize_peaks, ThermalPeak diff --git a/core/execution_engine.py b/core/execution_engine.py index 6de688ad..ca458f38 100644 --- a/core/execution_engine.py +++ b/core/execution_engine.py @@ -121,6 +121,7 @@ def run_single_analysis( workflow_template_id: str | None, app_version: str | None, run_id: str | None = None, + unit_mode: str | None = None, ) -> dict[str, Any]: """Run one stable modality analysis against one dataset.""" spec = require_stable_modality(analysis_type) @@ -150,6 +151,7 @@ def run_single_analysis( analyst_name=((state.get("branding") or {}).get("analyst_name") or ""), app_version=app_version, batch_run_id=execution_id, + unit_mode=unit_mode, ) except Exception as exc: # pragma: no cover - covered by API-level tests message = str(exc) diff --git a/core/experiment_recommender.py b/core/experiment_recommender.py index 83d4c823..c7444dc9 100644 --- a/core/experiment_recommender.py +++ b/core/experiment_recommender.py @@ -154,6 +154,14 @@ def recommend_next_experiments( "Validate component assignments against orthogonal measurements or known reference transitions.", ] ) + elif analysis == "FTIR": + recommendations.extend( + [ + "Expand or enable reference-library coverage (or add curated user references) before treating similarity scores as identification.", + "Revisit peak picking, baseline correction, and normalization settings, then rerun to confirm peak lists are stable.", + "Pair FTIR with orthogonal characterization when chemistry must be confirmed beyond spectral similarity.", + ] + ) if mechanism_hint in {"conversion_dependent", "residue_forming"} and material_class not in {"hydrate_salt", "carbonate_inorganic", "hydroxide_to_oxide", "oxalate_multistage_inorganic"}: recommendations.append( diff --git a/core/figure_preview_resize.py b/core/figure_preview_resize.py new file mode 100644 index 00000000..c6c17da2 --- /dev/null +++ b/core/figure_preview_resize.py @@ -0,0 +1,40 @@ +"""Downscale PNG bytes for lightweight previews (Slice 6). + +If Pillow is missing or the payload is not a valid PNG, returns the original bytes. +""" + +from __future__ import annotations + +import io + + +def maybe_downscale_png_to_max_edge(png_bytes: bytes, max_edge: int) -> bytes: + """If max(width, height) > max_edge, resize proportionally so the long edge is max_edge.""" + if max_edge < 1 or not png_bytes: + return png_bytes + try: + from PIL import Image + except ImportError: + return png_bytes + try: + src = io.BytesIO(png_bytes) + im = Image.open(src) + im.load() + except Exception: + return png_bytes + w, h = im.size + if max(w, h) <= max_edge: + return png_bytes + scale = max_edge / float(max(w, h)) + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + try: + resample = Image.Resampling.LANCZOS + except AttributeError: + resample = Image.LANCZOS # type: ignore[attr-defined] + resized = im.resize((nw, nh), resample) + out = io.BytesIO() + if resized.mode == "P": + resized = resized.convert("RGBA") + resized.save(out, format="PNG", optimize=True) + return out.getvalue() diff --git a/core/figure_render.py b/core/figure_render.py new file mode 100644 index 00000000..5dd04c12 --- /dev/null +++ b/core/figure_render.py @@ -0,0 +1,175 @@ +"""Shared Plotly-to-PNG rendering helpers with resilient fallbacks.""" + +from __future__ import annotations + +import io +import math +import os +import re +from typing import Any + +import plotly.graph_objects as go +import plotly.io as pio + +_FORCE_MPL_FALLBACK_ENV = "MATERIALSCOPE_FORCE_MPL_FIG_CAPTURE" +_DASH_RE = re.compile(r"<[^>]+>") + + +def _clean_text(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return _DASH_RE.sub("", text).strip() + + +def _dash_to_linestyle(dash: str | None) -> str: + token = str(dash or "").strip().lower() + if token in {"dash", "longdash"}: + return "--" + if token in {"dot", "longdashdot"}: + return ":" + if token == "dashdot": + return "-." + return "-" + + +def _to_float_list(values: Any) -> list[float]: + if values is None: + return [] + out: list[float] = [] + for item in list(values): + try: + num = float(item) + except (TypeError, ValueError): + return [] + if not math.isfinite(num): + return [] + out.append(num) + return out + + +def _matplotlib_fallback_png(fig: go.Figure, *, width: int | None, height: int | None) -> bytes | None: + try: + import matplotlib + + matplotlib.use("Agg", force=True) + from matplotlib import pyplot as plt + except Exception: + return None + + width_px = int(width or fig.layout.width or 980) + height_px = int(height or fig.layout.height or 560) + width_in = max(width_px / 100.0, 4.0) + height_in = max(height_px / 100.0, 3.0) + + figure = plt.figure(figsize=(width_in, height_in), dpi=120) + axis = figure.add_subplot(1, 1, 1) + plotted = 0 + legend_entries = 0 + + for trace in list(fig.data): + x = _to_float_list(getattr(trace, "x", None)) + y = _to_float_list(getattr(trace, "y", None)) + if not x or not y or len(x) != len(y): + continue + + mode = str(getattr(trace, "mode", "lines") or "lines").lower() + draw_markers = "markers" in mode + color = getattr(getattr(trace, "line", None), "color", None) or getattr(getattr(trace, "marker", None), "color", None) + if isinstance(color, (list, tuple, dict)): + color = None + width_px_line = getattr(getattr(trace, "line", None), "width", None) + try: + linewidth = float(width_px_line) if width_px_line is not None else 1.8 + except (TypeError, ValueError): + linewidth = 1.8 + opacity = getattr(trace, "opacity", None) + try: + alpha = float(opacity) if opacity is not None else 1.0 + except (TypeError, ValueError): + alpha = 1.0 + alpha = min(max(alpha, 0.1), 1.0) + linestyle = _dash_to_linestyle(getattr(getattr(trace, "line", None), "dash", None)) + label = str(getattr(trace, "name", "") or "").strip() + showlegend = getattr(trace, "showlegend", True) + if not label or showlegend is False: + label = None + if label: + legend_entries += 1 + + axis.plot( + x, + y, + linestyle=linestyle, + marker="o" if draw_markers else None, + markersize=3.2 if draw_markers else 0, + linewidth=linewidth, + color=color, + alpha=alpha, + label=label, + ) + plotted += 1 + + if plotted == 0: + plt.close(figure) + return None + + x_title = _clean_text(getattr(getattr(fig.layout, "xaxis", None), "title", None).text if getattr(getattr(fig.layout, "xaxis", None), "title", None) else "") + y_title = _clean_text(getattr(getattr(fig.layout, "yaxis", None), "title", None).text if getattr(getattr(fig.layout, "yaxis", None), "title", None) else "") + title = _clean_text(getattr(getattr(fig.layout, "title", None), "text", "")) + if x_title: + axis.set_xlabel(x_title) + if y_title: + axis.set_ylabel(y_title) + if title: + axis.set_title(title, fontsize=11) + axis.grid(True, alpha=0.18, linewidth=0.7) + if legend_entries > 0: + axis.legend(loc="best", fontsize=8) + figure.tight_layout() + + buffer = io.BytesIO() + figure.savefig(buffer, format="png", dpi=140) + plt.close(figure) + return buffer.getvalue() + + +def render_plotly_figure_png( + fig: go.Figure, + *, + width: int | None = None, + height: int | None = None, +) -> tuple[bytes | None, str | None]: + """Render a Plotly figure to PNG. + + Returns + ------- + (png_bytes, render_mode_or_error) + ``png_bytes`` is ``None`` only if every renderer failed. + ``render_mode_or_error`` is: + - ``None`` for Plotly/Kaleido success, + - a renderer tag (for example ``matplotlib_fallback``) on fallback success, + - an error message on failure. + """ + force_fallback = os.getenv(_FORCE_MPL_FALLBACK_ENV, "").strip().lower() in {"1", "true", "yes"} + primary_error: str | None = None + + if not force_fallback: + try: + return pio.to_image( + fig, + format="png", + engine="kaleido", + width=width, + height=height, + ), None + except Exception as exc: # pragma: no cover - depends on runtime renderer availability. + primary_error = str(exc) + + fallback_png = _matplotlib_fallback_png(fig, width=width, height=height) + if fallback_png: + return fallback_png, "matplotlib_fallback" + + if primary_error: + return None, primary_error + return None, "Figure rendering failed: neither Kaleido nor matplotlib fallback produced PNG bytes." diff --git a/core/ftir_literature_query_builder.py b/core/ftir_literature_query_builder.py new file mode 100644 index 00000000..fcd781a4 --- /dev/null +++ b/core/ftir_literature_query_builder.py @@ -0,0 +1,247 @@ +"""Deterministic FTIR literature query payloads for spectral-similarity records.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from core.thermal_literature_query_builder import ( + _best_subject, + _build_payload, + _clean_float, + _clean_int, + _clean_text, + _infer_search_mode, + _infer_subject_trust, + _quoted_subject, + _rows, + _summary, + _workflow_label, +) + + +def _top_row_evidence(rows: list[dict[str, Any]]) -> dict[str, Any]: + if not rows: + return {} + top = rows[0] + return dict(top.get("evidence") or {}) if isinstance(top, Mapping) else {} + + +def _wavenumber_query_terms(evidence: Mapping[str, Any], *, limit: int = 6) -> list[str]: + pairs = evidence.get("matched_peak_pairs") or [] + out: list[str] = [] + for pair in pairs[:limit]: + if not isinstance(pair, Mapping): + continue + raw = pair.get("observed_position") + try: + v = float(raw) + except (TypeError, ValueError): + continue + if v > 0.0: + out.append(f"{v:.0f} cm-1") + return out + + +def _numeric_anchors_from_terms(terms: list[str]) -> list[str]: + anchors: list[str] = [] + for term in terms: + cleaned = _clean_text(term).replace("cm-1", "").replace("cm^-1", "").strip() + parts = cleaned.replace(",", " ").split() + for p in parts: + if p.replace(".", "", 1).isdigit(): + anchors.append(p.split(".")[0] if "." in p else p) + deduped: list[str] = [] + seen: set[str] = set() + for a in anchors: + key = a.casefold() + if key in seen or not key: + continue + seen.add(key) + deduped.append(a) + return deduped[:8] + + +def build_ftir_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: + """Build a traceable FTIR literature query payload (spectral similarity / library context).""" + summary = _summary(record) + rows = _rows(record) + processing = dict(record.get("processing") or {}) + method_context = dict(processing.get("method_context") or {}) + + subject = _best_subject(record) + subject_trust = _infer_subject_trust(subject) + subject_label = _clean_text(subject.get("cleaned")) + quoted_subject = _quoted_subject(subject) + + match_status = _clean_text(summary.get("match_status")).lower() + confidence_band = _clean_text(summary.get("confidence_band")).lower() + peak_count = _clean_int(summary.get("peak_count")) or 0 + top_name = _clean_text(summary.get("top_match_name")) + top_score = _clean_float(summary.get("top_match_score")) + + evidence = _top_row_evidence(rows) + wn_terms = _wavenumber_query_terms(evidence) + shared_peaks = _clean_int(evidence.get("shared_peak_count")) or 0 + coverage = _clean_float(evidence.get("coverage_ratio")) + + signal_role = _clean_text(method_context.get("ftir_signal_role")).lower() + + modality_terms = [ + "FTIR", + "Fourier transform infrared spectroscopy", + "infrared spectroscopy", + "vibrational spectroscopy", + ] + + prioritized: list[str] = [] + rationale_parts: list[str] = [] + trust = subject_trust + + if match_status == "library_unavailable": + search_mode = "behavior_first" + trust = "absent" if subject_trust == "absent" else subject_trust + prioritized.extend( + [ + " ".join([modality_terms[0], modality_terms[1], "spectral library", "reference matching"]), + " ".join([modality_terms[0], "spectral preprocessing", "baseline correction", "similarity metric"]), + " ".join([modality_terms[2], "qualitative screening", "best practices"]), + ] + ) + rationale_parts.append( + "The on-device reference spectral library was unavailable or not configured, so the literature query deliberately avoids " + "asserting a ranked spectral identification and instead targets FTIR methodology and library-matching practice." + ) + elif match_status == "matched" and top_name and confidence_band not in {"", "no_match"}: + search_mode = "known_material" if subject_trust == "trusted" else "behavior_first" + trust = subject_trust + core = " ".join(part for part in [modality_terms[0], modality_terms[2], f"\"{top_name}\"", "reference spectrum"] if part) + prioritized.append(core) + if wn_terms: + prioritized.append(" ".join([modality_terms[0], top_name, *wn_terms[:3], "absorption bands"])) + if subject_label: + prioritized.append(" ".join([quoted_subject or subject_label, modality_terms[0], top_name, "infrared"])) + prioritized.append(" ".join([modality_terms[0], "spectral similarity", "library screening"])) + rationale_parts.append( + f"The literature search is anchored to the retained top spectral candidate ({top_name}) as a qualitative FTIR library outcome" + + (f" (normalized score {top_score:.3f})." if top_score is not None else ".") + ) + if shared_peaks: + rationale_parts.append(f"Observed–reference overlap retained {shared_peaks} shared peak correspondences for query shaping.") + else: + search_mode = _infer_search_mode(subject_trust) + trust = subject_trust + if subject_label and search_mode == "known_material": + prioritized.append( + " ".join(part for part in [quoted_subject or subject_label, modality_terms[0], modality_terms[2], "absorption bands"] if part) + ) + if wn_terms and peak_count >= 2: + prioritized.append(" ".join([modality_terms[0], *wn_terms[:4], "infrared absorption"])) + if peak_count >= 1 and not prioritized: + prioritized.append(" ".join([modality_terms[0], modality_terms[3], "peak-resolved screening"])) + prioritized.append(" ".join([modality_terms[0], modality_terms[2], "qualitative interpretation"])) + if subject_label: + prioritized.append(" ".join([quoted_subject or subject_label, "infrared spectroscopy", "functional groups"])) + prioritized.append(" ".join([modality_terms[1], "spectral similarity"])) + rationale_parts.append( + "The FTIR literature search uses modality-first wording aligned to the current spectral-similarity summary" + + (" and detected peak positions when available." if wn_terms else ".") + ) + if match_status == "no_match": + rationale_parts.append( + "No candidate met the similarity threshold; external literature is framed as contextual vibrational spectroscopy guidance, " + "not as validation of a library identification." + ) + + prioritized = [q for q in prioritized if _clean_text(q)] + deduped: list[str] = [] + seen: set[str] = set() + for q in prioritized: + key = q.casefold() + if key in seen: + continue + seen.add(key) + deduped.append(q) + + query_text = deduped[0] if deduped else " ".join([modality_terms[0], modality_terms[2], "qualitative screening"]) + fallback_queries = deduped[1:] + rationale = " ".join(rationale_parts).strip() + if signal_role and signal_role != "unknown": + rationale += f" Recorded signal role: {signal_role}." + + display_title = subject_label or top_name or "FTIR spectral screening" + display_mode = "FTIR / vibrational spectroscopy" + display_terms = _clean_display_terms( + modality_terms[:3], + [top_name] if top_name else [], + wn_terms[:3], + [f"peaks:{peak_count}"] if peak_count else [], + ) + + evidence_snapshot: dict[str, Any] = { + "sample_name": subject_label, + "raw_subject": _clean_text(subject.get("raw")), + "subject_source": _clean_text(subject.get("source")), + "subject_trust": trust, + "search_mode": search_mode, + "filename_like_subject": bool(subject.get("filename_like")), + "workflow_template": _workflow_label(record), + "match_status": match_status, + "confidence_band": confidence_band, + "peak_count": peak_count, + "top_match_name": top_name, + "top_match_score": top_score, + "shared_peak_count": shared_peaks, + "coverage_ratio": coverage, + "wavenumber_terms": wn_terms, + "wavenumber_anchors": _numeric_anchors_from_terms(wn_terms), + "signal_role": signal_role, + "library_result_source": _clean_text(summary.get("library_result_source")), + } + + return _build_payload( + analysis_type="FTIR", + search_mode=search_mode, + subject_trust=trust, + query_text=query_text, + fallback_queries=fallback_queries, + query_rationale=rationale, + query_display_title=display_title, + query_display_mode=display_mode, + query_display_terms=display_terms, + evidence_snapshot=evidence_snapshot, + ) + + +def _clean_display_terms(*groups: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for group in groups: + for item in group: + cleaned = _clean_text(item) + if not cleaned: + continue + key = cleaned.casefold() + if key in seen: + continue + seen.add(key) + out.append(cleaned) + if len(out) >= 10: + return out + return out + + +def build_ftir_query_presentation(query_payload: Mapping[str, Any]) -> dict[str, Any]: + from core.thermal_literature_query_builder import build_thermal_query_presentation + + return build_thermal_query_presentation(query_payload) + + +def _ftir_query_is_too_narrow(query_payload: Mapping[str, Any]) -> bool: + snap = dict(query_payload.get("evidence_snapshot") or {}) + if _clean_text(snap.get("match_status")).lower() == "library_unavailable": + return False + has_subject = bool(_clean_text(snap.get("sample_name"))) + has_top = bool(_clean_text(snap.get("top_match_name"))) + peaks = _clean_int(snap.get("peak_count")) or 0 + wn = snap.get("wavenumber_terms") or [] + return not has_subject and not has_top and peaks < 2 and not wn diff --git a/core/hosted_library.py b/core/hosted_library.py index 1c6eaf48..3e9e118f 100644 --- a/core/hosted_library.py +++ b/core/hosted_library.py @@ -4,6 +4,7 @@ import hashlib import json +import logging import os from datetime import UTC, datetime, timedelta from pathlib import Path @@ -11,7 +12,10 @@ import numpy as np -LIBRARY_ENV_HOSTED_ROOT = "THERMOANALYZER_LIBRARY_HOSTED_ROOT" +from core.path_env import library_filesystem_env_looks_like_windows_leak + +LIBRARY_ENV_HOSTED_ROOT = "MATERIALSCOPE_LIBRARY_HOSTED_ROOT" +LIBRARY_ENV_HOSTED_ROOT_LEGACY = "THERMOANALYZER_LIBRARY_HOSTED_ROOT" PROJECT_ROOT = Path(__file__).resolve().parents[1] DEFAULT_HOSTED_ROOT = PROJECT_ROOT / "build" / "reference_library_hosted" HOSTED_MANIFEST_FILE = "manifest.json" @@ -22,13 +26,28 @@ _SAMPLE_NORMALIZED_ROOT_NAMES = ("reference_library_ingest_cloud_dev",) _XRD_SEED_COVERAGE_THRESHOLD = 12 +logger = logging.getLogger(__name__) + def utcnow_iso() -> str: return datetime.now(UTC).isoformat(timespec="seconds") def resolve_hosted_root(root: str | Path | None = None) -> Path: - configured = str(root or os.getenv(LIBRARY_ENV_HOSTED_ROOT, "")).strip() + configured = str( + root + or os.getenv(LIBRARY_ENV_HOSTED_ROOT, "") + or os.getenv(LIBRARY_ENV_HOSTED_ROOT_LEGACY, "") + ).strip() + if configured and library_filesystem_env_looks_like_windows_leak(configured): + logger.warning( + "Ignoring %s / %s hosted root %r on this platform; using default %s.", + LIBRARY_ENV_HOSTED_ROOT, + LIBRARY_ENV_HOSTED_ROOT_LEGACY, + configured, + DEFAULT_HOSTED_ROOT, + ) + configured = "" return Path(configured or DEFAULT_HOSTED_ROOT).resolve() diff --git a/core/library_cloud_client.py b/core/library_cloud_client.py index 8a745727..f7185051 100644 --- a/core/library_cloud_client.py +++ b/core/library_cloud_client.py @@ -18,10 +18,14 @@ ) -CLOUD_URL_ENV = "THERMOANALYZER_LIBRARY_CLOUD_URL" -CLOUD_ENABLED_ENV = "THERMOANALYZER_LIBRARY_CLOUD_ENABLED" -DEV_CLOUD_AUTH_ENV = "THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH" -LIBRARY_LICENSE_HEADER = "X-TA-License" +CLOUD_URL_ENV = "MATERIALSCOPE_LIBRARY_CLOUD_URL" +CLOUD_URL_ENV_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_URL" +CLOUD_ENABLED_ENV = "MATERIALSCOPE_LIBRARY_CLOUD_ENABLED" +CLOUD_ENABLED_ENV_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_ENABLED" +DEV_CLOUD_AUTH_ENV = "MATERIALSCOPE_LIBRARY_DEV_CLOUD_AUTH" +DEV_CLOUD_AUTH_ENV_LEGACY = "THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH" +LIBRARY_LICENSE_HEADER = "X-MaterialScope-License" +LIBRARY_LICENSE_HEADER_LEGACY = "X-TA-License" AUTHORIZATION_HEADER = "Authorization" HEALTH_PATH = "/health" TOKEN_SKEW_SECONDS = 20 @@ -45,11 +49,15 @@ def _parse_expiry_epoch(value: Any) -> float: return float(parsed.timestamp()) +def _env_value(primary: str, legacy: str, default: str = "") -> str: + return str(os.getenv(primary, "") or os.getenv(legacy, "") or default) + + class ManagedLibraryCloudClient: - """HTTP client for ThermoAnalyzer-owned managed library endpoints.""" + """HTTP client for MaterialScope-managed library endpoints.""" def __init__(self, *, base_url: str | None = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS) -> None: - self.base_url = str(base_url or os.getenv(CLOUD_URL_ENV, "")).strip().rstrip("/") + self.base_url = str(base_url or _env_value(CLOUD_URL_ENV, CLOUD_URL_ENV_LEGACY)).strip().rstrip("/") self.timeout_seconds = float(timeout_seconds) self._token_value = "" self._token_expires_epoch = 0.0 @@ -62,7 +70,7 @@ def __init__(self, *, base_url: str | None = None, timeout_seconds: float = DEFA def configured(self) -> bool: if not self.base_url: return False - enabled_override = os.getenv(CLOUD_ENABLED_ENV, "").strip() + enabled_override = _env_value(CLOUD_ENABLED_ENV, CLOUD_ENABLED_ENV_LEGACY).strip() if not enabled_override: return True return _truthy(enabled_override) @@ -84,11 +92,11 @@ def last_auth_mode(self) -> str: return self._last_auth_mode def _dev_auth_override_enabled(self) -> bool: - return _truthy(os.getenv(DEV_CLOUD_AUTH_ENV, "")) + return _truthy(_env_value(DEV_CLOUD_AUTH_ENV, DEV_CLOUD_AUTH_ENV_LEGACY)) @property def enabled_by_env(self) -> bool: - enabled_override = os.getenv(CLOUD_ENABLED_ENV, "").strip() + enabled_override = _env_value(CLOUD_ENABLED_ENV, CLOUD_ENABLED_ENV_LEGACY).strip() if not enabled_override: return True return _truthy(enabled_override) @@ -219,7 +227,7 @@ def _acquire_token(self) -> str | None: if not self.configured: kind = "cloud_disabled" if self.base_url and not self.enabled_by_env else "cloud_not_configured" message = ( - "Cloud library access is disabled by THERMOANALYZER_LIBRARY_CLOUD_ENABLED." + "Cloud library access is disabled by MATERIALSCOPE_LIBRARY_CLOUD_ENABLED." if kind == "cloud_disabled" else "Cloud library URL is not configured." ) @@ -235,7 +243,10 @@ def _acquire_token(self) -> str | None: try: response = httpx.post( f"{self.base_url}/v1/library/auth/token", - headers={LIBRARY_LICENSE_HEADER: encoded}, + headers={ + LIBRARY_LICENSE_HEADER: encoded, + LIBRARY_LICENSE_HEADER_LEGACY: encoded, + }, timeout=self.timeout_seconds, ) response.raise_for_status() @@ -270,7 +281,7 @@ def _request( if not self.configured: kind = "cloud_disabled" if self.base_url and not self.enabled_by_env else "cloud_not_configured" message = ( - "Cloud library access is disabled by THERMOANALYZER_LIBRARY_CLOUD_ENABLED." + "Cloud library access is disabled by MATERIALSCOPE_LIBRARY_CLOUD_ENABLED." if kind == "cloud_disabled" else "Cloud library URL is not configured." ) @@ -314,7 +325,7 @@ def health_probe(self) -> dict[str, Any]: return { "url": self.base_url, "state": "disabled", - "message": "Cloud library access is disabled by THERMOANALYZER_LIBRARY_CLOUD_ENABLED.", + "message": "Cloud library access is disabled by MATERIALSCOPE_LIBRARY_CLOUD_ENABLED.", } try: response = httpx.get(f"{self.base_url}{HEALTH_PATH}", timeout=self.timeout_seconds) @@ -372,9 +383,9 @@ def prefetch(self, payload: Mapping[str, Any]) -> dict[str, Any] | None: def _client_env_signature() -> tuple[str, str, str]: return ( - str(os.getenv(CLOUD_URL_ENV, "")).strip().rstrip("/"), - str(os.getenv(CLOUD_ENABLED_ENV, "")).strip(), - str(os.getenv(DEV_CLOUD_AUTH_ENV, "")).strip(), + _env_value(CLOUD_URL_ENV, CLOUD_URL_ENV_LEGACY).strip().rstrip("/"), + _env_value(CLOUD_ENABLED_ENV, CLOUD_ENABLED_ENV_LEGACY).strip(), + _env_value(DEV_CLOUD_AUTH_ENV, DEV_CLOUD_AUTH_ENV_LEGACY).strip(), ) @@ -383,3 +394,9 @@ def get_library_cloud_client() -> ManagedLibraryCloudClient: if _CLIENT_INSTANCE is None or getattr(_CLIENT_INSTANCE, "_env_signature", ()) != _client_env_signature(): _CLIENT_INSTANCE = ManagedLibraryCloudClient() return _CLIENT_INSTANCE + + +def reset_library_cloud_client() -> None: + """Drop the process-wide client singleton (used after mutating cloud URL env vars).""" + global _CLIENT_INSTANCE + _CLIENT_INSTANCE = None diff --git a/core/library_combined_bootstrap.py b/core/library_combined_bootstrap.py new file mode 100644 index 00000000..bb14a710 --- /dev/null +++ b/core/library_combined_bootstrap.py @@ -0,0 +1,135 @@ +"""Environment bootstrap for ``python -m dash_app.server`` (combined FastAPI + Dash). + +The FastAPI app and spectral batch runner call the managed cloud-library HTTP client against +``MATERIALSCOPE_LIBRARY_CLOUD_URL``. Docker-style defaults use port **8000**, while the combined +dev server listens on **8050** by default, which previously required manual shell fixes on WSL. + +This module runs **before** ``backend.app.create_app`` so ``ManagedLibraryCloudService`` reads +corrected environment variables on first startup. +""" + +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + +from core.library_cloud_client import ( + CLOUD_URL_ENV, + CLOUD_URL_ENV_LEGACY, + DEV_CLOUD_AUTH_ENV, + DEV_CLOUD_AUTH_ENV_LEGACY, + reset_library_cloud_client, +) +from core.path_env import library_filesystem_env_looks_like_windows_leak + +# Keep literal names here so this module stays lightweight (no numpy via core.hosted_library). +_ENV_HOSTED_ROOT = ("MATERIALSCOPE_LIBRARY_HOSTED_ROOT", "THERMOANALYZER_LIBRARY_HOSTED_ROOT") +_ENV_MIRROR_ROOT = ("MATERIALSCOPE_LIBRARY_MIRROR_ROOT", "THERMOANALYZER_LIBRARY_MIRROR_ROOT") + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _loopback_hostname(hostname: str) -> bool: + return hostname.strip().lower() in {"", "127.0.0.1", "localhost", "::1"} + + +def _bind_host_for_client(listen_host: str) -> str: + token = str(listen_host or "").strip() + if token in {"0.0.0.0", "::", "[::]"}: + return "127.0.0.1" + return token or "127.0.0.1" + + +def sanitize_library_path_env_vars() -> list[str]: + """Clear obviously broken Windows-derived library path env vars on POSIX hosts.""" + lines: list[str] = [] + pairs: list[tuple[str, str]] = [ + (_ENV_HOSTED_ROOT[0], "hosted library catalog"), + (_ENV_HOSTED_ROOT[1], "hosted library catalog (legacy)"), + (_ENV_MIRROR_ROOT[0], "library mirror"), + (_ENV_MIRROR_ROOT[1], "library mirror (legacy)"), + ] + for env_name, label in pairs: + raw = os.getenv(env_name, "") + if not str(raw).strip(): + continue + if library_filesystem_env_looks_like_windows_leak(str(raw)): + os.environ.pop(env_name, None) + lines.append( + f"[library-env] Ignored {env_name} ({label}): value looks like a Windows path on " + f"{sys.platform!r} ({raw!r}). Using repo defaults instead. Fix or remove this line in .env." + ) + return lines + + +def apply_combined_dash_server_library_env(*, listen_host: str, listen_port: int) -> list[str]: + """Align cloud-library client URL with this combined server's listen port when appropriate.""" + lines: list[str] = [] + if _truthy(os.getenv("MATERIALSCOPE_LIBRARY_DISABLE_COMBINED_BOOTSTRAP", "")): + lines.append( + "[library-env] Combined Dash bootstrap disabled via MATERIALSCOPE_LIBRARY_DISABLE_COMBINED_BOOTSTRAP." + ) + return lines + + bind = _bind_host_for_client(listen_host) + desired = f"http://{bind}:{int(listen_port)}".rstrip("/") + + primary = str(os.getenv(CLOUD_URL_ENV, "") or "").strip() + legacy = str(os.getenv(CLOUD_URL_ENV_LEGACY, "") or "").strip() + raw = primary or legacy + + if not raw: + os.environ[CLOUD_URL_ENV] = desired + reset_library_cloud_client() + lines.append( + f"[library-env] MATERIALSCOPE_LIBRARY_CLOUD_URL was unset; defaulting to {desired!r} for " + "this combined Dash + FastAPI process." + ) + if not _truthy(os.getenv(DEV_CLOUD_AUTH_ENV, "") or os.getenv(DEV_CLOUD_AUTH_ENV_LEGACY, "")): + lines.append( + f"[library-env] Tip: set {DEV_CLOUD_AUTH_ENV}=1 in .env for local trial-token cloud auth " + "(unless you already use a stored trial/activated license)." + ) + return lines + + try: + parsed = urlparse(raw) + except Exception: + lines.append(f"[library-env] MATERIALSCOPE_LIBRARY_CLOUD_URL is not a valid URL: {raw!r}") + return lines + + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + lines.append(f"[library-env] MATERIALSCOPE_LIBRARY_CLOUD_URL must be http(s): {raw!r}") + return lines + + url_port = parsed.port + if url_port is None: + url_port = 443 if parsed.scheme == "https" else 80 + + # Typical docker/.env.example default while this process listens elsewhere. + if ( + _loopback_hostname(str(parsed.hostname or "")) + and url_port == 8000 + and int(listen_port) != 8000 + ): + os.environ[CLOUD_URL_ENV] = desired + os.environ.pop(CLOUD_URL_ENV_LEGACY, None) + reset_library_cloud_client() + lines.append( + f"[library-env] Cloud URL pointed to loopback port 8000 ({raw!r}) but this combined server " + f"listens on {int(listen_port)}; updated MATERIALSCOPE_LIBRARY_CLOUD_URL to {desired!r}. " + "Use a standalone backend on 8000 (``python -m backend.main``) if you need that layout." + ) + return lines + + if _loopback_hostname(str(parsed.hostname or "")) and url_port != int(listen_port): + lines.append( + f"[library-env] Warning: MATERIALSCOPE_LIBRARY_CLOUD_URL uses port {url_port} ({raw!r}) but " + f"this combined server listens on {int(listen_port)}. Cloud library calls may fail with " + "connection_refused unless a separate backend is running on that port." + ) + + return lines diff --git a/core/literature_compare.py b/core/literature_compare.py index f095425b..b5339f13 100644 --- a/core/literature_compare.py +++ b/core/literature_compare.py @@ -21,6 +21,16 @@ ) from core.literature_provider import FixtureLiteratureProvider, LiteratureProvider from core.literature_provider import citation_identity_key, merge_literature_candidates +from core.ftir_literature_query_builder import ( + _ftir_query_is_too_narrow, + build_ftir_literature_query, + build_ftir_query_presentation, +) +from core.raman_literature_query_builder import ( + _raman_query_is_too_narrow, + build_raman_literature_query, + build_raman_query_presentation, +) from core.thermal_literature_query_builder import ( build_dsc_literature_query, build_dta_literature_query, @@ -79,6 +89,37 @@ "mass loss", "residue", } +FTIR_DISTRACTOR_TERMS = THERMAL_GENERIC_NEIGHBOR_TERMS | { + "x-ray diffraction", + "xrd", + "chromatography", + "hplc", + "mass spectrometry", + "nuclear magnetic resonance", + "uv-vis", + "uv/vis", +} +FTIR_DIRECT_MODALITY_TERMS = ( + "ftir", + "ft-ir", + "fourier transform infrared", + "infrared spectroscopy", + "infrared absorption", + "vibrational spectroscopy", + "ir spectroscopy", + "mid-infrared", + "molecular spectroscopy", +) + +RAMAN_DISTRACTOR_TERMS = FTIR_DISTRACTOR_TERMS +RAMAN_DIRECT_MODALITY_TERMS = ( + "raman", + "raman spectroscopy", + "raman scattering", + "stokes", + "anti-stokes", + "vibrational spectroscopy", +) def _clean_text(value: Any) -> str: @@ -526,7 +567,7 @@ def _thermal_relevance_score( and not (direct_entity_hits and (direct_process_hits or direct_modality_hits)) and process_hits == 0 ) - return score, low_specificity + return score, low_specificity, temperature_hits, entity_hits def _thermal_query_is_too_narrow(query_payload: Mapping[str, Any]) -> bool: @@ -542,12 +583,16 @@ def _thermal_validation_posture( source_text: str, hint: str, precision_score: int, + temperature_hits: int = 0, + entity_hits: int = 0, ) -> str: lowered = source_text.lower() if hint in {"contradicts", "contradict"} or any(phrase in lowered for phrase in CONTRADICT_PHRASES): return "alternative_interpretation" if precision_score >= 10 and access_class in {"open_access_full_text", "user_provided_document", "abstract_only"}: - return "related_support" + # DTA: require material/temperature anchor so generic modality-only matches do not read as validating support. + if analysis_type != "DTA" or entity_hits >= 1 or temperature_hits >= 1: + return "related_support" if analysis_type in {"DSC", "DTA", "TGA"} and precision_score >= 5: return "contextual_only" return "non_validating" @@ -561,6 +606,41 @@ def _thermal_support_label_for_posture(posture: str) -> str: return "related_but_inconclusive" +def _thermal_evidence_scope( + *, + analysis_type: str, + query_payload: Mapping[str, Any], + source_text: str, + search_mode: str, + subject_trust: str, + entity_hits: int, + temperature_hits: int, + posture: str, + low_specificity: bool, +) -> str: + if low_specificity and posture == "non_validating": + return "generic_context" + signals = _thermal_precision_signals(query_payload, analysis_type) + process_hits = _phrase_overlap_count(source_text, signals["process_terms"]) + modality_hits = _phrase_overlap_count(source_text, signals["modality_terms"]) + if search_mode == "known_material" and subject_trust == "trusted" and entity_hits >= 1: + return "material_specific" + if process_hits >= 1 or modality_hits >= 1 or temperature_hits >= 1: + return "behavior_level" + return "generic_context" + + +def _thermal_evidence_scope_summary(comparisons: list[dict[str, Any]]) -> str: + scopes = {_clean_text(item.get("evidence_scope")).lower() for item in comparisons if _clean_text(item.get("evidence_scope"))} + if "material_specific" in scopes: + return "material_specific" + if "behavior_level" in scopes: + return "behavior_level" + if "generic_context" in scopes: + return "generic_context" + return "" + + def _thermal_evidence_basis(access_field: str, access_class: str) -> str: field = _clean_text(access_field).lower() token = _clean_text(access_class).lower() @@ -646,93 +726,1039 @@ def _thermal_subject_label(query_payload: Mapping[str, Any], record: Mapping[str ) -def _thermal_comparison_note( - *, - analysis_type: str, - subject: str, - posture: str, - query_payload: Mapping[str, Any], - access_field: str, - source_text: str, -) -> str: - evidence_snapshot = dict(query_payload.get("evidence_snapshot") or {}) - evidence_basis = _thermal_evidence_basis(access_field, "") - basis_note = ( - "Reasoning used accessible open-access or user-provided text." - if evidence_basis == "oa_backed" - else "Reasoning used accessible abstract-level text." - if evidence_basis == "abstract_backed" - else "Reasoning was limited to metadata-level overlap." - ) - if analysis_type == "DSC": - tg_midpoint = _clean_float(evidence_snapshot.get("tg_midpoint")) - event_label = _clean_text(evidence_snapshot.get("peak_type")) or "thermal event" - if posture == "alternative_interpretation": - return ( - f"This paper discusses DSC behavior relevant to {subject}. It may indicate an alternative interpretation for the recorded calorimetric event rather than confirming the present result. {basis_note}" - ) - if posture == "related_support": - if tg_midpoint is not None: - return ( - f"This paper discusses DSC glass-transition or event behavior relevant to {subject}. It is directionally consistent with a transition near {tg_midpoint:.0f} C, but it remains contextual support rather than confirmation. {basis_note}" - ) - return ( - f"This paper discusses DSC {event_label} behavior relevant to {subject}. It adds contextual support for the recorded event, but it does not validate the current result. {basis_note}" - ) - if posture == "contextual_only": - return ( - f"This paper discusses DSC behavior relevant to {subject}. It provides thermal-event context only and should not be treated as confirmation of the current interpretation. {basis_note}" - ) - return ( - f"This paper is related to the DSC interpretation for {subject}, but the current evidence remains limited and non-validating. {basis_note}" - ) - if analysis_type == "DTA": - direction = _clean_text(evidence_snapshot.get("event_direction")) or "thermal event" - if posture == "alternative_interpretation": - return ( - f"This paper discusses DTA behavior relevant to {subject}. It may point toward an alternative reading of the recorded {direction} event rather than confirming the present interpretation. {basis_note}" - ) - if posture == "related_support": - return ( - f"This paper discusses DTA behavior relevant to {subject}. It is directionally consistent with the recorded {direction} event, but it remains qualitative context rather than validation. {basis_note}" - ) - if posture == "contextual_only": - return ( - f"This paper discusses DTA behavior relevant to {subject}. It adds qualitative thermal-event context only and does not validate the current result. {basis_note}" - ) - return ( - f"This paper is related to the DTA interpretation for {subject}, but the current evidence remains limited and non-validating. {basis_note}" +def _thermal_comparison_note( + *, + analysis_type: str, + subject: str, + posture: str, + query_payload: Mapping[str, Any], + access_field: str, + source_text: str, +) -> str: + evidence_snapshot = dict(query_payload.get("evidence_snapshot") or {}) + evidence_basis = _thermal_evidence_basis(access_field, "") + basis_note = ( + "Reasoning used accessible open-access or user-provided text." + if evidence_basis == "oa_backed" + else "Reasoning used accessible abstract-level text." + if evidence_basis == "abstract_backed" + else "Reasoning was limited to metadata-level overlap." + ) + if analysis_type == "DSC": + tg_midpoint = _clean_float(evidence_snapshot.get("tg_midpoint")) + event_label = _clean_text(evidence_snapshot.get("peak_type")) or "thermal event" + if posture == "alternative_interpretation": + return ( + f"This paper discusses DSC behavior relevant to {subject}. It may indicate an alternative interpretation for the recorded calorimetric event rather than confirming the present result. {basis_note}" + ) + if posture == "related_support": + if tg_midpoint is not None: + return ( + f"This paper discusses DSC glass-transition or event behavior relevant to {subject}. It is directionally consistent with a transition near {tg_midpoint:.0f} C, but it remains contextual support rather than confirmation. {basis_note}" + ) + return ( + f"This paper discusses DSC {event_label} behavior relevant to {subject}. It adds contextual support for the recorded event, but it does not validate the current result. {basis_note}" + ) + if posture == "contextual_only": + return ( + f"This paper discusses DSC behavior relevant to {subject}. It provides thermal-event context only and should not be treated as confirmation of the current interpretation. {basis_note}" + ) + return ( + f"This paper is related to the DSC interpretation for {subject}, but the current evidence remains limited and non-validating. {basis_note}" + ) + if analysis_type == "DTA": + direction = _clean_text(evidence_snapshot.get("event_direction")) or "thermal event" + if posture == "alternative_interpretation": + return ( + f"This paper discusses DTA behavior relevant to {subject}. It may point toward an alternative reading of the recorded {direction} event rather than confirming the present interpretation. {basis_note}" + ) + if posture == "related_support": + return ( + f"This paper discusses DTA behavior relevant to {subject}. It is directionally consistent with the recorded {direction} event, but it remains qualitative context rather than validation. {basis_note}" + ) + if posture == "contextual_only": + return ( + f"This paper discusses DTA behavior relevant to {subject}. It adds qualitative thermal-event context only and does not validate the current result. {basis_note}" + ) + return ( + f"This paper is related to the DTA interpretation for {subject}, but the current evidence remains limited and non-validating. {basis_note}" + ) + total_mass_loss = _clean_float(evidence_snapshot.get("total_mass_loss_percent")) + residue = _clean_float(evidence_snapshot.get("residue_percent")) + lowered = source_text.lower() + entity_bits = [term for term in ("calcium carbonate", "calcite", "caco3") if term in lowered] + process_bits = [term for term in ("decarbonation", "calcination", "thermal decomposition", "decomposition") if term in lowered] + modality_bits = [term for term in ("thermogravimetric analysis", "thermogravimetric", "tga") if term in lowered] + product_bits = [term for term in ("cao", "co2 release", "co2 evolution") if term in lowered] + topic_tokens = _dedupe(entity_bits + process_bits + modality_bits + product_bits) + topic_clause = f" It explicitly discusses {' / '.join(topic_tokens[:4])}." if topic_tokens else "" + if posture == "alternative_interpretation": + return ( + f"This paper discusses TGA decomposition behavior relevant to {subject}.{topic_clause} It may indicate an alternative interpretation for the recorded mass-loss profile rather than confirming the current result. {basis_note}" + ) + if posture == "related_support": + detail = [] + if total_mass_loss is not None: + detail.append(f"total mass loss around {total_mass_loss:.1f}%") + if residue is not None: + detail.append(f"residue around {residue:.1f}%") + suffix = f" ({', '.join(detail)})" if detail else "" + return ( + f"This paper discusses TGA decomposition behavior relevant to {subject}.{topic_clause} It is directionally consistent with the recorded mass-loss profile{suffix}, but it remains contextual support rather than validation. {basis_note}" + ) + if posture == "contextual_only": + return ( + f"This paper discusses TGA decomposition behavior relevant to {subject}.{topic_clause} It provides decomposition-profile context only and does not validate the current result. {basis_note}" + ) + return ( + f"This paper is related to the TGA interpretation for {subject}.{topic_clause} The current evidence remains limited and non-validating. {basis_note}" + ) + + +def _ftir_query_payload(record: Mapping[str, Any]) -> dict[str, Any]: + return build_ftir_literature_query(record) + + +def _ftir_subject_tokens(query_payload: Mapping[str, Any], record: Mapping[str, Any]) -> set[str]: + tokens: set[str] = set() + summary = dict(record.get("summary") or {}) + metadata = dict(record.get("metadata") or {}) + snap = dict(query_payload.get("evidence_snapshot") or {}) + values = [ + query_payload.get("query_display_title"), + summary.get("sample_name"), + metadata.get("sample_name"), + metadata.get("display_name"), + snap.get("sample_name"), + snap.get("top_match_name"), + ] + values.extend(_as_list(snap.get("wavenumber_terms"))) + values.extend(_as_list(snap.get("wavenumber_anchors"))) + for value in values: + cleaned = _clean_text(value) + if not cleaned: + continue + tokens.add(cleaned.lower()) + tokens.update(_tokenize(cleaned)) + return {token for token in tokens if token} + + +def _ftir_subject_label(query_payload: Mapping[str, Any], record: Mapping[str, Any]) -> str: + summary = dict(record.get("summary") or {}) + metadata = dict(record.get("metadata") or {}) + return ( + _clean_text(query_payload.get("query_display_title")) + or _clean_text(summary.get("top_match_name")) + or _clean_text(summary.get("sample_name")) + or _clean_text(metadata.get("sample_name")) + or _clean_text(metadata.get("display_name")) + or "the FTIR result" + ) + + +def _ftir_precision_signals(query_payload: Mapping[str, Any]) -> dict[str, Any]: + snap = dict(query_payload.get("evidence_snapshot") or {}) + entity_terms = _dedupe( + [ + _clean_text(query_payload.get("query_display_title")), + _clean_text(snap.get("sample_name")), + _clean_text(snap.get("top_match_name")), + ] + ) + process_terms = _dedupe([_clean_text(t) for t in _as_list(snap.get("wavenumber_terms")) if _clean_text(t)]) + process_terms.extend( + _dedupe( + [ + "spectral similarity", + "raman shift", + "raman bands", + "vibrational modes", + "library matching", + ] + ) + ) + modality_terms = list(FTIR_DIRECT_MODALITY_TERMS) + wn_numeric = [_clean_text(w) for w in _as_list(snap.get("wavenumber_anchors")) if _clean_text(w)] + return { + "entity_terms": entity_terms, + "process_terms": process_terms, + "modality_terms": modality_terms, + "wavenumber_terms": wn_numeric, + } + + +def _ftir_relevance_score( + *, + source_text: str, + access_class: str, + query_payload: Mapping[str, Any], + overlap: int, +) -> tuple[int, bool, int, int]: + signals = _ftir_precision_signals(query_payload) + entity_hits = _phrase_overlap_count(source_text, signals["entity_terms"]) + process_hits = _phrase_overlap_count(source_text, signals["process_terms"]) + modality_hits = _phrase_overlap_count(source_text, signals["modality_terms"]) + wn_hits = _phrase_overlap_count(source_text, signals["wavenumber_terms"]) + distractor_hits = _phrase_overlap_count(source_text, list(FTIR_DISTRACTOR_TERMS)) + direct_modality = _phrase_overlap_count(source_text, list(FTIR_DIRECT_MODALITY_TERMS)) + + score = overlap + score += entity_hits * 4 + score += process_hits * 3 + score += modality_hits * 4 + score += wn_hits * 3 + score += direct_modality * 2 + if direct_modality and (entity_hits or process_hits): + score += 6 + if access_class in {"open_access_full_text", "user_provided_document"}: + score += 2 + if distractor_hits: + direct_signal = int(bool(entity_hits)) + int(bool(process_hits)) + int(bool(modality_hits or direct_modality)) + if direct_signal == 0: + score -= distractor_hits * 6 + elif direct_signal == 1: + score -= distractor_hits * 4 + else: + score -= distractor_hits * 2 + + low_specificity = ( + score < 12 + and access_class in {"metadata_only", "abstract_only"} + and not (direct_modality and (entity_hits or wn_hits)) + and process_hits == 0 + ) + return score, low_specificity, wn_hits, entity_hits + + +def _ftir_validation_posture( + *, + access_class: str, + overlap: int, + source_text: str, + hint: str, + precision_score: int, + wavenumber_hits: int = 0, + entity_hits: int = 0, + modality_hits: int = 0, +) -> str: + lowered = source_text.lower() + if hint in {"contradicts", "contradict"} or any(phrase in lowered for phrase in CONTRADICT_PHRASES): + return "alternative_interpretation" + if modality_hits == 0: + return "non_validating" + if precision_score >= 10 and access_class in {"open_access_full_text", "user_provided_document", "abstract_only"}: + if entity_hits >= 1 or wavenumber_hits >= 1: + return "related_support" + if precision_score >= 5: + return "contextual_only" + return "non_validating" + + +def _ftir_evidence_scope( + *, + query_payload: Mapping[str, Any], + source_text: str, + search_mode: str, + subject_trust: str, + entity_hits: int, + wavenumber_hits: int, + posture: str, + low_specificity: bool, + modality_hits: int, +) -> str: + if low_specificity and posture == "non_validating": + return "generic_context" + signals = _ftir_precision_signals(query_payload) + process_hits = _phrase_overlap_count(source_text, signals["process_terms"]) + snap = dict(query_payload.get("evidence_snapshot") or {}) + if search_mode == "known_material" and subject_trust == "trusted" and entity_hits >= 1: + return "material_specific" + if modality_hits >= 1 and (process_hits >= 1 or wavenumber_hits >= 1): + return "behavior_level" + if modality_hits >= 1: + return "behavior_level" + if str(snap.get("match_status") or "").lower() == "matched" and _clean_text(snap.get("top_match_name")) and entity_hits >= 1: + return "behavior_level" + return "generic_context" + + +def _ftir_comparison_note( + *, + subject: str, + posture: str, + query_payload: Mapping[str, Any], + access_field: str, +) -> str: + snap = dict(query_payload.get("evidence_snapshot") or {}) + evidence_basis = _thermal_evidence_basis(access_field, "") + basis_note = ( + "Reasoning used accessible open-access or user-provided text." + if evidence_basis == "oa_backed" + else "Reasoning used accessible abstract-level text." + if evidence_basis == "abstract_backed" + else "Reasoning was limited to metadata-level overlap." + ) + peak_n = _clean_int(snap.get("peak_count")) + top_match = _clean_text(snap.get("top_match_name")) + mstat = _clean_text(snap.get("match_status")).lower() + anchor = "" + if top_match and mstat == "matched": + anchor = ( + f"The current FTIR ranking highlights {top_match} as a qualitative library target (not a confirmed chemical identification). " + ) + elif mstat == "library_unavailable": + anchor = ( + "Reference-library matching was unavailable for this result, so external papers should be read as methodological context rather than " + "confirmation of an absent spectral ranking. " + ) + elif mstat == "no_match": + anchor = "No library candidate met the similarity threshold; treat any external overlap as contextual spectroscopy background. " + + if posture == "alternative_interpretation": + return ( + f"{anchor}This paper discusses vibrational or infrared-related work relevant to {subject}, but it may favor an alternative reading " + f"rather than supporting the present FTIR screening outcome. {basis_note}" + ) + if posture == "related_support": + pk = f"Detected peak budget in-record: {peak_n}." if peak_n is not None else "" + return ( + f"{anchor}This paper discusses FTIR / infrared evidence relevant to {subject}. " + f"It is directionally consistent with the recorded screening context; it remains contextual support—not validation. {pk} {basis_note}".strip() + ) + if posture == "contextual_only": + return ( + f"{anchor}This paper provides infrared or vibrational-spectroscopy context around {subject}, without tightly matching the retained peaks or ranking. {basis_note}" + ) + return ( + f"{anchor}Metadata or abstracts overlap the query for {subject}, but the linkage is too weak to treat as FTIR-specific supporting evidence. {basis_note}" + ) + + +def _raman_query_payload(record: Mapping[str, Any]) -> dict[str, Any]: + return build_raman_literature_query(record) + + +def _raman_subject_tokens(query_payload: Mapping[str, Any], record: Mapping[str, Any]) -> set[str]: + tokens: set[str] = set() + summary = dict(record.get("summary") or {}) + metadata = dict(record.get("metadata") or {}) + snap = dict(query_payload.get("evidence_snapshot") or {}) + values = [ + query_payload.get("query_display_title"), + summary.get("sample_name"), + metadata.get("sample_name"), + metadata.get("display_name"), + snap.get("sample_name"), + snap.get("top_match_name"), + ] + values.extend(_as_list(snap.get("wavenumber_terms"))) + values.extend(_as_list(snap.get("wavenumber_anchors"))) + for value in values: + cleaned = _clean_text(value) + if not cleaned: + continue + tokens.add(cleaned.lower()) + tokens.update(_tokenize(cleaned)) + return {token for token in tokens if token} + + +def _raman_subject_label(query_payload: Mapping[str, Any], record: Mapping[str, Any]) -> str: + summary = dict(record.get("summary") or {}) + metadata = dict(record.get("metadata") or {}) + return ( + _clean_text(query_payload.get("query_display_title")) + or _clean_text(summary.get("top_match_name")) + or _clean_text(summary.get("sample_name")) + or _clean_text(metadata.get("sample_name")) + or _clean_text(metadata.get("display_name")) + or "the RAMAN result" + ) + + +def _raman_precision_signals(query_payload: Mapping[str, Any]) -> dict[str, Any]: + snap = dict(query_payload.get("evidence_snapshot") or {}) + entity_terms = _dedupe( + [ + _clean_text(query_payload.get("query_display_title")), + _clean_text(snap.get("sample_name")), + _clean_text(snap.get("top_match_name")), + ] + ) + process_terms = _dedupe([_clean_text(t) for t in _as_list(snap.get("wavenumber_terms")) if _clean_text(t)]) + process_terms.extend( + _dedupe( + [ + "spectral similarity", + "infrared absorption", + "infrared bands", + "vibrational modes", + "library matching", + ] + ) + ) + modality_terms = list(RAMAN_DIRECT_MODALITY_TERMS) + wn_numeric = [_clean_text(w) for w in _as_list(snap.get("wavenumber_anchors")) if _clean_text(w)] + return { + "entity_terms": entity_terms, + "process_terms": process_terms, + "modality_terms": modality_terms, + "wavenumber_terms": wn_numeric, + } + + +def _raman_relevance_score( + *, + source_text: str, + access_class: str, + query_payload: Mapping[str, Any], + overlap: int, +) -> tuple[int, bool, int, int]: + signals = _raman_precision_signals(query_payload) + entity_hits = _phrase_overlap_count(source_text, signals["entity_terms"]) + process_hits = _phrase_overlap_count(source_text, signals["process_terms"]) + modality_hits = _phrase_overlap_count(source_text, signals["modality_terms"]) + wn_hits = _phrase_overlap_count(source_text, signals["wavenumber_terms"]) + distractor_hits = _phrase_overlap_count(source_text, list(RAMAN_DISTRACTOR_TERMS)) + direct_modality = _phrase_overlap_count(source_text, list(RAMAN_DIRECT_MODALITY_TERMS)) + + score = overlap + score += entity_hits * 4 + score += process_hits * 3 + score += modality_hits * 4 + score += wn_hits * 3 + score += direct_modality * 2 + if direct_modality and (entity_hits or process_hits): + score += 6 + if access_class in {"open_access_full_text", "user_provided_document"}: + score += 2 + if distractor_hits: + direct_signal = int(bool(entity_hits)) + int(bool(process_hits)) + int(bool(modality_hits or direct_modality)) + if direct_signal == 0: + score -= distractor_hits * 6 + elif direct_signal == 1: + score -= distractor_hits * 4 + else: + score -= distractor_hits * 2 + + low_specificity = ( + score < 12 + and access_class in {"metadata_only", "abstract_only"} + and not (direct_modality and (entity_hits or wn_hits)) + and process_hits == 0 + ) + return score, low_specificity, wn_hits, entity_hits + + +def _raman_validation_posture( + *, + access_class: str, + overlap: int, + source_text: str, + hint: str, + precision_score: int, + wavenumber_hits: int = 0, + entity_hits: int = 0, + modality_hits: int = 0, +) -> str: + lowered = source_text.lower() + if hint in {"contradicts", "contradict"} or any(phrase in lowered for phrase in CONTRADICT_PHRASES): + return "alternative_interpretation" + if modality_hits == 0: + return "non_validating" + if precision_score >= 10 and access_class in {"open_access_full_text", "user_provided_document", "abstract_only"}: + if entity_hits >= 1 or wavenumber_hits >= 1: + return "related_support" + if precision_score >= 5: + return "contextual_only" + return "non_validating" + + +def _raman_evidence_scope( + *, + query_payload: Mapping[str, Any], + source_text: str, + search_mode: str, + subject_trust: str, + entity_hits: int, + wavenumber_hits: int, + posture: str, + low_specificity: bool, + modality_hits: int, +) -> str: + if low_specificity and posture == "non_validating": + return "generic_context" + signals = _raman_precision_signals(query_payload) + process_hits = _phrase_overlap_count(source_text, signals["process_terms"]) + snap = dict(query_payload.get("evidence_snapshot") or {}) + if search_mode == "known_material" and subject_trust == "trusted" and entity_hits >= 1: + return "material_specific" + if modality_hits >= 1 and (process_hits >= 1 or wavenumber_hits >= 1): + return "behavior_level" + if modality_hits >= 1: + return "behavior_level" + if str(snap.get("match_status") or "").lower() == "matched" and _clean_text(snap.get("top_match_name")) and entity_hits >= 1: + return "behavior_level" + return "generic_context" + + +def _raman_comparison_note( + *, + subject: str, + posture: str, + query_payload: Mapping[str, Any], + access_field: str, +) -> str: + snap = dict(query_payload.get("evidence_snapshot") or {}) + evidence_basis = _thermal_evidence_basis(access_field, "") + basis_note = ( + "Reasoning used accessible open-access or user-provided text." + if evidence_basis == "oa_backed" + else "Reasoning used accessible abstract-level text." + if evidence_basis == "abstract_backed" + else "Reasoning was limited to metadata-level overlap." + ) + peak_n = _clean_int(snap.get("peak_count")) + top_match = _clean_text(snap.get("top_match_name")) + mstat = _clean_text(snap.get("match_status")).lower() + anchor = "" + if top_match and mstat == "matched": + anchor = ( + f"The current RAMAN ranking highlights {top_match} as a qualitative library target (not a confirmed chemical identification). " + ) + elif mstat == "library_unavailable": + anchor = ( + "Reference-library matching was unavailable for this result, so external papers should be read as methodological context rather than " + "confirmation of an absent spectral ranking. " + ) + elif mstat == "no_match": + anchor = "No library candidate met the similarity threshold; treat any external overlap as contextual spectroscopy background. " + + if posture == "alternative_interpretation": + return ( + f"{anchor}This paper discusses vibrational or Raman-related work relevant to {subject}, but it may favor an alternative reading " + f"rather than supporting the present RAMAN screening outcome. {basis_note}" + ) + if posture == "related_support": + pk = f"Detected peak budget in-record: {peak_n}." if peak_n is not None else "" + return ( + f"{anchor}This paper discusses RAMAN / vibrational evidence relevant to {subject}. " + f"It is directionally consistent with the recorded screening context; it remains contextual support—not validation. {pk} {basis_note}".strip() + ) + if posture == "contextual_only": + return ( + f"{anchor}This paper provides Raman or vibrational-spectroscopy context around {subject}, without tightly matching the retained peaks or ranking. {basis_note}" + ) + return ( + f"{anchor}Metadata or abstracts overlap the query for {subject}, but the linkage is too weak to treat as RAMAN-specific supporting evidence. {basis_note}" + ) + + +def _compare_ftir_result_to_literature( + record: Mapping[str, Any], + *, + provider: LiteratureProvider, + provider_scope: list[str], + max_claims: int, + filters: Mapping[str, Any] | None, + user_documents: list[dict[str, Any]] | None, +) -> dict[str, Any]: + comparison_run_id = f"litcmp_{uuid.uuid4().hex[:12]}" + analysis_type = "FTIR" + query_payload = _ftir_query_payload(record) + search_mode = _clean_text(query_payload.get("search_mode")).lower() or "behavior_first" + subject_trust = _clean_text(query_payload.get("subject_trust")).lower() or "absent" + query_presentation = build_ftir_query_presentation(query_payload) + claims = extract_literature_claims(record, max_claims=max(1, int(max_claims or 1))) + normalized_user_documents = _normalize_user_document_sources(user_documents) + + search_results: dict[str, dict[str, Any]] = { + _search_result_identity(source): copy.deepcopy(source) + for source in normalized_user_documents + if _search_result_identity(source) + } + provider_request_ids: list[str] = [] + provider_result_sources = { + _clean_text((source.get("provenance") or {}).get("result_source")) + for source in normalized_user_documents + if _clean_text((source.get("provenance") or {}).get("result_source")) + } + search_filters = copy.deepcopy(dict(filters or {})) + modalities = [_clean_text(item).upper() for item in _as_list(search_filters.get("modalities")) if _clean_text(item)] + if analysis_type not in modalities: + modalities.insert(0, analysis_type) + search_filters["modalities"] = modalities + search_filters["analysis_type"] = analysis_type + search_filters.setdefault("top_k", 5) + + executed_queries: list[str] = [] + for query_text in _thermal_search_queries(query_payload): + executed_queries.append(query_text) + for candidate in provider.search(query_text, filters=search_filters): + source_key = _search_result_identity(candidate) + if not source_key: + continue + if source_key in search_results: + search_results[source_key] = merge_literature_candidates(search_results[source_key], candidate) + else: + search_results[source_key] = copy.deepcopy(candidate) + result_source = _clean_text((candidate.get("provenance") or {}).get("result_source")) + if result_source: + provider_result_sources.add(result_source) + for request_id in _provider_request_ids(provider): + if request_id and request_id not in provider_request_ids: + provider_request_ids.append(request_id) + provider_query_status = _provider_query_status(provider) + provider_error_message = _provider_error_message(provider) + + citations_by_identity: dict[str, dict[str, Any]] = {} + comparisons_with_rank: list[tuple[int, bool, dict[str, Any]]] = [] + accessible_source_ids: set[str] = set() + restricted_source_ids: set[str] = set() + used_access_fields: set[str] = set() + subject_tokens = _ftir_subject_tokens(query_payload, record) + subject_label = _ftir_subject_label(query_payload, record) + real_literature_available = False + + for source in search_results.values(): + source_identity = _search_result_identity(source) + access_class = _clean_text(source.get("access_class")).lower() or "metadata_only" + accessible = _fetch_accessible_text(source, provider=provider) + if accessible is None: + if access_class == "restricted_external": + restricted_source_ids.add(source_identity) + evidence_used: list[str] = [] + access_field = "metadata_only" + else: + accessible_source_ids.add(source_identity) + access_field = _clean_text(accessible.get("field")).lower() or "abstract_text" + used_access_fields.add(access_field) + evidence_used = [_brief_evidence(_clean_text(accessible.get("text")))] if _clean_text(accessible.get("text")) else [] + + source_text = _thermal_source_text(source, accessible) + overlap = _thermal_subject_overlap(source_text, subject_tokens) + hint = _clean_text((source.get("provenance") or {}).get("comparison_hint")).lower() + precision_score, low_specificity, wavenumber_hits, entity_hits = _ftir_relevance_score( + source_text=source_text, + access_class=access_class, + query_payload=query_payload, + overlap=overlap, + ) + modality_hits = _phrase_overlap_count(source_text, _ftir_precision_signals(query_payload)["modality_terms"]) + posture = _ftir_validation_posture( + access_class=access_class, + overlap=overlap, + source_text=source_text, + hint=hint, + precision_score=precision_score, + wavenumber_hits=wavenumber_hits, + entity_hits=entity_hits, + modality_hits=modality_hits, + ) + note = _ftir_comparison_note( + subject=subject_label, + posture=posture, + query_payload=query_payload, + access_field=access_field, + ) + evidence_scope = _ftir_evidence_scope( + query_payload=query_payload, + source_text=source_text, + search_mode=search_mode, + subject_trust=subject_trust, + entity_hits=entity_hits, + wavenumber_hits=wavenumber_hits, + posture=posture, + low_specificity=low_specificity, + modality_hits=modality_hits, + ) + citation_key = citation_identity_key(source) + if citation_key not in citations_by_identity: + citations_by_identity[citation_key] = build_citation_entry(source, citation_id=f"ref{len(citations_by_identity) + 1}") + citation = citations_by_identity[citation_key] + provider_id = _clean_text((source.get("provenance") or {}).get("provider_id")) + if provider_id in REAL_BIBLIOGRAPHIC_PROVIDERS: + real_literature_available = True + + claim = claims[0] if claims else {} + comparison = LiteratureComparison( + claim_id=str(claim.get("claim_id") or "C1"), + claim_text=str(claim.get("claim_text") or ""), + candidate_name=subject_label, + paper_title=_clean_text(source.get("title")), + paper_year=source.get("year"), + paper_journal=_clean_text(source.get("journal")), + paper_doi=_clean_text(source.get("doi")), + paper_url=_clean_text(source.get("url")), + provider_id=provider_id, + access_class=access_class, + comparison_note=note, + validation_posture=posture, + query_text=executed_queries[0] if executed_queries else _clean_text(query_payload.get("query_text")), + retrieved_sources=[_clean_text(source.get("source_id"))] if _clean_text(source.get("source_id")) else [], + support_label=_thermal_support_label_for_posture(posture), + rationale=note, + evidence_used=evidence_used, + citation_ids=[citation["citation_id"]], + confidence=_thermal_comparison_confidence(posture, access_class, overlap, precision_score=precision_score, access_field=access_field), + sources_considered=len(search_results), + evidence_scope=evidence_scope, + ).to_dict() + score = precision_score + (6 if posture == "related_support" else 4 if posture == "alternative_interpretation" else 2 if posture == "contextual_only" else 0) + comparisons_with_rank.append((score, low_specificity, comparison)) + + comparisons_with_rank.sort(key=lambda item: (-item[0], item[1], -(item[2].get("paper_year") or 0), str(item[2].get("paper_title") or ""))) + surfaced_comparisons: list[dict[str, Any]] = [] + seen_titles: set[str] = set() + strong_rows = [item for item in comparisons_with_rank if item[0] >= 10 and not item[1]] + candidate_rows = strong_rows if strong_rows else comparisons_with_rank + limit = 2 if strong_rows else 1 + for _score, low_specificity, item in candidate_rows: + title_key = _clean_text(item.get("paper_title") or item.get("paper_doi") or item.get("paper_url")).casefold() + if title_key and title_key in seen_titles: + continue + if low_specificity and strong_rows: + continue + if title_key: + seen_titles.add(title_key) + surfaced_comparisons.append(item) + if len(surfaced_comparisons) >= limit: + break + if not surfaced_comparisons and comparisons_with_rank: + surfaced_comparisons = [comparisons_with_rank[0][2]] + comparisons = _merge_thermal_surfaced_comparisons(surfaced_comparisons) + low_specificity_retrieval = bool(comparisons_with_rank) and not strong_rows and all(item[1] for item in comparisons_with_rank[: min(len(comparisons_with_rank), 3)]) + evidence_specificity = _thermal_evidence_specificity_summary( + source_count=len(search_results), + accessible_source_count=len(accessible_source_ids), + used_access_fields=used_access_fields, + ) + evidence_snapshot = dict(query_payload.get("evidence_snapshot") or {}) + summary = dict(record.get("summary") or {}) + context = LiteratureContext( + mode="metadata_abstract_oa_only", + comparison_run_id=comparison_run_id, + provider_scope=provider_scope, + result_id=_clean_text(record.get("id")), + analysis_type=analysis_type, + provider_request_ids=provider_request_ids, + provider_result_source=( + "multi_provider_search" + if len(provider_scope) > 1 + else (sorted(provider_result_sources)[0] if len(provider_result_sources) == 1 else _provider_result_source(provider, provider_scope=provider_scope)) + ), + query_count=len(executed_queries), + source_count=len(search_results), + citation_count=len(citations_by_identity), + accessible_source_count=len(accessible_source_ids), + restricted_source_count=len(restricted_source_ids), + metadata_only_evidence=evidence_specificity == "metadata_only", + restricted_content_used=False, + generated_at_utc=datetime.now(timezone.utc).isoformat(), + query_text=executed_queries[0] if executed_queries else _clean_text(query_payload.get("query_text")), + candidate_name=subject_label, + candidate_display_name=subject_label, + real_literature_available=real_literature_available, + fixture_fallback_used=bool(search_filters.get("allow_fixture_fallback")) and not real_literature_available and any( + _is_fixture_source(source) for source in search_results.values() + ), + query_rationale=_clean_text(query_payload.get("query_rationale")), + provider_query_status=provider_query_status, + no_results_reason=( + "provider_unavailable" + if provider_query_status == "provider_unavailable" + else "request_failed" + if provider_query_status == "request_failed" + else "not_configured" + if provider_query_status == "not_configured" + else "query_too_narrow" + if not search_results and _ftir_query_is_too_narrow(query_payload) + else "query_too_narrow" + if provider_query_status == "query_too_narrow" + else "no_real_results" + if not search_results and provider_query_status in {"no_results", "success", ""} + else "" + ), + fixture_fallback_allowed=bool(search_filters.get("allow_fixture_fallback")), + query_display_title=_clean_text(query_presentation.get("display_title")), + query_display_mode=_clean_text(query_presentation.get("display_mode")), + query_display_terms=_as_list(query_presentation.get("display_terms")), + search_mode=search_mode, + subject_trust=subject_trust, + evidence_scope_summary=_thermal_evidence_scope_summary(comparisons), + low_specificity_retrieval=low_specificity_retrieval, + surfaced_comparison_count=len(comparisons), + evidence_specificity_summary=evidence_specificity, + executed_queries=executed_queries, + match_status_snapshot=_clean_text(summary.get("match_status")), + confidence_band_snapshot=_clean_text(summary.get("confidence_band")), + shared_peak_count_snapshot=_clean_int(evidence_snapshot.get("shared_peak_count") or evidence_snapshot.get("peak_count")), + coverage_ratio_snapshot=_clean_float(evidence_snapshot.get("coverage_ratio")), + weighted_overlap_score_snapshot=_clean_float(evidence_snapshot.get("top_match_score")), + ).to_dict() + if provider_error_message: + context["provider_error_message"] = provider_error_message + + citations = sorted(citations_by_identity.values(), key=lambda item: item["citation_id"]) + return { + "literature_context": normalize_literature_context(context), + "literature_claims": claims, + "literature_comparisons": normalize_literature_comparisons(comparisons), + "citations": normalize_citations(citations), + } + + +def _compare_raman_result_to_literature( + record: Mapping[str, Any], + *, + provider: LiteratureProvider, + provider_scope: list[str], + max_claims: int, + filters: Mapping[str, Any] | None, + user_documents: list[dict[str, Any]] | None, +) -> dict[str, Any]: + comparison_run_id = f"litcmp_{uuid.uuid4().hex[:12]}" + analysis_type = "RAMAN" + query_payload = _raman_query_payload(record) + search_mode = _clean_text(query_payload.get("search_mode")).lower() or "behavior_first" + subject_trust = _clean_text(query_payload.get("subject_trust")).lower() or "absent" + query_presentation = build_raman_query_presentation(query_payload) + claims = extract_literature_claims(record, max_claims=max(1, int(max_claims or 1))) + normalized_user_documents = _normalize_user_document_sources(user_documents) + + search_results: dict[str, dict[str, Any]] = { + _search_result_identity(source): copy.deepcopy(source) + for source in normalized_user_documents + if _search_result_identity(source) + } + provider_request_ids: list[str] = [] + provider_result_sources = { + _clean_text((source.get("provenance") or {}).get("result_source")) + for source in normalized_user_documents + if _clean_text((source.get("provenance") or {}).get("result_source")) + } + search_filters = copy.deepcopy(dict(filters or {})) + modalities = [_clean_text(item).upper() for item in _as_list(search_filters.get("modalities")) if _clean_text(item)] + if analysis_type not in modalities: + modalities.insert(0, analysis_type) + search_filters["modalities"] = modalities + search_filters["analysis_type"] = analysis_type + search_filters.setdefault("top_k", 5) + + executed_queries: list[str] = [] + for query_text in _thermal_search_queries(query_payload): + executed_queries.append(query_text) + for candidate in provider.search(query_text, filters=search_filters): + source_key = _search_result_identity(candidate) + if not source_key: + continue + if source_key in search_results: + search_results[source_key] = merge_literature_candidates(search_results[source_key], candidate) + else: + search_results[source_key] = copy.deepcopy(candidate) + result_source = _clean_text((candidate.get("provenance") or {}).get("result_source")) + if result_source: + provider_result_sources.add(result_source) + for request_id in _provider_request_ids(provider): + if request_id and request_id not in provider_request_ids: + provider_request_ids.append(request_id) + provider_query_status = _provider_query_status(provider) + provider_error_message = _provider_error_message(provider) + + citations_by_identity: dict[str, dict[str, Any]] = {} + comparisons_with_rank: list[tuple[int, bool, dict[str, Any]]] = [] + accessible_source_ids: set[str] = set() + restricted_source_ids: set[str] = set() + used_access_fields: set[str] = set() + subject_tokens = _raman_subject_tokens(query_payload, record) + subject_label = _raman_subject_label(query_payload, record) + real_literature_available = False + + for source in search_results.values(): + source_identity = _search_result_identity(source) + access_class = _clean_text(source.get("access_class")).lower() or "metadata_only" + accessible = _fetch_accessible_text(source, provider=provider) + if accessible is None: + if access_class == "restricted_external": + restricted_source_ids.add(source_identity) + evidence_used: list[str] = [] + access_field = "metadata_only" + else: + accessible_source_ids.add(source_identity) + access_field = _clean_text(accessible.get("field")).lower() or "abstract_text" + used_access_fields.add(access_field) + evidence_used = [_brief_evidence(_clean_text(accessible.get("text")))] if _clean_text(accessible.get("text")) else [] + + source_text = _thermal_source_text(source, accessible) + overlap = _thermal_subject_overlap(source_text, subject_tokens) + hint = _clean_text((source.get("provenance") or {}).get("comparison_hint")).lower() + precision_score, low_specificity, wavenumber_hits, entity_hits = _raman_relevance_score( + source_text=source_text, + access_class=access_class, + query_payload=query_payload, + overlap=overlap, ) - total_mass_loss = _clean_float(evidence_snapshot.get("total_mass_loss_percent")) - residue = _clean_float(evidence_snapshot.get("residue_percent")) - lowered = source_text.lower() - entity_bits = [term for term in ("calcium carbonate", "calcite", "caco3") if term in lowered] - process_bits = [term for term in ("decarbonation", "calcination", "thermal decomposition", "decomposition") if term in lowered] - modality_bits = [term for term in ("thermogravimetric analysis", "thermogravimetric", "tga") if term in lowered] - product_bits = [term for term in ("cao", "co2 release", "co2 evolution") if term in lowered] - topic_tokens = _dedupe(entity_bits + process_bits + modality_bits + product_bits) - topic_clause = f" It explicitly discusses {' / '.join(topic_tokens[:4])}." if topic_tokens else "" - if posture == "alternative_interpretation": - return ( - f"This paper discusses TGA decomposition behavior relevant to {subject}.{topic_clause} It may indicate an alternative interpretation for the recorded mass-loss profile rather than confirming the current result. {basis_note}" + modality_hits = _phrase_overlap_count(source_text, _raman_precision_signals(query_payload)["modality_terms"]) + posture = _raman_validation_posture( + access_class=access_class, + overlap=overlap, + source_text=source_text, + hint=hint, + precision_score=precision_score, + wavenumber_hits=wavenumber_hits, + entity_hits=entity_hits, + modality_hits=modality_hits, ) - if posture == "related_support": - detail = [] - if total_mass_loss is not None: - detail.append(f"total mass loss around {total_mass_loss:.1f}%") - if residue is not None: - detail.append(f"residue around {residue:.1f}%") - suffix = f" ({', '.join(detail)})" if detail else "" - return ( - f"This paper discusses TGA decomposition behavior relevant to {subject}.{topic_clause} It is directionally consistent with the recorded mass-loss profile{suffix}, but it remains contextual support rather than validation. {basis_note}" + note = _raman_comparison_note( + subject=subject_label, + posture=posture, + query_payload=query_payload, + access_field=access_field, ) - if posture == "contextual_only": - return ( - f"This paper discusses TGA decomposition behavior relevant to {subject}.{topic_clause} It provides decomposition-profile context only and does not validate the current result. {basis_note}" + evidence_scope = _raman_evidence_scope( + query_payload=query_payload, + source_text=source_text, + search_mode=search_mode, + subject_trust=subject_trust, + entity_hits=entity_hits, + wavenumber_hits=wavenumber_hits, + posture=posture, + low_specificity=low_specificity, + modality_hits=modality_hits, ) - return ( - f"This paper is related to the TGA interpretation for {subject}.{topic_clause} The current evidence remains limited and non-validating. {basis_note}" + citation_key = citation_identity_key(source) + if citation_key not in citations_by_identity: + citations_by_identity[citation_key] = build_citation_entry(source, citation_id=f"ref{len(citations_by_identity) + 1}") + citation = citations_by_identity[citation_key] + provider_id = _clean_text((source.get("provenance") or {}).get("provider_id")) + if provider_id in REAL_BIBLIOGRAPHIC_PROVIDERS: + real_literature_available = True + + claim = claims[0] if claims else {} + comparison = LiteratureComparison( + claim_id=str(claim.get("claim_id") or "C1"), + claim_text=str(claim.get("claim_text") or ""), + candidate_name=subject_label, + paper_title=_clean_text(source.get("title")), + paper_year=source.get("year"), + paper_journal=_clean_text(source.get("journal")), + paper_doi=_clean_text(source.get("doi")), + paper_url=_clean_text(source.get("url")), + provider_id=provider_id, + access_class=access_class, + comparison_note=note, + validation_posture=posture, + query_text=executed_queries[0] if executed_queries else _clean_text(query_payload.get("query_text")), + retrieved_sources=[_clean_text(source.get("source_id"))] if _clean_text(source.get("source_id")) else [], + support_label=_thermal_support_label_for_posture(posture), + rationale=note, + evidence_used=evidence_used, + citation_ids=[citation["citation_id"]], + confidence=_thermal_comparison_confidence(posture, access_class, overlap, precision_score=precision_score, access_field=access_field), + sources_considered=len(search_results), + evidence_scope=evidence_scope, + ).to_dict() + score = precision_score + (6 if posture == "related_support" else 4 if posture == "alternative_interpretation" else 2 if posture == "contextual_only" else 0) + comparisons_with_rank.append((score, low_specificity, comparison)) + + comparisons_with_rank.sort(key=lambda item: (-item[0], item[1], -(item[2].get("paper_year") or 0), str(item[2].get("paper_title") or ""))) + surfaced_comparisons: list[dict[str, Any]] = [] + seen_titles: set[str] = set() + strong_rows = [item for item in comparisons_with_rank if item[0] >= 10 and not item[1]] + candidate_rows = strong_rows if strong_rows else comparisons_with_rank + limit = 2 if strong_rows else 1 + for _score, low_specificity, item in candidate_rows: + title_key = _clean_text(item.get("paper_title") or item.get("paper_doi") or item.get("paper_url")).casefold() + if title_key and title_key in seen_titles: + continue + if low_specificity and strong_rows: + continue + if title_key: + seen_titles.add(title_key) + surfaced_comparisons.append(item) + if len(surfaced_comparisons) >= limit: + break + if not surfaced_comparisons and comparisons_with_rank: + surfaced_comparisons = [comparisons_with_rank[0][2]] + comparisons = _merge_thermal_surfaced_comparisons(surfaced_comparisons) + low_specificity_retrieval = bool(comparisons_with_rank) and not strong_rows and all(item[1] for item in comparisons_with_rank[: min(len(comparisons_with_rank), 3)]) + evidence_specificity = _thermal_evidence_specificity_summary( + source_count=len(search_results), + accessible_source_count=len(accessible_source_ids), + used_access_fields=used_access_fields, ) + evidence_snapshot = dict(query_payload.get("evidence_snapshot") or {}) + summary = dict(record.get("summary") or {}) + context = LiteratureContext( + mode="metadata_abstract_oa_only", + comparison_run_id=comparison_run_id, + provider_scope=provider_scope, + result_id=_clean_text(record.get("id")), + analysis_type=analysis_type, + provider_request_ids=provider_request_ids, + provider_result_source=( + "multi_provider_search" + if len(provider_scope) > 1 + else (sorted(provider_result_sources)[0] if len(provider_result_sources) == 1 else _provider_result_source(provider, provider_scope=provider_scope)) + ), + query_count=len(executed_queries), + source_count=len(search_results), + citation_count=len(citations_by_identity), + accessible_source_count=len(accessible_source_ids), + restricted_source_count=len(restricted_source_ids), + metadata_only_evidence=evidence_specificity == "metadata_only", + restricted_content_used=False, + generated_at_utc=datetime.now(timezone.utc).isoformat(), + query_text=executed_queries[0] if executed_queries else _clean_text(query_payload.get("query_text")), + candidate_name=subject_label, + candidate_display_name=subject_label, + real_literature_available=real_literature_available, + fixture_fallback_used=bool(search_filters.get("allow_fixture_fallback")) and not real_literature_available and any( + _is_fixture_source(source) for source in search_results.values() + ), + query_rationale=_clean_text(query_payload.get("query_rationale")), + provider_query_status=provider_query_status, + no_results_reason=( + "provider_unavailable" + if provider_query_status == "provider_unavailable" + else "request_failed" + if provider_query_status == "request_failed" + else "not_configured" + if provider_query_status == "not_configured" + else "query_too_narrow" + if not search_results and _raman_query_is_too_narrow(query_payload) + else "query_too_narrow" + if provider_query_status == "query_too_narrow" + else "no_real_results" + if not search_results and provider_query_status in {"no_results", "success", ""} + else "" + ), + fixture_fallback_allowed=bool(search_filters.get("allow_fixture_fallback")), + query_display_title=_clean_text(query_presentation.get("display_title")), + query_display_mode=_clean_text(query_presentation.get("display_mode")), + query_display_terms=_as_list(query_presentation.get("display_terms")), + search_mode=search_mode, + subject_trust=subject_trust, + evidence_scope_summary=_thermal_evidence_scope_summary(comparisons), + low_specificity_retrieval=low_specificity_retrieval, + surfaced_comparison_count=len(comparisons), + evidence_specificity_summary=evidence_specificity, + executed_queries=executed_queries, + match_status_snapshot=_clean_text(summary.get("match_status")), + confidence_band_snapshot=_clean_text(summary.get("confidence_band")), + shared_peak_count_snapshot=_clean_int(evidence_snapshot.get("shared_peak_count") or evidence_snapshot.get("peak_count")), + coverage_ratio_snapshot=_clean_float(evidence_snapshot.get("coverage_ratio")), + weighted_overlap_score_snapshot=_clean_float(evidence_snapshot.get("top_match_score")), + ).to_dict() + if provider_error_message: + context["provider_error_message"] = provider_error_message + + citations = sorted(citations_by_identity.values(), key=lambda item: item["citation_id"]) + return { + "literature_context": normalize_literature_context(context), + "literature_claims": claims, + "literature_comparisons": normalize_literature_comparisons(comparisons), + "citations": normalize_citations(citations), + } def _compare_generic_result_to_literature( @@ -919,6 +1945,8 @@ def _compare_thermal_result_to_literature( comparison_run_id = f"litcmp_{uuid.uuid4().hex[:12]}" analysis_type = _clean_text(record.get("analysis_type")).upper() query_payload = _thermal_query_payload(record) + search_mode = _clean_text(query_payload.get("search_mode")).lower() or "behavior_first" + subject_trust = _clean_text(query_payload.get("subject_trust")).lower() or "absent" query_presentation = build_thermal_query_presentation(query_payload) claims = extract_literature_claims(record, max_claims=max(1, int(max_claims or 1))) normalized_user_documents = _normalize_user_document_sources(user_documents) @@ -989,7 +2017,7 @@ def _compare_thermal_result_to_literature( source_text = _thermal_source_text(source, accessible) overlap = _thermal_subject_overlap(source_text, subject_tokens) hint = _clean_text((source.get("provenance") or {}).get("comparison_hint")).lower() - precision_score, low_specificity = _thermal_relevance_score( + precision_score, low_specificity, temperature_hits, entity_hits = _thermal_relevance_score( source_text=source_text, access_class=access_class, query_payload=query_payload, @@ -1003,6 +2031,8 @@ def _compare_thermal_result_to_literature( source_text=source_text, hint=hint, precision_score=precision_score, + temperature_hits=temperature_hits, + entity_hits=entity_hits, ) note = _thermal_comparison_note( analysis_type=analysis_type, @@ -1012,6 +2042,17 @@ def _compare_thermal_result_to_literature( access_field=access_field, source_text=source_text, ) + evidence_scope = _thermal_evidence_scope( + analysis_type=analysis_type, + query_payload=query_payload, + source_text=source_text, + search_mode=search_mode, + subject_trust=subject_trust, + entity_hits=entity_hits, + temperature_hits=temperature_hits, + posture=posture, + low_specificity=low_specificity, + ) citation_key = citation_identity_key(source) if citation_key not in citations_by_identity: citations_by_identity[citation_key] = build_citation_entry(source, citation_id=f"ref{len(citations_by_identity) + 1}") @@ -1042,6 +2083,7 @@ def _compare_thermal_result_to_literature( citation_ids=[citation["citation_id"]], confidence=_thermal_comparison_confidence(posture, access_class, overlap, precision_score=precision_score, access_field=access_field), sources_considered=len(search_results), + evidence_scope=evidence_scope, ).to_dict() score = precision_score + (6 if posture == "related_support" else 4 if posture == "alternative_interpretation" else 2 if posture == "contextual_only" else 0) comparisons_with_rank.append((score, low_specificity, comparison)) @@ -1121,9 +2163,13 @@ def _compare_thermal_result_to_literature( query_display_title=_clean_text(query_presentation.get("display_title")), query_display_mode=_clean_text(query_presentation.get("display_mode")), query_display_terms=_as_list(query_presentation.get("display_terms")), + search_mode=search_mode, + subject_trust=subject_trust, + evidence_scope_summary=_thermal_evidence_scope_summary(comparisons), low_specificity_retrieval=low_specificity_retrieval, surfaced_comparison_count=len(comparisons), evidence_specificity_summary=evidence_specificity, + executed_queries=executed_queries, shared_peak_count_snapshot=_clean_int(evidence_snapshot.get("peak_count") or evidence_snapshot.get("step_count")), coverage_ratio_snapshot=_clean_float(evidence_snapshot.get("total_mass_loss_percent")), weighted_overlap_score_snapshot=_clean_float( @@ -1532,6 +2578,24 @@ def compare_result_to_literature( filters=filters, user_documents=user_documents, ) + if analysis_type == "FTIR": + return _compare_ftir_result_to_literature( + record, + provider=active_provider, + provider_scope=scope, + max_claims=max_claims, + filters=filters, + user_documents=user_documents, + ) + if analysis_type == "RAMAN": + return _compare_raman_result_to_literature( + record, + provider=active_provider, + provider_scope=scope, + max_claims=max_claims, + filters=filters, + user_documents=user_documents, + ) return _compare_generic_result_to_literature( record, provider=active_provider, diff --git a/core/literature_models.py b/core/literature_models.py index 33250aad..0b83724f 100644 --- a/core/literature_models.py +++ b/core/literature_models.py @@ -29,6 +29,9 @@ "alternative_interpretation", "non_validating", } +ALLOWED_THERMAL_SEARCH_MODES = {"known_material", "behavior_first"} +ALLOWED_SUBJECT_TRUST = {"trusted", "low_trust", "absent"} +ALLOWED_EVIDENCE_SCOPES = {"material_specific", "behavior_level", "generic_context"} def _clean_text(value: Any) -> str: @@ -84,6 +87,27 @@ def _normalize_validation_posture(value: Any) -> str: return token +def _normalize_thermal_search_mode(value: Any) -> str: + token = _clean_text(value).lower() + if token not in ALLOWED_THERMAL_SEARCH_MODES: + return "" + return token + + +def _normalize_subject_trust(value: Any) -> str: + token = _clean_text(value).lower() + if token not in ALLOWED_SUBJECT_TRUST: + return "" + return token + + +def _normalize_evidence_scope(value: Any) -> str: + token = _clean_text(value).lower() + if token not in ALLOWED_EVIDENCE_SCOPES: + return "" + return token + + @dataclass(slots=True) class LiteratureClaim: claim_id: str @@ -168,6 +192,7 @@ class LiteratureComparison: citation_ids: list[str] = field(default_factory=list) confidence: str = "low" sources_considered: int = 0 + evidence_scope: str = "" def to_dict(self) -> dict[str, Any]: payload = asdict(self) @@ -192,6 +217,7 @@ def to_dict(self) -> dict[str, Any]: payload["evidence_used"] = _to_str_list(payload.get("evidence_used")) payload["citation_ids"] = _to_str_list(payload.get("citation_ids")) payload["confidence"] = _normalize_confidence(payload.get("confidence"), default="low") + payload["evidence_scope"] = _normalize_evidence_scope(payload.get("evidence_scope")) try: payload["paper_year"] = int(payload.get("paper_year")) if payload.get("paper_year") not in (None, "") else None except (TypeError, ValueError): @@ -272,9 +298,13 @@ class LiteratureContext: query_display_title: str = "" query_display_mode: str = "" query_display_terms: list[str] = field(default_factory=list) + search_mode: str = "" + subject_trust: str = "" + evidence_scope_summary: str = "" low_specificity_retrieval: bool = False surfaced_comparison_count: int = 0 evidence_specificity_summary: str = "" + executed_queries: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: payload = asdict(self) @@ -329,12 +359,16 @@ def to_dict(self) -> dict[str, Any]: payload["query_display_title"] = _clean_text(payload.get("query_display_title")) payload["query_display_mode"] = _clean_text(payload.get("query_display_mode")) payload["query_display_terms"] = _to_str_list(payload.get("query_display_terms")) + payload["search_mode"] = _normalize_thermal_search_mode(payload.get("search_mode")) + payload["subject_trust"] = _normalize_subject_trust(payload.get("subject_trust")) + payload["evidence_scope_summary"] = _normalize_evidence_scope(payload.get("evidence_scope_summary")) payload["low_specificity_retrieval"] = bool(payload.get("low_specificity_retrieval")) try: payload["surfaced_comparison_count"] = int(payload.get("surfaced_comparison_count") or 0) except (TypeError, ValueError): payload["surfaced_comparison_count"] = 0 payload["evidence_specificity_summary"] = _clean_text(payload.get("evidence_specificity_summary")).lower() + payload["executed_queries"] = _to_str_list(payload.get("executed_queries")) return payload @@ -381,9 +415,13 @@ def normalize_literature_context(value: Any) -> dict[str, Any]: query_display_title=source.get("query_display_title", ""), query_display_mode=source.get("query_display_mode", ""), query_display_terms=source.get("query_display_terms", []), + search_mode=source.get("search_mode", ""), + subject_trust=source.get("subject_trust", ""), + evidence_scope_summary=source.get("evidence_scope_summary", ""), low_specificity_retrieval=source.get("low_specificity_retrieval", False), surfaced_comparison_count=source.get("surfaced_comparison_count", 0), evidence_specificity_summary=source.get("evidence_specificity_summary", ""), + executed_queries=source.get("executed_queries", []), ).to_dict() @@ -461,6 +499,7 @@ def normalize_literature_comparisons(value: Any) -> list[dict[str, Any]]: citation_ids=_to_str_list(source.get("citation_ids")), confidence=source.get("confidence") or "low", sources_considered=source.get("sources_considered") or 0, + evidence_scope=source.get("evidence_scope") or "", ).to_dict() ) return output diff --git a/core/literature_provider.py b/core/literature_provider.py index 0c42b200..a7a4fe22 100644 --- a/core/literature_provider.py +++ b/core/literature_provider.py @@ -398,6 +398,20 @@ def build_openalex_like_client_from_env() -> OpenAlexHTTPClient | None: ) +def openalex_literature_env_configured() -> bool: + """True when OpenAlex-backed literature search has explicit env configuration.""" + return build_openalex_like_client_from_env() is not None + + +def literature_fixture_fallback_enabled() -> bool: + """Opt-in dev/demo: combine bundled fixture literature when live OpenAlex is not configured.""" + for name in ("MATERIALSCOPE_LITERATURE_FIXTURE_FALLBACK", "THERMOANALYZER_LITERATURE_FIXTURE_FALLBACK"): + raw = _clean_text(os.getenv(name)).lower() + if raw in {"1", "true", "yes", "on"}: + return True + return False + + class FixtureLiteratureProvider: """Synthetic provider used for MVP development and test coverage.""" diff --git a/core/modalities/adapters.py b/core/modalities/adapters.py index f63b3949..19e917c3 100644 --- a/core/modalities/adapters.py +++ b/core/modalities/adapters.py @@ -53,6 +53,7 @@ def run( analyst_name: str | None = None, app_version: str | None = None, batch_run_id: str | None = None, + unit_mode: str | None = None, ) -> dict[str, Any]: return execute_batch_template( dataset_key=dataset_key, @@ -64,6 +65,7 @@ def run( analyst_name=analyst_name, app_version=app_version, batch_run_id=batch_run_id, + unit_mode=unit_mode, ) diff --git a/core/modalities/contracts.py b/core/modalities/contracts.py index 5dd87682..3d01abfd 100644 --- a/core/modalities/contracts.py +++ b/core/modalities/contracts.py @@ -37,6 +37,7 @@ def run( analyst_name: str | None = None, app_version: str | None = None, batch_run_id: str | None = None, + unit_mode: str | None = None, ) -> dict[str, Any]: ... diff --git a/core/modality_specs.py b/core/modality_specs.py new file mode 100644 index 00000000..19a330e6 --- /dev/null +++ b/core/modality_specs.py @@ -0,0 +1,333 @@ +"""Modality-first import contracts for thermal and spectral analysis. + +Each modality defines the expected axis roles, units, column-name aliases, +suspicious combinations, and metadata requirements so that the import pipeline +can operate deterministically when the user selects a technique up-front. +""" + +from __future__ import annotations + +from typing import Any + + +def _spec( + *, + label: str, + axis_label: str, + axis_role: str, + signal_label: str, + signal_role: str, + allowed_x_units: tuple[str, ...], + allowed_y_units: tuple[str, ...], + default_x_unit: str, + default_y_unit: str, + x_aliases: tuple[str, ...], + y_aliases: tuple[str, ...], + required_columns: tuple[str, ...], + optional_columns: tuple[str, ...], + optional_metadata: tuple[str, ...], + axis_monotonic_required: bool = True, + spectral_modality: bool = False, + suspicious_x_units: tuple[str, ...] = (), + suspicious_y_units: tuple[str, ...] = (), + axis_range_hint: tuple[float, float] | None = None, + description: str = "", +) -> dict[str, Any]: + return { + "label": label, + "axis_label": axis_label, + "axis_role": axis_role, + "signal_label": signal_label, + "signal_role": signal_role, + "allowed_x_units": allowed_x_units, + "allowed_y_units": allowed_y_units, + "default_x_unit": default_x_unit, + "default_y_unit": default_y_unit, + "x_aliases": x_aliases, + "y_aliases": y_aliases, + "required_columns": required_columns, + "optional_columns": optional_columns, + "optional_metadata": optional_metadata, + "axis_monotonic_required": axis_monotonic_required, + "spectral_modality": spectral_modality, + "suspicious_x_units": suspicious_x_units, + "suspicious_y_units": suspicious_y_units, + "axis_range_hint": axis_range_hint, + "description": description, + } + + +MODALITY_SPECS: dict[str, dict[str, Any]] = { + "DSC": _spec( + label="Differential Scanning Calorimetry", + axis_label="Temperature", + axis_role="temperature", + signal_label="Heat Flow", + signal_role="signal", + allowed_x_units=("°C", "degC", "K", "°F"), + allowed_y_units=("mW", "mW/mg", "W/g", "a.u."), + default_x_unit="°C", + default_y_unit="mW", + x_aliases=( + r"[Tt]emp", + r"\u00b0[Cc]", + r"[Cc]elsius", + r"[Kk]elvin", + r"^T\b", + r"^T_", + ), + y_aliases=( + r"[Hh]eat\s*[Ff]low", + r"\bDSC\b", + r"[Mm][Ww]", + r"[Ee]ndo", + r"[Ee]xo", + r"Cp\b", + ), + required_columns=("temperature", "signal"), + optional_columns=("time",), + optional_metadata=("sample_mass", "heating_rate", "instrument"), + axis_monotonic_required=True, + axis_range_hint=(-200.0, 2000.0), + description="Measures heat flow as a function of temperature. X-axis is temperature (°C/K), Y-axis is heat flow (mW or mW/mg).", + ), + "TGA": _spec( + label="Thermogravimetric Analysis", + axis_label="Temperature", + axis_role="temperature", + signal_label="Mass", + signal_role="signal", + allowed_x_units=("°C", "degC", "K", "°F"), + allowed_y_units=("%", "mg", "g", "a.u."), + default_x_unit="°C", + default_y_unit="%", + x_aliases=( + r"[Tt]emp", + r"\u00b0[Cc]", + r"[Cc]elsius", + r"[Kk]elvin", + r"^T\b", + r"^T_", + ), + y_aliases=( + r"[Mm]ass", + r"[Ww]eight", + r"\bTG(?!A)\b", + r"\bTGA\b", + r"[Ww]t\.?\s*%", + ), + required_columns=("temperature", "signal"), + optional_columns=("time",), + optional_metadata=("sample_mass", "heating_rate", "instrument"), + axis_monotonic_required=True, + axis_range_hint=(-200.0, 2000.0), + description="Measures mass change as a function of temperature. X-axis is temperature (°C/K), Y-axis is mass (wt% or mg).", + ), + "DTA": _spec( + label="Differential Thermal Analysis", + axis_label="Temperature", + axis_role="temperature", + signal_label="ΔT", + signal_role="signal", + allowed_x_units=("°C", "degC", "K", "°F"), + allowed_y_units=("µV", "uV", "mV", "a.u."), + default_x_unit="°C", + default_y_unit="µV", + x_aliases=( + r"[Tt]emp", + r"\u00b0[Cc]", + r"[Cc]elsius", + r"[Kk]elvin", + r"^T\b", + r"^T_", + ), + y_aliases=( + r"\bDTA\b", + r"[Dd]elta\s*T", + r"\u0394T", + r"\u00b5[Vv]", + ), + required_columns=("temperature", "signal"), + optional_columns=("time",), + optional_metadata=("sample_mass", "heating_rate", "instrument"), + axis_monotonic_required=True, + axis_range_hint=(-200.0, 2000.0), + description="Measures temperature difference between sample and reference. X-axis is temperature (°C/K), Y-axis is ΔT (µV).", + ), + "FTIR": _spec( + label="Fourier Transform Infrared Spectroscopy", + axis_label="Wavenumber", + axis_role="temperature", + signal_label="Absorbance / Transmittance", + signal_role="signal", + allowed_x_units=("cm^-1", "1/cm", "nm"), + allowed_y_units=("absorbance", "transmittance", "%T", "a.u."), + default_x_unit="cm^-1", + default_y_unit="a.u.", + x_aliases=( + r"[Ww]avenumber", + r"\bcm[-\^]?\s*1\b", + r"\b1/cm\b", + r"[Cc]m[-\^]1", + ), + y_aliases=( + r"\bFTIR\b", + r"[Aa]bsorb", + r"[Tt]ransmitt", + r"[Rr]eflect", + r"%\s*T\b", + ), + required_columns=("temperature", "signal"), + optional_columns=(), + optional_metadata=("instrument",), + axis_monotonic_required=False, + spectral_modality=True, + suspicious_x_units=("°C", "K", "°F"), + suspicious_y_units=("mW", "mW/mg", "%", "mg", "µV"), + description="Infrared absorption spectrum. X-axis is wavenumber (cm⁻¹), Y-axis is absorbance or transmittance.", + ), + "RAMAN": _spec( + label="Raman Spectroscopy", + axis_label="Raman Shift", + axis_role="temperature", + signal_label="Intensity", + signal_role="signal", + allowed_x_units=("cm^-1", "1/cm"), + allowed_y_units=("counts", "cps", "intensity", "a.u."), + default_x_unit="cm^-1", + default_y_unit="a.u.", + x_aliases=( + r"[Rr]aman\s*[Ss]hift", + r"\bcm[-\^]?\s*1\b", + r"\b1/cm\b", + r"[Ss]hift\s*\(cm", + ), + y_aliases=( + r"\bRAMAN\b", + r"[Ii]ntensity", + r"\bCPS\b", + r"[Cc]ounts?", + r"[Rr]aman", + ), + required_columns=("temperature", "signal"), + optional_columns=(), + optional_metadata=("instrument", "laser_wavelength"), + axis_monotonic_required=False, + spectral_modality=True, + suspicious_x_units=("°C", "K", "°F"), + suspicious_y_units=("mW", "mW/mg", "%", "mg", "µV", "absorbance", "transmittance"), + description="Inelastic scattering spectrum. X-axis is Raman shift (cm⁻¹), Y-axis is intensity (counts or CPS).", + ), + "XRD": _spec( + label="X-Ray Diffraction", + axis_label="2θ", + axis_role="temperature", + signal_label="Intensity", + signal_role="signal", + allowed_x_units=("degree_2theta", "deg", "2theta", "1/angstrom"), + allowed_y_units=("counts", "cps", "intensity", "a.u."), + default_x_unit="degree_2theta", + default_y_unit="counts", + x_aliases=( + r"2\s*theta", + r"2\u03b8", + r"[Tt]wo\s*theta", + r"[Aa]ngle", + r"\bXRD\b", + r"[Dd]iffract", + ), + y_aliases=( + r"\bXRD\b", + r"[Dd]iffract", + r"[Ii]ntensity", + r"[Cc]ounts?", + r"\bCPS\b", + ), + required_columns=("temperature", "signal"), + optional_columns=(), + optional_metadata=("xrd_wavelength_angstrom", "instrument"), + axis_monotonic_required=True, + suspicious_x_units=("°C", "K", "°F", "cm^-1"), + suspicious_y_units=("mW", "mW/mg", "%", "mg", "µV", "absorbance", "transmittance"), + description="Powder diffraction pattern. X-axis is 2θ (degrees), Y-axis is intensity (counts).", + ), +} + +SUPPORTED_MODALITIES = tuple(MODALITY_SPECS.keys()) + + +def get_modality_spec(modality: str) -> dict[str, Any] | None: + """Return the spec dict for a modality, or None if unknown.""" + return MODALITY_SPECS.get(str(modality).upper()) + + +def modality_allowed_x_units(modality: str) -> tuple[str, ...]: + spec = get_modality_spec(modality) + return spec["allowed_x_units"] if spec else () + + +def modality_allowed_y_units(modality: str) -> tuple[str, ...]: + spec = get_modality_spec(modality) + return spec["allowed_y_units"] if spec else () + + +def modality_default_x_unit(modality: str) -> str: + spec = get_modality_spec(modality) + return spec["default_x_unit"] if spec else "unknown" + + +def modality_default_y_unit(modality: str) -> str: + spec = get_modality_spec(modality) + return spec["default_y_unit"] if spec else "unknown" + + +def check_suspicious_unit_combo( + modality: str, + x_unit: str, + y_unit: str, +) -> list[str]: + """Return warning strings for suspicious unit combinations given a modality.""" + spec = get_modality_spec(modality) + if spec is None: + return [] + + warnings: list[str] = [] + if x_unit in spec["suspicious_x_units"]: + warnings.append( + f"{modality} axis unit '{x_unit}' is unusual for this modality; " + f"expected one of {spec['allowed_x_units']}." + ) + if y_unit in spec["suspicious_y_units"]: + warnings.append( + f"{modality} signal unit '{y_unit}' is unusual for this modality; " + f"expected one of {spec['allowed_y_units']}." + ) + if x_unit not in spec["allowed_x_units"] and x_unit not in spec["suspicious_x_units"]: + warnings.append( + f"{modality} axis unit '{x_unit}' is not in the known allowed set " + f"{spec['allowed_x_units']}; verify before proceeding." + ) + if y_unit not in spec["allowed_y_units"] and y_unit not in spec["suspicious_y_units"]: + warnings.append( + f"{modality} signal unit '{y_unit}' is not in the known allowed set " + f"{spec['allowed_y_units']}; verify before proceeding." + ) + return warnings + + +def modality_signal_pattern_key(modality: str) -> str | None: + """Map modality to the signal pattern key used in data_io._PATTERNS.""" + mapping = { + "DSC": "signal_dsc", + "TGA": "signal_tga", + "DTA": "signal_dta", + "FTIR": "signal_ftir", + "RAMAN": "signal_raman", + "XRD": "signal_xrd", + } + return mapping.get(str(modality).upper()) + + +def modality_is_spectral(modality: str) -> bool: + spec = get_modality_spec(modality) + return bool(spec and spec.get("spectral_modality")) diff --git a/core/online_providers/registry.py b/core/online_providers/registry.py index e49a797c..e656d81b 100644 --- a/core/online_providers/registry.py +++ b/core/online_providers/registry.py @@ -16,7 +16,10 @@ def online_search_enabled() -> bool: """Check if online search is enabled via environment variable.""" - return os.getenv("THERMOANALYZER_ONLINE_SEARCH", "true").strip().lower() in {"true", "1", "yes", "on"} + return ( + os.getenv("MATERIALSCOPE_ONLINE_SEARCH") + or os.getenv("THERMOANALYZER_ONLINE_SEARCH", "true") + ).strip().lower() in {"true", "1", "yes", "on"} def register_provider(provider: OnlineProvider) -> None: diff --git a/core/path_env.py b/core/path_env.py new file mode 100644 index 00000000..dcd08ce6 --- /dev/null +++ b/core/path_env.py @@ -0,0 +1,32 @@ +"""Heuristics for library-related filesystem paths in cross-platform dev environments.""" + +from __future__ import annotations + +import re +import sys + + +def library_filesystem_env_looks_like_windows_leak(raw: str | None) -> bool: + """Return True when a *library path* env value looks like a Windows path used on POSIX. + + Typical failure mode: a ``.env`` copied from Windows contains ``C:\\...`` or a mangled + ``.../C:thermoanalyzer...`` segment. Treating that as a Linux path breaks hosted/mirror + resolution and yields empty manifests. + """ + if sys.platform == "win32" or raw is None: + return False + token = str(raw).strip() + if not token or "://" in token: + return False + if "\\" in token: + return True + # ``C:\Users\...`` style pasted into a POSIX shell/.env + if re.match(r"^[A-Za-z]:\\", token): + return True + # Mangled paste: ``.../C:thermoanalyzer...`` (drive letter mid-path, not ``D:/unix``) + if re.search(r"/[A-Za-z]:[^/]", token): + return True + # ``C:thermo...`` without leading slash (non-UNC Windows path fragment) + if re.match(r"^[A-Za-z]:[^/\\]", token): + return True + return False diff --git a/core/processing_schema.py b/core/processing_schema.py index 8611ee5a..2336b541 100644 --- a/core/processing_schema.py +++ b/core/processing_schema.py @@ -10,7 +10,7 @@ PROCESSING_SCHEMA_VERSION = 1 _SIGNAL_PIPELINE_SECTIONS = { - "DSC": ("smoothing", "baseline"), + "DSC": ("smoothing", "baseline", "normalization"), "TGA": ("smoothing",), "DTA": ("smoothing", "baseline"), "FTIR": ("smoothing", "baseline", "normalization"), diff --git a/core/project_io.py b/core/project_io.py index faade523..353d60a6 100644 --- a/core/project_io.py +++ b/core/project_io.py @@ -1,4 +1,4 @@ -"""Project archive save/load helpers for ThermoAnalyzer.""" +"""Project archive save/load helpers for MaterialScope.""" from __future__ import annotations @@ -26,7 +26,7 @@ from core.tga_processor import TGAResult -PROJECT_EXTENSION = ".thermozip" +PROJECT_EXTENSION = ".scopezip" APP_VERSION = "2.0" @@ -169,7 +169,7 @@ def deserialize_project( def save_project_archive(session_state: Mapping[str, Any], app_version: str = APP_VERSION) -> bytes: - """Create a .thermozip archive from session state.""" + """Create a .scopezip archive from session state.""" payload = serialize_project(session_state, app_version=app_version) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: @@ -186,7 +186,7 @@ def save_project_archive(session_state: Mapping[str, Any], app_version: str = AP def load_project_archive(source: Any) -> dict[str, Any]: - """Load a .thermozip archive and return session-state compatible data.""" + """Load a project archive and return session-state compatible data.""" archive_bytes = _read_bytes(source) archive_members: dict[str, bytes] = {} with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as archive: diff --git a/core/raman_literature_query_builder.py b/core/raman_literature_query_builder.py new file mode 100644 index 00000000..beff6edf --- /dev/null +++ b/core/raman_literature_query_builder.py @@ -0,0 +1,248 @@ +"""Deterministic RAMAN literature query payloads for spectral-similarity records.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from core.thermal_literature_query_builder import ( + _best_subject, + _build_payload, + _clean_float, + _clean_int, + _clean_text, + _infer_search_mode, + _infer_subject_trust, + _quoted_subject, + _rows, + _summary, + _workflow_label, +) + + +def _top_row_evidence(rows: list[dict[str, Any]]) -> dict[str, Any]: + if not rows: + return {} + top = rows[0] + return dict(top.get("evidence") or {}) if isinstance(top, Mapping) else {} + + +def _wavenumber_query_terms(evidence: Mapping[str, Any], *, limit: int = 6) -> list[str]: + pairs = evidence.get("matched_peak_pairs") or [] + out: list[str] = [] + for pair in pairs[:limit]: + if not isinstance(pair, Mapping): + continue + raw = pair.get("observed_position") + try: + v = float(raw) + except (TypeError, ValueError): + continue + if v > 0.0: + out.append(f"{v:.0f} cm-1") + return out + + +def _numeric_anchors_from_terms(terms: list[str]) -> list[str]: + anchors: list[str] = [] + for term in terms: + cleaned = _clean_text(term).replace("cm-1", "").replace("cm^-1", "").strip() + parts = cleaned.replace(",", " ").split() + for p in parts: + if p.replace(".", "", 1).isdigit(): + anchors.append(p.split(".")[0] if "." in p else p) + deduped: list[str] = [] + seen: set[str] = set() + for a in anchors: + key = a.casefold() + if key in seen or not key: + continue + seen.add(key) + deduped.append(a) + return deduped[:8] + + +def build_raman_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: + """Build a traceable RAMAN literature query payload (spectral similarity / library context).""" + summary = _summary(record) + rows = _rows(record) + processing = dict(record.get("processing") or {}) + method_context = dict(processing.get("method_context") or {}) + + subject = _best_subject(record) + subject_trust = _infer_subject_trust(subject) + subject_label = _clean_text(subject.get("cleaned")) + quoted_subject = _quoted_subject(subject) + + match_status = _clean_text(summary.get("match_status")).lower() + confidence_band = _clean_text(summary.get("confidence_band")).lower() + peak_count = _clean_int(summary.get("peak_count")) or 0 + top_name = _clean_text(summary.get("top_match_name")) + top_score = _clean_float(summary.get("top_match_score")) + + evidence = _top_row_evidence(rows) + wn_terms = _wavenumber_query_terms(evidence) + shared_peaks = _clean_int(evidence.get("shared_peak_count")) or 0 + coverage = _clean_float(evidence.get("coverage_ratio")) + + signal_role = _clean_text(method_context.get("raman_signal_role") or method_context.get("ftir_signal_role")).lower() + + modality_terms = [ + "RAMAN", + "Raman spectroscopy", + "Raman scattering", + "vibrational spectroscopy", + ] + + prioritized: list[str] = [] + rationale_parts: list[str] = [] + trust = subject_trust + + if match_status == "library_unavailable": + search_mode = "behavior_first" + trust = "absent" if subject_trust == "absent" else subject_trust + prioritized.extend( + [ + " ".join([modality_terms[0], modality_terms[1], "spectral library", "reference matching"]), + " ".join([modality_terms[0], "spectral preprocessing", "baseline correction", "similarity metric"]), + " ".join([modality_terms[1], "qualitative screening", "best practices"]), + ] + ) + rationale_parts.append( + "The on-device reference spectral library was unavailable or not configured, so the literature query deliberately avoids " + "asserting a ranked spectral identification and instead targets RAMAN methodology and library-matching practice." + ) + elif match_status == "matched" and top_name and confidence_band not in {"", "no_match"}: + search_mode = "known_material" if subject_trust == "trusted" else "behavior_first" + trust = subject_trust + core = " ".join(part for part in [modality_terms[0], modality_terms[2], f"\"{top_name}\"", "reference spectrum"] if part) + prioritized.append(core) + if wn_terms: + prioritized.append(" ".join([modality_terms[0], top_name, *wn_terms[:3], "raman bands"])) + if subject_label: + prioritized.append(" ".join([quoted_subject or subject_label, modality_terms[0], top_name, "raman"])) + prioritized.append(" ".join([modality_terms[0], "spectral similarity", "library screening"])) + rationale_parts.append( + f"The literature search is anchored to the retained top spectral candidate ({top_name}) as a qualitative RAMAN library outcome" + + (f" (normalized score {top_score:.3f})." if top_score is not None else ".") + ) + if shared_peaks: + rationale_parts.append(f"Observed–reference overlap retained {shared_peaks} shared peak correspondences for query shaping.") + else: + search_mode = _infer_search_mode(subject_trust) + trust = subject_trust + if subject_label and search_mode == "known_material": + prioritized.append( + " ".join(part for part in [quoted_subject or subject_label, modality_terms[0], modality_terms[2], "raman bands"] if part) + ) + if wn_terms and peak_count >= 2: + prioritized.append(" ".join([modality_terms[0], *wn_terms[:4], "raman shift"])) + if peak_count >= 1 and not prioritized: + prioritized.append(" ".join([modality_terms[0], modality_terms[3], "peak-resolved screening"])) + prioritized.append(" ".join([modality_terms[0], modality_terms[2], "qualitative interpretation"])) + if subject_label: + prioritized.append(" ".join([quoted_subject or subject_label, "raman spectroscopy", "vibrational modes"])) + prioritized.append(" ".join([modality_terms[1], "spectral similarity"])) + rationale_parts.append( + "The RAMAN literature search uses modality-first wording aligned to the current spectral-similarity summary" + + (" and detected peak positions when available." if wn_terms else ".") + ) + if match_status == "no_match": + rationale_parts.append( + "No candidate met the similarity threshold; external literature is framed as contextual vibrational spectroscopy guidance, " + "not as validation of a library identification." + ) + + prioritized = [q for q in prioritized if _clean_text(q)] + deduped: list[str] = [] + seen: set[str] = set() + for q in prioritized: + key = q.casefold() + if key in seen: + continue + seen.add(key) + deduped.append(q) + + query_text = deduped[0] if deduped else " ".join([modality_terms[0], modality_terms[2], "qualitative screening"]) + fallback_queries = deduped[1:] + rationale = " ".join(rationale_parts).strip() + if signal_role and signal_role != "unknown": + rationale += f" Recorded signal role: {signal_role}." + + display_title = subject_label or top_name or "RAMAN spectral screening" + display_mode = "RAMAN / vibrational spectroscopy" + display_terms = _clean_display_terms( + modality_terms[:3], + [top_name] if top_name else [], + wn_terms[:3], + [f"peaks:{peak_count}"] if peak_count else [], + ) + + evidence_snapshot: dict[str, Any] = { + "sample_name": subject_label, + "raw_subject": _clean_text(subject.get("raw")), + "subject_source": _clean_text(subject.get("source")), + "subject_trust": trust, + "search_mode": search_mode, + "filename_like_subject": bool(subject.get("filename_like")), + "workflow_template": _workflow_label(record), + "match_status": match_status, + "confidence_band": confidence_band, + "peak_count": peak_count, + "top_match_name": top_name, + "top_match_score": top_score, + "shared_peak_count": shared_peaks, + "coverage_ratio": coverage, + "wavenumber_terms": wn_terms, + "wavenumber_anchors": _numeric_anchors_from_terms(wn_terms), + "signal_role": signal_role, + "library_result_source": _clean_text(summary.get("library_result_source")), + } + + return _build_payload( + analysis_type="RAMAN", + search_mode=search_mode, + subject_trust=trust, + query_text=query_text, + fallback_queries=fallback_queries, + query_rationale=rationale, + query_display_title=display_title, + query_display_mode=display_mode, + query_display_terms=display_terms, + evidence_snapshot=evidence_snapshot, + ) + + +def _clean_display_terms(*groups: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for group in groups: + for item in group: + cleaned = _clean_text(item) + if not cleaned: + continue + key = cleaned.casefold() + if key in seen: + continue + seen.add(key) + out.append(cleaned) + if len(out) >= 10: + return out + return out + + +def build_raman_query_presentation(query_payload: Mapping[str, Any]) -> dict[str, Any]: + from core.thermal_literature_query_builder import build_thermal_query_presentation + + return build_thermal_query_presentation(query_payload) + + +def _raman_query_is_too_narrow(query_payload: Mapping[str, Any]) -> bool: + snap = dict(query_payload.get("evidence_snapshot") or {}) + if _clean_text(snap.get("match_status")).lower() == "library_unavailable": + return False + has_subject = bool(_clean_text(snap.get("sample_name"))) + has_top = bool(_clean_text(snap.get("top_match_name"))) + peaks = _clean_int(snap.get("peak_count")) or 0 + wn = snap.get("wavenumber_terms") or [] + return not has_subject and not has_top and peaks < 2 and not wn + diff --git a/core/reference_library.py b/core/reference_library.py index d3b3ea58..e56e631a 100644 --- a/core/reference_library.py +++ b/core/reference_library.py @@ -5,6 +5,7 @@ import hashlib import io import json +import logging import os import shutil import tempfile @@ -18,19 +19,28 @@ import httpx import numpy as np +from core.path_env import library_filesystem_env_looks_like_windows_leak from utils.license_manager import encode_license_key, get_storage_dir +logger = logging.getLogger(__name__) + DEFAULT_BACKGROUND_REFRESH_HOURS = 24 DEFAULT_PRIORITY = 0 MANIFEST_FILE = "manifest.json" SYNC_STATE_FILE = "sync_state.json" -LIBRARY_ENV_FEED_URL = "THERMOANALYZER_LIBRARY_FEED_URL" -LIBRARY_ENV_MIRROR_ROOT = "THERMOANALYZER_LIBRARY_MIRROR_ROOT" -LIBRARY_ENV_CLOUD_URL = "THERMOANALYZER_LIBRARY_CLOUD_URL" -LIBRARY_ENV_CLOUD_ENABLED = "THERMOANALYZER_LIBRARY_CLOUD_ENABLED" -LIBRARY_ENV_ALLOW_FULL_PROVIDER_SYNC = "THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC" -LIBRARY_HEADER = "X-TA-License" +LIBRARY_ENV_FEED_URL = "MATERIALSCOPE_LIBRARY_FEED_URL" +LIBRARY_ENV_FEED_URL_LEGACY = "THERMOANALYZER_LIBRARY_FEED_URL" +LIBRARY_ENV_MIRROR_ROOT = "MATERIALSCOPE_LIBRARY_MIRROR_ROOT" +LIBRARY_ENV_MIRROR_ROOT_LEGACY = "THERMOANALYZER_LIBRARY_MIRROR_ROOT" +LIBRARY_ENV_CLOUD_URL = "MATERIALSCOPE_LIBRARY_CLOUD_URL" +LIBRARY_ENV_CLOUD_URL_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_URL" +LIBRARY_ENV_CLOUD_ENABLED = "MATERIALSCOPE_LIBRARY_CLOUD_ENABLED" +LIBRARY_ENV_CLOUD_ENABLED_LEGACY = "THERMOANALYZER_LIBRARY_CLOUD_ENABLED" +LIBRARY_ENV_ALLOW_FULL_PROVIDER_SYNC = "MATERIALSCOPE_LIBRARY_ALLOW_FULL_PROVIDER_SYNC" +LIBRARY_ENV_ALLOW_FULL_PROVIDER_SYNC_LEGACY = "THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC" +LIBRARY_HEADER = "X-MaterialScope-License" +LIBRARY_HEADER_LEGACY = "X-TA-License" LIBRARY_API_PREFIX = "/v1/library" DELIVERY_TIER_LIMITED_FALLBACK = "limited_fallback" DELIVERY_TIER_FULL_PROVIDER = "full_provider" @@ -125,16 +135,28 @@ def utcnow_iso() -> str: return datetime.now(UTC).isoformat() +def _env_value(primary: str, legacy: str, default: str = "") -> str: + return str(os.getenv(primary, "") or os.getenv(legacy, "") or default) + + def get_library_root() -> Path: return get_storage_dir() / "libraries" def configured_library_feed_source() -> str | None: - feed_url = os.getenv(LIBRARY_ENV_FEED_URL, "").strip() + feed_url = _env_value(LIBRARY_ENV_FEED_URL, LIBRARY_ENV_FEED_URL_LEGACY).strip() if feed_url: return feed_url.rstrip("/") - mirror_root = os.getenv(LIBRARY_ENV_MIRROR_ROOT, "").strip() + mirror_root = _env_value(LIBRARY_ENV_MIRROR_ROOT, LIBRARY_ENV_MIRROR_ROOT_LEGACY).strip() + if mirror_root and library_filesystem_env_looks_like_windows_leak(mirror_root): + logger.warning( + "Ignoring %s / %s mirror root %r on this platform (Windows-style path on POSIX).", + LIBRARY_ENV_MIRROR_ROOT, + LIBRARY_ENV_MIRROR_ROOT_LEGACY, + mirror_root, + ) + mirror_root = "" if mirror_root: return Path(mirror_root).resolve().as_uri() return None @@ -322,7 +344,10 @@ def _headers(self, license_state: Mapping[str, Any] | None) -> dict[str, str]: encoded = _license_header_value(license_state) if not encoded: return {} - return {LIBRARY_HEADER: encoded} + return { + LIBRARY_HEADER: encoded, + LIBRARY_HEADER_LEGACY: encoded, + } def _is_file_source(self) -> bool: return self.source.startswith("file://") @@ -518,16 +543,18 @@ def _cache_status(self, state: LibrarySyncState | None = None) -> str: def _cloud_access_enabled(self, state: LibrarySyncState | None = None) -> bool: state = state or self.load_sync_state() - cloud_root = str(os.getenv(LIBRARY_ENV_CLOUD_URL, "")).strip() + cloud_root = _env_value(LIBRARY_ENV_CLOUD_URL, LIBRARY_ENV_CLOUD_URL_LEGACY).strip() if not cloud_root: return False - enabled_override = os.getenv(LIBRARY_ENV_CLOUD_ENABLED, "") + enabled_override = _env_value(LIBRARY_ENV_CLOUD_ENABLED, LIBRARY_ENV_CLOUD_ENABLED_LEGACY) if enabled_override != "" and not _truthy(enabled_override): return False return bool(state.cloud_access_enabled) and max(0, int(state.cloud_provider_count or 0)) > 0 def _allow_full_provider_sync(self) -> bool: - return _truthy(os.getenv(LIBRARY_ENV_ALLOW_FULL_PROVIDER_SYNC, "")) + return _truthy( + _env_value(LIBRARY_ENV_ALLOW_FULL_PROVIDER_SYNC, LIBRARY_ENV_ALLOW_FULL_PROVIDER_SYNC_LEGACY) + ) def _installed_mapping(self) -> dict[str, InstalledLibrary]: state = self.load_sync_state() @@ -623,9 +650,12 @@ def status(self) -> dict[str, Any]: "last_error": state.last_error, "sync_due": self.needs_manifest_refresh() if self.client.configured else False, "library_mode": library_mode, - "cloud_url": str(os.getenv(LIBRARY_ENV_CLOUD_URL, "")).strip(), - "cloud_enabled_by_env": bool(str(os.getenv(LIBRARY_ENV_CLOUD_URL, "")).strip()) - and (str(os.getenv(LIBRARY_ENV_CLOUD_ENABLED, "")).strip() == "" or _truthy(os.getenv(LIBRARY_ENV_CLOUD_ENABLED, ""))), + "cloud_url": _env_value(LIBRARY_ENV_CLOUD_URL, LIBRARY_ENV_CLOUD_URL_LEGACY).strip(), + "cloud_enabled_by_env": bool(_env_value(LIBRARY_ENV_CLOUD_URL, LIBRARY_ENV_CLOUD_URL_LEGACY).strip()) + and ( + _env_value(LIBRARY_ENV_CLOUD_ENABLED, LIBRARY_ENV_CLOUD_ENABLED_LEGACY).strip() == "" + or _truthy(_env_value(LIBRARY_ENV_CLOUD_ENABLED, LIBRARY_ENV_CLOUD_ENABLED_LEGACY)) + ), "cloud_access_enabled": cloud_access_enabled, "cloud_provider_count": cloud_provider_count, "fallback_package_count": len(fallback_packages), @@ -728,8 +758,8 @@ def record_cloud_lookup( error: str = "", ) -> None: state = self.load_sync_state() - cloud_root = str(os.getenv(LIBRARY_ENV_CLOUD_URL, "")).strip() - enabled_override = os.getenv(LIBRARY_ENV_CLOUD_ENABLED, "") + cloud_root = _env_value(LIBRARY_ENV_CLOUD_URL, LIBRARY_ENV_CLOUD_URL_LEGACY).strip() + enabled_override = _env_value(LIBRARY_ENV_CLOUD_ENABLED, LIBRARY_ENV_CLOUD_ENABLED_LEGACY) cloud_configured = bool(cloud_root) and (enabled_override == "" or _truthy(enabled_override)) effective_provider_count = max(0, int(provider_count)) if provider_count is not None else None zero_provider_lookup = bool(success and effective_provider_count is not None and effective_provider_count <= 0) diff --git a/core/report_generator.py b/core/report_generator.py index 3bfba03e..85017a19 100644 --- a/core/report_generator.py +++ b/core/report_generator.py @@ -1,4 +1,4 @@ -"""Report generation for normalized ThermoAnalyzer result records.""" +"""Report generation for normalized MaterialScope result records.""" from __future__ import annotations @@ -1980,8 +1980,8 @@ def _citation_lookup(record: Mapping[str, Any]) -> dict[str, dict[str, Any]]: def _literature_demo_enabled() -> bool: token = ( - os.getenv("THERMOANALYZER_INCLUDE_DEMO_LITERATURE") - or os.getenv("MATERIALSCOPE_INCLUDE_DEMO_LITERATURE") + os.getenv("MATERIALSCOPE_INCLUDE_DEMO_LITERATURE") + or os.getenv("THERMOANALYZER_INCLUDE_DEMO_LITERATURE") or "" ) return token.strip().lower() in {"1", "true", "yes", "on"} @@ -2954,7 +2954,17 @@ def _render_main_record_docx( used_figures = set() doc.add_paragraph(_record_title(record), style="Heading 2") + primary_key = _record_primary_figure_key(record) matched_figures = select_record_figures(record, figures, used_figures) + if not matched_figures and primary_key and figures is not None and primary_key not in figures: + doc.add_paragraph( + normalize_report_text( + f"Report figure unavailable: expected image for '{primary_key}' was not found in the export figure bundle." + ), + style="Intense Quote", + ) + doc.add_paragraph() + for index, (caption, png_bytes) in enumerate(matched_figures, start=1): doc.add_paragraph(normalize_report_text(f"Figure {index}: {caption}"), style="Heading 3") try: @@ -3124,6 +3134,7 @@ def generate_docx_report( branding: Optional[dict] = None, comparison_workspace: Optional[dict] = None, license_state: Optional[dict] = None, + figure_export_warnings: Optional[list[str]] = None, ) -> bytes: """Generate a DOCX report from normalized stable/experimental records.""" valid_results, issues = split_valid_results(results) @@ -3198,6 +3209,19 @@ def generate_docx_report( doc.add_paragraph("Figure could not be embedded and was skipped.") doc.add_paragraph() + export_warn = [str(item) for item in (figure_export_warnings or []) if str(item).strip()] + if export_warn: + _add_heading(doc, "Figure Export Notes", level=1) + doc.add_paragraph( + normalize_report_text( + "The following issues were detected while assembling figures for this export. " + "Narrative sections may still be complete without embedded plots." + ) + ) + for line in export_warn: + doc.add_paragraph(normalize_report_text(line), style="List Bullet") + doc.add_paragraph() + _add_heading(doc, "Stable Analyses", level=1) if not stable_results: doc.add_paragraph("No stable analysis results available.") @@ -3524,6 +3548,7 @@ def generate_pdf_report( branding: Optional[dict] = None, comparison_workspace: Optional[dict] = None, license_state: Optional[dict] = None, + figure_export_warnings: Optional[list[str]] = None, ) -> bytes: """Generate a scientific-paper-style PDF report with hardened table layout.""" try: # pragma: no cover - optional dependency @@ -3658,7 +3683,18 @@ def add_matrix_table(headers: list[str], rows: list[list[Any]], *, width: float, def append_record_discussion(record: dict) -> None: add_heading(_paper_record_heading(record, datasets), level=2) + primary_key = _record_primary_figure_key(record) matched_figures = select_record_figures(record, figures, used_figures) + if not matched_figures and primary_key and figures is not None and primary_key not in figures: + story.append( + Paragraph( + normalize_report_text( + f"Report figure unavailable: expected image for '{primary_key}' was not found in the export figure bundle." + ), + body_style, + ) + ) + story.append(Spacer(1, 3)) for index, (caption, png_bytes) in enumerate(matched_figures, start=1): try: img_reader = ImageReader(io.BytesIO(png_bytes)) @@ -3781,6 +3817,21 @@ def append_record_discussion(record: dict) -> None: story.append(Paragraph(normalize_report_text(comparison_payload['interpretation']), body_style)) story.append(Spacer(1, 6)) + export_warn = [str(item) for item in (figure_export_warnings or []) if str(item).strip()] + if export_warn: + add_heading('Figure export notes', level=2) + story.append( + Paragraph( + normalize_report_text( + 'Figure embedding issues were detected; narrative text may still be complete without plots.' + ), + body_style, + ) + ) + for line in export_warn: + story.append(Paragraph(normalize_report_text(f"• {line}"), body_style)) + story.append(Spacer(1, 6)) + if stable_results: for record in stable_results: append_record_discussion(record) diff --git a/core/result_serialization.py b/core/result_serialization.py index 94bf6f5c..e7e1031a 100644 --- a/core/result_serialization.py +++ b/core/result_serialization.py @@ -1,4 +1,4 @@ -"""Serializable result helpers for ThermoAnalyzer analysis outputs.""" +"""Serializable result helpers for MaterialScope analysis outputs.""" from __future__ import annotations @@ -1051,21 +1051,33 @@ def serialize_dta_result( ) -> dict[str, Any]: """Serialize a DTA analysis record.""" peaks = list(peaks) - rows = [ - { - "peak_type": getattr(peak, "direction", peak.peak_type), - "peak_temperature": _clean_scalar(peak.peak_temperature), - "onset_temperature": _clean_scalar(peak.onset_temperature), - "endset_temperature": _clean_scalar(peak.endset_temperature), - "area": _clean_scalar(peak.area), - "fwhm": _clean_scalar(peak.fwhm), - "height": _clean_scalar(peak.height), - } - for peak in peaks - ] + rows = [] + exotherm_count = 0 + endotherm_count = 0 + for peak in peaks: + direction = str(getattr(peak, "direction", "") or getattr(peak, "peak_type", "") or "").strip().lower() + if direction.startswith("exo"): + exotherm_count += 1 + elif direction.startswith("endo"): + endotherm_count += 1 + rows.append( + { + "direction": direction or None, + "peak_type": getattr(peak, "peak_type", None), + "peak_temperature": _clean_scalar(peak.peak_temperature), + "onset_temperature": _clean_scalar(peak.onset_temperature), + "endset_temperature": _clean_scalar(peak.endset_temperature), + "area": _clean_scalar(peak.area), + "fwhm": _clean_scalar(peak.fwhm), + "height": _clean_scalar(peak.height), + } + ) summary = { "peak_count": len(peaks), + "exotherm_count": exotherm_count, + "endotherm_count": endotherm_count, "sample_name": dataset.metadata.get("sample_name"), + "display_name": dataset.metadata.get("display_name"), "sample_mass": dataset.metadata.get("sample_mass"), "heating_rate": dataset.metadata.get("heating_rate"), } @@ -1191,7 +1203,19 @@ def serialize_spectral_result( match_status = str(normalized_summary.get("match_status") or "").lower() confidence_band = str(normalized_summary.get("confidence_band") or "").lower() caution_payload = {} - if match_status == "no_match": + if match_status == "library_unavailable": + caution_payload = { + "code": str(normalized_summary.get("caution_code") or "spectral_library_unavailable"), + "message": str( + normalized_summary.get("caution_message") + or ( + "Reference spectral library matching was unavailable or not configured; " + "absence of ranked candidates is a tooling limitation, not a spectroscopic no-match." + ) + ), + "top_match_score": _clean_scalar(normalized_summary.get("top_match_score")), + } + elif match_status == "no_match": caution_payload = { "code": str(normalized_summary.get("caution_code") or "spectral_no_match"), "message": str( diff --git a/core/scientific_reasoning.py b/core/scientific_reasoning.py index 2189f3e8..02f855ff 100644 --- a/core/scientific_reasoning.py +++ b/core/scientific_reasoning.py @@ -431,6 +431,238 @@ def _build_dta_reasoning( } +def _build_ftir_reasoning( + summary: dict[str, Any], + rows: list[dict[str, Any]], + metadata: dict[str, Any], + fit_band: str, + fit_reason: str, + validation: dict[str, Any] | None, +) -> dict[str, Any]: + top_row = rows[0] if rows else {} + evidence = dict(top_row.get("evidence") or {}) + match_status = str(summary.get("match_status") or "").lower() + confidence_band = str(summary.get("confidence_band") or "").lower() + peak_count = _safe_int(summary.get("peak_count")) + if peak_count is None: + peak_count = 0 + top_name = str(summary.get("top_match_name") or "").strip() + top_score = _safe_float(summary.get("top_match_score")) + shared = _safe_int(evidence.get("shared_peak_count")) + sample = str(summary.get("sample_name") or metadata.get("sample_name") or "").strip() + library_src = str(summary.get("library_result_source") or "").strip() + + claims: list[dict[str, Any]] = [] + evidence_map: dict[str, list[str]] = {} + + desc_ev = [ + f"Detected peaks (in-record): {peak_count}.", + f"Library match status: {match_status}.", + ] + if sample: + desc_ev.append(f"Sample label: {sample}.") + if library_src: + desc_ev.append(f"Library result source: {library_src}.") + claims.append( + _claim( + "C1", + "descriptive", + f"The FTIR workflow executed spectral preprocessing, peak detection, and reference similarity matching, ending in match_status={match_status}.", + desc_ev, + ) + ) + evidence_map["C1"] = desc_ev + + if match_status == "library_unavailable": + caution = str(summary.get("caution_message") or "").strip() + c2_ev = [ + "Reference spectral library access was not available for this run.", + (caution[:240] + "…") if len(caution) > 240 else caution if caution else "See caution banner for library access details.", + ] + claims.append( + _claim( + "C2", + "descriptive", + "Because reference-library retrieval was unavailable, no ranked similarity candidates should be read as a spectrum-level identification outcome.", + c2_ev, + ) + ) + evidence_map["C2"] = c2_ev + elif match_status == "matched" and top_name: + strength = "comparative" if confidence_band not in {"", "no_match", "low"} else "descriptive" + matched_ev = [ + f"Top candidate: {top_name}.", + f"Confidence band: {confidence_band}.", + ] + if top_score is not None: + matched_ev.append(f"Normalized similarity score: {top_score:.4f}.") + if shared is not None: + matched_ev.append(f"Shared peak correspondences (top candidate): {shared}.") + claims.append( + _claim( + "C2", + strength, + f"The retained library spectrum {top_name} is a qualitative top match that still requires analyst review before any chemistry claim.", + matched_ev, + ) + ) + evidence_map["C2"] = matched_ev + elif match_status == "no_match": + nm_ev: list[str] = [] + if top_score is not None: + nm_ev.append(f"Best similarity score (below threshold): {top_score:.4f}.") + claims.append( + _claim( + "C2", + "descriptive", + "No reference entry met the minimum similarity rule; treat the spectrum as unmatched screening output until better references or preprocessing are available.", + nm_ev or ["No candidate retained above configured minimum score."], + ) + ) + evidence_map["C2"] = nm_ev or ["No candidate retained above configured minimum score."] + + gaps = metadata_gaps(metadata, ["instrument", "atmosphere", "pathlength"]) + uncertainty_items = [fit_reason] + if validation: + for w in (validation.get("warnings") or [])[:4]: + if str(w).strip(): + uncertainty_items.append(str(w).strip()) + + return { + "scientific_claims": claims, + "evidence_map": evidence_map, + "uncertainty_assessment": { + "overall_confidence": "moderate" + if match_status == "matched" and confidence_band not in {"", "no_match", "low"} + else "low", + "fit_assessment": fit_band, + "metadata_gaps": gaps, + "items": uncertainty_items, + }, + "alternative_hypotheses": [ + "Baseline and smoothing parameter changes can move detected peaks enough to shift similarity scores.", + "Library coverage and reference preprocessing assumptions dominate apparent no_match outcomes.", + ], + "next_experiments": recommend_next_experiments("FTIR", metadata_gaps=gaps, fit_band=fit_band), + } + + +def _build_raman_reasoning( + summary: dict[str, Any], + rows: list[dict[str, Any]], + metadata: dict[str, Any], + fit_band: str, + fit_reason: str, + validation: dict[str, Any] | None, +) -> dict[str, Any]: + top_row = rows[0] if rows else {} + evidence = dict(top_row.get("evidence") or {}) + match_status = str(summary.get("match_status") or "").lower() + confidence_band = str(summary.get("confidence_band") or "").lower() + peak_count = _safe_int(summary.get("peak_count")) + if peak_count is None: + peak_count = 0 + top_name = str(summary.get("top_match_name") or "").strip() + top_score = _safe_float(summary.get("top_match_score")) + shared = _safe_int(evidence.get("shared_peak_count")) + sample = str(summary.get("sample_name") or metadata.get("sample_name") or "").strip() + library_src = str(summary.get("library_result_source") or "").strip() + + claims: list[dict[str, Any]] = [] + evidence_map: dict[str, list[str]] = {} + + desc_ev = [ + f"Detected peaks (in-record): {peak_count}.", + f"Library match status: {match_status}.", + ] + if sample: + desc_ev.append(f"Sample label: {sample}.") + if library_src: + desc_ev.append(f"Library result source: {library_src}.") + claims.append( + _claim( + "C1", + "descriptive", + f"The RAMAN workflow executed spectral preprocessing, peak detection, and reference similarity matching, ending in match_status={match_status}.", + desc_ev, + ) + ) + evidence_map["C1"] = desc_ev + + if match_status == "library_unavailable": + caution = str(summary.get("caution_message") or "").strip() + c2_ev = [ + "Reference spectral library access was not available for this run.", + (caution[:240] + "…") if len(caution) > 240 else caution if caution else "See caution banner for library access details.", + ] + claims.append( + _claim( + "C2", + "descriptive", + "Because reference-library retrieval was unavailable, no ranked similarity candidates should be read as a spectrum-level identification outcome.", + c2_ev, + ) + ) + evidence_map["C2"] = c2_ev + elif match_status == "matched" and top_name: + strength = "comparative" if confidence_band not in {"", "no_match", "low"} else "descriptive" + matched_ev = [ + f"Top candidate: {top_name}.", + f"Confidence band: {confidence_band}.", + ] + if top_score is not None: + matched_ev.append(f"Normalized similarity score: {top_score:.4f}.") + if shared is not None: + matched_ev.append(f"Shared peak correspondences (top candidate): {shared}.") + claims.append( + _claim( + "C2", + strength, + f"The retained library spectrum {top_name} is a qualitative top match that still requires analyst review before any chemistry claim.", + matched_ev, + ) + ) + evidence_map["C2"] = matched_ev + elif match_status == "no_match": + nm_ev: list[str] = [] + if top_score is not None: + nm_ev.append(f"Best similarity score (below threshold): {top_score:.4f}.") + claims.append( + _claim( + "C2", + "descriptive", + "No reference entry met the minimum similarity rule; treat the spectrum as unmatched screening output until better references or preprocessing are available.", + nm_ev or ["No candidate retained above configured minimum score."], + ) + ) + evidence_map["C2"] = nm_ev or ["No candidate retained above configured minimum score."] + + gaps = metadata_gaps(metadata, ["instrument", "atmosphere", "pathlength"]) + uncertainty_items = [fit_reason] + if validation: + for w in (validation.get("warnings") or [])[:4]: + if str(w).strip(): + uncertainty_items.append(str(w).strip()) + + return { + "scientific_claims": claims, + "evidence_map": evidence_map, + "uncertainty_assessment": { + "overall_confidence": "moderate" + if match_status == "matched" and confidence_band not in {"", "no_match", "low"} + else "low", + "fit_assessment": fit_band, + "metadata_gaps": gaps, + "items": uncertainty_items, + }, + "alternative_hypotheses": [ + "Baseline and smoothing parameter changes can move detected peaks enough to shift similarity scores.", + "Library coverage and reference preprocessing assumptions dominate apparent no_match outcomes.", + ], + "next_experiments": recommend_next_experiments("RAMAN", metadata_gaps=gaps, fit_band=fit_band), + } + + def _build_xrd_reasoning( summary: dict[str, Any], rows: list[dict[str, Any]], @@ -878,6 +1110,10 @@ def build_scientific_reasoning( return _build_dsc_reasoning(summary, rows, metadata, band, fit_reason, validation) if analysis == "DTA": return _build_dta_reasoning(summary, rows, metadata, band, fit_reason, validation) + if analysis == "FTIR": + return _build_ftir_reasoning(summary, rows, metadata, band, fit_reason, validation) + if analysis == "RAMAN": + return _build_raman_reasoning(summary, rows, metadata, band, fit_reason, validation) if analysis == "XRD": return _build_xrd_reasoning(summary, rows, metadata, band, fit_reason, validation) if analysis in {"KISSINGER", "OZAWA-FLYNN-WALL", "FRIEDMAN"}: diff --git a/core/spectral_library_diagnostics.py b/core/spectral_library_diagnostics.py new file mode 100644 index 00000000..799076b5 --- /dev/null +++ b/core/spectral_library_diagnostics.py @@ -0,0 +1,98 @@ +"""Single entry point for spectral reference-library runtime diagnostics (FTIR/Raman/XRD client path).""" + +from __future__ import annotations + +import os +from typing import Any, Mapping + +from core.hosted_library import HostedLibraryCatalog, resolve_hosted_root +from core.library_cloud_client import ( + CLOUD_URL_ENV, + CLOUD_URL_ENV_LEGACY, + get_library_cloud_client, +) +from core.reference_library import get_reference_library_manager + + +def collect_spectral_library_runtime_diagnostics( + *, + analysis_type: str = "FTIR", + include_health_probe: bool = True, +) -> dict[str, Any]: + """Return a JSON-serializable snapshot for dev tools and logging. + + Spectral batch runs try the managed cloud HTTP client first; if that returns no payload, + ``core.batch_runner`` falls back to installed mirror candidates, then dataset-embedded refs. + This helper surfaces those branches without executing a search. + """ + modality = str(analysis_type or "").strip().upper() or "FTIR" + client = get_library_cloud_client() + mgr = get_reference_library_manager() + ctx = mgr.library_context(modality) + status = mgr.status() + hosted = HostedLibraryCatalog() + probe: dict[str, Any] + if include_health_probe: + probe = dict(client.health_probe()) + else: + probe = {"state": "skipped", "message": "health probe skipped"} + + health_state = str(probe.get("state") or "") + installed_n = int(mgr.count_installed_candidates(modality)) + env_url = str(os.getenv(CLOUD_URL_ENV, "") or os.getenv(CLOUD_URL_ENV_LEGACY, "") or "").strip() + + resolution_order: list[dict[str, Any]] = [ + { + "step": "cloud_search", + "eligible": bool(client.configured and client.enabled_by_env), + "health_probe_state": health_state, + "summary": ( + "Batch calls /v1/library/search/* with a bearer token when configured and enabled." + ), + }, + { + "step": "limited_fallback_cache", + "eligible": installed_n > 0, + "installed_candidate_count": installed_n, + "summary": "Used when cloud search is not used or returns no usable payload but mirror packages exist.", + }, + { + "step": "dataset_embedded", + "eligible": False, + "summary": "Used when dataset metadata carries embedded spectral references (see batch_runner).", + }, + { + "step": "unavailable", + "eligible": not client.configured, + "summary": "No cloud client and no offline candidates → library_result_source may be unavailable.", + }, + ] + + return { + "modality": modality, + "effective_cloud_url": str(client.base_url or ""), + "raw_cloud_url_env": env_url, + "cloud_client_configured": bool(client.configured), + "cloud_client_enabled_by_env": bool(client.enabled_by_env), + "cloud_health_probe": probe, + "cloud_last_error": (client.last_error or "")[:500], + "cloud_last_error_kind": getattr(client, "last_error_kind", "") or "", + "cloud_last_auth_mode": getattr(client, "last_auth_mode", "") or "", + "effective_hosted_root": str(resolve_hosted_root()), + "hosted_catalog_root_used": str(hosted.root), + "hosted_manifest_exists": bool(hosted.manifest_path.exists()), + "hosted_live_provider_count": int(hosted.live_provider_count(modality=modality)), + "hosted_load_entries_count": len(hosted.load_entries(modality)), + "hosted_missing_modalities": hosted.missing_modalities(("FTIR", "RAMAN", "XRD")), + "reference_library_status": dict(status) if isinstance(status, Mapping) else status, + "library_context": dict(ctx) if isinstance(ctx, Mapping) else ctx, + "installed_candidate_count": installed_n, + "batch_resolution_order": resolution_order, + "library_result_source_vocabulary": [ + "cloud_search", + "limited_fallback_cache", + "dataset_embedded", + "unavailable", + "not_configured", + ], + } diff --git a/core/thermal_literature_query_builder.py b/core/thermal_literature_query_builder.py index b0ceb879..06041f84 100644 --- a/core/thermal_literature_query_builder.py +++ b/core/thermal_literature_query_builder.py @@ -24,6 +24,37 @@ "dta", "tg", } +PLACEHOLDER_SUBJECT_VALUES = { + "unknown", + "n/a", + "na", + "none", + "null", + "not recorded", + "unnamed", + "sample", + "sample name", + "specimen", + "material", +} +PLACEHOLDER_SUBJECT_TOKENS = { + "unknown", + "unnamed", + "sample", + "specimen", + "material", + "dataset", + "file", + "record", + "result", + "name", + "not", + "recorded", + "na", + "none", + "null", +} +TRUSTED_SUBJECT_SOURCES = {"summary.sample_name", "metadata.sample_name"} CHEMICAL_ALIASES = { "caco3": ["calcium carbonate", "calcite"], } @@ -136,6 +167,16 @@ def _dedupe(values: list[str]) -> list[str]: return output +def _is_placeholder_subject(value: str) -> bool: + lowered = _clean_text(value).lower() + if lowered in PLACEHOLDER_SUBJECT_VALUES: + return True + tokens = _tokenize(lowered) + if not tokens: + return True + return all(token in PLACEHOLDER_SUBJECT_TOKENS for token in tokens) + + def _is_scientific_subject(value: str) -> bool: cleaned = _strip_filename_artifacts(value) if not cleaned: @@ -143,6 +184,8 @@ def _is_scientific_subject(value: str) -> bool: lowered = cleaned.lower() if lowered in {"tga", "dsc", "dta", "thermal", "decomposition", "thermal event"}: return False + if _is_placeholder_subject(cleaned): + return False tokens = _tokenize(cleaned) if not tokens: return False @@ -164,6 +207,8 @@ def _normalized_subject_candidates(record: Mapping[str, Any]) -> list[dict[str, cleaned = _strip_filename_artifacts(raw) if not cleaned: continue + if _is_placeholder_subject(cleaned): + continue filename_like = _looks_like_filename(raw) score = 100 - index * 10 if source.endswith("sample_name"): @@ -208,6 +253,24 @@ def _best_subject(record: Mapping[str, Any]) -> dict[str, Any]: } +def _infer_subject_trust(subject: Mapping[str, Any]) -> str: + cleaned = _clean_text(subject.get("cleaned")) + if not cleaned: + return "absent" + source = _clean_text(subject.get("source")) + if source not in TRUSTED_SUBJECT_SOURCES: + return "low_trust" + if bool(subject.get("filename_like")): + return "low_trust" + if not _is_scientific_subject(cleaned): + return "low_trust" + return "trusted" + + +def _infer_search_mode(subject_trust: str) -> str: + return "known_material" if _clean_text(subject_trust).lower() == "trusted" else "behavior_first" + + def _quoted_subject(subject: Mapping[str, Any]) -> str: cleaned = _clean_text(subject.get("cleaned")) if cleaned and subject.get("quote_safe"): @@ -247,6 +310,8 @@ def _generic_subject_query(subject: Mapping[str, Any], *, analysis_type: str, fa def _build_payload( *, analysis_type: str, + search_mode: str, + subject_trust: str, query_text: str, fallback_queries: list[str], query_rationale: str, @@ -257,6 +322,8 @@ def _build_payload( ) -> dict[str, Any]: return { "analysis_type": analysis_type, + "search_mode": _clean_text(search_mode).lower(), + "subject_trust": _clean_text(subject_trust).lower(), "query_text": _clean_text(query_text), "fallback_queries": _dedupe([_clean_text(item) for item in fallback_queries if _clean_text(item)]), "query_rationale": _clean_text(query_rationale), @@ -271,6 +338,8 @@ def build_dsc_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: summary = _summary(record) rows = _rows(record) subject = _best_subject(record) + subject_trust = _infer_subject_trust(subject) + search_mode = _infer_search_mode(subject_trust) subject_label = _clean_text(subject.get("cleaned")) quoted_subject = _quoted_subject(subject) tg_midpoint = _round_temperature(summary.get("tg_midpoint")) @@ -281,35 +350,72 @@ def build_dsc_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: glass_transition_count = _clean_int(summary.get("glass_transition_count")) or 0 if tg_midpoint is not None: - query_text = " ".join( - part for part in [quoted_subject, "DSC glass transition thermal analysis", f"{tg_midpoint} C"] if part - ) - fallback_queries = [ - _generic_subject_query(subject, analysis_type="DSC", fallback_label="glass transition"), - "DSC glass transition calorimetry", - _generic_subject_query(subject, analysis_type="thermal analysis", fallback_label="glass transition polymer"), - ] + if search_mode == "known_material": + query_text = " ".join( + part for part in [quoted_subject, "DSC glass transition thermal analysis", f"{tg_midpoint} C"] if part + ) + fallback_queries = [ + _generic_subject_query(subject, analysis_type="DSC", fallback_label="glass transition"), + "DSC glass transition calorimetry", + _generic_subject_query(subject, analysis_type="thermal analysis", fallback_label="glass transition polymer"), + ] + rationale = f"The DSC literature search is centered on a glass-transition signal near {tg_midpoint} C." + else: + query_text = " ".join(part for part in ["DSC glass transition thermal analysis", f"{tg_midpoint} C"] if part) + fallback_queries = [ + "DSC glass transition calorimetry", + "thermal analysis glass transition polymer", + "differential scanning calorimetry glass transition", + ] + if subject_label: + fallback_queries.append(_generic_subject_query(subject, analysis_type="DSC", fallback_label="glass transition")) + fallback_queries.append(f"DSC glass transition {tg_midpoint} C polymer") + rationale = f"The DSC literature search uses behavior-first semantics centered on a glass-transition signal near {tg_midpoint} C." display_title = subject_label or "DSC glass transition" - rationale = f"The DSC literature search is centered on a glass-transition signal near {tg_midpoint} C." display_terms = ["glass transition", "calorimetry", "thermal event"] else: event_label = peak_type or "thermal event" - query_text = " ".join( - part - for part in [quoted_subject, "DSC thermal event calorimetry", event_label, f"{peak_temp} C" if peak_temp is not None else ""] - if part - ) - fallback_queries = [ - _generic_subject_query(subject, analysis_type="DSC", fallback_label="thermal event"), - "DSC endothermic exothermic event calorimetry", - _generic_subject_query(subject, analysis_type="thermal analysis", fallback_label=event_label), - ] + if search_mode == "known_material": + query_text = " ".join( + part + for part in [quoted_subject, "DSC thermal event calorimetry", event_label, f"{peak_temp} C" if peak_temp is not None else ""] + if part + ) + fallback_queries = [ + _generic_subject_query(subject, analysis_type="DSC", fallback_label="thermal event"), + "DSC endothermic exothermic event calorimetry", + _generic_subject_query(subject, analysis_type="thermal analysis", fallback_label=event_label), + ] + rationale = f"The DSC literature search is centered on the leading {event_label} event" + (f" near {peak_temp} C." if peak_temp is not None else ".") + else: + query_text = " ".join( + part for part in ["DSC thermal event calorimetry", event_label, f"{peak_temp} C" if peak_temp is not None else ""] if part + ) + fallback_queries = [ + "DSC endothermic exothermic event calorimetry", + "differential scanning calorimetry thermal analysis", + ] + if peak_type in ("endo", "endotherm", "endothermic"): + fallback_queries.append("DSC endotherm endothermic peak calorimetry") + elif peak_type in ("exo", "exotherm", "exothermic"): + fallback_queries.append("DSC exotherm exothermic peak crystallization calorimetry") + else: + fallback_queries.append("DSC endothermic exothermic thermal event") + if subject_label: + fallback_queries.append(_generic_subject_query(subject, analysis_type="DSC", fallback_label=event_label)) + fallback_queries.append(_generic_subject_query(subject, analysis_type="thermal analysis", fallback_label=event_label)) + if peak_temp is not None: + fallback_queries.append(f"DSC thermal event {peak_temp} C") + rationale = f"The DSC literature search uses behavior-first semantics centered on the leading {event_label} event" + ( + f" near {peak_temp} C." if peak_temp is not None else "." + ) display_title = subject_label or "DSC thermal event" - rationale = f"The DSC literature search is centered on the leading {event_label} event" + (f" near {peak_temp} C." if peak_temp is not None else ".") display_terms = ["thermal event", "calorimetry", event_label] return _build_payload( analysis_type="DSC", + search_mode=search_mode, + subject_trust=subject_trust, query_text=query_text, fallback_queries=fallback_queries, query_rationale=rationale, @@ -319,6 +425,9 @@ def build_dsc_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: evidence_snapshot={ "sample_name": subject_label, "raw_subject": _clean_text(subject.get("raw")), + "subject_source": _clean_text(subject.get("source")), + "subject_trust": subject_trust, + "search_mode": search_mode, "filename_like_subject": bool(subject.get("filename_like")), "workflow_template": _workflow_label(record), "peak_count": peak_count, @@ -334,6 +443,8 @@ def build_dta_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: summary = _summary(record) rows = _rows(record) subject = _best_subject(record) + subject_trust = _infer_subject_trust(subject) + search_mode = _infer_search_mode(subject_trust) subject_label = _clean_text(subject.get("cleaned")) quoted_subject = _quoted_subject(subject) first_peak = rows[0] if rows else {} @@ -342,25 +453,43 @@ def build_dta_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: processing = dict(record.get("processing") or {}) method_context = dict(processing.get("method_context") or {}) - query_text = " ".join( - part for part in [quoted_subject, "DTA differential thermal analysis", direction, f"{peak_temp} C" if peak_temp is not None else ""] if part - ) - fallback_queries = [ - _generic_subject_query(subject, analysis_type="DTA", fallback_label="thermal event"), - "DTA endothermic exothermic event differential thermal analysis", - _generic_subject_query(subject, analysis_type="thermal analysis", fallback_label=direction), - ] + if search_mode == "known_material": + query_text = " ".join( + part for part in [quoted_subject, "DTA differential thermal analysis", direction, f"{peak_temp} C" if peak_temp is not None else ""] if part + ) + fallback_queries = [ + _generic_subject_query(subject, analysis_type="DTA", fallback_label="thermal event"), + "DTA endothermic exothermic event differential thermal analysis", + _generic_subject_query(subject, analysis_type="DTA", fallback_label=direction), + ] + rationale = f"The DTA literature search is centered on the leading {direction} event" + (f" near {peak_temp} C." if peak_temp is not None else ".") + else: + query_text = " ".join(part for part in ["DTA differential thermal analysis", direction, f"{peak_temp} C" if peak_temp is not None else ""] if part) + fallback_queries = [ + "DTA endothermic exothermic event differential thermal analysis", + _generic_subject_query(subject, analysis_type="DTA", fallback_label=direction), + ] + if subject_label: + fallback_queries.append(_generic_subject_query(subject, analysis_type="DTA", fallback_label="thermal event")) + rationale = f"The DTA literature search uses behavior-first semantics centered on the leading {direction} event" + ( + f" near {peak_temp} C." if peak_temp is not None else "." + ) return _build_payload( analysis_type="DTA", + search_mode=search_mode, + subject_trust=subject_trust, query_text=query_text, fallback_queries=fallback_queries, - query_rationale=f"The DTA literature search is centered on the leading {direction} event" + (f" near {peak_temp} C." if peak_temp is not None else "."), + query_rationale=rationale, query_display_title=subject_label or "DTA thermal event", query_display_mode="DTA / thermal events", query_display_terms=["thermal event", "differential thermal analysis", direction], evidence_snapshot={ "sample_name": subject_label, "raw_subject": _clean_text(subject.get("raw")), + "subject_source": _clean_text(subject.get("source")), + "subject_trust": subject_trust, + "search_mode": search_mode, "filename_like_subject": bool(subject.get("filename_like")), "workflow_template": _workflow_label(record), "peak_count": _clean_int(summary.get("peak_count")) or len(rows), @@ -375,6 +504,8 @@ def build_tga_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: summary = _summary(record) rows = _rows(record) subject = _best_subject(record) + subject_trust = _infer_subject_trust(subject) + search_mode = _infer_search_mode(subject_trust) subject_label = _clean_text(subject.get("cleaned")) quoted_subject = _quoted_subject(subject) first_step = rows[0] if rows else {} @@ -392,38 +523,60 @@ def build_tga_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: temperature_band = f"{lower} {upper} C" prioritized_queries: list[str] = [] - if preferred_entity: - primary_process = "decarbonation" if "decarbonation" in [term.lower() for term in process_terms] else "decomposition" - prioritized_queries.append( - " ".join(part for part in [preferred_entity, "thermogravimetric analysis", primary_process] if part) - ) - if subject_formulas: - formula = subject_formulas[0] - formula_parts = [formula, "calcination", "TGA"] - if "CaO" in process_terms: - formula_parts.append("CaO") - if "CO2 release" in process_terms: - formula_parts.append("CO2") - prioritized_queries.append(" ".join(formula_parts)) - if len(subject_aliases) > 1: - prioritized_queries.append(" ".join([subject_aliases[1], "decomposition", "thermogravimetric analysis"])) - elif preferred_entity: - prioritized_queries.append(" ".join([preferred_entity, "decomposition", "thermogravimetric analysis"])) - if preferred_entity and temperature_band: - prioritized_queries.append(" ".join([preferred_entity, "decomposition mass loss", temperature_band])) - elif quoted_subject: - prioritized_queries.append(" ".join(part for part in [quoted_subject, "decomposition mass loss residue", f"{midpoint} C" if midpoint is not None else ""] if part)) - prioritized_queries.append("thermogravimetric analysis decomposition mass loss residue") - - query_text = prioritized_queries[0] + if search_mode == "known_material": + if preferred_entity: + primary_process = "decarbonation" if "decarbonation" in [term.lower() for term in process_terms] else "decomposition" + prioritized_queries.append( + " ".join(part for part in [preferred_entity, "thermogravimetric analysis", primary_process] if part) + ) + if subject_formulas: + formula = subject_formulas[0] + formula_parts = [formula, "calcination", "TGA"] + if "CaO" in process_terms: + formula_parts.append("CaO") + if "CO2 release" in process_terms: + formula_parts.append("CO2") + prioritized_queries.append(" ".join(formula_parts)) + if len(subject_aliases) > 1: + prioritized_queries.append(" ".join([subject_aliases[1], "decomposition", "thermogravimetric analysis"])) + elif preferred_entity: + prioritized_queries.append(" ".join([preferred_entity, "decomposition", "thermogravimetric analysis"])) + if preferred_entity and temperature_band: + prioritized_queries.append(" ".join([preferred_entity, "decomposition mass loss", temperature_band])) + elif quoted_subject: + prioritized_queries.append(" ".join(part for part in [quoted_subject, "decomposition mass loss residue", f"{midpoint} C" if midpoint is not None else ""] if part)) + prioritized_queries.append("thermogravimetric analysis decomposition mass loss residue") + if subject_label and not subject.get("quote_safe"): + prioritized_queries.insert(1, f"{subject_label} thermogravimetric analysis decomposition") + if process_terms: + process_query_parts = [preferred_entity] if preferred_entity else ([subject_label] if subject_label else []) + prioritized_queries.append(" ".join(process_query_parts + process_terms + ["thermogravimetric analysis"])) + else: + primary_parts = ["thermogravimetric analysis", "decomposition mass loss residue"] + if midpoint is not None: + primary_parts.append(f"{midpoint} C") + prioritized_queries.append(" ".join(primary_parts)) + if temperature_band: + prioritized_queries.append(" ".join(["TGA decomposition mass loss", temperature_band])) + if process_terms: + prioritized_queries.append(" ".join(_dedupe(["TGA", *process_terms[:3], "thermogravimetric analysis"]))) + prioritized_queries.append("TGA thermogravimetric analysis decomposition mass loss residue") + if subject_label: + prioritized_queries.append(f"{subject_label} thermogravimetric analysis decomposition") + if subject_formulas: + prioritized_queries.append(" ".join([subject_formulas[0], "thermogravimetric analysis decomposition"])) + if subject_aliases: + prioritized_queries.append(" ".join([subject_aliases[0], "thermogravimetric analysis decomposition"])) + if len(subject_aliases) > 1: + prioritized_queries.append(" ".join([subject_aliases[1], "decomposition", "thermogravimetric analysis"])) + + query_text = prioritized_queries[0] if prioritized_queries else "thermogravimetric analysis decomposition mass loss residue" fallback_queries = prioritized_queries[1:] - if subject_label and not subject.get("quote_safe"): - fallback_queries.insert(0, f"{subject_label} thermogravimetric analysis decomposition") - if process_terms: - process_query_parts = [preferred_entity] if preferred_entity else ([subject_label] if subject_label else []) - fallback_queries.append(" ".join(process_query_parts + process_terms + ["thermogravimetric analysis"])) - - rationale = "The TGA literature search is centered on the decomposition profile" + rationale = ( + "The TGA literature search is centered on the decomposition profile" + if search_mode == "known_material" + else "The TGA literature search uses behavior-first semantics centered on the decomposition profile" + ) if midpoint is not None: rationale += f" with a leading step near {midpoint} C" if total_mass_loss is not None: @@ -431,6 +584,8 @@ def build_tga_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: rationale += "." return _build_payload( analysis_type="TGA", + search_mode=search_mode, + subject_trust=subject_trust, query_text=query_text, fallback_queries=fallback_queries, query_rationale=rationale, @@ -440,6 +595,9 @@ def build_tga_literature_query(record: Mapping[str, Any]) -> dict[str, Any]: evidence_snapshot={ "sample_name": subject_label, "raw_subject": _clean_text(subject.get("raw")), + "subject_source": _clean_text(subject.get("source")), + "subject_trust": subject_trust, + "search_mode": search_mode, "filename_like_subject": bool(subject.get("filename_like")), "subject_aliases": subject_aliases, "subject_formulas": subject_formulas, diff --git a/core/validation.py b/core/validation.py index 26acc290..d38b6422 100644 --- a/core/validation.py +++ b/core/validation.py @@ -19,6 +19,7 @@ TEMPERATURE_MIN_C = -200.0 TEMPERATURE_MAX_C = 2000.0 TEMPERATURE_UNITS = {"°C", "degC", "K"} +_SPECTRAL_AXIS_UNITS = {"cm^-1", "1/cm", "nm", "eV"} XRD_AXIS_UNITS = {"degree_2theta", "deg", "2theta", "angstrom", "1/angstrom"} SIGNAL_UNITS_BY_TYPE = { "DSC": {"mW", "mW/mg", "W/g"}, @@ -93,11 +94,14 @@ def _coerce_float(value: Any) -> float | None: return result -def _validation_status(*, issues: list[str], warnings: list[str]) -> str: +def _validation_status(*, issues: list[str], warnings: list[str], review_flags: list[str] | None = None) -> str: if issues: return "fail" + review_flags = review_flags or [] if warnings: return "warn" + if review_flags: + return "pass_with_review" return "pass" @@ -128,6 +132,7 @@ def _check_dataset_axis( analysis_type: str, checks: dict[str, Any], issues: list[str], + warnings: list[str], ) -> None: if temperature.isna().any(): if analysis_type in _SPECTRAL_ANALYSIS_TYPES or analysis_type == "XRD": @@ -149,9 +154,34 @@ def _check_dataset_axis( return if analysis_type in _SPECTRAL_ANALYSIS_TYPES: - checks["axis_direction"] = "increasing" if increasing else "decreasing" if decreasing else "mixed" - if not increasing and not decreasing: - issues.append(f"{analysis_type} spectral axis must be strictly monotonic.") + duplicate_points = int((diffs == 0).sum()) + positive_steps = int((diffs > 0).sum()) + negative_steps = int((diffs < 0).sum()) + unique_points = int(temperature.nunique(dropna=True)) + checks["axis_duplicate_points"] = duplicate_points + checks["axis_unique_points"] = unique_points + + if positive_steps and negative_steps: + checks["axis_direction"] = "mixed" + elif negative_steps: + checks["axis_direction"] = "decreasing" + elif positive_steps: + checks["axis_direction"] = "increasing" + else: + checks["axis_direction"] = "constant" + + if unique_points < 3: + issues.append(f"{analysis_type} spectral axis must contain at least 3 unique points.") + return + + if checks["axis_direction"] == "mixed": + warnings.append( + f"{analysis_type} spectral axis was not monotonic at import; points will be sorted by axis position during analysis." + ) + if duplicate_points: + warnings.append( + f"{analysis_type} spectral axis contains duplicate positions; duplicates will be collapsed during analysis." + ) return checks["axis_direction"] = "increasing" if increasing else "mixed" @@ -775,6 +805,16 @@ def enrich_spectral_result_validation( issues.append(f"{normalized_type} matched outputs must include top_match_id.") if confidence_band in {"no_match", "not_recorded"}: issues.append(f"{normalized_type} matched outputs must include a confidence band.") + elif match_status == "library_unavailable": + checks["caution_state_output"] = "library_unavailable" + message = ( + f"{normalized_type} reference library matching was unavailable or not configured; " + "this is not an accepted-spectrum no-match conclusion." + ) + if message not in warnings: + warnings.append(message) + if not caution_code: + warnings.append(f"{normalized_type} library-unavailable output is missing caution_code metadata.") elif match_status == "no_match": checks["caution_state_output"] = "no_match" message = ( @@ -1003,6 +1043,7 @@ def validate_thermal_dataset( """Return a structured validation summary for a ThermalDataset-like object.""" issues: list[str] = [] warnings: list[str] = [] + review_flags: list[str] = [] checks: dict[str, Any] = {} if dataset is None: @@ -1010,6 +1051,7 @@ def validate_thermal_dataset( "status": "fail", "issues": ["Dataset is missing."], "warnings": [], + "review_flags": [], "checks": {}, "required_metadata": list(RECOMMENDED_METADATA_FIELDS), "optional_metadata": list(OPTIONAL_METADATA_FIELDS), @@ -1029,6 +1071,7 @@ def validate_thermal_dataset( "status": "fail", "issues": issues, "warnings": warnings, + "review_flags": review_flags, "checks": checks, "required_metadata": list(RECOMMENDED_METADATA_FIELDS), "optional_metadata": list(OPTIONAL_METADATA_FIELDS), @@ -1041,6 +1084,7 @@ def validate_thermal_dataset( "status": "fail", "issues": issues, "warnings": warnings, + "review_flags": review_flags, "checks": checks, "required_metadata": list(RECOMMENDED_METADATA_FIELDS), "optional_metadata": list(OPTIONAL_METADATA_FIELDS), @@ -1053,6 +1097,7 @@ def validate_thermal_dataset( analysis_type=normalized_analysis_type, checks=checks, issues=issues, + warnings=warnings, ) if signal.isna().all(): @@ -1074,6 +1119,11 @@ def validate_thermal_dataset( if normalized_analysis_type == "XRD": if temperature_unit and str(temperature_unit) not in XRD_AXIS_UNITS: warnings.append(f"XRD axis unit '{temperature_unit}' is unusual; verify axis normalization before analysis.") + elif normalized_analysis_type in _SPECTRAL_ANALYSIS_TYPES: + if temperature_unit and str(temperature_unit) not in _SPECTRAL_AXIS_UNITS: + warnings.append( + f"{normalized_analysis_type} axis unit '{temperature_unit}' is unusual; verify spectral-axis normalization before analysis." + ) elif temperature_unit and temperature_unit not in TEMPERATURE_UNITS: warnings.append(f"Temperature unit '{temperature_unit}' is unusual; verify unit conversion before analysis.") checks["temperature_unit"] = temperature_unit or "unspecified" @@ -1088,6 +1138,16 @@ def validate_thermal_dataset( _check_import_context(metadata=metadata, checks=checks, warnings=warnings) + # Modality-specific suspicious unit combinations (review flags) + try: + from core.modality_specs import check_suspicious_unit_combo + suspicious_warnings = check_suspicious_unit_combo(normalized_analysis_type, temperature_unit or "", signal_unit or "") + for sw in suspicious_warnings: + review_flags.append(sw) + checks.setdefault("modality_unit_review", []).append(sw) + except ImportError: + pass + missing_metadata = [field for field in RECOMMENDED_METADATA_FIELDS if not metadata.get(field)] if missing_metadata: warnings.append(f"Recommended metadata missing: {', '.join(missing_metadata)}.") @@ -1115,9 +1175,10 @@ def validate_thermal_dataset( if not enforce_workflow_context: return { - "status": _validation_status(issues=issues, warnings=warnings), + "status": _validation_status(issues=issues, warnings=warnings, review_flags=review_flags), "issues": issues, "warnings": warnings, + "review_flags": review_flags, "checks": checks, "required_metadata": list(RECOMMENDED_METADATA_FIELDS), "optional_metadata": list(OPTIONAL_METADATA_FIELDS), @@ -1169,9 +1230,10 @@ def validate_thermal_dataset( ) return { - "status": _validation_status(issues=issues, warnings=warnings), + "status": _validation_status(issues=issues, warnings=warnings, review_flags=review_flags), "issues": issues, "warnings": warnings, + "review_flags": review_flags, "checks": checks, "required_metadata": list(RECOMMENDED_METADATA_FIELDS), "optional_metadata": list(OPTIONAL_METADATA_FIELDS), diff --git a/dash_app/__init__.py b/dash_app/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/dash_app/__init__.py @@ -0,0 +1 @@ + diff --git a/dash_app/api_client.py b/dash_app/api_client.py new file mode 100644 index 00000000..6e99a5ff --- /dev/null +++ b/dash_app/api_client.py @@ -0,0 +1,451 @@ +"""HTTP client for the co-located FastAPI backend. + +Since Dash and FastAPI run in the same process, we use httpx to call +the backend endpoints at localhost. The base URL is configurable for +testing and split-deployment scenarios. +""" + +from __future__ import annotations + +import base64 +import os +from typing import Any + +import httpx + +_BASE_URL = os.environ.get("MATERIALSCOPE_API_URL", "http://127.0.0.1:8050") +_TOKEN = os.environ.get("MATERIALSCOPE_API_TOKEN", "") +_TIMEOUT = 60.0 + + +def _headers() -> dict[str, str]: + h: dict[str, str] = {"Accept": "application/json"} + if _TOKEN: + h["X-MaterialScope-Token"] = _TOKEN + h["X-TA-Token"] = _TOKEN + return h + + +def _client() -> httpx.Client: + return httpx.Client(base_url=_BASE_URL, headers=_headers(), timeout=_TIMEOUT) + + +def _raise_with_detail(r: httpx.Response) -> None: + """Like ``_raise_with_detail(r)`` but includes the backend error detail.""" + if r.is_success: + return + try: + body = r.json().get("detail", r.text) + except Exception: + body = r.text + raise httpx.HTTPStatusError( + f"{r.status_code} {r.reason_phrase}: {body}", + request=r.request, + response=r, + ) + + +def workspace_new() -> dict[str, Any]: + with _client() as c: + r = c.post("/workspace/new") + _raise_with_detail(r) + return r.json() + + +def workspace_summary(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}") + _raise_with_detail(r) + return r.json() + + +def workspace_datasets(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/datasets") + _raise_with_detail(r) + return r.json() + + +def workspace_dataset_detail(project_id: str, dataset_key: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/datasets/{dataset_key}") + _raise_with_detail(r) + return r.json() + + +def workspace_dataset_data(project_id: str, dataset_key: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/datasets/{dataset_key}/data") + _raise_with_detail(r) + return r.json() + + +def workspace_delete_dataset(project_id: str, dataset_key: str) -> dict[str, Any]: + with _client() as c: + r = c.delete(f"/workspace/{project_id}/datasets/{dataset_key}") + _raise_with_detail(r) + return r.json() + + +def workspace_results(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/results") + _raise_with_detail(r) + return r.json() + + +def workspace_result_detail(project_id: str, result_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/results/{result_id}") + _raise_with_detail(r) + return r.json() + + +def analysis_state_curves(project_id: str, analysis_type: str, dataset_key: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/analysis-state/{analysis_type}/{dataset_key}") + _raise_with_detail(r) + return r.json() + + +def workspace_context(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/context") + _raise_with_detail(r) + return r.json() + + +def workspace_set_active_dataset(project_id: str, dataset_key: str) -> dict[str, Any]: + with _client() as c: + r = c.put( + f"/workspace/{project_id}/active-dataset", + json={"dataset_key": dataset_key}, + ) + _raise_with_detail(r) + return r.json() + + +def dataset_import( + project_id: str, + file_name: str, + file_base64: str, + data_type: str | None = None, + *, + column_mapping: dict[str, str] | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + with _client() as c: + r = c.post( + "/dataset/import", + json={ + "project_id": project_id, + "file_name": file_name, + "file_base64": file_base64, + "data_type": data_type, + "column_mapping": column_mapping or {}, + "metadata": metadata or {}, + }, + ) + _raise_with_detail(r) + return r.json() + + +def analysis_run( + project_id: str, + dataset_key: str, + analysis_type: str, + workflow_template_id: str | None = None, + unit_mode: str | None = None, + processing_overrides: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "project_id": project_id, + "dataset_key": dataset_key, + "analysis_type": analysis_type, + } + if workflow_template_id: + payload["workflow_template_id"] = workflow_template_id + if unit_mode: + payload["unit_mode"] = unit_mode + if processing_overrides: + payload["processing_overrides"] = processing_overrides + with _client() as c: + r = c.post("/analysis/run", json=payload) + _raise_with_detail(r) + return r.json() + + +def literature_compare( + project_id: str, + result_id: str, + *, + provider_ids: list[str] | None = None, + max_claims: int = 3, + filters: dict[str, Any] | None = None, + user_documents: list[dict[str, Any]] | None = None, + persist: bool = False, +) -> dict[str, Any]: + """Fetch literature comparison for a saved result via the backend endpoint.""" + payload: dict[str, Any] = { + "max_claims": int(max_claims), + "persist": bool(persist), + } + if provider_ids is not None: + payload["provider_ids"] = list(provider_ids) + if filters is not None: + payload["filters"] = dict(filters) + if user_documents is not None: + payload["user_documents"] = [dict(doc) for doc in user_documents] + with _client() as c: + r = c.post( + f"/workspace/{project_id}/results/{result_id}/literature/compare", + json=payload, + ) + _raise_with_detail(r) + return r.json() + + +def register_result_figure( + project_id: str, + result_id: str, + png_bytes: bytes, + *, + label: str, + replace: bool = False, +) -> dict[str, Any]: + """Register a PNG figure for a saved result in the backend project state.""" + figure_b64 = base64.b64encode(bytes(png_bytes)).decode("ascii") + with _client() as c: + r = c.post( + f"/workspace/{project_id}/results/{result_id}/figure", + json={ + "figure_png_base64": figure_b64, + "figure_label": label, + "replace": bool(replace), + }, + ) + _raise_with_detail(r) + return r.json() + + +def fetch_result_figure_png( + project_id: str, + result_id: str, + figure_key: str, + *, + max_edge: int | None = None, +) -> bytes: + """Download a registered PNG for this result (GET; same auth as other workspace calls). + + When ``max_edge`` is set, the server may return a downscaled PNG (long edge <= max_edge). + """ + params: dict[str, str | int] = {"figure_key": str(figure_key or "").strip()} + if max_edge is not None: + params["max_edge"] = int(max_edge) + with _client() as c: + r = c.get( + f"/workspace/{project_id}/results/{result_id}/figure", + params=params, + ) + _raise_with_detail(r) + return r.content + + +def list_analysis_presets(analysis_type: str) -> dict[str, Any]: + """List saved processing presets for an analysis type.""" + with _client() as c: + r = c.get(f"/presets/{analysis_type}") + _raise_with_detail(r) + return r.json() + + +def save_analysis_preset( + analysis_type: str, + preset_name: str, + *, + workflow_template_id: str | None, + processing: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Save or overwrite a processing preset for an analysis type.""" + payload: dict[str, Any] = { + "preset_name": preset_name, + "workflow_template_id": workflow_template_id, + "processing": dict(processing or {}), + } + with _client() as c: + r = c.post(f"/presets/{analysis_type}", json=payload) + _raise_with_detail(r) + return r.json() + + +def load_analysis_preset(analysis_type: str, preset_name: str) -> dict[str, Any]: + """Load the full payload for a saved processing preset.""" + with _client() as c: + r = c.get(f"/presets/{analysis_type}/{preset_name}") + _raise_with_detail(r) + return r.json() + + +def delete_analysis_preset(analysis_type: str, preset_name: str) -> dict[str, Any]: + """Delete a saved processing preset.""" + with _client() as c: + r = c.delete(f"/presets/{analysis_type}/{preset_name}") + _raise_with_detail(r) + return r.json() + + +def export_preparation(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/exports/preparation") + _raise_with_detail(r) + return r.json() + + +def export_support_snapshot(project_id: str) -> bytes: + with _client() as c: + r = c.get(f"/workspace/{project_id}/exports/support-snapshot") + _raise_with_detail(r) + return r.content + + +def export_results_csv( + project_id: str, + selected_result_ids: list[str] | None = None, +) -> dict[str, Any]: + with _client() as c: + r = c.post( + f"/workspace/{project_id}/exports/results-csv", + json={"selected_result_ids": selected_result_ids}, + ) + _raise_with_detail(r) + return r.json() + + +def export_results_xlsx( + project_id: str, + selected_result_ids: list[str] | None = None, +) -> dict[str, Any]: + with _client() as c: + r = c.post( + f"/workspace/{project_id}/exports/results-xlsx", + json={"selected_result_ids": selected_result_ids}, + ) + _raise_with_detail(r) + return r.json() + + +def export_report_docx( + project_id: str, + selected_result_ids: list[str] | None = None, + *, + include_figures: bool = True, +) -> dict[str, Any]: + with _client() as c: + r = c.post( + f"/workspace/{project_id}/exports/report-docx", + json={ + "selected_result_ids": selected_result_ids, + "include_figures": include_figures, + }, + ) + _raise_with_detail(r) + return r.json() + + +def export_report_pdf( + project_id: str, + selected_result_ids: list[str] | None = None, + *, + include_figures: bool = True, +) -> dict[str, Any]: + with _client() as c: + r = c.post( + f"/workspace/{project_id}/exports/report-pdf", + json={ + "selected_result_ids": selected_result_ids, + "include_figures": include_figures, + }, + ) + _raise_with_detail(r) + return r.json() + + +def workspace_branding(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/branding") + _raise_with_detail(r) + return r.json() + + +def update_workspace_branding(project_id: str, payload: dict[str, Any]) -> dict[str, Any]: + with _client() as c: + r = c.put(f"/workspace/{project_id}/branding", json=payload) + _raise_with_detail(r) + return r.json() + + +def compare_workspace(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.get(f"/workspace/{project_id}/compare") + _raise_with_detail(r) + return r.json() + + +def workspace_batch_run( + project_id: str, + *, + analysis_type: str, + workflow_template_id: str | None = None, + dataset_keys: list[str] | None = None, +) -> dict[str, Any]: + """Run stable batch analysis for compare workspace (uses selected datasets when keys omitted).""" + payload: dict[str, Any] = {"analysis_type": analysis_type} + if workflow_template_id: + payload["workflow_template_id"] = workflow_template_id + if dataset_keys is not None: + payload["dataset_keys"] = dataset_keys + with _client() as c: + r = c.post(f"/workspace/{project_id}/batch/run", json=payload) + _raise_with_detail(r) + return r.json() + + +def update_compare_workspace( + project_id: str, + *, + analysis_type: str | None = None, + selected_datasets: list[str] | None = None, + notes: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = {} + if analysis_type is not None: + payload["analysis_type"] = analysis_type + if selected_datasets is not None: + payload["selected_datasets"] = selected_datasets + if notes is not None: + payload["notes"] = notes + with _client() as c: + r = c.put(f"/workspace/{project_id}/compare", json=payload) + _raise_with_detail(r) + return r.json() + + +def project_save(project_id: str) -> dict[str, Any]: + with _client() as c: + r = c.post("/project/save", json={"project_id": project_id}) + _raise_with_detail(r) + return r.json() + + +def project_load(archive_base64: str) -> dict[str, Any]: + with _client() as c: + r = c.post("/project/load", json={"archive_base64": archive_base64}) + _raise_with_detail(r) + return r.json() + + +def health() -> dict[str, Any]: + with _client() as c: + r = c.get("/health") + _raise_with_detail(r) + return r.json() diff --git a/dash_app/app.py b/dash_app/app.py new file mode 100644 index 00000000..552690ae --- /dev/null +++ b/dash_app/app.py @@ -0,0 +1,38 @@ +"""Dash application factory for MaterialScope.""" + +from __future__ import annotations + +import dash +import dash_bootstrap_components as dbc + + +def create_dash_app(*, requests_pathname_prefix: str = "/") -> dash.Dash: + """Create and return the Dash application instance.""" + # Unit tests may ``import dash_app.pages.*`` with a throwaway Dash app, which registers + # pages under ``dash_app.pages.``. The real app loads the same files via + # ``pages_folder`` as ``pages.``, which would duplicate routes — clear stale entries. + import dash._pages as _dash_pages + + for _key in list(_dash_pages.PAGE_REGISTRY.keys()): + if isinstance(_key, str) and _key.startswith("dash_app.pages."): + del _dash_pages.PAGE_REGISTRY[_key] + + app = dash.Dash( + __name__, + use_pages=True, + pages_folder="pages", + external_stylesheets=[ + dbc.themes.BOOTSTRAP, + "https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap", + ], + suppress_callback_exceptions=True, + requests_pathname_prefix=requests_pathname_prefix, + title="MaterialScope", + update_title="MaterialScope ...", + ) + + from dash_app.layout import build_layout, register_clientside_theme + + app.layout = build_layout() + register_clientside_theme(app) + return app diff --git a/dash_app/assets/dta_shortcuts.js b/dash_app/assets/dta_shortcuts.js new file mode 100644 index 00000000..049135b6 --- /dev/null +++ b/dash_app/assets/dta_shortcuts.js @@ -0,0 +1,55 @@ +/** + * DTA page keyboard shortcuts (Phase 4). + * Undo / redo / run when DTA controls are present; ignores typing in inputs. + */ +(function () { + function isEditableTarget(el) { + if (!el || !el.tagName) return false; + var tag = el.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + if (el.isContentEditable) return true; + return false; + } + + function modifierDown(e) { + return e.ctrlKey || e.metaKey; + } + + document.addEventListener( + 'keydown', + function (e) { + if (!modifierDown(e)) return; + if (isEditableTarget(e.target)) return; + + var undo = document.getElementById('dta-undo-btn'); + var redo = document.getElementById('dta-redo-btn'); + var run = document.getElementById('dta-run-btn'); + if (!undo && !redo && !run) return; + + var key = e.key; + if (key === 'Enter' || key === 'enter') { + if (run && run.disabled !== true) { + e.preventDefault(); + run.click(); + } + return; + } + + if (key !== 'z' && key !== 'Z') return; + + if (e.shiftKey) { + if (redo && redo.disabled !== true) { + e.preventDefault(); + redo.click(); + } + return; + } + + if (undo && undo.disabled !== true) { + e.preventDefault(); + undo.click(); + } + }, + true + ); +})(); diff --git a/dash_app/assets/style.css b/dash_app/assets/style.css new file mode 100644 index 00000000..e250e797 --- /dev/null +++ b/dash_app/assets/style.css @@ -0,0 +1,1732 @@ +/* MaterialScope Dash — restrained scientific theme (light + dark via html[data-theme]) */ + +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap'); +@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css'); + +/* Light palette (product spec) */ +html[data-theme="light"], +html:not([data-theme]) { + --ta-bg: #FFFFFF; + --ta-ink: #1C1A1A; + --ta-muted: #66645E; + --ta-border: #E0DDD6; + --ta-panel: #FFFFFF; + --ta-panel-strong: #F7F6F3; + --ta-accent: #EBDBB7; + --ta-accent-hover: #D9C9A3; + --ta-accent-ink: #1C1A1A; + --ta-hero-badge-bg: rgba(102, 100, 94, 0.10); + --ta-hero-badge-fg: #66645E; + --ta-sidebar-bg: #1C1A1A; + --ta-sidebar-text: #F2F0EB; + --ta-sidebar-muted: #A8A59C; + --ta-sidebar-border: rgba(255, 255, 255, 0.08); + --ta-sidebar-hover: rgba(255, 255, 255, 0.05); + --ta-sidebar-active-bg: rgba(235, 219, 183, 0.12); + --ta-sidebar-active-border: #EBDBB7; + --ta-input-bg: #FFFFFF; + --ta-input-border: #D4D1CA; + --ta-card-shadow: 0 1px 2px rgba(28, 26, 26, 0.06); + --ta-danger-bg: rgba(92, 64, 60, 0.10); + --ta-danger-ink: #5C403C; + --ta-danger-border: #9A847E; + --ta-listbox-bg: #FFFFFF; + --ta-listbox-border: #D4D1CA; + --ta-option-hover-bg: rgba(235, 219, 183, 0.55); + --ta-option-selected-bg: rgba(102, 100, 94, 0.12); +} + +/* Dark counterpart — same typographic discipline, warm neutrals */ +html[data-theme="dark"] { + --ta-bg: #121110; + --ta-ink: #EEEDEA; + --ta-muted: #9E9A93; + --ta-border: #3D3B38; + --ta-panel: #1A1917; + --ta-panel-strong: #22211E; + --ta-accent: #CBB896; + --ta-accent-hover: #B8A382; + --ta-accent-ink: #121110; + --ta-hero-badge-bg: rgba(203, 184, 150, 0.12); + --ta-hero-badge-fg: #CBB896; + --ta-sidebar-bg: #1C1A1A; + --ta-sidebar-text: #F2F0EB; + --ta-sidebar-muted: #9E9A93; + --ta-sidebar-border: rgba(255, 255, 255, 0.07); + --ta-sidebar-hover: rgba(255, 255, 255, 0.05); + --ta-sidebar-active-bg: rgba(203, 184, 150, 0.14); + --ta-sidebar-active-border: #CBB896; + --ta-input-bg: #1A1917; + --ta-input-border: #3D3B38; + --ta-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + --ta-danger-bg: rgba(212, 166, 153, 0.12); + --ta-danger-ink: #D4A699; + --ta-danger-border: #8B726D; + --ta-listbox-bg: #22211E; + --ta-listbox-border: #3D3B38; + --ta-option-hover-bg: rgba(203, 184, 150, 0.35); + --ta-option-selected-bg: rgba(203, 184, 150, 0.22); +} + +html, body { + font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--ta-ink); + background: var(--ta-bg); + margin: 0; + padding: 0; +} + +/* App layout */ +.app-wrapper { + display: flex; + min-height: 100vh; +} + +.sidebar-container { + width: 260px; + min-width: 260px; + flex-shrink: 0; +} + +.main-content { + flex: 1; + padding: 1.5rem 2rem; + max-width: calc(100vw - 260px); + overflow-x: hidden; + background: var(--ta-bg); +} + +/* Sidebar (fixed dark rail in both themes) */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: 260px; + height: 100vh; + background: var(--ta-sidebar-bg); + color: var(--ta-sidebar-text); + overflow-y: auto; + z-index: 1000; + border-right: 1px solid var(--ta-sidebar-border); +} + +.sidebar-brand { + font-family: 'IBM Plex Mono', monospace; + font-weight: 700; + font-size: 1.12rem; + color: #FFFFFF; + letter-spacing: 0.03em; +} + +.sidebar-version { + font-size: 0.76rem; + color: var(--ta-sidebar-muted); + line-height: 1.4; + margin-top: 0.25rem; +} + +.sidebar-hr { + border-color: var(--ta-sidebar-border); + margin: 0.5rem 0; + opacity: 1; +} + +.sidebar-history-list { + list-style: none; + padding-left: 0; + margin-bottom: 0; + max-height: 240px; + overflow-y: auto; +} + +.sidebar-history-list ul { + list-style: none; + padding-left: 0; +} + +.sidebar-history-item { + padding: 0.25rem 0; + border-bottom: 1px solid var(--ta-sidebar-border); + font-size: 0.72rem; + color: var(--ta-sidebar-muted); + line-height: 1.3; +} + +.sidebar-history-item:last-child { + border-bottom: none; +} + +.sidebar-history-ts { + font-size: 0.62rem; + opacity: 0.7; +} + +.sidebar-section-label { + color: var(--ta-sidebar-muted); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 0.55rem 0.75rem 0.2rem; +} + +.sidebar-nav-link { + color: rgba(242, 240, 235, 0.92) !important; + font-size: 0.88rem; + font-weight: 500; + padding: 0.42rem 0.75rem !important; + border-radius: 0 6px 6px 0 !important; + border-left: 2px solid transparent; + transition: background 0.14s ease, border-color 0.14s ease; +} + +.sidebar-nav-link:hover { + background: var(--ta-sidebar-hover) !important; + border-left-color: rgba(235, 219, 183, 0.35); +} + +.sidebar-nav-link.active { + background: var(--ta-sidebar-active-bg) !important; + border-left-color: var(--ta-sidebar-active-border) !important; + color: #FFFFFF !important; + font-weight: 600; +} + +.sidebar-nav-link.disabled { + color: #6B6A66 !important; + opacity: 0.55; +} + +.sidebar-control-group-label { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ta-sidebar-muted); + margin-bottom: 0.35rem; +} + +.sidebar-theme-btn { + border-color: rgba(235, 219, 183, 0.45) !important; + color: var(--ta-sidebar-text) !important; + font-weight: 600; + border-radius: 6px !important; +} + +.sidebar-theme-btn:hover, +.sidebar-theme-btn:focus { + background: rgba(235, 219, 183, 0.14) !important; + border-color: #EBDBB7 !important; + color: #FFFFFF !important; +} + +.sidebar-theme-btn-text { + font-size: 0.8rem; +} + +.sidebar-select { + background: rgba(255, 255, 255, 0.06) !important; + color: var(--ta-sidebar-text) !important; + border-color: rgba(255, 255, 255, 0.14) !important; + border-radius: 6px !important; + font-size: 0.82rem; +} + +.sidebar-select:focus { + border-color: rgba(235, 219, 183, 0.55) !important; + box-shadow: 0 0 0 0.15rem rgba(235, 219, 183, 0.18) !important; +} + +/* Page header */ +.ta-hero { + margin: 0 0 1.4rem 0; + padding: 1.5rem 1.6rem; + border-radius: 4px; + background: var(--ta-panel); + border: 1px solid var(--ta-border); +} + +.ta-hero-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.32rem 0.7rem; + border-radius: 999px; + background: var(--ta-hero-badge-bg); + color: var(--ta-hero-badge-fg); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + margin-bottom: 0.85rem; +} + +.ta-hero-title { + margin: 0; + color: var(--ta-ink); + font-size: 1.75rem; + line-height: 1.12; + letter-spacing: -0.02em; + font-weight: 700; +} + +.ta-hero-copy { + max-width: 880px; + margin: 0.55rem 0 0 0; + color: var(--ta-muted); + font-size: 0.94rem; + line-height: 1.6; +} + +/* Cards */ +.card { + border: 1px solid var(--ta-border); + border-radius: 4px; + box-shadow: var(--ta-card-shadow); + background: var(--ta-panel); +} + +/* Upload zone */ +.upload-zone { + border: 2px dashed var(--ta-input-border); + border-radius: 8px; + background: var(--ta-panel-strong); + cursor: pointer; + transition: border-color 0.2s ease; +} + +.upload-zone:hover { + border-color: var(--ta-accent); +} + +/* Primary actions — cream family */ +.btn-primary { + background: var(--ta-accent) !important; + border: 1px solid rgba(28, 26, 26, 0.12) !important; + color: var(--ta-accent-ink) !important; + font-weight: 600; + border-radius: 6px; +} + +.btn-primary:hover, +.btn-primary:focus { + background: var(--ta-accent-hover) !important; + border-color: rgba(28, 26, 26, 0.18) !important; + color: var(--ta-accent-ink) !important; +} + +.btn-primary:active { + background: var(--ta-accent-hover) !important; +} + +/* Metrics */ +.card .text-uppercase { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.05em; +} + +/* Lists */ +.list-group-item { + border-color: var(--ta-border); + background: transparent; + color: var(--ta-ink); +} + +/* Alerts */ +.alert { + border-radius: 4px; + border-left-width: 3px; +} + +/* Badge */ +.badge { + font-weight: 600; + font-size: 0.72rem; +} + +/* Form controls */ +.form-select, .form-control { + background: var(--ta-input-bg); + border-color: var(--ta-input-border); + color: var(--ta-ink); +} + +.form-label, +.col-form-label { + color: var(--ta-ink); +} + +.text-muted { + color: var(--ta-muted) !important; +} + +/* Tables */ +.table { + color: var(--ta-ink); + --bs-table-bg: transparent; +} + +html[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > * { + --bs-table-accent-bg: rgba(255, 255, 255, 0.03); +} + +/* -------------------------------------------------------------------------- */ +/* Bootstrap tokens — remove default “link blue” / improve dark readability */ +/* -------------------------------------------------------------------------- */ + +html[data-theme="light"], +html:not([data-theme]) { + --bs-body-color: var(--ta-ink); + --bs-body-bg: var(--ta-bg); + --bs-secondary-color: var(--ta-muted); + --bs-heading-color: var(--ta-ink); + --bs-border-color: var(--ta-border); + --bs-link-color: var(--ta-muted); + --bs-link-hover-color: var(--ta-ink); +} + +html[data-theme="dark"] { + --bs-body-color: var(--ta-ink); + --bs-body-bg: var(--ta-bg); + --bs-secondary-color: var(--ta-muted); + --bs-heading-color: var(--ta-ink); + --bs-border-color: var(--ta-border); + --bs-link-color: #CBB896; + --bs-link-hover-color: #EBDBB7; +} + +/* Tabs (dbc.Tabs) — inactive tab text not link-blue */ +.main-content .nav-tabs .nav-link { + color: var(--ta-muted) !important; + border-color: transparent; + font-weight: 500; +} + +.main-content .nav-tabs .nav-link:hover, +.main-content .nav-tabs .nav-link:focus { + color: var(--ta-ink) !important; + border-color: var(--ta-border); + isolation: isolate; +} + +.main-content .nav-tabs .nav-link.active, +.main-content .nav-tabs .nav-link.active:hover { + color: var(--ta-ink) !important; + font-weight: 600; + background-color: var(--ta-panel) !important; + border-color: var(--ta-border) var(--ta-border) var(--ta-panel) !important; +} + +/* Info alerts — neutral / cream tint instead of cyan */ +.alert-info { + color: var(--ta-ink) !important; + background-color: rgba(235, 219, 183, 0.32) !important; + border-color: var(--ta-border) !important; + border-left: 3px solid var(--ta-muted) !important; +} + +html[data-theme="dark"] .alert-info { + background-color: rgba(203, 184, 150, 0.14) !important; + border-left-color: #CBB896 !important; +} + +.alert-success { + border-left: 3px solid #5A7A62 !important; +} + +.alert-warning { + border-left: 3px solid #9A8A5A !important; +} + +.alert-danger { + color: var(--ta-ink) !important; + background-color: var(--ta-danger-bg) !important; + border-color: var(--ta-danger-border) !important; + border-left: 3px solid var(--ta-danger-ink) !important; +} + +html[data-theme="dark"] .alert-danger { + color: var(--ta-ink) !important; +} + +/* Secondary / outline — no Bootstrap blue-gray */ +.btn-secondary, +.btn-outline-secondary { + background: var(--ta-panel-strong) !important; + color: var(--ta-ink) !important; + border: 1px solid var(--ta-border) !important; + font-weight: 600; +} + +.btn-secondary:hover, +.btn-secondary:focus, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus { + background: var(--ta-border) !important; + color: var(--ta-ink) !important; + border-color: var(--ta-muted) !important; +} + +/* Destructive actions — restrained (Remove, confirm delete) */ +.btn-danger, +.btn-outline-danger { + background: var(--ta-input-bg) !important; + color: var(--ta-muted) !important; + border: 1px solid var(--ta-border) !important; + font-weight: 600; +} + +.btn-danger:hover, +.btn-danger:focus, +.btn-outline-danger:hover, +.btn-outline-danger:focus { + background: var(--ta-danger-bg) !important; + color: var(--ta-ink) !important; + border-color: var(--ta-danger-border) !important; +} + +.ta-btn-remove { + border-style: dashed !important; +} + +.ta-link-emphasis { + color: var(--ta-muted) !important; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} + +.ta-link-emphasis:hover { + color: var(--ta-ink) !important; +} + +/* Inline error text — readable, not pure red */ +.main-content .text-danger { + color: var(--ta-danger-ink) !important; +} + +/* -------------------------------------------------------------------------- */ +/* dcc.Dropdown — hardening pass (react-select v5 + legacy v1) */ +/* -------------------------------------------------------------------------- */ + +/* Known problematic non-analysis controls (kept as reliability backstop): + #locale-select, #compare-selected-runs, #data-export-datasets, + #result-export-results, #report-export-results */ + +/* ----- Main content controls: shared class + targeted fallback selectors ---- */ +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-control, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class$="-control"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="__control"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="-control"] { + min-height: 38px !important; + border-radius: 6px !important; + border-color: var(--ta-input-border) !important; + background-color: var(--ta-input-bg) !important; + box-shadow: none !important; +} + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-value-label, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-placeholder, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="ingleValue"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="laceholder"] { + color: var(--ta-ink) !important; +} + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-placeholder { + color: var(--ta-muted) !important; +} + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-input > input, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-input input, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="Input"] input { + color: var(--ta-ink) !important; + background: transparent !important; + outline: none !important; +} + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-arrow-zone, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="ndicatorCo"] svg { + fill: var(--ta-muted) !important; +} + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-arrow-zone .Select-arrow, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-arrow { + border-top-color: var(--ta-muted) !important; +} + +/* ----- Sidebar locale: legacy react-select dark skin (dark theme only) ----- */ +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-control, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class$="-control"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="__control"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="-control"] { + background-color: rgba(42, 40, 38, 0.96) !important; + border-color: rgba(235, 219, 183, 0.28) !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-value-label, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-placeholder, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="ingleValue"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="laceholder"] { + color: #f2f0eb !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-placeholder { + color: rgba(242, 240, 235, 0.72) !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-input > input, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-input input, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="Input"] input { + color: #f2f0eb !important; + background: transparent !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-arrow-zone, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="ndicatorCo"] svg { + fill: rgba(242, 240, 235, 0.75) !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-arrow-zone .Select-arrow, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-arrow { + border-top-color: rgba(242, 240, 235, 0.75) !important; +} + +.sidebar-locale-wrap { + margin-bottom: 0; +} + +/* ----- Listbox (often portaled — uses theme CSS vars) ----- */ +div[role="listbox"] { + background-color: var(--ta-listbox-bg) !important; + border: 1px solid var(--ta-listbox-border) !important; + border-radius: 6px !important; + box-shadow: var(--ta-card-shadow) !important; + z-index: 12000 !important; +} + +/* Kill white inner shells from react-select v5 (menu list / loading) */ +div[role="listbox"] [class*="MenuList"], +div[role="listbox"] [class*="enuList"], +div[role="listbox"] [class*="loadingMessage"], +div[role="listbox"] [class*="noOptionsMessage"] { + background-color: var(--ta-listbox-bg) !important; + color: var(--ta-ink) !important; +} + +div[role="listbox"] div[role="option"] { + color: var(--ta-ink) !important; + background-color: transparent !important; +} + +div[role="listbox"] div[role="option"][aria-selected="true"] { + background-color: var(--ta-option-selected-bg) !important; + color: var(--ta-ink) !important; +} + +div[role="listbox"] div[role="option"]:hover, +div[role="listbox"] div[role="option"][class*="focused"] { + background-color: var(--ta-option-hover-bg) !important; + color: var(--ta-ink) !important; +} + +/* Multi chips — light */ +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="ultiValue"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select--multi .Select-value { + background: rgba(235, 219, 183, 0.45) !important; + border-radius: 4px !important; + border: 1px solid transparent !important; + color: var(--ta-ink) !important; +} + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="ultiValueLabel"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select--multi .Select-value-label { + color: var(--ta-ink) !important; +} + +/* When legacy menu renders inside sidebar DOM, pin dark tokens in dark mode */ +html[data-theme="dark"] .sidebar div[role="listbox"] { + --ta-listbox-bg: #2A2826; + --ta-listbox-border: rgba(255, 255, 255, 0.12); + background-color: #2A2826 !important; + border-color: rgba(255, 255, 255, 0.12) !important; +} + +html[data-theme="dark"] .sidebar div[role="listbox"] div[role="option"] { + color: #F2F0EB !important; +} + +html[data-theme="dark"] .sidebar div[role="listbox"] div[role="option"][aria-selected="true"] { + background-color: rgba(235, 219, 183, 0.28) !important; + color: #1c1a1a !important; +} + +html[data-theme="dark"] .sidebar div[role="listbox"] div[role="option"]:hover, +html[data-theme="dark"] .sidebar div[role="listbox"] div[role="option"][class*="focused"] { + background-color: rgba(235, 219, 183, 0.22) !important; + color: #1c1a1a !important; +} + +/* ----- Dark mode: legacy react-select v1 (global; menus often portaled) ----- */ +html[data-theme="dark"] .Select-menu-outer { + background-color: var(--ta-listbox-bg) !important; + border: 1px solid var(--ta-listbox-border) !important; + border-radius: 6px !important; + box-shadow: var(--ta-card-shadow) !important; +} + +html[data-theme="dark"] .Select-menu { + background-color: var(--ta-listbox-bg) !important; +} + +html[data-theme="dark"] .Select-option { + background-color: transparent !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .Select-option.is-focused { + background-color: var(--ta-option-hover-bg) !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .Select-option.is-selected { + background-color: var(--ta-option-selected-bg) !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .VirtualizedSelectOption { + background-color: transparent !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .VirtualizedSelectFocusedOption { + background-color: var(--ta-option-hover-bg) !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .Select--multi .Select-value { + background-color: rgba(203, 184, 150, 0.32) !important; + border-color: var(--ta-border) !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .Select--multi .Select-value-label { + color: var(--ta-ink) !important; +} + +/* Any legacy control (including portaled menus’ parent stacks) */ +html[data-theme="dark"] .Select-control { + background-color: var(--ta-input-bg) !important; + border-color: var(--ta-input-border) !important; + color: var(--ta-ink) !important; + box-shadow: none !important; +} + +html[data-theme="dark"] .Select.is-focused > .Select-control, +html[data-theme="dark"] .Select.is-open > .Select-control, +html[data-theme="dark"] .Select-control.is-focused { + border-color: #CBB896 !important; + box-shadow: 0 0 0 1px rgba(203, 184, 150, 0.28) !important; +} + +html[data-theme="dark"] .Select-placeholder, +html[data-theme="dark"] .Select-value-label { + color: var(--ta-muted) !important; +} + +html[data-theme="dark"] .Select-input > input, +html[data-theme="dark"] .Select-input input { + color: var(--ta-ink) !important; + background-color: transparent !important; +} + +html[data-theme="dark"] .Select-arrow-zone { + color: var(--ta-muted) !important; +} + +html[data-theme="dark"] .Select-arrow { + border-top-color: var(--ta-muted) !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-menu-outer, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-menu, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [role="listbox"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-control, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select.is-focused > .Select-control, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select.is-open > .Select-control, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-control.is-focused { + background-color: rgba(42, 40, 38, 0.96) !important; + border-color: rgba(235, 219, 183, 0.28) !important; + color: #f2f0eb !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-option, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .VirtualizedSelectOption, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) div[role="option"] { + background-color: transparent !important; + color: #f2f0eb !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-option.is-focused, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .VirtualizedSelectFocusedOption, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) div[role="option"]:hover { + background-color: rgba(235, 219, 183, 0.22) !important; + color: #1c1a1a !important; +} + +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="ultiValue"] { + background: rgba(203, 184, 150, 0.32) !important; +} + +/* ----- Dark mode: v5 inner rows that default to white ----- */ +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="alueContainer"], +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="IndicatorsContainer"], +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="ndicatorsContainer"] { + background-color: var(--ta-input-bg) !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="alueContainer"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="IndicatorsContainer"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="ndicatorsContainer"] { + background-color: rgba(42, 40, 38, 0.96) !important; +} + +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="-control"]:focus-within, +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-control.is-focused, +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select.is-focused > .Select-control, +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select.is-open > .Select-control { + border-color: #CBB896 !important; + box-shadow: 0 0 0 1px rgba(203, 184, 150, 0.28) !important; +} + +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="-control"]:focus-within, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select-control.is-focused, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select.is-focused > .Select-control, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) .Select.is-open > .Select-control { + border-color: rgba(235, 219, 183, 0.55) !important; + box-shadow: 0 0 0 1px rgba(235, 219, 183, 0.22) !important; +} + +/* -------------------------------------------------------------------------- */ +/* Range inputs (dcc.Slider / native) if used later */ +/* -------------------------------------------------------------------------- */ + +input[type="range"] { + accent-color: var(--ta-muted); +} + +input[type="range"]:hover { + accent-color: #8A8780; +} + +html[data-theme="dark"] input[type="range"] { + accent-color: #CBB896; +} + +/* -------------------------------------------------------------------------- */ +/* Multi-select row height + light-mode value strip (class-based) */ +/* -------------------------------------------------------------------------- */ + +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-control, +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class$="-control"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="__control"], +.main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="-control"] { + min-height: 42px !important; +} + +html[data-theme="light"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="alueContainer"], +html:not([data-theme]) .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="alueContainer"] { + background-color: transparent !important; +} + +html[data-theme="light"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="-control"]:focus-within, +html:not([data-theme]) .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="-control"]:focus-within, +html[data-theme="light"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-control.is-focused, +html:not([data-theme]) .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select-control.is-focused, +html[data-theme="light"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select.is-focused > .Select-control, +html:not([data-theme]) .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) .Select.is-focused > .Select-control { + border-color: rgba(102, 100, 94, 0.55) !important; + box-shadow: 0 0 0 1px rgba(235, 219, 183, 0.35) !important; +} + +div[role="listbox"] input[type="text"], +div[role="listbox"] input[type="search"] { + background: var(--ta-input-bg) !important; + color: var(--ta-ink) !important; + border: 1px solid var(--ta-input-border) !important; + border-radius: 4px !important; + box-shadow: none !important; + outline: none !important; +} + +/* -------------------------------------------------------------------------- */ +/* Dash DataTable — theme-aligned cells (import + analysis tables) */ +/* -------------------------------------------------------------------------- */ + +html[data-theme="dark"] .ta-datatable .dash-cell, +html[data-theme="dark"] .ta-datatable .dash-header-cell, +html[data-theme="dark"] .ta-datatable th.column-header, +html[data-theme="dark"] .ta-datatable thead th, +html[data-theme="dark"] .ta-datatable .dash-spreadsheet th, +html[data-theme="dark"] .ta-datatable .dash-spreadsheet-container th { + background-color: var(--ta-panel-strong) !important; + color: var(--ta-ink) !important; + border-color: var(--ta-border) !important; +} + +html[data-theme="dark"] .ta-datatable .dash-spreadsheet-inner tr:hover td.dash-cell { + background-color: var(--ta-panel-strong) !important; +} + +html[data-theme="light"] .ta-datatable .dash-cell, +html:not([data-theme]) .ta-datatable .dash-cell, +html[data-theme="light"] .ta-datatable .dash-header-cell, +html:not([data-theme]) .ta-datatable .dash-header-cell { + background-color: #ffffff !important; + color: var(--ta-ink) !important; + border-color: var(--ta-border) !important; +} + +html[data-theme="dark"] .ta-datatable .previous-next-container button, +html[data-theme="dark"] .ta-datatable .page-number, +html[data-theme="dark"] .ta-datatable input.current-page { + color: var(--ta-ink) !important; + background: var(--ta-panel-strong) !important; + border-color: var(--ta-border) !important; +} + +/* -------------------------------------------------------------------------- */ +/* Compare radios — neutral checked state */ +/* -------------------------------------------------------------------------- */ + +.main-content .form-check-input { + border-color: var(--ta-input-border); +} + +.main-content .form-check-input:checked { + background-color: var(--ta-muted) !important; + border-color: var(--ta-muted) !important; +} + +html[data-theme="dark"] .main-content .form-check-input:checked { + background-color: #CBB896 !important; + border-color: #CBB896 !important; +} + +.main-content .form-check-label { + color: var(--ta-ink); +} + +/* -------------------------------------------------------------------------- */ +/* Plotly graph shell */ +/* -------------------------------------------------------------------------- */ + +.ta-plot { + width: 100%; +} + +/* -------------------------------------------------------------------------- */ +/* Dark mode: scrollbars + react-select chrome (links, placeholder, checkbox) */ +/* -------------------------------------------------------------------------- */ + +html[data-theme="dark"] .ta-datatable .dash-table-container, +html[data-theme="dark"] .ta-datatable .dash-spreadsheet-menu, +html[data-theme="dark"] .ta-datatable .dash-spreadsheet-inner { + scrollbar-color: var(--ta-border) var(--ta-panel); +} + +html[data-theme="dark"] .ta-datatable *::-webkit-scrollbar, +html[data-theme="dark"] .dash-spreadsheet *::-webkit-scrollbar, +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) *::-webkit-scrollbar, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, .ta-dropdown, .dash-dropdown, #locale-select) *::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +html[data-theme="dark"] .ta-datatable *::-webkit-scrollbar-track, +html[data-theme="dark"] .dash-spreadsheet *::-webkit-scrollbar-track, +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) *::-webkit-scrollbar-track, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, .ta-dropdown, .dash-dropdown, #locale-select) *::-webkit-scrollbar-track { + background: var(--ta-bg); +} + +html[data-theme="dark"] .ta-datatable *::-webkit-scrollbar-thumb, +html[data-theme="dark"] .dash-spreadsheet *::-webkit-scrollbar-thumb, +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) *::-webkit-scrollbar-thumb, +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, .ta-dropdown, .dash-dropdown, #locale-select) *::-webkit-scrollbar-thumb { + background: var(--ta-border); + border-radius: 6px; +} + +div[role="listbox"] a, +div[role="listbox"] button { + color: var(--ta-muted) !important; +} + +div[role="listbox"] a:hover { + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] div[role="listbox"] input[type="checkbox"] { + accent-color: #cbb896; +} + +html[data-theme="dark"] .main-content :is(.ta-dropdown, .dash-dropdown, #compare-selected-runs, #data-export-datasets, #result-export-results, #report-export-results) [class*="laceholder"], +html[data-theme="dark"] .sidebar :is(.ta-dropdown--sidebar, #locale-select) [class*="laceholder"] { + color: var(--ta-muted) !important; +} + +/* Native selects (e.g. Active Dataset) — kill white popover chrome in dark */ +html[data-theme="dark"] .main-content select.form-select, +html[data-theme="dark"] .main-content textarea.form-control { + background-color: var(--ta-input-bg) !important; + color: var(--ta-ink) !important; + border-color: var(--ta-input-border) !important; +} + +/* -------------------------------------------------------------------------- */ +/* Guidance blocks — non-analysis workflow help */ +/* -------------------------------------------------------------------------- */ + +.ta-guidance { + border-radius: 6px !important; + border-width: 1px !important; + border-style: solid !important; + box-shadow: none !important; + padding: 0.9rem 1rem !important; +} + +.ta-guidance-title { + color: var(--ta-ink) !important; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.ta-guidance-body, +.ta-guidance-list, +.ta-guidance-item { + color: var(--ta-ink) !important; + font-size: 0.88rem; + line-height: 1.55; +} + +.ta-guidance-list { + margin-top: 0.35rem; +} + +.ta-guidance--workflow .ta-guidance-list { + padding-left: 1.15rem !important; +} + +html[data-theme="light"] .ta-guidance--info, +html:not([data-theme]) .ta-guidance--info { + background: rgba(235, 219, 183, 0.28) !important; + border-color: rgba(102, 100, 94, 0.24) !important; +} + +html[data-theme="light"] .ta-guidance--secondary, +html:not([data-theme]) .ta-guidance--secondary { + background: #f7f6f3 !important; + border-color: #e0ddd6 !important; +} + +html[data-theme="light"] .ta-guidance--warning, +html:not([data-theme]) .ta-guidance--warning { + background: rgba(203, 184, 150, 0.28) !important; + border-color: rgba(154, 138, 90, 0.38) !important; +} + +html[data-theme="dark"] .ta-guidance--info { + background: rgba(203, 184, 150, 0.16) !important; + border-color: rgba(203, 184, 150, 0.34) !important; +} + +html[data-theme="dark"] .ta-guidance--secondary { + background: #1f1e1c !important; + border-color: #3d3b38 !important; +} + +html[data-theme="dark"] .ta-guidance--warning { + background: rgba(203, 184, 150, 0.22) !important; + border-color: rgba(203, 184, 150, 0.4) !important; +} + +/* -------------------------------------------------------------------------- */ +/* HARD OVERRIDE — Dash dcc.Dropdown legacy react-select dark mode fix */ +/* Put this at the VERY END of style.css */ +/* -------------------------------------------------------------------------- */ + +html[data-theme="dark"] .Select-control, +html[data-theme="dark"] .is-open > .Select-control, +html[data-theme="dark"] .is-focused > .Select-control { + background: #1A1917 !important; + border-color: #3D3B38 !important; + color: #EEEDEA !important; + box-shadow: none !important; +} + +html[data-theme="dark"] .Select-value, +html[data-theme="dark"] .Select-multi-value-wrapper, +html[data-theme="dark"] .Select-input, +html[data-theme="dark"] .Select-input > input, +html[data-theme="dark"] .Select-input input { + background: #1A1917 !important; + color: #EEEDEA !important; +} + +html[data-theme="dark"] .Select-placeholder, +html[data-theme="dark"] .Select-value-label { + color: #9E9A93 !important; +} + +html[data-theme="dark"] .Select-menu-outer, +html[data-theme="dark"] .Select-menu { + background: #22211E !important; + border-color: #3D3B38 !important; +} + +html[data-theme="dark"] .Select-option, +html[data-theme="dark"] .VirtualizedSelectOption { + background: #22211E !important; + color: #EEEDEA !important; +} + +html[data-theme="dark"] .Select-option.is-focused, +html[data-theme="dark"] .VirtualizedSelectFocusedOption { + background: rgba(203,184,150,0.18) !important; + color: #EEEDEA !important; +} + +html[data-theme="dark"] .Select-option.is-selected { + background: rgba(203,184,150,0.30) !important; + color: #121110 !important; +} + +html[data-theme="dark"] .Select-arrow-zone, +html[data-theme="dark"] .Select-clear-zone, +html[data-theme="dark"] .Select-arrow { + color: #9E9A93 !important; + border-color: #3D3B38 !important; +} + +html[data-theme="dark"] .Select--multi .Select-value { + background: rgba(203,184,150,0.24) !important; + border: 1px solid rgba(203,184,150,0.28) !important; + color: #EEEDEA !important; +} + +html[data-theme="dark"] .Select--multi .Select-value-label { + color: #EEEDEA !important; +} + +html[data-theme="dark"] .Select--multi .Select-value-icon { + border-right: 1px solid rgba(203,184,150,0.22) !important; + color: #EEEDEA !important; +} + +html[data-theme="dark"] #locale-select .Select-control, +html[data-theme="dark"] .ta-dropdown--sidebar .Select-control { + background: #2A2826 !important; + border-color: rgba(235,219,183,0.28) !important; +} + +html[data-theme="dark"] #locale-select .Select-value, +html[data-theme="dark"] #locale-select .Select-input, +html[data-theme="dark"] .ta-dropdown--sidebar .Select-value, +html[data-theme="dark"] .ta-dropdown--sidebar .Select-input { + background: #2A2826 !important; + color: #F2F0EB !important; +} + +html[data-theme="dark"] #locale-select .Select-menu-outer, +html[data-theme="dark"] #locale-select .Select-menu, +html[data-theme="dark"] .ta-dropdown--sidebar .Select-menu-outer, +html[data-theme="dark"] .ta-dropdown--sidebar .Select-menu { + background: #2A2826 !important; +} + +/* -------------------------------------------------------------------------- */ +/* Dash 4 dropdown (radix) — verified DOM selectors */ +/* -------------------------------------------------------------------------- */ + +/* Sidebar scrollbar theme parity */ +.sidebar { + scrollbar-width: thin; + scrollbar-color: var(--ta-sidebar-border) var(--ta-sidebar-bg); +} + +.sidebar::-webkit-scrollbar { + width: 8px; +} + +.sidebar::-webkit-scrollbar-track { + background: var(--ta-sidebar-bg); +} + +.sidebar::-webkit-scrollbar-thumb { + background-color: var(--ta-sidebar-border); + border-radius: 4px; + border: 2px solid var(--ta-sidebar-bg); +} + +.sidebar::-webkit-scrollbar-thumb:hover { + background-color: var(--ta-sidebar-muted); +} + +/* Sidebar locale trigger surface (id is on the trigger button) */ +html[data-theme="dark"] .sidebar #locale-select.dash-dropdown, +html[data-theme="dark"] .sidebar .ta-dropdown--sidebar.dash-dropdown { + background-color: rgba(42, 40, 38, 0.96) !important; + border-color: rgba(235, 219, 183, 0.28) !important; + color: #F2F0EB !important; + box-shadow: none !important; +} + +html[data-theme="dark"] .sidebar #locale-select.dash-dropdown:focus, +html[data-theme="dark"] .sidebar #locale-select.dash-dropdown[aria-expanded="true"], +html[data-theme="dark"] .sidebar .ta-dropdown--sidebar.dash-dropdown:focus, +html[data-theme="dark"] .sidebar .ta-dropdown--sidebar.dash-dropdown[aria-expanded="true"] { + border-color: rgba(235, 219, 183, 0.55) !important; + outline: 1px solid rgba(235, 219, 183, 0.24) !important; +} + +html[data-theme="dark"] .sidebar #locale-select .dash-dropdown-value, +html[data-theme="dark"] .sidebar .ta-dropdown--sidebar .dash-dropdown-value { + color: #F2F0EB !important; +} + +html[data-theme="dark"] .sidebar #locale-select .dash-dropdown-placeholder, +html[data-theme="dark"] .sidebar .ta-dropdown--sidebar .dash-dropdown-placeholder { + color: rgba(242, 240, 235, 0.72) !important; +} + +html[data-theme="dark"] .sidebar #locale-select .dash-dropdown-trigger-icon, +html[data-theme="dark"] .sidebar .ta-dropdown--sidebar .dash-dropdown-trigger-icon { + color: rgba(242, 240, 235, 0.75) !important; + fill: rgba(242, 240, 235, 0.75) !important; +} + +/* Light theme locale trigger/menu surfaces */ +html[data-theme="light"] .sidebar #locale-select.dash-dropdown, +html:not([data-theme]) .sidebar #locale-select.dash-dropdown, +html[data-theme="light"] .sidebar .ta-dropdown--sidebar.dash-dropdown, +html:not([data-theme]) .sidebar .ta-dropdown--sidebar.dash-dropdown { + background-color: #F6F5F2 !important; + border-color: #D4D1CA !important; + color: #1C1A1A !important; + box-shadow: none !important; +} + +html[data-theme="light"] .sidebar #locale-select.dash-dropdown:focus, +html:not([data-theme]) .sidebar #locale-select.dash-dropdown:focus, +html[data-theme="light"] .sidebar #locale-select.dash-dropdown[aria-expanded="true"], +html:not([data-theme]) .sidebar #locale-select.dash-dropdown[aria-expanded="true"], +html[data-theme="light"] .sidebar .ta-dropdown--sidebar.dash-dropdown:focus, +html:not([data-theme]) .sidebar .ta-dropdown--sidebar.dash-dropdown:focus, +html[data-theme="light"] .sidebar .ta-dropdown--sidebar.dash-dropdown[aria-expanded="true"], +html:not([data-theme]) .sidebar .ta-dropdown--sidebar.dash-dropdown[aria-expanded="true"] { + border-color: #BFAE8B !important; + outline: 1px solid rgba(191, 174, 139, 0.28) !important; +} + +html[data-theme="light"] .sidebar #locale-select .dash-dropdown-value, +html:not([data-theme]) .sidebar #locale-select .dash-dropdown-value, +html[data-theme="light"] .sidebar .ta-dropdown--sidebar .dash-dropdown-value, +html:not([data-theme]) .sidebar .ta-dropdown--sidebar .dash-dropdown-value { + color: #1C1A1A !important; +} + +html[data-theme="light"] .sidebar #locale-select .dash-dropdown-placeholder, +html:not([data-theme]) .sidebar #locale-select .dash-dropdown-placeholder, +html[data-theme="light"] .sidebar .ta-dropdown--sidebar .dash-dropdown-placeholder, +html:not([data-theme]) .sidebar .ta-dropdown--sidebar .dash-dropdown-placeholder { + color: #66645E !important; +} + +html[data-theme="light"] .sidebar #locale-select .dash-dropdown-trigger-icon, +html:not([data-theme]) .sidebar #locale-select .dash-dropdown-trigger-icon, +html[data-theme="light"] .sidebar .ta-dropdown--sidebar .dash-dropdown-trigger-icon, +html:not([data-theme]) .sidebar .ta-dropdown--sidebar .dash-dropdown-trigger-icon { + color: #66645E !important; + fill: #66645E !important; +} + +/* Sidebar locale menu/search surfaces */ +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-content, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-content { + background-color: #2A2826 !important; + border-color: rgba(255, 255, 255, 0.12) !important; +} + +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-search-container, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-search, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-search-container, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-search { + background-color: #2A2826 !important; + border-color: rgba(255, 255, 255, 0.12) !important; + color: #F2F0EB !important; +} + +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option { + background-color: transparent !important; + color: #F2F0EB !important; +} + +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option:hover, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option:focus, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option:hover, +html[data-theme="dark"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option:focus { + background-color: rgba(235, 219, 183, 0.22) !important; + color: #1C1A1A !important; +} + +/* Light theme locale menu/search/option surfaces */ +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-content, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-content, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-content, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-content { + background-color: #FFFFFF !important; + border-color: #D4D1CA !important; +} + +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-search-container, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-search-container, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-search, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-search, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-search-container, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-search-container, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-search, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-search { + background-color: #FFFFFF !important; + border-color: #D4D1CA !important; + color: #1C1A1A !important; +} + +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option { + background-color: transparent !important; + color: #1C1A1A !important; +} + +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option:hover, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option:hover, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option:focus, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> #locale-select) .dash-dropdown-option:focus, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option:hover, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option:hover, +html[data-theme="light"] .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option:focus, +html:not([data-theme]) .sidebar .dash-dropdown-wrapper:has(> .ta-dropdown--sidebar) .dash-dropdown-option:focus { + background-color: rgba(235, 219, 183, 0.45) !important; + color: #1C1A1A !important; +} + +/* Non-analysis main-content dropdowns (ta-dropdown) */ +html[data-theme="dark"] .main-content .dash-dropdown.ta-dropdown { + background-color: var(--ta-input-bg) !important; + border-color: var(--ta-input-border) !important; + color: var(--ta-ink) !important; + box-shadow: none !important; +} + +html[data-theme="dark"] .main-content .dash-dropdown.ta-dropdown:focus, +html[data-theme="dark"] .main-content .dash-dropdown.ta-dropdown[aria-expanded="true"] { + border-color: #CBB896 !important; + outline: 1px solid rgba(203, 184, 150, 0.28) !important; +} + +html[data-theme="dark"] .main-content .dash-dropdown.ta-dropdown .dash-dropdown-placeholder { + color: var(--ta-muted) !important; +} + +html[data-theme="dark"] .main-content .dash-dropdown-wrapper:has(> .dash-dropdown.ta-dropdown) .dash-dropdown-content { + background-color: var(--ta-listbox-bg) !important; + border-color: var(--ta-listbox-border) !important; +} + +html[data-theme="dark"] .main-content .dash-dropdown-wrapper:has(> .dash-dropdown.ta-dropdown) .dash-dropdown-search-container, +html[data-theme="dark"] .main-content .dash-dropdown-wrapper:has(> .dash-dropdown.ta-dropdown) .dash-dropdown-search { + background-color: var(--ta-input-bg) !important; + border-color: var(--ta-input-border) !important; + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .main-content .dash-dropdown-wrapper:has(> .dash-dropdown.ta-dropdown) .dash-dropdown-option { + color: var(--ta-ink) !important; +} + +html[data-theme="dark"] .main-content .dash-dropdown-wrapper:has(> .dash-dropdown.ta-dropdown) .dash-dropdown-option:hover, +html[data-theme="dark"] .main-content .dash-dropdown-wrapper:has(> .dash-dropdown.ta-dropdown) .dash-dropdown-option:focus { + background-color: var(--ta-option-hover-bg) !important; + color: var(--ta-ink) !important; +} + +/* -------------------------------------------------------------------------- */ +/* DTA — collapsible result cards (>> / vv) + theme-native code / JSON blocks */ +/* -------------------------------------------------------------------------- */ + +.main-content .ta-ms-details { + border: 1px solid var(--ta-border); + border-radius: 0.375rem; + padding: 0.65rem 0.9rem; + background: var(--ta-panel); +} + +.main-content .ta-ms-details > .ta-details-summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: baseline; + font-weight: 600; + color: var(--ta-ink); + user-select: none; +} + +.main-content .ta-ms-details > .ta-details-summary::-webkit-details-marker { + display: none; +} + +.main-content .ta-ms-details .ta-details-chevron::before { + content: '>>'; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.78em; + color: var(--ta-muted); + margin-right: 0.35rem; + letter-spacing: -0.14em; +} + +.main-content .ta-ms-details[open] > .ta-details-summary .ta-details-chevron::before { + content: 'vv'; + letter-spacing: -0.06em; +} + +.main-content pre.ta-code-block { + font-family: 'IBM Plex Mono', monospace; + font-size: 0.8125rem; + background: var(--ta-panel-strong) !important; + color: var(--ta-ink) !important; + border: 1px solid var(--ta-border) !important; + white-space: pre-wrap; + word-break: break-word; + max-height: 22rem; + overflow: auto; +} + +html[data-theme="dark"] .main-content .ta-quality-alert.alert-success { + background-color: rgba(90, 122, 98, 0.28) !important; + color: var(--ta-ink) !important; + border-color: rgba(90, 122, 98, 0.55) !important; +} + +html[data-theme="dark"] .main-content .ta-quality-alert.alert-warning { + background-color: rgba(154, 138, 90, 0.28) !important; + color: var(--ta-ink) !important; + border-color: rgba(154, 138, 90, 0.5) !important; +} + +/* -------------------------------------------------------------------------- */ +/* Shared results surface v2 (generic ms-* classes; DTA debug classes retained) */ +/* -------------------------------------------------------------------------- */ + +.main-content .ms-results-surface { + display: flex; + flex-direction: column; +} + +.main-content .ms-results-surface .ms-result-section { + margin-bottom: 1rem; +} + +.main-content .ms-results-surface .ms-result-section:last-child { + margin-bottom: 0; +} + +.main-content .ms-results-surface .ms-result-section > .card { + margin-bottom: 0 !important; + border-radius: 0.45rem; +} + +.main-content .ms-results-surface .ms-result-section .card-body { + padding: 1.05rem 1.15rem; +} + +.main-content .ms-results-surface .ms-result-section h5 { + font-size: 1.01rem; + line-height: 1.25; + letter-spacing: 0.01em; +} + +.main-content .ms-results-surface .ms-result-context > .card, +.main-content .ms-results-surface .ms-result-support > .card, +.main-content .ms-results-surface .ms-result-secondary > .card { + border-color: var(--ta-border); +} + +.main-content .ms-results-surface .ms-result-hero > .card { + border-color: rgba(102, 100, 94, 0.26); +} + +html[data-theme="dark"] .main-content .ms-results-surface .ms-result-hero > .card { + border-color: rgba(158, 154, 147, 0.36); +} + +.main-content .ms-results-surface .dta-figure-stack { + display: flex; + flex-direction: column; +} + +.main-content .ms-results-surface .ms-result-figure-shell { + border: 1px solid var(--ta-border); + border-radius: 0.5rem; + background: var(--ta-panel); + padding: 0.65rem 0.78rem 0.32rem; + box-shadow: 0 1px 2px rgba(28, 26, 26, 0.035); +} + +html[data-theme="dark"] .main-content .ms-results-surface .ms-result-figure-shell { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22); +} + +.main-content .ms-results-surface .ms-result-graph { + min-height: 560px; +} + +.main-content .ms-results-surface .dta-result-debug { + margin-top: 0.75rem; +} + +.main-content .ms-results-surface .dta-debug-shell { + border-style: dashed; + background: var(--ta-panel-strong); + padding: 0.55rem 0.8rem; + border-color: rgba(102, 100, 94, 0.34); +} + +html[data-theme="dark"] .main-content .ms-results-surface .dta-debug-shell { + border-color: rgba(158, 154, 147, 0.32); +} + +.main-content .ms-results-surface .dta-debug-shell > .ta-details-summary { + color: var(--ta-muted); + font-size: 0.88rem; + letter-spacing: 0.015em; +} + +.main-content .ms-results-surface .dta-debug-graph { + opacity: 0.92; +} + +.main-content .ms-results-surface .ms-meta-term { + font-size: 0.79rem; + letter-spacing: 0.015em; +} + +.main-content .ms-results-surface .ms-meta-def { + margin-bottom: 0.55rem !important; +} + +.main-content .ms-results-surface .ms-meta-value { + display: inline-block; + max-width: 100%; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-word; +} + +@media (max-width: 991.98px) { + .main-content .ms-results-surface .ms-result-section .card-body { + padding: 0.9rem 1rem; + } + + .main-content .ms-results-surface .ms-result-figure-shell { + padding: 0.55rem 0.55rem 0.2rem; + } + + .main-content .ms-results-surface .ms-result-graph { + min-height: 500px; + } +} + + +/* -------------------------------------------------------------------------- */ +/* Shared analysis figure artifact surfaces */ +/* -------------------------------------------------------------------------- */ + +.main-content .ms-results-surface .ms-figure-surface .ms-result-figure-shell > .card-body.ms-figure-shell-body { + padding: 0.55rem 0.62rem 0.45rem; +} + +@media (min-width: 576px) { + .main-content .ms-results-surface .ms-figure-surface .ms-result-figure-shell > .card-body.ms-figure-shell-body { + padding: 0.62rem 0.85rem 0.5rem; + } +} + +.main-content .ms-results-surface .ms-figure-surface .ms-figure-toolbar { + align-items: center; + row-gap: 0.5rem; + column-gap: 0.65rem; +} + +.main-content .ms-results-surface .ms-figure-toolbar__overlay { + flex: 1 1 12rem; + min-width: min(100%, 12rem); +} + +.main-content .ms-results-surface .ms-figure-toolbar__actions.btn-group { + flex: 0 0 auto; + margin-left: auto; +} + +@media (max-width: 575.98px) { + .main-content .ms-results-surface .ms-figure-toolbar__actions.btn-group { + margin-left: 0; + width: 100%; + justify-content: flex-end; + } +} + +.main-content .ms-results-surface .ms-figure-artifact-status:empty { + display: none; +} + +.main-content .ms-results-surface .ms-figure-artifacts-disclosure { + opacity: 0.96; +} + +.main-content .ms-results-surface .ms-figure-artifacts-disclosure-summary { + font-size: 0.8125rem; +} + +.main-content .ms-results-surface .ms-figure-artifacts-body { + border-left: 2px solid transparent; + padding-left: 0.35rem; +} + +/* -------------------------------------------------------------------------- */ +/* XRD analysis page — final surface polish (toolbar, rhythm, artifacts) */ +/* -------------------------------------------------------------------------- */ + +.main-content .ms-results-surface .ms-result-section.xrd-result-surface-block { + margin-bottom: 1.125rem; +} + +.main-content .ms-results-surface .ms-result-section.xrd-result-surface-block:last-child { + margin-bottom: 0; +} + +.main-content .ms-results-surface .xrd-figure-surface .ms-result-figure-shell > .card-body.xrd-figure-shell-body { + padding: 0.55rem 0.62rem 0.45rem; +} + +@media (min-width: 576px) { + .main-content .ms-results-surface .xrd-figure-surface .ms-result-figure-shell > .card-body.xrd-figure-shell-body { + padding: 0.62rem 0.85rem 0.5rem; + } +} + +.main-content .ms-results-surface .xrd-figure-surface .ta-xrd-figure-host .js-plotly-plot { + min-height: 420px; +} + +@media (min-width: 992px) { + .main-content .ms-results-surface .xrd-figure-surface .ta-xrd-figure-host .js-plotly-plot { + min-height: 460px; + } +} + +.main-content .ms-results-surface .xrd-figure-toolbar { + align-items: center; + row-gap: 0.5rem; + column-gap: 0.65rem; +} + +.main-content .ms-results-surface .xrd-figure-toolbar__overlay { + flex: 1 1 12rem; + min-width: min(100%, 12rem); +} + +.main-content .ms-results-surface .xrd-figure-toolbar__actions.btn-group { + flex: 0 0 auto; + margin-left: auto; +} + +@media (max-width: 575.98px) { + .main-content .ms-results-surface .xrd-figure-toolbar__actions.btn-group { + margin-left: 0; + width: 100%; + justify-content: flex-end; + } +} + +.main-content .ms-results-surface .xrd-overlay-toolbar-inner .xrd-overlay-dropdown-dash { + width: 100%; +} + +.main-content .ms-results-surface .xrd-figure-artifact-status:empty { + display: none; +} + +.main-content .ms-results-surface .ta-xrd-artifacts-disclosure { + opacity: 0.96; +} + +.main-content .ms-results-surface .xrd-artifacts-disclosure-summary { + font-size: 0.8125rem; +} + +.main-content .ms-results-surface .xrd-artifacts-body { + border-left: 2px solid transparent; + padding-left: 0.35rem; +} + +.main-content .ms-results-surface .xrd-evidence-table-section { + margin-top: 0.25rem; + padding-top: 0.35rem; + border-top: 1px solid var(--ta-border); +} + +.main-content .ms-results-surface .xrd-evidence-table-wrap { + font-size: 0.875rem; +} + +.main-content .ms-results-surface .xrd-processing-details-wrap h5 { + margin-bottom: 0.5rem; + font-size: 1.01rem; +} + +.main-content .ms-results-surface .xrd-processing-details-wrap p:last-child { + margin-bottom: 0; +} + +.main-content .xrd-processing-tab-pane .xrd-left-panel-card .card-body.xrd-left-panel-card-body { + padding: 0.72rem 0.82rem; +} + +.main-content .xrd-processing-tab-pane .xrd-left-panel-card .card-title { + font-size: 0.98rem; + margin-bottom: 0.35rem; +} + +.main-content .xrd-processing-tab-pane .xrd-left-panel-card .card-body.xrd-left-panel-card-body .small, +.main-content .xrd-processing-tab-pane .xrd-left-panel-card .card-body.xrd-left-panel-card-body .form-text { + line-height: 1.45; +} + +.main-content .ms-results-surface .xrd-literature-card .card-body { + padding: 0.65rem 0.75rem; +} + +.main-content .ms-results-surface .xrd-literature-card .card-title { + font-size: 0.9rem; + margin-bottom: 0.35rem; + color: var(--ta-muted); +} + +.main-content .ms-results-surface .xrd-literature-card .ta-ms-details .ta-details-summary { + font-size: 0.8125rem; +} diff --git a/dash_app/compare_curve_utils.py b/dash_app/compare_curve_utils.py new file mode 100644 index 00000000..a6340725 --- /dev/null +++ b/dash_app/compare_curve_utils.py @@ -0,0 +1,33 @@ +"""Pure helpers for Compare overlay axes and analysis-state series selection (no Dash page registration).""" + + +def axis_titles(analysis_type: str) -> tuple[str, str]: + """X and Y axis titles for compare overlay.""" + upper = (analysis_type or "").upper() + if upper == "FTIR": + return "Wavenumber (cm^-1)", "Intensity (a.u.)" + if upper == "RAMAN": + return "Raman shift (cm^-1)", "Intensity (a.u.)" + if upper == "XRD": + return "2theta (deg)", "Intensity (a.u.)" + if upper == "TGA": + return "Temperature (C)", "Mass or signal (a.u.)" + return "Temperature (C)", "Signal (a.u.)" + + +def pick_best_series(curves: dict) -> tuple[list, list, str] | None: + """Return (x, y, source_label) from analysis-state payload, or None if unusable.""" + x = curves.get("temperature") or [] + if not x: + return None + n = len(x) + corrected = curves.get("corrected") or [] + smoothed = curves.get("smoothed") or [] + raw = curves.get("raw_signal") or [] + if len(corrected) == n: + return x, corrected, "corrected" + if len(smoothed) == n: + return x, smoothed, "smoothed" + if len(raw) == n: + return x, raw, "raw" + return None diff --git a/dash_app/components/__init__.py b/dash_app/components/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/dash_app/components/__init__.py @@ -0,0 +1 @@ + diff --git a/dash_app/components/analysis_boilerplate.py b/dash_app/components/analysis_boilerplate.py new file mode 100644 index 00000000..e7026f69 --- /dev/null +++ b/dash_app/components/analysis_boilerplate.py @@ -0,0 +1,294 @@ +"""Shared pure Dash UI boilerplate for analysis pages.""" + +from __future__ import annotations + +import json +from typing import Any, Callable + +import dash_bootstrap_components as dbc +from dash import html + +from dash_app.components.analysis_page import finalized_validation_warning_issue_counts +from utils.i18n import translate_ui + + +def build_collapsible_section( + loc: str, + title_key: str, + body: Any, + *, + open: bool = False, + summary_suffix: Any | None = None, +) -> html.Details: + summary_children: list[Any] = [ + html.Span(className="ta-details-chevron"), + html.Span(translate_ui(loc, title_key), className="ms-1"), + ] + if summary_suffix is not None: + if isinstance(summary_suffix, (list, tuple)): + summary_children.extend(summary_suffix) + else: + summary_children.append(summary_suffix) + return html.Details( + [ + html.Summary(summary_children, className="ta-details-summary"), + html.Div(body, className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=open, + ) + + +def build_processing_history_card( + *, + title_id: str, + hint_id: str, + undo_button_id: str, + redo_button_id: str, + reset_button_id: str, + status_id: str, + card_class_name: str = "mb-3", + body_class_name: str | None = None, +) -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H6(id=title_id, className="card-title mb-1"), + html.P(id=hint_id, className="small text-muted mb-2"), + dbc.Row( + [ + dbc.Col(dbc.Button(id=undo_button_id, color="secondary", size="sm", outline=True, disabled=True), width="auto"), + dbc.Col(dbc.Button(id=redo_button_id, color="secondary", size="sm", outline=True, disabled=True), width="auto"), + dbc.Col(dbc.Button(id=reset_button_id, color="secondary", size="sm", outline=True), width="auto"), + ], + className="g-2 align-items-center mb-1", + ), + html.Div(id=status_id, className="small text-muted"), + ], + className=body_class_name, + ), + className=card_class_name, + ) + + +def build_apply_preset_card( + *, + id_prefix: str, + card_class_name: str = "mb-3", + body_class_name: str | None = None, + include_dirty_state: bool = False, +) -> dbc.Card: + dirty_children: list[Any] = [] + if include_dirty_state: + dirty_children = [ + html.Div(id=f"{id_prefix}-preset-loaded-line", className="small mb-1"), + html.Div(id=f"{id_prefix}-preset-dirty-flag", className="small mb-2"), + ] + return dbc.Card( + dbc.CardBody( + [ + html.H5(id=f"{id_prefix}-preset-card-title", className="card-title mb-1"), + html.Small(id=f"{id_prefix}-preset-help", className="form-text text-muted d-block mb-2"), + html.Div(id=f"{id_prefix}-preset-caption", className="small text-muted mb-2"), + *dirty_children, + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id=f"{id_prefix}-preset-select-label", html_for=f"{id_prefix}-preset-select"), + dbc.Select(id=f"{id_prefix}-preset-select", options=[], value=None), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.ButtonGroup( + [ + dbc.Button(id=f"{id_prefix}-preset-apply-btn", color="primary", size="sm", disabled=True), + dbc.Button(id=f"{id_prefix}-preset-delete-btn", color="secondary", size="sm", outline=True, disabled=True), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id=f"{id_prefix}-preset-save-name-label", html_for=f"{id_prefix}-preset-save-name"), + dbc.Input(id=f"{id_prefix}-preset-save-name", type="text", value="", maxLength=80), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Button(id=f"{id_prefix}-preset-save-btn", color="primary", size="sm", className="mb-2"), + html.Div(id=f"{id_prefix}-preset-status", className="small text-muted"), + ], + className=body_class_name, + ), + className=card_class_name, + ) + + +def build_load_saveas_preset_card( + *, + id_prefix: str, + card_class_name: str = "mb-3", + body_class_name: str | None = None, +) -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id=f"{id_prefix}-preset-card-title", className="card-title mb-1"), + html.Small(id=f"{id_prefix}-preset-help", className="form-text text-muted d-block mb-2"), + html.Div(id=f"{id_prefix}-preset-caption", className="small text-muted mb-2"), + html.Div(id=f"{id_prefix}-preset-loaded-line", className="small mb-1"), + html.Div(id=f"{id_prefix}-preset-dirty-flag", className="small mb-2"), + dbc.Label(id=f"{id_prefix}-preset-select-label", html_for=f"{id_prefix}-preset-select", className="mb-1"), + dbc.Select(id=f"{id_prefix}-preset-select", options=[], value=None), + dbc.Row( + [ + dbc.Col(dbc.Button(id=f"{id_prefix}-preset-load-btn", color="primary", size="sm", disabled=True, className="me-2"), width="auto"), + dbc.Col(dbc.Button(id=f"{id_prefix}-preset-delete-btn", color="secondary", size="sm", outline=True, disabled=True), width="auto"), + ], + className="g-2 my-2 align-items-center", + ), + dbc.Label(id=f"{id_prefix}-preset-save-name-label", html_for=f"{id_prefix}-preset-save-name", className="mb-1"), + dbc.Input(id=f"{id_prefix}-preset-save-name", type="text", value="", maxLength=80), + html.Small(id=f"{id_prefix}-preset-save-hint", className="text-muted d-block my-1"), + dbc.Row( + [ + dbc.Col(dbc.Button(id=f"{id_prefix}-preset-save-btn", color="primary", size="sm", className="me-2"), width="auto"), + dbc.Col(dbc.Button(id=f"{id_prefix}-preset-saveas-btn", color="secondary", size="sm", outline=True), width="auto"), + ], + className="g-2 mb-2 align-items-center", + ), + html.Div(id=f"{id_prefix}-preset-status", className="small text-muted"), + ], + className=body_class_name, + ), + className=card_class_name, + ) + + +def build_validation_quality_card( + detail: dict, + result_meta: dict, + loc: str, + *, + i18n_prefix: str, + collapsible_builder: Callable[..., html.Details] = build_collapsible_section, + derive_counts_from_lists: bool = True, + open_when_attention: bool = False, + include_attention_badges: bool = False, +) -> html.Details: + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + status = str(validation.get("status") or result_meta.get("validation_status") or "unknown") + warnings_list = validation.get("warnings") if isinstance(validation.get("warnings"), list) else [] + issues_list = validation.get("issues") if isinstance(validation.get("issues"), list) else [] + if derive_counts_from_lists: + wc, ic = finalized_validation_warning_issue_counts(validation) + else: + wc = int(validation.get("warning_count", len(warnings_list)) or 0) + ic = int(validation.get("issue_count", len(issues_list)) or 0) + + status_token = status.strip().lower() + if status_token in {"ok", "pass", "valid"} and wc == 0 and ic == 0: + alert_color = "success" + elif ic == 0: + alert_color = "warning" + else: + alert_color = "danger" + + body_children: list[Any] = [ + html.P([html.Strong(translate_ui(loc, f"{i18n_prefix}.status_label")), f" {status}"], className="mb-2"), + html.P([html.Strong(translate_ui(loc, f"{i18n_prefix}.warnings_label")), f" {wc}"], className="mb-2"), + html.P([html.Strong(translate_ui(loc, f"{i18n_prefix}.issues_label")), f" {ic}"], className="mb-0"), + ] + if warnings_list: + body_children.append(html.Ul([html.Li(str(w)) for w in warnings_list[:12]], className="small mb-0 mt-2")) + if issues_list: + body_children.append(html.Ul([html.Li(str(w)) for w in issues_list[:12]], className="small mb-0 mt-2")) + + summary_suffix: list[Any] | None = None + if include_attention_badges: + summary_suffix = [] + if wc: + summary_suffix.append( + dbc.Badge( + translate_ui(loc, f"{i18n_prefix}.badge_warnings", n=wc), + color="warning", + text_color="dark", + className="ms-2", + pill=True, + ) + ) + if ic: + summary_suffix.append( + dbc.Badge( + translate_ui(loc, f"{i18n_prefix}.badge_issues", n=ic), + color="danger", + className="ms-2", + pill=True, + ) + ) + if not summary_suffix: + summary_suffix = None + + inner = dbc.Alert(body_children, color=alert_color, className="mb-0 ta-quality-alert") + return collapsible_builder( + loc, + f"{i18n_prefix}.card_title", + inner, + open=(wc > 0 or ic > 0) if open_when_attention else False, + summary_suffix=summary_suffix, + ) + + +def build_split_raw_metadata_panel( + metadata: dict | None, + loc: str, + *, + i18n_prefix: str, + user_facing_keys: frozenset[str], + value_formatter: Callable[[Any], str | None], + collapsible_builder: Callable[..., html.Details] = build_collapsible_section, +) -> html.Details: + meta = metadata if isinstance(metadata, dict) else {} + if not meta: + inner = html.P(translate_ui(loc, f"{i18n_prefix}.empty"), className="text-muted mb-0") + else: + user_keys = sorted([k for k in meta if k in user_facing_keys], key=lambda k: str(k).lower()) + tech_keys = sorted([k for k in meta if k not in user_facing_keys], key=lambda k: str(k).lower()) + + def _make_rows(keys: list[str]) -> list[Any]: + rows: list[Any] = [] + for key in keys: + value = meta[key] + if isinstance(value, (dict, list)): + text = json.dumps(value, ensure_ascii=False, indent=2) + else: + formatted = value_formatter(value) + text = formatted if formatted is not None else str(value) + rows.extend( + [ + html.Dt(str(key), className="col-sm-4 text-muted small"), + html.Dd(html.Pre(text, className="small mb-0 ta-code-block p-2 rounded"), className="col-sm-8 mb-2"), + ] + ) + return rows + + body_parts: list[Any] = [] + if user_keys: + body_parts.append(html.Dl(_make_rows(user_keys), className="row mb-0")) + if tech_keys: + tech_collapsible = build_collapsible_section( + loc, + f"{i18n_prefix}.technical_details", + html.Dl(_make_rows(tech_keys), className="row mb-0"), + open=False, + ) + body_parts.append(html.Div(tech_collapsible, className="mt-2")) + inner = html.Div(body_parts) if body_parts else html.P(translate_ui(loc, f"{i18n_prefix}.empty"), className="text-muted mb-0") + return collapsible_builder(loc, f"{i18n_prefix}.card_title", inner, open=False) diff --git a/dash_app/components/analysis_page.py b/dash_app/components/analysis_page.py new file mode 100644 index 00000000..551fe155 --- /dev/null +++ b/dash_app/components/analysis_page.py @@ -0,0 +1,527 @@ +"""Shared analysis-page primitives for stable modality Dash pages. + +Reusable layout blocks, callback helpers, and display components used by +DSC, TGA, and future modality pages. Modality-specific logic (figures, +specialised cards, extra selectors) stays inside each page module. +""" + +from __future__ import annotations + +from typing import Any + +import dash_bootstrap_components as dbc +from dash import dcc, html +import plotly.graph_objects as go + +from core.figure_render import render_plotly_figure_png +from utils.i18n import normalize_ui_locale, translate_ui + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _metric_label(locale_data: str | None, label_key_or_text: str) -> str: + if label_key_or_text.startswith("dash."): + return translate_ui(_loc(locale_data), label_key_or_text) + return label_key_or_text + + +# --------------------------------------------------------------------------- +# Metric card +# --------------------------------------------------------------------------- + +def metric_card(label: str, value: str) -> dbc.Card: + """Small KPI card used in result summary rows.""" + return dbc.Card( + dbc.CardBody( + [html.Small(label, className="text-muted text-uppercase"), html.H4(value, className="mb-0")] + ) + ) + + +def metrics_row( + pairs: list[tuple[str, str]], + *, + heading: str | None = None, + locale_data: str | None = None, +) -> html.Div: + """Row of metric cards with a heading. + + Parameters + ---------- + pairs : list of (label_key, value) tuples + *label_key* is either a ``dash.analysis.*`` translation key or a literal label string. + heading : optional literal heading; if omitted, uses translated Result Summary. + locale_data : ``ui-locale`` store value for translation. + """ + loc = _loc(locale_data) + resolved_heading = heading if heading is not None else translate_ui(loc, "dash.analysis.result_summary") + cards = [ + dbc.Col(metric_card(_metric_label(locale_data, label), value), md=max(3, 12 // max(len(pairs), 1))) + for label, value in pairs + ] + return html.Div( + [html.H5(resolved_heading, className="mb-3"), dbc.Row(cards, className="g-3")] + ) + + +# --------------------------------------------------------------------------- +# Dataset selection helpers +# --------------------------------------------------------------------------- + +def eligible_datasets(datasets: list[dict], eligible_types: set[str]) -> list[dict]: + """Filter datasets whose ``data_type`` (upper-cased) is in *eligible_types*.""" + return [d for d in datasets if (d.get("data_type") or "").upper() in eligible_types] + + +def dataset_options(datasets: list[dict]) -> list[dict]: + """Build ``dbc.Select`` option dicts from a dataset list.""" + return [ + { + "label": f"{d.get('display_name', d.get('key', '?'))} ({d.get('data_type', '?')})", + "value": d["key"], + } + for d in datasets + ] + + +def dataset_selector_block( + *, + selector_id: str, + empty_msg: str, + eligible: list[dict], + all_datasets: list[dict], + eligible_types: set[str], + active_dataset: str | None = None, + locale_data: str | None = None, +) -> tuple[html.Div, bool]: + """Build the dataset selector area and disabled state. + + Returns + ------- + (children, disabled) : tuple + *children* is the content for the ``-dataset-selector-area`` div. + *disabled* is the run button disabled state. + """ + loc = _loc(locale_data) + if not eligible: + type_labels = ", ".join(sorted(eligible_types)) + text = translate_ui(loc, "dash.analysis.no_eligible_prefix", types=type_labels, empty=empty_msg) + return html.P(text, className="text-muted"), True + + options = dataset_options(eligible) + default_value = None + if active_dataset: + eligible_keys = {d["key"] for d in eligible} + if active_dataset in eligible_keys: + default_value = active_dataset + + selector = dbc.Select( + id=selector_id, + options=options, + value=default_value or (options[0]["value"] if options else None), + ) + type_labels = ", ".join(sorted(eligible_types)) + info = html.P( + translate_ui( + loc, + "dash.analysis.eligible_count", + eligible=len(eligible), + total=len(all_datasets), + types=type_labels, + ), + className="text-muted small mt-2", + ) + return html.Div([selector, info]), False + + +# --------------------------------------------------------------------------- +# Layout building blocks +# --------------------------------------------------------------------------- + +def dataset_selection_card(selector_area_id: str, *, card_title_id: str) -> dbc.Card: + """Card with a placeholder div for the dataset selector.""" + return dbc.Card( + dbc.CardBody( + [ + html.H5(id=card_title_id, children="", className="mb-3"), + html.Div(id=selector_area_id), + ] + ), + className="mb-4", + ) + + +def workflow_template_card( + select_id: str, + description_id: str, + options: list[dict], + default_value: str, + *, + card_title_id: str, +) -> dbc.Card: + """Card with a workflow-template ``dbc.Select`` and description.""" + return dbc.Card( + dbc.CardBody( + [ + html.H5(id=card_title_id, children="", className="mb-3"), + dbc.Select(id=select_id, options=options, value=default_value), + html.P("", className="text-muted small mt-2", id=description_id), + ] + ), + className="mb-4", + ) + + +def execute_card(status_id: str, button_id: str, *, card_title_id: str) -> dbc.Card: + """Card with run-status area and execute button (label filled via locale callback).""" + return dbc.Card( + dbc.CardBody( + [ + html.H5(id=card_title_id, children="", className="mb-3"), + html.Div(id=status_id), + dbc.Button("", id=button_id, color="primary", className="w-100", disabled=True), + ] + ), + className="mb-4", + ) + + +def result_placeholder_card(div_id: str) -> dbc.Card: + """Generic card wrapping a result display div.""" + return dbc.Card(dbc.CardBody(html.Div(id=div_id)), className="mb-4") + + +def analysis_page_stores(refresh_id: str, latest_result_id: str) -> list[dcc.Store]: + """Two ``dcc.Store`` elements needed by every analysis page.""" + return [ + dcc.Store(id=refresh_id, data=0), + dcc.Store(id=latest_result_id), + ] + + +def _extract_graph_figure_payload(node: Any) -> Any: + """Return the first Plotly figure payload in visual component order. + + Traversal is deterministic: inspect the current component before its + descendants, and visit children left-to-right / top-to-bottom as Dash + renders them. This keeps primary result graphs ahead of later debug graphs. + """ + stack: list[Any] = [node] + + def _push_children(children: Any) -> None: + if children is None: + return + if isinstance(children, (list, tuple)): + for child in reversed(children): + stack.append(child) + return + stack.append(children) + + while stack: + current = stack.pop() + if current is None: + continue + if isinstance(current, (list, tuple)): + _push_children(current) + continue + if isinstance(current, dict): + props = current.get("props") + if isinstance(props, dict): + figure_payload = props.get("figure") + if figure_payload is not None: + return figure_payload + _push_children(props.get("children")) + if not isinstance(props, dict) or "children" not in props: + _push_children(current.get("children")) + continue + if hasattr(current, "figure"): + figure_payload = getattr(current, "figure") + if figure_payload is not None: + return figure_payload + _push_children(getattr(current, "children", None)) + return None + + +def capture_result_figure_from_layout( + *, + result_id: str | None, + project_id: str | None, + figure_children: Any, + captured: dict | None, + analysis_type: str, +) -> dict[str, dict[str, str]]: + """Capture and register a rendered result figure for a saved analysis result. + + This helper centralizes the Dash-side figure persistence pipeline used by all + stable modality pages. It extracts the first ``dcc.Graph`` figure from the + result-figure container, renders it to PNG via kaleido, and registers it in + the backend result artifact store. + """ + captured_state = dict(captured or {}) + if not result_id or not project_id: + return captured_state + + prior = captured_state.get(result_id) + # Only lock after a successful registration; "skipped" may mean the graph was not hydrated yet. + if isinstance(prior, dict) and prior.get("status") == "ok": + return captured_state + + figure_payload = _extract_graph_figure_payload(figure_children) + if figure_payload is None: + # Figure area may not be hydrated yet; keep waiting. + return captured_state + + try: + fig = figure_payload if isinstance(figure_payload, go.Figure) else go.Figure(figure_payload) + except Exception as exc: + captured_state[result_id] = {"status": "skipped", "reason": f"invalid_figure_payload: {exc}"} + return captured_state + + png_bytes, render_meta = render_plotly_figure_png(fig) + if not png_bytes: + captured_state[result_id] = {"status": "skipped", "reason": str(render_meta or "render_failed")} + return captured_state + + from dash_app.api_client import register_result_figure, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + captured_state[result_id] = {"status": "skipped", "reason": f"detail_load_failed: {exc}"} + return captured_state + + result_meta = (detail or {}).get("result") or {} + dataset_key = str(result_meta.get("dataset_key") or "").strip() or str(result_id) + label = f"{str(analysis_type or 'ANALYSIS').upper()} Analysis - {dataset_key}" + + try: + response = register_result_figure(project_id, result_id, png_bytes, label=label, replace=True) + except Exception as exc: + captured_state[result_id] = {"status": "skipped", "label": label, "reason": f"register_failed: {exc}"} + return captured_state + + persisted_label = str((response or {}).get("figure_key") or label) + item = {"status": "ok", "label": persisted_label} + if render_meta: + item["render_mode"] = str(render_meta) + captured_state[result_id] = item + return captured_state + + +def register_result_figure_from_layout_children( + *, + figure_children: Any, + project_id: str | None, + result_id: str | None, + label: str, + replace: bool, +) -> dict[str, Any]: + """Render the first nested ``dcc.Graph`` to PNG and register it for a result. + + Unlike :func:`capture_result_figure_from_layout`, this does not consult a + dedupe store: callers use it for explicit user actions (extra snapshots, + refreshing the primary report figure after overlay edits). + + Returns + ------- + dict + ``{"status": "ok", "figure_key": str}``, ``{"status": "skipped", "reason": str}``, + or ``{"status": "error", "reason": str}``. + """ + if not result_id or not project_id: + return {"status": "skipped", "reason": "missing_project_or_result"} + clean_label = str(label or "").strip() + if not clean_label: + return {"status": "skipped", "reason": "empty_label"} + + figure_payload = _extract_graph_figure_payload(figure_children) + if figure_payload is None: + return {"status": "skipped", "reason": "no_graph_in_layout"} + + try: + fig = figure_payload if isinstance(figure_payload, go.Figure) else go.Figure(figure_payload) + except Exception as exc: + return {"status": "skipped", "reason": f"invalid_figure_payload: {exc}"} + + png_bytes, render_meta = render_plotly_figure_png(fig) + if not png_bytes: + return {"status": "skipped", "reason": str(render_meta or "render_failed")} + + from dash_app.api_client import register_result_figure + + try: + response = register_result_figure(project_id, result_id, png_bytes, label=clean_label, replace=bool(replace)) + except Exception as exc: + return {"status": "error", "reason": str(exc)} + + persisted = str((response or {}).get("figure_key") or clean_label) + out: dict[str, Any] = {"status": "ok", "figure_key": persisted} + if render_meta: + out["render_mode"] = str(render_meta) + return out + + +# --------------------------------------------------------------------------- +# Execute callback helpers +# --------------------------------------------------------------------------- + + +def finalized_validation_warning_issue_counts(validation: dict[str, Any] | None) -> tuple[int, int]: + """Warning/issue counts for UI: derive from list payloads only. + + Keeps run banners, badges, and bullet lists aligned when stored + ``warning_count`` / ``issue_count`` disagree with ``warnings`` / ``issues``. + """ + v = validation if isinstance(validation, dict) else {} + warnings = v.get("warnings") if isinstance(v.get("warnings"), list) else [] + issues = v.get("issues") if isinstance(v.get("issues"), list) else [] + return len(warnings), len(issues) + + +def interpret_run_result(result: dict[str, Any], *, locale_data: str | None = None) -> tuple[Any, bool, str | None]: + """Interpret an ``analysis_run`` API response. + + Returns + ------- + (status_alert, saved, result_id) + *status_alert* : a ``dbc.Alert`` to show the user. + *saved* : True when the result was persisted. + *result_id* : the saved result id (None if not saved). + """ + loc = _loc(locale_data) + status = result.get("execution_status", "unknown") + result_id = result.get("result_id") + failure = result.get("failure_reason") + validation = result.get("validation", {}) if isinstance(result.get("validation"), dict) else {} + + if status == "saved" and result_id: + warn_n, _issue_n = finalized_validation_warning_issue_counts(validation) + alert = dbc.Alert( + translate_ui( + loc, + "dash.analysis.interpret_saved", + rid=result_id, + vstatus=validation.get("status", translate_ui(loc, "dash.analysis.na")), + warnings=warn_n, + ), + color="success", + ) + return alert, True, result_id + + if status == "blocked": + alert = dbc.Alert( + translate_ui(loc, "dash.analysis.interpret_blocked", reason=failure or translate_ui(loc, "dash.analysis.na")), + color="warning", + ) + return alert, False, None + + alert = dbc.Alert( + translate_ui(loc, "dash.analysis.interpret_failed", reason=failure or translate_ui(loc, "dash.analysis.na")), + color="danger", + ) + return alert, False, None + + +# --------------------------------------------------------------------------- +# Processing details +# --------------------------------------------------------------------------- + +def processing_details_section( + processing: dict, + *, + extra_lines: list[html.P] | None = None, + locale_data: str | None = None, +) -> html.Div: + """Render processing details shared by all modality pages. + + Parameters + ---------- + processing : dict + The ``processing`` payload from the result detail response. + extra_lines : list of html.P, optional + Modality-specific lines appended after the shared ones. + locale_data : ``ui-locale`` store value for translation. + """ + loc = _loc(locale_data) + signal_pipeline = processing.get("signal_pipeline", {}) + + lines: list[Any] = [ + html.H5(translate_ui(loc, "dash.analysis.processing_title"), className="mb-3"), + html.P( + translate_ui( + loc, + "dash.analysis.processing_workflow", + label=processing.get("workflow_template_label", translate_ui(loc, "dash.analysis.na")), + version=processing.get("workflow_template_version", "?"), + ) + ), + html.P( + translate_ui( + loc, + "dash.analysis.processing_smoothing", + detail=signal_pipeline.get("smoothing", {}), + ) + ), + ] + + if extra_lines: + lines.extend(extra_lines) + + return html.Div(lines) + + +# --------------------------------------------------------------------------- +# Empty state helpers +# --------------------------------------------------------------------------- + +def empty_result_msg(*, text: str | None = None, locale_data: str | None = None) -> html.P: + if text is not None: + return html.P(text, className="text-muted") + return html.P(translate_ui(_loc(locale_data), "dash.analysis.empty_run_result"), className="text-muted") + + +def no_data_figure_msg(*, text: str | None = None, locale_data: str | None = None) -> html.P: + if text is not None: + return html.P(text, className="text-muted") + return html.P(translate_ui(_loc(locale_data), "dash.analysis.empty_figure"), className="text-muted") + + +# --------------------------------------------------------------------------- +# Sample name resolution +# --------------------------------------------------------------------------- + +def resolve_sample_name( + summary: dict, + result_meta: dict, + *, + fallback_display_name: str | None = None, + locale_data: str | None = None, +) -> str: + """Resolve the best available sample name for display. + + Fallback chain (first non-empty value wins): + 1. ``summary["sample_name"]`` -- set from dataset metadata during analysis + 2. ``fallback_display_name`` -- typically from the dataset list's display_name + 3. ``result_meta["dataset_key"]`` -- the raw key, with extension stripped + 4. Translated ``dash.analysis.na`` -- last resort + """ + loc = _loc(locale_data) + name = summary.get("sample_name") + if name and str(name).strip(): + return str(name).strip() + + if fallback_display_name and str(fallback_display_name).strip(): + return str(fallback_display_name).strip() + + dataset_key = result_meta.get("dataset_key") or "" + if dataset_key: + for ext in (".csv", ".txt", ".dat", ".xls", ".xlsx"): + if dataset_key.lower().endswith(ext): + dataset_key = dataset_key[: -len(ext)] + break + if dataset_key.strip(): + return dataset_key.strip() + + return translate_ui(loc, "dash.analysis.na") diff --git a/dash_app/components/chrome.py b/dash_app/components/chrome.py new file mode 100644 index 00000000..eeee1f76 --- /dev/null +++ b/dash_app/components/chrome.py @@ -0,0 +1,17 @@ +"""Reusable page header component (mirrors ui/components/chrome.py).""" + +from __future__ import annotations + +from dash import html + + +def page_header(title: str, caption: str = "", badge: str = "") -> html.Div: + children = [] + if badge: + children.append( + html.Span(badge, className="ta-hero-badge") + ) + children.append(html.H2(title, className="ta-hero-title")) + if caption: + children.append(html.P(caption, className="ta-hero-copy")) + return html.Div(children, className="ta-hero") diff --git a/dash_app/components/data_preview.py b/dash_app/components/data_preview.py new file mode 100644 index 00000000..12f0db37 --- /dev/null +++ b/dash_app/components/data_preview.py @@ -0,0 +1,124 @@ +"""Dash data preview helpers.""" + +from __future__ import annotations + +import math +from typing import Any + +import dash_bootstrap_components as dbc +from dash import dash_table, dcc, html +import plotly.graph_objects as go + +from dash_app.theme import apply_figure_theme + + +def metric_cards(detail: dict[str, Any]) -> dbc.Row: + dataset = detail.get("dataset") or {} + metadata = detail.get("metadata") or {} + return dbc.Row( + [ + dbc.Col(_metric_card("Data Type", dataset.get("data_type", "N/A")), md=3), + dbc.Col(_metric_card("Points", str(dataset.get("points", 0))), md=3), + dbc.Col(_metric_card("Vendor", dataset.get("vendor", "Generic")), md=3), + dbc.Col( + _metric_card( + "Heating Rate", + str(metadata.get("heating_rate") or "—"), + ), + md=3, + ), + ], + className="g-3 mb-3", + ) + + +def dataset_table(rows: list[dict[str, Any]], columns: list[str], *, page_size: int = 10, table_id: str = "dataset-preview-table"): + return html.Div( + dash_table.DataTable( + id=table_id, + data=rows, + columns=[{"name": column, "id": column} for column in columns], + page_size=page_size, + sort_action="native", + style_table={"overflowX": "auto"}, + style_cell={"textAlign": "left", "padding": "0.5rem", "fontSize": "0.85rem"}, + style_header={"fontWeight": 700}, + ), + className="ta-datatable", + ) + + +def metadata_list(detail: dict[str, Any]) -> html.Div: + metadata = { + key: value + for key, value in (detail.get("metadata") or {}).items() + if value not in (None, "", []) + } + if not metadata: + return html.P("No metadata.", className="text-muted") + return html.Ul([html.Li(f"{key}: {value}") for key, value in metadata.items()], className="mb-0") + + +def original_columns_list(detail: dict[str, Any]) -> html.Div: + original_columns = detail.get("original_columns") or {} + if not original_columns: + return html.P("No column mapping stored.", className="text-muted") + return html.Ul( + [html.Li(f"{key} ← {value}") for key, value in original_columns.items()], + className="mb-0", + ) + + +def quick_plot(rows: list[dict[str, Any]], detail: dict[str, Any], *, ui_theme: str | None = None): + if not rows: + return html.P("No data available for plotting.", className="text-muted") + first = rows[0] + if "temperature" not in first or "signal" not in first: + return html.P("No standard temperature/signal axes available.", className="text-muted") + + x = [row.get("temperature") for row in rows] + y = [row.get("signal") for row in rows] + dataset = detail.get("dataset") or {} + units = detail.get("units") or {} + fig = go.Figure() + fig.add_trace(go.Scatter(x=x, y=y, mode="lines", name=dataset.get("display_name", "Signal"))) + fig.update_layout( + title=f"{dataset.get('data_type', 'Data')} - {dataset.get('display_name', dataset.get('key', 'Dataset'))}", + margin=dict(l=48, r=24, t=56, b=48), + xaxis_title=f"Temperature ({units.get('temperature', '°C')})", + yaxis_title=f"Signal ({units.get('signal', 'a.u.')})", + height=360, + ) + apply_figure_theme(fig, ui_theme) + return dcc.Graph(figure=fig, config={"displaylogo": False, "responsive": True}, className="ta-plot") + + +def stats_table(rows: list[dict[str, Any]], columns: list[str]): + if not rows: + return html.P("No statistics available.", className="text-muted") + numeric_rows: list[dict[str, float]] = [] + for row in rows: + numeric_row: dict[str, float] = {} + for column in columns: + value = row.get(column) + if isinstance(value, (int, float)) and not isinstance(value, bool) and not math.isnan(value): + numeric_row[column] = float(value) + if numeric_row: + numeric_rows.append(numeric_row) + if not numeric_rows: + return html.P("No numeric statistics available.", className="text-muted") + import pandas as pd + + frame = pd.DataFrame(numeric_rows).describe().round(4).reset_index().rename(columns={"index": "stat"}) + return dataset_table(frame.to_dict(orient="records"), list(frame.columns), page_size=min(8, len(frame)), table_id="dataset-stats-table") + + +def _metric_card(label: str, value: str) -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.Small(label, className="text-muted text-uppercase"), + html.H4(value, className="mb-0"), + ] + ) + ) diff --git a/dash_app/components/figure_artifacts.py b/dash_app/components/figure_artifacts.py new file mode 100644 index 00000000..50086f72 --- /dev/null +++ b/dash_app/components/figure_artifacts.py @@ -0,0 +1,384 @@ +"""Shared figure artifact UI helpers for Dash analysis pages. + +This module intentionally stays UI/pure-helper only. Page modules own Dash +callbacks and API orchestration for registering and fetching figures. +""" + +from __future__ import annotations + +from typing import Any + +import dash_bootstrap_components as dbc +from dash import html + +from utils.i18n import translate_ui + + +FIGURE_ARTIFACT_PREVIEW_TILES = 6 +FIGURE_ARTIFACT_PREVIEW_MAX_EDGE = 320 +GENERIC_FIGURE_I18N_PREFIX = "dash.analysis.figure" + + +def _classes(*values: str | None) -> str: + return " ".join(str(v).strip() for v in values if str(v or "").strip()) + + +def primary_report_figure_label(analysis_type: str, dataset_key: str | None, result_id: str | None) -> str: + dk = str(dataset_key or "").strip() or str(result_id or "").strip() or "dataset" + return f"{str(analysis_type or 'ANALYSIS').upper()} Analysis - {dk}" + + +def snapshot_figure_label( + analysis_type: str, + dataset_key: str | None, + result_id: str | None, + stamp: str, +) -> str: + dk = str(dataset_key or "").strip() or str(result_id or "").strip() or "dataset" + clean_stamp = str(stamp or "").strip() + suffix = f" - {clean_stamp}" if clean_stamp else "" + return f"{str(analysis_type or 'ANALYSIS').upper()} Snapshot - {dk}{suffix}" + + +def figure_action_metadata( + action: str, + *, + analysis_type: str, + dataset_key: str | None, + result_id: str | None, + snapshot_stamp: str = "", +) -> dict[str, Any]: + clean_action = str(action or "").strip().lower() + if clean_action == "snapshot": + return { + "action": "snapshot", + "label": snapshot_figure_label(analysis_type, dataset_key, result_id, snapshot_stamp), + "replace": False, + "success_key": "snapshot_ok", + } + if clean_action == "report": + return { + "action": "report", + "label": primary_report_figure_label(analysis_type, dataset_key, result_id), + "replace": True, + "success_key": "report_ok", + } + return {} + + +def figure_action_from_trigger( + triggered_id: str | None, + *, + snapshot_button_id: str, + report_button_id: str, +) -> str | None: + if triggered_id == snapshot_button_id: + return "snapshot" + if triggered_id == report_button_id: + return "report" + return None + + +def ordered_figure_preview_keys(figure_artifacts: dict | None) -> list[str]: + """Prefer report figure first, then remaining registered keys, deduped.""" + fa = figure_artifacts if isinstance(figure_artifacts, dict) else {} + keys = [str(k).strip() for k in (fa.get("figure_keys") or []) if isinstance(k, str) and str(k).strip()] + primary = str(fa.get("report_figure_key") or "").strip() + ordered: list[str] = [] + if primary: + ordered.append(primary) + for key in keys: + if key not in ordered: + ordered.append(key) + return ordered + + +def figure_artifact_count(figure_artifacts: dict | None) -> int: + return len(ordered_figure_preview_keys(figure_artifacts)) + + +def figure_artifact_button_labels( + loc: str, + *, + i18n_prefix: str = GENERIC_FIGURE_I18N_PREFIX, +) -> tuple[str, str, str]: + return ( + translate_ui(loc, f"{i18n_prefix}.btn_snapshot"), + translate_ui(loc, f"{i18n_prefix}.btn_report"), + translate_ui(loc, f"{i18n_prefix}.artifacts_details_summary"), + ) + + +def figure_action_status_alert( + loc: str, + *, + i18n_prefix: str = GENERIC_FIGURE_I18N_PREFIX, + action: str | None = None, + status: str, + figure_key: str | None = None, + reason: str | None = None, + class_prefix: str = "ms", +) -> dbc.Alert: + clean_status = str(status or "").strip().lower() + clean_action = str(action or "").strip().lower() + if clean_status == "ok": + key_name = "snapshot_ok" if clean_action == "snapshot" else "report_ok" + return dbc.Alert( + translate_ui(loc, f"{i18n_prefix}.{key_name}", figure_key=str(figure_key or "")), + color="success", + className=_classes("py-1 mb-0 small ms-figure-inline-alert", f"{class_prefix}-figure-inline-alert"), + ) + if clean_status == "error": + return dbc.Alert( + translate_ui(loc, f"{i18n_prefix}.artifact_error", reason=str(reason or "")), + color="danger", + className=_classes("py-1 mb-0 small ms-figure-inline-alert", f"{class_prefix}-figure-inline-alert"), + ) + return dbc.Alert( + translate_ui(loc, f"{i18n_prefix}.artifact_skip", reason=str(reason or "")), + color="secondary" if clean_status == "skipped" else "warning", + className=_classes("py-1 mb-0 small ms-figure-inline-alert", f"{class_prefix}-figure-inline-alert"), + ) + + +def build_figure_artifact_surface( + modality_id: str, + *, + figure_host_class: str = "mb-0", + control_slot_id: str | None = None, + control_slot_class: str = "", + card_body_class: str = "", + surface_class: str = "", + artifacts_open: bool = False, +) -> html.Div: + """Return a figure card that wraps the existing ``-result-figure`` slot.""" + prefix = str(modality_id).strip() + controls: list[Any] = [] + if control_slot_id: + controls.append( + html.Div( + id=control_slot_id, + className=_classes( + "ms-figure-toolbar__overlay flex-grow-1 d-flex align-items-center", + f"{prefix}-figure-toolbar__overlay", + control_slot_class, + ), + style={"minWidth": "min(100%, 12.5rem)"}, + ) + ) + controls.append( + dbc.ButtonGroup( + [ + dbc.Button( + id=f"{prefix}-figure-save-snapshot-btn", + color="secondary", + size="sm", + outline=True, + disabled=True, + className="text-nowrap", + ), + dbc.Button( + id=f"{prefix}-figure-use-report-btn", + color="primary", + size="sm", + disabled=True, + className="text-nowrap", + ), + ], + className=_classes( + "ms-figure-toolbar__actions flex-shrink-0 align-self-center", + f"{prefix}-figure-toolbar__actions", + ), + ) + ) + + return html.Div( + [ + dbc.Card( + dbc.CardBody( + [ + html.Div( + id=f"{prefix}-result-figure", + className=_classes("ms-figure-host", f"{prefix}-figure-host", figure_host_class), + ), + html.Div( + controls, + className=_classes( + "ms-figure-toolbar d-flex flex-wrap align-items-stretch gap-2 pt-2 mt-2 border-top border-secondary-subtle", + f"{prefix}-figure-toolbar", + ), + ), + html.Div( + id=f"{prefix}-figure-artifact-status", + className=_classes( + "ms-figure-artifact-status small mt-2 text-muted", + f"{prefix}-figure-artifact-status", + ), + ), + ], + className=_classes("ms-figure-shell-body", f"{prefix}-figure-shell-body", card_body_class), + ), + className="ms-result-figure-shell shadow-sm mb-0", + ), + html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span( + id=f"{prefix}-figure-artifacts-summary", + className=_classes( + "ms-1 small text-muted ms-figure-artifacts-summary-label", + f"{prefix}-artifacts-summary-label", + ), + ), + ], + className=_classes( + "ta-details-summary py-1 ms-figure-artifacts-disclosure-summary", + f"{prefix}-artifacts-disclosure-summary", + ), + ), + html.Div( + id=f"{prefix}-result-figure-artifacts", + className=_classes( + "ta-details-body mt-2 small text-muted ms-figure-artifacts-body", + f"{prefix}-artifacts-body", + ), + ), + ], + className=_classes("ta-ms-details ms-figure-artifacts-disclosure mb-0 mt-2", f"ta-{prefix}-artifacts-disclosure"), + open=artifacts_open, + ), + ], + className=_classes("ms-figure-surface", f"{prefix}-figure-surface", surface_class), + ) + + +def build_figure_artifacts_panel( + figure_artifacts: dict | None, + loc: str, + *, + previews: dict[str, str] | None = None, + i18n_prefix: str = GENERIC_FIGURE_I18N_PREFIX, + class_prefix: str = "ms", + max_preview_tiles: int = FIGURE_ARTIFACT_PREVIEW_TILES, +) -> html.Div: + """Render saved figure metadata and optional inline preview data URLs.""" + fa = figure_artifacts if isinstance(figure_artifacts, dict) else {} + keys = [str(k).strip() for k in (fa.get("figure_keys") or []) if isinstance(k, str) and str(k).strip()] + primary = str(fa.get("report_figure_key") or "").strip() + status = str(fa.get("report_figure_status") or "").strip() + err = str(fa.get("report_figure_error") or "").strip() + + panel_class = _classes("ms-figure-artifacts-panel", f"{class_prefix}-figure-artifacts-panel") + if not keys and not primary and not status and not err: + return html.Div( + html.P(translate_ui(loc, f"{i18n_prefix}.artifacts_none"), className="small text-muted mb-0"), + className=panel_class, + ) + + primary_line = html.P( + translate_ui(loc, f"{i18n_prefix}.artifacts_primary", label=primary) + if primary + else translate_ui(loc, f"{i18n_prefix}.artifacts_primary_none"), + className=_classes( + "small mb-1 ms-figure-artifacts-primary-line", + "text-body-secondary" if primary else "text-muted opacity-75", + f"{class_prefix}-artifacts-primary-line", + ), + ) + + ordered = ordered_figure_preview_keys(fa) + preview_children: list[Any] = [] + if previews is not None: + preview_keys = ordered[: max(0, int(max_preview_tiles))] + if preview_keys: + preview_children.append( + html.Div( + translate_ui(loc, f"{i18n_prefix}.artifacts_previews_heading"), + className=_classes("small text-muted mb-1 fw-normal ms-figure-artifacts-previews-label", f"{class_prefix}-artifacts-previews-label"), + ) + ) + cols: list[Any] = [] + for key in preview_keys: + src = str((previews or {}).get(key) or "").strip() + badge = "* " if key == primary else "" + label_short = key if len(key) <= 48 else key[:45] + "..." + if src: + tile = [ + html.Img( + src=src, + alt=key, + className=_classes( + "img-fluid rounded border border-secondary-subtle mb-1 ms-figure-artifact-preview-img", + f"{class_prefix}-artifact-preview-img", + ), + style={"maxHeight": "72px", "objectFit": "contain", "opacity": 0.94}, + ), + html.Div(badge + label_short, className="small text-muted text-break opacity-90"), + ] + else: + tile = [ + html.Div( + translate_ui(loc, f"{i18n_prefix}.artifacts_preview_missing"), + className="border border-secondary-subtle rounded d-flex align-items-center justify-content-center text-muted small mb-1 bg-body-secondary bg-opacity-25", + style={"height": "72px"}, + ), + html.Div(badge + label_short, className="small text-muted text-break opacity-90"), + ] + cols.append(dbc.Col(tile, xs=12, sm=6, md=4, className="mb-1")) + preview_children.append(dbc.Row(cols, className=_classes("g-2 mb-0 ms-figure-artifact-preview-row", f"{class_prefix}-artifact-preview-row"))) + if len(ordered) > max_preview_tiles: + preview_children.append( + html.P( + translate_ui(loc, f"{i18n_prefix}.artifacts_previews_truncated", n=len(ordered) - max_preview_tiles), + className="small text-muted mb-1", + ) + ) + + meta_lines: list[Any] = [] + if status: + meta_lines.append( + html.P( + translate_ui(loc, f"{i18n_prefix}.artifacts_status", status=status), + className=_classes("small text-muted mb-1 ms-figure-artifacts-registry-status opacity-80", f"{class_prefix}-artifacts-registry-status"), + ) + ) + if err: + meta_lines.append( + html.P( + translate_ui(loc, f"{i18n_prefix}.artifacts_error", err=err), + className="small text-danger mb-0", + ) + ) + + if keys: + keys_block = html.Ul( + [html.Li(html.Code(key), className="small") for key in keys], + className=_classes("small mb-0 ps-3 ms-figure-artifacts-key-list", f"{class_prefix}-artifacts-key-list"), + ) + else: + keys_block = html.P( + translate_ui(loc, f"{i18n_prefix}.artifacts_empty"), + className="small text-muted mb-0 opacity-85", + ) + + registry = html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span( + translate_ui(loc, f"{i18n_prefix}.artifacts_registry_summary"), + className=_classes("ms-1 small text-muted ms-figure-artifacts-registry-summary-label", f"{class_prefix}-artifacts-registry-summary-label"), + ), + ], + className="ta-details-summary py-1", + ), + html.Div([*meta_lines, keys_block], className="ta-details-body mt-2"), + ], + className=_classes("ta-ms-details mb-0 mt-1 ms-figure-artifacts-registry-disclosure", f"{class_prefix}-artifacts-registry-disclosure"), + open=False, + ) + + return html.Div([primary_line, *preview_children, registry], className=panel_class) diff --git a/dash_app/components/ftir_explore.py b/dash_app/components/ftir_explore.py new file mode 100644 index 00000000..af2fefe7 --- /dev/null +++ b/dash_app/components/ftir_explore.py @@ -0,0 +1,107 @@ +"""FTIR Dash exploration helpers: undo stacks and lightweight data helpers. + +Reuses the same patterns established by TGA exploration helpers. +""" + +from __future__ import annotations + +import copy +import math +from typing import Any + +import numpy as np + +MAX_FTIR_UNDO_DEPTH = 25 + + +def ftir_draft_processing_equal(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + """Deep-compare normalized FTIR processing draft payloads.""" + if not isinstance(a, dict) or not isinstance(b, dict): + return a == b + try: + import json + + def norm(d: dict[str, Any]) -> str: + return json.dumps(d, sort_keys=True, default=str) + + return norm(a) == norm(b) + except Exception: + return a == b + + +def append_undo_after_edit( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + old_draft: dict[str, Any] | None, + new_draft: dict[str, Any], + *, + max_depth: int = MAX_FTIR_UNDO_DEPTH, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """After a user edit, push *old_draft* onto past and clear redo when draft actually changes.""" + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + if old_draft is None or ftir_draft_processing_equal(old_draft, new_draft): + return past_list, [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + past_list.append(copy.deepcopy(old_draft)) + if len(past_list) > max_depth: + past_list = past_list[-max_depth:] + return past_list, [] + + +def perform_undo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not past: + return None + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + previous = past_list.pop() + if current is not None: + future_list.append(copy.deepcopy(current)) + return previous, past_list, future_list + + +def perform_redo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not future: + return None + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in future if isinstance(x, dict)] + nxt = future_list.pop() + if current is not None: + past_list.append(copy.deepcopy(current)) + return nxt, past_list, future_list + + +def downsample_rows(rows: list[dict[str, Any]], columns: list[str], max_points: int = 6000) -> tuple[np.ndarray, np.ndarray]: + """Extract axis/signal as float arrays; stride if very long.""" + if not rows: + return np.array([]), np.array([]) + t_key = "temperature" if "temperature" in columns else None + s_key = "signal" if "signal" in columns else None + if t_key is None or s_key is None: + return np.array([]), np.array([]) + t_vals: list[float] = [] + s_vals: list[float] = [] + for row in rows: + if not isinstance(row, dict): + continue + try: + tv = float(row.get(t_key)) + sv = float(row.get(s_key)) + except (TypeError, ValueError): + continue + if math.isfinite(tv) and math.isfinite(sv): + t_vals.append(tv) + s_vals.append(sv) + t_arr = np.asarray(t_vals, dtype=float) + s_arr = np.asarray(s_vals, dtype=float) + n = len(t_arr) + if n <= max_points or n == 0: + return t_arr, s_arr + step = int(math.ceil(n / max_points)) + return t_arr[::step], s_arr[::step] diff --git a/dash_app/components/literature_compare_ui.py b/dash_app/components/literature_compare_ui.py new file mode 100644 index 00000000..8affe1bd --- /dev/null +++ b/dash_app/components/literature_compare_ui.py @@ -0,0 +1,893 @@ +"""Shared literature compare rendering and layout for Dash analysis pages.""" + +from __future__ import annotations + +import re +from typing import Any + +import dash_bootstrap_components as dbc +from dash import html + +from utils.i18n import translate_ui + +# Default compact evidence layout for Dash analysis pages (matches TGA / DSC / DTA). +LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT = 2 +LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT = 1 + + +def coerce_literature_max_claims(value: Any, *, default: int = 3) -> int: + """Clamp manual max-claims input to 1..10.""" + try: + if value in (None, ""): + return max(1, default) + n = int(float(value)) + except (TypeError, ValueError): + return max(1, default) + return max(1, min(10, n)) + + +def build_literature_compare_card( + *, + id_prefix: str, + class_name: str = "mb-3", + compact_toolbar: bool = False, +) -> dbc.Card: + """Reusable literature compare card; element ids are ``{id_prefix}-literature-*``. + + When *compact_toolbar* is True, compare options are tucked behind a collapsed summary + so the card leads with title, hint, status, and output (used on XRD for calmer UX). + """ + controls_row = dbc.Row( + [ + dbc.Col( + [ + dbc.Label( + id=f"{id_prefix}-literature-max-claims-label", + html_for=f"{id_prefix}-literature-max-claims", + ), + dbc.Input( + id=f"{id_prefix}-literature-max-claims", + type="number", + min=1, + max=10, + step=1, + value=3, + ), + ], + md=6, + ), + dbc.Col( + [ + dbc.Checklist( + id=f"{id_prefix}-literature-persist", + options=[{"label": "", "value": "persist"}], + value=[], + switch=True, + className="mt-2", + ), + dbc.Label( + id=f"{id_prefix}-literature-persist-label", + html_for=f"{id_prefix}-literature-persist", + className="small", + ), + ], + md=6, + ), + ], + className="g-2 mb-2", + ) + compare_btn = dbc.Button( + id=f"{id_prefix}-literature-compare-btn", + color="primary", + size="sm", + disabled=True, + className="mb-2", + ) + if compact_toolbar: + options_block = html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span( + id=f"{id_prefix}-literature-options-summary", + className="ms-1 small fw-semibold", + ), + ], + className="ta-details-summary py-1", + ), + html.Div([controls_row, compare_btn], className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-2", + open=False, + ) + body_top: list[Any] = [ + html.H6(id=f"{id_prefix}-literature-card-title", className="card-title mb-2"), + html.Div(id=f"{id_prefix}-literature-hint", className="small text-muted mb-2"), + options_block, + ] + else: + body_top = [ + html.H5(id=f"{id_prefix}-literature-card-title", className="card-title mb-3"), + html.Div(id=f"{id_prefix}-literature-hint", className="small text-muted mb-2"), + controls_row, + compare_btn, + ] + return dbc.Card( + dbc.CardBody( + body_top + + [ + html.Div(id=f"{id_prefix}-literature-status", className="small text-muted"), + html.Div(id=f"{id_prefix}-literature-output", className="mt-2"), + ] + ), + className=class_name, + ) + + +_DOI_IN_TEXT = re.compile(r"(10\.\d{4,9}/[^\s\],;)}\]]+)", re.IGNORECASE) + + +def _clean_str(value: Any) -> str: + if value in (None, ""): + return "" + return str(value).strip() + + +def canonical_doi_string(raw: str) -> str: + """Strip wrappers and return a bare DOI path (``10.xxxx/...``) or empty string.""" + s = _clean_str(raw) + if not s: + return "" + low = s.lower() + for prefix in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/", "http://dx.doi.org/"): + if low.startswith(prefix): + s = s[len(prefix) :].strip() + low = s.lower() + break + if low.startswith("doi:"): + s = s[4:].lstrip() + return s.rstrip(").,]}\"").strip() + + +def doi_to_https_url(doi_or_raw: str) -> str | None: + """Normalize a DOI or doi-prefixed string to ``https://doi.org/...``.""" + d = canonical_doi_string(doi_or_raw) + if not d or not re.match(r"^10\.\d{4,9}/\S+$", d): + return None + return f"https://doi.org/{d}" + + +def normalize_http_url(url: str) -> str | None: + """Return an absolute http(s) URL, or None if *url* is missing or not usable.""" + u = _clean_str(url) + if not u: + return None + low = u.lower() + if low.startswith("//"): + return "https:" + u + if low.startswith("http://") or low.startswith("https://"): + return u + return None + + +def resolve_literature_href( + *, + direct_doi: str = "", + direct_url: str = "", + fallback_doi: str = "", + fallback_url: str = "", +) -> str | None: + """Pick the best external link: direct DOI, direct URL, citation DOI, citation URL.""" + dd = _clean_str(direct_doi) + du = _clean_str(direct_url) + fd = _clean_str(fallback_doi) + fu = _clean_str(fallback_url) + if dd: + u = doi_to_https_url(dd) + if u: + return u + if du: + u = normalize_http_url(du) + if u: + return u + if fd: + u = doi_to_https_url(fd) + if u: + return u + if fu: + u = normalize_http_url(fu) + if u: + return u + return None + + +def linkify_doi_fragments(text: str) -> list[Any]: + """Split *text* into strings and ``html.A`` nodes for bare DOI tokens.""" + if not text: + return [] + parts: list[Any] = [] + pos = 0 + for m in _DOI_IN_TEXT.finditer(text): + if m.start() > pos: + parts.append(text[pos : m.start()]) + display = m.group(0) + href = doi_to_https_url(m.group(1)) + if href: + parts.append( + html.A( + display, + href=href, + target="_blank", + rel="noopener noreferrer", + className="text-reset", + ) + ) + else: + parts.append(display) + pos = m.end() + if pos < len(text): + parts.append(text[pos:]) + return [p for p in parts if p != ""] + + +def citation_meta_children(entry: dict) -> list[Any]: + """Year / journal / DOI line as inline children; DOI (or bare URL) is linked when possible.""" + parts: list[Any] = [] + year = _clean_str(entry.get("year")) + journal = _clean_str(entry.get("journal")) + doi = _clean_str(entry.get("doi") or entry.get("paper_doi")) + url = _clean_str(entry.get("url") or entry.get("paper_url") or entry.get("link")) + + def _sep() -> None: + if parts: + parts.append(" | ") + + if year: + parts.append(year) + if journal: + _sep() + parts.append(journal) + href_d = doi_to_https_url(doi) if doi else None + href_u = normalize_http_url(url) if url else None + if doi and href_d: + _sep() + parts.append("DOI: ") + parts.append( + html.A( + doi, + href=href_d, + target="_blank", + rel="noopener noreferrer", + className="text-reset", + ) + ) + elif doi: + _sep() + parts.append(f"DOI: {doi}") + elif url and href_u: + _sep() + parts.append( + html.A( + url, + href=href_u, + target="_blank", + rel="noopener noreferrer", + className="text-reset text-break", + ) + ) + return parts + + +def literature_t(loc: str, key: str, fallback: str) -> str: + """Translate with a literal fallback when the key is missing from the bundle.""" + value = translate_ui(loc, key) + return fallback if value == key else value + + +def _collapsible_section( + loc: str, title_key: str, body: Any, *, open: bool = False, title_fallback: str = "Details" +) -> html.Details: + title_text = literature_t(loc, title_key, title_fallback) + return html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(title_text, className="ms-1"), + ], + className="ta-details-summary", + ), + html.Div(body, className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=open, + ) + + +def render_literature_output( + payload: dict, + loc: str, + *, + i18n_prefix: str, + evidence_preview_limit: int | None = None, + alternative_preview_limit: int | None = None, + collapse_retained_evidence: bool = False, +) -> html.Div: + """Render literature compare payload as curated product-facing sections. + + When *evidence_preview_limit* is set, only that many relevant retained references are + shown inline; the rest appear behind a collapsed details section. Dash analysis pages + pass the module constants LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT for a consistent + compact layout; pass None for the legacy full inline layout. + + When *collapse_retained_evidence* is True, the retained-reference sections are wrapped + in a collapsed ``Details`` so the first read stays on interpretation claims and previews. + """ + claims = payload.get("literature_claims") or [] + comparisons = payload.get("literature_comparisons") or [] + citations = payload.get("citations") or [] + context = payload.get("literature_context") if isinstance(payload.get("literature_context"), dict) else {} + + def _k(suffix: str) -> str: + return f"{i18n_prefix}.{suffix}" + + def _clean_text(value) -> str: + if value in (None, ""): + return "" + return str(value).strip() + + def _entry_text(entry: dict) -> str: + text = ( + entry.get("claim_text") + or entry.get("statement") + or entry.get("claim") + or entry.get("title") + or entry.get("summary") + or entry.get("comparison_note") + or entry.get("rationale") + or "" + ) + return _clean_text(text) + + def _is_meaningful_entry(entry: dict) -> bool: + if _entry_text(entry): + return True + for key in ("doi", "source", "provider", "citation_id", "paper_title", "paper_doi"): + if _clean_text(entry.get(key)): + return True + return False + + def _context_token(key: str) -> str: + return _clean_text(context.get(key)).lower() + + def _context_bool(key: str) -> bool: + return bool(context.get(key)) + + def _citation_title(citation: dict) -> str: + return _clean_text( + citation.get("title") + or citation.get("paper_title") + or citation.get("citation_text") + or citation.get("doi") + or citation.get("url") + ) + + def _reason_text() -> str: + status_token = _context_token("provider_query_status") + reason_token = _context_token("no_results_reason") + token = reason_token or status_token + if token == "provider_unavailable": + return literature_t(loc, _k("status.reason.provider_unavailable"), "") + if token == "request_failed": + return literature_t(loc, _k("status.reason.request_failed"), "") + if token == "not_configured": + return literature_t(loc, _k("status.reason.not_configured"), "") + if token == "query_too_narrow": + return literature_t(loc, _k("status.reason.query_too_narrow"), "") + return literature_t(loc, _k("status.reason.no_retained"), "") + + def _comparison_citation_ids(entry: dict) -> list[str]: + raw_ids = entry.get("citation_ids") or [] + if isinstance(raw_ids, str): + raw_ids = [raw_ids] + return [_clean_text(item) for item in raw_ids if _clean_text(item)] + + citation_by_id: dict[str, dict] = {} + for entry in citations: + if not isinstance(entry, dict): + continue + citation_id = _clean_text(entry.get("citation_id")) + if citation_id and citation_id not in citation_by_id: + citation_by_id[citation_id] = entry + + retained_rows: list[dict[str, Any]] = [] + consumed_citation_ids: set[str] = set() + + for entry in comparisons: + if not isinstance(entry, dict) or not _is_meaningful_entry(entry): + continue + citation_ids = _comparison_citation_ids(entry) + linked_citations = [citation_by_id[citation_id] for citation_id in citation_ids if citation_id in citation_by_id] + consumed_citation_ids.update(citation_ids) + + title = _entry_text(entry) + if not title and linked_citations: + title = _citation_title(linked_citations[0]) + if not title: + title = literature_t(loc, _k("evidence.generic_title"), "Retained literature reference") + + provider = _clean_text(entry.get("provider") or entry.get("provider_id") or entry.get("source")) + rationale = _clean_text(entry.get("rationale") or entry.get("comparison_note")) + citation_titles = [_citation_title(item) for item in linked_citations if _citation_title(item)] + + support = _clean_text(entry.get("support_label")).lower() + posture = _clean_text(entry.get("validation_posture")).lower() + is_alternative = support == "contradicts" or posture == "alternative_interpretation" + + entry_doi = _clean_text(entry.get("doi") or entry.get("paper_doi")) + entry_url = _clean_text(entry.get("url") or entry.get("paper_url") or entry.get("link")) + fc = linked_citations[0] if linked_citations else {} + cite_doi = _clean_text(fc.get("doi") or fc.get("paper_doi")) if fc else "" + cite_url = _clean_text(fc.get("url") or fc.get("paper_url") or fc.get("link")) if fc else "" + row_href = resolve_literature_href( + direct_doi=entry_doi, + direct_url=entry_url, + fallback_doi=cite_doi, + fallback_url=cite_url, + ) + + retained_rows.append( + { + "title": title, + "provider": provider, + "rationale": rationale, + "citation_titles": citation_titles, + "is_alternative": is_alternative, + "href": row_href, + } + ) + + for entry in citations: + if not isinstance(entry, dict) or not _is_meaningful_entry(entry): + continue + citation_id = _clean_text(entry.get("citation_id")) + if citation_id and citation_id in consumed_citation_ids: + continue + title = _citation_title(entry) + if not title: + continue + provider = _clean_text(entry.get("provider") or entry.get("source")) + provenance = entry.get("provenance") + if not provider and isinstance(provenance, dict): + provider = _clean_text(provenance.get("provider_id")) + cite_href = resolve_literature_href( + direct_doi=_clean_text(entry.get("doi") or entry.get("paper_doi")), + direct_url=_clean_text(entry.get("url") or entry.get("paper_url") or entry.get("link")), + ) + meta_nodes = citation_meta_children(entry) + retained_rows.append( + { + "title": title, + "provider": provider, + "rationale": "", + "rationale_nodes": meta_nodes, + "citation_titles": [], + "is_alternative": False, + "href": cite_href, + } + ) + + relevant_rows = [row for row in retained_rows if not row.get("is_alternative")] + alternative_rows = [row for row in retained_rows if row.get("is_alternative")] + has_retained_evidence = bool(relevant_rows or alternative_rows) + + alt_limit = alternative_preview_limit + if alt_limit is None and evidence_preview_limit is not None: + alt_limit = 1 + + compact_lit = evidence_preview_limit is not None + claims_cap = 2 if compact_lit else None + + claim_items: list[html.Li] = [] + for entry in claims: + if not isinstance(entry, dict): + continue + text = _entry_text(entry) + if text: + claim_items.append(html.Li(text, className="small mb-0")) + + def _render_evidence_rows(rows: list[dict[str, Any]], *, compact: bool = False) -> list[html.Div]: + if compact: + row_class = "mb-1 pb-1 border-bottom ta-literature-evidence-compact py-0" + else: + row_class = "border rounded p-2 mb-2" + rendered: list[html.Div] = [] + for row in rows: + meta_parts: list[str] = [] + if row.get("provider"): + meta_parts.append( + literature_t(loc, _k("evidence.provider_prefix"), "Source: {source}").replace("{source}", str(row["provider"])) + ) + citation_titles = row.get("citation_titles") or [] + if citation_titles: + cite_summary = ", ".join(citation_titles[:2]) + if len(citation_titles) > 2: + cite_summary += "..." + meta_parts.append( + literature_t(loc, _k("evidence.citations_prefix"), "Linked citations: {titles}").replace("{titles}", cite_summary) + ) + title_cls = "fw-semibold small lh-sm mb-0" if compact else "fw-semibold small" + title_link_cls = f"{title_cls} link-primary text-decoration-underline" + rationale_cls = "small text-muted mb-0 lh-sm mt-1" if compact else "small mb-1" + meta_cls = "small text-muted mb-0 lh-sm mt-1" if compact else "small text-muted mb-0" + href = row.get("href") + title = row.get("title") or "" + title_el: Any = ( + html.A( + title, + href=href, + target="_blank", + rel="noopener noreferrer", + className=title_link_cls, + ) + if href + else html.Div(title, className=title_cls) + ) + rat_nodes = row.get("rationale_nodes") + if rat_nodes is not None: + rat_el = html.P(rat_nodes, className=rationale_cls) if rat_nodes else None + elif row.get("rationale"): + chunks = linkify_doi_fragments(str(row["rationale"])) + rat_el = html.P(chunks, className=rationale_cls) if chunks else None + else: + rat_el = None + rendered.append( + html.Div( + [ + title_el, + rat_el, + html.P(" | ".join(meta_parts), className=meta_cls) if meta_parts else None, + ], + className=row_class, + ) + ) + return rendered + + def _evidence_rows_block(rows: list[dict[str, Any]], preview_limit: int | None) -> html.Div: + if not rows: + return html.Div() + use_compact = preview_limit is not None + if preview_limit is None or len(rows) <= preview_limit: + return html.Div(_render_evidence_rows(rows, compact=use_compact)) + head = rows[:preview_limit] + tail = rows[preview_limit:] + n_more = len(tail) + show_more_key = _k("evidence_show_more") + formatted = translate_ui(loc, show_more_key, n=n_more) + show_more_label = formatted if formatted != show_more_key else f"Show {n_more} more references" + return html.Div( + [ + html.Div(_render_evidence_rows(head, compact=True)), + html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(show_more_label, className="ms-1 small fw-semibold text-primary"), + html.Span( + literature_t(loc, _k("evidence_show_more_hint"), " (expand)"), + className="small text-muted ms-1", + ), + ], + className="ta-details-summary py-1 border border-secondary-subtle rounded px-2", + ), + html.Div( + html.Div(_render_evidence_rows(tail, compact=True)), + className="ta-details-body mt-2", + ), + ], + className="ta-ms-details mb-0 mt-2", + open=False, + ), + ] + ) + + children: list[Any] = [] + + if claim_items: + if compact_lit and claims_cap is not None and len(claim_items) > claims_cap: + head_li, tail_li = claim_items[:claims_cap], claim_items[claims_cap:] + n_claim_more = len(tail_li) + claims_more_key = _k("claims_show_more") + claims_more_fmt = translate_ui(loc, claims_more_key, n=n_claim_more) + claims_more_label = claims_more_fmt if claims_more_fmt != claims_more_key else f"Show {n_claim_more} more claims" + claims_block = html.Div( + [ + html.Div( + literature_t(loc, _k("claims_generated"), "Generated interpretation claims"), + className="small fw-semibold mt-2 mb-1", + ), + html.P( + literature_t( + loc, + _k("claims_note_compact"), + "Model-generated bullets; not external literature.", + ), + className="small text-muted mb-1 lh-sm", + ), + html.Ul(head_li, className="mb-1 ps-3 small"), + html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(claims_more_label, className="ms-1 small fw-semibold text-primary"), + ], + className="ta-details-summary py-1 border border-secondary-subtle rounded px-2", + ), + html.Div(html.Ul(tail_li, className="mb-0 ps-3 small"), className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=False, + ), + ], + className="mb-2", + ) + elif compact_lit: + claims_block = html.Div( + [ + html.Div( + literature_t(loc, _k("claims_generated"), "Generated interpretation claims"), + className="small fw-semibold mt-2 mb-1", + ), + html.P( + literature_t( + loc, + _k("claims_note_compact"), + "Model-generated bullets; not external literature.", + ), + className="small text-muted mb-1 lh-sm", + ), + html.Ul(claim_items, className="mb-0 ps-3 small"), + ], + className="mb-2", + ) + else: + claims_block = html.Div( + [ + html.H6(literature_t(loc, _k("claims_generated"), "Generated interpretation claims"), className="mt-2 mb-1"), + html.P( + literature_t( + loc, + _k("claims_note"), + "These claims are generated from the analysis interpretation and are not retained external literature evidence on their own.", + ), + className="small text-muted mb-1", + ), + html.Ul(claim_items, className="mb-0 ps-3"), + ], + className="mb-3", + ) + children.append(claims_block) + + retained_inner = html.Div( + [ + html.H6(literature_t(loc, _k("retained_evidence_title"), "Retained literature evidence"), className="mt-2 mb-1"), + html.Div( + [ + html.H6(literature_t(loc, _k("relevant_references"), "Relevant retained references"), className="mt-2 mb-1"), + _evidence_rows_block(relevant_rows, evidence_preview_limit) + if relevant_rows + else html.P( + literature_t(loc, _k("relevant_references_empty"), "No relevant retained references were found."), + className="small text-muted mb-1", + ), + ] + ), + html.Div( + [ + html.H6( + literature_t(loc, _k("alternative_references"), "Alternative or non-validating references"), + className="mt-2 mb-1", + ), + _evidence_rows_block(alternative_rows, alt_limit) + if alternative_rows + else html.P( + literature_t(loc, _k("alternative_references_empty"), "No alternative or non-validating references were retained."), + className="small text-muted mb-1", + ), + ] + ), + ], + className="mb-2", + ) + if collapse_retained_evidence: + n_refs = len(relevant_rows) + len(alternative_rows) + show_refs_key = _k("evidence_list_summary") + show_refs = translate_ui(loc, show_refs_key, n=n_refs) + if show_refs == show_refs_key: + show_refs = literature_t(loc, show_refs_key, "Full reference evidence ({n})").replace("{n}", str(n_refs)) + retained_block = html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(show_refs, className="ms-1 small fw-semibold"), + ], + className="ta-details-summary py-1", + ), + html.Div(retained_inner, className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-2", + open=False, + ) + else: + retained_block = retained_inner + children.append(retained_block) + + if not has_retained_evidence: + follow_up: list[str] = [] + reason_token = _context_token("no_results_reason") or _context_token("provider_query_status") + if reason_token in {"query_too_narrow"} or _context_bool("low_specificity_retrieval"): + follow_up.append(literature_t(loc, _k("follow_up.refine_query"), "")) + if reason_token in {"provider_unavailable", "request_failed", "not_configured"}: + follow_up.append(literature_t(loc, _k("follow_up.retry_provider"), "")) + if _context_bool("metadata_only_evidence") or not _context_bool("real_literature_available"): + follow_up.append(literature_t(loc, _k("follow_up.add_accessible_sources"), "")) + follow_up = [x for x in follow_up if x][:2] + children.append( + html.Div( + [ + html.H6(literature_t(loc, _k("no_evidence_title"), "No retained literature evidence"), className="mt-2 mb-1"), + html.P(_reason_text(), className="small text-muted mb-1"), + html.Ul([html.Li(item, className="small") for item in follow_up], className="mb-0 ps-3") if follow_up else None, + ], + className="mt-2", + ) + ) + + technical_rows: list[Any] = [] + + def _technical_line(key_suffix: str, fallback: str, value) -> None: + text = _clean_text(value) + if not text: + return + technical_rows.append( + html.Li( + [ + html.Strong(f"{literature_t(loc, _k(key_suffix), fallback)}: "), + text, + ], + className="small", + ) + ) + + _technical_line("technical.provider_status", "Provider status", context.get("provider_query_status")) + _technical_line("technical.no_results_reason", "No-results reason", context.get("no_results_reason")) + if context.get("source_count") is not None: + _technical_line("technical.source_count", "Source count", context.get("source_count")) + if context.get("citation_count") is not None: + _technical_line("technical.citation_count", "Citation count", context.get("citation_count")) + _technical_line("technical.provider_note", "Provider note", context.get("provider_error_message")) + _technical_line("technical.query", "Technical query", context.get("query_text")) + _technical_line("technical.search_mode", "Search mode", context.get("search_mode")) + _technical_line("technical.subject_trust", "Subject trust", context.get("subject_trust")) + display_terms = context.get("query_display_terms") + if display_terms: + _technical_line("technical.display_terms", "Display terms", ", ".join(str(t) for t in display_terms if t)) + executed_queries_list = context.get("executed_queries") or [] + if len(executed_queries_list) > 1: + fallback_text = "; ".join(executed_queries_list[1:]) + _technical_line("technical.fallback_queries", "Fallback queries", fallback_text) + + if technical_rows: + children.append( + html.Div( + _collapsible_section( + loc, + _k("technical_details_title"), + html.Ul(technical_rows, className="mb-0 ps-3"), + open=False, + title_fallback="Technical search details", + ), + className="mt-2", + ) + ) + + return html.Div(children) + + +def literature_compare_status_alert(payload: dict, loc: str, *, i18n_prefix: str) -> dbc.Alert: + """Build the summary status alert for a literature compare response.""" + + def _k(suffix: str) -> str: + return f"{i18n_prefix}.{suffix}" + + def _entry_text(entry: dict) -> str: + text = ( + entry.get("claim_text") + or entry.get("statement") + or entry.get("claim") + or entry.get("title") + or entry.get("summary") + or "" + ) + return str(text).strip() + + def _has_meaningful_entries(items) -> bool: + for item in items or []: + if not isinstance(item, dict): + continue + if _entry_text(item): + return True + if str(item.get("doi") or "").strip(): + return True + if str(item.get("source") or "").strip(): + return True + if str(item.get("provider") or "").strip(): + return True + return False + + context = payload.get("literature_context") if isinstance(payload.get("literature_context"), dict) else {} + has_claims = _has_meaningful_entries(payload.get("literature_claims")) + has_retained_comparisons = _has_meaningful_entries(payload.get("literature_comparisons")) + has_retained_citations = _has_meaningful_entries(payload.get("citations")) + has_retained_evidence = has_retained_comparisons or has_retained_citations + limited_evidence = has_retained_evidence and bool( + context.get("low_specificity_retrieval") or context.get("metadata_only_evidence") + ) + + status_token = str(context.get("provider_query_status") or "").strip().lower() + reason_token = str(context.get("no_results_reason") or "").strip().lower() + state_token = reason_token or status_token + + if state_token == "provider_unavailable": + reason_text = literature_t(loc, _k("status.reason.provider_unavailable"), "") + elif state_token == "request_failed": + reason_text = literature_t(loc, _k("status.reason.request_failed"), "") + elif state_token == "not_configured": + reason_text = literature_t(loc, _k("status.reason.not_configured"), "") + elif state_token == "query_too_narrow": + reason_text = literature_t(loc, _k("status.reason.query_too_narrow"), "") + else: + reason_text = literature_t(loc, _k("status.reason.no_retained"), "") + + if has_retained_evidence and not limited_evidence: + headline = literature_t(loc, _k("status.evidence_found"), "Retained literature evidence was found.") + detail = literature_t(loc, _k("status.evidence_found_detail"), "Use retained references as contextual support for this interpretation.") + color = "success" + elif limited_evidence: + headline = literature_t(loc, _k("status.limited_evidence"), "Retained literature evidence is limited.") + detail = literature_t(loc, _k("status.limited_evidence_detail"), "") + color = "info" + elif has_claims: + headline = literature_t(loc, _k("status.claims_without_evidence"), "") + detail = reason_text + color = "warning" + else: + headline = literature_t(loc, _k("status.no_evidence"), "") + detail = reason_text + color = "warning" + + if state_token == "not_configured" and not has_retained_evidence and color == "warning": + color = "danger" + + alert_children: list = [html.Div(html.Strong(headline)), html.Div(detail, className="small mt-1")] + if state_token == "not_configured": + setup_hint = literature_t( + loc, + _k("status.not_configured_setup_hint"), + "Set MATERIALSCOPE_OPENALEX_EMAIL (recommended) or MATERIALSCOPE_OPENALEX_API_KEY in the server environment, " + "or enable demo fixtures with MATERIALSCOPE_LITERATURE_FIXTURE_FALLBACK=1. Restart the app after changing environment variables.", + ) + if setup_hint: + alert_children.append( + html.Div(setup_hint, className="small mt-2 border-start border-3 ps-2 text-body-secondary"), + ) + + return dbc.Alert( + alert_children, + color=color, + className="py-2 small mb-2", + ) diff --git a/dash_app/components/page_guidance.py b/dash_app/components/page_guidance.py new file mode 100644 index 00000000..59e80ddb --- /dev/null +++ b/dash_app/components/page_guidance.py @@ -0,0 +1,74 @@ +"""Reusable guidance blocks for non-analysis Dash pages.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import dash_bootstrap_components as dbc +from dash import html + +from utils.i18n import normalize_ui_locale, translate_ui + + +def guidance_block( + title: str, + body: str | None = None, + bullets: Sequence[str] | None = None, + *, + tone: str = "info", +) -> dbc.Alert: + """Generic guidance callout with optional body and bullet list.""" + tone_key = str(tone or "info").lower() + children: list = [html.H6(title, className="ta-guidance-title mb-2")] + if body: + children.append(html.P(body, className=f"ta-guidance-body {'mb-2' if bullets else 'mb-0'}")) + if bullets: + items = [item for item in bullets if item] + if items: + children.append( + html.Ul( + [html.Li(item, className="ta-guidance-item") for item in items], + className="ta-guidance-list mb-0 ps-3", + ) + ) + return dbc.Alert(children, color=tone_key, className=f"ta-guidance ta-guidance--{tone_key} mb-3") + + +def typical_workflow_block( + steps: Sequence[str], + *, + title: str | None = None, + locale: str | None = None, +) -> dbc.Alert: + """Ordered step-by-step workflow guidance.""" + loc = normalize_ui_locale(locale) + resolved_title = title if title is not None else translate_ui(loc, "dash.guidance.typical_workflow_title") + items = [step for step in steps if step] + return dbc.Alert( + [ + html.H6(resolved_title, className="ta-guidance-title mb-2"), + html.Ol([html.Li(step, className="ta-guidance-item") for step in items], className="ta-guidance-list mb-0 ps-3"), + ], + color="secondary", + className="ta-guidance ta-guidance--secondary ta-guidance--workflow mb-3", + ) + + +def next_step_block(text: str, *, title: str | None = None, locale: str | None = None) -> dbc.Alert: + """Short recommended next action for the current page state.""" + loc = normalize_ui_locale(locale) + resolved_title = title if title is not None else translate_ui(loc, "dash.guidance.next_step_title") + return guidance_block(resolved_title, body=text, tone="info") + + +def prereq_or_empty_help( + text: str, + *, + tone: str = "warning", + title: str | None = None, + locale: str | None = None, +) -> dbc.Alert: + """Actionable prerequisite or empty-state guidance.""" + loc = normalize_ui_locale(locale) + resolved_title = title if title is not None else translate_ui(loc, "dash.guidance.prereq_title") + return guidance_block(resolved_title, body=text, tone=tone) diff --git a/dash_app/components/processing_inputs.py b/dash_app/components/processing_inputs.py new file mode 100644 index 00000000..dc002e4e --- /dev/null +++ b/dash_app/components/processing_inputs.py @@ -0,0 +1,39 @@ +"""Shared processing-control input coercion helpers.""" + +from __future__ import annotations + +import math + + +def coerce_int_positive(value, *, default: int, minimum: int) -> int: + try: + if value in (None, ""): + return max(default, minimum) + parsed = int(float(value)) + except (TypeError, ValueError): + return max(default, minimum) + return max(parsed, minimum) + + +def coerce_float_positive(value, *, default: float, minimum: float) -> float: + try: + if value in (None, ""): + return max(default, minimum) + parsed = float(value) + except (TypeError, ValueError): + return max(default, minimum) + if not math.isfinite(parsed): + return max(default, minimum) + return max(parsed, minimum) + + +def coerce_float_non_negative(value, *, default: float) -> float: + try: + if value in (None, ""): + return max(default, 0.0) + parsed = float(value) + except (TypeError, ValueError): + return max(default, 0.0) + if not math.isfinite(parsed) or parsed < 0: + return max(default, 0.0) + return parsed diff --git a/dash_app/components/raman_explore.py b/dash_app/components/raman_explore.py new file mode 100644 index 00000000..ec417192 --- /dev/null +++ b/dash_app/components/raman_explore.py @@ -0,0 +1,108 @@ +"""RAMAN Dash exploration helpers: undo stacks and lightweight data helpers. + +Reuses the same patterns established by TGA exploration helpers. +""" + +from __future__ import annotations + +import copy +import math +from typing import Any + +import numpy as np + +MAX_RAMAN_UNDO_DEPTH = 25 + + +def raman_draft_processing_equal(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + """Deep-compare normalized RAMAN processing draft payloads.""" + if not isinstance(a, dict) or not isinstance(b, dict): + return a == b + try: + import json + + def norm(d: dict[str, Any]) -> str: + return json.dumps(d, sort_keys=True, default=str) + + return norm(a) == norm(b) + except Exception: + return a == b + + +def append_undo_after_edit( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + old_draft: dict[str, Any] | None, + new_draft: dict[str, Any], + *, + max_depth: int = MAX_RAMAN_UNDO_DEPTH, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """After a user edit, push *old_draft* onto past and clear redo when draft actually changes.""" + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + if old_draft is None or raman_draft_processing_equal(old_draft, new_draft): + return past_list, [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + past_list.append(copy.deepcopy(old_draft)) + if len(past_list) > max_depth: + past_list = past_list[-max_depth:] + return past_list, [] + + +def perform_undo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not past: + return None + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + previous = past_list.pop() + if current is not None: + future_list.append(copy.deepcopy(current)) + return previous, past_list, future_list + + +def perform_redo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not future: + return None + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in future if isinstance(x, dict)] + nxt = future_list.pop() + if current is not None: + past_list.append(copy.deepcopy(current)) + return nxt, past_list, future_list + + +def downsample_rows(rows: list[dict[str, Any]], columns: list[str], max_points: int = 6000) -> tuple[np.ndarray, np.ndarray]: + """Extract axis/signal as float arrays; stride if very long.""" + if not rows: + return np.array([]), np.array([]) + t_key = "temperature" if "temperature" in columns else None + s_key = "signal" if "signal" in columns else None + if t_key is None or s_key is None: + return np.array([]), np.array([]) + t_vals: list[float] = [] + s_vals: list[float] = [] + for row in rows: + if not isinstance(row, dict): + continue + try: + tv = float(row.get(t_key)) + sv = float(row.get(s_key)) + except (TypeError, ValueError): + continue + if math.isfinite(tv) and math.isfinite(sv): + t_vals.append(tv) + s_vals.append(sv) + t_arr = np.asarray(t_vals, dtype=float) + s_arr = np.asarray(s_vals, dtype=float) + n = len(t_arr) + if n <= max_points or n == 0: + return t_arr, s_arr + step = int(math.ceil(n / max_points)) + return t_arr[::step], s_arr[::step] + diff --git a/dash_app/components/spectral_explore.py b/dash_app/components/spectral_explore.py new file mode 100644 index 00000000..900c9a3b --- /dev/null +++ b/dash_app/components/spectral_explore.py @@ -0,0 +1,177 @@ +"""Shared raw-data exploration helpers for spectral Dash pages.""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from dash import html + +from utils.i18n import translate_ui + + +def downsample_spectral_rows(rows: list[dict[str, Any]], columns: list[str], max_points: int = 6000) -> tuple[np.ndarray, np.ndarray]: + """Extract spectral axis and signal arrays from workspace rows.""" + if not rows: + return np.array([]), np.array([]) + axis_key = "temperature" if "temperature" in columns else "wavenumber" if "wavenumber" in columns else None + signal_key = "signal" if "signal" in columns else None + if axis_key is None or signal_key is None: + return np.array([]), np.array([]) + + axis_vals: list[float] = [] + signal_vals: list[float] = [] + for row in rows: + if not isinstance(row, dict): + continue + try: + av = float(row.get(axis_key)) + sv = float(row.get(signal_key)) + except (TypeError, ValueError): + continue + if math.isfinite(av) and math.isfinite(sv): + axis_vals.append(av) + signal_vals.append(sv) + + axis = np.asarray(axis_vals, dtype=float) + signal = np.asarray(signal_vals, dtype=float) + n = len(axis) + if n <= max_points or n == 0: + return axis, signal + step = int(math.ceil(n / max_points)) + return axis[::step], signal[::step] + + +def compute_spectral_raw_quality_stats( + axis: np.ndarray, + signal: np.ndarray, + *, + validation: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Pre-run spectral data stats and hints without Streamlit dependencies.""" + out: dict[str, Any] = {"warnings": [], "hints": [], "checks": {}, "validation_messages": []} + val = validation if isinstance(validation, dict) else {} + checks = val.get("checks") if isinstance(val.get("checks"), dict) else {} + out["checks"] = dict(checks) + + for key in ("warnings", "issues"): + for item in val.get(key) or []: + if isinstance(item, str) and item.strip(): + out["validation_messages"].append(item.strip()) + + if axis.size == 0 or signal.size == 0 or axis.size != signal.size: + out["warnings"] = ["missing_series"] + return out + + a = axis.astype(float) + s = signal.astype(float) + valid_mask = np.isfinite(a) & np.isfinite(s) + missing = int(len(a) - np.sum(valid_mask)) + a = a[valid_mask] + s = s[valid_mask] + n = int(a.size) + out["point_count"] = n + out["missing_count"] = missing + if n < 2: + out["warnings"] = ["too_few_points"] + return out + + out["axis_min"] = float(a.min()) + out["axis_max"] = float(a.max()) + out["signal_min"] = float(s.min()) + out["signal_max"] = float(s.max()) + + da = np.diff(a) + pos = int(np.sum(da > 0)) + neg = int(np.sum(da < 0)) + zero = int(np.sum(da == 0)) + dom = max(pos, neg, zero) + if dom < len(da) * 0.85: + out["hints"].append("axis_non_monotonic") + elif neg > pos: + out["hints"].append("axis_mostly_decreasing") + elif pos > neg: + out["hints"].append("axis_mostly_increasing") + + nonzero_spacing = np.abs(da[np.abs(da) > 1e-12]) + if nonzero_spacing.size > 1: + spacing_cv = float(np.std(nonzero_spacing) / max(abs(float(np.mean(nonzero_spacing))), 1e-12)) + else: + spacing_cv = 0.0 + out["spacing_cv"] = spacing_cv + if spacing_cv > 0.15: + out["hints"].append("irregular_spacing") + elif spacing_cv > 0.05: + out["hints"].append("somewhat_irregular_spacing") + + if n > 2 and float(np.ptp(s)) > 1e-12: + coeffs = np.polyfit(np.arange(n), s, 1) + drift = abs(float(coeffs[0])) * n / float(np.ptp(s)) + else: + drift = 0.0 + out["baseline_drift"] = drift + if drift > 0.5: + out["hints"].append("strong_baseline_drift") + elif drift > 0.2: + out["hints"].append("moderate_baseline_drift") + return out + + +def build_spectral_raw_quality_panel( + stats: dict[str, Any], + loc: str, + *, + i18n_prefix: str, + signal_unit: str, +) -> html.Div: + """Render raw spectral quality stats with modality-owned copy.""" + first_warning = (stats.get("warnings") or [None])[0] + if first_warning in {"missing_series", "too_few_points"}: + return html.Div( + html.P(translate_ui(loc, f"{i18n_prefix}.empty_{first_warning}"), className="text-muted small mb-0"), + className="ms-spectral-raw-quality-inner", + ) + + lines: list[Any] = [html.Li(translate_ui(loc, f"{i18n_prefix}.stat_points", n=int(stats.get("point_count") or 0)))] + if stats.get("axis_min") is not None and stats.get("axis_max") is not None: + lines.append( + html.Li( + translate_ui( + loc, + f"{i18n_prefix}.stat_axis_range", + a0=float(stats["axis_min"]), + a1=float(stats["axis_max"]), + ) + ) + ) + if stats.get("signal_min") is not None and stats.get("signal_max") is not None: + lines.append( + html.Li( + translate_ui( + loc, + f"{i18n_prefix}.stat_signal_range", + s0=float(stats["signal_min"]), + s1=float(stats["signal_max"]), + u=signal_unit, + ) + ) + ) + lines.append(html.Li(translate_ui(loc, f"{i18n_prefix}.stat_missing", n=int(stats.get("missing_count") or 0)))) + lines.append(html.Li(translate_ui(loc, f"{i18n_prefix}.stat_baseline_drift", drift=f"{float(stats.get('baseline_drift') or 0):.3f}"))) + lines.append(html.Li(translate_ui(loc, f"{i18n_prefix}.stat_spacing_cv", cv=f"{float(stats.get('spacing_cv') or 0):.3f}"))) + + hint_items = [ + html.Li(translate_ui(loc, f"{i18n_prefix}.hint.{str(h)}"), className="small") + for h in (stats.get("hints") or []) + ] + warn_items = [html.Li(str(msg), className="small text-warning") for msg in (stats.get("validation_messages") or [])] + + return html.Div( + [ + html.Ul(lines, className="small mb-2 ps-3"), + html.Ul(hint_items, className="small mb-2 ps-3 text-muted") if hint_items else None, + html.Ul(warn_items, className="small mb-0 ps-3") if warn_items else None, + ], + className="ms-spectral-raw-quality-inner", + ) diff --git a/dash_app/components/spectral_plot_settings.py b/dash_app/components/spectral_plot_settings.py new file mode 100644 index 00000000..b7ffb587 --- /dev/null +++ b/dash_app/components/spectral_plot_settings.py @@ -0,0 +1,320 @@ +"""Shared spectral plot display settings for FTIR/Raman Dash pages.""" + +from __future__ import annotations + +from typing import Any, Mapping + +import dash_bootstrap_components as dbc +from dash import dcc, html + +from utils.i18n import normalize_ui_locale, translate_ui + + +_SPECTRAL_PLOT_FALLBACK = { + "legend_mode": "auto", + "compact": False, + "show_grid": True, + "show_spikes": True, + "line_width_scale": 1.0, + "marker_size_scale": 1.0, + "export_scale": 2, + "reverse_x_axis": True, + "x_range_enabled": False, + "x_min": None, + "x_max": None, + "y_range_enabled": False, + "y_min": None, + "y_max": None, + "show_raw": True, + "show_smoothed": True, + "show_corrected": True, + "show_normalized": True, + "show_peaks": True, +} + +_SPECTRAL_PLOT_I18N_PREFIX = "dash.analysis.spectral_plot" +_LEGEND_OPTION_KEYS = ( + ("auto", "legend_auto"), + ("external", "legend_external_right"), + ("compact", "legend_compact"), + ("hidden", "legend_hidden"), +) + + +def spectral_plot_settings_chrome(locale: str | None) -> dict[str, Any]: + loc = normalize_ui_locale(locale) + pfx = _SPECTRAL_PLOT_I18N_PREFIX + return { + "card_title": translate_ui(loc, f"{pfx}.card_title"), + "card_hint": translate_ui(loc, f"{pfx}.card_hint"), + "legend_label": translate_ui(loc, f"{pfx}.legend"), + "legend_options": [ + {"label": translate_ui(loc, f"{pfx}.{key}"), "value": value} + for value, key in _LEGEND_OPTION_KEYS + ], + "compact_label": translate_ui(loc, f"{pfx}.compact"), + "show_grid_label": translate_ui(loc, f"{pfx}.show_grid"), + "show_spikes_label": translate_ui(loc, f"{pfx}.show_spikes"), + "reverse_x_axis_label": translate_ui(loc, f"{pfx}.reverse_x_axis"), + "export_scale_label": translate_ui(loc, f"{pfx}.export_scale"), + "line_width_label": translate_ui(loc, f"{pfx}.line_width"), + "marker_size_label": translate_ui(loc, f"{pfx}.marker_size"), + "show_raw_label": translate_ui(loc, f"{pfx}.show_raw"), + "show_smoothed_label": translate_ui(loc, f"{pfx}.show_smoothed"), + "show_corrected_label": translate_ui(loc, f"{pfx}.show_corrected"), + "show_normalized_label": translate_ui(loc, f"{pfx}.show_normalized"), + "show_peaks_label": translate_ui(loc, f"{pfx}.show_peaks"), + "x_lock_label": translate_ui(loc, f"{pfx}.x_lock"), + "y_lock_label": translate_ui(loc, f"{pfx}.y_lock"), + "x_min_placeholder": translate_ui(loc, f"{pfx}.x_min"), + "x_max_placeholder": translate_ui(loc, f"{pfx}.x_max"), + "y_min_placeholder": translate_ui(loc, f"{pfx}.y_min"), + "y_max_placeholder": translate_ui(loc, f"{pfx}.y_max"), + } + + +def _bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return bool(value) if value not in (None, "") else fallback + + +def _float(value: Any, fallback: float, minimum: float, maximum: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = fallback + return max(minimum, min(maximum, parsed)) + + +def _int(value: Any, fallback: int, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = fallback + return max(minimum, min(maximum, parsed)) + + +def _optional_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def normalize_spectral_plot_settings(payload: Mapping[str, Any] | None) -> dict[str, Any]: + source = payload if isinstance(payload, Mapping) else {} + settings = dict(_SPECTRAL_PLOT_FALLBACK) + legend = str(source.get("legend_mode") or settings["legend_mode"]).strip().lower() + settings["legend_mode"] = legend if legend in {"auto", "external", "compact", "hidden"} else "auto" + for key in ( + "compact", + "show_grid", + "show_spikes", + "reverse_x_axis", + "x_range_enabled", + "y_range_enabled", + "show_raw", + "show_smoothed", + "show_corrected", + "show_normalized", + "show_peaks", + ): + settings[key] = _bool(source.get(key), bool(settings[key])) + settings["line_width_scale"] = _float(source.get("line_width_scale"), 1.0, 0.6, 1.8) + settings["marker_size_scale"] = _float(source.get("marker_size_scale"), 1.0, 0.6, 1.8) + settings["export_scale"] = _int(source.get("export_scale"), 2, 1, 4) + settings["x_min"] = _optional_float(source.get("x_min")) + settings["x_max"] = _optional_float(source.get("x_max")) + settings["y_min"] = _optional_float(source.get("y_min")) + settings["y_max"] = _optional_float(source.get("y_max")) + if ( + settings["x_range_enabled"] + and settings["x_min"] is not None + and settings["x_max"] is not None + and settings["x_min"] > settings["x_max"] + ): + settings["x_min"], settings["x_max"] = settings["x_max"], settings["x_min"] + if ( + settings["y_range_enabled"] + and settings["y_min"] is not None + and settings["y_max"] is not None + and settings["y_min"] > settings["y_max"] + ): + settings["y_min"], settings["y_max"] = settings["y_max"], settings["y_min"] + return settings + + +def spectral_plot_settings_from_controls( + legend_mode, + compact, + show_grid, + show_spikes, + line_width_scale, + marker_size_scale, + export_scale, + reverse_x_axis, + show_raw, + show_smoothed, + show_corrected, + show_normalized, + show_peaks, + x_range_enabled, + x_min, + x_max, + y_range_enabled, + y_min, + y_max, +) -> dict[str, Any]: + return normalize_spectral_plot_settings( + { + "legend_mode": legend_mode, + "compact": compact, + "show_grid": show_grid, + "show_spikes": show_spikes, + "line_width_scale": line_width_scale, + "marker_size_scale": marker_size_scale, + "export_scale": export_scale, + "reverse_x_axis": reverse_x_axis, + "show_raw": show_raw, + "show_smoothed": show_smoothed, + "show_corrected": show_corrected, + "show_normalized": show_normalized, + "show_peaks": show_peaks, + "x_range_enabled": x_range_enabled, + "x_min": x_min if x_range_enabled else None, + "x_max": x_max if x_range_enabled else None, + "y_range_enabled": y_range_enabled, + "y_min": y_min if y_range_enabled else None, + "y_max": y_max if y_range_enabled else None, + } + ) + + +def build_spectral_plot_settings_card(id_prefix: str) -> dbc.Card: + defaults = normalize_spectral_plot_settings(None) + chrome = spectral_plot_settings_chrome("en") + return dbc.Card( + dbc.CardBody( + [ + html.H6(id=f"{id_prefix}-plot-card-title", className="card-title mb-1"), + html.P(id=f"{id_prefix}-plot-card-hint", className="small text-muted mb-2"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id=f"{id_prefix}-plot-legend-mode-label", html_for=f"{id_prefix}-plot-legend-mode", className="mb-1"), + dbc.Select(id=f"{id_prefix}-plot-legend-mode", options=chrome["legend_options"], value=defaults["legend_mode"]), + ], + md=4, + ), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-compact", value=defaults["compact"], label=chrome["compact_label"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-grid", value=defaults["show_grid"], label=chrome["show_grid_label"]), md=4), + ], + className="g-2 align-items-end mb-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-spikes", value=defaults["show_spikes"], label=chrome["show_spikes_label"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-reverse-x-axis", value=defaults["reverse_x_axis"], label=chrome["reverse_x_axis_label"]), md=4), + dbc.Col( + [ + dbc.Label(id=f"{id_prefix}-plot-export-scale-label", html_for=f"{id_prefix}-plot-export-scale", className="mb-1"), + dbc.Select( + id=f"{id_prefix}-plot-export-scale", + options=[{"label": str(v), "value": v} for v in (1, 2, 3, 4)], + value=defaults["export_scale"], + ), + ], + md=4, + ), + ], + className="g-2 align-items-end mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id=f"{id_prefix}-plot-line-width-label", html_for=f"{id_prefix}-plot-line-width-scale", className="mb-1"), + dcc.Slider(id=f"{id_prefix}-plot-line-width-scale", min=0.6, max=1.8, step=0.1, value=defaults["line_width_scale"], marks=None, tooltip={"placement": "bottom"}), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id=f"{id_prefix}-plot-marker-size-label", html_for=f"{id_prefix}-plot-marker-size-scale", className="mb-1"), + dcc.Slider(id=f"{id_prefix}-plot-marker-size-scale", min=0.6, max=1.8, step=0.1, value=defaults["marker_size_scale"], marks=None, tooltip={"placement": "bottom"}), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-raw", value=defaults["show_raw"], label=chrome["show_raw_label"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-smoothed", value=defaults["show_smoothed"], label=chrome["show_smoothed_label"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-corrected", value=defaults["show_corrected"], label=chrome["show_corrected_label"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-normalized", value=defaults["show_normalized"], label=chrome["show_normalized_label"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-show-peaks", value=defaults["show_peaks"], label=chrome["show_peaks_label"]), md=4), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-x-range-enabled", value=defaults["x_range_enabled"], label=chrome["x_lock_label"]), md=4), + dbc.Col(dbc.Input(id=f"{id_prefix}-plot-x-min", type="number", placeholder=chrome["x_min_placeholder"]), md=4), + dbc.Col(dbc.Input(id=f"{id_prefix}-plot-x-max", type="number", placeholder=chrome["x_max_placeholder"]), md=4), + dbc.Col(dbc.Checkbox(id=f"{id_prefix}-plot-y-range-enabled", value=defaults["y_range_enabled"], label=chrome["y_lock_label"]), md=4), + dbc.Col(dbc.Input(id=f"{id_prefix}-plot-y-min", type="number", placeholder=chrome["y_min_placeholder"]), md=4), + dbc.Col(dbc.Input(id=f"{id_prefix}-plot-y-max", type="number", placeholder=chrome["y_max_placeholder"]), md=4), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def build_plotly_config(settings: Mapping[str, Any] | None, *, filename: str | None = None) -> dict[str, Any]: + resolved = normalize_spectral_plot_settings(settings) + opts: dict[str, Any] = {"format": "png", "filename": filename or "materialscope_spectrum", "scale": resolved["export_scale"]} + return {"displaylogo": False, "responsive": True, "toImageButtonOptions": opts} + + +def spectral_legend_layout(trace_count: int, settings: Mapping[str, Any], *, theme: Mapping[str, str], legend_bg: str) -> tuple[bool, dict[str, Any]]: + mode = str(settings.get("legend_mode") or "auto") + if mode == "hidden": + return False, {} + if mode == "external" or mode == "compact" or (mode == "auto" and trace_count >= 5): + return True, { + "orientation": "v", + "yanchor": "top", + "y": 1.0, + "xanchor": "left", + "x": 1.02, + "bgcolor": legend_bg, + "bordercolor": "rgba(0,0,0,0)", + "borderwidth": 0, + "font": {"size": 10 if settings.get("compact") else 11, "color": theme["text"]}, + } + return True, { + "orientation": "h", + "yanchor": "bottom", + "y": 1.02, + "xanchor": "left", + "x": 0, + "bgcolor": legend_bg, + "bordercolor": theme["grid"], + "borderwidth": 1, + "font": {"size": 11 if settings.get("compact") else 12, "color": theme["text"]}, + } diff --git a/dash_app/components/stepper.py b/dash_app/components/stepper.py new file mode 100644 index 00000000..f7adc8d1 --- /dev/null +++ b/dash_app/components/stepper.py @@ -0,0 +1,94 @@ +"""Reusable multi-step wizard / stepper component for Dash pages.""" + +from __future__ import annotations + +from typing import Any + +import dash_bootstrap_components as dbc +from dash import html + + +def stepper_indicator( + steps: list[dict[str, str]], + active_step: int, + completed_steps: set[int] | None = None, +) -> html.Div: + """Render a horizontal step indicator. + + Parameters + ---------- + steps : list of dict + Each dict has keys ``label`` and ``description``. + active_step : int + Zero-based index of the currently active step. + completed_steps : set of int, optional + Zero-based indices of completed steps. + """ + completed_steps = completed_steps or set() + items: list[Any] = [] + for i, step in enumerate(steps): + if i in completed_steps: + variant = "success" + icon = "bi-check-circle-fill" + elif i == active_step: + variant = "primary" + icon = "bi-circle-fill" + else: + variant = "secondary" + icon = "bi-circle" + items.append( + html.Div( + [ + html.I(className=f"bi {icon} me-1 text-{variant}"), + html.Span(step["label"], className=f"fw-{('bold' if i == active_step else 'normal')}"), + ], + className="d-flex align-items-center me-3", + ) + ) + if i < len(steps) - 1: + items.append(html.I(className="bi bi-chevron-right me-3 text-muted")) + return html.Div(items, className="d-flex align-items-center flex-wrap mb-3 stepper-bar") + + +def step_container(step_id: str, content: list[Any], visible: bool = True) -> html.Div: + """Wrap step content in a toggleable div.""" + return html.Div( + content, + id=step_id, + style={"display": "block" if visible else "none"}, + ) + + +def step_navigation( + *, + prev_id: str, + next_id: str, + confirm_id: str | None = None, + show_prev: bool = True, + show_next: bool = True, + show_confirm: bool = False, +) -> dbc.Row: + """Render step navigation buttons.""" + buttons: list[dbc.Col] = [] + if show_prev: + buttons.append( + dbc.Col( + dbc.Button("Back", id=prev_id, color="secondary", outline=True), + width="auto", + ) + ) + if show_next: + buttons.append( + dbc.Col( + dbc.Button("Next", id=next_id, color="primary"), + width="auto", + ) + ) + if show_confirm: + buttons.append( + dbc.Col( + dbc.Button("Confirm Import", id=confirm_id, color="success"), + width="auto", + ) + ) + return dbc.Row(buttons, className="g-2 mt-3") diff --git a/dash_app/components/tga_explore.py b/dash_app/components/tga_explore.py new file mode 100644 index 00000000..15186a28 --- /dev/null +++ b/dash_app/components/tga_explore.py @@ -0,0 +1,355 @@ +"""TGA Dash exploration helpers: raw-quality stats, step reference callouts, undo stacks. + +Reuses Streamlit-side quality metrics computation where practical. +""" + +from __future__ import annotations + +import copy +import math +from typing import Any + +import numpy as np +from dash import html + +from utils.i18n import translate_ui +from utils.reference_data import find_nearest_reference + +MAX_TGA_UNDO_DEPTH = 25 + + +def _compute_signal_quality_metrics(temperature: np.ndarray, signal: np.ndarray) -> dict[str, Any]: + """Same logic as ``ui.components.quality_dashboard.compute_quality_metrics`` (no Streamlit).""" + + metrics: dict[str, Any] = {} + + nan_count = int(np.isnan(signal).sum() + np.isnan(temperature).sum()) + metrics["NaN Count"] = { + "value": nan_count, + "display": str(nan_count), + "level": "green" if nan_count == 0 else "red", + } + + mask = np.isfinite(temperature) & np.isfinite(signal) + t = temperature[mask] + s = signal[mask] + + if len(s) < 10: + return metrics + + noise = float(np.std(np.diff(s))) + nlevel = "green" if noise < 0.01 else "yellow" if noise < 0.1 else "red" + metrics["Noise Level"] = {"value": noise, "display": f"{noise:.4f}", "level": nlevel} + + dt = np.diff(t) + if len(dt) > 1 and np.mean(np.abs(dt)) > 1e-12: + cv = float(np.std(dt) / np.abs(np.mean(dt))) + else: + cv = 0.0 + cvlevel = "green" if cv < 0.05 else "yellow" if cv < 0.15 else "red" + metrics["Heating Rate CV"] = {"value": cv, "display": f"{cv:.4f}", "level": cvlevel} + + if len(s) > 2: + coeffs = np.polyfit(np.arange(len(s)), s, 1) + slope = abs(coeffs[0]) + sig_range = float(np.ptp(s)) or 1.0 + norm_drift = slope * len(s) / sig_range + else: + norm_drift = 0.0 + dlevel = "green" if norm_drift < 0.2 else "yellow" if norm_drift < 0.5 else "red" + metrics["Baseline Drift"] = {"value": norm_drift, "display": f"{norm_drift:.3f}", "level": dlevel} + + win = max(5, len(s) // 50) + kernel = np.ones(win) / win + rolling_mean = np.convolve(s, kernel, mode="same") + residuals = np.abs(s - rolling_mean) + threshold = 3 * np.std(residuals) + outlier_frac = float(np.sum(residuals > threshold) / len(s)) if threshold > 0 else 0.0 + olevel = "green" if outlier_frac == 0 else "yellow" if outlier_frac < 0.005 else "red" + metrics["Outliers (>3σ)"] = {"value": outlier_frac, "display": f"{outlier_frac * 100:.2f}%", "level": olevel} + + snr = float(np.ptp(s) / np.std(np.diff(s))) if np.std(np.diff(s)) > 0 else 999.0 + slevel = "green" if snr > 10 else "yellow" if snr > 5 else "red" + metrics["SNR"] = {"value": snr, "display": f"{snr:.1f}", "level": slevel} + + red_count = sum(1 for m in metrics.values() if m["level"] == "red") + yellow_count = sum(1 for m in metrics.values() if m["level"] == "yellow") + if red_count > 0: + grade, glevel = "Poor", "red" + elif yellow_count > 1: + grade, glevel = "Fair", "yellow" + else: + grade, glevel = "Good", "green" + metrics["Overall Grade"] = {"value": grade, "display": grade, "level": glevel} + + return metrics + + +def tga_draft_processing_equal(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + """Deep-compare normalized TGA processing draft payloads (smoothing + step_detection).""" + if not isinstance(a, dict) or not isinstance(b, dict): + return a == b + try: + import json + + def norm(d: dict[str, Any]) -> str: + return json.dumps(d, sort_keys=True, default=str) + + return norm(a) == norm(b) + except Exception: + return a == b + + +def append_undo_after_edit( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + old_draft: dict[str, Any] | None, + new_draft: dict[str, Any], + *, + max_depth: int = MAX_TGA_UNDO_DEPTH, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """After a user edit, push *old_draft* onto past and clear redo when draft actually changes.""" + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + if old_draft is None or tga_draft_processing_equal(old_draft, new_draft): + return past_list, [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + past_list.append(copy.deepcopy(old_draft)) + if len(past_list) > max_depth: + past_list = past_list[-max_depth:] + return past_list, [] + + +def perform_undo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not past: + return None + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + previous = past_list.pop() + if current is not None: + future_list.append(copy.deepcopy(current)) + return previous, past_list, future_list + + +def perform_redo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not future: + return None + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in future if isinstance(x, dict)] + nxt = future_list.pop() + if current is not None: + past_list.append(copy.deepcopy(current)) + return nxt, past_list, future_list + + +def downsample_rows(rows: list[dict[str, Any]], columns: list[str], max_points: int = 6000) -> tuple[np.ndarray, np.ndarray]: + """Extract temperature/signal as float arrays; stride if very long.""" + if not rows: + return np.array([]), np.array([]) + t_key = "temperature" if "temperature" in columns else None + s_key = "signal" if "signal" in columns else None + if t_key is None or s_key is None: + return np.array([]), np.array([]) + t_vals: list[float] = [] + s_vals: list[float] = [] + for row in rows: + if not isinstance(row, dict): + continue + try: + tv = float(row.get(t_key)) + sv = float(row.get(s_key)) + except (TypeError, ValueError): + continue + if math.isfinite(tv) and math.isfinite(sv): + t_vals.append(tv) + s_vals.append(sv) + t_arr = np.asarray(t_vals, dtype=float) + s_arr = np.asarray(s_vals, dtype=float) + n = len(t_arr) + if n <= max_points or n == 0: + return t_arr, s_arr + step = int(math.ceil(n / max_points)) + return t_arr[::step], s_arr[::step] + + +def compute_tga_raw_exploration_stats( + temperature: np.ndarray, + signal: np.ndarray, + *, + validation: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Pre-run / raw exploration stats and hints (no Streamlit dependency).""" + out: dict[str, Any] = {"warnings": [], "hints": [], "checks": {}, "validation_messages": []} + val = validation if isinstance(validation, dict) else {} + out["validation_status"] = str(val.get("status") or "") + for w in val.get("warnings") or []: + if isinstance(w, str) and w.strip(): + out["validation_messages"].append(w.strip()) + for issue in val.get("issues") or []: + if isinstance(issue, str) and issue.strip(): + out["validation_messages"].append(issue.strip()) + vchecks = val.get("checks") if isinstance(val.get("checks"), dict) else {} + out["checks"] = dict(vchecks) + + if temperature.size == 0 or signal.size == 0 or temperature.size != signal.size: + out["warnings"] = ["missing_series"] + return out + + t = temperature.astype(float) + s = signal.astype(float) + mask = np.isfinite(t) & np.isfinite(s) + t = t[mask] + s = s[mask] + n = int(t.size) + out["point_count"] = n + if n < 2: + out["warnings"] = ["too_few_points"] + return out + + out["temp_min"] = float(t.min()) + out["temp_max"] = float(t.max()) + out["signal_min"] = float(s.min()) + out["signal_max"] = float(s.max()) + out["apparent_mass_change"] = float(s.max() - s.min()) + + dt = np.diff(t) + pos = int(np.sum(dt > 0)) + neg = int(np.sum(dt < 0)) + zero = int(np.sum(dt == 0)) + dom = max(pos, neg, zero) + if dom < len(dt) * 0.85: + out["hints"].append("temperature_non_monotonic") + elif neg > pos: + out["hints"].append("temperature_mostly_decreasing") + + ds = np.diff(s) + frac_inc = float(np.sum(ds > 0) / len(ds)) if len(ds) else 0.0 + frac_dec = float(np.sum(ds < 0) / len(ds)) if len(ds) else 0.0 + if frac_inc > 0.55 and frac_dec < 0.35: + out["hints"].append("mass_trend_increasing") + elif frac_dec > 0.55 and frac_inc < 0.35: + out["hints"].append("mass_trend_decreasing") + + qm = _compute_signal_quality_metrics(t, s) + out["quality_metrics"] = {k: {"display": v.get("display"), "level": v.get("level")} for k, v in qm.items()} + + return out + + +def build_tga_raw_quality_panel(stats: dict[str, Any], loc: str, *, temp_unit: str, signal_unit: str) -> html.Div: + """Dash layout for raw-quality exploration block.""" + prefix = "dash.analysis.tga.raw_quality" + w0 = (stats.get("warnings") or [None])[0] + if w0 in ("missing_series", "too_few_points"): + key = f"{prefix}.empty_{w0}" + return html.Div( + html.P(translate_ui(loc, key), className="text-muted small mb-0"), + className="tga-raw-quality-inner", + ) + + n = int(stats.get("point_count") or 0) + t0 = stats.get("temp_min") + t1 = stats.get("temp_max") + s0 = stats.get("signal_min") + s1 = stats.get("signal_max") + chg = stats.get("apparent_mass_change") + + lines: list[Any] = [ + html.Li(translate_ui(loc, f"{prefix}.stat_points", n=n)), + ] + if t0 is not None and t1 is not None: + lines.append(html.Li(translate_ui(loc, f"{prefix}.stat_temp_range", t0=t0, t1=t1, u=temp_unit))) + if s0 is not None and s1 is not None: + lines.append(html.Li(translate_ui(loc, f"{prefix}.stat_mass_range", s0=s0, s1=s1, u=signal_unit))) + if chg is not None: + lines.append(html.Li(translate_ui(loc, f"{prefix}.stat_apparent_change", chg=chg, u=signal_unit))) + + qm = stats.get("quality_metrics") or {} + og = qm.get("Overall Grade") or {} + if og.get("display"): + gdisp = str(og.get("display")) + grade_key = {"Good": f"{prefix}.grade_good", "Fair": f"{prefix}.grade_fair", "Poor": f"{prefix}.grade_poor"}.get(gdisp) + if grade_key: + gdisp = translate_ui(loc, grade_key) + lines.append( + html.Li( + [ + html.Span(translate_ui(loc, f"{prefix}.grade_label"), className="me-1"), + html.Span(gdisp, className=f"text-{_level_to_bootstrap(og.get('level'))}"), + ] + ) + ) + + hint_items: list[html.Li] = [] + for h in stats.get("hints") or []: + hint_items.append(html.Li(translate_ui(loc, f"{prefix}.hint.{h}"), className="small")) + + warn_items: list[html.Li] = [] + for w in stats.get("warnings") or []: + if w in ("missing_series", "too_few_points"): + continue + ikey = f"{prefix}.warn.{w}" + label = translate_ui(loc, ikey) if translate_ui(loc, ikey) != ikey else w + warn_items.append(html.Li(label, className="small text-warning")) + for msg in stats.get("validation_messages") or []: + warn_items.append(html.Li(msg, className="small text-warning")) + + return html.Div( + [ + html.Ul(lines, className="small mb-2 ps-3"), + html.Ul(hint_items, className="small mb-2 ps-3 text-muted") if hint_items else None, + html.Ul(warn_items, className="small mb-0 ps-3") if warn_items else None, + ], + className="tga-raw-quality-inner", + ) + + +def _level_to_bootstrap(level: str | None) -> str: + if level == "red": + return "danger" + if level == "yellow": + return "warning" + return "success" + + +def format_tga_step_reference_callout(midpoint_c: float | None, loc: str) -> html.Div: + """Compact reference line for a TGA step midpoint (decomposition standards).""" + prefix = "dash.analysis.tga.step_ref" + if midpoint_c is None or not math.isfinite(float(midpoint_c)): + return html.Div(translate_ui(loc, f"{prefix}.no_midpoint"), className="small text-muted mt-1") + mp = float(midpoint_c) + ref = find_nearest_reference(mp, threshold_c=15.0, analysis_type="TGA") + if ref is None: + return html.Div(translate_ui(loc, f"{prefix}.neutral"), className="small text-muted mt-1") + delta = mp - ref.temperature_c + ad = abs(delta) + if ad < 2.0: + tone = "success" + elif ad < 5.0: + tone = "warning" + else: + tone = "danger" + sign = "+" if delta >= 0 else "" + line = translate_ui( + loc, + f"{prefix}.line", + name=ref.name, + rt=ref.temperature_c, + sg=sign, + dv=delta, + ) + extra = ref.standard or "" + return html.Div( + [ + html.Span(translate_ui(loc, f"{prefix}.badge"), className=f"badge bg-{tone} me-1 align-middle"), + html.Span(line, className="small"), + html.Span(f" · {extra}", className="small text-muted") if extra else None, + ], + className="mt-1", + ) diff --git a/dash_app/components/xrd_explore.py b/dash_app/components/xrd_explore.py new file mode 100644 index 00000000..ad99291a --- /dev/null +++ b/dash_app/components/xrd_explore.py @@ -0,0 +1,69 @@ +"""XRD Dash exploration helpers: undo stacks (same semantics as Raman/TGA).""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +MAX_XRD_UNDO_DEPTH = 25 + + +def xrd_draft_processing_equal(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + if not isinstance(a, dict) or not isinstance(b, dict): + return a == b + try: + + def norm(d: dict[str, Any]) -> str: + return json.dumps(d, sort_keys=True, default=str) + + return norm(a) == norm(b) + except Exception: + return a == b + + +def append_undo_after_edit( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + old_draft: dict[str, Any] | None, + new_draft: dict[str, Any], + *, + max_depth: int = MAX_XRD_UNDO_DEPTH, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + if old_draft is None or xrd_draft_processing_equal(old_draft, new_draft): + return past_list, [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + past_list.append(copy.deepcopy(old_draft)) + if len(past_list) > max_depth: + past_list = past_list[-max_depth:] + return past_list, [] + + +def perform_undo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not past: + return None + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in (future or []) if isinstance(x, dict)] + previous = past_list.pop() + if current is not None: + future_list.append(copy.deepcopy(current)) + return previous, past_list, future_list + + +def perform_redo( + past: list[dict[str, Any]] | None, + future: list[dict[str, Any]] | None, + current: dict[str, Any] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]] | None: + if not future: + return None + past_list = [copy.deepcopy(x) for x in (past or []) if isinstance(x, dict)] + future_list = [copy.deepcopy(x) for x in future if isinstance(x, dict)] + nxt = future_list.pop() + if current is not None: + past_list.append(copy.deepcopy(current)) + return nxt, past_list, future_list diff --git a/dash_app/components/xrd_processing_draft.py b/dash_app/components/xrd_processing_draft.py new file mode 100644 index 00000000..2394b21a --- /dev/null +++ b/dash_app/components/xrd_processing_draft.py @@ -0,0 +1,411 @@ +"""XRD processing draft normalization and API payload helpers (Streamlit-default aligned).""" + +from __future__ import annotations + +import copy +import json +import math +from typing import Any + +from dash_app.components.processing_inputs import coerce_int_positive as _coerce_int_positive + +_XRD_TEMPLATE_IDS = ("xrd.general", "xrd.phase_screening") + +_XRD_TEMPLATE_DEFAULTS: dict[str, dict[str, Any]] = { + "xrd.general": { + "axis_normalization": {"sort_axis": True, "deduplicate": "first", "axis_min": None, "axis_max": None}, + "smoothing": {"method": "savgol", "window_length": 11, "polyorder": 3}, + "baseline": {"method": "rolling_minimum", "window_length": 31, "smoothing_window": 9}, + "peak_detection": {"method": "scipy_find_peaks", "prominence": 0.08, "distance": 6, "width": 2, "max_peaks": 12}, + "method_context": { + "xrd_match_metric": "peak_overlap_weighted", + "xrd_match_tolerance_deg": 0.28, + "xrd_match_top_n": 5, + "xrd_match_minimum_score": 0.42, + "xrd_match_intensity_weight": 0.35, + "xrd_match_major_peak_fraction": 0.4, + }, + }, + "xrd.phase_screening": { + "axis_normalization": {"sort_axis": True, "deduplicate": "first", "axis_min": 5.0, "axis_max": 90.0}, + "smoothing": {"method": "savgol", "window_length": 15, "polyorder": 3}, + "baseline": {"method": "rolling_minimum", "window_length": 41, "smoothing_window": 9}, + "peak_detection": {"method": "scipy_find_peaks", "prominence": 0.12, "distance": 8, "width": 3, "max_peaks": 16}, + "method_context": { + "xrd_match_metric": "peak_overlap_weighted", + "xrd_match_tolerance_deg": 0.24, + "xrd_match_top_n": 7, + "xrd_match_minimum_score": 0.45, + "xrd_match_intensity_weight": 0.4, + "xrd_match_major_peak_fraction": 0.45, + }, + }, +} + +_XRD_PLOT_DEFAULTS: dict[str, Any] = { + "show_peak_labels": True, + "label_density_mode": "smart", + "max_labels": 8, + "min_label_intensity_ratio": 0.12, + "marker_size": 8, + "label_position_precision": 2, + "label_intensity_precision": 0, + # Match overlays off by default — enable under Plot appearance (advanced). + "show_matched_peaks": False, + "show_unmatched_observed": False, + "show_unmatched_reference": False, + "show_match_connectors": False, + "show_match_labels": False, + # Smoothed / baseline as separate traces (advanced). + "show_intermediate_traces": False, + "style_preset": "color_shape", + "only_selected_candidate": True, + "x_range_enabled": False, + "x_min": None, + "x_max": None, + "y_range_enabled": False, + "y_min": None, + "y_max": None, + "log_y": False, + "line_width": 2.0, +} + + +def xrd_template_ids() -> tuple[str, ...]: + return _XRD_TEMPLATE_IDS + + +def default_xrd_draft_for_template(template_id: str | None) -> dict[str, Any]: + tid = str(template_id or "").strip() if str(template_id or "").strip() in _XRD_TEMPLATE_IDS else "xrd.general" + src = _XRD_TEMPLATE_DEFAULTS[tid] + mc = copy.deepcopy(src["method_context"]) + mc["xrd_plot_settings"] = copy.deepcopy(_XRD_PLOT_DEFAULTS) + return { + "axis_normalization": copy.deepcopy(src["axis_normalization"]), + "smoothing": copy.deepcopy(src["smoothing"]), + "baseline": copy.deepcopy(src["baseline"]), + "peak_detection": copy.deepcopy(src["peak_detection"]), + "method_context": mc, + } + + +def _coerce_float_bounds(value, *, default: float, minimum: float, maximum: float) -> float: + try: + if value in (None, ""): + parsed = default + else: + parsed = float(value) + except (TypeError, ValueError): + parsed = default + if not math.isfinite(parsed): + parsed = default + return max(minimum, min(maximum, parsed)) + + +def _coerce_optional_float(value) -> float | None: + if value in (None, ""): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def _normalize_axis_norm(d: dict | None) -> dict[str, Any]: + src = dict(d or {}) + sort_axis = bool(src.get("sort_axis", True)) + dedup = str(src.get("deduplicate") or "first").strip().lower() + if dedup not in {"first", "last", "mean"}: + dedup = "first" + return { + "sort_axis": sort_axis, + "deduplicate": dedup, + "axis_min": _coerce_optional_float(src.get("axis_min")), + "axis_max": _coerce_optional_float(src.get("axis_max")), + } + + +def _normalize_smoothing(d: dict | None) -> dict[str, Any]: + src = dict(d or {}) + method = str(src.get("method") or "savgol").strip().lower() + if method not in {"savgol", "moving_average", "mean"}: + method = "savgol" + if method in {"moving_average", "mean"}: + wl = _coerce_int_positive(src.get("window_length"), default=11, minimum=3) + if wl % 2 == 0: + wl += 1 + return {"method": "moving_average", "window_length": wl} + wl = _coerce_int_positive(src.get("window_length"), default=11, minimum=5) + if wl % 2 == 0: + wl += 1 + po = _coerce_int_positive(src.get("polyorder"), default=3, minimum=1) + po = min(po, max(wl - 2, 1)) + return {"method": "savgol", "window_length": wl, "polyorder": po} + + +def _normalize_baseline(d: dict | None) -> dict[str, Any]: + src = dict(d or {}) + method = str(src.get("method") or "rolling_minimum").strip().lower() + if method not in {"rolling_minimum", "linear", "asls", "none", "off"}: + method = "rolling_minimum" + if method in {"none", "off"}: + return {"method": "none"} + if method == "linear": + return {"method": "linear"} + wl = _coerce_int_positive(src.get("window_length"), default=31, minimum=5) + if wl % 2 == 0: + wl += 1 + sw = _coerce_int_positive(src.get("smoothing_window"), default=9, minimum=3) + if sw % 2 == 0: + sw += 1 + return {"method": "rolling_minimum", "window_length": wl, "smoothing_window": sw} + + +def _normalize_peak_detection(d: dict | None) -> dict[str, Any]: + src = dict(d or {}) + prom = _coerce_float_bounds(src.get("prominence"), default=0.08, minimum=1e-9, maximum=1e9) + dist = _coerce_int_positive(src.get("distance"), default=6, minimum=1) + width = _coerce_int_positive(src.get("width"), default=2, minimum=1) + max_peaks = _coerce_int_positive(src.get("max_peaks"), default=12, minimum=1) + return { + "method": "scipy_find_peaks", + "prominence": prom, + "distance": dist, + "width": width, + "max_peaks": max_peaks, + } + + +def _normalize_method_context(mc: dict | None, plot: dict | None) -> dict[str, Any]: + src = dict(mc or {}) + metric = str(src.get("xrd_match_metric") or "peak_overlap_weighted").strip() + tol = _coerce_float_bounds(src.get("xrd_match_tolerance_deg"), default=0.28, minimum=1e-6, maximum=10.0) + top_n = _coerce_int_positive(src.get("xrd_match_top_n"), default=5, minimum=1) + min_score = _coerce_float_bounds(src.get("xrd_match_minimum_score"), default=0.42, minimum=0.0, maximum=1.0) + iw = _coerce_float_bounds(src.get("xrd_match_intensity_weight"), default=0.35, minimum=0.0, maximum=1.0) + mj = _coerce_float_bounds(src.get("xrd_match_major_peak_fraction"), default=0.4, minimum=0.0, maximum=1.0) + out = { + "xrd_match_metric": metric, + "xrd_match_tolerance_deg": tol, + "xrd_match_top_n": top_n, + "xrd_match_minimum_score": min_score, + "xrd_match_intensity_weight": iw, + "xrd_match_major_peak_fraction": mj, + } + for key in ( + "xrd_axis_role", + "xrd_axis_unit", + "xrd_wavelength_angstrom", + "xrd_axis_mapping_review_required", + "xrd_stable_matching_blocked", + "xrd_provenance_state", + "xrd_provenance_warning", + ): + if key in src and src[key] is not None: + out[key] = copy.deepcopy(src[key]) + ps = plot if isinstance(plot, dict) else src.get("xrd_plot_settings") + out["xrd_plot_settings"] = _normalize_plot_settings(ps if isinstance(ps, dict) else {}) + return out + + +def _normalize_plot_settings(src: dict) -> dict[str, Any]: + from dash_app.components.xrd_result_plot import normalize_xrd_plot_settings + + return normalize_xrd_plot_settings(src) + + +def normalize_xrd_processing_draft(draft: dict | None) -> dict[str, Any]: + d = dict(draft or {}) + axis = _normalize_axis_norm(d.get("axis_normalization") if isinstance(d.get("axis_normalization"), dict) else {}) + sm = _normalize_smoothing(d.get("smoothing") if isinstance(d.get("smoothing"), dict) else {}) + bl = _normalize_baseline(d.get("baseline") if isinstance(d.get("baseline"), dict) else {}) + pk = _normalize_peak_detection(d.get("peak_detection") if isinstance(d.get("peak_detection"), dict) else {}) + mc_in = d.get("method_context") if isinstance(d.get("method_context"), dict) else {} + plot_in = mc_in.get("xrd_plot_settings") if isinstance(mc_in.get("xrd_plot_settings"), dict) else {} + mc = _normalize_method_context(mc_in, plot_in) + return { + "axis_normalization": axis, + "smoothing": sm, + "baseline": bl, + "peak_detection": pk, + "method_context": mc, + } + + +def xrd_overrides_from_draft(draft: dict | None) -> dict[str, Any]: + norm = normalize_xrd_processing_draft(draft) + mc = copy.deepcopy(norm["method_context"]) + return { + "axis_normalization": copy.deepcopy(norm["axis_normalization"]), + "smoothing": copy.deepcopy(norm["smoothing"]), + "baseline": copy.deepcopy(norm["baseline"]), + "peak_detection": copy.deepcopy(norm["peak_detection"]), + "method_context": mc, + } + + +def xrd_draft_from_loaded_processing(processing: dict | None) -> dict[str, Any]: + if not isinstance(processing, dict): + return default_xrd_draft_for_template("xrd.general") + sp = processing.get("signal_pipeline") or {} + ast = processing.get("analysis_steps") or {} + ax = sp.get("axis_normalization") if isinstance(sp.get("axis_normalization"), dict) else processing.get("axis_normalization") + sm = sp.get("smoothing") if isinstance(sp.get("smoothing"), dict) else processing.get("smoothing") + bl = sp.get("baseline") if isinstance(sp.get("baseline"), dict) else processing.get("baseline") + pk = ast.get("peak_detection") if isinstance(ast.get("peak_detection"), dict) else processing.get("peak_detection") + mc = processing.get("method_context") if isinstance(processing.get("method_context"), dict) else {} + return normalize_xrd_processing_draft( + { + "axis_normalization": ax, + "smoothing": sm, + "baseline": bl, + "peak_detection": pk, + "method_context": mc, + } + ) + + +def xrd_preset_processing_body_for_save(draft: dict | None) -> dict[str, Any]: + return xrd_overrides_from_draft(draft) + + +def xrd_ui_snapshot_dict(template_id: str | None, draft: dict | None) -> dict[str, Any]: + tid = template_id if template_id in _XRD_TEMPLATE_IDS else "xrd.general" + norm = normalize_xrd_processing_draft(draft) + return { + "workflow_template_id": tid, + **{k: copy.deepcopy(norm[k]) for k in ("axis_normalization", "smoothing", "baseline", "peak_detection")}, + "method_context": copy.deepcopy(norm["method_context"]), + } + + +def xrd_snapshots_equal(a: dict | None, b: dict | None) -> bool: + if not isinstance(a, dict) or not isinstance(b, dict): + return False + return json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str) + + +def apply_dataset_review_to_method_context( + draft: dict | None, + *, + axis_confirmed: bool, + wavelength_value: Any, +) -> dict[str, Any]: + norm = normalize_xrd_processing_draft(draft) + mc = copy.deepcopy(norm["method_context"]) + if axis_confirmed: + mc["xrd_axis_role"] = "two_theta" + mc["xrd_axis_unit"] = "degree_2theta" + mc["xrd_axis_mapping_review_required"] = False + mc["xrd_stable_matching_blocked"] = False + wl = _coerce_optional_float(wavelength_value) + if wl is not None and wl > 0: + mc["xrd_wavelength_angstrom"] = float(wl) + mc["xrd_provenance_state"] = "complete" + mc["xrd_provenance_warning"] = "" + norm["method_context"] = _normalize_method_context(mc, mc.get("xrd_plot_settings")) + return norm + + +def xrd_draft_from_control_values( + *, + axis_sort, + axis_dedup, + axis_min, + axis_max, + sm_method, + sm_window, + sm_poly, + bl_method, + bl_window, + bl_smooth_window, + pk_prom, + pk_dist, + pk_width, + pk_max, + match_metric, + match_tol, + match_top_n, + match_min_score, + match_iw, + match_maj, + review_axis_ok, + review_wavelength, + plot_show_labels, + plot_density, + plot_max_labels, + plot_min_ratio, + plot_msize, + plot_pos_prec, + plot_int_prec, + plot_matched, + plot_u_obs, + plot_u_ref, + plot_conn, + plot_m_lbl, + plot_style, + plot_x_en, + plot_x_min, + plot_x_max, + plot_y_en, + plot_y_min, + plot_y_max, + plot_log_y, + plot_lw, + plot_show_intermediate, +) -> dict[str, Any]: + plot = { + "show_peak_labels": bool(plot_show_labels), + "label_density_mode": str(plot_density or "smart"), + "max_labels": plot_max_labels, + "min_label_intensity_ratio": plot_min_ratio, + "marker_size": plot_msize, + "label_position_precision": plot_pos_prec, + "label_intensity_precision": plot_int_prec, + "show_matched_peaks": bool(plot_matched), + "show_unmatched_observed": bool(plot_u_obs), + "show_unmatched_reference": bool(plot_u_ref), + "show_match_connectors": bool(plot_conn), + "show_match_labels": bool(plot_m_lbl), + "show_intermediate_traces": bool(plot_show_intermediate), + "style_preset": str(plot_style or "color_shape"), + "x_range_enabled": bool(plot_x_en), + "x_min": plot_x_min, + "x_max": plot_x_max, + "y_range_enabled": bool(plot_y_en), + "y_min": plot_y_min, + "y_max": plot_y_max, + "log_y": bool(plot_log_y), + "line_width": plot_lw, + } + mc = { + "xrd_match_metric": str(match_metric or "peak_overlap_weighted"), + "xrd_match_tolerance_deg": match_tol, + "xrd_match_top_n": match_top_n, + "xrd_match_minimum_score": match_min_score, + "xrd_match_intensity_weight": match_iw, + "xrd_match_major_peak_fraction": match_maj, + "xrd_plot_settings": plot, + } + draft = { + "axis_normalization": { + "sort_axis": bool(axis_sort), + "deduplicate": str(axis_dedup or "first"), + "axis_min": axis_min, + "axis_max": axis_max, + }, + "smoothing": {"method": sm_method, "window_length": sm_window, "polyorder": sm_poly}, + "baseline": {"method": bl_method, "window_length": bl_window, "smoothing_window": bl_smooth_window}, + "peak_detection": { + "prominence": pk_prom, + "distance": pk_dist, + "width": pk_width, + "max_peaks": pk_max, + }, + "method_context": mc, + } + norm = normalize_xrd_processing_draft(draft) + return apply_dataset_review_to_method_context(norm, axis_confirmed=bool(review_axis_ok), wavelength_value=review_wavelength) diff --git a/dash_app/components/xrd_result_plot.py b/dash_app/components/xrd_result_plot.py new file mode 100644 index 00000000..87bcc4f1 --- /dev/null +++ b/dash_app/components/xrd_result_plot.py @@ -0,0 +1,493 @@ +"""Plotly figure builder for XRD Dash results (curves + peak markers + optional match overlay).""" + +from __future__ import annotations + +import math +from typing import Any, Mapping + +import plotly.graph_objects as go + +from dash_app.theme import PLOT_THEME, apply_figure_theme, normalize_ui_theme +from utils.i18n import translate_ui + +_XRD_MATCH_STYLE = { + "matched_observed": {"color": "#22C55E", "symbol": "diamond"}, + "unmatched_observed": {"color": "#EF4444", "symbol": "x"}, + "matched_reference": {"color": "#2563EB", "symbol": "square"}, + "unmatched_reference": {"color": "#F59E0B", "symbol": "triangle-up"}, +} + +_XRD_PLOT_FALLBACK = { + "show_peak_labels": True, + "label_density_mode": "smart", + "max_labels": 8, + "min_label_intensity_ratio": 0.12, + "marker_size": 8, + "label_position_precision": 2, + "label_intensity_precision": 0, + "show_matched_peaks": False, + "show_unmatched_observed": False, + "show_unmatched_reference": False, + "show_match_connectors": False, + "show_match_labels": False, + "show_intermediate_traces": False, + "style_preset": "color_shape", + "x_range_enabled": False, + "x_min": None, + "x_max": None, + "y_range_enabled": False, + "y_min": None, + "y_max": None, + "log_y": False, + "line_width": 2.0, +} + + +def _coerce_plot_bool(value, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return bool(value) if value not in (None, "") else fallback + + +def _coerce_plot_int(value, fallback: int, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = fallback + return max(minimum, min(maximum, parsed)) + + +def _coerce_plot_float(value, fallback: float, minimum: float, maximum: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = fallback + return max(minimum, min(maximum, parsed)) + + +def _coerce_optional_plot_float(value) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def normalize_xrd_plot_settings(payload: Mapping[str, Any] | None) -> dict[str, Any]: + source = payload if isinstance(payload, Mapping) else {} + settings = dict(_XRD_PLOT_FALLBACK) + settings["show_peak_labels"] = _coerce_plot_bool(source.get("show_peak_labels"), settings["show_peak_labels"]) + label_density = str(source.get("label_density_mode") or settings["label_density_mode"]).strip().lower() + settings["label_density_mode"] = label_density if label_density in {"smart", "all", "selected"} else "smart" + settings["max_labels"] = _coerce_plot_int(source.get("max_labels"), settings["max_labels"], 1, 60) + settings["min_label_intensity_ratio"] = _coerce_plot_float( + source.get("min_label_intensity_ratio"), + settings["min_label_intensity_ratio"], + 0.0, + 1.0, + ) + settings["marker_size"] = _coerce_plot_int(source.get("marker_size"), settings["marker_size"], 4, 20) + settings["label_position_precision"] = _coerce_plot_int( + source.get("label_position_precision"), + settings["label_position_precision"], + 1, + 5, + ) + settings["label_intensity_precision"] = _coerce_plot_int( + source.get("label_intensity_precision"), + settings["label_intensity_precision"], + 0, + 4, + ) + settings["show_matched_peaks"] = _coerce_plot_bool(source.get("show_matched_peaks"), settings["show_matched_peaks"]) + settings["show_unmatched_observed"] = _coerce_plot_bool( + source.get("show_unmatched_observed"), + settings["show_unmatched_observed"], + ) + settings["show_unmatched_reference"] = _coerce_plot_bool( + source.get("show_unmatched_reference"), + settings["show_unmatched_reference"], + ) + settings["show_match_connectors"] = _coerce_plot_bool( + source.get("show_match_connectors"), + settings["show_match_connectors"], + ) + settings["show_match_labels"] = _coerce_plot_bool(source.get("show_match_labels"), settings["show_match_labels"]) + settings["show_intermediate_traces"] = _coerce_plot_bool( + source.get("show_intermediate_traces"), + settings.get("show_intermediate_traces", False), + ) + style_preset = str(source.get("style_preset") or settings["style_preset"]).strip().lower() + settings["style_preset"] = style_preset if style_preset in {"color_shape", "color_only", "shape_only"} else "color_shape" + settings["x_range_enabled"] = _coerce_plot_bool(source.get("x_range_enabled"), settings["x_range_enabled"]) + settings["x_min"] = _coerce_optional_plot_float(source.get("x_min")) + settings["x_max"] = _coerce_optional_plot_float(source.get("x_max")) + if ( + settings["x_range_enabled"] + and settings["x_min"] is not None + and settings["x_max"] is not None + and settings["x_min"] > settings["x_max"] + ): + settings["x_min"], settings["x_max"] = settings["x_max"], settings["x_min"] + settings["y_range_enabled"] = _coerce_plot_bool(source.get("y_range_enabled"), settings["y_range_enabled"]) + settings["y_min"] = _coerce_optional_plot_float(source.get("y_min")) + settings["y_max"] = _coerce_optional_plot_float(source.get("y_max")) + if ( + settings["y_range_enabled"] + and settings["y_min"] is not None + and settings["y_max"] is not None + and settings["y_min"] > settings["y_max"] + ): + settings["y_min"], settings["y_max"] = settings["y_max"], settings["y_min"] + settings["log_y"] = _coerce_plot_bool(source.get("log_y"), settings["log_y"]) + settings["line_width"] = _coerce_plot_float(source.get("line_width"), float(settings["line_width"]), 0.8, 5.0) + return settings + + +def _reference_marker_y(value: Any, observed_max_intensity: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = 0.0 + if parsed <= 1.5: + return max(parsed * max(observed_max_intensity, 1.0), 0.0) + return max(parsed, 0.0) + + +def _xrd_match_marker_style(kind: str, settings: Mapping[str, Any]) -> dict[str, Any]: + base = dict(_XRD_MATCH_STYLE.get(kind) or {"color": "#94A3B8", "symbol": "circle"}) + style_preset = str(settings.get("style_preset") or "color_shape").lower() + if style_preset == "color_only": + base["symbol"] = "circle" + elif style_preset == "shape_only": + base["color"] = "#CBD5E1" + return base + + +def _xrd_peak_label(position: float, intensity: float, *, settings: Mapping[str, Any], lang: str) -> str: + pos_precision = int(settings.get("label_position_precision", 2)) + intensity_precision = int(settings.get("label_intensity_precision", 0)) + angle_unit = "°" if lang == "tr" else " deg" + return f"{position:.{pos_precision}f}{angle_unit} | I={intensity:.{intensity_precision}f}" + + +def _pick_peak_label_indices(peaks: list[dict[str, float]], settings: Mapping[str, Any]) -> set[int]: + if not peaks or not bool(settings.get("show_peak_labels", True)): + return set() + + label_mode = str(settings.get("label_density_mode") or "smart").lower() + if label_mode == "selected": + label_mode = "smart" + + max_labels = int(settings.get("max_labels", 10)) + if max_labels <= 0: + return set() + + intensities = [max(float(item.get("intensity", 0.0)), 0.0) for item in peaks] + max_intensity = max(intensities) if intensities else 0.0 + ratio_threshold = float(settings.get("min_label_intensity_ratio", 0.12)) + threshold = max_intensity * max(ratio_threshold, 0.0) + + ranked_indices = sorted( + range(len(peaks)), + key=lambda idx: ( + -float(peaks[idx].get("intensity", 0.0)), + float(peaks[idx].get("position", 0.0)), + ), + ) + chosen: set[int] = set() + if label_mode == "all": + for idx in ranked_indices[:max_labels]: + chosen.add(idx) + return chosen + + for idx in ranked_indices: + if float(peaks[idx].get("intensity", 0.0)) >= threshold: + chosen.add(idx) + if len(chosen) >= max_labels: + break + if not chosen: + for idx in ranked_indices[:max_labels]: + chosen.add(idx) + return chosen + + +def build_xrd_result_figure( + *, + axis: list[float], + raw_signal: list[float], + smoothed: list[float], + baseline: list[float], + corrected: list[float], + peaks: list[dict[str, Any]], + selected_match: dict[str, Any] | None, + plot_settings: Mapping[str, Any] | None, + ui_theme: str | None, + loc: str, + sample_name: str, + axis_title: str, +) -> go.Figure: + settings = normalize_xrd_plot_settings(plot_settings) + line_width = float(settings.get("line_width", 2.0)) + tone = normalize_ui_theme(ui_theme) + pt = PLOT_THEME[tone] + muted = "#66645E" if tone == "light" else "#9E9A93" + line_primary = pt["text"] + + has_corrected = bool(corrected and len(corrected) == len(axis)) + has_smoothed = bool(smoothed and len(smoothed) == len(axis)) + has_raw = bool(raw_signal and len(raw_signal) == len(axis)) + has_baseline = bool(baseline and len(baseline) == len(axis)) + primary_signal = corrected if has_corrected else smoothed if has_smoothed else raw_signal + + legend_raw = translate_ui(loc, "dash.analysis.figure.legend_raw_diffractogram") + legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed_diffractogram") + legend_corr = translate_ui(loc, "dash.analysis.figure.legend_corrected_diffractogram") + primary_name = legend_corr if has_corrected else legend_smooth if has_smoothed else legend_raw + has_overlay = has_corrected or has_smoothed + + fig = go.Figure() + show_intermediate = bool(settings.get("show_intermediate_traces")) + if has_raw: + fig.add_trace( + go.Scatter( + x=axis, + y=raw_signal, + mode="lines", + name=legend_raw, + line=dict(color="#94A3B8", width=max(0.8, line_width - 0.4)), + opacity=0.35 if has_overlay else 0.95, + showlegend=bool(show_intermediate) or not has_overlay, + ) + ) + if show_intermediate and has_smoothed: + fig.add_trace( + go.Scatter( + x=axis, + y=smoothed, + mode="lines", + name=legend_smooth, + line=dict(color="#0369A1", width=max(1.0, line_width - 0.2)), + opacity=0.85 if has_corrected else 1.0, + ) + ) + if show_intermediate and has_baseline: + fig.add_trace( + go.Scatter( + x=axis, + y=baseline, + mode="lines", + name=translate_ui(loc, "dash.analysis.figure.legend_baseline"), + line=dict(color="#6D28D9", width=max(0.9, line_width - 0.8), dash="dash"), + opacity=0.7, + ) + ) + + fig.add_trace( + go.Scatter( + x=axis, + y=primary_signal, + mode="lines", + name=primary_name, + line=dict(color=line_primary, width=line_width + 1.0), + ) + ) + + peak_x: list[float] = [] + peak_y: list[float] = [] + peak_text: list[str] = [] + if peaks: + label_indices = _pick_peak_label_indices(peaks, settings) + peak_x = [float(item.get("position", 0.0)) for item in peaks] + peak_y = [float(item.get("intensity", 0.0)) for item in peaks] + peak_text = [ + _xrd_peak_label(peak_x[idx], peak_y[idx], settings=settings, lang=loc) if idx in label_indices else "" + for idx in range(len(peaks)) + ] + fig.add_trace( + go.Scatter( + x=peak_x, + y=peak_y, + mode="markers", + name=translate_ui(loc, "dash.analysis.xrd.figure.peaks"), + marker=dict(color="#D97706", size=int(settings.get("marker_size", 8)), symbol="diamond"), + ) + ) + + subtitle = None + if selected_match: + evidence = dict((selected_match.get("evidence") or {})) + matched_pairs = [item for item in (evidence.get("matched_peak_pairs") or []) if isinstance(item, Mapping)] + unmatched_observed = [item for item in (evidence.get("unmatched_observed_peaks") or []) if isinstance(item, Mapping)] + unmatched_reference = [item for item in (evidence.get("unmatched_reference_peaks") or []) if isinstance(item, Mapping)] + + cand = ( + str(selected_match.get("display_name_unicode") or "").strip() + or str(selected_match.get("display_name") or "").strip() + or str(selected_match.get("candidate_name") or "").strip() + or translate_ui(loc, "dash.analysis.xrd.candidate_unknown") + ) + subtitle = translate_ui(loc, "dash.analysis.xrd.figure.selected_candidate", name=cand) + + observed_max = max([float(item.get("intensity", 0.0)) for item in peaks] + [1.0]) + + if settings.get("show_match_connectors") and matched_pairs: + for pair in matched_pairs: + try: + obs_x = float(pair.get("observed_position")) + obs_y = float(pair.get("observed_intensity")) + ref_x = float(pair.get("reference_position")) + ref_y = _reference_marker_y(pair.get("reference_intensity"), observed_max) + except (TypeError, ValueError): + continue + fig.add_shape( + type="line", + x0=obs_x, + y0=obs_y, + x1=ref_x, + y1=ref_y, + line=dict(color="rgba(148, 163, 184, 0.55)", width=1.0, dash="dot"), + ) + + if settings.get("show_matched_peaks") and matched_pairs: + mo_style = _xrd_match_marker_style("matched_observed", settings) + mr_style = _xrd_match_marker_style("matched_reference", settings) + matched_text = ( + [ + f"Δ2θ={float(item.get('delta_position') or 0.0):.3f}" + for item in matched_pairs + ] + if settings.get("show_match_labels") + else None + ) + fig.add_trace( + go.Scatter( + x=[float(item.get("observed_position", 0.0)) for item in matched_pairs], + y=[float(item.get("observed_intensity", 0.0)) for item in matched_pairs], + mode="markers+text" if matched_text else "markers", + name=translate_ui(loc, "dash.analysis.xrd.figure.matched_observed"), + marker=dict( + color=mo_style["color"], + size=int(settings.get("marker_size", 8)) + 1, + symbol=mo_style["symbol"], + line=dict(width=1, color="#052E16"), + ), + text=matched_text, + textposition="top center", + textfont=dict(size=9.5, color="#3F5E4B"), + ) + ) + fig.add_trace( + go.Scatter( + x=[float(item.get("reference_position", 0.0)) for item in matched_pairs], + y=[_reference_marker_y(item.get("reference_intensity"), observed_max) for item in matched_pairs], + mode="markers", + name=translate_ui(loc, "dash.analysis.xrd.figure.matched_reference"), + marker=dict( + color=mr_style["color"], + size=int(settings.get("marker_size", 8)), + symbol=mr_style["symbol"], + ), + ) + ) + + if settings.get("show_unmatched_observed") and unmatched_observed: + uo_style = _xrd_match_marker_style("unmatched_observed", settings) + fig.add_trace( + go.Scatter( + x=[float(item.get("position", 0.0)) for item in unmatched_observed], + y=[float(item.get("intensity", 0.0)) for item in unmatched_observed], + mode="markers", + name=translate_ui(loc, "dash.analysis.xrd.figure.unmatched_observed"), + marker=dict( + color=uo_style["color"], + size=int(settings.get("marker_size", 8)), + symbol=uo_style["symbol"], + ), + ) + ) + + if settings.get("show_unmatched_reference") and unmatched_reference: + ur_style = _xrd_match_marker_style("unmatched_reference", settings) + ref_y = [_reference_marker_y(item.get("intensity"), observed_max) for item in unmatched_reference] + fig.add_trace( + go.Scatter( + x=[float(item.get("position", 0.0)) for item in unmatched_reference], + y=ref_y, + mode="markers", + name=translate_ui(loc, "dash.analysis.xrd.figure.unmatched_reference"), + marker=dict( + color=ur_style["color"], + size=int(settings.get("marker_size", 8)), + symbol=ur_style["symbol"], + ), + ) + ) + + if any(peak_text): + fig.add_trace( + go.Scatter( + x=peak_x, + y=peak_y, + mode="text", + text=peak_text, + textposition="top center", + textfont=dict(size=10.5, color="#475569"), + hoverinfo="skip", + showlegend=False, + ) + ) + + title_main = translate_ui(loc, "dash.analysis.figure.title_xrd_main") + sub_html = f"
{sample_name}" + if subtitle: + sub_html += f"
{subtitle}" + fig.update_layout( + title=(f"{title_main}{sub_html}"), + paper_bgcolor=pt["paper_bg"], + plot_bgcolor=pt["plot_bg"], + hovermode="x unified", + xaxis_title=axis_title, + yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_intensity_au"), + margin=dict(l=64, r=24, t=88, b=72), + height=500, + legend=dict( + orientation="h", + yanchor="top", + y=-0.14, + xanchor="left", + x=0, + font=dict(size=10), + traceorder="normal", + ), + ) + + x_min = settings.get("x_min") + x_max = settings.get("x_max") + if settings.get("x_range_enabled") and x_min is not None and x_max is not None: + fig.update_xaxes(range=[float(x_min), float(x_max)]) + else: + fig.update_xaxes(autorange=True) + + y_min = settings.get("y_min") + y_max = settings.get("y_max") + if settings.get("log_y"): + fig.update_yaxes(type="log") + if settings.get("y_range_enabled") and y_min is not None and y_max is not None: + fig.update_yaxes( + range=[math.log10(max(float(y_min), 1e-6)), math.log10(max(float(y_max), 1e-6))] + ) + else: + fig.update_yaxes(type="linear") + if settings.get("y_range_enabled") and y_min is not None and y_max is not None: + fig.update_yaxes(range=[float(y_min), float(y_max)]) + + apply_figure_theme(fig, ui_theme) + return fig diff --git a/dash_app/i18n.py b/dash_app/i18n.py new file mode 100644 index 00000000..c8c8fa9b --- /dev/null +++ b/dash_app/i18n.py @@ -0,0 +1,24 @@ +"""Dash-facing locale helpers (thin wrapper over shared ``utils.i18n`` translations). + +All user-visible strings for the Dash app should resolve through :func:`t` so the +sidebar ``ui-locale`` store and pages share one catalog (``utils.i18n.TRANSLATIONS``). +""" + +from __future__ import annotations + +from typing import Any, Final + +from utils.i18n import normalize_ui_locale, translate_ui + +DEFAULT_LOCALE: Final[str] = "en" +SUPPORTED_LOCALES: Final[tuple[str, ...]] = ("en", "tr") + + +def normalize_locale(locale: str | None) -> str: + """Normalize locale to ``en`` or ``tr`` (BCP-47 prefixes accepted).""" + return normalize_ui_locale(locale) + + +def t(locale: str | None, key: str, **kwargs: Any) -> str: + """Translate *key* for Dash using explicit *locale* (same catalog as Streamlit-free ``translate_ui``).""" + return translate_ui(locale, key, **kwargs) diff --git a/dash_app/import_preview.py b/dash_app/import_preview.py new file mode 100644 index 00000000..1d2946f1 --- /dev/null +++ b/dash_app/import_preview.py @@ -0,0 +1,89 @@ +"""Helpers for Dash import preview and column mapping.""" + +from __future__ import annotations + +import base64 +import io +import os +from typing import Any + +import pandas as pd + +from core.data_io import detect_file_format, guess_columns + + +def decode_base64_content(content_string: str) -> bytes: + return base64.b64decode(content_string.encode("ascii")) + + +def load_raw_preview_dataframe(file_name: str, file_bytes: bytes) -> pd.DataFrame: + source = io.BytesIO(file_bytes) + source.name = file_name + + raw_ext = os.path.splitext(file_name)[1].lower() + if raw_ext in (".xlsx", ".xls"): + df = pd.read_excel(source) + if all(isinstance(col, int) for col in df.columns): + df.columns = [f"Column {index + 1}" for index in range(len(df.columns))] + return df + + fmt = detect_file_format(source) + source.seek(0) + delimiter = fmt.get("delimiter", ",") + sep = r"\s+" if delimiter == " " else delimiter + header = fmt.get("header_row", 0) + encoding = fmt.get("encoding", "utf-8") + + try: + df = pd.read_csv( + source, + sep=sep, + header=header, + encoding=encoding, + engine="python", + skip_blank_lines=True, + ) + except Exception: + source.seek(0) + df = pd.read_csv( + source, + sep=r"\s+", + header=None, + encoding=encoding, + engine="python", + skip_blank_lines=True, + ) + else: + numeric_headers = sum( + 1 for col in df.columns + if pd.to_numeric(pd.Series([col]), errors="coerce").notna().all() + ) + if len(df.columns) <= 3 and numeric_headers == len(df.columns): + source.seek(0) + df = pd.read_csv( + source, + sep=r"\s+", + header=None, + encoding=encoding, + engine="python", + skip_blank_lines=True, + ) + + if all(isinstance(col, int) for col in df.columns): + df.columns = [f"Column {index + 1}" for index in range(len(df.columns))] + return df + + +def build_import_preview(file_name: str, content_string: str, modality: str | None = None) -> dict[str, Any]: + file_bytes = decode_base64_content(content_string) + frame = load_raw_preview_dataframe(file_name, file_bytes) + guessed = guess_columns(frame, source_name=file_name, modality=modality) + preview = frame.head(20).copy().where(pd.notna(frame.head(20)), None) + return { + "file_name": file_name, + "file_base64": content_string, + "columns": [str(column) for column in frame.columns], + "preview_rows": preview.to_dict(orient="records"), + "guessed_mapping": guessed, + "row_count": len(frame), + } diff --git a/dash_app/layout.py b/dash_app/layout.py new file mode 100644 index 00000000..d90acfcb --- /dev/null +++ b/dash_app/layout.py @@ -0,0 +1,298 @@ +"""Root layout: sidebar navigation + page container.""" + +from __future__ import annotations + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html + +from dash_app.i18n import SUPPORTED_LOCALES, normalize_locale, t + +NAV_PRIMARY_DEF: list[tuple[str, str, str]] = [ + ("nav.import", "bi-folder2-open", "/"), + ("nav.project", "bi-archive", "/project"), + ("nav.report", "bi-file-earmark-text", "/export"), + ("nav.compare", "bi-intersect", "/compare"), +] + +NAV_ANALYSIS_DEF: list[tuple[str, str, str]] = [ + ("nav.dsc", "bi-graph-up", "/dsc"), + ("nav.tga", "bi-graph-down", "/tga"), + ("nav.dta", "bi-bar-chart", "/dta"), + ("nav.ftir", "bi-border-style", "/ftir"), + ("nav.raman", "bi-lightbulb", "/raman"), + ("nav.xrd", "bi-bullseye", "/xrd"), +] + +NAV_MANAGEMENT_DEF: list[tuple[str, str, str]] = [ + ("nav.about", "bi-info-circle", "/about"), +] + + +def _sidebar_history(locale: str) -> html.Div: + loc = normalize_locale(locale) + return html.Div( + [ + html.Div( + t(loc, "sidebar.history_title"), + className="sidebar-section-label", + ), + html.Div( + id="sidebar-history-panel", + children=html.Small(t(loc, "sidebar.history_empty"), className="text-muted"), + className="sidebar-history-list", + ), + ], + className="px-3 pb-2", + ) + + +def _nav_section(locale: str, title_key: str, defs: list[tuple[str, str, str]]) -> html.Div: + links = [] + for label_key, icon, href in defs: + link = dbc.NavLink( + [html.I(className=f"bi {icon} me-2"), t(locale, label_key)], + href=href, + active="exact", + disabled=False, + className="sidebar-nav-link", + ) + links.append(link) + return html.Div( + [ + html.Div(t(locale, title_key), className="sidebar-section-label"), + dbc.Nav(links, vertical=True), + ], + className="mb-2", + ) + + +def _sidebar_controls(locale: str, theme: str) -> html.Div: + theme = theme if theme in ("light", "dark") else "light" + next_is_dark = theme == "light" + tip = t(locale, "ui.theme_use_dark" if next_is_dark else "ui.theme_use_light") + label_cur = t(locale, "ui.theme_current_light" if theme == "light" else "ui.theme_current_dark") + return html.Div( + [ + html.Div( + t(locale, "ui.theme_hint"), + className="sidebar-control-group-label", + ), + dbc.Button( + [ + html.I(className=("bi bi-moon-stars" if next_is_dark else "bi bi-sun")), + html.Span(label_cur, className="ms-2 sidebar-theme-btn-text"), + ], + id="theme-toggle", + color="light", + outline=True, + size="sm", + className="sidebar-theme-btn w-100 mb-2", + n_clicks=0, + title=tip, + ), + html.Div( + t(locale, "ui.language"), + className="sidebar-control-group-label", + ), + html.Div( + dcc.Dropdown( + id="locale-select", + options=[ + {"label": "English", "value": "en"}, + {"label": "Türkçe", "value": "tr"}, + ], + value=normalize_locale(locale), + clearable=False, + searchable=False, + className="ta-dropdown ta-dropdown--sidebar", + ), + className="sidebar-locale-wrap", + ), + ], + className="sidebar-controls px-3 pb-2", + ) + + +def build_sidebar_inner(locale: str, theme: str) -> html.Div: + loc = normalize_locale(locale) + return html.Div( + [ + html.Div( + [ + html.Div("MaterialScope", className="sidebar-brand"), + html.Div(t(loc, "dash.sidebar.tagline"), className="sidebar-version"), + ], + className="px-3 pt-3 pb-2", + ), + _sidebar_controls(loc, theme), + html.Hr(className="sidebar-hr"), + html.Div( + [ + _nav_section(loc, "nav.section_primary", NAV_PRIMARY_DEF), + _nav_section(loc, "nav.section_analysis", NAV_ANALYSIS_DEF), + _nav_section(loc, "nav.section_management", NAV_MANAGEMENT_DEF), + html.Hr(className="sidebar-hr my-2"), + _sidebar_history(loc), + ], + className="px-2", + ), + html.Div( + [ + html.Hr(className="sidebar-hr"), + html.Div(id="sidebar-dataset-badge", className="px-3 pb-3"), + ], + className="mt-auto", + ), + ], + className="d-flex flex-column h-100", + ) + + +def _sidebar() -> html.Div: + return html.Div( + [ + html.Div( + id="sidebar-inner", + children=build_sidebar_inner("en", "light"), + ), + ], + className="sidebar d-flex flex-column", + ) + + +def build_layout() -> html.Div: + return html.Div( + [ + dcc.Store(id="project-id", storage_type="session"), + dcc.Store(id="workspace-data", storage_type="memory"), + dcc.Store(id="workspace-refresh", storage_type="memory", data=0), + dcc.Store(id="ui-theme", data="light", storage_type="session"), + dcc.Store(id="ui-locale", data="en", storage_type="session"), + html.Div(id="_clientside-theme-holder", style={"display": "none"}), + dcc.Location(id="url", refresh="callback-nav"), + html.Div( + [ + html.Div(_sidebar(), className="sidebar-container"), + html.Div( + dash.page_container, + className="main-content", + ), + ], + className="app-wrapper", + ), + ] + ) + + +def register_clientside_theme(app: dash.Dash) -> None: + app.clientside_callback( + """ + function(theme) { + const t = (theme === 'dark') ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', t); + return ''; + } + """, + Output("_clientside-theme-holder", "children"), + Input("ui-theme", "data"), + ) + + +@callback( + Output("sidebar-inner", "children"), + Input("ui-locale", "data"), + Input("ui-theme", "data"), +) +def render_sidebar(locale: str | None, theme: str | None) -> html.Div: + th = theme if theme in ("light", "dark") else "light" + return build_sidebar_inner(normalize_locale(locale), th) + + +@callback( + Output("ui-theme", "data"), + Input("theme-toggle", "n_clicks"), + State("ui-theme", "data"), + prevent_initial_call=True, +) +def toggle_ui_theme(n_clicks: int | None, current: str | None) -> str: + if not n_clicks: + raise dash.exceptions.PreventUpdate + cur = current if current in ("light", "dark") else "light" + return "dark" if cur == "light" else "light" + + +@callback( + Output("ui-locale", "data"), + Input("locale-select", "value"), + prevent_initial_call=True, +) +def persist_ui_locale(value: str | None) -> str: + if not value: + return "en" + v = str(value).lower().split("-", 1)[0] + return v if v in SUPPORTED_LOCALES else "en" + + +@callback( + Output("project-id", "data"), + Input("project-id", "data"), + prevent_initial_call=False, +) +def ensure_project(current_id): + """Auto-create a workspace on first load; validate stale ids after server restart.""" + from dash_app.api_client import workspace_new, workspace_summary + + if current_id: + try: + resp = workspace_summary(current_id) + if resp.get("project_id"): + return dash.no_update + except Exception: + pass # project_id is stale (server restart) — fall through to create new + + try: + result = workspace_new() + return result.get("project_id") + except Exception: + return None + + +@callback( + Output("sidebar-history-panel", "children"), + Input("project-id", "data"), + Input("workspace-refresh", "data"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def refresh_sidebar_history(project_id, _refresh, locale): + loc = normalize_locale(locale) + if not project_id: + return html.Small(t(loc, "sidebar.history_empty"), className="text-muted") + + from dash_app.api_client import workspace_context + + try: + context = workspace_context(project_id) + except Exception: + return html.Small(t(loc, "sidebar.history_empty"), className="text-muted") + + history = context.get("recent_history") or [] + if not history: + return html.Small(t(loc, "sidebar.history_empty"), className="text-muted") + + items = [] + for item in history: + ts = item.get("timestamp", "--") + action = item.get("action", "?") + detail = item.get("details", "") + label = f"{action}" + if detail: + label += f" — {detail}" + items.append( + html.Li( + [html.Small(ts, className="d-block text-muted sidebar-history-ts"), html.Span(label)], + className="sidebar-history-item", + ) + ) + return html.Ul(items, className="sidebar-history-list mb-0") diff --git a/dash_app/pages/__init__.py b/dash_app/pages/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/dash_app/pages/__init__.py @@ -0,0 +1 @@ + diff --git a/dash_app/pages/about.py b/dash_app/pages/about.py new file mode 100644 index 00000000..d973e08e --- /dev/null +++ b/dash_app/pages/about.py @@ -0,0 +1,44 @@ +"""About page -- static product info.""" + +import dash +from dash import html +import dash_bootstrap_components as dbc + +from dash_app.components.chrome import page_header + +dash.register_page(__name__, path="/about", title="About - MaterialScope") + + +layout = html.Div([ + page_header("About MaterialScope", "Product information and capabilities.", badge="Product Info"), + + dbc.Card([ + dbc.CardBody([ + html.H4("Overview", className="mb-3"), + html.P( + "MaterialScope is a vendor-independent, multimodal characterization " + "workbench for QC and R&D laboratories." + ), + html.P( + "It supports DSC, TGA, DTA, FTIR, Raman, and XRD data import, " + "analysis, comparison, and reporting -- all within a single unified workspace." + ), + html.Hr(), + html.H5("Supported Modalities"), + dbc.ListGroup([ + dbc.ListGroupItem("DSC -- Differential Scanning Calorimetry"), + dbc.ListGroupItem("TGA -- Thermogravimetric Analysis"), + dbc.ListGroupItem("DTA -- Differential Thermal Analysis"), + dbc.ListGroupItem("FTIR -- Fourier-Transform Infrared Spectroscopy"), + dbc.ListGroupItem("RAMAN -- Raman Spectroscopy"), + dbc.ListGroupItem("XRD -- X-Ray Diffraction"), + ], flush=True, className="mb-3"), + html.Hr(), + html.H5("Architecture"), + html.P( + "This application uses a Dash + Plotly frontend with a FastAPI backend " + "and a pure-Python scientific core engine." + ), + ]) + ], className="mb-4"), +]) diff --git a/dash_app/pages/compare.py b/dash_app/pages/compare.py new file mode 100644 index 00000000..6b17eef8 --- /dev/null +++ b/dash_app/pages/compare.py @@ -0,0 +1,636 @@ +"""Compare workspace -- best-available analysis-state overlays, raw import mode, and batch runs.""" + +from __future__ import annotations + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import plotly.graph_objects as go + +from core.modalities import get_modality, stable_analysis_types +from core.processing_schema import get_workflow_templates +from dash_app.compare_curve_utils import axis_titles, pick_best_series +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.page_guidance import ( + guidance_block, + next_step_block, + prereq_or_empty_help, + typical_workflow_block, +) +from dash_app.theme import apply_figure_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/compare", title="Compare - MaterialScope") + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _eligible_dataset(dataset: dict, analysis_type: str) -> bool: + modality = get_modality(analysis_type) + if modality is None: + return False + return modality.adapter.is_dataset_eligible(str(dataset.get("data_type") or "UNKNOWN")) + + +def _available_types(datasets: list[dict]) -> list[str]: + options: list[str] = [] + for token in stable_analysis_types(): + if any(_eligible_dataset(dataset, token) for dataset in datasets): + options.append(token) + return options + + +def _compare_prereq_state(datasets: list[dict], analysis_type: str | None, selected_runs: list[str] | None) -> str | None: + """Return a human-readable prerequisite state for compare guidance.""" + if not datasets: + return "no_datasets" + available_types = _available_types(datasets) + if not available_types: + return "no_eligible_types" + if not analysis_type: + return "select_analysis_type" + run_count = len(selected_runs or []) + if run_count < 2: + return "insufficient_overlay_runs" + return None + + +layout = html.Div( + [ + dcc.Store(id="compare-refresh", data=0), + html.Div(id="compare-hero-slot"), + html.Div(id="compare-guidance-slot", className="mb-2"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Card( + dbc.CardBody( + [ + dbc.Label(id="compare-lbl-analysis-type", children=""), + dbc.Select(id="compare-analysis-type"), + dbc.Label(id="compare-lbl-selected-runs", className="mt-3", children=""), + html.Div( + id="compare-selected-runs-shell", + children=dcc.Dropdown( + id="compare-selected-runs", + multi=True, + className="ta-dropdown", + ), + ), + dbc.Label(id="compare-lbl-overlay-signal", className="mt-3", children=""), + dbc.RadioItems( + id="compare-signal-mode", + options=[ + {"label": "—", "value": "best"}, + {"label": "—", "value": "raw"}, + ], + value="best", + inline=False, + ), + dbc.Label(id="compare-lbl-workspace-notes", className="mt-3", children=""), + dbc.Textarea(id="compare-notes", style={"height": "120px"}), + dbc.Button("", id="save-compare-workspace-btn", color="primary", className="mt-3"), + html.Div(id="compare-status", className="mt-3"), + ] + ), + className="mb-4", + ), + dbc.Card( + dbc.CardBody( + [ + html.H5(id="compare-h5-batch", className="mb-3", children=""), + dbc.Label(id="compare-lbl-workflow-template", children=""), + dbc.Select(id="compare-batch-template-select"), + dbc.Button( + "", + id="compare-batch-run-btn", + color="secondary", + className="mt-3", + ), + html.Div(id="compare-batch-status", className="mt-3"), + ] + ), + className="mb-4", + ), + dbc.Card(dbc.CardBody(html.Div(id="compare-overlay-panel")), className="mb-4"), + ], + md=8, + ), + dbc.Col( + [ + dbc.Card(dbc.CardBody(html.Div(id="compare-summary-panel")), className="mb-4"), + dbc.Card(dbc.CardBody(html.Div(id="compare-saved-result-preview")), className="mb-4"), + dbc.Card(dbc.CardBody(html.Div(id="compare-batch-summary-panel")), className="mb-4"), + ], + md=4, + ), + ] + ), + ] +) + + +@callback( + Output("compare-hero-slot", "children"), + Output("compare-guidance-slot", "children"), + Output("compare-lbl-analysis-type", "children"), + Output("compare-lbl-selected-runs", "children"), + Output("compare-lbl-overlay-signal", "children"), + Output("compare-lbl-workspace-notes", "children"), + Output("save-compare-workspace-btn", "children"), + Output("compare-h5-batch", "children"), + Output("compare-lbl-workflow-template", "children"), + Output("compare-batch-run-btn", "children"), + Output("compare-signal-mode", "options"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def render_compare_locale_chrome(locale_data): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.compare.title"), + translate_ui(loc, "dash.compare.caption"), + badge=translate_ui(loc, "dash.compare.badge"), + ) + guidance = html.Div( + [ + guidance_block( + translate_ui(loc, "dash.compare.guidance_what_title"), + body=translate_ui(loc, "dash.compare.guidance_what_body"), + ), + typical_workflow_block( + [ + translate_ui(loc, "dash.compare.workflow_step1"), + translate_ui(loc, "dash.compare.workflow_step2"), + translate_ui(loc, "dash.compare.workflow_step3"), + ], + locale=loc, + ), + guidance_block( + translate_ui(loc, "dash.compare.usage_title"), + bullets=[ + translate_ui(loc, "dash.compare.usage_bullet1"), + translate_ui(loc, "dash.compare.usage_bullet2"), + ], + tone="secondary", + ), + next_step_block(translate_ui(loc, "dash.compare.next_step_body"), locale=loc), + ] + ) + sig_opts = [ + {"label": translate_ui(loc, "dash.compare.overlay_best"), "value": "best"}, + {"label": translate_ui(loc, "dash.compare.overlay_raw"), "value": "raw"}, + ] + return ( + hero, + guidance, + translate_ui(loc, "dash.compare.label_analysis_type"), + translate_ui(loc, "dash.compare.label_selected_runs"), + translate_ui(loc, "dash.compare.label_overlay_signal"), + translate_ui(loc, "dash.compare.label_workspace_notes"), + translate_ui(loc, "dash.compare.btn_save_workspace"), + translate_ui(loc, "dash.compare.batch_title"), + translate_ui(loc, "dash.compare.batch_label_template"), + translate_ui(loc, "dash.compare.batch_run_btn"), + sig_opts, + ) + + +@callback( + Output("compare-analysis-type", "options"), + Output("compare-analysis-type", "value"), + Output("compare-selected-runs", "options"), + Output("compare-selected-runs", "value"), + Output("compare-notes", "value"), + Input("project-id", "data"), + Input("compare-refresh", "data"), + Input("workspace-refresh", "data"), + prevent_initial_call=False, +) +def load_compare_workspace(project_id, _refresh, _global_refresh): + if not project_id: + return [], None, [], [], "" + + from dash_app.api_client import compare_workspace, workspace_datasets + + datasets = workspace_datasets(project_id).get("datasets", []) + available_types = _available_types(datasets) + if not available_types: + return [], None, [], [], "" + + workspace = compare_workspace(project_id).get("compare_workspace", {}) + analysis_type = workspace.get("analysis_type") + if analysis_type not in available_types: + analysis_type = available_types[0] + eligible = [item for item in datasets if _eligible_dataset(item, analysis_type)] + run_options = [{"label": item.get("display_name", item.get("key")), "value": item.get("key")} for item in eligible] + selected = [key for key in (workspace.get("selected_datasets") or []) if key in {item["value"] for item in run_options}] + return ( + [{"label": token, "value": token} for token in available_types], + analysis_type, + run_options, + selected, + workspace.get("notes") or "", + ) + + +@callback( + Output("compare-selected-runs-shell", "children"), + Input("ui-theme", "data"), + State("compare-selected-runs", "options"), + State("compare-selected-runs", "value"), + prevent_initial_call=True, +) +def remount_compare_selected_runs_dropdown(_ui_theme, options, value): + return dcc.Dropdown( + id="compare-selected-runs", + multi=True, + className="ta-dropdown", + options=options or [], + value=value or [], + ) + + +@callback( + Output("compare-selected-runs", "options", allow_duplicate=True), + Output("compare-selected-runs", "value", allow_duplicate=True), + Input("compare-analysis-type", "value"), + State("project-id", "data"), + State("compare-selected-runs", "value"), + prevent_initial_call=True, +) +def update_compare_eligible_runs(analysis_type, project_id, selected_runs): + if not analysis_type or not project_id: + raise dash.exceptions.PreventUpdate + from dash_app.api_client import workspace_datasets + + datasets = workspace_datasets(project_id).get("datasets", []) + eligible = [item for item in datasets if _eligible_dataset(item, analysis_type)] + run_options = [{"label": item.get("display_name", item.get("key")), "value": item.get("key")} for item in eligible] + allowed = {item["value"] for item in run_options} + selected = [key for key in (selected_runs or []) if key in allowed] + return run_options, selected + + +@callback( + Output("compare-batch-template-select", "options"), + Output("compare-batch-template-select", "value"), + Input("compare-analysis-type", "value"), + Input("project-id", "data"), + Input("compare-refresh", "data"), + prevent_initial_call=False, +) +def load_batch_templates(analysis_type, project_id, _refresh): + if not project_id or not analysis_type: + return [], None + templates = get_workflow_templates(analysis_type) + options = [{"label": entry.get("label", entry["id"]), "value": entry["id"]} for entry in templates] + default_value = options[0]["value"] if options else None + return options, default_value + + +@callback( + Output("compare-status", "children"), + Output("compare-refresh", "data", allow_duplicate=True), + Input("save-compare-workspace-btn", "n_clicks"), + State("project-id", "data"), + State("compare-analysis-type", "value"), + State("compare-selected-runs", "value"), + State("compare-notes", "value"), + State("compare-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_compare_workspace(n_clicks, project_id, analysis_type, selected_runs, notes, refresh_value, locale_data): + if not n_clicks or not project_id or not analysis_type: + raise dash.exceptions.PreventUpdate + loc = _loc(locale_data) + from dash_app.api_client import update_compare_workspace + + try: + update_compare_workspace( + project_id, + analysis_type=analysis_type, + selected_datasets=selected_runs or [], + notes=notes or "", + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.compare.save_fail", error=exc), color="danger"), dash.no_update + return dbc.Alert(translate_ui(loc, "dash.compare.save_ok"), color="success"), int(refresh_value or 0) + 1 + + +@callback( + Output("compare-batch-status", "children"), + Output("compare-refresh", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("compare-batch-run-btn", "n_clicks"), + State("project-id", "data"), + State("compare-analysis-type", "value"), + State("compare-selected-runs", "value"), + State("compare-batch-template-select", "value"), + State("compare-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def run_compare_batch( + n_clicks, project_id, analysis_type, selected_runs, template_id, compare_refresh, workspace_refresh, locale_data +): + loc = _loc(locale_data) + if not n_clicks or not project_id or not analysis_type: + raise dash.exceptions.PreventUpdate + selected_runs = selected_runs or [] + if len(selected_runs) < 1: + return dbc.Alert(translate_ui(loc, "dash.compare.batch_need_selection"), color="warning"), dash.no_update, dash.no_update + + from dash_app.api_client import workspace_batch_run + + try: + result = workspace_batch_run( + project_id, + analysis_type=analysis_type, + workflow_template_id=template_id, + dataset_keys=selected_runs, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.compare.batch_fail", error=exc), color="danger"), dash.no_update, dash.no_update + + outcomes = result.get("outcomes") or {} + msg = translate_ui( + loc, + "dash.compare.batch_complete", + saved=outcomes.get("saved", 0), + blocked=outcomes.get("blocked", 0), + failed=outcomes.get("failed", 0), + ) + alert = dbc.Alert(msg, color="success") + return ( + alert, + int(compare_refresh or 0) + 1, + int(workspace_refresh or 0) + 1, + ) + + +@callback( + Output("compare-overlay-panel", "children"), + Output("compare-summary-panel", "children"), + Output("compare-saved-result-preview", "children"), + Output("compare-batch-summary-panel", "children"), + Input("project-id", "data"), + Input("compare-analysis-type", "value"), + Input("compare-selected-runs", "value"), + Input("compare-signal-mode", "value"), + Input("compare-refresh", "data"), + Input("workspace-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def render_compare_workspace( + project_id, + analysis_type, + selected_runs, + signal_mode, + _compare_refresh, + _workspace_refresh, + ui_theme, + locale_data, +): + loc = _loc(locale_data) + if not project_id: + empty = prereq_or_empty_help( + translate_ui(loc, "dash.compare.prereq_workspace_body"), + title=translate_ui(loc, "dash.compare.prereq_workspace_title"), + locale=loc, + ) + return empty, empty, empty, empty + + from dash_app.api_client import ( + analysis_state_curves, + compare_workspace, + workspace_dataset_data, + workspace_datasets, + workspace_results, + ) + + datasets = {item.get("key"): item for item in workspace_datasets(project_id).get("datasets", [])} + selected_runs = selected_runs or [] + results = workspace_results(project_id).get("results", []) + result_keys = { + item.get("dataset_key") + for item in results + if item.get("analysis_type") == analysis_type + } + cmp = compare_workspace(project_id).get("compare_workspace", {}) + prereq_state = _compare_prereq_state(list(datasets.values()), analysis_type, selected_runs) + + if prereq_state == "no_datasets": + empty = prereq_or_empty_help( + translate_ui(loc, "dash.compare.prereq_datasets_body"), + title=translate_ui(loc, "dash.compare.prereq_datasets_title"), + locale=loc, + ) + return empty, empty, empty, empty + if prereq_state == "no_eligible_types": + empty = prereq_or_empty_help( + translate_ui(loc, "dash.compare.prereq_no_eligible_body"), + title=translate_ui(loc, "dash.compare.prereq_no_eligible_title"), + locale=loc, + ) + return empty, empty, empty, empty + if prereq_state == "select_analysis_type": + empty = prereq_or_empty_help( + translate_ui(loc, "dash.compare.prereq_need_analysis_body"), + tone="secondary", + title=translate_ui(loc, "dash.compare.prereq_need_analysis_title"), + locale=loc, + ) + return empty, empty, empty, empty + + if len(selected_runs) < 2: + overlay = prereq_or_empty_help( + translate_ui(loc, "dash.compare.prereq_overlay_runs_body"), + tone="secondary", + title=translate_ui(loc, "dash.compare.prereq_overlay_runs_title"), + locale=loc, + ) + else: + fig = go.Figure() + x_title, y_title = axis_titles(analysis_type) + use_best = str(signal_mode or "best").lower() == "best" + + for dataset_key in selected_runs: + label_base = datasets.get(dataset_key, {}).get("display_name", dataset_key) + if use_best: + try: + curves = analysis_state_curves(project_id, analysis_type, dataset_key) + except Exception: + curves = {} + picked = pick_best_series(curves) if curves else None + if picked: + xs, ys, src = picked + fig.add_trace( + go.Scatter( + x=xs, + y=ys, + mode="lines", + name=f"{label_base} ({src})", + ) + ) + else: + payload = workspace_dataset_data(project_id, dataset_key) + rows = payload.get("rows", []) + columns = payload.get("columns", []) + x_column = "temperature" if "temperature" in columns else (columns[0] if columns else None) + preferred_y = next( + (item for item in ["signal", "heat_flow", "mass_percent", "intensity", "absorbance"] if item in columns), + None, + ) + y_column = preferred_y or (columns[1] if len(columns) > 1 else None) + if x_column and y_column: + x = [row.get(x_column) for row in rows] + y = [row.get(y_column) for row in rows] + x_title = x_column + y_title = y_column + fig.add_trace( + go.Scatter( + x=x, + y=y, + mode="lines", + name=translate_ui(loc, "dash.compare.trace_raw_fallback", label=label_base), + ) + ) + else: + payload = workspace_dataset_data(project_id, dataset_key) + rows = payload.get("rows", []) + columns = payload.get("columns", []) + x_column = "temperature" if "temperature" in columns else (columns[0] if columns else None) + preferred_y = next( + (item for item in ["signal", "heat_flow", "mass_percent", "intensity", "absorbance"] if item in columns), + None, + ) + y_column = preferred_y or (columns[1] if len(columns) > 1 else None) + if x_column and y_column: + x = [row.get(x_column) for row in rows] + y = [row.get(y_column) for row in rows] + x_title = x_column + y_title = y_column + fig.add_trace( + go.Scatter( + x=x, + y=y, + mode="lines", + name=translate_ui(loc, "dash.compare.trace_raw", label=label_base), + ) + ) + + mode_caption = ( + translate_ui(loc, "dash.compare.figure_caption_best") + if use_best + else translate_ui(loc, "dash.compare.figure_caption_raw") + ) + if not fig.data: + overlay = html.P(translate_ui(loc, "dash.compare.overlay_build_fail"), className="text-muted") + else: + fig_title = translate_ui(loc, "dash.compare.figure_title", analysis=analysis_type, mode=mode_caption) + fig.update_layout( + title=fig_title, + xaxis_title=x_title, + yaxis_title=y_title, + margin=dict(l=48, r=24, t=56, b=48), + height=420, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0), + ) + apply_figure_theme(fig, ui_theme) + overlay = dcc.Graph(figure=fig, config={"displaylogo": False, "responsive": True}, className="ta-plot") + + yes = translate_ui(loc, "dash.compare.summary_yes") + no = translate_ui(loc, "dash.compare.summary_no") + summary_rows = [] + for dataset_key in selected_runs: + dataset = datasets.get(dataset_key) or {} + summary_rows.append( + { + "run": dataset_key, + "sample_name": dataset.get("sample_name"), + "vendor": dataset.get("vendor"), + "heating_rate": dataset.get("heating_rate"), + "points": dataset.get("points"), + "saved_result": yes if dataset_key in result_keys else no, + } + ) + summary = html.Div( + [ + html.H5(translate_ui(loc, "dash.compare.summary_title"), className="mb-3"), + dataset_table( + summary_rows, + ["run", "sample_name", "vendor", "heating_rate", "points", "saved_result"], + table_id="compare-summary-table", + ) + if summary_rows + else html.P(translate_ui(loc, "dash.compare.no_runs_selected"), className="text-muted"), + ] + ) + + preview_rows = [ + { + "id": item.get("id"), + "dataset_key": item.get("dataset_key"), + "status": item.get("status"), + "saved_at_utc": item.get("saved_at_utc"), + } + for item in results + if item.get("analysis_type") == analysis_type and item.get("dataset_key") in selected_runs + ] + preview = html.Div( + [ + html.H5(translate_ui(loc, "dash.compare.saved_preview_title"), className="mb-3"), + dataset_table( + preview_rows, + ["id", "dataset_key", "status", "saved_at_utc"], + table_id="compare-result-preview-table", + ) + if preview_rows + else html.P(translate_ui(loc, "dash.compare.no_saved_for_runs"), className="text-muted"), + ] + ) + + batch_children: list = [html.H5(translate_ui(loc, "dash.compare.batch_panel_title"), className="mb-3")] + feedback = cmp.get("batch_last_feedback") or {} + if feedback: + batch_children.append( + html.P( + translate_ui( + loc, + "dash.compare.batch_outcomes", + saved=feedback.get("saved", 0), + blocked=feedback.get("blocked", 0), + failed=feedback.get("failed", 0), + ), + className="small", + ) + ) + if cmp.get("batch_template_id"): + batch_children.append( + html.P( + translate_ui( + loc, + "dash.compare.batch_template_line", + name=cmp.get("batch_template_label") or cmp.get("batch_template_id"), + ), + className="small text-muted", + ) + ) + batch_summary = cmp.get("batch_summary") or [] + if batch_summary: + cols = [k for k in batch_summary[0].keys()][:8] + batch_children.append(dataset_table(batch_summary, cols, table_id="compare-batch-summary-table")) + else: + batch_children.append(html.P(translate_ui(loc, "dash.compare.batch_no_record"), className="text-muted small")) + batch_panel = html.Div(batch_children) + + return overlay, summary, preview, batch_panel diff --git a/dash_app/pages/dsc.py b/dash_app/pages/dsc.py new file mode 100644 index 00000000..e90e7b75 --- /dev/null +++ b/dash_app/pages/dsc.py @@ -0,0 +1,3143 @@ +"""DSC analysis page -- backend-driven first analysis slice. + +Lets the user: + 1. Select an eligible DSC dataset from the workspace + 2. Select a DSC workflow template + 3. Run analysis through the backend /analysis/run endpoint + 4. View execution status, result summary, and DSC figure/preview + 5. Enriched display: Tg metric cards, smoothed/baseline/corrected overlay, + labelled peak cards, auto-refresh of Project/Compare/Report pages +""" + +from __future__ import annotations + +import base64 +import copy +import json +import math +from datetime import datetime, timezone +from typing import Any + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import plotly.graph_objects as go + +from dash_app.components.analysis_boilerplate import ( + build_apply_preset_card, + build_collapsible_section, + build_processing_history_card, + build_split_raw_metadata_panel, + build_validation_quality_card, +) +from dash_app.components.analysis_page import ( + analysis_page_stores, + capture_result_figure_from_layout, + register_result_figure_from_layout_children, + dataset_selection_card, + dataset_selector_block, + eligible_datasets, + empty_result_msg, + execute_card, + interpret_run_result, + metrics_row, + no_data_figure_msg, + processing_details_section, + resolve_sample_name, + result_placeholder_card, + workflow_template_card, +) +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.figure_artifacts import ( + FIGURE_ARTIFACT_PREVIEW_MAX_EDGE, + FIGURE_ARTIFACT_PREVIEW_TILES, + build_figure_artifact_surface, + build_figure_artifacts_panel, + figure_action_from_trigger, + figure_action_metadata, + figure_action_status_alert, + figure_artifact_button_labels, + ordered_figure_preview_keys, +) +from dash_app.components.literature_compare_ui import ( + LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + build_literature_compare_card, + coerce_literature_max_claims, + literature_compare_status_alert, + literature_t, + render_literature_output, +) +from dash_app.components.processing_inputs import ( + coerce_float_non_negative as _coerce_float_non_negative, + coerce_float_positive as _coerce_float_positive, + coerce_int_positive as _coerce_int_positive, +) +from dash_app.theme import apply_figure_theme, normalize_ui_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/dsc", title="DSC Analysis - MaterialScope") + +_DSC_TEMPLATE_IDS = ["dsc.general", "dsc.polymer_tg", "dsc.polymer_melting_crystallization"] + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + +_DSC_ELIGIBLE_TYPES = {"DSC", "DTA", "UNKNOWN"} + +_PEAK_TYPE_COLORS = { + "endotherm": "#0E7490", + "exotherm": "#DC2626", + "step": "#7C3AED", +} +_PEAK_TYPE_ICONS = { + "endotherm": "bi-arrow-down-circle", + "exotherm": "bi-arrow-up-circle", + "step": "bi-arrow-right-circle", +} +_DSC_RESULT_CARD_ROLES = { + "context": "ms-result-context", + "hero": "ms-result-hero", + "support": "ms-result-support", + "secondary": "ms-result-secondary", +} +_DSC_LITERATURE_PREFIX = "dash.analysis.dsc.literature" + +_SMOOTH_METHODS = ("savgol", "moving_average", "gaussian") +_DSC_SMOOTHING_DEFAULTS: dict[str, dict] = { + "savgol": {"method": "savgol", "window_length": 11, "polyorder": 3}, + "moving_average": {"method": "moving_average", "window_length": 11}, + "gaussian": {"method": "gaussian", "sigma": 2.0}, +} +_BASELINE_METHODS = ("asls", "airpls", "modpoly", "imodpoly", "snip", "rubberband", "linear", "spline") +_DSC_BASELINE_DEFAULTS: dict[str, dict] = { + "asls": {"method": "asls", "lam": 1e6, "p": 0.01, "region": None}, + "airpls": {"method": "airpls", "lam": 1e6, "region": None}, + "modpoly": {"method": "modpoly", "poly_order": 6, "region": None}, + "imodpoly": {"method": "imodpoly", "poly_order": 6, "region": None}, + "snip": {"method": "snip", "max_half_window": 40, "region": None}, + "linear": {"method": "linear", "region": None}, + "rubberband": {"method": "rubberband", "region": None}, + "spline": {"method": "spline", "n_anchors": 6, "region": None}, +} +_DSC_PEAK_DETECTION_DEFAULTS: dict = { + "direction": "both", + "prominence": None, + "distance": None, +} +_DSC_GLASS_TRANSITION_DEFAULTS: dict = { + "mode": "auto", + "region": None, +} +_DSC_NORMALIZATION_DEFAULTS: dict = { + "enabled": True, +} +_DSC_USER_FACING_METADATA_KEYS: frozenset[str] = frozenset({ + "sample_name", + "display_name", + "sample_mass", + "heating_rate", + "instrument", + "vendor", + "file_name", + "source_data_hash", +}) +_UNDO_STACK_LIMIT = 32 +_ANNOTATION_MIN_SEP = 15.0 + + +def _default_processing_draft() -> dict: + return { + "smoothing": copy.deepcopy(_DSC_SMOOTHING_DEFAULTS["savgol"]), + "baseline": copy.deepcopy(_DSC_BASELINE_DEFAULTS["asls"]), + "normalization": copy.deepcopy(_DSC_NORMALIZATION_DEFAULTS), + "peak_detection": copy.deepcopy(_DSC_PEAK_DETECTION_DEFAULTS), + "glass_transition": copy.deepcopy(_DSC_GLASS_TRANSITION_DEFAULTS), + } + + +def _normalize_smoothing_values(method: str | None, window_length, polyorder, sigma) -> dict: + token = str(method or "savgol").strip().lower() + if token not in _SMOOTH_METHODS: + token = "savgol" + if token == "savgol": + wl = _coerce_int_positive(window_length, default=11, minimum=5) + if wl % 2 == 0: + wl += 1 + po = _coerce_int_positive(polyorder, default=3, minimum=1) + po = min(po, max(wl - 2, 1)) + return {"method": "savgol", "window_length": wl, "polyorder": po} + if token == "moving_average": + wl = _coerce_int_positive(window_length, default=11, minimum=3) + if wl % 2 == 0: + wl += 1 + return {"method": "moving_average", "window_length": wl} + sg = _coerce_float_positive(sigma, default=2.0, minimum=0.1) + return {"method": "gaussian", "sigma": sg} + + +def _normalize_baseline_region(enabled, rmin, rmax) -> list[float] | None: + if not enabled: + return None + try: + lower = float(rmin) + upper = float(rmax) + except (TypeError, ValueError): + return None + if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper: + return None + return [lower, upper] + + +def _normalize_baseline_values(method: str | None, lam, p, poly_order=None, max_half_window=None, n_anchors=None, region_enabled=None, region_min=None, region_max=None) -> dict: + token = str(method or "asls").strip().lower() + if token not in _BASELINE_METHODS: + token = "asls" + region = _normalize_baseline_region(region_enabled, region_min, region_max) + if token == "asls": + lam_value = _coerce_float_positive(lam, default=1e6, minimum=1e-3) + p_value = _coerce_float_positive(p, default=0.01, minimum=1e-4) + p_value = min(p_value, 0.5) + return {"method": "asls", "lam": lam_value, "p": p_value, "region": region} + if token == "airpls": + lam_value = _coerce_float_positive(lam, default=1e6, minimum=1e-3) + return {"method": "airpls", "lam": lam_value, "region": region} + if token in ("modpoly", "imodpoly"): + po = _coerce_int_positive(poly_order, default=6, minimum=1) + return {"method": token, "poly_order": po, "region": region} + if token == "snip": + mhw = _coerce_int_positive(max_half_window, default=40, minimum=1) + return {"method": "snip", "max_half_window": mhw, "region": region} + if token == "spline": + na = _coerce_int_positive(n_anchors, default=6, minimum=2) + return {"method": "spline", "n_anchors": na, "region": region} + return {"method": token, "region": region} + + +def _normalize_peak_detection_values(direction: str | None, prominence, distance) -> dict: + dir_token = str(direction or "both").strip().lower() + if dir_token not in {"both", "up", "down"}: + dir_token = "both" + prom = _coerce_float_non_negative(prominence, default=0.0) + dist = _coerce_int_positive(distance, default=1, minimum=1) + return { + "direction": dir_token, + "prominence": None if prom in (0.0, 0) else prom, + "distance": None if dist <= 1 else dist, + } + + +def _normalize_glass_transition_values(enabled, rmin, rmax) -> dict: + if not enabled: + return {"mode": "auto", "region": None} + try: + lower = float(rmin) + upper = float(rmax) + except (TypeError, ValueError): + return {"mode": "auto", "region": None} + if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper: + return {"mode": "auto", "region": None} + return {"mode": "auto", "region": [lower, upper]} + + +def _normalize_normalization_values(enabled) -> dict: + if isinstance(enabled, str): + token = enabled.strip().lower() + if token in {"0", "false", "off", "no"}: + return {"enabled": False} + if token in {"1", "true", "on", "yes"}: + return {"enabled": True} + if enabled in (None, ""): + return copy.deepcopy(_DSC_NORMALIZATION_DEFAULTS) + return {"enabled": bool(enabled)} + + +def _normalize_dsc_processing_draft(draft: dict | None) -> dict: + draft_payload = dict(draft or {}) + smoothing = draft_payload.get("smoothing") + baseline = draft_payload.get("baseline") + normalization = draft_payload.get("normalization") + peak_detection = draft_payload.get("peak_detection") + glass_transition = draft_payload.get("glass_transition") + + if isinstance(smoothing, dict): + smoothing = _normalize_smoothing_values( + smoothing.get("method"), + smoothing.get("window_length"), + smoothing.get("polyorder"), + smoothing.get("sigma"), + ) + else: + smoothing = copy.deepcopy(_DSC_SMOOTHING_DEFAULTS["savgol"]) + + if isinstance(baseline, dict): + baseline_region = baseline.get("region") + baseline_enabled = isinstance(baseline_region, (list, tuple)) and len(baseline_region) == 2 + baseline = _normalize_baseline_values( + baseline.get("method"), + baseline.get("lam"), + baseline.get("p"), + baseline.get("poly_order"), + baseline.get("max_half_window"), + baseline.get("n_anchors"), + baseline_enabled, + baseline_region[0] if baseline_enabled else None, + baseline_region[1] if baseline_enabled else None, + ) + else: + baseline = copy.deepcopy(_DSC_BASELINE_DEFAULTS["asls"]) + + if isinstance(normalization, dict): + normalization = _normalize_normalization_values(normalization.get("enabled")) + else: + normalization = copy.deepcopy(_DSC_NORMALIZATION_DEFAULTS) + + if isinstance(peak_detection, dict): + peak_detection = _normalize_peak_detection_values( + peak_detection.get("direction"), + peak_detection.get("prominence"), + peak_detection.get("distance"), + ) + else: + peak_detection = copy.deepcopy(_DSC_PEAK_DETECTION_DEFAULTS) + + if isinstance(glass_transition, dict): + tg_region = glass_transition.get("region") + tg_enabled = isinstance(tg_region, (list, tuple)) and len(tg_region) == 2 + glass_transition = _normalize_glass_transition_values( + tg_enabled, + tg_region[0] if tg_enabled else None, + tg_region[1] if tg_enabled else None, + ) + else: + glass_transition = copy.deepcopy(_DSC_GLASS_TRANSITION_DEFAULTS) + + return { + "smoothing": smoothing, + "baseline": baseline, + "normalization": normalization, + "peak_detection": peak_detection, + "glass_transition": glass_transition, + } + + +def _dsc_draft_from_loaded_processing(processing: dict | None) -> dict: + if not isinstance(processing, dict): + return copy.deepcopy(_default_processing_draft()) + signal_pipeline = processing.get("signal_pipeline") or {} + analysis_steps = processing.get("analysis_steps") or {} + return _normalize_dsc_processing_draft( + { + "smoothing": signal_pipeline.get("smoothing") if isinstance(signal_pipeline.get("smoothing"), dict) else processing.get("smoothing"), + "baseline": signal_pipeline.get("baseline") if isinstance(signal_pipeline.get("baseline"), dict) else processing.get("baseline"), + "normalization": signal_pipeline.get("normalization") if isinstance(signal_pipeline.get("normalization"), dict) else processing.get("normalization"), + "peak_detection": analysis_steps.get("peak_detection") if isinstance(analysis_steps.get("peak_detection"), dict) else processing.get("peak_detection"), + "glass_transition": analysis_steps.get("glass_transition") if isinstance(analysis_steps.get("glass_transition"), dict) else processing.get("glass_transition"), + } + ) + + +def _dsc_ui_snapshot_dict(template_id: str | None, draft: dict | None) -> dict[str, Any]: + tid = template_id if template_id in _DSC_TEMPLATE_IDS else "dsc.general" + norm = _normalize_dsc_processing_draft(draft) + return { + "workflow_template_id": tid, + "smoothing": norm["smoothing"], + "baseline": norm["baseline"], + "normalization": norm["normalization"], + "peak_detection": norm["peak_detection"], + "glass_transition": norm["glass_transition"], + } + + +def _dsc_snapshots_equal(a: dict | None, b: dict | None) -> bool: + if not isinstance(a, dict) or not isinstance(b, dict): + return False + return json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str) + + +def _dsc_draft_from_control_values( + normalization_enabled, + sm_m, + sm_w, + sm_p, + sm_s, + bl_m, + bl_lam, + bl_p, + bl_poly_order, + bl_max_half_window, + bl_n_anchors, + bl_region_enabled, + bl_region_min, + bl_region_max, + peak_direction, + peak_prominence, + peak_distance, + tg_enabled, + tg_min, + tg_max, +) -> dict[str, Any]: + return _normalize_dsc_processing_draft( + { + "normalization": _normalize_normalization_values(normalization_enabled), + "smoothing": _normalize_smoothing_values(sm_m, sm_w, sm_p, sm_s), + "baseline": _normalize_baseline_values( + bl_m, + bl_lam, + bl_p, + bl_poly_order, + bl_max_half_window, + bl_n_anchors, + bl_region_enabled, + bl_region_min, + bl_region_max, + ), + "peak_detection": _normalize_peak_detection_values(peak_direction, peak_prominence, peak_distance), + "glass_transition": _normalize_glass_transition_values(tg_enabled, tg_min, tg_max), + } + ) + + +def _apply_draft_section(draft: dict | None, section: str, values: dict) -> dict: + next_draft = _normalize_dsc_processing_draft(draft) + next_draft[section] = copy.deepcopy(values) + return _normalize_dsc_processing_draft(next_draft) + + +def _push_undo(undo: list | None, snapshot: dict | None) -> list: + stack = list(undo or []) + stack.append(_normalize_dsc_processing_draft(snapshot)) + if len(stack) > _UNDO_STACK_LIMIT: + stack = stack[-_UNDO_STACK_LIMIT:] + return stack + + +def _do_undo(draft: dict, undo: list | None, redo: list | None) -> tuple[dict, list, list]: + undo_stack = list(undo or []) + redo_stack = list(redo or []) + if not undo_stack: + return _normalize_dsc_processing_draft(draft), undo_stack, redo_stack + previous = _normalize_dsc_processing_draft(undo_stack.pop()) + redo_stack.append(_normalize_dsc_processing_draft(draft)) + return previous, undo_stack, redo_stack + + +def _do_redo(draft: dict, undo: list | None, redo: list | None) -> tuple[dict, list, list]: + undo_stack = list(undo or []) + redo_stack = list(redo or []) + if not redo_stack: + return _normalize_dsc_processing_draft(draft), undo_stack, redo_stack + following = _normalize_dsc_processing_draft(redo_stack.pop()) + undo_stack.append(_normalize_dsc_processing_draft(draft)) + return following, undo_stack, redo_stack + + +def _do_reset(draft: dict, undo: list | None, redo: list | None, defaults: dict | None) -> tuple[dict, list, list]: + reset_target = _normalize_dsc_processing_draft(defaults or _default_processing_draft()) + current = _normalize_dsc_processing_draft(draft) + if current == reset_target: + return reset_target, list(undo or []), list(redo or []) + undo_stack = _push_undo(undo, current) + return reset_target, undo_stack, [] + + +def _overrides_from_draft(draft: dict | None) -> dict: + draft_payload = _normalize_dsc_processing_draft(draft) + combined: dict[str, dict] = {} + for section in ("smoothing", "baseline", "normalization", "peak_detection", "glass_transition"): + values = draft_payload.get(section) + if isinstance(values, dict): + combined[section] = copy.deepcopy(values) + return combined + + +# --------------------------------------------------------------------------- +# DSC-specific cards +# --------------------------------------------------------------------------- + +def _processing_draft_stores() -> list: + defaults = _default_processing_draft() + return [ + dcc.Store(id="dsc-processing-default", data=defaults), + dcc.Store(id="dsc-processing-draft", data=copy.deepcopy(defaults)), + dcc.Store(id="dsc-processing-undo", data=[]), + dcc.Store(id="dsc-processing-redo", data=[]), + dcc.Store(id="dsc-figure-captured", data={}), + dcc.Store(id="dsc-figure-artifact-refresh", data=0), + dcc.Store(id="dsc-preset-refresh", data=0), + dcc.Store(id="dsc-preset-loaded-name", data=""), + dcc.Store(id="dsc-preset-snapshot", data=None), + ] + + +_DSC_PRESET_ANALYSIS_TYPE = "DSC" + + +def _dsc_processing_history_card() -> dbc.Card: + """Undo / redo / reset for the shared DSC processing draft (same stack as Apply actions).""" + return build_processing_history_card( + title_id="dsc-processing-history-title", + hint_id="dsc-processing-history-hint", + undo_button_id="dsc-undo-btn", + redo_button_id="dsc-redo-btn", + reset_button_id="dsc-reset-btn", + status_id="dsc-history-status", + ) + + +def _preset_controls_card() -> dbc.Card: + return build_apply_preset_card(id_prefix="dsc", include_dirty_state=True) + + +def _normalization_setup_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dsc-normalization-card-title", className="card-title mb-2"), + html.P(id="dsc-normalization-card-hint", className="small text-muted mb-2"), + dbc.Checkbox(id="dsc-normalization-enabled", value=True, label=" "), + ] + ), + className="mb-3", + ) + + +def _smoothing_controls_card() -> dbc.Card: + method_options = [ + {"label": "Savitzky-Golay", "value": "savgol"}, + {"label": "Moving Average", "value": "moving_average"}, + {"label": "Gaussian", "value": "gaussian"}, + ] + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dsc-smoothing-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-smooth-method-label", html_for="dsc-smooth-method"), + dbc.Select(id="dsc-smooth-method", options=method_options, value="savgol"), + html.Small(id="dsc-smooth-method-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-smooth-window-label", html_for="dsc-smooth-window"), + dbc.Input(id="dsc-smooth-window", type="number", min=3, max=51, step=2, value=11), + html.Small(id="dsc-smooth-window-hint", className="form-text text-muted d-block mt-1"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="dsc-smooth-polyorder-label", html_for="dsc-smooth-polyorder"), + dbc.Input(id="dsc-smooth-polyorder", type="number", min=1, max=7, step=1, value=3), + html.Small(id="dsc-smooth-polyorder-hint", className="form-text text-muted d-block mt-1"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="dsc-smooth-sigma-label", html_for="dsc-smooth-sigma"), + dbc.Input(id="dsc-smooth-sigma", type="number", min=0.1, max=10.0, step=0.1, value=2.0, disabled=True), + html.Small(id="dsc-smooth-sigma-hint", className="form-text text-muted d-block mt-1"), + ], + md=4, + ), + ], + className="g-2 mb-2", + ), + dbc.ButtonGroup( + [ + dbc.Button(id="dsc-smooth-apply-btn", color="primary", size="sm"), + ], + className="mb-2", + ), + html.Div(id="dsc-smooth-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _baseline_controls_card() -> dbc.Card: + method_options = [ + {"label": "AsLS", "value": "asls"}, + {"label": "airPLS", "value": "airpls"}, + {"label": "Modified Polynomial", "value": "modpoly"}, + {"label": "Improved Modified Polynomial", "value": "imodpoly"}, + {"label": "SNIP", "value": "snip"}, + {"label": "Linear", "value": "linear"}, + {"label": "Rubberband", "value": "rubberband"}, + {"label": "Spline", "value": "spline"}, + ] + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dsc-baseline-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-baseline-method-label", html_for="dsc-baseline-method"), + dbc.Select(id="dsc-baseline-method", options=method_options, value="asls"), + html.Small(id="dsc-baseline-method-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + html.Div( + id="dsc-baseline-lam-p-group", + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-baseline-lam-label", html_for="dsc-baseline-lam"), + dbc.Input(id="dsc-baseline-lam", type="number", min=1e-3, step=1e5, value=1e6), + html.Small(id="dsc-baseline-lam-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="dsc-baseline-p-label", html_for="dsc-baseline-p"), + dbc.Input(id="dsc-baseline-p", type="number", min=1e-4, max=0.5, step=0.005, value=0.01), + html.Small(id="dsc-baseline-p-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.Div( + id="dsc-baseline-poly-order-group", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-baseline-poly-order-label", html_for="dsc-baseline-poly-order"), + dbc.Input(id="dsc-baseline-poly-order", type="number", min=1, step=1, value=6), + html.Small(id="dsc-baseline-poly-order-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.Div( + id="dsc-baseline-max-half-window-group", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-baseline-max-half-window-label", html_for="dsc-baseline-max-half-window"), + dbc.Input(id="dsc-baseline-max-half-window", type="number", min=1, step=1, value=40), + html.Small(id="dsc-baseline-max-half-window-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.Div( + id="dsc-baseline-n-anchors-group", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-baseline-n-anchors-label", html_for="dsc-baseline-n-anchors"), + dbc.Input(id="dsc-baseline-n-anchors", type="number", min=2, step=1, value=6), + html.Small(id="dsc-baseline-n-anchors-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.H6(id="dsc-baseline-region-section-title", className="mt-2 mb-2 small text-muted text-uppercase"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Checkbox(id="dsc-baseline-region-enabled", value=False, label=" "), + html.Small(id="dsc-baseline-region-enable-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-baseline-region-min-label", html_for="dsc-baseline-region-min"), + dbc.Input(id="dsc-baseline-region-min", type="number", value=None), + html.Small(id="dsc-baseline-region-min-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="dsc-baseline-region-max-label", html_for="dsc-baseline-region-max"), + dbc.Input(id="dsc-baseline-region-max", type="number", value=None), + html.Small(id="dsc-baseline-region-max-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + dbc.Button(id="dsc-baseline-apply-btn", color="primary", size="sm", className="mb-2"), + html.Div(id="dsc-baseline-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _literature_compare_card() -> dbc.Card: + """Manual literature compare (same interaction model as DTA / TGA).""" + return build_literature_compare_card(id_prefix="dsc") + + +def _peak_controls_card() -> dbc.Card: + direction_options = [ + {"label": "Both", "value": "both"}, + {"label": "Upward (Exotherm)", "value": "up"}, + {"label": "Downward (Endotherm)", "value": "down"}, + ] + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dsc-peak-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-peak-direction-label", html_for="dsc-peak-direction"), + dbc.Select(id="dsc-peak-direction", options=direction_options, value="both"), + html.Small(id="dsc-peak-direction-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-peak-prominence-label", html_for="dsc-peak-prominence"), + dbc.Input(id="dsc-peak-prominence", type="number", min=0.0, step=0.005, value=0.0), + html.Small(id="dsc-peak-prominence-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="dsc-peak-distance-label", html_for="dsc-peak-distance"), + dbc.Input(id="dsc-peak-distance", type="number", min=1, step=1, value=1), + html.Small(id="dsc-peak-distance-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + dbc.Button(id="dsc-peak-apply-btn", color="primary", size="sm", className="mb-2"), + html.Div(id="dsc-peak-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _tg_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dsc-tg-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Checkbox(id="dsc-tg-region-enabled", value=False, label=" "), + html.Small(id="dsc-tg-region-enable-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dsc-tg-region-min-label", html_for="dsc-tg-region-min"), + dbc.Input(id="dsc-tg-region-min", type="number", value=None), + html.Small(id="dsc-tg-region-min-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="dsc-tg-region-max-label", html_for="dsc-tg-region-max"), + dbc.Input(id="dsc-tg-region-max", type="number", value=None), + html.Small(id="dsc-tg-region-max-hint", className="form-text text-muted d-block mt-1"), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + dbc.Button(id="dsc-tg-apply-btn", color="primary", size="sm", className="mb-2"), + html.Div(id="dsc-tg-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _dsc_left_column_tabs() -> dbc.Tabs: + return dbc.Tabs( + [ + dbc.Tab( + [ + dataset_selection_card("dsc-dataset-selector-area", card_title_id="dsc-dataset-card-title"), + html.Div(id="dsc-prerun-dataset-info", className="mb-3"), + _normalization_setup_card(), + workflow_template_card( + "dsc-template-select", + "dsc-template-description", + [], + "dsc.general", + card_title_id="dsc-workflow-card-title", + ), + ], + tab_id="dsc-tab-setup", + label_class_name="ta-tab-label", + id="dsc-tab-setup-shell", + ), + dbc.Tab( + [ + _dsc_processing_history_card(), + _preset_controls_card(), + _smoothing_controls_card(), + _baseline_controls_card(), + _peak_controls_card(), + _tg_controls_card(), + ], + tab_id="dsc-tab-processing", + label_class_name="ta-tab-label", + id="dsc-tab-processing-shell", + ), + dbc.Tab( + [ + execute_card("dsc-run-status", "dsc-run-btn", card_title_id="dsc-execute-card-title"), + ], + tab_id="dsc-tab-run", + label_class_name="ta-tab-label", + id="dsc-tab-run-shell", + ), + ], + id="dsc-left-tabs", + active_tab="dsc-tab-setup", + className="mb-3", + ) + + +def _dsc_result_section(child: Any, *, role: str = "support") -> html.Div: + role_class = _DSC_RESULT_CARD_ROLES.get(role, _DSC_RESULT_CARD_ROLES["support"]) + return html.Div(child, className=f"ms-result-section {role_class}") + + +def _peak_card(row: dict, idx: int, loc: str) -> dbc.Card: + peak_type = str(row.get("peak_type", "unknown")).lower() + color = _PEAK_TYPE_COLORS.get(peak_type, "#6B7280") + icon = _PEAK_TYPE_ICONS.get(peak_type, "bi-circle") + pt = row.get("peak_temperature") + onset = row.get("onset_temperature") + endset = row.get("endset_temperature") + area = row.get("area") + fwhm = row.get("fwhm") + height = row.get("height") + return dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className=f"bi {icon} me-2", style={"color": color, "fontSize": "1.1rem"}), + html.Strong(translate_ui(loc, "dash.analysis.label.peak_n", n=idx + 1), className="me-2"), + html.Span( + peak_type.title(), + className="badge", + style={"backgroundColor": color, "color": "white", "fontSize": "0.75rem"}, + ), + html.Span(f" {pt:.1f} °C" if pt is not None else " --", className="ms-2"), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.onset"), className="text-muted d-block"), + html.Span(f"{onset:.1f}" if onset is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.endset"), className="text-muted d-block"), + html.Span(f"{endset:.1f}" if endset is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.area"), className="text-muted d-block"), + html.Span(f"{area:.3f}" if area is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.fwhm"), className="text-muted d-block"), + html.Span(f"{fwhm:.1f}" if fwhm is not None else "--"), + html.Small(f" {translate_ui(loc, 'dash.analysis.label.height')}", className="text-muted ms-2"), + html.Span(f"{height:.3f}" if height is not None else "--"), + ], + md=3, + ), + ], + className="g-2", + ), + ] + ), + className="mb-2", + ) + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = html.Div( + analysis_page_stores("dsc-refresh", "dsc-latest-result-id") + + _processing_draft_stores() + + [ + html.Div(id="dsc-hero-slot"), + dbc.Row( + [ + dbc.Col([_dsc_left_column_tabs()], md=4), + dbc.Col( + [ + _dsc_result_section(result_placeholder_card("dsc-result-dataset-summary"), role="context"), + _dsc_result_section(result_placeholder_card("dsc-result-metrics"), role="context"), + _dsc_result_section(result_placeholder_card("dsc-result-quality"), role="support"), + _dsc_result_section(build_figure_artifact_surface("dsc"), role="hero"), + _dsc_result_section(result_placeholder_card("dsc-result-derivative"), role="support"), + _dsc_result_section(result_placeholder_card("dsc-result-event-cards"), role="support"), + _dsc_result_section(result_placeholder_card("dsc-result-table"), role="support"), + _dsc_result_section(result_placeholder_card("dsc-result-processing"), role="support"), + _dsc_result_section(result_placeholder_card("dsc-result-raw-metadata"), role="support"), + _dsc_result_section(_literature_compare_card(), role="secondary"), + ], + md=8, + className="ms-results-surface", + ), + ] + ), + ], + className="dsc-page", +) + + +@callback( + Output("dsc-hero-slot", "children"), + Output("dsc-dataset-card-title", "children"), + Output("dsc-workflow-card-title", "children"), + Output("dsc-execute-card-title", "children"), + Output("dsc-run-btn", "children"), + Output("dsc-template-select", "options"), + Output("dsc-template-select", "value"), + Output("dsc-template-description", "children"), + Input("ui-locale", "data"), + Input("dsc-template-select", "value"), +) +def render_dsc_locale_chrome(locale_data, template_id): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.analysis.dsc.title"), + translate_ui(loc, "dash.analysis.dsc.caption"), + badge=translate_ui(loc, "dash.analysis.badge"), + ) + opts = [ + {"label": translate_ui(loc, f"dash.analysis.dsc.template.{tid}.label"), "value": tid} for tid in _DSC_TEMPLATE_IDS + ] + valid = {o["value"] for o in opts} + tid = template_id if template_id in valid else "dsc.general" + desc_key = f"dash.analysis.dsc.template.{tid}.desc" + desc = translate_ui(loc, desc_key) + if desc == desc_key: + desc = translate_ui(loc, "dash.analysis.dsc.workflow_fallback") + return ( + hero, + translate_ui(loc, "dash.analysis.dataset_selection_title"), + translate_ui(loc, "dash.analysis.workflow_template_title"), + translate_ui(loc, "dash.analysis.execute_title"), + translate_ui(loc, "dash.analysis.dsc.run_btn"), + opts, + tid, + desc, + ) + + +@callback( + Output("dsc-tab-setup-shell", "label"), + Output("dsc-tab-processing-shell", "label"), + Output("dsc-tab-run-shell", "label"), + Input("ui-locale", "data"), +) +def render_dsc_tab_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.tab.setup"), + translate_ui(loc, "dash.analysis.dsc.tab.processing"), + translate_ui(loc, "dash.analysis.dsc.tab.run"), + ) + + +@callback( + Output("dsc-preset-card-title", "children"), + Output("dsc-preset-select-label", "children"), + Output("dsc-preset-select", "placeholder"), + Output("dsc-preset-apply-btn", "children"), + Output("dsc-preset-delete-btn", "children"), + Output("dsc-preset-save-name-label", "children"), + Output("dsc-preset-save-name", "placeholder"), + Output("dsc-preset-save-btn", "children"), + Output("dsc-preset-help", "children"), + Input("ui-locale", "data"), +) +def render_dsc_preset_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.presets.title"), + translate_ui(loc, "dash.analysis.dsc.presets.select_label"), + translate_ui(loc, "dash.analysis.dsc.presets.select_placeholder"), + translate_ui(loc, "dash.analysis.dsc.presets.apply_btn"), + translate_ui(loc, "dash.analysis.dsc.presets.delete_btn"), + translate_ui(loc, "dash.analysis.dsc.presets.save_name_label"), + translate_ui(loc, "dash.analysis.dsc.presets.save_name_placeholder"), + translate_ui(loc, "dash.analysis.dsc.presets.save_btn"), + translate_ui(loc, "dash.analysis.dsc.presets.help.overview"), + ) + + +@callback( + Output("dsc-smoothing-card-title", "children"), + Output("dsc-smooth-method-label", "children"), + Output("dsc-smooth-window-label", "children"), + Output("dsc-smooth-polyorder-label", "children"), + Output("dsc-smooth-sigma-label", "children"), + Output("dsc-smooth-apply-btn", "children"), + Output("dsc-smooth-method-hint", "children"), + Output("dsc-smooth-window-hint", "children"), + Output("dsc-smooth-polyorder-hint", "children"), + Output("dsc-smooth-sigma-hint", "children"), + Input("ui-locale", "data"), +) +def render_dsc_smoothing_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.smoothing.title"), + translate_ui(loc, "dash.analysis.dsc.smoothing.method"), + translate_ui(loc, "dash.analysis.dsc.smoothing.window"), + translate_ui(loc, "dash.analysis.dsc.smoothing.polyorder"), + translate_ui(loc, "dash.analysis.dsc.smoothing.sigma"), + translate_ui(loc, "dash.analysis.dsc.smoothing.apply_btn"), + translate_ui(loc, "dash.analysis.dsc.smoothing.help.method"), + translate_ui(loc, "dash.analysis.dsc.smoothing.help.window"), + translate_ui(loc, "dash.analysis.dsc.smoothing.help.polyorder"), + translate_ui(loc, "dash.analysis.dsc.smoothing.help.sigma"), + ) + + +@callback( + Output("dsc-processing-history-title", "children"), + Output("dsc-processing-history-hint", "children"), + Output("dsc-undo-btn", "children"), + Output("dsc-redo-btn", "children"), + Output("dsc-reset-btn", "children"), + Input("ui-locale", "data"), +) +def render_dsc_processing_history_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.processing.history_title"), + translate_ui(loc, "dash.analysis.dsc.processing.history_hint"), + translate_ui(loc, "dash.analysis.dsc.processing.undo_btn"), + translate_ui(loc, "dash.analysis.dsc.processing.redo_btn"), + translate_ui(loc, "dash.analysis.dsc.processing.reset_btn"), + ) + + +@callback( + Output("dsc-normalization-card-title", "children"), + Output("dsc-normalization-card-hint", "children"), + Output("dsc-normalization-enabled", "label"), + Input("ui-locale", "data"), +) +def render_dsc_normalization_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.normalization.title"), + translate_ui(loc, "dash.analysis.dsc.normalization.hint"), + translate_ui(loc, "dash.analysis.dsc.normalization.enable"), + ) + + +@callback( + Output("dsc-baseline-card-title", "children"), + Output("dsc-baseline-method-label", "children"), + Output("dsc-baseline-lam-label", "children"), + Output("dsc-baseline-p-label", "children"), + Output("dsc-baseline-poly-order-label", "children"), + Output("dsc-baseline-max-half-window-label", "children"), + Output("dsc-baseline-n-anchors-label", "children"), + Output("dsc-baseline-apply-btn", "children"), + Output("dsc-baseline-method-hint", "children"), + Output("dsc-baseline-lam-hint", "children"), + Output("dsc-baseline-p-hint", "children"), + Output("dsc-baseline-poly-order-hint", "children"), + Output("dsc-baseline-max-half-window-hint", "children"), + Output("dsc-baseline-n-anchors-hint", "children"), + Input("ui-locale", "data"), +) +def render_dsc_baseline_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.baseline.title"), + translate_ui(loc, "dash.analysis.dsc.baseline.method"), + translate_ui(loc, "dash.analysis.dsc.baseline.lam"), + translate_ui(loc, "dash.analysis.dsc.baseline.p"), + translate_ui(loc, "dash.analysis.dsc.baseline.poly_order"), + translate_ui(loc, "dash.analysis.dsc.baseline.max_half_window"), + translate_ui(loc, "dash.analysis.dsc.baseline.n_anchors"), + translate_ui(loc, "dash.analysis.dsc.baseline.apply_btn"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.method"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.lam"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.p"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.poly_order"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.max_half_window"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.n_anchors"), + ) + + +@callback( + Output("dsc-peak-card-title", "children"), + Output("dsc-peak-direction-label", "children"), + Output("dsc-peak-prominence-label", "children"), + Output("dsc-peak-distance-label", "children"), + Output("dsc-peak-apply-btn", "children"), + Output("dsc-peak-direction-hint", "children"), + Output("dsc-peak-prominence-hint", "children"), + Output("dsc-peak-distance-hint", "children"), + Input("ui-locale", "data"), +) +def render_dsc_peak_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.peaks.title"), + translate_ui(loc, "dash.analysis.dsc.peaks.direction"), + translate_ui(loc, "dash.analysis.dsc.peaks.prominence"), + translate_ui(loc, "dash.analysis.dsc.peaks.distance"), + translate_ui(loc, "dash.analysis.dsc.peaks.apply_btn"), + translate_ui(loc, "dash.analysis.dsc.peaks.help.direction"), + translate_ui(loc, "dash.analysis.dsc.peaks.help.prominence"), + translate_ui(loc, "dash.analysis.dsc.peaks.help.distance"), + ) + + +@callback( + Output("dsc-tg-card-title", "children"), + Output("dsc-tg-region-enabled", "label"), + Output("dsc-tg-region-min-label", "children"), + Output("dsc-tg-region-max-label", "children"), + Output("dsc-tg-apply-btn", "children"), + Output("dsc-tg-region-enable-hint", "children"), + Output("dsc-tg-region-min-hint", "children"), + Output("dsc-tg-region-max-hint", "children"), + Input("ui-locale", "data"), +) +def render_dsc_tg_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.tg.title"), + translate_ui(loc, "dash.analysis.dsc.tg.enable_region"), + translate_ui(loc, "dash.analysis.dsc.tg.region_min"), + translate_ui(loc, "dash.analysis.dsc.tg.region_max"), + translate_ui(loc, "dash.analysis.dsc.tg.apply_btn"), + translate_ui(loc, "dash.analysis.dsc.tg.help.enable_region"), + translate_ui(loc, "dash.analysis.dsc.tg.help.region_min"), + translate_ui(loc, "dash.analysis.dsc.tg.help.region_max"), + ) + + +@callback( + Output("dsc-prerun-dataset-info", "children"), + Input("dsc-dataset-select", "value"), + Input("dsc-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def render_dsc_prerun_dataset_info(dataset_key, _refresh, locale_data, project_id): + loc = _loc(locale_data) + na = translate_ui(loc, "dash.analysis.na") + if not project_id or not dataset_key: + return html.Div() + + from dash_app.api_client import workspace_dataset_detail + + try: + detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + return html.Div() + + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + checks = validation.get("checks") if isinstance(validation.get("checks"), dict) else {} + meta = detail.get("metadata") if isinstance(detail.get("metadata"), dict) else {} + + tmin = checks.get("temperature_min") + tmax = checks.get("temperature_max") + n_pts = checks.get("data_points") + if tmin is not None and tmax is not None: + try: + trange = translate_ui(loc, "dash.analysis.dsc.prerun.temp_range").format( + tmin=float(tmin), + tmax=float(tmax), + ) + except (TypeError, ValueError): + trange = na + else: + trange = na + + points_txt = str(int(n_pts)) if isinstance(n_pts, int) or (isinstance(n_pts, float) and math.isfinite(n_pts)) else na + + mass_raw = meta.get("sample_mass") + mass_txt = _format_dataset_metadata_value(mass_raw) if mass_raw is not None else None + if mass_txt: + mass_txt = f"{mass_txt} {translate_ui(loc, 'dash.analysis.dsc.summary.mass_unit')}" + else: + mass_txt = na + + hr_raw = meta.get("heating_rate") + hr_txt = _format_dataset_metadata_value(hr_raw) if hr_raw is not None else None + if hr_txt: + hr_txt = f"{hr_txt} {translate_ui(loc, 'dash.analysis.dsc.summary.heating_rate_unit')}" + else: + hr_txt = na + + return html.Div( + [ + html.H6(translate_ui(loc, "dash.analysis.dsc.prerun.card_title"), className="mb-2"), + html.Dl( + [ + html.Dt(translate_ui(loc, "dash.analysis.dsc.prerun.range"), className="col-sm-5 text-muted small"), + html.Dd(trange, className="col-sm-7 small"), + html.Dt(translate_ui(loc, "dash.analysis.dsc.prerun.points"), className="col-sm-5 text-muted small"), + html.Dd(points_txt, className="col-sm-7 small"), + html.Dt(translate_ui(loc, "dash.analysis.dsc.prerun.sample_mass"), className="col-sm-5 text-muted small"), + html.Dd(mass_txt, className="col-sm-7 small"), + html.Dt(translate_ui(loc, "dash.analysis.dsc.prerun.heating_rate"), className="col-sm-5 text-muted small"), + html.Dd(hr_txt, className="col-sm-7 small"), + ], + className="row mb-0 small", + ), + ], + className="border rounded p-3 bg-light", + ) + + +@callback( + Output("dsc-dataset-selector-area", "children"), + Output("dsc-run-btn", "disabled"), + Input("project-id", "data"), + Input("dsc-refresh", "data"), + Input("ui-locale", "data"), +) +def load_eligible_datasets(project_id, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + return html.P(translate_ui(loc, "dash.analysis.workspace_inactive"), className="text-muted"), True + + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_datasets", error=str(exc)), color="danger"), True + + all_datasets = payload.get("datasets", []) + return dataset_selector_block( + selector_id="dsc-dataset-select", + empty_msg=translate_ui(loc, "dash.analysis.dsc.empty_import"), + eligible=eligible_datasets(all_datasets, _DSC_ELIGIBLE_TYPES), + all_datasets=all_datasets, + eligible_types=_DSC_ELIGIBLE_TYPES, + active_dataset=payload.get("active_dataset"), + locale_data=locale_data, + ) + + +@callback( + Output("dsc-preset-select", "options"), + Output("dsc-preset-caption", "children"), + Input("dsc-preset-refresh", "data"), + Input("ui-locale", "data"), +) +def refresh_dsc_preset_options(_refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + try: + payload = api_client.list_analysis_presets(_DSC_PRESET_ANALYSIS_TYPE) + except Exception as exc: + message = translate_ui(loc, "dash.analysis.dsc.presets.list_failed").format(error=str(exc)) + return [], message + + presets = payload.get("presets") or [] + options = [ + {"label": item.get("preset_name", ""), "value": item.get("preset_name", "")} + for item in presets + if isinstance(item, dict) and item.get("preset_name") + ] + caption = translate_ui(loc, "dash.analysis.dsc.presets.caption").format( + analysis_type=payload.get("analysis_type", _DSC_PRESET_ANALYSIS_TYPE), + count=int(payload.get("count", len(options)) or 0), + max_count=int(payload.get("max_count", 10) or 10), + ) + return options, caption + + +@callback( + Output("dsc-preset-apply-btn", "disabled"), + Output("dsc-preset-delete-btn", "disabled"), + Input("dsc-preset-select", "value"), +) +def toggle_dsc_preset_action_buttons(selected_name): + has_selection = bool(str(selected_name or "").strip()) + return (not has_selection, not has_selection) + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Output("dsc-template-select", "value", allow_duplicate=True), + Output("dsc-preset-status", "children", allow_duplicate=True), + Output("dsc-left-tabs", "active_tab", allow_duplicate=True), + Output("dsc-preset-loaded-name", "data", allow_duplicate=True), + Output("dsc-preset-snapshot", "data", allow_duplicate=True), + Input("dsc-preset-apply-btn", "n_clicks"), + State("dsc-preset-select", "value"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def apply_dsc_preset(n_clicks, selected_name, draft, undo, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dsc.presets.select_required"), + dash.no_update, + dash.no_update, + dash.no_update, + ) + try: + payload = api_client.load_analysis_preset(_DSC_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dsc.presets.apply_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + dash.no_update, + ) + + processing = dict(payload.get("processing") or {}) + next_draft = _dsc_draft_from_loaded_processing(processing) + + template_id_raw = str(payload.get("workflow_template_id") or "").strip() + template_output = template_id_raw if template_id_raw in _DSC_TEMPLATE_IDS else dash.no_update + resolved_tid = template_id_raw if template_id_raw in _DSC_TEMPLATE_IDS else "dsc.general" + snap = _dsc_ui_snapshot_dict(resolved_tid, next_draft) + next_undo = _push_undo(undo, draft) + status = translate_ui(loc, "dash.analysis.dsc.presets.applied").format(preset=name) + return next_draft, next_undo, [], template_output, status, "dsc-tab-run", name, snap + + +@callback( + Output("dsc-preset-refresh", "data", allow_duplicate=True), + Output("dsc-preset-save-name", "value", allow_duplicate=True), + Output("dsc-preset-status", "children", allow_duplicate=True), + Output("dsc-left-tabs", "active_tab", allow_duplicate=True), + Output("dsc-preset-loaded-name", "data", allow_duplicate=True), + Output("dsc-preset-snapshot", "data", allow_duplicate=True), + Input("dsc-preset-save-btn", "n_clicks"), + State("dsc-preset-save-name", "value"), + State("dsc-processing-draft", "data"), + State("dsc-template-select", "value"), + State("dsc-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_dsc_preset(n_clicks, save_name, draft, template_id, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(save_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dsc.presets.save_name_required"), + dash.no_update, + dash.no_update, + dash.no_update, + ) + try: + response = api_client.save_analysis_preset( + _DSC_PRESET_ANALYSIS_TYPE, + name, + workflow_template_id=str(template_id or "").strip() or None, + processing=_overrides_from_draft(draft or {}), + ) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dsc.presets.save_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + dash.no_update, + ) + resolved_template = str(response.get("workflow_template_id") or template_id or "") + status = translate_ui(loc, "dash.analysis.dsc.presets.saved").format(preset=name, template=resolved_template) + snap = _dsc_ui_snapshot_dict(resolved_template, draft) + return int(refresh_token or 0) + 1, "", status, "dsc-tab-run", name, snap + + +@callback( + Output("dsc-preset-refresh", "data", allow_duplicate=True), + Output("dsc-preset-select", "value", allow_duplicate=True), + Output("dsc-preset-status", "children", allow_duplicate=True), + Output("dsc-preset-loaded-name", "data", allow_duplicate=True), + Output("dsc-preset-snapshot", "data", allow_duplicate=True), + Input("dsc-preset-delete-btn", "n_clicks"), + State("dsc-preset-select", "value"), + State("dsc-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def delete_dsc_preset(n_clicks, selected_name, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.dsc.presets.select_required"), dash.no_update, dash.no_update + try: + api_client.delete_analysis_preset(_DSC_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dsc.presets.delete_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + status = translate_ui(loc, "dash.analysis.dsc.presets.deleted").format(preset=name) + return int(refresh_token or 0) + 1, None, status, "", None + + +@callback( + Output("dsc-preset-loaded-line", "children"), + Input("dsc-preset-loaded-name", "data"), + Input("ui-locale", "data"), +) +def render_dsc_preset_loaded_line(name, locale_data): + loc = _loc(locale_data) + preset_name = str(name or "").strip() + if not preset_name: + return "" + return translate_ui(loc, "dash.analysis.dsc.presets.loaded_line").format(preset=preset_name) + + +@callback( + Output("dsc-preset-dirty-flag", "children"), + Input("ui-locale", "data"), + Input("dsc-template-select", "value"), + Input("dsc-normalization-enabled", "value"), + Input("dsc-smooth-method", "value"), + Input("dsc-smooth-window", "value"), + Input("dsc-smooth-polyorder", "value"), + Input("dsc-smooth-sigma", "value"), + Input("dsc-baseline-method", "value"), + Input("dsc-baseline-lam", "value"), + Input("dsc-baseline-p", "value"), + Input("dsc-baseline-poly-order", "value"), + Input("dsc-baseline-max-half-window", "value"), + Input("dsc-baseline-n-anchors", "value"), + Input("dsc-baseline-region-enabled", "value"), + Input("dsc-baseline-region-min", "value"), + Input("dsc-baseline-region-max", "value"), + Input("dsc-peak-direction", "value"), + Input("dsc-peak-prominence", "value"), + Input("dsc-peak-distance", "value"), + Input("dsc-tg-region-enabled", "value"), + Input("dsc-tg-region-min", "value"), + Input("dsc-tg-region-max", "value"), + State("dsc-preset-snapshot", "data"), +) +def render_dsc_preset_dirty_flag( + locale_data, + template_id, + normalization_enabled, + sm_m, + sm_w, + sm_p, + sm_s, + bl_m, + bl_lam, + bl_p, + bl_poly_order, + bl_max_half_window, + bl_n_anchors, + bl_region_enabled, + bl_region_min, + bl_region_max, + peak_direction, + peak_prominence, + peak_distance, + tg_enabled, + tg_min, + tg_max, + snapshot, +): + loc = _loc(locale_data) + if not isinstance(snapshot, dict): + return html.Span(translate_ui(loc, "dash.analysis.dsc.presets.dirty_no_baseline"), className="text-muted") + current = _dsc_ui_snapshot_dict( + template_id, + _dsc_draft_from_control_values( + normalization_enabled, + sm_m, + sm_w, + sm_p, + sm_s, + bl_m, + bl_lam, + bl_p, + bl_poly_order, + bl_max_half_window, + bl_n_anchors, + bl_region_enabled, + bl_region_min, + bl_region_max, + peak_direction, + peak_prominence, + peak_distance, + tg_enabled, + tg_min, + tg_max, + ), + ) + if _dsc_snapshots_equal(snapshot, current): + return html.Span(translate_ui(loc, "dash.analysis.dsc.presets.clean"), className="text-success") + return html.Span(translate_ui(loc, "dash.analysis.dsc.presets.dirty"), className="text-warning") + + +@callback( + Output("dsc-smooth-window", "disabled"), + Output("dsc-smooth-polyorder", "disabled"), + Output("dsc-smooth-sigma", "disabled"), + Input("dsc-smooth-method", "value"), +) +def toggle_smoothing_inputs(method): + token = str(method or "savgol").strip().lower() + if token == "savgol": + return False, False, True + if token == "moving_average": + return False, True, True + return True, True, False + + +@callback( + Output("dsc-baseline-lam-p-group", "style"), + Output("dsc-baseline-poly-order-group", "style"), + Output("dsc-baseline-max-half-window-group", "style"), + Output("dsc-baseline-n-anchors-group", "style"), + Input("dsc-baseline-method", "value"), +) +def toggle_baseline_parameter_groups(method): + token = str(method or "asls").strip().lower() + show = {"display": "block"} + hide = {"display": "none"} + if token == "asls" or token == "airpls": + return show, hide, hide, hide + if token in ("modpoly", "imodpoly"): + return hide, show, hide, hide + if token == "snip": + return hide, hide, show, hide + if token == "spline": + return hide, hide, hide, show + return hide, hide, hide, hide + + +@callback( + Output("dsc-baseline-lam", "disabled"), + Output("dsc-baseline-p", "disabled"), + Input("dsc-baseline-method", "value"), +) +def toggle_baseline_inputs(method): + token = str(method or "asls").strip().lower() + if token == "asls": + return False, False + if token == "airpls": + return False, True + return True, True + + +@callback( + Output("dsc-tg-region-min", "disabled"), + Output("dsc-tg-region-max", "disabled"), + Input("dsc-tg-region-enabled", "value"), +) +def toggle_tg_region_inputs(enabled): + return (not bool(enabled), not bool(enabled)) + + +@callback( + Output("dsc-baseline-region-min", "disabled"), + Output("dsc-baseline-region-max", "disabled"), + Input("dsc-baseline-region-enabled", "value"), +) +def toggle_dsc_baseline_region_inputs(enabled): + return (not bool(enabled), not bool(enabled)) + + +@callback( + Output("dsc-baseline-region-section-title", "children"), + Output("dsc-baseline-region-enabled", "label"), + Output("dsc-baseline-region-enable-hint", "children"), + Output("dsc-baseline-region-min-label", "children"), + Output("dsc-baseline-region-max-label", "children"), + Output("dsc-baseline-region-min-hint", "children"), + Output("dsc-baseline-region-max-hint", "children"), + Input("ui-locale", "data"), +) +def render_dsc_baseline_region_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dsc.baseline.region_section"), + translate_ui(loc, "dash.analysis.dsc.baseline.enable_region"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.enable_region"), + translate_ui(loc, "dash.analysis.dsc.baseline.region_min"), + translate_ui(loc, "dash.analysis.dsc.baseline.region_max"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.region_min"), + translate_ui(loc, "dash.analysis.dsc.baseline.help.region_max"), + ) + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Input("dsc-normalization-enabled", "value"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + prevent_initial_call=True, +) +def sync_dsc_normalization_from_setup(enabled, draft, undo): + current = _normalize_dsc_processing_draft(draft) + next_draft = copy.deepcopy(current) + next_draft["normalization"] = _normalize_normalization_values(enabled) + if next_draft == current: + raise dash.exceptions.PreventUpdate + return next_draft, _push_undo(undo, current), [] + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Input("dsc-smooth-apply-btn", "n_clicks"), + State("dsc-smooth-method", "value"), + State("dsc-smooth-window", "value"), + State("dsc-smooth-polyorder", "value"), + State("dsc-smooth-sigma", "value"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_smoothing(n_clicks, method, window, polyorder, sigma, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_smoothing_values(method, window, polyorder, sigma) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "smoothing", values) + return next_draft, next_undo, [] + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Input("dsc-baseline-apply-btn", "n_clicks"), + State("dsc-baseline-method", "value"), + State("dsc-baseline-lam", "value"), + State("dsc-baseline-p", "value"), + State("dsc-baseline-poly-order", "value"), + State("dsc-baseline-max-half-window", "value"), + State("dsc-baseline-n-anchors", "value"), + State("dsc-baseline-region-enabled", "value"), + State("dsc-baseline-region-min", "value"), + State("dsc-baseline-region-max", "value"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_baseline(n_clicks, method, lam, p, poly_order, max_half_window, n_anchors, region_enabled, region_min, region_max, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_baseline_values(method, lam, p, poly_order, max_half_window, n_anchors, region_enabled, region_min, region_max) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "baseline", values) + return next_draft, next_undo, [] + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Input("dsc-peak-apply-btn", "n_clicks"), + State("dsc-peak-direction", "value"), + State("dsc-peak-prominence", "value"), + State("dsc-peak-distance", "value"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_peak_detection(n_clicks, direction, prominence, distance, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_peak_detection_values(direction, prominence, distance) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "peak_detection", values) + return next_draft, next_undo, [] + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Input("dsc-tg-apply-btn", "n_clicks"), + State("dsc-tg-region-enabled", "value"), + State("dsc-tg-region-min", "value"), + State("dsc-tg-region-max", "value"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_glass_transition(n_clicks, enabled, region_min, region_max, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_glass_transition_values(enabled, region_min, region_max) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "glass_transition", values) + return next_draft, next_undo, [] + + +@callback( + Output("dsc-processing-draft", "data", allow_duplicate=True), + Output("dsc-processing-undo", "data", allow_duplicate=True), + Output("dsc-processing-redo", "data", allow_duplicate=True), + Output("dsc-history-status", "children", allow_duplicate=True), + Input("dsc-undo-btn", "n_clicks"), + Input("dsc-redo-btn", "n_clicks"), + Input("dsc-reset-btn", "n_clicks"), + State("dsc-processing-draft", "data"), + State("dsc-processing-undo", "data"), + State("dsc-processing-redo", "data"), + State("dsc-processing-default", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def dsc_processing_history_actions(n_undo, n_redo, n_reset, draft, undo, redo, defaults, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + cur = _normalize_dsc_processing_draft(draft) + + if trig == "dsc-undo-btn": + if not n_undo: + raise dash.exceptions.PreventUpdate + next_draft, next_undo, next_redo = _do_undo(cur, undo, redo) + return next_draft, next_undo, next_redo, translate_ui(loc, "dash.analysis.dsc.processing.history_status_undo") + + if trig == "dsc-redo-btn": + if not n_redo: + raise dash.exceptions.PreventUpdate + next_draft, next_undo, next_redo = _do_redo(cur, undo, redo) + return next_draft, next_undo, next_redo, translate_ui(loc, "dash.analysis.dsc.processing.history_status_redo") + + if trig == "dsc-reset-btn": + if not n_reset: + raise dash.exceptions.PreventUpdate + next_draft, next_undo, next_redo = _do_reset(cur, undo, redo, defaults) + if next_draft == cur and next_undo == (undo or []) and next_redo == (redo or []): + raise dash.exceptions.PreventUpdate + return next_draft, next_undo, next_redo, translate_ui(loc, "dash.analysis.dsc.processing.history_status_reset") + + raise dash.exceptions.PreventUpdate + + +def _smoothing_status_text(draft: dict | None, loc: str) -> str: + values = (draft or {}).get("smoothing") or {} + method = str(values.get("method") or "savgol") + method_label = {"savgol": "Savitzky-Golay", "moving_average": "Moving Average", "gaussian": "Gaussian"}.get(method, method) + parts = [method_label] + if "window_length" in values: + parts.append(f"window={values['window_length']}") + if "polyorder" in values: + parts.append(f"polyorder={values['polyorder']}") + if "sigma" in values: + parts.append(f"sigma={values['sigma']}") + applied = translate_ui(loc, "dash.analysis.dsc.smoothing.applied") + return f"{applied}: {' - '.join(parts)}" + + +def _baseline_status_text(draft: dict | None, loc: str) -> str: + values = (draft or {}).get("baseline") or {} + method = str(values.get("method") or "asls") + method_label = { + "asls": "AsLS", + "airpls": "airPLS", + "modpoly": "ModPoly", + "imodpoly": "iModPoly", + "snip": "SNIP", + "linear": "Linear", + "rubberband": "Rubberband", + "spline": "Spline", + }.get(method, method) + parts = [method_label] + if method in ("asls", "airpls"): + if "lam" in values: + parts.append(f"lam={values['lam']:g}") + if method == "asls": + if "p" in values: + parts.append(f"p={values['p']:g}") + if method in ("modpoly", "imodpoly"): + if "poly_order" in values: + parts.append(f"order={values['poly_order']}") + if method == "snip": + if "max_half_window" in values: + parts.append(f"window={values['max_half_window']}") + if method == "spline": + if "n_anchors" in values: + parts.append(f"anchors={values['n_anchors']}") + region = values.get("region") + if isinstance(region, (list, tuple)) and len(region) == 2: + parts.append( + translate_ui(loc, "dash.analysis.dsc.baseline.region_applied").format(tmin=region[0], tmax=region[1]) + ) + applied = translate_ui(loc, "dash.analysis.dsc.baseline.applied") + return f"{applied}: {' - '.join(parts)}" + + +def _peak_status_text(draft: dict | None, loc: str) -> str: + values = (draft or {}).get("peak_detection") or {} + direction = str(values.get("direction") or "both") + parts = [f"direction={direction}"] + prominence = values.get("prominence") + if prominence is not None: + parts.append("prominence=auto" if float(prominence) == 0.0 else f"prominence={prominence:g}") + distance = values.get("distance") + if distance is not None: + parts.append(f"distance={int(distance)}") + applied = translate_ui(loc, "dash.analysis.dsc.peaks.applied") + return f"{applied}: {' - '.join(parts)}" + + +def _tg_status_text(draft: dict | None, loc: str) -> str: + values = (draft or {}).get("glass_transition") or {} + region = values.get("region") + applied = translate_ui(loc, "dash.analysis.dsc.tg.applied") + if isinstance(region, (list, tuple)) and len(region) == 2: + return f"{applied}: {translate_ui(loc, 'dash.analysis.dsc.tg.region_custom').format(tmin=region[0], tmax=region[1])}" + return f"{applied}: {translate_ui(loc, 'dash.analysis.dsc.tg.region_auto')}" + + +@callback( + Output("dsc-normalization-enabled", "value"), + Input("dsc-processing-draft", "data"), +) +def sync_normalization_control(draft): + values = _normalize_dsc_processing_draft(draft).get("normalization") or {} + return bool(values.get("enabled", True)) + + +@callback( + Output("dsc-smooth-method", "value"), + Output("dsc-smooth-window", "value"), + Output("dsc-smooth-polyorder", "value"), + Output("dsc-smooth-sigma", "value"), + Output("dsc-smooth-status", "children"), + Output("dsc-undo-btn", "disabled"), + Output("dsc-redo-btn", "disabled"), + Output("dsc-reset-btn", "disabled"), + Input("dsc-processing-draft", "data"), + Input("dsc-processing-undo", "data"), + Input("dsc-processing-redo", "data"), + Input("dsc-processing-default", "data"), + Input("ui-locale", "data"), +) +def sync_smoothing_controls(draft, undo, redo, defaults, locale_data): + loc = _loc(locale_data) + normalized_draft = _normalize_dsc_processing_draft(draft) + normalized_defaults = _normalize_dsc_processing_draft(defaults) + values = normalized_draft.get("smoothing") or {} + method = str(values.get("method") or "savgol") + window_length = values.get("window_length", 11) + polyorder = values.get("polyorder", 3) + sigma = values.get("sigma", 2.0) + status = _smoothing_status_text(normalized_draft, loc) + undo_disabled = not bool(undo) + redo_disabled = not bool(redo) + reset_disabled = normalized_draft == normalized_defaults + return method, window_length, polyorder, sigma, status, undo_disabled, redo_disabled, reset_disabled + + +@callback( + Output("dsc-baseline-method", "value"), + Output("dsc-baseline-lam", "value"), + Output("dsc-baseline-p", "value"), + Output("dsc-baseline-poly-order", "value"), + Output("dsc-baseline-max-half-window", "value"), + Output("dsc-baseline-n-anchors", "value"), + Output("dsc-baseline-region-enabled", "value"), + Output("dsc-baseline-region-min", "value"), + Output("dsc-baseline-region-max", "value"), + Output("dsc-baseline-status", "children"), + Input("dsc-processing-draft", "data"), + Input("ui-locale", "data"), +) +def sync_baseline_controls(draft, locale_data): + loc = _loc(locale_data) + values = (draft or {}).get("baseline") or {} + method = str(values.get("method") or "asls") + lam = values.get("lam", 1e6) + p = values.get("p", 0.01) + poly_order = values.get("poly_order", 6) + max_half_window = values.get("max_half_window", 40) + n_anchors = values.get("n_anchors", 6) + region = values.get("region") + enabled = isinstance(region, (list, tuple)) and len(region) == 2 + region_min = region[0] if enabled else None + region_max = region[1] if enabled else None + status = _baseline_status_text(draft, loc) + return method, lam, p, poly_order, max_half_window, n_anchors, bool(enabled), region_min, region_max, status + + +@callback( + Output("dsc-peak-direction", "value"), + Output("dsc-peak-prominence", "value"), + Output("dsc-peak-distance", "value"), + Output("dsc-peak-status", "children"), + Input("dsc-processing-draft", "data"), + Input("ui-locale", "data"), +) +def sync_peak_controls(draft, locale_data): + loc = _loc(locale_data) + values = (draft or {}).get("peak_detection") or {} + direction = str(values.get("direction") or "both") + prominence = values.get("prominence", 0.0) + distance = values.get("distance", 1) + status = _peak_status_text(draft, loc) + return direction, prominence, distance, status + + +@callback( + Output("dsc-tg-region-enabled", "value"), + Output("dsc-tg-region-min", "value"), + Output("dsc-tg-region-max", "value"), + Output("dsc-tg-status", "children"), + Input("dsc-processing-draft", "data"), + Input("ui-locale", "data"), +) +def sync_tg_controls(draft, locale_data): + loc = _loc(locale_data) + values = (draft or {}).get("glass_transition") or {} + region = values.get("region") + enabled = isinstance(region, (list, tuple)) and len(region) == 2 + region_min = region[0] if enabled else None + region_max = region[1] if enabled else None + status = _tg_status_text(draft, loc) + return bool(enabled), region_min, region_max, status + + +@callback( + Output("dsc-run-status", "children"), + Output("dsc-refresh", "data", allow_duplicate=True), + Output("dsc-latest-result-id", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("dsc-run-btn", "n_clicks"), + State("project-id", "data"), + State("dsc-dataset-select", "value"), + State("dsc-template-select", "value"), + State("dsc-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + State("dsc-processing-draft", "data"), + prevent_initial_call=True, +) +def run_dsc_analysis( + n_clicks, + project_id, + dataset_key, + template_id, + refresh_val, + global_refresh, + locale_data, + processing_draft, +): + loc = _loc(locale_data) + if not n_clicks or not project_id or not dataset_key: + raise dash.exceptions.PreventUpdate + + from dash_app.api_client import analysis_run + + overrides = _overrides_from_draft(processing_draft) or None + try: + result = analysis_run( + project_id=project_id, + dataset_key=dataset_key, + analysis_type="DSC", + workflow_template_id=template_id, + processing_overrides=overrides, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.analysis_failed", error=str(exc)), color="danger"), dash.no_update, dash.no_update, dash.no_update + + alert, saved, result_id = interpret_run_result(result, locale_data=locale_data) + refresh = (refresh_val or 0) + 1 + if saved: + return alert, refresh, result_id, (global_refresh or 0) + 1 + return alert, refresh, dash.no_update, dash.no_update + + +@callback( + Output("dsc-result-dataset-summary", "children"), + Output("dsc-result-metrics", "children"), + Output("dsc-result-quality", "children"), + Output("dsc-result-figure", "children"), + Output("dsc-result-derivative", "children"), + Output("dsc-result-event-cards", "children"), + Output("dsc-result-table", "children"), + Output("dsc-result-processing", "children"), + Output("dsc-result-raw-metadata", "children"), + Input("dsc-latest-result-id", "data"), + Input("dsc-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def display_result(result_id, _refresh, ui_theme, locale_data, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + summary_empty = html.P(translate_ui(loc, "dash.analysis.dsc.summary.empty"), className="text-muted") + quality_empty = _dsc_collapsible_section( + loc, + "dash.analysis.dsc.quality.card_title", + html.P(translate_ui(loc, "dash.analysis.dsc.quality.empty"), className="text-muted mb-0"), + open=False, + ) + raw_meta_empty = _dsc_collapsible_section( + loc, + "dash.analysis.dsc.raw_metadata.card_title", + html.P(translate_ui(loc, "dash.analysis.dsc.raw_metadata.empty"), className="text-muted mb-0"), + open=False, + ) + if not result_id or not project_id: + return summary_empty, empty_msg, quality_empty, empty_msg, empty_msg, empty_msg, empty_msg, empty_msg, raw_meta_empty + + from dash_app.api_client import workspace_dataset_detail, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + err = dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_result", error=str(exc)), color="danger") + return summary_empty, err, quality_empty, empty_msg, empty_msg, empty_msg, empty_msg, empty_msg, raw_meta_empty + + summary = detail.get("summary", {}) + result_meta = detail.get("result", {}) + processing = detail.get("processing", {}) + rows = _event_rows(detail.get("rows") or detail.get("rows_preview") or []) + dataset_key = result_meta.get("dataset_key") + + dataset_detail = {} + if dataset_key: + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + + dataset_summary_panel = _build_dsc_dataset_summary( + dataset_detail, + summary, + result_meta, + loc, + locale_data=locale_data, + ) + quality_panel = _build_dsc_quality_card(detail, result_meta, loc) + raw_metadata_panel = _build_dsc_raw_metadata_panel((dataset_detail or {}).get("metadata"), loc) + + peak_count = int(summary.get("peak_count") or len(rows) or 0) + tg_count = int(summary.get("glass_transition_count") or 0) + fallback_display_name = _format_dataset_metadata_value(((dataset_detail or {}).get("dataset") or {}).get("display_name")) + sample_name = resolve_sample_name(summary, result_meta, fallback_display_name=fallback_display_name, locale_data=locale_data) + na = translate_ui(loc, "dash.analysis.na") + metrics = metrics_row( + [ + ("dash.analysis.metric.peaks", str(peak_count)), + ("dash.analysis.metric.glass_transitions", str(tg_count)), + ("dash.analysis.metric.template", str(processing.get("workflow_template_label", na))), + ("dash.analysis.metric.sample", sample_name), + ], + locale_data=locale_data, + ) + + figure_area = empty_msg + derivative_area = empty_msg + if dataset_key: + figure_area = _build_figure(project_id, dataset_key, summary, rows, ui_theme, loc, locale_data=locale_data) + derivative_area = _build_derivative_panel(project_id, dataset_key, ui_theme, loc, locale_data=locale_data) + + event_cards = _build_event_cards(summary, rows, loc) + table_area = _build_peak_table(rows, loc) + + proc_view = processing_details_section( + processing, + extra_lines=[ + html.P( + translate_ui( + loc, + "dash.analysis.dsc.normalization", + detail=translate_ui( + loc, + "dash.analysis.dsc.normalization.enabled" + if bool((processing.get("signal_pipeline", {}).get("normalization", {}) or {}).get("enabled", True)) + else "dash.analysis.dsc.normalization.disabled", + ), + ) + ), + html.P(translate_ui(loc, "dash.analysis.dsc.baseline", detail=processing.get("signal_pipeline", {}).get("baseline", {}))), + html.P( + translate_ui( + loc, + "dash.analysis.dsc.peak_detection", + detail=processing.get("analysis_steps", {}).get("peak_detection", {}), + ) + ), + html.P( + translate_ui( + loc, + "dash.analysis.dsc.tg_detection", + detail=processing.get("analysis_steps", {}).get("glass_transition", {}), + ) + ), + html.P( + translate_ui( + loc, + "dash.analysis.dsc.sign_convention", + detail=processing.get("method_context", {}).get("sign_convention_label", na), + ), + className="mb-0", + ), + ], + locale_data=locale_data, + ) + proc_view = _wrap_dsc_processing_details(proc_view, processing, loc) + + return ( + dataset_summary_panel, + metrics, + quality_panel, + figure_area, + derivative_area, + event_cards, + table_area, + proc_view, + raw_metadata_panel, + ) + + +@callback( + Output("dsc-literature-card-title", "children"), + Output("dsc-literature-hint", "children"), + Output("dsc-literature-max-claims-label", "children"), + Output("dsc-literature-persist-label", "children"), + Output("dsc-literature-compare-btn", "children"), + Input("ui-locale", "data"), + Input("dsc-latest-result-id", "data"), +) +def render_dsc_literature_chrome(locale_data, result_id): + loc = _loc(locale_data) + if result_id: + hint = literature_t( + loc, + f"{_DSC_LITERATURE_PREFIX}.ready", + "Compare the saved DSC result to literature sources.", + ) + else: + hint = literature_t( + loc, + f"{_DSC_LITERATURE_PREFIX}.empty", + "Run a DSC analysis first to enable literature comparison.", + ) + return ( + literature_t(loc, f"{_DSC_LITERATURE_PREFIX}.title", "Literature Compare"), + hint, + literature_t(loc, f"{_DSC_LITERATURE_PREFIX}.max_claims", "Max Claims"), + literature_t(loc, f"{_DSC_LITERATURE_PREFIX}.persist", "Persist to project"), + literature_t(loc, f"{_DSC_LITERATURE_PREFIX}.compare_btn", "Compare"), + ) + + +@callback( + Output("dsc-literature-compare-btn", "disabled"), + Input("dsc-latest-result-id", "data"), +) +def toggle_dsc_literature_compare_button(result_id): + return not bool(result_id) + + +@callback( + Output("dsc-literature-output", "children"), + Output("dsc-literature-status", "children"), + Input("dsc-literature-compare-btn", "n_clicks"), + State("project-id", "data"), + State("dsc-latest-result-id", "data"), + State("dsc-literature-max-claims", "value"), + State("dsc-literature-persist", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def compare_dsc_literature(n_clicks, project_id, result_id, max_claims, persist_values, locale_data): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + if not project_id or not result_id: + msg = literature_t( + loc, + f"{_DSC_LITERATURE_PREFIX}.missing_result", + "Run a DSC analysis first.", + ) + return dash.no_update, dbc.Alert(msg, color="warning", className="py-1 small") + + claims_limit = coerce_literature_max_claims(max_claims, default=3) + persist = bool(persist_values) and "persist" in (persist_values or []) + + from dash_app.api_client import literature_compare + + try: + payload = literature_compare( + project_id, + result_id, + max_claims=claims_limit, + persist=persist, + ) + except Exception as exc: + err = dbc.Alert( + literature_t( + loc, + f"{_DSC_LITERATURE_PREFIX}.error", + "Literature compare failed: {error}", + ).replace("{error}", str(exc)), + color="danger", + className="py-1 small", + ) + return dash.no_update, err + + return ( + render_literature_output( + payload, + loc, + i18n_prefix=_DSC_LITERATURE_PREFIX, + evidence_preview_limit=LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + alternative_preview_limit=LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + ), + literature_compare_status_alert(payload, loc, i18n_prefix=_DSC_LITERATURE_PREFIX), + ) + + +def _dsc_collapsible_section(loc: str, title_key: str, body: Any, *, open: bool = False, summary_suffix: Any | None = None) -> html.Details: + return build_collapsible_section(loc, title_key, body, open=open, summary_suffix=summary_suffix) + + +def _dsc_fetch_figure_preview_data_urls(project_id: str, result_id: str, figure_artifacts: dict) -> dict[str, str]: + from dash_app.api_client import fetch_result_figure_png + + out: dict[str, str] = {} + for label in ordered_figure_preview_keys(figure_artifacts)[:FIGURE_ARTIFACT_PREVIEW_TILES]: + try: + raw = fetch_result_figure_png(project_id, result_id, label, max_edge=FIGURE_ARTIFACT_PREVIEW_MAX_EDGE) + out[label] = "data:image/png;base64," + base64.standard_b64encode(bytes(raw)).decode("ascii") if raw else "" + except Exception: + out[label] = "" + return out + + +@callback( + Output("dsc-figure-save-snapshot-btn", "children"), + Output("dsc-figure-use-report-btn", "children"), + Output("dsc-figure-artifacts-summary", "children"), + Input("ui-locale", "data"), +) +def render_dsc_figure_artifact_button_labels(locale_data): + return figure_artifact_button_labels(_loc(locale_data)) + + +@callback( + Output("dsc-figure-save-snapshot-btn", "disabled"), + Output("dsc-figure-use-report-btn", "disabled"), + Input("dsc-latest-result-id", "data"), +) +def toggle_dsc_figure_artifact_buttons(result_id): + disabled = not bool(result_id) + return disabled, disabled + + +@callback( + Output("dsc-result-figure-artifacts", "children"), + Input("dsc-latest-result-id", "data"), + Input("dsc-figure-artifact-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def refresh_dsc_figure_artifacts_panel(result_id, _artifact_refresh, locale_data, project_id): + loc = _loc(locale_data) + if not result_id or not project_id: + return "" + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + return "" + artifacts = detail.get("figure_artifacts") if isinstance(detail.get("figure_artifacts"), dict) else {} + previews = _dsc_fetch_figure_preview_data_urls(project_id, result_id, artifacts) if ordered_figure_preview_keys(artifacts) else None + return build_figure_artifacts_panel(artifacts, loc, previews=previews) + + +@callback( + Output("dsc-figure-artifact-status", "children"), + Output("dsc-figure-artifact-refresh", "data"), + Input("dsc-figure-save-snapshot-btn", "n_clicks"), + Input("dsc-figure-use-report-btn", "n_clicks"), + Input("dsc-latest-result-id", "data"), + State("project-id", "data"), + State("dsc-result-figure", "children"), + State("ui-locale", "data"), + State("dsc-figure-artifact-refresh", "data"), + prevent_initial_call=True, +) +def dsc_figure_snapshot_or_report_figure(_snap_clicks, _report_clicks, latest_result_id, project_id, figure_children, locale_data, refresh_value): + loc = _loc(locale_data) + triggered_id = getattr(dash.callback_context, "triggered_id", None) + if triggered_id == "dsc-latest-result-id": + return "", dash.no_update + action = figure_action_from_trigger( + triggered_id, + snapshot_button_id="dsc-figure-save-snapshot-btn", + report_button_id="dsc-figure-use-report-btn", + ) + if action is None: + raise dash.exceptions.PreventUpdate + if not project_id or not latest_result_id: + return ( + figure_action_status_alert(loc, action=action, status="missing", reason="missing_project_or_result", class_prefix="dsc"), + dash.no_update, + ) + + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, latest_result_id) + except Exception as exc: + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(exc), class_prefix="dsc"), + dash.no_update, + ) + result_meta = detail.get("result", {}) or {} + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + meta = figure_action_metadata( + action, + analysis_type="DSC", + dataset_key=result_meta.get("dataset_key"), + result_id=latest_result_id, + snapshot_stamp=stamp, + ) + outcome = register_result_figure_from_layout_children( + figure_children=figure_children, + project_id=project_id, + result_id=latest_result_id, + label=str(meta.get("label") or ""), + replace=bool(meta.get("replace")), + ) + if outcome.get("status") == "ok": + key = str(outcome.get("figure_key") or meta.get("label") or "") + return ( + figure_action_status_alert(loc, action=action, status="ok", figure_key=key, class_prefix="dsc"), + (refresh_value or 0) + 1, + ) + if outcome.get("status") == "error": + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(outcome.get("reason") or ""), class_prefix="dsc"), + dash.no_update, + ) + return ( + figure_action_status_alert(loc, action=action, status="skipped", reason=str(outcome.get("reason") or ""), class_prefix="dsc"), + dash.no_update, + ) + + +@callback( + Output("dsc-figure-captured", "data"), + Input("dsc-latest-result-id", "data"), + Input("project-id", "data"), + Input("dsc-result-figure", "children"), + State("dsc-figure-captured", "data"), + prevent_initial_call=True, +) +def capture_dsc_figure(result_id, project_id, figure_children, captured): + return capture_result_figure_from_layout( + result_id=result_id, + project_id=project_id, + figure_children=figure_children, + captured=captured, + analysis_type="DSC", + ) + + +# --------------------------------------------------------------------------- +# DSC-specific builders +# --------------------------------------------------------------------------- + +def _coerce_float(value) -> float | None: + try: + if value in (None, ""): + return None + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if math.isfinite(parsed) else None + + +def _format_dataset_metadata_value(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, float): + if value != value: + return None + text = f"{value:g}" + else: + text = str(value).strip() + return text or None + + +def _series_for_temperature(series: list | tuple, temperature: list[float]) -> list[float]: + if not isinstance(series, (list, tuple)) or len(series) != len(temperature): + return [] + values: list[float] = [] + for item in series: + parsed = _coerce_float(item) + if parsed is None: + return [] + values.append(parsed) + return values + + +def _format_temp_c(value: float | None) -> str: + if value is None: + return "--" + return f"{value:.1f} °C" + + +def _format_numeric(value: float | None, *, digits: int = 3) -> str: + if value is None: + return "--" + return f"{value:.{digits}f}" + + +def _peak_type_label(peak_type: str | None, loc: str) -> str: + token = str(peak_type or "").strip().lower() + if token.startswith("endo"): + return translate_ui(loc, "dash.analysis.dsc.peak_type.endotherm") + if token.startswith("exo"): + return translate_ui(loc, "dash.analysis.dsc.peak_type.exotherm") + if token == "step": + return translate_ui(loc, "dash.analysis.dsc.peak_type.step") + return translate_ui(loc, "dash.analysis.dsc.peak_type.unknown") + + +def _event_rows(rows: list) -> list[dict]: + clean = [dict(item) for item in rows if isinstance(item, dict)] + return _sort_events_by_temperature(clean) + + +def _sort_events_by_temperature(rows: list[dict]) -> list[dict]: + return sorted(rows, key=lambda row: _coerce_float(row.get("peak_temperature")) or float("inf")) + + +def _event_score(row: dict) -> float: + area = abs(_coerce_float(row.get("area")) or 0.0) + if area > 0: + return area + return abs(_coerce_float(row.get("height")) or 0.0) + + +def _split_primary_events(rows: list[dict], *, limit: int = 4) -> tuple[list[dict], list[dict]]: + if len(rows) <= limit: + return rows, [] + indexed = list(enumerate(rows)) + ranked = sorted( + indexed, + key=lambda item: ( + -_event_score(item[1]), + _coerce_float(item[1].get("peak_temperature")) or float("inf"), + item[0], + ), + ) + primary_idx = {idx for idx, _ in ranked[:limit]} + primary = _sort_events_by_temperature([row for idx, row in indexed if idx in primary_idx]) + secondary = _sort_events_by_temperature([row for idx, row in indexed if idx not in primary_idx]) + return primary, secondary + + +def _trace_hover_template(trace_name: str, loc: str) -> str: + return ( + f"{trace_name}
" + f"{translate_ui(loc, 'dash.analysis.dsc.figure.hover.temperature')}: %{{x:.2f}} °C
" + f"{translate_ui(loc, 'dash.analysis.dsc.figure.hover.signal')}: %{{y:.5g}}" + "" + ) + + +def _event_hover_html(row: dict, y_value: float | None, loc: str) -> str: + peak_type_label = _peak_type_label(row.get("peak_type"), loc) + peak_temperature = _coerce_float(row.get("peak_temperature")) + onset = _coerce_float(row.get("onset_temperature")) + endset = _coerce_float(row.get("endset_temperature")) + area = _coerce_float(row.get("area")) + height = _coerce_float(row.get("height")) + return ( + f"{peak_type_label}
" + f"{translate_ui(loc, 'dash.analysis.dsc.figure.hover.temperature')}: {_format_temp_c(peak_temperature)}
" + f"{translate_ui(loc, 'dash.analysis.dsc.figure.hover.signal')}: {_format_numeric(y_value, digits=4)}
" + f"{translate_ui(loc, 'dash.analysis.label.onset')}: {_format_temp_c(onset)}
" + f"{translate_ui(loc, 'dash.analysis.label.endset')}: {_format_temp_c(endset)}
" + f"{translate_ui(loc, 'dash.analysis.label.area')}: {_format_numeric(area)}
" + f"{translate_ui(loc, 'dash.analysis.label.height')}: {_format_numeric(height)}" + "" + ) + + +def _build_tg_summary(summary: dict, loc: str) -> html.Div: + tg_mid = _coerce_float(summary.get("tg_midpoint")) + tg_onset = _coerce_float(summary.get("tg_onset")) + tg_endset = _coerce_float(summary.get("tg_endset")) + delta_cp = _coerce_float(summary.get("delta_cp")) + tg_count = int(summary.get("glass_transition_count") or (1 if tg_mid is not None else 0) or 0) + + if tg_count == 0 or tg_mid is None: + return html.Div( + html.P(translate_ui(loc, "dash.analysis.state.not_detected"), className="text-muted mb-0 small"), + className="mb-3", + ) + + onset_txt = f"{tg_onset:.1f}" if tg_onset is not None else "--" + end_txt = f"{tg_endset:.1f}" if tg_endset is not None else "--" + dcp_txt = f"{delta_cp:.4f}" if delta_cp is not None else "--" + summary_line = translate_ui(loc, "dash.analysis.dsc.events.tg_one_liner").format( + midpoint=f"{tg_mid:.1f}", + onset=onset_txt, + endset=end_txt, + dcp=dcp_txt, + ) + extra: list[Any] = [] + if tg_count > 1: + extra.append( + html.P( + translate_ui(loc, "dash.analysis.state.more_transitions", n=tg_count - 1), + className="text-muted small mb-0", + ) + ) + return html.Div( + [html.P(summary_line, className="small mb-1"), *extra], + className="mb-3", + ) + + +def _build_event_cards(summary: dict, rows: list[dict], loc: str) -> html.Div: + tg_block = _build_tg_summary(summary, loc) + primary_rows, secondary_rows = _split_primary_events(rows, limit=4) + + cards: list[Any] = [ + html.H5(translate_ui(loc, "dash.analysis.section.key_thermal_events"), className="mb-2"), + html.H6(translate_ui(loc, "dash.analysis.section.glass_transitions"), className="mb-2 text-muted small text-uppercase"), + tg_block, + html.H6(translate_ui(loc, "dash.analysis.section.detected_peaks"), className="mb-2 text-muted small text-uppercase"), + ] + + if not rows: + cards.append(html.P(translate_ui(loc, "dash.analysis.state.no_peaks"), className="text-muted mb-0")) + if not summary.get("glass_transition_count"): + cards.append(html.P(translate_ui(loc, "dash.analysis.dsc.events.empty"), className="text-muted small mt-2 mb-0")) + return html.Div(cards) + + cards.append( + html.P( + translate_ui(loc, "dash.analysis.dsc.events_cards_intro", shown=len(primary_rows), total=len(rows)), + className="text-muted small mb-2", + ) + ) + cards.append(dbc.Row([dbc.Col(_peak_card(row, idx, loc), md=6) for idx, row in enumerate(primary_rows)], className="g-3")) + + if secondary_rows: + cards.append( + html.Details( + [ + html.Summary(translate_ui(loc, "dash.analysis.dsc.show_more_events", n=len(secondary_rows)), className="small"), + html.Div( + dataset_table( + secondary_rows, + ["peak_type", "peak_temperature", "onset_temperature", "endset_temperature", "area", "height"], + table_id="dsc-secondary-events-table", + ), + className="mt-3", + ), + ], + className="mt-3", + ) + ) + return html.Div(cards) + + +def _build_peak_table(rows: list[dict], loc: str) -> html.Div: + if not rows: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.all_event_details"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_event_data"), className="text-muted"), + ] + ) + columns = ["peak_type", "peak_temperature", "onset_temperature", "endset_temperature", "area", "fwhm", "height"] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.all_event_details"), className="mb-3"), + dataset_table(rows, columns, table_id="dsc-peaks-table"), + ] + ) + + +def _build_dsc_dataset_summary( + dataset_detail: dict, + summary: dict, + result_meta: dict, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + metadata = (dataset_detail or {}).get("metadata") or {} + dataset_summary = (dataset_detail or {}).get("dataset") or {} + na = translate_ui(loc, "dash.analysis.na") + + dataset_label = ( + _format_dataset_metadata_value(metadata.get("file_name")) + or _format_dataset_metadata_value(dataset_summary.get("display_name")) + or _format_dataset_metadata_value(result_meta.get("dataset_key")) + or na + ) + fallback_display_name = _format_dataset_metadata_value(dataset_summary.get("display_name")) + sample_label = resolve_sample_name( + summary or {}, + result_meta or {}, + fallback_display_name=fallback_display_name, + locale_data=locale_data, + ) or na + + sample_mass = _format_dataset_metadata_value(summary.get("sample_mass")) or _format_dataset_metadata_value(metadata.get("sample_mass")) + if sample_mass: + sample_mass = f"{sample_mass} {translate_ui(loc, 'dash.analysis.dsc.summary.mass_unit')}" + else: + sample_mass = na + + heating_rate = _format_dataset_metadata_value(summary.get("heating_rate")) or _format_dataset_metadata_value(metadata.get("heating_rate")) + if heating_rate: + heating_rate = f"{heating_rate} {translate_ui(loc, 'dash.analysis.dsc.summary.heating_rate_unit')}" + else: + heating_rate = na + + def _meta_value(value: str) -> html.Span: + return html.Span(value, className="ms-meta-value", title=value) + + rows: list[Any] = [ + html.Dt(translate_ui(loc, "dash.analysis.dsc.summary.dataset_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(dataset_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.dsc.summary.sample_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.dsc.summary.mass_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_mass), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.dsc.summary.heating_rate_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(heating_rate), className="col-sm-8 ms-meta-def"), + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.dsc.summary.card_title"), className="mb-3"), + html.Dl(rows, className="row mb-0"), + ] + ) + + +def _build_dsc_quality_card(detail: dict, result_meta: dict, loc: str) -> html.Details: + return build_validation_quality_card( + detail, + result_meta, + loc, + i18n_prefix="dash.analysis.dsc.quality", + collapsible_builder=_dsc_collapsible_section, + derive_counts_from_lists=False, + ) + + +def _build_dsc_raw_metadata_panel(metadata: dict | None, loc: str) -> html.Details: + return build_split_raw_metadata_panel( + metadata, + loc, + i18n_prefix="dash.analysis.dsc.raw_metadata", + user_facing_keys=_DSC_USER_FACING_METADATA_KEYS, + value_formatter=_format_dataset_metadata_value, + collapsible_builder=_dsc_collapsible_section, + ) + + +def _build_dsc_processing_expansion_blocks(processing: dict, loc: str) -> html.Div: + sp = processing.get("signal_pipeline") or {} + asteps = processing.get("analysis_steps") or {} + blocks: list[Any] = [] + pairs = [ + ("dash.analysis.dsc.processing.block_smoothing", sp.get("smoothing")), + ("dash.analysis.dsc.processing.block_normalization", sp.get("normalization")), + ("dash.analysis.dsc.processing.block_baseline", sp.get("baseline")), + ("dash.analysis.dsc.processing.block_peaks", asteps.get("peak_detection")), + ("dash.analysis.dsc.processing.block_tg", asteps.get("glass_transition")), + ] + for title_key, data in pairs: + if not isinstance(data, dict) or not data: + continue + blocks.append(html.H6(translate_ui(loc, title_key), className="mt-2 small text-muted mb-1")) + blocks.append(html.Pre(json.dumps(data, indent=2, ensure_ascii=False), className="small ta-code-block p-2 rounded mb-0")) + if not blocks: + return html.Div() + return html.Div(blocks, className="mt-3 pt-2 border-top") + + +def _wrap_dsc_processing_details(inner: html.Div, processing: dict, loc: str) -> html.Div: + expansion = _build_dsc_processing_expansion_blocks(processing, loc) + return html.Div( + [ + html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(translate_ui(loc, "dash.analysis.dsc.processing.expand_summary"), className="ms-1"), + ], + className="ta-details-summary", + ), + html.Div([inner, expansion], className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=False, + ) + ] + ) + + +def _build_derivative_panel( + project_id: str, + dataset_key: str, + ui_theme: str | None, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + """Compact d(corrected signal)/dT vs temperature helper (uses backend ``dtg`` curve).""" + _ld = locale_data if locale_data is not None else loc + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "DSC", dataset_key) + except Exception: + curves = {} + + if not curves.get("has_dtg") and not curves.get("dtg"): + return html.Div() + + raw_temperature = curves.get("temperature") or [] + raw_dtg = curves.get("dtg") or [] + if not raw_temperature or not raw_dtg or len(raw_temperature) != len(raw_dtg): + return html.Div() + + temperature: list[float] = [] + dtg: list[float] = [] + for tx, dx in zip(raw_temperature, raw_dtg): + pt = _coerce_float(tx) + pd = _coerce_float(dx) + if pt is None or pd is None: + continue + temperature.append(pt) + dtg.append(pd) + if len(temperature) < 3: + return html.Div() + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=temperature, + y=dtg, + mode="lines", + name=translate_ui(loc, "dash.analysis.dsc.derivative.trace_name"), + line=dict(color="#7C3AED", width=1.8), + ) + ) + fig.update_layout( + title=dict( + text=translate_ui(loc, "dash.analysis.dsc.derivative.title"), + x=0.01, + xanchor="left", + font=dict(size=14), + ), + xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_temperature_c"), + yaxis_title=translate_ui(loc, "dash.analysis.dsc.derivative.axis_label"), + height=280, + margin=dict(l=56, r=18, t=48, b=44), + showlegend=False, + ) + apply_figure_theme(fig, ui_theme) + graph = dcc.Graph( + figure=fig, + config={ + "displaylogo": False, + "responsive": True, + "modeBarButtonsToRemove": ["lasso2d", "select2d", "toggleSpikelines", "hoverCompareCartesian"], + }, + className="ta-plot dsc-derivative-graph", + ) + return html.Div( + [ + html.H6(translate_ui(loc, "dash.analysis.dsc.derivative.card_title"), className="mb-2"), + html.P(translate_ui(loc, "dash.analysis.dsc.derivative.caption"), className="small text-muted mb-2"), + graph, + ], + className="dsc-derivative-helper", + ) + + +def _build_dsc_go_figure( + project_id: str, + dataset_key: str, + summary: dict, + peak_rows: list[dict], + ui_theme: str | None, + loc: str, +) -> go.Figure | None: + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "DSC", dataset_key) + except Exception: + curves = {} + + raw_temperature = curves.get("temperature") or [] + if not raw_temperature: + return None + temperature: list[float] = [] + for item in raw_temperature: + parsed = _coerce_float(item) + if parsed is None: + return None + temperature.append(parsed) + + raw_signal = _series_for_temperature(curves.get("raw_signal", []), temperature) + smoothed = _series_for_temperature(curves.get("smoothed", []), temperature) + baseline = _series_for_temperature(curves.get("baseline", []), temperature) + corrected = _series_for_temperature(curves.get("corrected", []), temperature) + primary_signal = corrected or smoothed or raw_signal + if not primary_signal: + return None + + is_dark = normalize_ui_theme(ui_theme) == "dark" + fig = go.Figure() + + legend_raw = translate_ui(loc, "dash.analysis.figure.legend_raw_signal") + legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed") + legend_base = translate_ui(loc, "dash.analysis.figure.legend_baseline") + legend_corrected = translate_ui(loc, "dash.analysis.figure.legend_corrected") + + primary_key = "corrected" if corrected else "smoothed" if smoothed else "raw" + + if raw_signal: + fig.add_trace( + go.Scatter( + x=temperature, + y=raw_signal, + mode="lines", + name=legend_raw, + line=dict(color="#94A3B8", width=2.8 if primary_key == "raw" else 1.0), + opacity=0.95 if primary_key == "raw" else 0.26, + hovertemplate=_trace_hover_template(legend_raw, loc), + ) + ) + if smoothed: + fig.add_trace( + go.Scatter( + x=temperature, + y=smoothed, + mode="lines", + name=legend_smooth, + line=dict(color="#0E7490", width=2.8 if primary_key == "smoothed" else 1.5), + opacity=0.98 if primary_key == "smoothed" else 0.64, + hovertemplate=_trace_hover_template(legend_smooth, loc), + ) + ) + if baseline: + fig.add_trace( + go.Scatter( + x=temperature, + y=baseline, + mode="lines", + name=legend_base, + line=dict(color="#64748B", width=1.0, dash="dot"), + opacity=0.42, + hovertemplate=_trace_hover_template(legend_base, loc), + ) + ) + if corrected: + fig.add_trace( + go.Scatter( + x=temperature, + y=corrected, + mode="lines", + name=legend_corrected, + line=dict(color="#047857", width=3.0 if primary_key == "corrected" else 1.8), + opacity=1.0 if primary_key == "corrected" else 0.72, + hovertemplate=_trace_hover_template(legend_corrected, loc), + ) + ) + + annotated_temps: list[float] = [] + tg_midpoint = _coerce_float(summary.get("tg_midpoint")) + tg_onset = _coerce_float(summary.get("tg_onset")) + tg_endset = _coerce_float(summary.get("tg_endset")) + if tg_midpoint is not None: + fig.add_vline( + x=tg_midpoint, + line=dict(color="#EF4444", width=2, dash="dash"), + annotation_text=translate_ui(loc, "dash.analysis.figure.annot_tg", v=f"{tg_midpoint:.1f}"), + annotation_position="top left", + ) + annotated_temps.append(tg_midpoint) + if tg_onset is not None and all(abs(tg_onset - value) >= _ANNOTATION_MIN_SEP for value in annotated_temps): + fig.add_vline( + x=tg_onset, + line=dict(color="#F59E0B", width=1, dash="dot"), + annotation_text=translate_ui(loc, "dash.analysis.figure.annot_on", v=f"{tg_onset:.1f}"), + annotation_position="top left", + ) + annotated_temps.append(tg_onset) + if tg_endset is not None and all(abs(tg_endset - value) >= _ANNOTATION_MIN_SEP for value in annotated_temps): + fig.add_vline( + x=tg_endset, + line=dict(color="#F59E0B", width=1, dash="dot"), + annotation_text=translate_ui(loc, "dash.analysis.figure.annot_end", v=f"{tg_endset:.1f}"), + annotation_position="top left", + ) + annotated_temps.append(tg_endset) + + for row in _sort_events_by_temperature(peak_rows): + peak_temperature = _coerce_float(row.get("peak_temperature")) + if peak_temperature is None: + continue + idx = min(range(len(temperature)), key=lambda i: abs(temperature[i] - peak_temperature)) + peak_type = str(row.get("peak_type", "unknown")).strip().lower() + color = _PEAK_TYPE_COLORS.get(peak_type, "#B45309") + too_close = any(abs(peak_temperature - value) < _ANNOTATION_MIN_SEP for value in annotated_temps) + label = "" if too_close else f"{peak_temperature:.1f}°C" + fig.add_trace( + go.Scatter( + x=[temperature[idx]], + y=[primary_signal[idx]], + mode="markers+text", + marker=dict(size=8, color=color, symbol="diamond", line=dict(color="white", width=1.0)), + text=[label], + textposition="top center", + textfont=dict(size=8, color=color), + name=f"{_peak_type_label(peak_type, loc)} {_format_temp_c(peak_temperature)}", + showlegend=False, + hovertemplate=_event_hover_html(row, _coerce_float(primary_signal[idx]), loc), + ) + ) + if label: + annotated_temps.append(peak_temperature) + + sample_name = resolve_sample_name(summary, {"dataset_key": dataset_key}, fallback_display_name=dataset_key, locale_data=loc) + y_grid_color = "rgba(61, 59, 56, 0.34)" if is_dark else "rgba(224, 221, 214, 0.52)" + x_grid_color = "rgba(61, 59, 56, 0.26)" if is_dark else "rgba(224, 221, 214, 0.36)" + axis_line_color = "rgba(61, 59, 56, 0.84)" if is_dark else "rgba(183, 177, 168, 0.9)" + tick_color = "rgba(238, 237, 234, 0.9)" if is_dark else "rgba(28, 26, 26, 0.78)" + + fig.update_layout( + title=dict( + text=translate_ui(loc, "dash.analysis.figure.title_dsc", name=sample_name), + x=0.01, + xanchor="left", + y=0.985, + yanchor="top", + font=dict(size=17), + ), + xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_temperature_c"), + yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_heat_flow"), + hovermode="x unified", + margin=dict(l=70, r=34, t=86, b=62), + height=600, + hoverlabel=dict(namelength=-1), + legend=dict( + orientation="h", + yanchor="bottom", + y=1.015, + xanchor="right", + x=1.0, + traceorder="normal", + itemclick="toggleothers", + itemdoubleclick="toggle", + font=dict(size=11), + itemsizing="constant", + ), + ) + apply_figure_theme(fig, ui_theme) + fig.update_xaxes( + gridcolor=x_grid_color, + showgrid=False, + showline=True, + linewidth=1, + linecolor=axis_line_color, + ticks="outside", + ticklen=4, + tickcolor=axis_line_color, + tickfont=dict(size=12, color=tick_color), + title_standoff=12, + ) + fig.update_yaxes( + gridcolor=y_grid_color, + showgrid=True, + showline=True, + linewidth=1, + linecolor=axis_line_color, + ticks="outside", + ticklen=4, + tickcolor=axis_line_color, + tickfont=dict(size=12, color=tick_color), + title_standoff=12, + ) + return fig + + +def _dsc_graph_config() -> dict[str, Any]: + return { + "displaylogo": False, + "responsive": True, + "modeBarButtonsToRemove": ["lasso2d", "select2d", "toggleSpikelines", "hoverCompareCartesian"], + "toImageButtonOptions": { + "format": "png", + "filename": "dsc-analysis", + "scale": 2, + }, + } + + +def _build_figure( + project_id: str, + dataset_key: str, + summary: dict, + peak_rows: list[dict], + ui_theme: str | None, + loc: str, + locale_data: str | None = None, +) -> html.Div: + _ld = locale_data if locale_data is not None else loc + fig = _build_dsc_go_figure(project_id, dataset_key, summary, peak_rows, ui_theme, loc) + if fig is None: + return no_data_figure_msg(text=translate_ui(loc, "dash.analysis.dsc.no_plot_signal"), locale_data=_ld) + graph = dcc.Graph(figure=fig, config=_dsc_graph_config(), className="ta-plot ms-result-graph") + return html.Div(graph, className="ms-result-figure-shell") diff --git a/dash_app/pages/dta.py b/dash_app/pages/dta.py new file mode 100644 index 00000000..4047b4dd --- /dev/null +++ b/dash_app/pages/dta.py @@ -0,0 +1,3187 @@ +"""DTA analysis page -- backend-driven first analysis slice. + +Lets the user: + 1. Select an eligible DTA dataset from the workspace + 2. Select a DTA workflow template + 3. Run analysis through the backend /analysis/run endpoint + 4. View result summary cards, DTA curve figure (raw / smoothed / + baseline / corrected), detected peak / event cards and table, + and processing details + 5. Auto-refresh workspace/report/compare state after a successful run +""" + +from __future__ import annotations + +import base64 +import copy +import json +import math +from datetime import datetime, timezone +from typing import Any + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import plotly.graph_objects as go + +from core.figure_render import render_plotly_figure_png +from dash_app.components.analysis_boilerplate import ( + build_apply_preset_card, + build_collapsible_section, + build_processing_history_card, +) +from dash_app.components.analysis_page import ( + analysis_page_stores, + register_result_figure_from_layout_children, + dataset_selection_card, + dataset_selector_block, + eligible_datasets, + empty_result_msg, + execute_card, + interpret_run_result, + metrics_row, + no_data_figure_msg, + processing_details_section, + resolve_sample_name, + result_placeholder_card, + workflow_template_card, +) +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.figure_artifacts import ( + FIGURE_ARTIFACT_PREVIEW_MAX_EDGE, + FIGURE_ARTIFACT_PREVIEW_TILES, + build_figure_artifact_surface, + build_figure_artifacts_panel, + figure_action_from_trigger, + figure_action_metadata, + figure_action_status_alert, + figure_artifact_button_labels, + ordered_figure_preview_keys, +) +from dash_app.components.literature_compare_ui import ( + LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + build_literature_compare_card, + coerce_literature_max_claims, + literature_compare_status_alert, + literature_t, + render_literature_output, +) +from dash_app.components.processing_inputs import ( + coerce_float_positive as _coerce_float_positive, + coerce_int_positive as _coerce_int_positive, +) +from dash_app.theme import apply_figure_theme, normalize_ui_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/dta", title="DTA Analysis - MaterialScope") + +_DTA_TEMPLATE_IDS = ["dta.general", "dta.thermal_events"] +_DTA_ELIGIBLE_TYPES = {"DTA", "UNKNOWN"} + +_DTA_WORKFLOW_TEMPLATES = [ + {"id": "dta.general", "label": "General DTA"}, + {"id": "dta.thermal_events", "label": "Thermal Event Screening"}, +] +_TEMPLATE_OPTIONS = [{"label": t["label"], "value": t["id"]} for t in _DTA_WORKFLOW_TEMPLATES] +_TEMPLATE_DESCRIPTIONS = { + "dta.general": ( + "General DTA: Savitzky-Golay smoothing, ASLS baseline, bidirectional peak detection (exothermic + endothermic)." + ), + "dta.thermal_events": ( + "Thermal Event Screening: Wider smoothing window, more permissive peak detection for complex thermal histories." + ), +} + +_DIRECTION_COLORS = { + "exo": "#DC2626", + "endo": "#2563EB", + "exotherm": "#DC2626", + "endotherm": "#2563EB", +} +_DIRECTION_ICONS = { + "exo": "bi-arrow-up-circle", + "endo": "bi-arrow-down-circle", + "exotherm": "bi-arrow-up-circle", + "endotherm": "bi-arrow-down-circle", +} +_DIRECTION_GUIDE_COLORS = { + "exo": "rgba(220, 38, 38, 0.22)", + "endo": "rgba(37, 99, 235, 0.22)", + "exotherm": "rgba(220, 38, 38, 0.22)", + "endotherm": "rgba(37, 99, 235, 0.22)", +} + +_ANNOTATION_MIN_SEP = 15.0 +_PRIMARY_EVENT_LIMIT = 4 +_DTA_VIEW_MODES = ("result", "debug") +_EMPTY_SAMPLE_TOKENS = {"", "unknown", "n/a", "na", "none", "null", "unnamed"} +_DTA_RESULT_CARD_ROLES = { + "context": "ms-result-context", + "hero": "ms-result-hero", + "support": "ms-result-support", + "secondary": "ms-result-secondary", +} + +# Smoothing defaults mirror core/batch_runner._DTA_TEMPLATE_DEFAULTS["dta.general"] +_SMOOTH_METHODS = ("savgol", "moving_average", "gaussian") +_DTA_SMOOTHING_DEFAULTS: dict[str, dict] = { + "savgol": {"method": "savgol", "window_length": 11, "polyorder": 3}, + "moving_average": {"method": "moving_average", "window_length": 11}, + "gaussian": {"method": "gaussian", "sigma": 2.0}, +} +# Baseline defaults mirror core/batch_runner._DTA_TEMPLATE_DEFAULTS["dta.general"]["baseline"] +# plus the pybaselines asls default kwargs (lam=1e6, p=0.01) documented in core/baseline.py. +# Dash now exposes the full DTA baseline method set that core.baseline supports, +# with method-specific parameter groups shown only when relevant. +_BASELINE_METHODS = ("asls", "airpls", "modpoly", "imodpoly", "snip", "rubberband", "linear", "spline") +_DTA_BASELINE_DEFAULTS: dict[str, dict] = { + "asls": {"method": "asls", "lam": 1e6, "p": 0.01}, + "airpls": {"method": "airpls", "lam": 1e6}, + "modpoly": {"method": "modpoly", "poly_order": 6}, + "imodpoly": {"method": "imodpoly", "poly_order": 6}, + "snip": {"method": "snip", "max_half_window": 40}, + "linear": {"method": "linear"}, + "rubberband": {"method": "rubberband"}, + "spline": {"method": "spline", "n_anchors": 6}, +} +# Peak-detection defaults mirror core/batch_runner._DTA_TEMPLATE_DEFAULTS["dta.general"]["peak_detection"] +# with two UI-visible knobs added (prominence, distance) that core/dta_processor.find_peaks already +# accepts (prominence=None -> adaptive 5% of p-p range; distance forwarded to find_thermal_peaks kwargs). +# prominence == 0.0 is the sentinel "auto / adaptive". +_DTA_PEAK_DETECTION_DEFAULTS: dict = { + "detect_endothermic": True, + "detect_exothermic": True, + "prominence": 0.0, + "distance": 1, +} +_UNDO_STACK_LIMIT = 32 + + +def _default_processing_draft() -> dict: + """Return a fresh default processing-draft payload for DTA. + + Includes all three user-tunable sections exposed in Phases 1 + 2a so that a + single undo/redo/reset stack covers every control. + """ + return { + "smoothing": copy.deepcopy(_DTA_SMOOTHING_DEFAULTS["savgol"]), + "baseline": copy.deepcopy(_DTA_BASELINE_DEFAULTS["asls"]), + "peak_detection": copy.deepcopy(_DTA_PEAK_DETECTION_DEFAULTS), + } + + +def _normalize_smoothing_values(method: str | None, window_length, polyorder, sigma) -> dict: + """Build a canonical signal_pipeline.smoothing values dict from raw control inputs.""" + token = str(method or "savgol").strip().lower() + if token not in _SMOOTH_METHODS: + token = "savgol" + if token == "savgol": + wl = _coerce_int_positive(window_length, default=11, minimum=5) + if wl % 2 == 0: + wl += 1 + po = _coerce_int_positive(polyorder, default=3, minimum=1) + po = min(po, max(wl - 2, 1)) + return {"method": "savgol", "window_length": wl, "polyorder": po} + if token == "moving_average": + wl = _coerce_int_positive(window_length, default=11, minimum=3) + if wl % 2 == 0: + wl += 1 + return {"method": "moving_average", "window_length": wl} + sg = _coerce_float_positive(sigma, default=2.0, minimum=0.1) + return {"method": "gaussian", "sigma": sg} + + +def _apply_draft_section(draft: dict | None, section: str, values: dict) -> dict: + """Return a new draft with *section* replaced by *values* (deep copied).""" + next_draft = copy.deepcopy(draft or {}) + next_draft[section] = copy.deepcopy(values) + return next_draft + + +def _push_undo(undo: list | None, snapshot: dict | None) -> list: + stack = list(undo or []) + stack.append(copy.deepcopy(snapshot or {})) + if len(stack) > _UNDO_STACK_LIMIT: + stack = stack[-_UNDO_STACK_LIMIT:] + return stack + + +def _do_undo(draft: dict, undo: list | None, redo: list | None) -> tuple[dict, list, list]: + """Pop from undo into draft, pushing current draft onto redo. No-op when empty.""" + undo_stack = list(undo or []) + redo_stack = list(redo or []) + if not undo_stack: + return copy.deepcopy(draft or {}), undo_stack, redo_stack + previous = undo_stack.pop() + redo_stack.append(copy.deepcopy(draft or {})) + return copy.deepcopy(previous), undo_stack, redo_stack + + +def _do_redo(draft: dict, undo: list | None, redo: list | None) -> tuple[dict, list, list]: + """Pop from redo into draft, pushing current draft onto undo. No-op when empty.""" + undo_stack = list(undo or []) + redo_stack = list(redo or []) + if not redo_stack: + return copy.deepcopy(draft or {}), undo_stack, redo_stack + following = redo_stack.pop() + undo_stack.append(copy.deepcopy(draft or {})) + return copy.deepcopy(following), undo_stack, redo_stack + + +def _do_reset( + draft: dict, + undo: list | None, + redo: list | None, + defaults: dict | None, +) -> tuple[dict, list, list]: + """Restore defaults, pushing current draft to undo, clearing redo.""" + reset_target = copy.deepcopy(defaults or _default_processing_draft()) + if (draft or {}) == reset_target: + return reset_target, list(undo or []), list(redo or []) + undo_stack = _push_undo(undo, draft) + return reset_target, undo_stack, [] + + +def _smoothing_overrides_from_draft(draft: dict | None) -> dict: + """Extract the smoothing section override if the draft carries one.""" + section = (draft or {}).get("smoothing") + if not isinstance(section, dict): + return {} + return {"smoothing": copy.deepcopy(section)} + + +def _normalize_baseline_values(method: str | None, lam, p, poly_order=None, max_half_window=None, n_anchors=None) -> dict: + """Build a canonical signal_pipeline.baseline values dict from raw control inputs. + + Unknown methods fall back to ``asls`` so a stale draft never produces an invalid payload. + """ + token = str(method or "asls").strip().lower() + if token not in _BASELINE_METHODS: + token = "asls" + if token == "asls": + lam_value = _coerce_float_positive(lam, default=1e6, minimum=1e-3) + p_value = _coerce_float_in_range(p, default=0.01, minimum=1e-4, maximum=0.5) + return {"method": "asls", "lam": lam_value, "p": p_value} + if token == "airpls": + lam_value = _coerce_float_positive(lam, default=1e6, minimum=1e-3) + return {"method": "airpls", "lam": lam_value} + if token in ("modpoly", "imodpoly"): + po = _coerce_int_positive(poly_order, default=6, minimum=1) + return {"method": token, "poly_order": po} + if token == "snip": + mhw = _coerce_int_positive(max_half_window, default=40, minimum=1) + return {"method": "snip", "max_half_window": mhw} + if token == "spline": + na = _coerce_int_positive(n_anchors, default=6, minimum=2) + return {"method": "spline", "n_anchors": na} + return {"method": token} + + +def _normalize_peak_detection_values(detect_endo, detect_exo, prominence, distance) -> dict: + """Build a canonical analysis_steps.peak_detection values dict from raw inputs.""" + endo = _coerce_bool(detect_endo, default=True) + exo = _coerce_bool(detect_exo, default=True) + prom = _coerce_float_non_negative(prominence, default=0.0, minimum=0.0) + dist = _coerce_int_positive(distance, default=1, minimum=1) + return { + "detect_endothermic": endo, + "detect_exothermic": exo, + "prominence": prom, + "distance": dist, + } + + +def _coerce_float_in_range(value, *, default: float, minimum: float, maximum: float) -> float: + parsed = _coerce_float_positive(value, default=default, minimum=minimum) + if parsed > maximum: + return maximum + return parsed + + +def _coerce_float_non_negative(value, *, default: float, minimum: float) -> float: + try: + if value in (None, ""): + return max(default, minimum) + parsed = float(value) + except (TypeError, ValueError): + return max(default, minimum) + if not math.isfinite(parsed) or parsed < minimum: + return max(default, minimum) + return parsed + + +def _coerce_bool(value, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + token = str(value).strip().lower() + if token in {"true", "1", "yes", "on"}: + return True + if token in {"false", "0", "no", "off", ""}: + return False + return default + + +def _baseline_overrides_from_draft(draft: dict | None) -> dict: + """Extract the baseline section override if the draft carries one.""" + section = (draft or {}).get("baseline") + if not isinstance(section, dict): + return {} + return {"baseline": copy.deepcopy(section)} + + +def _peak_detection_overrides_from_draft(draft: dict | None) -> dict: + """Extract the peak_detection section override if the draft carries one.""" + section = (draft or {}).get("peak_detection") + if not isinstance(section, dict): + return {} + return {"peak_detection": copy.deepcopy(section)} + + +def _overrides_from_draft(draft: dict | None) -> dict: + """Union of all Dash-side DTA override sections present in *draft*. + + Returns a dict suitable for the ``processing_overrides`` payload on + ``/analysis/run``; empty dict when no section is present so callers can + forward ``None`` and skip the field entirely. + """ + combined: dict = {} + combined.update(_smoothing_overrides_from_draft(draft)) + combined.update(_baseline_overrides_from_draft(draft)) + combined.update(_peak_detection_overrides_from_draft(draft)) + return combined + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _clean_sample_token(value) -> str | None: + token = str(value or "").strip() + if not token or token.lower() in _EMPTY_SAMPLE_TOKENS: + return None + return token + + +def _dataset_key_stem_token(dataset_key) -> str | None: + """Strip common data extensions from *dataset_key* for filename-like comparisons. + + Extension list kept aligned with ``resolve_sample_name`` in + ``dash_app.components.analysis_page``. + """ + key = str(dataset_key or "").strip() + if not key: + return None + lowered = key.lower() + for ext in (".csv", ".txt", ".dat", ".xls", ".xlsx"): + if lowered.endswith(ext): + key = key[: -len(ext)] + break + return _clean_sample_token(key) + + +def _normalize_direction(value) -> str: + token = str(value or "").strip().lower() + if token.startswith("exo"): + return "exotherm" + if token.startswith("endo"): + return "endotherm" + return token + + +def _direction_label(direction: str, loc: str) -> str: + normalized = _normalize_direction(direction) + if normalized == "exotherm": + return translate_ui(loc, "dash.analysis.dta.direction.exo") + if normalized == "endotherm": + return translate_ui(loc, "dash.analysis.dta.direction.endo") + if not normalized or normalized == "unknown": + return translate_ui(loc, "dash.analysis.dta.direction.unknown") + return str(direction).strip().title() or translate_ui(loc, "dash.analysis.dta.direction.unknown") + + +def _coerce_float(value) -> float | None: + try: + if value in (None, ""): + return None + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def _event_rows(rows: list[dict]) -> list[dict]: + return [dict(row) for row in (rows or []) if isinstance(row, dict)] + + +def _derive_event_metrics(summary: dict, rows: list[dict]) -> tuple[int, int, int]: + derived_exo = 0 + derived_endo = 0 + for row in rows: + direction = _normalize_direction(row.get("direction", row.get("peak_type"))) + if direction == "exotherm": + derived_exo += 1 + elif direction == "endotherm": + derived_endo += 1 + + peak_count = len(rows) or int(summary.get("peak_count") or 0) + exo_count = derived_exo + endo_count = derived_endo + if not rows: + exo_count = int(summary.get("exotherm_count", summary.get("exo_count")) or 0) + endo_count = int(summary.get("endotherm_count", summary.get("endo_count")) or 0) + if peak_count <= 0 and (exo_count or endo_count): + peak_count = exo_count + endo_count + return peak_count, exo_count, endo_count + + +def _resolve_dta_sample_name( + summary: dict, + result_meta: dict, + dataset_detail: dict | None = None, + *, + locale_data: str | None = None, +) -> str: + dataset_detail = dataset_detail or {} + dataset_summary = dataset_detail.get("dataset", {}) or {} + metadata = dataset_detail.get("metadata", {}) or {} + + fallback_display = ( + _clean_sample_token(dataset_summary.get("display_name")) + or _clean_sample_token(metadata.get("display_name")) + or _clean_sample_token(summary.get("display_name")) + or _clean_sample_token(dataset_summary.get("sample_name")) + or _clean_sample_token(metadata.get("sample_name")) + or _clean_sample_token(metadata.get("file_name")) + ) + normalized_summary = dict(summary or {}) + cleaned_summary_name = _clean_sample_token(normalized_summary.get("sample_name")) + normalized_summary["sample_name"] = cleaned_summary_name + + # Narrow override: filename-like persisted sample_name should not hide a + # richer workspace display_name when it differs. + if fallback_display and cleaned_summary_name: + fb = str(fallback_display).strip() + sn_cf = str(cleaned_summary_name).casefold() + if fb.casefold() != sn_cf: + meta_file = _clean_sample_token(metadata.get("file_name")) + key_stem = _dataset_key_stem_token((result_meta or {}).get("dataset_key")) + matches_meta_file = bool(meta_file and str(meta_file).casefold() == sn_cf) + matches_key_stem = bool(key_stem and str(key_stem).casefold() == sn_cf) + if matches_meta_file or matches_key_stem: + normalized_summary["sample_name"] = None + + return resolve_sample_name(normalized_summary, result_meta or {}, fallback_display_name=fallback_display, locale_data=locale_data) + + +def _event_priority(row: dict) -> tuple[float, float, float]: + area = abs(_coerce_float(row.get("area")) or 0.0) + height = abs(_coerce_float(row.get("height")) or 0.0) + peak_temp = _coerce_float(row.get("peak_temperature")) + return area, height, -(peak_temp if peak_temp is not None else float("inf")) + + +def _sort_events_by_temperature(rows: list[dict]) -> list[dict]: + return sorted( + rows, + key=lambda row: (_coerce_float(row.get("peak_temperature")) is None, _coerce_float(row.get("peak_temperature")) or 0.0), + ) + + +def _split_primary_events(rows: list[dict], limit: int = _PRIMARY_EVENT_LIMIT) -> tuple[list[dict], list[dict]]: + if len(rows) <= limit: + return _sort_events_by_temperature(rows), [] + + indexed_rows = list(enumerate(rows)) + selected = sorted(indexed_rows, key=lambda item: _event_priority(item[1]), reverse=True)[:limit] + selected_indices = {index for index, _row in selected} + primary = _sort_events_by_temperature([row for index, row in indexed_rows if index in selected_indices]) + secondary = _sort_events_by_temperature([row for index, row in indexed_rows if index not in selected_indices]) + return primary, secondary + + +def _series_values(series: list) -> list[float]: + values: list[float] = [] + for item in series or []: + parsed = _coerce_float(item) + if parsed is not None: + values.append(parsed) + return values + + +def _series_for_temperature(series: list, temperature: list[float]) -> list[float]: + return series if series and len(series) == len(temperature) else [] + + +def _compute_y_axis_range(*series_collection: list[float]) -> list[float] | None: + values: list[float] = [] + for series in series_collection: + values.extend(_series_values(series)) + if not values: + return None + + lower = min(values) + upper = max(values) + span = upper - lower + if span <= 0: + pad = max(abs(upper) * 0.12, 1.0) + else: + pad = span * 0.12 + return [lower - pad, upper + pad] + + +def _temperature_label(loc: str) -> str: + return "Sıcaklık (°C)" if normalize_ui_locale(loc) == "tr" else "Temperature (°C)" + + +def _format_temp_c(value: float | None) -> str: + parsed = _coerce_float(value) + if parsed is None: + return "--" + return f"{parsed:.1f}°C" + + +def _signal_label(loc: str) -> str: + return "ΔT (a.b.)" if normalize_ui_locale(loc) == "tr" else "ΔT (a.u.)" + + +def _format_signal(value: float | None) -> str: + parsed = _coerce_float(value) + if parsed is None: + return "--" + return f"{parsed:.4f}" + + +def _trace_hover_template(trace_label: str, loc: str) -> str: + temp_label = _temperature_label(loc) + signal_label = _signal_label(loc) + return ( + f"{trace_label}" + f"
{temp_label}: %{{x:.1f}}°C" + f"
{signal_label}: %{{y:.4f}}" + "" + ) + + +def _event_hover_html(row: dict, direction_label: str, signal_value: float | None, loc: str) -> str: + onset_label = "Başlangıç" if normalize_ui_locale(loc) == "tr" else "Onset" + endset_label = "Bitiş" if normalize_ui_locale(loc) == "tr" else "Endset" + area_label = translate_ui(loc, "dash.analysis.label.area") + height_label = translate_ui(loc, "dash.analysis.label.height") + fwhm_label = translate_ui(loc, "dash.analysis.label.fwhm") + signal_label = _signal_label(loc) + return ( + f"{direction_label} event" + f"
{_temperature_label(loc)}: {_format_temp_c(row.get('peak_temperature'))}" + f"
{onset_label}: {_format_temp_c(row.get('onset_temperature'))}" + f"
{endset_label}: {_format_temp_c(row.get('endset_temperature'))}" + f"
{area_label}: {_format_signal(row.get('area'))}" + f"
{height_label}: {_format_signal(row.get('height'))}" + f"
{fwhm_label}: {_format_signal(row.get('fwhm'))}°C" + f"
{signal_label}: {_format_signal(signal_value)}" + "" + ) + + +def _event_text_label( + row: dict, + *, + direction_label: str, + pt: float, + is_primary: bool, + min_sep: float, + annotated_temps: list[float], + mode: str, +) -> str: + if mode == "debug": + return "" + if not is_primary: + return "" + if any(abs(pt - t) < min_sep for t in annotated_temps): + return "" + normalized_direction = _normalize_direction(row.get("direction", row.get("peak_type", "unknown"))) + short_direction = "Endo" if normalized_direction == "endotherm" else "Exo" + return f"{short_direction} {_format_temp_c(pt)}" + + +def _build_event_guides(fig: go.Figure, rows: list[dict], *, loc: str, mode: str) -> None: + if not rows: + return + sorted_rows = _sort_events_by_temperature(rows) + if mode == "result": + return + + for row in sorted_rows: + direction = _normalize_direction(row.get("direction", row.get("peak_type"))) + guide_color = _DIRECTION_GUIDE_COLORS.get(direction, "rgba(100, 116, 139, 0.18)") + onset = _coerce_float(row.get("onset_temperature")) + endset = _coerce_float(row.get("endset_temperature")) + if onset is not None: + fig.add_vline( + x=onset, + line=dict(color=guide_color, width=1, dash="dot"), + layer="below", + ) + if endset is not None: + fig.add_vline( + x=endset, + line=dict(color=guide_color, width=1, dash="dot"), + layer="below", + ) + + +def _peak_card(row: dict, idx: int, loc: str = "en") -> dbc.Card: + direction = _normalize_direction(row.get("direction", row.get("peak_type", "unknown"))) + color = _DIRECTION_COLORS.get(direction, "#6B7280") + icon = _DIRECTION_ICONS.get(direction, "bi-circle") + direction_label = _direction_label(direction, loc) + + pt = row.get("peak_temperature") + onset = row.get("onset_temperature") + endset = row.get("endset_temperature") + area = row.get("area") + fwhm = row.get("fwhm") + height = row.get("height") + + return dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className=f"bi {icon} me-2", style={"color": color, "fontSize": "1.1rem"}), + html.Strong(translate_ui(loc, "dash.analysis.label.peak_n", n=idx + 1), className="me-2"), + html.Span( + direction_label, + className="badge", + style={"backgroundColor": color, "color": "white", "fontSize": "0.75rem"}, + ), + html.Span(f" {pt:.1f} C" if pt is not None else " --", className="ms-2"), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.onset"), className="text-muted d-block"), + html.Span(f"{onset:.1f}" if onset is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.endset"), className="text-muted d-block"), + html.Span(f"{endset:.1f}" if endset is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.area"), className="text-muted d-block"), + html.Span(f"{area:.3f}" if area is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.fwhm"), className="text-muted d-block"), + html.Span(f"{fwhm:.1f}" if fwhm is not None else "--"), + html.Small(f" {translate_ui(loc, 'dash.analysis.label.height')}", className="text-muted ms-2"), + html.Span(f"{height:.3f}" if height is not None else "--"), + ], + md=3, + ), + ], + className="g-2", + ), + ] + ), + className="mb-2 h-100", + ) + + +def _smoothing_controls_card() -> dbc.Card: + """User-tunable smoothing controls with Apply / Undo / Redo / Reset. + + Phase 1 scope: smoothing only. Draft params are held in dcc.Store and + flushed to the backend only when the Run button is clicked. + """ + method_options = [ + {"label": "Savitzky-Golay", "value": "savgol"}, + {"label": "Moving Average", "value": "moving_average"}, + {"label": "Gaussian", "value": "gaussian"}, + ] + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dta-smoothing-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-smooth-method-label", html_for="dta-smooth-method"), + dbc.Select( + id="dta-smooth-method", + options=method_options, + value="savgol", + ), + html.Small( + id="dta-smooth-method-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-smooth-window-label", html_for="dta-smooth-window"), + dbc.Input( + id="dta-smooth-window", + type="number", + min=3, + max=51, + step=2, + value=11, + ), + html.Small( + id="dta-smooth-window-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="dta-smooth-polyorder-label", html_for="dta-smooth-polyorder"), + dbc.Input( + id="dta-smooth-polyorder", + type="number", + min=1, + max=7, + step=1, + value=3, + ), + html.Small( + id="dta-smooth-polyorder-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="dta-smooth-sigma-label", html_for="dta-smooth-sigma"), + dbc.Input( + id="dta-smooth-sigma", + type="number", + min=0.1, + max=10.0, + step=0.1, + value=2.0, + disabled=True, + ), + html.Small( + id="dta-smooth-sigma-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=4, + ), + ], + className="g-2 mb-2", + ), + dbc.ButtonGroup( + [ + dbc.Button(id="dta-smooth-apply-btn", color="primary", size="sm"), + ], + className="mb-2", + ), + html.Div(id="dta-smooth-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _baseline_controls_card() -> dbc.Card: + """User-tunable baseline controls (Phase 2a). + + Method ∈ {asls, airpls, modpoly, imodpoly, snip, linear, rubberband, spline}; + method-specific parameters are gated by visibility. Apply pushes the current + draft onto the shared undo stack, mutates ``draft["baseline"]``, and clears + redo. Undo/Redo/Reset live in the Processing history card; they operate on + the full draft atomically. + """ + method_options = [ + {"label": "AsLS", "value": "asls"}, + {"label": "airPLS", "value": "airpls"}, + {"label": "Modified Polynomial", "value": "modpoly"}, + {"label": "Improved Modified Polynomial", "value": "imodpoly"}, + {"label": "SNIP", "value": "snip"}, + {"label": "Linear", "value": "linear"}, + {"label": "Rubberband", "value": "rubberband"}, + {"label": "Spline", "value": "spline"}, + ] + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dta-baseline-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-baseline-method-label", html_for="dta-baseline-method"), + dbc.Select( + id="dta-baseline-method", + options=method_options, + value="asls", + ), + html.Small( + id="dta-baseline-method-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=12, + ), + ], + className="mb-2", + ), + html.Div( + id="dta-baseline-lam-p-group", + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-baseline-lam-label", html_for="dta-baseline-lam"), + dbc.Input( + id="dta-baseline-lam", + type="number", + min=1e-3, + step=1e5, + value=1e6, + ), + html.Small( + id="dta-baseline-lam-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="dta-baseline-p-label", html_for="dta-baseline-p"), + dbc.Input( + id="dta-baseline-p", + type="number", + min=1e-4, + max=0.5, + step=0.005, + value=0.01, + ), + html.Small( + id="dta-baseline-p-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.Div( + id="dta-baseline-poly-order-group", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-baseline-poly-order-label", html_for="dta-baseline-poly-order"), + dbc.Input( + id="dta-baseline-poly-order", + type="number", + min=1, + step=1, + value=6, + ), + html.Small( + id="dta-baseline-poly-order-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.Div( + id="dta-baseline-max-half-window-group", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-baseline-max-half-window-label", html_for="dta-baseline-max-half-window"), + dbc.Input( + id="dta-baseline-max-half-window", + type="number", + min=1, + step=1, + value=40, + ), + html.Small( + id="dta-baseline-max-half-window-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + ], + ), + html.Div( + id="dta-baseline-n-anchors-group", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-baseline-n-anchors-label", html_for="dta-baseline-n-anchors"), + dbc.Input( + id="dta-baseline-n-anchors", + type="number", + min=2, + step=1, + value=6, + ), + html.Small( + id="dta-baseline-n-anchors-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + ], + ), + dbc.Button( + id="dta-baseline-apply-btn", + color="primary", + size="sm", + className="mb-2", + ), + html.Div(id="dta-baseline-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _peak_controls_card() -> dbc.Card: + """User-tunable peak-detection controls (Phase 2a). + + Endo/exo checkboxes mirror ``core.dta_processor.find_peaks`` kwargs. + ``prominence == 0`` is the adaptive-threshold sentinel (find_peaks derives + 5 % of the signal peak-to-peak range when prominence is falsy). ``distance`` + is forwarded to ``find_thermal_peaks`` via ``**kwargs``. + """ + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="dta-peak-card-title", className="card-title mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Checkbox( + id="dta-peak-detect-exo", + label=" ", + value=True, + ), + html.Small( + id="dta-peak-detect-exo-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=6, + ), + dbc.Col( + [ + dbc.Checkbox( + id="dta-peak-detect-endo", + label=" ", + value=True, + ), + html.Small( + id="dta-peak-detect-endo-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="dta-peak-prominence-label", html_for="dta-peak-prominence"), + dbc.Input( + id="dta-peak-prominence", + type="number", + min=0.0, + step=0.005, + value=0.0, + ), + html.Small( + id="dta-peak-prominence-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="dta-peak-distance-label", html_for="dta-peak-distance"), + dbc.Input( + id="dta-peak-distance", + type="number", + min=1, + step=1, + value=1, + ), + html.Small( + id="dta-peak-distance-hint", + className="form-text text-muted d-block mt-1", + ), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + dbc.Button( + id="dta-peak-apply-btn", + color="primary", + size="sm", + className="mb-2", + ), + html.Div(id="dta-peak-status", className="small text-muted"), + ] + ), + className="mb-3", + ) + + +def _literature_compare_card() -> dbc.Card: + """Manual literature compare panel (same workflow as DSC / TGA).""" + return build_literature_compare_card(id_prefix="dta") + + +def _processing_draft_stores() -> list: + """dcc.Store components that hold the DTA smoothing draft and undo/redo stacks.""" + defaults = _default_processing_draft() + return [ + dcc.Store(id="dta-processing-default", data=defaults), + dcc.Store(id="dta-processing-draft", data=copy.deepcopy(defaults)), + dcc.Store(id="dta-processing-undo", data=[]), + dcc.Store(id="dta-processing-redo", data=[]), + dcc.Store(id="dta-figure-captured", data={}), + dcc.Store(id="dta-figure-artifact-refresh", data=0), + dcc.Store(id="dta-preset-refresh", data=0), + ] + + +_DTA_PRESET_ANALYSIS_TYPE = "DTA" + + +def _dta_processing_history_card() -> dbc.Card: + """Undo / redo / reset for the shared DTA processing draft (same stack as Apply actions).""" + return build_processing_history_card( + title_id="dta-processing-history-title", + hint_id="dta-processing-history-hint", + undo_button_id="dta-undo-btn", + redo_button_id="dta-redo-btn", + reset_button_id="dta-reset-btn", + status_id="dta-history-status", + ) + + +def _preset_controls_card() -> dbc.Card: + """Processing-preset panel for the DTA page (Phase 3b). + + Exposes preset save / apply / delete against the backend ``/presets/DTA`` + endpoints. Apply restores the saved ``workflow_template_id`` + ``processing`` + sections onto the active draft while pushing the previous draft onto the + shared undo stack, so an accidental apply can be reverted with Undo in the + Processing history card. + """ + return build_apply_preset_card(id_prefix="dta") + + +def _dta_left_column_tabs() -> dbc.Tabs: + """Stepwise Setup / Processing / Run tabs for the DTA left column (Phase 3a). + + Card output IDs remain mounted inside each tab so callbacks stay wired. + """ + return dbc.Tabs( + [ + dbc.Tab( + [ + dataset_selection_card( + "dta-dataset-selector-area", + card_title_id="dta-dataset-card-title", + ), + workflow_template_card( + "dta-template-select", + "dta-template-description", + [], + "dta.general", + card_title_id="dta-workflow-card-title", + ), + ], + tab_id="dta-tab-setup", + label_class_name="ta-tab-label", + id="dta-tab-setup-shell", + ), + dbc.Tab( + [ + _dta_processing_history_card(), + _preset_controls_card(), + _smoothing_controls_card(), + _baseline_controls_card(), + _peak_controls_card(), + ], + tab_id="dta-tab-processing", + label_class_name="ta-tab-label", + id="dta-tab-processing-shell", + ), + dbc.Tab( + [ + execute_card( + "dta-run-status", + "dta-run-btn", + card_title_id="dta-execute-card-title", + ), + ], + tab_id="dta-tab-run", + label_class_name="ta-tab-label", + id="dta-tab-run-shell", + ), + ], + id="dta-left-tabs", + active_tab="dta-tab-setup", + className="mb-3", + ) + + +def _dta_result_section(child: Any, *, role: str = "support") -> html.Div: + role_class = _DTA_RESULT_CARD_ROLES.get(role, _DTA_RESULT_CARD_ROLES["support"]) + return html.Div(child, className=f"ms-result-section {role_class}") + + +layout = html.Div( + analysis_page_stores("dta-refresh", "dta-latest-result-id") + + _processing_draft_stores() + + [ + html.Div(id="dta-hero-slot"), + dbc.Row( + [ + dbc.Col( + [_dta_left_column_tabs()], + md=4, + ), + dbc.Col( + [ + _dta_result_section(result_placeholder_card("dta-result-dataset-summary"), role="context"), + _dta_result_section(result_placeholder_card("dta-result-metrics"), role="context"), + _dta_result_section(result_placeholder_card("dta-result-quality"), role="support"), + _dta_result_section(result_placeholder_card("dta-result-raw-metadata"), role="support"), + _dta_result_section(build_figure_artifact_surface("dta"), role="hero"), + _dta_result_section(result_placeholder_card("dta-result-peak-cards"), role="support"), + _dta_result_section(result_placeholder_card("dta-result-table"), role="support"), + _dta_result_section(result_placeholder_card("dta-result-processing"), role="support"), + _dta_result_section(_literature_compare_card(), role="secondary"), + ], + md=8, + className="ms-results-surface", + ), + ] + ), + ], + className="dta-page", +) + + +@callback( + Output("dta-hero-slot", "children"), + Output("dta-dataset-card-title", "children"), + Output("dta-workflow-card-title", "children"), + Output("dta-execute-card-title", "children"), + Output("dta-run-btn", "children"), + Output("dta-template-select", "options"), + Output("dta-template-select", "value"), + Output("dta-template-description", "children"), + Input("ui-locale", "data"), + Input("dta-template-select", "value"), +) +def render_dta_locale_chrome(locale_data, template_id): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.analysis.dta.title"), + translate_ui(loc, "dash.analysis.dta.caption"), + badge=translate_ui(loc, "dash.analysis.badge"), + ) + opts = [{"label": translate_ui(loc, f"dash.analysis.dta.template.{tid}.label"), "value": tid} for tid in _DTA_TEMPLATE_IDS] + valid = {o["value"] for o in opts} + tid = template_id if template_id in valid else "dta.general" + desc_key = f"dash.analysis.dta.template.{tid}.desc" + desc = translate_ui(loc, desc_key) + if desc == desc_key: + desc = translate_ui(loc, "dash.analysis.dta.workflow_fallback") + return ( + hero, + translate_ui(loc, "dash.analysis.dataset_selection_title"), + translate_ui(loc, "dash.analysis.workflow_template_title"), + translate_ui(loc, "dash.analysis.execute_title"), + translate_ui(loc, "dash.analysis.dta.run_btn"), + opts, + tid, + desc, + ) + + +@callback( + Output("dta-tab-setup-shell", "label"), + Output("dta-tab-processing-shell", "label"), + Output("dta-tab-run-shell", "label"), + Input("ui-locale", "data"), +) +def render_dta_tab_chrome(locale_data): + """Localize the three DTA stepwise tab labels (Phase 3a). + + Reuses the ``dash.analysis.dta.tab.*`` bundle entries so TR / EN flip in sync + with every other DTA chrome callback. + """ + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dta.tab.setup"), + translate_ui(loc, "dash.analysis.dta.tab.processing"), + translate_ui(loc, "dash.analysis.dta.tab.run"), + ) + + +@callback( + Output("dta-preset-card-title", "children"), + Output("dta-preset-select-label", "children"), + Output("dta-preset-select", "placeholder"), + Output("dta-preset-apply-btn", "children"), + Output("dta-preset-delete-btn", "children"), + Output("dta-preset-save-name-label", "children"), + Output("dta-preset-save-name", "placeholder"), + Output("dta-preset-save-btn", "children"), + Output("dta-preset-help", "children"), + Input("ui-locale", "data"), +) +def render_dta_preset_chrome(locale_data): + """Localize the DTA preset panel chrome (Phase 3b).""" + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.dta.presets.title"), + translate_ui(loc, "dash.analysis.dta.presets.select_label"), + translate_ui(loc, "dash.analysis.dta.presets.select_placeholder"), + translate_ui(loc, "dash.analysis.dta.presets.apply_btn"), + translate_ui(loc, "dash.analysis.dta.presets.delete_btn"), + translate_ui(loc, "dash.analysis.dta.presets.save_name_label"), + translate_ui(loc, "dash.analysis.dta.presets.save_name_placeholder"), + translate_ui(loc, "dash.analysis.dta.presets.save_btn"), + translate_ui(loc, "dash.analysis.dta.presets.help.overview"), + ) + + +@callback( + Output("dta-preset-select", "options"), + Output("dta-preset-caption", "children"), + Input("dta-preset-refresh", "data"), + Input("ui-locale", "data"), +) +def refresh_dta_preset_options(refresh_token, locale_data): + """Populate the DTA preset dropdown + count caption on load and after save/delete.""" + from dash_app import api_client + + loc = _loc(locale_data) + try: + payload = api_client.list_analysis_presets(_DTA_PRESET_ANALYSIS_TYPE) + except Exception as exc: # noqa: BLE001 + message = translate_ui(loc, "dash.analysis.dta.presets.list_failed").format(error=str(exc)) + return [], message + + presets = payload.get("presets") or [] + options = [ + {"label": item.get("preset_name", ""), "value": item.get("preset_name", "")} + for item in presets + if isinstance(item, dict) and item.get("preset_name") + ] + caption = translate_ui(loc, "dash.analysis.dta.presets.caption").format( + analysis_type=payload.get("analysis_type", _DTA_PRESET_ANALYSIS_TYPE), + count=int(payload.get("count", len(options)) or 0), + max_count=int(payload.get("max_count", 10) or 10), + ) + return options, caption + + +@callback( + Output("dta-preset-apply-btn", "disabled"), + Output("dta-preset-delete-btn", "disabled"), + Input("dta-preset-select", "value"), +) +def toggle_dta_preset_action_buttons(selected_name): + """Disable Apply/Delete until the user picks a preset from the dropdown.""" + has_selection = bool(str(selected_name or "").strip()) + return (not has_selection, not has_selection) + + +@callback( + Output("dta-processing-draft", "data", allow_duplicate=True), + Output("dta-processing-undo", "data", allow_duplicate=True), + Output("dta-processing-redo", "data", allow_duplicate=True), + Output("dta-template-select", "value", allow_duplicate=True), + Output("dta-preset-status", "children", allow_duplicate=True), + Output("dta-left-tabs", "active_tab", allow_duplicate=True), + Input("dta-preset-apply-btn", "n_clicks"), + State("dta-preset-select", "value"), + State("dta-processing-draft", "data"), + State("dta-processing-undo", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def apply_dta_preset(n_clicks, selected_name, draft, undo, locale_data): + """Load the selected preset, push current draft onto undo, and apply its sections.""" + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dta.presets.select_required"), + dash.no_update, + ) + try: + payload = api_client.load_analysis_preset(_DTA_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: # noqa: BLE001 + return ( + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dta.presets.apply_failed").format(error=str(exc)), + dash.no_update, + ) + + processing = dict(payload.get("processing") or {}) + next_draft = copy.deepcopy(draft or _default_processing_draft()) + for section in ("smoothing", "baseline", "peak_detection"): + values = processing.get(section) + if isinstance(values, dict): + next_draft[section] = copy.deepcopy(values) + + template_id_raw = str(payload.get("workflow_template_id") or "").strip() + template_output = template_id_raw if template_id_raw in _DTA_TEMPLATE_IDS else dash.no_update + + next_undo = _push_undo(undo, draft) + status = translate_ui(loc, "dash.analysis.dta.presets.applied").format(preset=name) + return next_draft, next_undo, [], template_output, status, "dta-tab-run" + + +@callback( + Output("dta-preset-refresh", "data", allow_duplicate=True), + Output("dta-preset-save-name", "value", allow_duplicate=True), + Output("dta-preset-status", "children", allow_duplicate=True), + Output("dta-left-tabs", "active_tab", allow_duplicate=True), + Input("dta-preset-save-btn", "n_clicks"), + State("dta-preset-save-name", "value"), + State("dta-processing-draft", "data"), + State("dta-template-select", "value"), + State("dta-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_dta_preset(n_clicks, save_name, draft, template_id, refresh_token, locale_data): + """Persist the current draft + template as a new preset (or update an existing one).""" + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(save_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dta.presets.save_name_required"), + dash.no_update, + ) + try: + response = api_client.save_analysis_preset( + _DTA_PRESET_ANALYSIS_TYPE, + name, + workflow_template_id=str(template_id or "").strip() or None, + processing=_overrides_from_draft(draft or {}), + ) + except Exception as exc: # noqa: BLE001 + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dta.presets.save_failed").format(error=str(exc)), + dash.no_update, + ) + resolved_template = str(response.get("workflow_template_id") or template_id or "") + status = translate_ui(loc, "dash.analysis.dta.presets.saved").format( + preset=name, template=resolved_template + ) + return int(refresh_token or 0) + 1, "", status, "dta-tab-run" + + +@callback( + Output("dta-preset-refresh", "data", allow_duplicate=True), + Output("dta-preset-select", "value", allow_duplicate=True), + Output("dta-preset-status", "children", allow_duplicate=True), + Input("dta-preset-delete-btn", "n_clicks"), + State("dta-preset-select", "value"), + State("dta-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def delete_dta_preset(n_clicks, selected_name, refresh_token, locale_data): + """Remove a saved preset and refresh the dropdown.""" + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dta.presets.select_required"), + ) + try: + api_client.delete_analysis_preset(_DTA_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: # noqa: BLE001 + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.dta.presets.delete_failed").format(error=str(exc)), + ) + status = translate_ui(loc, "dash.analysis.dta.presets.deleted").format(preset=name) + return int(refresh_token or 0) + 1, None, status + + +@callback( + Output("dta-dataset-selector-area", "children"), + Output("dta-run-btn", "disabled"), + Input("project-id", "data"), + Input("dta-refresh", "data"), + Input("ui-locale", "data"), +) +def load_eligible_datasets(project_id, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + return html.P(translate_ui(loc, "dash.analysis.workspace_inactive"), className="text-muted"), True + + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_datasets", error=str(exc)), color="danger"), True + + all_datasets = payload.get("datasets", []) + return dataset_selector_block( + selector_id="dta-dataset-select", + empty_msg=translate_ui(loc, "dash.analysis.dta.empty_import"), + eligible=eligible_datasets(all_datasets, _DTA_ELIGIBLE_TYPES), + all_datasets=all_datasets, + eligible_types=_DTA_ELIGIBLE_TYPES, + active_dataset=payload.get("active_dataset"), + locale_data=locale_data, + ) + + +@callback( + Output("dta-run-status", "children"), + Output("dta-refresh", "data", allow_duplicate=True), + Output("dta-latest-result-id", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("dta-run-btn", "n_clicks"), + State("project-id", "data"), + State("dta-dataset-select", "value"), + State("dta-template-select", "value"), + State("dta-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + State("dta-processing-draft", "data"), + prevent_initial_call=True, +) +def run_dta_analysis( + n_clicks, + project_id, + dataset_key, + template_id, + refresh_val, + global_refresh, + locale_data, + processing_draft, +): + loc = _loc(locale_data) + if not n_clicks or not project_id or not dataset_key: + raise dash.exceptions.PreventUpdate + + from dash_app.api_client import analysis_run + + overrides = _overrides_from_draft(processing_draft) or None + try: + result = analysis_run( + project_id=project_id, + dataset_key=dataset_key, + analysis_type="DTA", + workflow_template_id=template_id, + processing_overrides=overrides, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.analysis_failed", error=str(exc)), color="danger"), dash.no_update, dash.no_update, dash.no_update + + alert, saved, result_id = interpret_run_result(result, locale_data=locale_data) + refresh = (refresh_val or 0) + 1 + if saved: + return alert, refresh, result_id, (global_refresh or 0) + 1 + return alert, refresh, dash.no_update, dash.no_update + + +def _dta_collapsible_section(loc: str, title_key: str, body: Any, *, open: bool = False, summary_suffix: Any | None = None) -> html.Details: + """Collapsible card body — summary shows ``>>`` chevron + title; closed by default.""" + return build_collapsible_section(loc, title_key, body, open=open, summary_suffix=summary_suffix) + + +@callback( + Output("dta-result-dataset-summary", "children"), + Output("dta-result-metrics", "children"), + Output("dta-result-quality", "children"), + Output("dta-result-raw-metadata", "children"), + Output("dta-result-figure", "children"), + Output("dta-result-peak-cards", "children"), + Output("dta-result-table", "children"), + Output("dta-result-processing", "children"), + Input("dta-latest-result-id", "data"), + Input("dta-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def display_result(result_id, _refresh, ui_theme, locale_data, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + summary_empty = html.P( + translate_ui(loc, "dash.analysis.dta.summary.empty"), + className="text-muted", + ) + quality_empty = _dta_collapsible_section( + loc, + "dash.analysis.dta.quality.card_title", + html.P(translate_ui(loc, "dash.analysis.dta.quality.empty"), className="text-muted mb-0"), + open=False, + ) + raw_meta_empty = _dta_collapsible_section( + loc, + "dash.analysis.dta.raw_metadata.card_title", + html.P(translate_ui(loc, "dash.analysis.dta.raw_metadata.empty"), className="text-muted mb-0"), + open=False, + ) + if not result_id or not project_id: + return ( + summary_empty, + empty_msg, + quality_empty, + raw_meta_empty, + empty_msg, + empty_msg, + empty_msg, + empty_msg, + ) + + from dash_app.api_client import workspace_dataset_detail, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + err = dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_result", error=str(exc)), color="danger") + return ( + summary_empty, + err, + quality_empty, + raw_meta_empty, + empty_msg, + empty_msg, + empty_msg, + empty_msg, + ) + + summary = detail.get("summary", {}) + result_meta = detail.get("result", {}) + processing = detail.get("processing", {}) + rows = _event_rows(detail.get("rows") or detail.get("rows_preview") or []) + dataset_key = result_meta.get("dataset_key") + + dataset_detail = {} + if dataset_key: + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + + dataset_summary_panel = _build_dta_dataset_summary( + dataset_detail, + summary, + result_meta, + loc, + locale_data=locale_data, + ) + + quality_panel = _build_dta_quality_card(detail, result_meta, loc) + raw_metadata_panel = _build_dta_raw_metadata_panel( + (dataset_detail or {}).get("metadata"), + loc, + ) + + peak_count, exo_count, endo_count = _derive_event_metrics(summary, rows) + sample_name = _resolve_dta_sample_name(summary, result_meta, dataset_detail, locale_data=locale_data) + + metrics = metrics_row( + [ + ("dash.analysis.metric.events", str(peak_count)), + ("dash.analysis.metric.exothermic", str(exo_count)), + ("dash.analysis.metric.endothermic", str(endo_count)), + ("dash.analysis.metric.sample", sample_name), + ], + locale_data=locale_data, + ) + + peak_cards = _build_peak_cards(rows, loc) + + figure_area = empty_msg + if dataset_key: + figure_area = _build_figure(project_id, dataset_key, sample_name, rows, ui_theme, loc, locale_data) + + table_area = _build_peak_table(rows, loc) + + method_context = processing.get("method_context", {}) + na = translate_ui(loc, "dash.analysis.na") + proc_inner = processing_details_section( + processing, + extra_lines=[ + html.P(translate_ui(loc, "dash.analysis.dta.baseline", detail=processing.get("signal_pipeline", {}).get("baseline", {}))), + html.P(translate_ui(loc, "dash.analysis.dta.peak_detection", detail=processing.get("analysis_steps", {}).get("peak_detection", {}))), + html.P(translate_ui(loc, "dash.analysis.dta.sign_convention", detail=method_context.get("sign_convention_label", na)), className="mb-0"), + ], + locale_data=locale_data, + ) + proc_view = _wrap_dta_processing_details(proc_inner, processing, loc) + + return ( + dataset_summary_panel, + metrics, + quality_panel, + raw_metadata_panel, + figure_area, + peak_cards, + table_area, + proc_view, + ) + + +def _format_dataset_metadata_value(value: Any) -> str | None: + """Return a trimmed string for metadata values or None when empty.""" + if value is None: + return None + if isinstance(value, float): + if value != value: # NaN guard + return None + text = f"{value:g}" + else: + text = str(value).strip() + return text or None + + +def _build_dta_dataset_summary( + dataset_detail: dict, + summary: dict, + result_meta: dict, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + """Render the Streamlit-parity dataset metadata block. + + Mirrors ``ui/dta_page.py::_render_dta_results`` lines 742-750 — shows + dataset file name, sample name, sample mass, and heating rate when + available. Falls back gracefully when any field is missing. + """ + + metadata = (dataset_detail or {}).get("metadata") or {} + dataset_summary = (dataset_detail or {}).get("dataset") or {} + na = translate_ui(loc, "dash.analysis.na") + + dataset_label = ( + _format_dataset_metadata_value(metadata.get("file_name")) + or _format_dataset_metadata_value(dataset_summary.get("display_name")) + or _format_dataset_metadata_value(result_meta.get("dataset_key")) + or na + ) + + sample_label = _resolve_dta_sample_name( + summary or {}, result_meta or {}, dataset_detail, locale_data=locale_data + ) or na + + def _meta_value(value: str) -> html.Span: + return html.Span(value, className="ms-meta-value", title=value) + + rows: list[Any] = [ + html.Dt( + translate_ui(loc, "dash.analysis.dta.summary.dataset_label"), + className="col-sm-4 text-muted ms-meta-term", + ), + html.Dd(_meta_value(dataset_label), className="col-sm-8 mb-2 ms-meta-def"), + html.Dt( + translate_ui(loc, "dash.analysis.dta.summary.sample_label"), + className="col-sm-4 text-muted ms-meta-term", + ), + html.Dd(_meta_value(sample_label), className="col-sm-8 mb-2 ms-meta-def"), + ] + + mass_value = _format_dataset_metadata_value(metadata.get("sample_mass")) + if mass_value is not None: + mass_unit = translate_ui(loc, "dash.analysis.dta.summary.mass_unit") + rows.extend( + [ + html.Dt( + translate_ui(loc, "dash.analysis.dta.summary.mass_label"), + className="col-sm-4 text-muted ms-meta-term", + ), + html.Dd(_meta_value(f"{mass_value} {mass_unit}"), className="col-sm-8 mb-2 ms-meta-def"), + ] + ) + + heating_value = _format_dataset_metadata_value( + metadata.get("heating_rate") + if metadata.get("heating_rate") is not None + else dataset_summary.get("heating_rate") + ) + if heating_value is not None: + heating_unit = translate_ui(loc, "dash.analysis.dta.summary.heating_rate_unit") + rows.extend( + [ + html.Dt( + translate_ui(loc, "dash.analysis.dta.summary.heating_rate_label"), + className="col-sm-4 text-muted ms-meta-term", + ), + html.Dd(_meta_value(f"{heating_value} {heating_unit}"), className="col-sm-8 mb-0 ms-meta-def"), + ] + ) + + return html.Div( + [ + html.H5( + translate_ui(loc, "dash.analysis.dta.summary.card_title"), + className="mb-3", + ), + html.Dl(rows, className="row mb-0", id="dta-dataset-summary-dl"), + ], + id="dta-dataset-summary-body", + ) + + +def _build_dta_quality_card( + detail: dict, + result_meta: dict, + loc: str, +) -> html.Div: + """Surface validation status and warning/issue counts (Phase 4).""" + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + status_raw = validation.get("status") + if status_raw in (None, ""): + status_raw = result_meta.get("validation_status") + status_display = _format_dataset_metadata_value(status_raw) or translate_ui(loc, "dash.analysis.na") + + warnings_list = validation.get("warnings") + if not isinstance(warnings_list, list): + warnings_list = [] + issues_list = validation.get("issues") + if not isinstance(issues_list, list): + issues_list = [] + + wc = validation.get("warning_count") + if wc is None: + wc = result_meta.get("warning_count") + if wc is None: + wc = len(warnings_list) + try: + wc = int(wc) + except (TypeError, ValueError): + wc = len(warnings_list) + + ic = validation.get("issue_count") + if ic is None: + ic = result_meta.get("issue_count") + if ic is None: + ic = len(issues_list) + try: + ic = int(ic) + except (TypeError, ValueError): + ic = len(issues_list) + + st_lower = str(status_raw or "").lower() + if ic > 0 or "fail" in st_lower or "error" in st_lower or "invalid" in st_lower: + alert_color = "danger" + elif wc > 0 or "warn" in st_lower: + alert_color = "warning" + else: + alert_color = "success" + + body_children: list[Any] = [ + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.dta.quality.status_label")), + " ", + status_display, + ], + className="mb-2", + ), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.dta.quality.warnings_label")), + f" {wc}", + ], + className="mb-2", + ), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.dta.quality.issues_label")), + f" {ic}", + ], + className="mb-0", + ), + ] + if warnings_list: + body_children.append( + html.Ul([html.Li(str(w)) for w in warnings_list[:12]], className="small mb-0 mt-2") + ) + if issues_list: + body_children.append( + html.Ul([html.Li(str(w)) for w in issues_list[:12]], className="small mb-0 mt-2") + ) + + inner = dbc.Alert(body_children, color=alert_color, className="mb-0 ta-quality-alert") + return _dta_collapsible_section(loc, "dash.analysis.dta.quality.card_title", inner, open=False) + + +def _build_dta_raw_metadata_panel(metadata: dict | None, loc: str) -> html.Details: + """Full ``dataset.metadata`` key/value list for the raw-metadata card (Phase 4).""" + meta = metadata if isinstance(metadata, dict) else {} + if not meta: + inner = html.P(translate_ui(loc, "dash.analysis.dta.raw_metadata.empty"), className="text-muted mb-0") + else: + rows: list[Any] = [] + for key in sorted(meta.keys(), key=lambda k: str(k).lower()): + val = meta[key] + if isinstance(val, (dict, list)): + text = json.dumps(val, ensure_ascii=False, indent=2) + else: + fv = _format_dataset_metadata_value(val) + text = fv if fv is not None else str(val) + rows.extend( + [ + html.Dt(str(key), className="col-sm-4 text-muted small"), + html.Dd( + html.Pre(text, className="small mb-0 ta-code-block p-2 rounded"), + className="col-sm-8 mb-2", + ), + ] + ) + inner = html.Dl(rows, className="row mb-0", id="dta-raw-metadata-dl") + + return _dta_collapsible_section(loc, "dash.analysis.dta.raw_metadata.card_title", inner, open=False) + + +def _build_dta_processing_expansion_blocks(processing: dict, loc: str) -> html.Div: + """Per-step JSON snapshots for the expandable processing section (Phase 4).""" + sp = processing.get("signal_pipeline") or {} + asteps = processing.get("analysis_steps") or {} + blocks: list[Any] = [] + pairs = [ + ("dash.analysis.dta.processing.block_smoothing", sp.get("smoothing")), + ("dash.analysis.dta.processing.block_baseline", sp.get("baseline")), + ("dash.analysis.dta.processing.block_peaks", asteps.get("peak_detection")), + ] + for title_key, data in pairs: + if not isinstance(data, dict) or not data: + continue + blocks.append( + html.H6(translate_ui(loc, title_key), className="mt-2 small text-muted mb-1"), + ) + blocks.append( + html.Pre( + json.dumps(data, indent=2, ensure_ascii=False), + className="small ta-code-block p-2 rounded mb-0", + ) + ) + if not blocks: + return html.Div() + return html.Div(blocks, className="mt-3 pt-2 border-top") + + +def _wrap_dta_processing_details(inner: html.Div, processing: dict, loc: str) -> html.Div: + """Wrap shared processing details + expansion in a ``
`` element.""" + expansion = _build_dta_processing_expansion_blocks(processing, loc) + return html.Div( + [ + html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(translate_ui(loc, "dash.analysis.dta.processing.expand_summary"), className="ms-1"), + ], + className="ta-details-summary", + ), + html.Div([inner, expansion], className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=False, + ) + ] + ) + + +def _build_peak_cards(rows: list, loc: str = "en") -> html.Div: + if not rows: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.key_thermal_events"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_thermal_events"), className="text-muted"), + ] + ) + + primary_rows, secondary_rows = _split_primary_events(rows) + cards = [ + html.H5(translate_ui(loc, "dash.analysis.section.key_thermal_events"), className="mb-2"), + html.P( + translate_ui( + loc, + "dash.analysis.dta.events_cards_intro", + shown=len(primary_rows), + total=len(rows), + ), + className="text-muted small mb-3", + ), + dbc.Row( + [dbc.Col(_peak_card(row, idx, loc), md=6) for idx, row in enumerate(primary_rows)], + className="g-3", + ), + ] + + if secondary_rows: + cards.append( + html.Details( + [ + html.Summary(translate_ui(loc, "dash.analysis.dta.show_more_events", n=len(secondary_rows)), className="small"), + html.Div( + dataset_table( + secondary_rows, + ["direction", "peak_temperature", "onset_temperature", "endset_temperature", "area", "height"], + table_id="dta-secondary-events-table", + ), + className="mt-3", + ), + ], + className="mt-3", + ) + ) + return html.Div(cards) + + +def _primary_trace_name(corrected: list, smoothed: list, raw_signal: list, loc: str) -> tuple[str, str]: + if corrected: + return translate_ui(loc, "dash.analysis.figure.legend_dta_primary_corrected"), "#047857" + if smoothed: + return translate_ui(loc, "dash.analysis.figure.legend_dta_primary_smoothed"), "#0E7490" + return translate_ui(loc, "dash.analysis.figure.legend_dta_primary_raw"), "#475569" + + +def _build_dta_go_figure( + project_id: str, + dataset_key: str, + sample_name: str, + peak_rows: list, + ui_theme: str | None, + loc: str = "en", + view_mode: str = "result", +) -> go.Figure | None: + """Build the DTA Plotly figure, or return None when data is missing. + + Separated from ``_build_figure`` so the figure-capture callback can reuse + the same plotting logic without constructing a ``dcc.Graph`` wrapper. + """ + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "DTA", dataset_key) + except Exception: + curves = {} + + if view_mode not in _DTA_VIEW_MODES: + view_mode = "result" + + temperature = curves.get("temperature", []) + raw_signal = _series_for_temperature(curves.get("raw_signal", []), temperature) + smoothed = _series_for_temperature(curves.get("smoothed", []), temperature) + baseline = _series_for_temperature(curves.get("baseline", []), temperature) + corrected = _series_for_temperature(curves.get("corrected", []), temperature) + + if not temperature: + return None + + primary_signal = corrected or smoothed or raw_signal + if not primary_signal: + return None + + is_result = view_mode == "result" + is_dark = normalize_ui_theme(ui_theme) == "dark" + result_title_size = 17 + debug_title_size = 15 + result_height = 600 + debug_height = 520 + fig = go.Figure() + primary_name, primary_color = _primary_trace_name(corrected, smoothed, raw_signal, loc) + y_range = _compute_y_axis_range(primary_signal, baseline, smoothed if corrected else [], raw_signal if not (corrected or smoothed) else []) + + legend_raw = translate_ui(loc, "dash.analysis.figure.legend_raw_signal") + legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed") + legend_base = translate_ui(loc, "dash.analysis.figure.legend_baseline") + legend_primary_smoothed = translate_ui(loc, "dash.analysis.figure.legend_dta_primary_smoothed") + + if raw_signal: + fig.add_trace( + go.Scatter( + x=temperature, + y=raw_signal, + mode="lines", + name=legend_raw, + line=dict(color="#94A3B8", width=0.95 if primary_name != legend_raw else 1.9), + opacity=0.18 if (is_result and primary_name != legend_raw) else (0.88 if primary_name == legend_raw else 0.34), + hovertemplate=_trace_hover_template(legend_raw, loc), + ) + ) + + if smoothed and primary_name != legend_primary_smoothed: + fig.add_trace( + go.Scatter( + x=temperature, + y=smoothed, + mode="lines", + name=legend_smooth, + line=dict(color="#0891B2", width=1.4 if is_result else 1.6), + opacity=0.62 if is_result else 0.84, + hovertemplate=_trace_hover_template(legend_smooth, loc), + ) + ) + + if baseline: + fig.add_trace( + go.Scatter( + x=temperature, + y=baseline, + mode="lines", + name=legend_base, + line=dict(color="#64748B", width=1.0 if is_result else 1.1, dash="dot"), + opacity=0.38 if is_result else 0.66, + hovertemplate=_trace_hover_template(legend_base, loc), + ) + ) + + fig.add_trace( + go.Scatter( + x=temperature, + y=primary_signal, + mode="lines", + name=primary_name, + line=dict(color=primary_color, width=3.0 if is_result else 2.8), + opacity=1.0, + hovertemplate=_trace_hover_template(primary_name, loc), + ) + ) + + primary_rows, _secondary_rows = _split_primary_events(peak_rows, limit=min(_PRIMARY_EVENT_LIMIT, len(peak_rows) or 0)) + primary_ids = {id(row) for row in primary_rows} + guide_rows = primary_rows if len(peak_rows) > _PRIMARY_EVENT_LIMIT else peak_rows + _build_event_guides(fig, guide_rows, loc=loc, mode=view_mode) + annotated_temps: list[float] = [] + + for row in _sort_events_by_temperature(peak_rows): + pt = _coerce_float(row.get("peak_temperature")) + if pt is None or not temperature: + continue + direction = _normalize_direction(row.get("direction", row.get("peak_type", "unknown"))) + direction_label = _direction_label(direction, loc) + color = _DIRECTION_COLORS.get(direction, "#B45309") + idx = min(range(len(temperature)), key=lambda i: abs(temperature[i] - pt)) + is_primary = id(row) in primary_ids + text_str = _event_text_label( + row, + direction_label=direction_label, + pt=pt, + is_primary=is_primary, + min_sep=_ANNOTATION_MIN_SEP, + annotated_temps=annotated_temps, + mode=view_mode, + ) + marker_hover = _event_hover_html(row, direction_label, _coerce_float(primary_signal[idx]), loc) + fig.add_trace( + go.Scatter( + x=[temperature[idx]], + y=[primary_signal[idx]], + mode="markers+text", + marker=dict( + size=9 if (is_result and is_primary) else (7 if is_result else (10 if is_primary else 8)), + color=color, + symbol="diamond", + line=dict(color="white", width=1.1), + ), + text=[text_str], + textposition="top center", + textfont=dict(size=8 if is_result else 8, color=color), + name=f"{direction_label} {_format_temp_c(pt)}", + showlegend=False, + hovertemplate=marker_hover, + ) + ) + if text_str: + annotated_temps.append(pt) + + y_grid_color = "rgba(61, 59, 56, 0.34)" if is_dark else "rgba(224, 221, 214, 0.52)" + x_grid_color = "rgba(61, 59, 56, 0.26)" if is_dark else "rgba(224, 221, 214, 0.36)" + axis_line_color = "rgba(61, 59, 56, 0.84)" if is_dark else "rgba(183, 177, 168, 0.9)" + tick_color = "rgba(238, 237, 234, 0.9)" if is_dark else "rgba(28, 26, 26, 0.78)" + title_size = result_title_size if is_result else debug_title_size + if is_result: + legend = dict( + orientation="h", + yanchor="bottom", + y=1.015, + xanchor="right", + x=1.0, + traceorder="normal", + itemclick="toggleothers", + itemdoubleclick="toggle", + font=dict(size=11), + itemsizing="constant", + ) + else: + legend = dict( + orientation="v", + yanchor="top", + y=0.99, + xanchor="right", + x=1.0, + traceorder="normal", + itemclick="toggle", + font=dict(size=11), + ) + + fig.update_layout( + title=dict( + text=translate_ui(loc, "dash.analysis.figure.title_dta", name=sample_name), + x=0.01, + xanchor="left", + y=0.985, + yanchor="top", + font=dict(size=title_size), + ), + xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_temperature_c"), + yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_delta_t"), + hovermode="x unified", + margin=dict(l=70, r=34, t=86 if is_result else 66, b=62 if is_result else 54), + height=result_height if is_result else debug_height, + hoverlabel=dict(namelength=-1), + legend=legend, + ) + apply_figure_theme(fig, ui_theme) + fig.update_xaxes( + gridcolor=x_grid_color, + showgrid=not is_result, + showline=True, + linewidth=1, + linecolor=axis_line_color, + ticks="outside", + ticklen=4, + tickcolor=axis_line_color, + tickfont=dict(size=12, color=tick_color), + title_standoff=12, + ) + fig.update_yaxes( + range=y_range, + gridcolor=y_grid_color, + showgrid=True, + showline=True, + linewidth=1, + linecolor=axis_line_color, + ticks="outside", + ticklen=4, + tickcolor=axis_line_color, + tickfont=dict(size=12, color=tick_color), + title_standoff=12, + ) + return fig + + +def _dta_graph_config(view_mode: str) -> dict[str, Any]: + mode = view_mode if view_mode in _DTA_VIEW_MODES else "result" + buttons_to_remove = ["lasso2d", "select2d", "toggleSpikelines"] + if mode == "result": + buttons_to_remove.append("hoverCompareCartesian") + return { + "displaylogo": False, + "responsive": True, + "modeBarButtonsToRemove": buttons_to_remove, + "toImageButtonOptions": { + "format": "png", + "filename": "dta-analysis", + "scale": 2, + }, + } + + +def _build_figure( + project_id: str, + dataset_key: str, + sample_name: str, + peak_rows: list, + ui_theme: str | None, + loc: str = "en", + locale_data: str | None = None, + view_mode: str = "result", +) -> html.Div: + _ld = locale_data if locale_data is not None else loc + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "DTA", dataset_key) + except Exception: + curves = {} + if not curves.get("temperature"): + return no_data_figure_msg(locale_data=_ld) + + fig = _build_dta_go_figure(project_id, dataset_key, sample_name, peak_rows, ui_theme, loc, view_mode=view_mode) + if fig is None: + return no_data_figure_msg(text=translate_ui(loc, "dash.analysis.dta.no_plot_signal"), locale_data=_ld) + + result_graph = dcc.Graph( + figure=fig, + config=_dta_graph_config(view_mode), + className="ta-plot ms-result-graph", + ) + result_shell = html.Div(result_graph, className="ms-result-figure-shell") + if view_mode != "result": + return result_shell + + debug_fig = _build_dta_go_figure(project_id, dataset_key, sample_name, peak_rows, ui_theme, loc, view_mode="debug") + if debug_fig is None: + return result_shell + debug_graph = dcc.Graph( + figure=debug_fig, + config=_dta_graph_config("debug"), + className="ta-plot dta-debug-graph", + ) + debug_title = "Gelişmiş Tanılama Görünümü" if normalize_ui_locale(loc) == "tr" else "Debug / Analysis View" + debug_details = html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(debug_title, className="ms-1"), + ], + className="ta-details-summary", + ), + html.Div(debug_graph, className="ta-details-body mt-2"), + ], + className="ta-ms-details dta-debug-shell mt-3", + open=False, + id="dta-debug-figure-details", + ) + return html.Div( + [result_shell, html.Div(debug_details, className="dta-result-debug")], + className="dta-figure-stack", + ) + + +def _build_peak_table(rows: list, loc: str = "en") -> html.Div: + if not rows: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.all_event_details"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_event_data"), className="text-muted"), + ] + ) + + columns = [ + "direction", + "peak_temperature", + "onset_temperature", + "endset_temperature", + "area", + "fwhm", + "height", + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.all_event_details"), className="mb-3"), + dataset_table(rows, columns, table_id="dta-peaks-table"), + ] + ) + + +def _smoothing_status_text(draft: dict | None, loc: str) -> str: + """Build a concise status line summarizing the current smoothing draft.""" + values = (draft or {}).get("smoothing") or {} + method = str(values.get("method") or "savgol") + method_label = {"savgol": "Savitzky-Golay", "moving_average": "Moving Average", "gaussian": "Gaussian"}.get(method, method) + parts = [f"{method_label}"] + if "window_length" in values: + parts.append(f"window={values['window_length']}") + if "polyorder" in values: + parts.append(f"polyorder={values['polyorder']}") + if "sigma" in values: + parts.append(f"sigma={values['sigma']}") + applied = translate_ui(loc, "dash.analysis.dta.smoothing.applied") + if applied == "dash.analysis.dta.smoothing.applied": + applied = "Applied" + return f"{applied}: {' - '.join(parts)}" + + +@callback( + Output("dta-smoothing-card-title", "children"), + Output("dta-smooth-method-label", "children"), + Output("dta-smooth-window-label", "children"), + Output("dta-smooth-polyorder-label", "children"), + Output("dta-smooth-sigma-label", "children"), + Output("dta-smooth-apply-btn", "children"), + Output("dta-smooth-method-hint", "children"), + Output("dta-smooth-window-hint", "children"), + Output("dta-smooth-polyorder-hint", "children"), + Output("dta-smooth-sigma-hint", "children"), + Input("ui-locale", "data"), +) +def render_dta_smoothing_chrome(locale_data): + loc = _loc(locale_data) + + def _t(key: str, fallback: str) -> str: + value = translate_ui(loc, key) + return fallback if value == key else value + + return ( + _t("dash.analysis.dta.smoothing.title", "Smoothing"), + _t("dash.analysis.dta.smoothing.method", "Smoothing Method"), + _t("dash.analysis.dta.smoothing.window", "Window Length"), + _t("dash.analysis.dta.smoothing.polyorder", "Polynomial Order"), + _t("dash.analysis.dta.smoothing.sigma", "Sigma"), + _t("dash.analysis.dta.smoothing.apply_btn", "Apply Smoothing"), + _t( + "dash.analysis.dta.smoothing.help.method", + "Savitzky-Golay preserves peak shape; Moving Average is simple and fast; Gaussian gives the smoothest curve.", + ), + _t( + "dash.analysis.dta.smoothing.help.window", + "Number of points averaged. Larger values smooth more but can blur small peaks. Must be odd; try 7-15 for typical DTA traces.", + ), + _t( + "dash.analysis.dta.smoothing.help.polyorder", + "Polynomial order for Savitzky-Golay. Higher orders preserve sharp peaks but may re-introduce noise. Usually 2-4.", + ), + _t( + "dash.analysis.dta.smoothing.help.sigma", + "Gaussian kernel width. Larger sigma = stronger smoothing. Start from 1.0-3.0 and raise if the baseline is still noisy.", + ), + ) + + +@callback( + Output("dta-processing-history-title", "children"), + Output("dta-processing-history-hint", "children"), + Output("dta-undo-btn", "children"), + Output("dta-redo-btn", "children"), + Output("dta-reset-btn", "children"), + Input("ui-locale", "data"), +) +def render_dta_processing_history_chrome(locale_data): + loc = _loc(locale_data) + + def _t(key: str, fallback: str) -> str: + value = translate_ui(loc, key) + return fallback if value == key else value + + return ( + _t("dash.analysis.dta.processing.history_title", "Processing history"), + _t("dash.analysis.dta.processing.history_hint", "Affects the processing draft only; preset selection is kept separate."), + _t("dash.analysis.dta.processing.undo_btn", "Undo"), + _t("dash.analysis.dta.processing.redo_btn", "Redo"), + _t("dash.analysis.dta.processing.reset_btn", "Reset to defaults"), + ) + + +@callback( + Output("dta-smooth-window", "disabled"), + Output("dta-smooth-polyorder", "disabled"), + Output("dta-smooth-sigma", "disabled"), + Input("dta-smooth-method", "value"), +) +def toggle_smoothing_inputs(method): + token = str(method or "savgol").strip().lower() + if token == "savgol": + return False, False, True + if token == "moving_average": + return False, True, True + return True, True, False + + +@callback( + Output("dta-processing-draft", "data", allow_duplicate=True), + Output("dta-processing-undo", "data", allow_duplicate=True), + Output("dta-processing-redo", "data", allow_duplicate=True), + Input("dta-smooth-apply-btn", "n_clicks"), + State("dta-smooth-method", "value"), + State("dta-smooth-window", "value"), + State("dta-smooth-polyorder", "value"), + State("dta-smooth-sigma", "value"), + State("dta-processing-draft", "data"), + State("dta-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_smoothing(n_clicks, method, window, polyorder, sigma, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_smoothing_values(method, window, polyorder, sigma) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "smoothing", values) + return next_draft, next_undo, [] + + +@callback( + Output("dta-processing-draft", "data", allow_duplicate=True), + Output("dta-processing-undo", "data", allow_duplicate=True), + Output("dta-processing-redo", "data", allow_duplicate=True), + Output("dta-history-status", "children", allow_duplicate=True), + Input("dta-undo-btn", "n_clicks"), + Input("dta-redo-btn", "n_clicks"), + Input("dta-reset-btn", "n_clicks"), + State("dta-processing-draft", "data"), + State("dta-processing-undo", "data"), + State("dta-processing-redo", "data"), + State("dta-processing-default", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def dta_processing_history_actions(n_undo, n_redo, n_reset, draft, undo, redo, defaults, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + cur = draft or {} + + if trig == "dta-undo-btn": + if not n_undo: + raise dash.exceptions.PreventUpdate + next_draft, next_undo, next_redo = _do_undo(cur, undo, redo) + return next_draft, next_undo, next_redo, translate_ui(loc, "dash.analysis.dta.processing.history_status_undo") + + if trig == "dta-redo-btn": + if not n_redo: + raise dash.exceptions.PreventUpdate + next_draft, next_undo, next_redo = _do_redo(cur, undo, redo) + return next_draft, next_undo, next_redo, translate_ui(loc, "dash.analysis.dta.processing.history_status_redo") + + if trig == "dta-reset-btn": + if not n_reset: + raise dash.exceptions.PreventUpdate + next_draft, next_undo, next_redo = _do_reset(cur, undo, redo, defaults) + if next_draft == cur and next_undo == (undo or []) and next_redo == (redo or []): + raise dash.exceptions.PreventUpdate + return next_draft, next_undo, next_redo, translate_ui(loc, "dash.analysis.dta.processing.history_status_reset") + + raise dash.exceptions.PreventUpdate + + +@callback( + Output("dta-smooth-method", "value"), + Output("dta-smooth-window", "value"), + Output("dta-smooth-polyorder", "value"), + Output("dta-smooth-sigma", "value"), + Output("dta-smooth-status", "children"), + Output("dta-undo-btn", "disabled"), + Output("dta-redo-btn", "disabled"), + Output("dta-reset-btn", "disabled"), + Input("dta-processing-draft", "data"), + Input("dta-processing-undo", "data"), + Input("dta-processing-redo", "data"), + Input("dta-processing-default", "data"), + Input("ui-locale", "data"), +) +def sync_smoothing_controls(draft, undo, redo, defaults, locale_data): + loc = _loc(locale_data) + values = (draft or {}).get("smoothing") or {} + method = str(values.get("method") or "savgol") + window_length = values.get("window_length", 11) + polyorder = values.get("polyorder", 3) + sigma = values.get("sigma", 2.0) + status = _smoothing_status_text(draft, loc) + undo_disabled = not bool(undo) + redo_disabled = not bool(redo) + reset_disabled = (draft or {}) == (defaults or {}) + return method, window_length, polyorder, sigma, status, undo_disabled, redo_disabled, reset_disabled + + +def _baseline_status_text(draft: dict | None, loc: str) -> str: + """Build a concise status line summarizing the current baseline draft.""" + values = (draft or {}).get("baseline") or {} + method = str(values.get("method") or "asls") + method_label = { + "asls": "AsLS", + "airpls": "airPLS", + "modpoly": "ModPoly", + "imodpoly": "iModPoly", + "snip": "SNIP", + "linear": "Linear", + "rubberband": "Rubberband", + "spline": "Spline", + }.get(method, method) + parts = [method_label] + if method in ("asls", "airpls"): + if "lam" in values: + parts.append(f"lam={values['lam']:g}") + if method in ("modpoly", "imodpoly"): + if "poly_order" in values: + parts.append(f"order={values['poly_order']}") + if method == "snip": + if "max_half_window" in values: + parts.append(f"window={values['max_half_window']}") + if method == "spline": + if "n_anchors" in values: + parts.append(f"anchors={values['n_anchors']}") + if method == "asls": + if "p" in values: + parts.append(f"p={values['p']:g}") + applied = translate_ui(loc, "dash.analysis.dta.baseline.applied") + if applied == "dash.analysis.dta.baseline.applied": + applied = "Applied" + return f"{applied}: {' - '.join(parts)}" + + +def _peak_status_text(draft: dict | None, loc: str) -> str: + """Build a concise status line summarizing the current peak-detection draft.""" + values = (draft or {}).get("peak_detection") or {} + flags = [] + if values.get("detect_exothermic", True): + flags.append("exo") + if values.get("detect_endothermic", True): + flags.append("endo") + parts = ["/".join(flags) if flags else "none"] + prom = values.get("prominence") + if prom is not None: + parts.append("prominence=auto" if float(prom) == 0.0 else f"prominence={prom:g}") + dist = values.get("distance") + if dist is not None: + parts.append(f"distance={int(dist)}") + applied = translate_ui(loc, "dash.analysis.dta.peaks.applied") + if applied == "dash.analysis.dta.peaks.applied": + applied = "Applied" + return f"{applied}: {' - '.join(parts)}" + + +@callback( + Output("dta-baseline-card-title", "children"), + Output("dta-baseline-method-label", "children"), + Output("dta-baseline-lam-label", "children"), + Output("dta-baseline-p-label", "children"), + Output("dta-baseline-poly-order-label", "children"), + Output("dta-baseline-max-half-window-label", "children"), + Output("dta-baseline-n-anchors-label", "children"), + Output("dta-baseline-apply-btn", "children"), + Output("dta-baseline-method-hint", "children"), + Output("dta-baseline-lam-hint", "children"), + Output("dta-baseline-p-hint", "children"), + Output("dta-baseline-poly-order-hint", "children"), + Output("dta-baseline-max-half-window-hint", "children"), + Output("dta-baseline-n-anchors-hint", "children"), + Input("ui-locale", "data"), +) +def render_dta_baseline_chrome(locale_data): + loc = _loc(locale_data) + + def _t(key: str, fallback: str) -> str: + value = translate_ui(loc, key) + return fallback if value == key else value + + return ( + _t("dash.analysis.dta.baseline.title", "Baseline"), + _t("dash.analysis.dta.baseline.method", "Baseline Method"), + _t("dash.analysis.dta.baseline.lam", "Lambda (AsLS / airPLS)"), + _t("dash.analysis.dta.baseline.p", "Asymmetry p (asls)"), + _t("dash.analysis.dta.baseline.poly_order", "Polynomial order"), + _t("dash.analysis.dta.baseline.max_half_window", "Max half-window"), + _t("dash.analysis.dta.baseline.n_anchors", "Anchor count"), + _t("dash.analysis.dta.baseline.apply_btn", "Apply Baseline"), + _t( + "dash.analysis.dta.baseline.help.method", + "AsLS handles curved drifting baselines; airPLS is adaptive for sharp peaks; ModPoly/iModPoly fit polynomial backgrounds; SNIP clips noise; Linear fits a straight line; Rubberband wraps the signal from below; Spline uses anchor points.", + ), + _t( + "dash.analysis.dta.baseline.help.lam", + "AsLS / airPLS baseline stiffness. Higher values (1e7+) keep the baseline flat; lower values (1e4) let it follow peaks — risks absorbing real events.", + ), + _t( + "dash.analysis.dta.baseline.help.p", + "AsLS asymmetry. Small values (0.001-0.01) push the baseline below exothermic peaks; use 0.1-0.5 when the baseline should pass above endotherms.", + ), + _t( + "dash.analysis.dta.baseline.help.poly_order", + "Polynomial order for ModPoly / iModPoly. Higher orders fit more complex backgrounds; typical range 3-8.", + ), + _t( + "dash.analysis.dta.baseline.help.max_half_window", + "SNIP half-window size. Larger values clip broader noise features; typical range 20-100.", + ), + _t( + "dash.analysis.dta.baseline.help.n_anchors", + "Number of automatically selected anchor points for the Spline baseline. More anchors follow the signal more closely; typical range 4-10.", + ), + ) + + +@callback( + Output("dta-peak-card-title", "children"), + Output("dta-peak-detect-exo", "label"), + Output("dta-peak-detect-endo", "label"), + Output("dta-peak-prominence-label", "children"), + Output("dta-peak-distance-label", "children"), + Output("dta-peak-apply-btn", "children"), + Output("dta-peak-detect-exo-hint", "children"), + Output("dta-peak-detect-endo-hint", "children"), + Output("dta-peak-prominence-hint", "children"), + Output("dta-peak-distance-hint", "children"), + Input("ui-locale", "data"), +) +def render_dta_peak_chrome(locale_data): + loc = _loc(locale_data) + + def _t(key: str, fallback: str) -> str: + value = translate_ui(loc, key) + return fallback if value == key else value + + return ( + _t("dash.analysis.dta.peaks.title", "Peak Detection"), + _t("dash.analysis.dta.peaks.detect_exo", "Detect Exothermic"), + _t("dash.analysis.dta.peaks.detect_endo", "Detect Endothermic"), + _t("dash.analysis.dta.peaks.prominence", "Prominence (0 = auto)"), + _t("dash.analysis.dta.peaks.distance", "Min Distance (samples)"), + _t("dash.analysis.dta.peaks.apply_btn", "Apply Peaks"), + _t( + "dash.analysis.dta.peaks.help.detect_exo", + "Report exothermic peaks (heat-releasing events such as crystallization or oxidation).", + ), + _t( + "dash.analysis.dta.peaks.help.detect_endo", + "Report endothermic peaks (heat-absorbing events such as melting or decomposition).", + ), + _t( + "dash.analysis.dta.peaks.help.prominence", + "Minimum relative height a peak must stand above its surroundings. 0 = auto-threshold (~5% of signal range). Raise to ignore noise; lower to catch subtle events.", + ), + _t( + "dash.analysis.dta.peaks.help.distance", + "Minimum sample separation between adjacent peaks. Raise to merge closely-spaced events into one; lower to keep doublets separate.", + ), + ) + + +@callback( + Output("dta-baseline-lam-p-group", "style"), + Output("dta-baseline-poly-order-group", "style"), + Output("dta-baseline-max-half-window-group", "style"), + Output("dta-baseline-n-anchors-group", "style"), + Input("dta-baseline-method", "value"), +) +def toggle_baseline_parameter_groups(method): + token = str(method or "asls").strip().lower() + show = {"display": "block"} + hide = {"display": "none"} + if token in ("asls", "airpls"): + return show, hide, hide, hide + if token in ("modpoly", "imodpoly"): + return hide, show, hide, hide + if token == "snip": + return hide, hide, show, hide + if token == "spline": + return hide, hide, hide, show + return hide, hide, hide, hide + + +@callback( + Output("dta-baseline-lam", "disabled"), + Output("dta-baseline-p", "disabled"), + Input("dta-baseline-method", "value"), +) +def toggle_baseline_inputs(method): + token = str(method or "asls").strip().lower() + if token == "asls": + return False, False + if token == "airpls": + return False, True + return True, True + + +@callback( + Output("dta-processing-draft", "data", allow_duplicate=True), + Output("dta-processing-undo", "data", allow_duplicate=True), + Output("dta-processing-redo", "data", allow_duplicate=True), + Input("dta-baseline-apply-btn", "n_clicks"), + State("dta-baseline-method", "value"), + State("dta-baseline-lam", "value"), + State("dta-baseline-p", "value"), + State("dta-baseline-poly-order", "value"), + State("dta-baseline-max-half-window", "value"), + State("dta-baseline-n-anchors", "value"), + State("dta-processing-draft", "data"), + State("dta-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_baseline(n_clicks, method, lam, p, poly_order, max_half_window, n_anchors, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_baseline_values(method, lam, p, poly_order, max_half_window, n_anchors) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "baseline", values) + return next_draft, next_undo, [] + + +@callback( + Output("dta-processing-draft", "data", allow_duplicate=True), + Output("dta-processing-undo", "data", allow_duplicate=True), + Output("dta-processing-redo", "data", allow_duplicate=True), + Input("dta-peak-apply-btn", "n_clicks"), + State("dta-peak-detect-exo", "value"), + State("dta-peak-detect-endo", "value"), + State("dta-peak-prominence", "value"), + State("dta-peak-distance", "value"), + State("dta-processing-draft", "data"), + State("dta-processing-undo", "data"), + prevent_initial_call=True, +) +def apply_peak_detection(n_clicks, detect_exo, detect_endo, prominence, distance, draft, undo): + if not n_clicks: + raise dash.exceptions.PreventUpdate + values = _normalize_peak_detection_values(detect_endo, detect_exo, prominence, distance) + next_undo = _push_undo(undo, draft) + next_draft = _apply_draft_section(draft, "peak_detection", values) + return next_draft, next_undo, [] + + +@callback( + Output("dta-baseline-method", "value"), + Output("dta-baseline-lam", "value"), + Output("dta-baseline-p", "value"), + Output("dta-baseline-poly-order", "value"), + Output("dta-baseline-max-half-window", "value"), + Output("dta-baseline-n-anchors", "value"), + Output("dta-baseline-status", "children"), + Input("dta-processing-draft", "data"), + Input("ui-locale", "data"), +) +def sync_baseline_controls(draft, locale_data): + loc = _loc(locale_data) + values = (draft or {}).get("baseline") or {} + method = str(values.get("method") or "asls") + lam = values.get("lam", 1e6) + p = values.get("p", 0.01) + poly_order = values.get("poly_order", 6) + max_half_window = values.get("max_half_window", 40) + n_anchors = values.get("n_anchors", 6) + status = _baseline_status_text(draft, loc) + return method, lam, p, poly_order, max_half_window, n_anchors, status + + +@callback( + Output("dta-peak-detect-exo", "value"), + Output("dta-peak-detect-endo", "value"), + Output("dta-peak-prominence", "value"), + Output("dta-peak-distance", "value"), + Output("dta-peak-status", "children"), + Input("dta-processing-draft", "data"), + Input("ui-locale", "data"), +) +def sync_peak_controls(draft, locale_data): + loc = _loc(locale_data) + values = (draft or {}).get("peak_detection") or {} + detect_exo = bool(values.get("detect_exothermic", True)) + detect_endo = bool(values.get("detect_endothermic", True)) + prominence = values.get("prominence", 0.0) + distance = values.get("distance", 1) + status = _peak_status_text(draft, loc) + return detect_exo, detect_endo, prominence, distance, status + + +# --------------------------------------------------------------------------- +# Phase 2b: Literature Compare panel + Figure capture for Report Center +# --------------------------------------------------------------------------- + + + +@callback( + Output("dta-literature-card-title", "children"), + Output("dta-literature-hint", "children"), + Output("dta-literature-max-claims-label", "children"), + Output("dta-literature-persist-label", "children"), + Output("dta-literature-compare-btn", "children"), + Input("ui-locale", "data"), + Input("dta-latest-result-id", "data"), +) +def render_dta_literature_chrome(locale_data, result_id): + loc = _loc(locale_data) + if result_id: + hint = literature_t( + loc, + "dash.analysis.dta.literature.ready", + "Compare the saved DTA result to literature sources.", + ) + else: + hint = literature_t( + loc, + "dash.analysis.dta.literature.empty", + "Run a DTA analysis first to enable literature comparison.", + ) + return ( + literature_t(loc, "dash.analysis.dta.literature.title", "Literature Compare"), + hint, + literature_t(loc, "dash.analysis.dta.literature.max_claims", "Max Claims"), + literature_t(loc, "dash.analysis.dta.literature.persist", "Persist to project"), + literature_t(loc, "dash.analysis.dta.literature.compare_btn", "Compare"), + ) + + +@callback( + Output("dta-literature-compare-btn", "disabled"), + Input("dta-latest-result-id", "data"), +) +def toggle_literature_compare_button(result_id): + return not bool(result_id) + + +@callback( + Output("dta-literature-output", "children"), + Output("dta-literature-status", "children"), + Input("dta-literature-compare-btn", "n_clicks"), + State("project-id", "data"), + State("dta-latest-result-id", "data"), + State("dta-literature-max-claims", "value"), + State("dta-literature-persist", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def compare_dta_literature(n_clicks, project_id, result_id, max_claims, persist_values, locale_data): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + if not project_id or not result_id: + msg = literature_t( + loc, + "dash.analysis.dta.literature.missing_result", + "Run a DTA analysis first.", + ) + return dash.no_update, dbc.Alert(msg, color="warning", className="py-1 small") + + claims_limit = coerce_literature_max_claims(max_claims, default=3) + persist = bool(persist_values) and "persist" in (persist_values or []) + + from dash_app.api_client import literature_compare + + try: + payload = literature_compare( + project_id, + result_id, + max_claims=claims_limit, + persist=persist, + ) + except Exception as exc: + err = dbc.Alert( + literature_t( + loc, + "dash.analysis.dta.literature.error", + "Literature compare failed: {error}", + ).replace("{error}", str(exc)), + color="danger", + className="py-1 small", + ) + return dash.no_update, err + + _prefix = "dash.analysis.dta.literature" + return ( + render_literature_output( + payload, + loc, + i18n_prefix=_prefix, + evidence_preview_limit=LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + alternative_preview_limit=LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + ), + literature_compare_status_alert(payload, loc, i18n_prefix=_prefix), + ) + + +def _dta_fetch_figure_preview_data_urls(project_id: str, result_id: str, figure_artifacts: dict) -> dict[str, str]: + from dash_app.api_client import fetch_result_figure_png + + out: dict[str, str] = {} + for label in ordered_figure_preview_keys(figure_artifacts)[:FIGURE_ARTIFACT_PREVIEW_TILES]: + try: + raw = fetch_result_figure_png(project_id, result_id, label, max_edge=FIGURE_ARTIFACT_PREVIEW_MAX_EDGE) + out[label] = "data:image/png;base64," + base64.standard_b64encode(bytes(raw)).decode("ascii") if raw else "" + except Exception: + out[label] = "" + return out + + +@callback( + Output("dta-figure-save-snapshot-btn", "children"), + Output("dta-figure-use-report-btn", "children"), + Output("dta-figure-artifacts-summary", "children"), + Input("ui-locale", "data"), +) +def render_dta_figure_artifact_button_labels(locale_data): + return figure_artifact_button_labels(_loc(locale_data)) + + +@callback( + Output("dta-figure-save-snapshot-btn", "disabled"), + Output("dta-figure-use-report-btn", "disabled"), + Input("dta-latest-result-id", "data"), +) +def toggle_dta_figure_artifact_buttons(result_id): + disabled = not bool(result_id) + return disabled, disabled + + +@callback( + Output("dta-result-figure-artifacts", "children"), + Input("dta-latest-result-id", "data"), + Input("dta-figure-artifact-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def refresh_dta_figure_artifacts_panel(result_id, _artifact_refresh, locale_data, project_id): + loc = _loc(locale_data) + if not result_id or not project_id: + return "" + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + return "" + artifacts = detail.get("figure_artifacts") if isinstance(detail.get("figure_artifacts"), dict) else {} + previews = _dta_fetch_figure_preview_data_urls(project_id, result_id, artifacts) if ordered_figure_preview_keys(artifacts) else None + return build_figure_artifacts_panel(artifacts, loc, previews=previews) + + +@callback( + Output("dta-figure-artifact-status", "children"), + Output("dta-figure-artifact-refresh", "data"), + Input("dta-figure-save-snapshot-btn", "n_clicks"), + Input("dta-figure-use-report-btn", "n_clicks"), + Input("dta-latest-result-id", "data"), + State("project-id", "data"), + State("dta-result-figure", "children"), + State("ui-locale", "data"), + State("dta-figure-artifact-refresh", "data"), + prevent_initial_call=True, +) +def dta_figure_snapshot_or_report_figure(_snap_clicks, _report_clicks, latest_result_id, project_id, figure_children, locale_data, refresh_value): + loc = _loc(locale_data) + triggered_id = getattr(dash.callback_context, "triggered_id", None) + if triggered_id == "dta-latest-result-id": + return "", dash.no_update + action = figure_action_from_trigger( + triggered_id, + snapshot_button_id="dta-figure-save-snapshot-btn", + report_button_id="dta-figure-use-report-btn", + ) + if action is None: + raise dash.exceptions.PreventUpdate + if not project_id or not latest_result_id: + return ( + figure_action_status_alert(loc, action=action, status="missing", reason="missing_project_or_result", class_prefix="dta"), + dash.no_update, + ) + + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, latest_result_id) + except Exception as exc: + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(exc), class_prefix="dta"), + dash.no_update, + ) + result_meta = detail.get("result", {}) or {} + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + meta = figure_action_metadata( + action, + analysis_type="DTA", + dataset_key=result_meta.get("dataset_key"), + result_id=latest_result_id, + snapshot_stamp=stamp, + ) + outcome = register_result_figure_from_layout_children( + figure_children=figure_children, + project_id=project_id, + result_id=latest_result_id, + label=str(meta.get("label") or ""), + replace=bool(meta.get("replace")), + ) + if outcome.get("status") == "ok": + key = str(outcome.get("figure_key") or meta.get("label") or "") + return ( + figure_action_status_alert(loc, action=action, status="ok", figure_key=key, class_prefix="dta"), + (refresh_value or 0) + 1, + ) + if outcome.get("status") == "error": + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(outcome.get("reason") or ""), class_prefix="dta"), + dash.no_update, + ) + return ( + figure_action_status_alert(loc, action=action, status="skipped", reason=str(outcome.get("reason") or ""), class_prefix="dta"), + dash.no_update, + ) + + +def _capture_dta_figure_png( + project_id: str, + dataset_key: str, + sample_name: str, + peak_rows: list, + ui_theme: str | None, + loc: str, + view_mode: str = "result", +) -> bytes | None: + """Build the DTA figure as PNG bytes; return None on any failure.""" + fig = _build_dta_go_figure(project_id, dataset_key, sample_name, peak_rows, ui_theme, loc, view_mode=view_mode) + if fig is None: + return None + png_bytes, _render_meta = render_plotly_figure_png(fig) + return png_bytes + + +@callback( + Output("dta-figure-captured", "data"), + Input("dta-latest-result-id", "data"), + State("project-id", "data"), + State("dta-figure-captured", "data"), + State("ui-theme", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def capture_dta_figure(result_id, project_id, captured, ui_theme, locale_data): + """Render a PNG of the saved DTA figure and register it with the backend. + + Fires once per saved ``result_id`` (dedup via ``dta-figure-captured`` store). + Failures degrade silently: the Report Center simply won't have a figure. + """ + if not result_id or not project_id: + raise dash.exceptions.PreventUpdate + + captured = dict(captured or {}) + if captured.get(result_id): + raise dash.exceptions.PreventUpdate + + loc = _loc(locale_data) + + from dash_app.api_client import ( + register_result_figure, + workspace_dataset_detail, + workspace_result_detail, + ) + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + captured[result_id] = {"status": "error", "stage": "detail"} + return captured + + result_meta = detail.get("result", {}) or {} + summary = detail.get("summary", {}) or {} + dataset_key = result_meta.get("dataset_key") + rows = _event_rows(detail.get("rows") or detail.get("rows_preview") or []) + + if not dataset_key: + captured[result_id] = {"status": "skipped", "reason": "missing_dataset_key"} + return captured + + dataset_detail: dict = {} + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + sample_name = _resolve_dta_sample_name(summary, result_meta, dataset_detail, locale_data=locale_data) + + png_bytes = _capture_dta_figure_png(project_id, dataset_key, sample_name, rows, ui_theme, loc, view_mode="result") + if not png_bytes: + captured[result_id] = {"status": "skipped", "reason": "render_failed"} + return captured + + label = f"DTA Analysis - {dataset_key}" + try: + register_result_figure(project_id, result_id, png_bytes, label=label, replace=True) + except Exception: + captured[result_id] = {"status": "error", "stage": "register", "label": label} + return captured + + captured[result_id] = {"status": "ok", "label": label} + return captured diff --git a/dash_app/pages/export.py b/dash_app/pages/export.py new file mode 100644 index 00000000..32409790 --- /dev/null +++ b/dash_app/pages/export.py @@ -0,0 +1,1084 @@ +"""Report center page -- parity-focused exports and branding.""" + +from __future__ import annotations + +import base64 +import io +import json +import zipfile + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import pandas as pd + +from core.batch_runner import filter_batch_summary_rows, normalize_batch_summary_rows, summarize_batch_outcomes +from core.report_generator import pdf_export_available +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.page_guidance import ( + guidance_block, + next_step_block, + prereq_or_empty_help, + typical_workflow_block, +) +from utils.i18n import normalize_ui_locale, translate_ui +from utils.license_manager import APP_VERSION, commercial_mode_enabled, license_allows_write, load_license_state + +dash.register_page(__name__, path="/export", title="Export - MaterialScope") + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _write_enabled() -> bool: + return license_allows_write(load_license_state(app_version=APP_VERSION)) + + +def _pending_logo_label(loc: str, name: str) -> str: + if str(loc).lower().startswith("tr"): + return f"Seçilen logo (kaydedilmedi): {name}" + return f"Selected logo (not saved yet): {name}" + + +def _invalid_logo_label(loc: str) -> str: + if str(loc).lower().startswith("tr"): + return "Seçilen dosya önizlenemedi. Lütfen geçerli bir görsel yükleyin." + return "Selected file could not be previewed. Please upload a valid image." + + +def _batch_summary_display_rows(filtered_rows: list[dict], loc: str) -> tuple[list[dict], list[str]]: + if not filtered_rows: + return [], [] + df = pd.DataFrame(filtered_rows) + preferred = [ + "dataset_key", + "sample_name", + "workflow_template", + "execution_status", + "validation_status", + "result_id", + "error_id", + "failure_reason", + ] + available = [column for column in preferred if column in df.columns] + if not available: + keys = list(filtered_rows[0].keys())[:8] + return filtered_rows, keys + df = df[available].copy() + rename_map = { + "dataset_key": translate_ui(loc, "dash.export.batch_col_run"), + "sample_name": translate_ui(loc, "dash.export.batch_col_sample"), + "workflow_template": translate_ui(loc, "dash.export.batch_col_template"), + "execution_status": translate_ui(loc, "dash.export.batch_col_execution"), + "validation_status": translate_ui(loc, "dash.export.batch_col_validation"), + "result_id": translate_ui(loc, "dash.export.batch_col_result_id"), + "error_id": translate_ui(loc, "dash.export.batch_col_error_id"), + "failure_reason": translate_ui(loc, "dash.export.batch_col_reason"), + } + df.rename(columns={k: rename_map[k] for k in available if k in rename_map}, inplace=True) + records = df.to_dict("records") + return records, list(df.columns) + + +def _build_export_workbench(loc: str) -> dbc.Card: + report_format_options = [{"label": translate_ui(loc, "dash.export.label_docx"), "value": "docx"}] + if pdf_export_available(): + report_format_options.append({"label": translate_ui(loc, "dash.export.label_pdf"), "value": "pdf"}) + return dbc.Card( + dbc.CardBody( + [ + html.Div(id="export-read-only-banner", className="mb-2"), + dbc.Tabs( + [ + dbc.Tab( + [ + html.Div( + [ + html.H5( + translate_ui(loc, "dash.export.section_raw_data"), + className="mt-3 mb-3", + ), + dbc.Select( + id="data-export-format", + options=[ + {"label": translate_ui(loc, "dash.export.label_csv"), "value": "csv"}, + {"label": translate_ui(loc, "dash.export.label_xlsx"), "value": "xlsx"}, + ], + value="xlsx", + ), + dbc.Label(translate_ui(loc, "dash.export.label_select_datasets"), className="mt-3"), + html.Div( + id="data-export-datasets-shell", + children=dcc.Dropdown( + id="data-export-datasets", + multi=True, + className="ta-dropdown", + ), + ), + dbc.Button( + translate_ui(loc, "dash.export.btn_prepare_data"), + id="prepare-data-export-btn", + color="primary", + className="mt-3", + ), + ] + ) + ], + label=translate_ui(loc, "dash.export.tab_export_data"), + ), + dbc.Tab( + [ + html.Div( + [ + html.H5( + translate_ui(loc, "dash.export.section_results"), + className="mt-3 mb-3", + ), + dbc.Select( + id="result-export-format", + options=[ + {"label": translate_ui(loc, "dash.export.label_csv"), "value": "csv"}, + {"label": translate_ui(loc, "dash.export.label_xlsx"), "value": "xlsx"}, + ], + value="csv", + ), + dbc.Label(translate_ui(loc, "dash.export.label_select_results"), className="mt-3"), + html.Div( + id="result-export-results-shell", + children=dcc.Dropdown( + id="result-export-results", + multi=True, + className="ta-dropdown", + ), + ), + dbc.Button( + translate_ui(loc, "dash.export.btn_prepare_results"), + id="prepare-result-export-btn", + color="primary", + className="mt-3", + ), + ] + ) + ], + label=translate_ui(loc, "dash.export.tab_export_results"), + ), + dbc.Tab( + [ + html.Div( + [ + html.H5( + translate_ui(loc, "dash.export.section_report"), + className="mt-3 mb-3", + ), + dbc.Select( + id="report-export-format", + options=report_format_options, + value="docx", + ), + html.Div( + translate_ui(loc, "dash.export.pdf_requires_reportlab") + if not pdf_export_available() + else "", + id="report-pdf-unavailable-hint", + className=( + "small text-muted mt-2" if not pdf_export_available() else "d-none" + ), + ), + dbc.Checkbox(id="report-include-figures", value=True, className="mt-3"), + dbc.Label( + translate_ui(loc, "dash.export.label_include_figures"), + html_for="report-include-figures", + className="ms-2", + ), + dbc.Label( + translate_ui(loc, "dash.export.label_select_results"), + className="mt-3 d-block", + ), + html.Div( + id="report-export-results-shell", + children=dcc.Dropdown( + id="report-export-results", + multi=True, + className="ta-dropdown", + ), + ), + dbc.Button( + translate_ui(loc, "dash.export.btn_prepare_report"), + id="prepare-report-export-btn", + color="primary", + className="mt-3", + ), + ] + ) + ], + label=translate_ui(loc, "dash.export.tab_generate_report"), + ), + ] + ), + html.Div(id="export-status", className="mt-3"), + dcc.Download(id="export-download"), + dcc.Download(id="support-snapshot-download"), + ] + ), + className="mb-4", + ) + + +def _build_branding_card(loc: str) -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(translate_ui(loc, "dash.export.branding_title"), className="mb-3"), + dbc.Label(translate_ui(loc, "dash.export.branding_report_title")), + dbc.Input(id="branding-report-title", type="text"), + dbc.Label(translate_ui(loc, "dash.export.branding_company"), className="mt-3"), + dbc.Input(id="branding-company-name", type="text"), + dbc.Label(translate_ui(loc, "dash.export.branding_lab"), className="mt-3"), + dbc.Input(id="branding-lab-name", type="text"), + dbc.Label(translate_ui(loc, "dash.export.branding_analyst"), className="mt-3"), + dbc.Input(id="branding-analyst-name", type="text"), + dbc.Label(translate_ui(loc, "dash.export.branding_notes"), className="mt-3"), + dbc.Textarea(id="branding-report-notes", style={"height": "140px"}), + dbc.Label(translate_ui(loc, "dash.export.branding_logo"), className="mt-3"), + dcc.Upload( + id="branding-logo-upload", + children=html.Div( + [ + html.I(className="bi bi-image me-2"), + translate_ui(loc, "dash.export.branding_upload_logo"), + ], + className="text-center py-3", + ), + className="upload-zone", + ), + html.Div(id="branding-logo-selection", className="mt-2"), + dbc.Button( + translate_ui(loc, "dash.export.btn_save_branding"), + id="save-branding-btn", + color="primary", + className="w-100 mt-3", + ), + html.Div(id="branding-status", className="mt-3"), + html.Div(id="branding-logo-preview", className="mt-3"), + ] + ), + className="mb-4", + ) + + +layout = html.Div( + [ + dcc.Store(id="report-refresh", data=0), + dcc.Store(id="export-batch-rows-store", data=None), + dcc.Store(id="support-snapshot-bytes", data=None), + html.Div(id="export-hero-slot"), + html.Div(id="export-guidance-slot", className="mb-2"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Card(dbc.CardBody(html.Div(id="report-preview-panel")), className="mb-4"), + html.Div(id="export-workbench-slot"), + ], + md=8, + ), + dbc.Col([html.Div(id="export-branding-slot")], md=4), + ] + ), + ] +) + + +@callback( + Output("export-hero-slot", "children"), + Output("export-guidance-slot", "children"), + Output("export-workbench-slot", "children"), + Output("export-branding-slot", "children"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def render_export_locale_chrome(locale_data): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.export.title"), + translate_ui(loc, "dash.export.caption"), + badge=translate_ui(loc, "dash.export.badge"), + ) + guidance = html.Div( + [ + guidance_block( + translate_ui(loc, "dash.export.guidance_what_title"), + body=translate_ui(loc, "dash.export.guidance_what_body"), + ), + typical_workflow_block( + [ + translate_ui(loc, "dash.export.workflow_step1"), + translate_ui(loc, "dash.export.workflow_step2"), + translate_ui(loc, "dash.export.workflow_step3"), + ], + locale=loc, + ), + next_step_block(translate_ui(loc, "dash.export.next_step_body"), locale=loc), + ] + ) + return hero, guidance, _build_export_workbench(loc), _build_branding_card(loc) + + +@callback( + Output("data-export-datasets-shell", "children"), + Output("result-export-results-shell", "children"), + Output("report-export-results-shell", "children"), + Input("ui-theme", "data"), + State("data-export-datasets", "options"), + State("data-export-datasets", "value"), + State("result-export-results", "options"), + State("result-export-results", "value"), + State("report-export-results", "options"), + State("report-export-results", "value"), + prevent_initial_call=True, +) +def remount_export_dropdowns( + _ui_theme, + data_options, + data_value, + result_options, + result_value, + report_options, + report_value, +): + return ( + dcc.Dropdown( + id="data-export-datasets", + multi=True, + className="ta-dropdown", + options=data_options or [], + value=data_value or [], + ), + dcc.Dropdown( + id="result-export-results", + multi=True, + className="ta-dropdown", + options=result_options or [], + value=result_value or [], + ), + dcc.Dropdown( + id="report-export-results", + multi=True, + className="ta-dropdown", + options=report_options or [], + value=report_value or [], + ), + ) + + +@callback( + Output("report-preview-panel", "children"), + Output("data-export-datasets", "options"), + Output("data-export-datasets", "value"), + Output("result-export-results", "options"), + Output("result-export-results", "value"), + Output("report-export-results", "options"), + Output("report-export-results", "value"), + Output("export-batch-rows-store", "data"), + Input("project-id", "data"), + Input("report-refresh", "data"), + Input("workspace-refresh", "data"), + Input("ui-locale", "data"), +) +def load_report_center(project_id, _refresh, _global_refresh, locale_data): + loc = _loc(locale_data) + default_title = translate_ui(loc, "dash.export.default_report_title") + if not project_id: + empty = prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_workspace_body"), + title=translate_ui(loc, "dash.export.prereq_workspace_title"), + locale=loc, + ) + return empty, [], [], [], [], [], [], None + + from dash_app.api_client import export_preparation, workspace_datasets + + try: + prep = export_preparation(project_id) + datasets_payload = workspace_datasets(project_id) + except Exception as exc: + error = html.P( + [translate_ui(loc, "dash.export.error_prefix"), " ", str(exc)], + className="text-danger", + ) + return error, [], [], [], [], [], [], None + + results = prep.get("exportable_results", []) + skipped = prep.get("skipped_record_issues", []) + branding = prep.get("branding", {}) + compare_workspace = prep.get("compare_workspace") or {} + + metrics = dbc.Row( + [ + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small(translate_ui(loc, "dash.export.metric_datasets"), className="text-muted text-uppercase"), + html.H4(str(prep.get("summary", {}).get("dataset_count", 0))), + ] + ) + ), + md=3, + ), + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small(translate_ui(loc, "dash.export.metric_stable"), className="text-muted text-uppercase"), + html.H4(str(sum(1 for item in results if item.get("status") == "stable"))), + ] + ) + ), + md=3, + ), + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small(translate_ui(loc, "dash.export.metric_preview"), className="text-muted text-uppercase"), + html.H4(str(sum(1 for item in results if item.get("status") == "experimental"))), + ] + ) + ), + md=3, + ), + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small(translate_ui(loc, "dash.export.metric_outputs"), className="text-muted text-uppercase"), + html.H4(str(len(prep.get("supported_outputs", [])))), + ] + ) + ), + md=3, + ), + ], + className="g-3 mb-3", + ) + result_rows = [ + { + "id": item.get("id"), + "analysis_type": item.get("analysis_type"), + "status": item.get("status"), + "dataset_key": item.get("dataset_key"), + "saved_at_utc": item.get("saved_at_utc"), + } + for item in results + ] + preview_children = [ + metrics, + html.H5(translate_ui(loc, "dash.export.preview_branding"), className="mb-2"), + html.Ul( + [ + html.Li( + translate_ui( + loc, + "dash.export.preview_li_report_title", + value=branding.get("report_title") or default_title, + ) + ), + html.Li( + translate_ui( + loc, + "dash.export.preview_li_company", + value=branding.get("company_name") or translate_ui(loc, "dash.export.not_set"), + ) + ), + html.Li( + translate_ui( + loc, + "dash.export.preview_li_lab", + value=branding.get("lab_name") or translate_ui(loc, "dash.export.not_set"), + ) + ), + html.Li( + translate_ui( + loc, + "dash.export.preview_li_analyst", + value=branding.get("analyst_name") or translate_ui(loc, "dash.export.not_set"), + ) + ), + ], + className="mb-3", + ), + html.H5(translate_ui(loc, "dash.export.preview_compare"), className="mb-2"), + html.P( + translate_ui( + loc, + "dash.export.analysis_type", + value=compare_workspace.get("analysis_type") or translate_ui(loc, "dash.export.na"), + ), + className="mb-1", + ), + html.P( + translate_ui( + loc, + "dash.export.selected_runs", + value=", ".join(compare_workspace.get("selected_datasets") or []) or translate_ui(loc, "dash.export.none"), + ), + className="mb-1", + ), + html.P(compare_workspace.get("notes") or translate_ui(loc, "dash.export.no_compare_notes"), className="text-muted"), + ] + batch_norm = normalize_batch_summary_rows(compare_workspace.get("batch_summary") or []) + batch_store_data = json.loads(json.dumps(batch_norm, default=str)) if batch_norm else None + if batch_norm: + totals = summarize_batch_outcomes(batch_norm) + preview_children.extend( + [ + html.H5(translate_ui(loc, "dash.export.preview_batch_summary"), className="mt-3 mb-2"), + dbc.Row( + [ + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small( + translate_ui(loc, "dash.export.batch_metric_total"), + className="text-muted text-uppercase", + ), + html.H4(str(totals["total"])), + ] + ) + ), + md=3, + ), + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small( + translate_ui(loc, "dash.export.batch_metric_saved"), + className="text-muted text-uppercase", + ), + html.H4(str(totals["saved"])), + ] + ) + ), + md=3, + ), + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small( + translate_ui(loc, "dash.export.batch_metric_blocked"), + className="text-muted text-uppercase", + ), + html.H4(str(totals["blocked"])), + ] + ) + ), + md=3, + ), + dbc.Col( + dbc.Card( + dbc.CardBody( + [ + html.Small( + translate_ui(loc, "dash.export.batch_metric_failed"), + className="text-muted text-uppercase", + ), + html.H4(str(totals["failed"])), + ] + ) + ), + md=3, + ), + ], + className="g-3 mb-2", + ), + dbc.Label(translate_ui(loc, "dash.export.batch_filter_label"), className="d-block"), + dbc.Select( + id="export-batch-filter", + options=[ + {"label": translate_ui(loc, "dash.export.batch_filter_all"), "value": "all"}, + {"label": translate_ui(loc, "dash.export.batch_filter_saved"), "value": "saved"}, + {"label": translate_ui(loc, "dash.export.batch_filter_blocked"), "value": "blocked"}, + {"label": translate_ui(loc, "dash.export.batch_filter_failed"), "value": "failed"}, + ], + value="all", + className="mb-2", + ), + html.Div(id="export-batch-table-slot"), + ] + ) + else: + preview_children.append( + html.Div( + [ + dbc.Select( + id="export-batch-filter", + options=[{"label": "-", "value": "all"}], + value="all", + className="d-none", + disabled=True, + ), + html.Div(id="export-batch-table-slot", className="d-none"), + ], + className="d-none", + ) + ) + if result_rows: + preview_children.extend( + [ + html.H5(translate_ui(loc, "dash.export.preview_report_pkg"), className="mt-3 mb-2"), + dataset_table(result_rows, ["id", "analysis_type", "status", "dataset_key", "saved_at_utc"], table_id="report-package-table"), + ] + ) + else: + preview_children.append( + prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_results_body"), + tone="secondary", + title=translate_ui(loc, "dash.export.prereq_results_title"), + locale=loc, + ) + ) + if skipped: + preview_children.append( + dbc.Alert( + [ + html.Div(translate_ui(loc, "dash.export.records_incomplete")), + html.Ul([html.Li(issue) for issue in skipped]), + ], + color="warning", + ) + ) + + preview_children.extend( + [ + html.Hr(className="my-3"), + html.H5(translate_ui(loc, "dash.export.support_diagnostics_title"), className="mb-2"), + html.P(translate_ui(loc, "dash.export.support_diagnostics_caption"), className="small text-muted"), + dbc.Button( + translate_ui(loc, "dash.export.btn_prepare_support_snapshot"), + id="prepare-support-snapshot-btn", + color="secondary", + outline=True, + className="me-2 mt-2", + ), + dbc.Button( + translate_ui(loc, "dash.export.btn_download_support_snapshot"), + id="download-support-snapshot-btn", + color="secondary", + className="mt-2", + ), + html.Div(id="support-snapshot-status", className="mt-2"), + ] + ) + + dataset_options = [{"label": item.get("display_name", item.get("key")), "value": item.get("key")} for item in datasets_payload.get("datasets", [])] + dataset_values = [item["value"] for item in dataset_options] + result_options = [{"label": f"{item.get('analysis_type')} | {item.get('id')}", "value": item.get("id")} for item in results] + result_values = [item["value"] for item in result_options] + return ( + html.Div(preview_children), + dataset_options, + dataset_values, + result_options, + result_values, + result_options, + result_values, + batch_store_data, + ) + + +@callback( + Output("support-snapshot-bytes", "data"), + Input("project-id", "data"), + Input("report-refresh", "data"), + Input("workspace-refresh", "data"), +) +def reset_support_snapshot_store(_project_id, _refresh, _global_refresh): + return None + + +@callback( + Output("export-read-only-banner", "children"), + Output("prepare-data-export-btn", "disabled"), + Output("prepare-result-export-btn", "disabled"), + Output("prepare-report-export-btn", "disabled"), + Output("save-branding-btn", "disabled"), + Output("prepare-support-snapshot-btn", "disabled"), + Output("download-support-snapshot-btn", "disabled"), + Input("project-id", "data"), + Input("report-refresh", "data"), + Input("workspace-refresh", "data"), + Input("ui-locale", "data"), + Input("support-snapshot-bytes", "data"), +) +def sync_export_read_only_and_controls(_project_id, _refresh, _global_refresh, locale_data, snapshot_b64): + loc = _loc(locale_data) + write_ok = _write_enabled() + read_only = commercial_mode_enabled() and not write_ok + banner = ( + dbc.Alert(translate_ui(loc, "dash.export.read_only_warning"), color="warning", className="mb-0") + if read_only + else "" + ) + dis = not write_ok + dl_disabled = dis or not snapshot_b64 + return banner, dis, dis, dis, dis, dis, dl_disabled + + +@callback( + Output("export-batch-table-slot", "children"), + Input("export-batch-filter", "value"), + Input("export-batch-rows-store", "data"), + Input("ui-locale", "data"), +) +def render_export_batch_table(filter_value, rows, locale_data): + loc = _loc(locale_data) + if not rows: + return "" + filtered = filter_batch_summary_rows(rows, execution_status=filter_value or "all") + display_rows, columns = _batch_summary_display_rows(filtered, loc) + if not display_rows: + return html.P(translate_ui(loc, "dash.export.none"), className="text-muted small") + return dataset_table(display_rows, columns, table_id="export-batch-summary-table") + + +@callback( + Output("support-snapshot-status", "children"), + Output("support-snapshot-bytes", "data", allow_duplicate=True), + Input("prepare-support-snapshot-btn", "n_clicks"), + State("project-id", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def prepare_support_snapshot(n_clicks, project_id, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id: + raise dash.exceptions.PreventUpdate + if not _write_enabled(): + return dbc.Alert(translate_ui(loc, "dash.export.read_only_action_blocked"), color="warning"), dash.no_update + from dash_app.api_client import export_support_snapshot + + try: + raw = export_support_snapshot(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.export.support_snapshot_fail", error=str(exc)), color="danger"), dash.no_update + return ( + dbc.Alert(translate_ui(loc, "dash.export.support_snapshot_ready"), color="success"), + base64.b64encode(raw).decode("ascii"), + ) + + +@callback( + Output("support-snapshot-download", "data"), + Input("download-support-snapshot-btn", "n_clicks"), + State("support-snapshot-bytes", "data"), + prevent_initial_call=True, +) +def download_support_snapshot(n_clicks, snapshot_b64): + if not n_clicks or not snapshot_b64: + raise dash.exceptions.PreventUpdate + raw = base64.b64decode(snapshot_b64.encode("ascii")) + return dcc.send_bytes(raw, "materialscope_support_snapshot.json") + + +@callback( + Output("branding-report-title", "value"), + Output("branding-company-name", "value"), + Output("branding-lab-name", "value"), + Output("branding-analyst-name", "value"), + Output("branding-report-notes", "value"), + Output("branding-logo-preview", "children"), + Input("project-id", "data"), + Input("report-refresh", "data"), + Input("workspace-refresh", "data"), + Input("ui-locale", "data"), +) +def load_branding(project_id, _refresh, _global_refresh, locale_data): + loc = _loc(locale_data) + default_title = translate_ui(loc, "dash.export.default_report_title") + if not project_id: + return default_title, "", "", "", "", "" + from dash_app.api_client import workspace_branding + + payload = workspace_branding(project_id).get("branding", {}) + logo_b64 = payload.get("logo_base64") + logo_preview = "" + if logo_b64: + logo_preview = html.Div( + [ + html.Img(src=f"data:image/png;base64,{logo_b64}", style={"maxWidth": "100%", "maxHeight": "180px"}), + html.Div( + translate_ui( + loc, + "dash.export.logo_current", + name=payload.get("logo_name") or "branding_logo", + ), + className="small text-muted mt-2", + ), + ] + ) + return ( + payload.get("report_title") or default_title, + payload.get("company_name") or "", + payload.get("lab_name") or "", + payload.get("analyst_name") or "", + payload.get("report_notes") or "", + logo_preview, + ) + + +@callback( + Output("branding-logo-selection", "children"), + Input("branding-logo-upload", "contents"), + State("branding-logo-upload", "filename"), + State("ui-locale", "data"), +) +def preview_branding_logo_selection(logo_contents, logo_name, locale_data): + loc = _loc(locale_data) + if not logo_contents: + return "" + + label = (logo_name or "branding_logo").strip() or "branding_logo" + if not str(logo_contents).startswith("data:image/"): + return dbc.Alert(_invalid_logo_label(loc), color="warning", className="py-2 mb-0") + + return html.Div( + [ + html.Img(src=logo_contents, style={"maxWidth": "100%", "maxHeight": "180px"}), + html.Div(_pending_logo_label(loc, label), className="small text-muted mt-2"), + ], + className="border rounded p-2 bg-light-subtle", + ) + + +@callback( + Output("branding-status", "children"), + Output("report-refresh", "data", allow_duplicate=True), + Input("save-branding-btn", "n_clicks"), + State("project-id", "data"), + State("branding-report-title", "value"), + State("branding-company-name", "value"), + State("branding-lab-name", "value"), + State("branding-analyst-name", "value"), + State("branding-report-notes", "value"), + State("branding-logo-upload", "contents"), + State("branding-logo-upload", "filename"), + State("report-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_branding( + n_clicks, + project_id, + report_title, + company_name, + lab_name, + analyst_name, + report_notes, + logo_contents, + logo_name, + refresh_value, + locale_data, +): + loc = _loc(locale_data) + if not n_clicks or not project_id: + raise dash.exceptions.PreventUpdate + if not _write_enabled(): + return dbc.Alert(translate_ui(loc, "dash.export.read_only_action_blocked"), color="warning"), dash.no_update + from dash_app.api_client import update_workspace_branding + + payload = { + "report_title": report_title, + "company_name": company_name, + "lab_name": lab_name, + "analyst_name": analyst_name, + "report_notes": report_notes, + } + if logo_contents: + _, content_string = logo_contents.split(",", 1) + payload["logo_base64"] = content_string + payload["logo_name"] = logo_name + try: + update_workspace_branding(project_id, payload) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.export.branding_save_fail", error=str(exc)), color="danger"), dash.no_update + return dbc.Alert(translate_ui(loc, "dash.export.branding_save_ok"), color="success"), int(refresh_value or 0) + 1 + + +@callback( + Output("export-status", "children", allow_duplicate=True), + Output("export-download", "data", allow_duplicate=True), + Input("prepare-data-export-btn", "n_clicks"), + State("project-id", "data"), + State("data-export-format", "value"), + State("data-export-datasets", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def export_data_files(n_clicks, project_id, export_format, dataset_keys, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id: + if n_clicks and not project_id: + return prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_workspace_data"), + title=translate_ui(loc, "dash.export.prereq_workspace_title"), + locale=loc, + ), dash.no_update + raise dash.exceptions.PreventUpdate + if not _write_enabled(): + return dbc.Alert(translate_ui(loc, "dash.export.read_only_action_blocked"), color="warning"), dash.no_update + if not dataset_keys: + return prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_select_datasets"), + title=translate_ui(loc, "dash.export.prereq_title_select_datasets"), + locale=loc, + ), dash.no_update + + from dash_app.api_client import workspace_dataset_data + + try: + payloads = [workspace_dataset_data(project_id, dataset_key) for dataset_key in dataset_keys] + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.export.data_export_fail", error=str(exc)), color="danger"), dash.no_update + + if export_format == "csv": + if len(payloads) == 1: + payload = payloads[0] + frame = pd.DataFrame(payload.get("rows", [])) + return ( + dbc.Alert(translate_ui(loc, "dash.export.data_csv_ready", key=payload["dataset_key"]), color="success"), + dcc.send_bytes(frame.to_csv(index=False).encode("utf-8"), f"{payload['dataset_key']}_export.csv"), + ) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for payload in payloads: + frame = pd.DataFrame(payload.get("rows", [])) + archive.writestr(f"{payload['dataset_key']}_export.csv", frame.to_csv(index=False)) + buffer.seek(0) + return ( + dbc.Alert(translate_ui(loc, "dash.export.data_zip_ready", count=len(payloads)), color="success"), + dcc.send_bytes(buffer.getvalue(), "materialscope_data_exports.zip"), + ) + + buffer = io.BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + for payload in payloads: + frame = pd.DataFrame(payload.get("rows", [])) + sheet_name = str(payload["dataset_key"])[:31] + frame.to_excel(writer, sheet_name=sheet_name, index=False) + buffer.seek(0) + return ( + dbc.Alert(translate_ui(loc, "dash.export.data_xlsx_ready", count=len(payloads)), color="success"), + dcc.send_bytes(buffer.getvalue(), "materialscope_data.xlsx"), + ) + + +@callback( + Output("export-status", "children", allow_duplicate=True), + Output("export-download", "data", allow_duplicate=True), + Input("prepare-result-export-btn", "n_clicks"), + State("project-id", "data"), + State("result-export-format", "value"), + State("result-export-results", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def export_result_files(n_clicks, project_id, export_format, selected_result_ids, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id: + if n_clicks and not project_id: + return prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_workspace_results"), + title=translate_ui(loc, "dash.export.prereq_workspace_title"), + locale=loc, + ), dash.no_update + raise dash.exceptions.PreventUpdate + if not _write_enabled(): + return dbc.Alert(translate_ui(loc, "dash.export.read_only_action_blocked"), color="warning"), dash.no_update + if not selected_result_ids: + return prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_select_results"), + title=translate_ui(loc, "dash.export.prereq_title_select_results"), + locale=loc, + ), dash.no_update + from dash_app.api_client import export_results_csv, export_results_xlsx + + try: + result = export_results_csv(project_id, selected_result_ids) if export_format == "csv" else export_results_xlsx(project_id, selected_result_ids) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.export.result_export_fail", error=str(exc)), color="danger"), dash.no_update + artifact = base64.b64decode(result["artifact_base64"]) + return ( + dbc.Alert( + translate_ui( + loc, + "dash.export.result_ready", + otype=result["output_type"], + count=len(result.get("included_result_ids", [])), + ), + color="success", + ), + dcc.send_bytes(artifact, result["file_name"]), + ) + + +@callback( + Output("export-status", "children"), + Output("export-download", "data"), + Input("prepare-report-export-btn", "n_clicks"), + State("project-id", "data"), + State("report-export-format", "value"), + State("report-export-results", "value"), + State("report-include-figures", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def export_report_files(n_clicks, project_id, export_format, selected_result_ids, include_figures, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id: + if n_clicks and not project_id: + return prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_workspace_report"), + title=translate_ui(loc, "dash.export.prereq_workspace_title"), + locale=loc, + ), dash.no_update + raise dash.exceptions.PreventUpdate + if not _write_enabled(): + return dbc.Alert(translate_ui(loc, "dash.export.read_only_action_blocked"), color="warning"), dash.no_update + if not selected_result_ids: + return prereq_or_empty_help( + translate_ui(loc, "dash.export.prereq_select_report_results"), + title=translate_ui(loc, "dash.export.prereq_title_select_results"), + locale=loc, + ), dash.no_update + if export_format == "pdf" and not pdf_export_available(): + return dbc.Alert(translate_ui(loc, "dash.export.pdf_requires_reportlab"), color="warning"), dash.no_update + from dash_app.api_client import export_report_docx, export_report_pdf + + try: + result = ( + export_report_docx(project_id, selected_result_ids, include_figures=bool(include_figures)) + if export_format == "docx" + else export_report_pdf(project_id, selected_result_ids, include_figures=bool(include_figures)) + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.export.report_export_fail", error=str(exc)), color="danger"), dash.no_update + artifact = base64.b64decode(result["artifact_base64"]) + return ( + dbc.Alert( + translate_ui( + loc, + "dash.export.result_ready", + otype=result["output_type"], + count=len(result.get("included_result_ids", [])), + ), + color="success", + ), + dcc.send_bytes(artifact, result["file_name"]), + ) diff --git a/dash_app/pages/ftir.py b/dash_app/pages/ftir.py new file mode 100644 index 00000000..f36ad3fa --- /dev/null +++ b/dash_app/pages/ftir.py @@ -0,0 +1,2888 @@ +"""FTIR analysis page -- product-grade implementation aligned with other modality Dash analysis pages. + +Left column tabs: + - Setup: dataset, workflow template, workflow guide + - Processing: undo/redo/reset, presets, smoothing, baseline, normalization, + peak detection, similarity matching + - Run: execute analysis + +Right column results surface: + 1. analysis summary + 2. result metrics + 3. validation and quality + 4. main FTIR figure + 5. top-match hero summary + 6. key spectral peaks / feature cards + 7. full match table + 8. applied processing summary + 9. raw metadata + 10. literature compare + +User-visible labels for presets, processing, baseline window, validation, and library status are read +from ``dash.analysis.ftir.*`` keys in ``utils/i18n.py`` (not thermal/TGA copy). +""" + +from __future__ import annotations + +import base64 +import copy +import json +import math +from datetime import datetime, timezone +from typing import Any + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import plotly.graph_objects as go + +from dash_app.components.analysis_boilerplate import ( + build_collapsible_section, + build_load_saveas_preset_card, + build_processing_history_card, + build_split_raw_metadata_panel, + build_validation_quality_card, +) +from dash_app.components.analysis_page import ( + analysis_page_stores, + capture_result_figure_from_layout, + register_result_figure_from_layout_children, + dataset_selection_card, + dataset_selector_block, + eligible_datasets, + empty_result_msg, + execute_card, + interpret_run_result, + metrics_row, + no_data_figure_msg, + processing_details_section, + resolve_sample_name, + result_placeholder_card, + workflow_template_card, +) +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.figure_artifacts import ( + FIGURE_ARTIFACT_PREVIEW_MAX_EDGE, + FIGURE_ARTIFACT_PREVIEW_TILES, + build_figure_artifact_surface, + build_figure_artifacts_panel, + figure_action_from_trigger, + figure_action_metadata, + figure_action_status_alert, + figure_artifact_button_labels, + ordered_figure_preview_keys, +) +from dash_app.components.ftir_explore import ( + MAX_FTIR_UNDO_DEPTH, + append_undo_after_edit, + ftir_draft_processing_equal, + perform_redo, + perform_undo, +) +from dash_app.components.literature_compare_ui import ( + LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + build_literature_compare_card, + coerce_literature_max_claims, + literature_compare_status_alert, + literature_t, + render_literature_output, +) +from dash_app.components.processing_inputs import ( + coerce_float_non_negative as _coerce_float_non_negative, + coerce_float_positive as _coerce_float_positive, + coerce_int_positive as _coerce_int_positive, +) +from dash_app.components.spectral_explore import ( + build_spectral_raw_quality_panel, + compute_spectral_raw_quality_stats, + downsample_spectral_rows, +) +from dash_app.components.spectral_plot_settings import ( + build_plotly_config as build_spectral_plotly_config, + build_spectral_plot_settings_card, + normalize_spectral_plot_settings, + spectral_legend_layout, + spectral_plot_settings_chrome, + spectral_plot_settings_from_controls, +) +from dash_app.theme import PLOT_THEME, normalize_ui_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/ftir", title="FTIR Analysis - MaterialScope") + +_FTIR_TEMPLATE_IDS = ["ftir.general", "ftir.functional_groups"] +_FTIR_ELIGIBLE_TYPES = {"FTIR", "UNKNOWN"} + +_FTIR_PRESET_ANALYSIS_TYPE = "FTIR" +_FTIR_LITERATURE_PREFIX = "dash.analysis.ftir.literature" + +_FTIR_RESULT_CARD_ROLES = { + "context": "ms-result-context", + "hero": "ms-result-hero", + "support": "ms-result-support", + "secondary": "ms-result-secondary", +} + +_FTIR_USER_FACING_METADATA_KEYS: frozenset[str] = frozenset({ + "sample_name", + "display_name", + "instrument", + "vendor", + "file_name", + "source_data_hash", +}) + +_FTIR_SMOOTH_METHODS = frozenset({"savgol", "moving_average", "gaussian"}) +_FTIR_SMOOTHING_DEFAULTS: dict[str, dict[str, Any]] = { + "savgol": {"method": "savgol", "window_length": 11, "polyorder": 3}, + "moving_average": {"method": "moving_average", "window_length": 11}, + "gaussian": {"method": "gaussian", "sigma": 2.0}, +} + +_FTIR_BASELINE_METHODS = frozenset({"asls", "linear", "rubberband"}) +_FTIR_BASELINE_DEFAULTS: dict[str, dict[str, Any]] = { + "asls": {"method": "asls", "lam": 1e6, "p": 0.01, "region": None}, + "linear": {"method": "linear", "region": None}, + "rubberband": {"method": "rubberband", "region": None}, +} + +_FTIR_NORMALIZATION_MODES = frozenset({"vector", "max", "snv"}) +_FTIR_NORMALIZATION_DEFAULTS: dict[str, Any] = {"method": "vector"} + +_FTIR_SIMILARITY_METRICS = frozenset({"cosine", "pearson"}) +_FTIR_TEMPLATE_SIMILARITY_METRICS: dict[str, str] = { + "ftir.general": "cosine", + "ftir.functional_groups": "cosine", +} + +_FTIR_PEAK_DETECTION_DEFAULTS: dict[str, Any] = { + "prominence": 0.035, + "distance": 5, + "max_peaks": 12, +} + +_FTIR_SIMILARITY_MATCHING_DEFAULTS: dict[str, Any] = { + "metric": "cosine", + "top_n": 3, + "minimum_score": 0.45, +} + +_FTIR_MAX_PEAK_CARDS = 8 +_FTIR_TRUNCATE_PEAK_CARDS_WHEN = 9 + + +# --------------------------------------------------------------------------- +# Coercion helpers +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Processing draft model +# --------------------------------------------------------------------------- + + +def _default_ftir_similarity_metric(template_id: str | None = None) -> str: + token = str(template_id or "").strip().lower() + return _FTIR_TEMPLATE_SIMILARITY_METRICS.get(token, "cosine") + + +def _default_ftir_processing_draft(template_id: str | None = None) -> dict[str, Any]: + return { + "smoothing": copy.deepcopy(_FTIR_SMOOTHING_DEFAULTS["savgol"]), + "baseline": copy.deepcopy(_FTIR_BASELINE_DEFAULTS["asls"]), + "normalization": copy.deepcopy(_FTIR_NORMALIZATION_DEFAULTS), + "peak_detection": copy.deepcopy(_FTIR_PEAK_DETECTION_DEFAULTS), + "similarity_matching": { + **copy.deepcopy(_FTIR_SIMILARITY_MATCHING_DEFAULTS), + "metric": _default_ftir_similarity_metric(template_id), + }, + } + + +def _normalize_smoothing_values(method: str | None, window_length, polyorder, sigma) -> dict[str, Any]: + token = str(method or "savgol").strip().lower() + if token not in _FTIR_SMOOTH_METHODS: + token = "savgol" + if token == "savgol": + wl = _coerce_int_positive(window_length, default=11, minimum=5) + if wl % 2 == 0: + wl += 1 + po = _coerce_int_positive(polyorder, default=3, minimum=1) + po = min(po, max(wl - 2, 1)) + return {"method": "savgol", "window_length": wl, "polyorder": po} + if token == "moving_average": + wl = _coerce_int_positive(window_length, default=11, minimum=3) + if wl % 2 == 0: + wl += 1 + return {"method": "moving_average", "window_length": wl} + sg = _coerce_float_positive(sigma, default=2.0, minimum=0.1) + return {"method": "gaussian", "sigma": sg} + + +def _normalize_baseline_region(enabled, rmin, rmax) -> list[float] | None: + if not enabled: + return None + try: + lower = float(rmin) + upper = float(rmax) + except (TypeError, ValueError): + return None + if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper: + return None + return [lower, upper] + + +def _normalize_baseline_values(method: str | None, lam, p, region_enabled=None, region_min=None, region_max=None) -> dict[str, Any]: + token = str(method or "asls").strip().lower() + if token not in _FTIR_BASELINE_METHODS: + token = "asls" + region = _normalize_baseline_region(region_enabled, region_min, region_max) + if token == "asls": + lam_value = _coerce_float_positive(lam, default=1e6, minimum=1e-3) + p_value = _coerce_float_positive(p, default=0.01, minimum=1e-4) + p_value = min(p_value, 0.5) + return {"method": "asls", "lam": lam_value, "p": p_value, "region": region} + return {"method": token, "region": region} + + +def _normalize_normalization_values(method: str | None) -> dict[str, Any]: + token = str(method or "vector").strip().lower() + if token not in _FTIR_NORMALIZATION_MODES: + token = "vector" + return {"method": token} + + +def _normalize_peak_detection_values(prominence, distance, max_peaks) -> dict[str, Any]: + prom = _coerce_float_non_negative(prominence, default=0.035) + dist = _coerce_int_positive(distance, default=5, minimum=1) + mp = _coerce_int_positive(max_peaks, default=10, minimum=1) + return {"prominence": prom, "distance": dist, "max_peaks": mp} + + +def _normalize_similarity_matching_values(metric, top_n, minimum_score, *, template_id: str | None = None) -> dict[str, Any]: + metric_token = str(metric or "").strip().lower() + if metric_token not in _FTIR_SIMILARITY_METRICS: + metric_token = _default_ftir_similarity_metric(template_id) + tn = _coerce_int_positive(top_n, default=3, minimum=1) + ms = _coerce_float_non_negative(minimum_score, default=0.45) + return {"metric": metric_token, "top_n": tn, "minimum_score": ms} + + +def _normalize_ftir_processing_draft(draft: dict | None, *, template_id: str | None = None) -> dict[str, Any]: + d = dict(draft or {}) + sm = d.get("smoothing") + bl = d.get("baseline") + nm = d.get("normalization") + pk = d.get("peak_detection") + sim = d.get("similarity_matching") + + if isinstance(sm, dict): + sm = _normalize_smoothing_values(sm.get("method"), sm.get("window_length"), sm.get("polyorder"), sm.get("sigma")) + else: + sm = copy.deepcopy(_FTIR_SMOOTHING_DEFAULTS["savgol"]) + + if isinstance(bl, dict): + bl = _normalize_baseline_values(bl.get("method"), bl.get("lam"), bl.get("p"), bl.get("region") is not None, (bl.get("region") or [None, None])[0], (bl.get("region") or [None, None])[1]) + else: + bl = copy.deepcopy(_FTIR_BASELINE_DEFAULTS["asls"]) + + if isinstance(nm, dict): + nm = _normalize_normalization_values(nm.get("method")) + else: + nm = copy.deepcopy(_FTIR_NORMALIZATION_DEFAULTS) + + if isinstance(pk, dict): + pk = _normalize_peak_detection_values(pk.get("prominence"), pk.get("distance"), pk.get("max_peaks")) + else: + pk = copy.deepcopy(_FTIR_PEAK_DETECTION_DEFAULTS) + + if isinstance(sim, dict): + sim = _normalize_similarity_matching_values( + sim.get("metric"), + sim.get("top_n"), + sim.get("minimum_score"), + template_id=template_id, + ) + else: + sim = _normalize_similarity_matching_values(None, None, None, template_id=template_id) + + return { + "smoothing": sm, + "baseline": bl, + "normalization": nm, + "peak_detection": pk, + "similarity_matching": sim, + } + + +def _ftir_draft_from_control_values( + smooth_method, + smooth_window, + smooth_poly, + smooth_sigma, + baseline_method, + baseline_lam, + baseline_p, + baseline_region_enabled, + baseline_region_min, + baseline_region_max, + norm_method, + peak_prominence, + peak_distance, + peak_max_peaks, + sim_metric, + sim_top_n, + sim_minimum_score, + *, + template_id: str | None = None, +) -> dict[str, Any]: + return { + "smoothing": _normalize_smoothing_values(smooth_method, smooth_window, smooth_poly, smooth_sigma), + "baseline": _normalize_baseline_values(baseline_method, baseline_lam, baseline_p, baseline_region_enabled, baseline_region_min, baseline_region_max), + "normalization": _normalize_normalization_values(norm_method), + "peak_detection": _normalize_peak_detection_values(peak_prominence, peak_distance, peak_max_peaks), + "similarity_matching": _normalize_similarity_matching_values( + sim_metric, + sim_top_n, + sim_minimum_score, + template_id=template_id, + ), + } + + +def _ftir_overrides_from_draft(draft: dict | None, *, template_id: str | None = None) -> dict[str, Any]: + norm = _normalize_ftir_processing_draft(draft, template_id=template_id) + return { + "smoothing": copy.deepcopy(norm["smoothing"]), + "baseline": copy.deepcopy(norm["baseline"]), + "normalization": copy.deepcopy(norm["normalization"]), + "peak_detection": copy.deepcopy(norm["peak_detection"]), + "similarity_matching": copy.deepcopy(norm["similarity_matching"]), + } + + +def _ftir_draft_from_loaded_processing(processing: dict | None) -> dict[str, Any]: + if not isinstance(processing, dict): + return copy.deepcopy(_default_ftir_processing_draft()) + template_id = str(processing.get("workflow_template_id") or "").strip() or None + sp = processing.get("signal_pipeline") or {} + ast = processing.get("analysis_steps") or {} + sm = sp.get("smoothing") if isinstance(sp.get("smoothing"), dict) else processing.get("smoothing") + bl = sp.get("baseline") if isinstance(sp.get("baseline"), dict) else processing.get("baseline") + nm = sp.get("normalization") if isinstance(sp.get("normalization"), dict) else processing.get("normalization") + pk = ast.get("peak_detection") if isinstance(ast.get("peak_detection"), dict) else processing.get("peak_detection") + sim = ast.get("similarity_matching") if isinstance(ast.get("similarity_matching"), dict) else processing.get("similarity_matching") + return _normalize_ftir_processing_draft( + { + "smoothing": sm, + "baseline": bl, + "normalization": nm, + "peak_detection": pk, + "similarity_matching": sim, + }, + template_id=template_id, + ) + + +def _ftir_preset_processing_body_for_save(draft: dict | None, *, template_id: str | None = None) -> dict[str, Any]: + norm = _normalize_ftir_processing_draft(draft, template_id=template_id) + return { + "smoothing": copy.deepcopy(norm["smoothing"]), + "baseline": copy.deepcopy(norm["baseline"]), + "normalization": copy.deepcopy(norm["normalization"]), + "peak_detection": copy.deepcopy(norm["peak_detection"]), + "similarity_matching": copy.deepcopy(norm["similarity_matching"]), + } + + +def _ftir_ui_snapshot_dict(template_id: str | None, draft: dict | None) -> dict[str, Any]: + tid = template_id if template_id in _FTIR_TEMPLATE_IDS else "ftir.general" + norm = _normalize_ftir_processing_draft(draft, template_id=tid) + return { + "workflow_template_id": tid, + "smoothing": norm["smoothing"], + "baseline": norm["baseline"], + "normalization": norm["normalization"], + "peak_detection": norm["peak_detection"], + "similarity_matching": norm["similarity_matching"], + } + + +def _ftir_snapshots_equal(a: dict | None, b: dict | None) -> bool: + if not isinstance(a, dict) or not isinstance(b, dict): + return False + return json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str) + + +# --------------------------------------------------------------------------- +# i18n +# --------------------------------------------------------------------------- + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +# --------------------------------------------------------------------------- +# Layout primitives +# --------------------------------------------------------------------------- + + +def _ftir_result_section(child: Any, *, role: str = "support") -> html.Div: + role_class = _FTIR_RESULT_CARD_ROLES.get(role, _FTIR_RESULT_CARD_ROLES["support"]) + return html.Div(child, className=f"ms-result-section {role_class}") + + +def _ftir_library_unavailable(summary: dict | None) -> bool: + return str((summary or {}).get("match_status") or "").lower() == "library_unavailable" + + +def _ftir_collapsible_section( + loc: str, + title_key: str, + body: Any, + *, + open: bool = False, + summary_suffix: Any | None = None, +) -> html.Details: + return build_collapsible_section(loc, title_key, body, open=open, summary_suffix=summary_suffix) + + +# --------------------------------------------------------------------------- +# Left-column cards +# --------------------------------------------------------------------------- + + +def _ftir_workflow_guide_block() -> html.Details: + return html.Details( + [ + html.Summary( + [html.Span(className="ta-details-chevron"), html.Span(id="ftir-workflow-guide-title", className="ms-1")], + className="ta-details-summary", + ), + html.Div(id="ftir-workflow-guide-body", className="ta-details-body mt-2 small"), + ], + className="ta-ms-details mb-3", + open=False, + ) + + +def _ftir_raw_quality_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H6(id="ftir-raw-quality-card-title", className="card-title mb-1"), + html.P(id="ftir-raw-quality-card-hint", className="small text-muted mb-2"), + html.Div(id="ftir-raw-quality-panel", className="ms-spectral-raw-quality-panel"), + ] + ), + className="mb-3", + ) + + +def _ftir_plot_settings_card() -> dbc.Card: + return build_spectral_plot_settings_card("ftir") + + +def _ftir_processing_history_card() -> dbc.Card: + return build_processing_history_card( + title_id="ftir-processing-history-title", + hint_id="ftir-processing-history-hint", + undo_button_id="ftir-processing-undo-btn", + redo_button_id="ftir-processing-redo-btn", + reset_button_id="ftir-processing-reset-btn", + status_id="ftir-history-status", + ) + + +def _ftir_preset_card() -> dbc.Card: + return build_load_saveas_preset_card(id_prefix="ftir") + + +def _ftir_smoothing_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="ftir-smoothing-card-title", className="card-title mb-2"), + html.P(id="ftir-smoothing-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-smooth-method-label", html_for="ftir-smooth-method", className="mb-1"), + dbc.Select( + id="ftir-smooth-method", + options=[ + {"label": "Savitzky–Golay", "value": "savgol"}, + {"label": "Moving average", "value": "moving_average"}, + {"label": "Gaussian", "value": "gaussian"}, + ], + value="savgol", + ), + ], + md=12, + ), + ], + className="g-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-smooth-window-label", html_for="ftir-smooth-window", className="mb-1"), + dbc.Input(id="ftir-smooth-window", type="number", min=3, step=2, value=11), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="ftir-smooth-polyorder-label", html_for="ftir-smooth-polyorder", className="mb-1"), + dbc.Input(id="ftir-smooth-polyorder", type="number", min=1, max=7, step=1, value=3), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="ftir-smooth-sigma-label", html_for="ftir-smooth-sigma", className="mb-1"), + dbc.Input(id="ftir-smooth-sigma", type="number", min=0.1, step=0.1, value=2.0), + ], + md=4, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _ftir_baseline_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="ftir-baseline-card-title", className="card-title mb-2"), + html.P(id="ftir-baseline-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-baseline-method-label", html_for="ftir-baseline-method", className="mb-1"), + dbc.Select( + id="ftir-baseline-method", + options=[ + {"label": "AsLS", "value": "asls"}, + {"label": "Linear", "value": "linear"}, + {"label": "Rubberband", "value": "rubberband"}, + ], + value="asls", + ), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-baseline-lam-label", html_for="ftir-baseline-lam", className="mb-1"), + dbc.Input(id="ftir-baseline-lam", type="number", min=1e-3, step=1e5, value=1e6), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="ftir-baseline-p-label", html_for="ftir-baseline-p", className="mb-1"), + dbc.Input(id="ftir-baseline-p", type="number", min=1e-4, max=0.5, step=0.005, value=0.01), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + html.H6(id="ftir-baseline-region-section-title", className="mt-2 mb-2 small text-muted text-uppercase"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Checkbox(id="ftir-baseline-region-enabled", value=False, label=" "), + html.Small(id="ftir-baseline-region-enable-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-baseline-region-min-label", html_for="ftir-baseline-region-min", className="mb-1"), + dbc.Input(id="ftir-baseline-region-min", type="number", value=None), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="ftir-baseline-region-max-label", html_for="ftir-baseline-region-max", className="mb-1"), + dbc.Input(id="ftir-baseline-region-max", type="number", value=None), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + ] + ), + className="mb-3", + ) + + +def _ftir_normalization_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="ftir-normalization-card-title", className="card-title mb-2"), + html.P(id="ftir-normalization-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-norm-method-label", html_for="ftir-norm-method", className="mb-1"), + dbc.Select( + id="ftir-norm-method", + options=[ + {"label": "Vector", "value": "vector"}, + {"label": "Max", "value": "max"}, + {"label": "SNV", "value": "snv"}, + ], + value="vector", + ), + ], + md=12, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _ftir_peak_detection_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="ftir-peak-card-title", className="card-title mb-2"), + html.P(id="ftir-peak-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-peak-prominence-label", html_for="ftir-peak-prominence", className="mb-1"), + dbc.Input(id="ftir-peak-prominence", type="number", min=0, step=0.001, value=0.035), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="ftir-peak-distance-label", html_for="ftir-peak-distance", className="mb-1"), + dbc.Input(id="ftir-peak-distance", type="number", min=1, step=1, value=5), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="ftir-peak-max-peaks-label", html_for="ftir-peak-max-peaks", className="mb-1"), + dbc.Input(id="ftir-peak-max-peaks", type="number", min=1, step=1, value=10), + ], + md=4, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _ftir_similarity_matching_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="ftir-similarity-card-title", className="card-title mb-2"), + html.P(id="ftir-similarity-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="ftir-sim-metric-label", html_for="ftir-sim-metric", className="mb-1"), + dbc.Select(id="ftir-sim-metric", options=[], value=_default_ftir_similarity_metric()), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="ftir-sim-top-n-label", html_for="ftir-sim-top-n", className="mb-1"), + dbc.Input(id="ftir-sim-top-n", type="number", min=1, step=1, value=3), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="ftir-sim-minimum-score-label", html_for="ftir-sim-minimum-score", className="mb-1"), + dbc.Input(id="ftir-sim-minimum-score", type="number", min=0, max=1, step=0.01, value=0.45), + ], + md=4, + ), + ], + className="g-3", + ), + ] + ), + className="mb-3", + ) + + +# --------------------------------------------------------------------------- +# Left-column tabs +# --------------------------------------------------------------------------- + + +def _ftir_left_column_tabs() -> dbc.Tabs: + return dbc.Tabs( + [ + dbc.Tab( + [ + dataset_selection_card("ftir-dataset-selector-area", card_title_id="ftir-dataset-card-title"), + workflow_template_card( + "ftir-template-select", + "ftir-template-description", + [], + "ftir.general", + card_title_id="ftir-workflow-card-title", + ), + _ftir_workflow_guide_block(), + _ftir_raw_quality_card(), + ], + tab_id="ftir-tab-setup", + label_class_name="ta-tab-label", + id="ftir-tab-setup-shell", + ), + dbc.Tab( + [ + _ftir_processing_history_card(), + _ftir_preset_card(), + _ftir_smoothing_controls_card(), + _ftir_baseline_controls_card(), + _ftir_normalization_controls_card(), + _ftir_peak_detection_controls_card(), + _ftir_similarity_matching_controls_card(), + _ftir_plot_settings_card(), + ], + tab_id="ftir-tab-processing", + label_class_name="ta-tab-label", + id="ftir-tab-processing-shell", + ), + dbc.Tab( + [ + execute_card("ftir-run-status", "ftir-run-btn", card_title_id="ftir-execute-card-title"), + ], + tab_id="ftir-tab-run", + label_class_name="ta-tab-label", + id="ftir-tab-run-shell", + ), + ], + id="ftir-left-tabs", + active_tab="ftir-tab-setup", + className="mb-3", + ) + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = html.Div( + analysis_page_stores("ftir-refresh", "ftir-latest-result-id") + + [ + dcc.Store(id="ftir-figure-captured", data={}), + dcc.Store(id="ftir-figure-artifact-refresh", data=0), + dcc.Store(id="ftir-processing-default", data=copy.deepcopy(_default_ftir_processing_draft())), + dcc.Store(id="ftir-processing-draft", data=copy.deepcopy(_default_ftir_processing_draft())), + dcc.Store(id="ftir-processing-undo-stack", data=[]), + dcc.Store(id="ftir-processing-redo-stack", data=[]), + dcc.Store(id="ftir-history-hydrate", data=0), + dcc.Store(id="ftir-preset-refresh", data=0), + dcc.Store(id="ftir-preset-hydrate", data=0), + dcc.Store(id="ftir-preset-loaded-name", data=""), + dcc.Store(id="ftir-preset-snapshot", data=None), + dcc.Store(id="ftir-plot-settings", data=normalize_spectral_plot_settings(None)), + html.Div(id="ftir-hero-slot"), + dbc.Row( + [ + dbc.Col( + [_ftir_left_column_tabs()], + md=4, + ), + dbc.Col( + [ + _ftir_result_section(result_placeholder_card("ftir-result-analysis-summary"), role="context"), + _ftir_result_section(html.Div(id="ftir-result-metrics", className="mb-2"), role="context"), + _ftir_result_section(html.Div(id="ftir-result-quality", className="mb-2"), role="support"), + _ftir_result_section(build_figure_artifact_surface("ftir"), role="hero"), + _ftir_result_section(html.Div(id="ftir-result-top-match", className="mb-2"), role="support"), + _ftir_result_section(html.Div(id="ftir-result-peak-cards", className="mb-2"), role="support"), + _ftir_result_section(html.Div(id="ftir-result-match-table", className="mb-2"), role="support"), + _ftir_result_section(html.Div(id="ftir-result-processing", className="mb-2"), role="support"), + _ftir_result_section(html.Div(id="ftir-result-raw-metadata", className="mb-2"), role="support"), + _ftir_result_section(build_literature_compare_card(id_prefix="ftir"), role="secondary"), + ], + md=8, + className="ms-results-surface", + ), + ] + ), + ], + className="ftir-page", +) + + +# --------------------------------------------------------------------------- +# Locale / chrome callbacks +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-hero-slot", "children"), + Output("ftir-dataset-card-title", "children"), + Output("ftir-workflow-card-title", "children"), + Output("ftir-execute-card-title", "children"), + Output("ftir-run-btn", "children"), + Output("ftir-template-select", "options"), + Output("ftir-template-select", "value"), + Output("ftir-template-description", "children"), + Input("ui-locale", "data"), + Input("ftir-template-select", "value"), +) +def render_ftir_locale_chrome(locale_data, template_id): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.analysis.ftir.title"), + translate_ui(loc, "dash.analysis.ftir.caption"), + badge=translate_ui(loc, "dash.analysis.badge"), + ) + opts = [{"label": translate_ui(loc, f"dash.analysis.ftir.template.{tid}.label"), "value": tid} for tid in _FTIR_TEMPLATE_IDS] + valid = {o["value"] for o in opts} + tid = template_id if template_id in valid else "ftir.general" + desc_key = f"dash.analysis.ftir.template.{tid}.desc" + desc = translate_ui(loc, desc_key) + if desc == desc_key: + desc = translate_ui(loc, "dash.analysis.ftir.workflow_fallback") + return ( + hero, + translate_ui(loc, "dash.analysis.dataset_selection_title"), + translate_ui(loc, "dash.analysis.workflow_template_title"), + translate_ui(loc, "dash.analysis.execute_title"), + translate_ui(loc, "dash.analysis.ftir.run_btn"), + opts, + tid, + desc, + ) + + +@callback( + Output("ftir-tab-setup-shell", "label"), + Output("ftir-tab-processing-shell", "label"), + Output("ftir-tab-run-shell", "label"), + Input("ui-locale", "data"), +) +def render_ftir_tab_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.ftir.tab.setup"), + translate_ui(loc, "dash.analysis.ftir.tab.processing"), + translate_ui(loc, "dash.analysis.ftir.tab.run"), + ) + + +@callback( + Output("ftir-workflow-guide-title", "children"), + Output("ftir-workflow-guide-body", "children"), + Input("ui-locale", "data"), +) +def render_ftir_workflow_guide_chrome(locale_data): + loc = _loc(locale_data) + pfx = "dash.analysis.ftir.workflow_guide" + body = html.Div( + [ + html.P(translate_ui(loc, f"{pfx}.intro"), className="mb-2"), + html.Ul( + [ + html.Li(translate_ui(loc, f"{pfx}.step1"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step2"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step3"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step4"), className="mb-0"), + ], + className="ps-3 mb-0", + ), + ] + ) + return translate_ui(loc, f"{pfx}.title"), body + + +@callback( + Output("ftir-raw-quality-card-title", "children"), + Output("ftir-raw-quality-card-hint", "children"), + Input("ui-locale", "data"), +) +def render_ftir_raw_quality_chrome(locale_data): + loc = _loc(locale_data) + return translate_ui(loc, "dash.analysis.ftir.raw_quality.card_title"), translate_ui(loc, "dash.analysis.ftir.raw_quality.card_hint") + + +@callback( + Output("ftir-raw-quality-panel", "children"), + Input("project-id", "data"), + Input("ftir-dataset-select", "value"), + Input("ftir-refresh", "data"), + Input("ui-locale", "data"), +) +def render_ftir_raw_quality_panel(project_id, dataset_key, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id or not dataset_key: + return html.P(translate_ui(loc, "dash.analysis.ftir.raw_quality.pick_dataset"), className="text-muted small mb-0") + + from dash_app.api_client import workspace_dataset_data, workspace_dataset_detail + + try: + detail = workspace_dataset_detail(project_id, dataset_key) + data = workspace_dataset_data(project_id, dataset_key) + except Exception as exc: + return html.P(translate_ui(loc, "dash.analysis.ftir.raw_quality.load_failed", error=str(exc)), className="text-danger small mb-0") + + rows = data.get("rows") or [] + columns = data.get("columns") or [] + axis, signal = downsample_spectral_rows(rows, columns) + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + stats = compute_spectral_raw_quality_stats(axis, signal, validation=validation) + units = detail.get("units") if isinstance(detail.get("units"), dict) else {} + signal_unit = str(units.get("signal") or "") + return build_spectral_raw_quality_panel( + stats, + loc, + i18n_prefix="dash.analysis.ftir.raw_quality", + signal_unit=signal_unit, + ) + + +@callback( + Output("ftir-plot-card-title", "children"), + Output("ftir-plot-card-hint", "children"), + Output("ftir-plot-legend-mode-label", "children"), + Output("ftir-plot-legend-mode", "options"), + Output("ftir-plot-compact", "label"), + Output("ftir-plot-show-grid", "label"), + Output("ftir-plot-show-spikes", "label"), + Output("ftir-plot-reverse-x-axis", "label"), + Output("ftir-plot-export-scale-label", "children"), + Output("ftir-plot-line-width-label", "children"), + Output("ftir-plot-marker-size-label", "children"), + Output("ftir-plot-show-raw", "label"), + Output("ftir-plot-show-smoothed", "label"), + Output("ftir-plot-show-corrected", "label"), + Output("ftir-plot-show-normalized", "label"), + Output("ftir-plot-show-peaks", "label"), + Output("ftir-plot-x-range-enabled", "label"), + Output("ftir-plot-x-min", "placeholder"), + Output("ftir-plot-x-max", "placeholder"), + Output("ftir-plot-y-range-enabled", "label"), + Output("ftir-plot-y-min", "placeholder"), + Output("ftir-plot-y-max", "placeholder"), + Input("ui-locale", "data"), +) +def render_ftir_plot_settings_chrome(locale_data): + chrome = spectral_plot_settings_chrome(_loc(locale_data)) + return ( + chrome["card_title"], + chrome["card_hint"], + chrome["legend_label"], + chrome["legend_options"], + chrome["compact_label"], + chrome["show_grid_label"], + chrome["show_spikes_label"], + chrome["reverse_x_axis_label"], + chrome["export_scale_label"], + chrome["line_width_label"], + chrome["marker_size_label"], + chrome["show_raw_label"], + chrome["show_smoothed_label"], + chrome["show_corrected_label"], + chrome["show_normalized_label"], + chrome["show_peaks_label"], + chrome["x_lock_label"], + chrome["x_min_placeholder"], + chrome["x_max_placeholder"], + chrome["y_lock_label"], + chrome["y_min_placeholder"], + chrome["y_max_placeholder"], + ) + + +@callback( + Output("ftir-plot-settings", "data"), + Input("ftir-plot-legend-mode", "value"), + Input("ftir-plot-compact", "value"), + Input("ftir-plot-show-grid", "value"), + Input("ftir-plot-show-spikes", "value"), + Input("ftir-plot-line-width-scale", "value"), + Input("ftir-plot-marker-size-scale", "value"), + Input("ftir-plot-export-scale", "value"), + Input("ftir-plot-reverse-x-axis", "value"), + Input("ftir-plot-show-raw", "value"), + Input("ftir-plot-show-smoothed", "value"), + Input("ftir-plot-show-corrected", "value"), + Input("ftir-plot-show-normalized", "value"), + Input("ftir-plot-show-peaks", "value"), + Input("ftir-plot-x-range-enabled", "value"), + Input("ftir-plot-x-min", "value"), + Input("ftir-plot-x-max", "value"), + Input("ftir-plot-y-range-enabled", "value"), + Input("ftir-plot-y-min", "value"), + Input("ftir-plot-y-max", "value"), +) +def update_ftir_plot_settings( + legend_mode, + compact, + show_grid, + show_spikes, + line_width_scale, + marker_size_scale, + export_scale, + reverse_x_axis, + show_raw, + show_smoothed, + show_corrected, + show_normalized, + show_peaks, + x_range_enabled, + x_min, + x_max, + y_range_enabled, + y_min, + y_max, +): + return spectral_plot_settings_from_controls( + legend_mode, + compact, + show_grid, + show_spikes, + line_width_scale, + marker_size_scale, + export_scale, + reverse_x_axis, + show_raw, + show_smoothed, + show_corrected, + show_normalized, + show_peaks, + x_range_enabled, + x_min, + x_max, + y_range_enabled, + y_min, + y_max, + ) + + +# --------------------------------------------------------------------------- +# Dataset loading +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-dataset-selector-area", "children"), + Output("ftir-run-btn", "disabled"), + Input("project-id", "data"), + Input("ftir-refresh", "data"), + Input("ui-locale", "data"), +) +def load_eligible_datasets(project_id, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + return html.P(translate_ui(loc, "dash.analysis.workspace_inactive"), className="text-muted"), True + + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_datasets", error=str(exc)), color="danger"), True + + all_datasets = payload.get("datasets", []) + return dataset_selector_block( + selector_id="ftir-dataset-select", + empty_msg=translate_ui(loc, "dash.analysis.ftir.empty_import"), + eligible=eligible_datasets(all_datasets, _FTIR_ELIGIBLE_TYPES), + all_datasets=all_datasets, + eligible_types=_FTIR_ELIGIBLE_TYPES, + active_dataset=payload.get("active_dataset"), + locale_data=locale_data, + ) + + +# --------------------------------------------------------------------------- +# Preset callbacks +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-preset-card-title", "children"), + Output("ftir-preset-help", "children"), + Output("ftir-preset-select-label", "children"), + Output("ftir-preset-load-btn", "children"), + Output("ftir-preset-delete-btn", "children"), + Output("ftir-preset-save-name-label", "children"), + Output("ftir-preset-save-name", "placeholder"), + Output("ftir-preset-save-btn", "children"), + Output("ftir-preset-saveas-btn", "children"), + Output("ftir-preset-save-hint", "children"), + Input("ui-locale", "data"), +) +def render_ftir_preset_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.ftir.presets.title"), + translate_ui(loc, "dash.analysis.ftir.presets.help.overview"), + translate_ui(loc, "dash.analysis.ftir.presets.select_label"), + translate_ui(loc, "dash.analysis.ftir.presets.load_btn"), + translate_ui(loc, "dash.analysis.ftir.presets.delete_btn"), + translate_ui(loc, "dash.analysis.ftir.presets.save_name_label"), + translate_ui(loc, "dash.analysis.ftir.presets.save_name_placeholder"), + translate_ui(loc, "dash.analysis.ftir.presets.save_btn"), + translate_ui(loc, "dash.analysis.ftir.presets.saveas_btn"), + translate_ui(loc, "dash.analysis.ftir.presets.save_hint"), + ) + + +@callback( + Output("ftir-preset-select", "options"), + Output("ftir-preset-caption", "children"), + Input("ftir-preset-refresh", "data"), + Input("ui-locale", "data"), +) +def refresh_ftir_preset_options(_refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + try: + payload = api_client.list_analysis_presets(_FTIR_PRESET_ANALYSIS_TYPE) + except Exception as exc: + message = translate_ui(loc, "dash.analysis.ftir.presets.list_failed").format(error=str(exc)) + return [], message + + presets = payload.get("presets") or [] + options = [ + {"label": item.get("preset_name", ""), "value": item.get("preset_name", "")} + for item in presets + if isinstance(item, dict) and item.get("preset_name") + ] + caption = translate_ui(loc, "dash.analysis.ftir.presets.caption").format( + analysis_type=payload.get("analysis_type", _FTIR_PRESET_ANALYSIS_TYPE), + count=int(payload.get("count", len(options)) or 0), + max_count=int(payload.get("max_count", 10) or 10), + ) + return options, caption + + +@callback( + Output("ftir-preset-load-btn", "disabled"), + Output("ftir-preset-delete-btn", "disabled"), + Output("ftir-preset-save-btn", "disabled"), + Input("ftir-preset-select", "value"), +) +def toggle_ftir_preset_action_buttons(selected_name): + has_selection = bool(str(selected_name or "").strip()) + return (not has_selection, not has_selection, not has_selection) + + +@callback( + Output("ftir-processing-draft", "data", allow_duplicate=True), + Output("ftir-template-select", "value", allow_duplicate=True), + Output("ftir-preset-status", "children", allow_duplicate=True), + Output("ftir-preset-hydrate", "data", allow_duplicate=True), + Output("ftir-preset-loaded-name", "data", allow_duplicate=True), + Output("ftir-preset-snapshot", "data", allow_duplicate=True), + Output("ftir-left-tabs", "active_tab", allow_duplicate=True), + Output("ftir-processing-undo-stack", "data", allow_duplicate=True), + Output("ftir-processing-redo-stack", "data", allow_duplicate=True), + Input("ftir-preset-load-btn", "n_clicks"), + State("ftir-preset-select", "value"), + State("ftir-preset-hydrate", "data"), + State("ftir-processing-draft", "data"), + State("ftir-processing-undo-stack", "data"), + State("ftir-processing-redo-stack", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def apply_ftir_preset(n_clicks, selected_name, hydrate_val, current_draft, undo_stack, redo_stack, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.ftir.presets.select_required"), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + try: + payload = api_client.load_analysis_preset(_FTIR_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.ftir.presets.load_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + + processing = dict(payload.get("processing") or {}) + draft = _ftir_draft_from_loaded_processing(processing) + template_id_raw = str(payload.get("workflow_template_id") or "").strip() + template_out = template_id_raw if template_id_raw in _FTIR_TEMPLATE_IDS else dash.no_update + resolved_tid = template_id_raw if template_id_raw in _FTIR_TEMPLATE_IDS else "ftir.general" + snap = _ftir_ui_snapshot_dict(resolved_tid, draft) + status = translate_ui(loc, "dash.analysis.ftir.presets.loaded").format(preset=name) + old_norm = _normalize_ftir_processing_draft(current_draft, template_id=resolved_tid) + new_norm = _normalize_ftir_processing_draft(draft, template_id=resolved_tid) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return ( + draft, + template_out, + status, + int(hydrate_val or 0) + 1, + name, + snap, + "ftir-tab-run", + past2, + fut2, + ) + + +@callback( + Output("ftir-preset-refresh", "data", allow_duplicate=True), + Output("ftir-preset-save-name", "value", allow_duplicate=True), + Output("ftir-preset-status", "children", allow_duplicate=True), + Output("ftir-preset-snapshot", "data", allow_duplicate=True), + Output("ftir-left-tabs", "active_tab", allow_duplicate=True), + Input("ftir-preset-save-btn", "n_clicks"), + Input("ftir-preset-saveas-btn", "n_clicks"), + State("ftir-preset-select", "value"), + State("ftir-preset-save-name", "value"), + State("ftir-processing-draft", "data"), + State("ftir-template-select", "value"), + State("ftir-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_ftir_preset(n_save, n_saveas, selected_name, save_name, draft, template_id, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + if trig == "ftir-preset-save-btn": + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.ftir.presets.select_required"), + dash.no_update, + dash.no_update, + ) + clear_name = dash.no_update + elif trig == "ftir-preset-saveas-btn": + name = str(save_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.ftir.presets.save_name_required"), + dash.no_update, + dash.no_update, + ) + clear_name = "" + else: + raise dash.exceptions.PreventUpdate + + processing_body = _ftir_preset_processing_body_for_save(draft, template_id=template_id) + try: + response = api_client.save_analysis_preset( + _FTIR_PRESET_ANALYSIS_TYPE, + name, + workflow_template_id=str(template_id or "").strip() or None, + processing=processing_body, + ) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.ftir.presets.save_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + resolved_template = str(response.get("workflow_template_id") or template_id or "") + snap = _ftir_ui_snapshot_dict(str(template_id or "").strip() or None, draft) + status = translate_ui(loc, "dash.analysis.ftir.presets.saved").format(preset=name, template=resolved_template) + return int(refresh_token or 0) + 1, clear_name, status, snap, "ftir-tab-run" + + +@callback( + Output("ftir-preset-refresh", "data", allow_duplicate=True), + Output("ftir-preset-select", "value", allow_duplicate=True), + Output("ftir-preset-status", "children", allow_duplicate=True), + Output("ftir-preset-loaded-name", "data", allow_duplicate=True), + Output("ftir-preset-snapshot", "data", allow_duplicate=True), + Input("ftir-preset-delete-btn", "n_clicks"), + State("ftir-preset-select", "value"), + State("ftir-preset-loaded-name", "data"), + State("ftir-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def delete_ftir_preset(n_clicks, selected_name, loaded_name, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.ftir.presets.select_required"), dash.no_update, dash.no_update + try: + api_client.delete_analysis_preset(_FTIR_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.ftir.presets.delete_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + status = translate_ui(loc, "dash.analysis.ftir.presets.deleted").format(preset=name) + loaded = str(loaded_name or "").strip() + if loaded == name: + return int(refresh_token or 0) + 1, None, status, "", None + return int(refresh_token or 0) + 1, None, status, dash.no_update, dash.no_update + + +@callback( + Output("ftir-preset-loaded-line", "children"), + Input("ftir-preset-loaded-name", "data"), + Input("ui-locale", "data"), +) +def render_ftir_preset_loaded_line(loaded_name, locale_data): + loc = _loc(locale_data) + name = str(loaded_name or "").strip() + if not name: + return "" + return translate_ui(loc, "dash.analysis.ftir.presets.loaded_line").format(preset=name) + + +@callback( + Output("ftir-preset-dirty-flag", "children"), + Input("ui-locale", "data"), + Input("ftir-template-select", "value"), + Input("ftir-smooth-method", "value"), + Input("ftir-smooth-window", "value"), + Input("ftir-smooth-polyorder", "value"), + Input("ftir-smooth-sigma", "value"), + Input("ftir-baseline-method", "value"), + Input("ftir-baseline-lam", "value"), + Input("ftir-baseline-p", "value"), + Input("ftir-baseline-region-enabled", "value"), + Input("ftir-baseline-region-min", "value"), + Input("ftir-baseline-region-max", "value"), + Input("ftir-norm-method", "value"), + Input("ftir-peak-prominence", "value"), + Input("ftir-peak-distance", "value"), + Input("ftir-peak-max-peaks", "value"), + Input("ftir-sim-metric", "value"), + Input("ftir-sim-top-n", "value"), + Input("ftir-sim-minimum-score", "value"), + State("ftir-preset-snapshot", "data"), +) +def render_ftir_preset_dirty_flag( + locale_data, + template_id, + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + sim_metric, sim_tn, sim_ms, + snapshot, +): + loc = _loc(locale_data) + if not isinstance(snapshot, dict): + return html.Span(translate_ui(loc, "dash.analysis.ftir.presets.dirty_no_baseline"), className="text-muted") + current = _ftir_ui_snapshot_dict( + template_id, + _ftir_draft_from_control_values( + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + sim_metric, sim_tn, sim_ms, + template_id=template_id, + ), + ) + if _ftir_snapshots_equal(snapshot, current): + return html.Span(translate_ui(loc, "dash.analysis.ftir.presets.clean"), className="text-success") + return html.Span(translate_ui(loc, "dash.analysis.ftir.presets.dirty"), className="text-warning") + + +# --------------------------------------------------------------------------- +# Processing controls chrome +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-processing-history-title", "children"), + Output("ftir-processing-history-hint", "children"), + Output("ftir-processing-undo-btn", "children"), + Output("ftir-processing-redo-btn", "children"), + Output("ftir-processing-reset-btn", "children"), + Input("ui-locale", "data"), +) +def render_ftir_processing_history_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.ftir.processing.history_title"), + translate_ui(loc, "dash.analysis.ftir.processing.history_hint"), + translate_ui(loc, "dash.analysis.ftir.processing.undo_btn"), + translate_ui(loc, "dash.analysis.ftir.processing.redo_btn"), + translate_ui(loc, "dash.analysis.ftir.processing.reset_btn"), + ) + + +@callback( + Output("ftir-smoothing-card-title", "children"), + Output("ftir-smoothing-card-hint", "children"), + Output("ftir-smooth-method-label", "children"), + Output("ftir-smooth-window-label", "children"), + Output("ftir-smooth-polyorder-label", "children"), + Output("ftir-smooth-sigma-label", "children"), + Output("ftir-smooth-method", "options"), + Input("ui-locale", "data"), +) +def render_ftir_smoothing_chrome(locale_data): + loc = _loc(locale_data) + smooth_opts = [ + {"label": translate_ui(loc, "dash.analysis.ftir.processing.smooth.savgol"), "value": "savgol"}, + {"label": translate_ui(loc, "dash.analysis.ftir.processing.smooth.moving_average"), "value": "moving_average"}, + {"label": translate_ui(loc, "dash.analysis.ftir.processing.smooth.gaussian"), "value": "gaussian"}, + ] + return ( + translate_ui(loc, "dash.analysis.ftir.processing.smoothing_card_title"), + translate_ui(loc, "dash.analysis.ftir.processing.smoothing_card_hint"), + translate_ui(loc, "dash.analysis.ftir.processing.smooth.method"), + translate_ui(loc, "dash.analysis.ftir.processing.smooth.window"), + translate_ui(loc, "dash.analysis.ftir.processing.smooth.polyorder"), + translate_ui(loc, "dash.analysis.ftir.processing.smooth.sigma"), + smooth_opts, + ) + + +@callback( + Output("ftir-baseline-card-title", "children"), + Output("ftir-baseline-card-hint", "children"), + Output("ftir-baseline-method-label", "children"), + Output("ftir-baseline-lam-label", "children"), + Output("ftir-baseline-p-label", "children"), + Output("ftir-baseline-region-section-title", "children"), + Output("ftir-baseline-region-enable-hint", "children"), + Output("ftir-baseline-region-min-label", "children"), + Output("ftir-baseline-region-max-label", "children"), + Input("ui-locale", "data"), +) +def render_ftir_baseline_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.ftir.baseline.title"), + translate_ui(loc, "dash.analysis.ftir.baseline.help.method"), + translate_ui(loc, "dash.analysis.ftir.baseline.method"), + translate_ui(loc, "dash.analysis.ftir.baseline.lam"), + translate_ui(loc, "dash.analysis.ftir.baseline.p"), + translate_ui(loc, "dash.analysis.ftir.baseline.region_section"), + translate_ui(loc, "dash.analysis.ftir.baseline.help.enable_region"), + translate_ui(loc, "dash.analysis.ftir.baseline.region_min"), + translate_ui(loc, "dash.analysis.ftir.baseline.region_max"), + ) + + +@callback( + Output("ftir-normalization-card-title", "children"), + Output("ftir-normalization-card-hint", "children"), + Output("ftir-norm-method-label", "children"), + Output("ftir-norm-method", "options"), + Input("ui-locale", "data"), +) +def render_ftir_normalization_chrome(locale_data): + loc = _loc(locale_data) + opts = [ + {"label": translate_ui(loc, "dash.analysis.ftir.norm.vector"), "value": "vector"}, + {"label": translate_ui(loc, "dash.analysis.ftir.norm.max"), "value": "max"}, + {"label": translate_ui(loc, "dash.analysis.ftir.norm.snv"), "value": "snv"}, + ] + return ( + translate_ui(loc, "dash.analysis.ftir.normalization.title"), + translate_ui(loc, "dash.analysis.ftir.normalization.hint"), + translate_ui(loc, "dash.analysis.ftir.normalization.method"), + opts, + ) + + +@callback( + Output("ftir-peak-card-title", "children"), + Output("ftir-peak-card-hint", "children"), + Output("ftir-peak-prominence-label", "children"), + Output("ftir-peak-distance-label", "children"), + Output("ftir-peak-max-peaks-label", "children"), + Input("ui-locale", "data"), +) +def render_ftir_peak_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.ftir.peaks.title"), + translate_ui(loc, "dash.analysis.ftir.peaks.hint"), + translate_ui(loc, "dash.analysis.ftir.peaks.prominence"), + translate_ui(loc, "dash.analysis.ftir.peaks.distance"), + translate_ui(loc, "dash.analysis.ftir.peaks.max_peaks"), + ) + + +@callback( + Output("ftir-similarity-card-title", "children"), + Output("ftir-similarity-card-hint", "children"), + Output("ftir-sim-metric-label", "children"), + Output("ftir-sim-metric", "options"), + Output("ftir-sim-top-n-label", "children"), + Output("ftir-sim-minimum-score-label", "children"), + Input("ui-locale", "data"), +) +def render_ftir_similarity_chrome(locale_data): + loc = _loc(locale_data) + metric_options = [ + {"label": translate_ui(loc, "dash.analysis.ftir.similarity.metric.cosine"), "value": "cosine"}, + {"label": translate_ui(loc, "dash.analysis.ftir.similarity.metric.pearson"), "value": "pearson"}, + ] + return ( + translate_ui(loc, "dash.analysis.ftir.similarity.title"), + translate_ui(loc, "dash.analysis.ftir.similarity.hint"), + translate_ui(loc, "dash.analysis.ftir.similarity.metric"), + metric_options, + translate_ui(loc, "dash.analysis.ftir.similarity.top_n"), + translate_ui(loc, "dash.analysis.ftir.similarity.minimum_score"), + ) + + +# --------------------------------------------------------------------------- +# Toggle inputs based on method +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-smooth-window", "disabled"), + Output("ftir-smooth-polyorder", "disabled"), + Output("ftir-smooth-sigma", "disabled"), + Input("ftir-smooth-method", "value"), +) +def toggle_ftir_smoothing_inputs(method): + token = str(method or "savgol").strip().lower() + if token == "savgol": + return False, False, True + if token == "moving_average": + return False, True, True + return True, True, False + + +@callback( + Output("ftir-baseline-lam", "disabled"), + Output("ftir-baseline-p", "disabled"), + Input("ftir-baseline-method", "value"), +) +def toggle_ftir_baseline_inputs(method): + token = str(method or "asls").strip().lower() + if token == "asls": + return False, False + return True, True + + +@callback( + Output("ftir-baseline-region-min", "disabled"), + Output("ftir-baseline-region-max", "disabled"), + Input("ftir-baseline-region-enabled", "value"), +) +def toggle_ftir_baseline_region_inputs(enabled): + return (not bool(enabled), not bool(enabled)) + + +# --------------------------------------------------------------------------- +# Hydrate controls from draft +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-smooth-method", "value"), + Output("ftir-smooth-window", "value"), + Output("ftir-smooth-polyorder", "value"), + Output("ftir-smooth-sigma", "value"), + Output("ftir-baseline-method", "value"), + Output("ftir-baseline-lam", "value"), + Output("ftir-baseline-p", "value"), + Output("ftir-baseline-region-enabled", "value"), + Output("ftir-baseline-region-min", "value"), + Output("ftir-baseline-region-max", "value"), + Output("ftir-norm-method", "value"), + Output("ftir-peak-prominence", "value"), + Output("ftir-peak-distance", "value"), + Output("ftir-peak-max-peaks", "value"), + Output("ftir-sim-metric", "value"), + Output("ftir-sim-top-n", "value"), + Output("ftir-sim-minimum-score", "value"), + Input("ftir-preset-hydrate", "data"), + Input("ftir-history-hydrate", "data"), + Input("ftir-template-select", "value"), + State("ftir-processing-draft", "data"), +) +def hydrate_ftir_processing_controls(_preset_hydrate, _history_hydrate, template_id, draft): + d = _normalize_ftir_processing_draft(draft, template_id=template_id) + sm = d["smoothing"] + bl = d["baseline"] + nm = d["normalization"] + pk = d["peak_detection"] + sim = d["similarity_matching"] + + method = str(sm.get("method") or "savgol") + wl = int(sm.get("window_length", 11)) + po = int(sm.get("polyorder", 3)) + sigma = float(sm.get("sigma", 2.0)) + + bl_method = str(bl.get("method") or "asls") + lam = float(bl.get("lam", 1e6)) + p = float(bl.get("p", 0.01)) + region = bl.get("region") + enabled = isinstance(region, (list, tuple)) and len(region) == 2 + region_min = region[0] if enabled else None + region_max = region[1] if enabled else None + + norm_method = str(nm.get("method") or "vector") + + prom = float(pk.get("prominence", 0.035)) + dist = int(pk.get("distance", 5)) + mp = int(pk.get("max_peaks", 10)) + + metric = str(sim.get("metric") or _default_ftir_similarity_metric(template_id)) + top_n = int(sim.get("top_n", 3)) + min_score = float(sim.get("minimum_score", 0.45)) + + return ( + method, wl, po, sigma, + bl_method, lam, p, bool(enabled), region_min, region_max, + norm_method, + prom, dist, mp, + metric, top_n, min_score, + ) + + +# --------------------------------------------------------------------------- +# Sync draft from controls + history +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-processing-draft", "data", allow_duplicate=True), + Output("ftir-processing-undo-stack", "data", allow_duplicate=True), + Output("ftir-processing-redo-stack", "data", allow_duplicate=True), + Input("ftir-smooth-method", "value"), + Input("ftir-smooth-window", "value"), + Input("ftir-smooth-polyorder", "value"), + Input("ftir-smooth-sigma", "value"), + Input("ftir-baseline-method", "value"), + Input("ftir-baseline-lam", "value"), + Input("ftir-baseline-p", "value"), + Input("ftir-baseline-region-enabled", "value"), + Input("ftir-baseline-region-min", "value"), + Input("ftir-baseline-region-max", "value"), + Input("ftir-norm-method", "value"), + Input("ftir-peak-prominence", "value"), + Input("ftir-peak-distance", "value"), + Input("ftir-peak-max-peaks", "value"), + Input("ftir-template-select", "value"), + Input("ftir-sim-metric", "value"), + Input("ftir-sim-top-n", "value"), + Input("ftir-sim-minimum-score", "value"), + State("ftir-processing-draft", "data"), + State("ftir-processing-undo-stack", "data"), + State("ftir-processing-redo-stack", "data"), + prevent_initial_call="initial_duplicate", +) +def sync_ftir_processing_draft_from_controls( + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + template_id, sim_metric, sim_tn, sim_ms, + prev_draft, undo_stack, redo_stack, +): + ctx = dash.callback_context + metric_value = None if ctx.triggered_id == "ftir-template-select" else sim_metric + new_draft = _ftir_draft_from_control_values( + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + metric_value, sim_tn, sim_ms, + template_id=template_id, + ) + old_norm = _normalize_ftir_processing_draft(prev_draft, template_id=template_id) + new_norm = _normalize_ftir_processing_draft(new_draft, template_id=template_id) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return new_norm, past2, fut2 + + +@callback( + Output("ftir-processing-draft", "data", allow_duplicate=True), + Output("ftir-processing-undo-stack", "data", allow_duplicate=True), + Output("ftir-processing-redo-stack", "data", allow_duplicate=True), + Output("ftir-history-hydrate", "data", allow_duplicate=True), + Output("ftir-history-status", "children", allow_duplicate=True), + Input("ftir-processing-undo-btn", "n_clicks"), + Input("ftir-processing-redo-btn", "n_clicks"), + Input("ftir-processing-reset-btn", "n_clicks"), + State("ftir-processing-draft", "data"), + State("ftir-processing-undo-stack", "data"), + State("ftir-processing-redo-stack", "data"), + State("ftir-history-hydrate", "data"), + State("ftir-processing-default", "data"), + State("ftir-template-select", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def ftir_processing_history_actions(n_undo, n_redo, n_reset, draft, undo_stack, redo_stack, hist_hydrate, defaults, template_id, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + cur = _normalize_ftir_processing_draft(draft, template_id=template_id) + past = undo_stack or [] + fut = redo_stack or [] + h = int(hist_hydrate or 0) + + if trig == "ftir-processing-undo-btn": + if not n_undo: + raise dash.exceptions.PreventUpdate + res = perform_undo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + prev, pl, fl = res + return prev, pl, fl, h + 1, translate_ui(loc, "dash.analysis.ftir.processing.history_status_undo") + + if trig == "ftir-processing-redo-btn": + if not n_redo: + raise dash.exceptions.PreventUpdate + res = perform_redo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + nxt, pl, fl = res + return nxt, pl, fl, h + 1, translate_ui(loc, "dash.analysis.ftir.processing.history_status_redo") + + if trig == "ftir-processing-reset-btn": + if not n_reset: + raise dash.exceptions.PreventUpdate + default_seed = copy.deepcopy(defaults or _default_ftir_processing_draft(template_id)) + if isinstance(default_seed.get("similarity_matching"), dict): + default_seed["similarity_matching"] = dict(default_seed["similarity_matching"]) + default_seed["similarity_matching"].pop("metric", None) + default_draft = _normalize_ftir_processing_draft(default_seed, template_id=template_id) + if ftir_draft_processing_equal(cur, default_draft): + raise dash.exceptions.PreventUpdate + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + past_list.append(copy.deepcopy(cur)) + if len(past_list) > MAX_FTIR_UNDO_DEPTH: + past_list = past_list[-MAX_FTIR_UNDO_DEPTH:] + return default_draft, past_list, [], h + 1, translate_ui(loc, "dash.analysis.ftir.processing.history_status_reset") + + raise dash.exceptions.PreventUpdate + + +@callback( + Output("ftir-processing-undo-btn", "disabled"), + Output("ftir-processing-redo-btn", "disabled"), + Input("ftir-processing-undo-stack", "data"), + Input("ftir-processing-redo-stack", "data"), +) +def toggle_ftir_processing_history_buttons(undo_stack, redo_stack): + u = undo_stack or [] + r = redo_stack or [] + return len(u) == 0, len(r) == 0 + + +# --------------------------------------------------------------------------- +# Run analysis +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-run-status", "children"), + Output("ftir-refresh", "data", allow_duplicate=True), + Output("ftir-latest-result-id", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("ftir-run-btn", "n_clicks"), + State("project-id", "data"), + State("ftir-dataset-select", "value"), + State("ftir-template-select", "value"), + State("ftir-processing-draft", "data"), + State("ftir-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def run_ftir_analysis(n_clicks, project_id, dataset_key, template_id, processing_draft, refresh_val, global_refresh, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id or not dataset_key: + raise dash.exceptions.PreventUpdate + + from dash_app.api_client import analysis_run + + overrides = _ftir_overrides_from_draft(processing_draft, template_id=template_id) + try: + result = analysis_run( + project_id=project_id, + dataset_key=dataset_key, + analysis_type="FTIR", + workflow_template_id=template_id, + processing_overrides=overrides or None, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.analysis_failed", error=str(exc)), color="danger"), dash.no_update, dash.no_update, dash.no_update + + alert, saved, result_id = interpret_run_result(result, locale_data=locale_data) + refresh = (refresh_val or 0) + 1 + if saved: + return alert, refresh, result_id, (global_refresh or 0) + 1 + return alert, refresh, dash.no_update, dash.no_update + + +# --------------------------------------------------------------------------- +# Display result (right-column surface) +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-result-analysis-summary", "children"), + Output("ftir-result-metrics", "children"), + Output("ftir-result-quality", "children"), + Output("ftir-result-figure", "children"), + Output("ftir-result-top-match", "children"), + Output("ftir-result-peak-cards", "children"), + Output("ftir-result-match-table", "children"), + Output("ftir-result-processing", "children"), + Output("ftir-result-raw-metadata", "children"), + Input("ftir-latest-result-id", "data"), + Input("ftir-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + Input("ftir-plot-settings", "data"), + State("project-id", "data"), +) +def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + summary_empty = html.P(translate_ui(loc, "dash.analysis.ftir.summary.empty"), className="text-muted") + quality_empty = _ftir_collapsible_section( + loc, + "dash.analysis.ftir.quality.card_title", + html.P(translate_ui(loc, "dash.analysis.ftir.quality.empty"), className="text-muted mb-0"), + open=False, + ) + raw_meta_empty = _ftir_collapsible_section( + loc, + "dash.analysis.ftir.raw_metadata.card_title", + html.P(translate_ui(loc, "dash.analysis.ftir.raw_metadata.empty"), className="text-muted mb-0"), + open=False, + ) + _deferred_hidden = html.Div(className="d-none") + metrics_hint = html.P(translate_ui(loc, "dash.analysis.ftir.empty_results_hint"), className="text-muted mb-0") + if not result_id or not project_id: + return ( + summary_empty, + metrics_hint, + quality_empty, + empty_msg, + _deferred_hidden, + _deferred_hidden, + _deferred_hidden, + _deferred_hidden, + raw_meta_empty, + ) + + from dash_app.api_client import workspace_dataset_detail, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + err = dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_result", error=str(exc)), color="danger") + return summary_empty, err, quality_empty, empty_msg, _deferred_hidden, _deferred_hidden, _deferred_hidden, _deferred_hidden, raw_meta_empty + + summary = detail.get("summary", {}) + result_meta = detail.get("result", {}) + processing = detail.get("processing", {}) + rows = detail.get("rows_preview", []) + dataset_key = result_meta.get("dataset_key") + + dataset_detail: dict = {} + if dataset_key: + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + + analysis_summary = _build_ftir_analysis_summary( + dataset_detail, + summary, + result_meta, + loc, + locale_data=locale_data, + ) + quality_panel = _build_ftir_quality_card(detail, result_meta, loc) + raw_metadata_panel = _build_ftir_raw_metadata_panel((dataset_detail or {}).get("metadata"), loc) + + peak_count = summary.get("peak_count", 0) + match_status = _match_status_label(loc, summary.get("match_status")) + top_score = summary.get("top_match_score", 0.0) + sample_name = resolve_sample_name(summary, result_meta, locale_data=locale_data) + na = translate_ui(loc, "dash.analysis.na") + lib_unavailable = _ftir_library_unavailable(summary) + top_score_str = ( + translate_ui(loc, "dash.analysis.ftir.metric.score_not_applicable") + if lib_unavailable + else (f"{float(top_score):.4f}" if top_score else na) + ) + + metrics = metrics_row( + [ + ("dash.analysis.metric.peaks", str(peak_count)), + ("dash.analysis.metric.match_status", match_status), + ("dash.analysis.metric.top_score", top_score_str), + ("dash.analysis.metric.sample", sample_name), + ], + locale_data=locale_data, + ) + + figure_area = empty_msg + top_match_area = empty_msg + peak_cards_area = empty_msg + if dataset_key: + figure_area = _build_figure(project_id, dataset_key, summary, ui_theme, loc, plot_settings=plot_settings) + top_match_area = _build_top_match_panel(summary, rows, loc) + peak_cards_area = _build_peak_cards_from_curves(project_id, dataset_key, summary, loc) + + table_area = _build_match_table(rows, loc, summary=summary) + + proc_view = processing_details_section( + processing, + extra_lines=[ + html.P(translate_ui(loc, "dash.analysis.ftir.baseline", detail=processing.get("signal_pipeline", {}).get("baseline", {}))), + html.P(translate_ui(loc, "dash.analysis.ftir.normalization", detail=processing.get("signal_pipeline", {}).get("normalization", {}))), + html.P(translate_ui(loc, "dash.analysis.ftir.peak_detection", detail=processing.get("analysis_steps", {}).get("peak_detection", {}))), + html.P(translate_ui(loc, "dash.analysis.ftir.similarity_matching", detail=processing.get("analysis_steps", {}).get("similarity_matching", {}))), + html.P( + translate_ui( + loc, + "dash.analysis.ftir.library", + mode=processing.get("method_context", {}).get("library_access_mode", na), + source=processing.get("method_context", {}).get("library_result_source", na), + ), + className="mb-0", + ), + ], + locale_data=locale_data, + ) + + return ( + analysis_summary, + metrics, + quality_panel, + figure_area, + top_match_area, + peak_cards_area, + table_area, + proc_view, + raw_metadata_panel, + ) + + +# --------------------------------------------------------------------------- +# Literature callbacks +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-literature-card-title", "children"), + Output("ftir-literature-hint", "children"), + Output("ftir-literature-max-claims-label", "children"), + Output("ftir-literature-persist-label", "children"), + Output("ftir-literature-compare-btn", "children"), + Input("ui-locale", "data"), + Input("ftir-latest-result-id", "data"), +) +def render_ftir_literature_chrome(locale_data, result_id): + loc = _loc(locale_data) + if result_id: + hint = literature_t( + loc, + f"{_FTIR_LITERATURE_PREFIX}.ready", + "Compare the saved FTIR result to literature sources.", + ) + else: + hint = literature_t( + loc, + f"{_FTIR_LITERATURE_PREFIX}.empty", + "Run an FTIR analysis first to enable literature comparison.", + ) + return ( + literature_t(loc, f"{_FTIR_LITERATURE_PREFIX}.title", "Literature Compare"), + hint, + literature_t(loc, f"{_FTIR_LITERATURE_PREFIX}.max_claims", "Max Claims"), + literature_t(loc, f"{_FTIR_LITERATURE_PREFIX}.persist", "Persist to project"), + literature_t(loc, f"{_FTIR_LITERATURE_PREFIX}.compare_btn", "Compare"), + ) + + +@callback( + Output("ftir-literature-compare-btn", "disabled"), + Input("ftir-latest-result-id", "data"), +) +def toggle_ftir_literature_compare_button(result_id): + return not bool(result_id) + + +@callback( + Output("ftir-literature-output", "children"), + Output("ftir-literature-status", "children"), + Input("ftir-literature-compare-btn", "n_clicks"), + State("project-id", "data"), + State("ftir-latest-result-id", "data"), + State("ftir-literature-max-claims", "value"), + State("ftir-literature-persist", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def compare_ftir_literature(n_clicks, project_id, result_id, max_claims, persist_values, locale_data): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + if not project_id or not result_id: + msg = literature_t( + loc, + f"{_FTIR_LITERATURE_PREFIX}.missing_result", + "Run an FTIR analysis first.", + ) + return dash.no_update, dbc.Alert(msg, color="warning", className="py-1 small") + + claims_limit = coerce_literature_max_claims(max_claims, default=3) + persist = bool(persist_values) and "persist" in (persist_values or []) + + from dash_app.api_client import literature_compare + + try: + payload = literature_compare( + project_id, + result_id, + max_claims=claims_limit, + persist=persist, + ) + except Exception as exc: + err = dbc.Alert( + literature_t( + loc, + f"{_FTIR_LITERATURE_PREFIX}.error", + "Literature compare failed: {error}", + ).replace("{error}", str(exc)), + color="danger", + className="py-1 small", + ) + return dash.no_update, err + + return ( + render_literature_output( + payload, + loc, + i18n_prefix=_FTIR_LITERATURE_PREFIX, + evidence_preview_limit=LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + alternative_preview_limit=LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + ), + literature_compare_status_alert(payload, loc, i18n_prefix=_FTIR_LITERATURE_PREFIX), + ) + + +def _ftir_fetch_figure_preview_data_urls(project_id: str, result_id: str, figure_artifacts: dict) -> dict[str, str]: + from dash_app.api_client import fetch_result_figure_png + + out: dict[str, str] = {} + for label in ordered_figure_preview_keys(figure_artifacts)[:FIGURE_ARTIFACT_PREVIEW_TILES]: + try: + raw = fetch_result_figure_png(project_id, result_id, label, max_edge=FIGURE_ARTIFACT_PREVIEW_MAX_EDGE) + out[label] = "data:image/png;base64," + base64.standard_b64encode(bytes(raw)).decode("ascii") if raw else "" + except Exception: + out[label] = "" + return out + + +@callback( + Output("ftir-figure-save-snapshot-btn", "children"), + Output("ftir-figure-use-report-btn", "children"), + Output("ftir-figure-artifacts-summary", "children"), + Input("ui-locale", "data"), +) +def render_ftir_figure_artifact_button_labels(locale_data): + return figure_artifact_button_labels(_loc(locale_data)) + + +@callback( + Output("ftir-figure-save-snapshot-btn", "disabled"), + Output("ftir-figure-use-report-btn", "disabled"), + Input("ftir-latest-result-id", "data"), +) +def toggle_ftir_figure_artifact_buttons(result_id): + disabled = not bool(result_id) + return disabled, disabled + + +@callback( + Output("ftir-result-figure-artifacts", "children"), + Input("ftir-latest-result-id", "data"), + Input("ftir-figure-artifact-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def refresh_ftir_figure_artifacts_panel(result_id, _artifact_refresh, locale_data, project_id): + loc = _loc(locale_data) + if not result_id or not project_id: + return "" + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + return "" + artifacts = detail.get("figure_artifacts") if isinstance(detail.get("figure_artifacts"), dict) else {} + previews = _ftir_fetch_figure_preview_data_urls(project_id, result_id, artifacts) if ordered_figure_preview_keys(artifacts) else None + return build_figure_artifacts_panel(artifacts, loc, previews=previews) + + +@callback( + Output("ftir-figure-artifact-status", "children"), + Output("ftir-figure-artifact-refresh", "data"), + Input("ftir-figure-save-snapshot-btn", "n_clicks"), + Input("ftir-figure-use-report-btn", "n_clicks"), + Input("ftir-latest-result-id", "data"), + State("project-id", "data"), + State("ftir-result-figure", "children"), + State("ui-locale", "data"), + State("ftir-figure-artifact-refresh", "data"), + prevent_initial_call=True, +) +def ftir_figure_snapshot_or_report_figure(_snap_clicks, _report_clicks, latest_result_id, project_id, figure_children, locale_data, refresh_value): + loc = _loc(locale_data) + triggered_id = getattr(dash.callback_context, "triggered_id", None) + if triggered_id == "ftir-latest-result-id": + return "", dash.no_update + action = figure_action_from_trigger( + triggered_id, + snapshot_button_id="ftir-figure-save-snapshot-btn", + report_button_id="ftir-figure-use-report-btn", + ) + if action is None: + raise dash.exceptions.PreventUpdate + if not project_id or not latest_result_id: + return ( + figure_action_status_alert(loc, action=action, status="missing", reason="missing_project_or_result", class_prefix="ftir"), + dash.no_update, + ) + + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, latest_result_id) + except Exception as exc: + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(exc), class_prefix="ftir"), + dash.no_update, + ) + result_meta = detail.get("result", {}) or {} + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + meta = figure_action_metadata( + action, + analysis_type="FTIR", + dataset_key=result_meta.get("dataset_key"), + result_id=latest_result_id, + snapshot_stamp=stamp, + ) + outcome = register_result_figure_from_layout_children( + figure_children=figure_children, + project_id=project_id, + result_id=latest_result_id, + label=str(meta.get("label") or ""), + replace=bool(meta.get("replace")), + ) + if outcome.get("status") == "ok": + key = str(outcome.get("figure_key") or meta.get("label") or "") + return ( + figure_action_status_alert(loc, action=action, status="ok", figure_key=key, class_prefix="ftir"), + (refresh_value or 0) + 1, + ) + if outcome.get("status") == "error": + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(outcome.get("reason") or ""), class_prefix="ftir"), + dash.no_update, + ) + return ( + figure_action_status_alert(loc, action=action, status="skipped", reason=str(outcome.get("reason") or ""), class_prefix="ftir"), + dash.no_update, + ) + + +# --------------------------------------------------------------------------- +# Figure capture +# --------------------------------------------------------------------------- + + +@callback( + Output("ftir-figure-captured", "data"), + Input("ftir-latest-result-id", "data"), + Input("project-id", "data"), + Input("ftir-result-figure", "children"), + State("ftir-figure-captured", "data"), + prevent_initial_call=True, +) +def capture_ftir_figure(result_id, project_id, figure_children, captured): + return capture_result_figure_from_layout( + result_id=result_id, + project_id=project_id, + figure_children=figure_children, + captured=captured, + analysis_type="FTIR", + ) + + +# --------------------------------------------------------------------------- +# FTIR-specific result builders +# --------------------------------------------------------------------------- + + +def _format_dataset_metadata_value(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, float): + if value != value: + return None + text = f"{value:g}" + else: + text = str(value).strip() + return text or None + + +def _match_status_label(loc: str, raw: str | None) -> str: + token = str(raw or "no_match").lower().replace(" ", "_") + key = f"dash.analysis.match_status.{token}" + text = translate_ui(loc, key) + if text == key: + s = str(raw or "").replace("_", " ").strip() + return s.title() if s else translate_ui(loc, "dash.analysis.na") + return text + + +def _confidence_band_label(loc: str, band: str | None) -> str: + token = str(band or "no_match").lower().replace(" ", "_") + key = f"dash.analysis.confidence.{token}" + text = translate_ui(loc, key) + if text == key: + return str(band).replace("_", " ").title() + return text + + +_CONFIDENCE_COLORS = { + "high_confidence": "#059669", + "moderate_confidence": "#D97706", + "low_confidence": "#DC2626", + "no_match": "#6B7280", +} + +_FTIR_FIGURE_COLORS = { + "query": "#0F172A", + "smoothed": "#0E7490", + "raw": "#94A3B8", + "baseline": "#B45309", + "normalized": "#7C3AED", + "grid": "rgba(148, 163, 184, 0.18)", + "axis": "#475569", + "panel": "#FCFDFE", +} + + +def _build_ftir_analysis_summary( + dataset_detail: dict, + summary: dict, + result_meta: dict, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + metadata = (dataset_detail or {}).get("metadata") or {} + dataset_summary = (dataset_detail or {}).get("dataset") or {} + na = translate_ui(loc, "dash.analysis.na") + + dataset_label = ( + _format_dataset_metadata_value(metadata.get("file_name")) + or _format_dataset_metadata_value(dataset_summary.get("display_name")) + or _format_dataset_metadata_value(result_meta.get("dataset_key")) + or na + ) + fallback_display_name = _format_dataset_metadata_value(dataset_summary.get("display_name")) + sample_label = resolve_sample_name( + summary or {}, + result_meta or {}, + fallback_display_name=fallback_display_name, + locale_data=locale_data, + ) or na + + instrument = _format_dataset_metadata_value(metadata.get("instrument")) or na + vendor = _format_dataset_metadata_value(metadata.get("vendor")) or na + + def _meta_value(value: str) -> html.Span: + return html.Span(value, className="ms-meta-value", title=value) + + dl_rows: list[Any] = [ + html.Dt(translate_ui(loc, "dash.analysis.ftir.summary.dataset_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(dataset_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.ftir.summary.sample_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.ftir.summary.instrument_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(instrument), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.ftir.summary.vendor_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(vendor), className="col-sm-8 ms-meta-def"), + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.ftir.summary.card_title"), className="mb-3"), + html.Dl(dl_rows, className="row mb-0"), + ] + ) + + +def _build_ftir_quality_card(detail: dict, result_meta: dict, loc: str) -> html.Details: + return build_validation_quality_card( + detail, + result_meta, + loc, + i18n_prefix="dash.analysis.ftir.quality", + collapsible_builder=_ftir_collapsible_section, + derive_counts_from_lists=True, + open_when_attention=True, + include_attention_badges=True, + ) + + +def _build_ftir_raw_metadata_panel(metadata: dict | None, loc: str) -> html.Details: + return build_split_raw_metadata_panel( + metadata, + loc, + i18n_prefix="dash.analysis.ftir.raw_metadata", + user_facing_keys=_FTIR_USER_FACING_METADATA_KEYS, + value_formatter=_format_dataset_metadata_value, + collapsible_builder=_ftir_collapsible_section, + ) + + +def _finite_series(values: list | None) -> list[float]: + series: list[float] = [] + for value in values or []: + if value is None: + continue + numeric = float(value) + if math.isfinite(numeric): + series.append(numeric) + return series + + +def _y_axis_range(*series: list | None) -> list[float] | None: + values: list[float] = [] + for entry in series: + values.extend(_finite_series(entry)) + if not values: + return None + y_min = min(values) + y_max = max(values) + span = y_max - y_min + padding = span * 0.08 if span > 0 else max(abs(y_max) * 0.12, 0.05) + return [y_min - padding, y_max + padding] + + +def _build_figure( + project_id: str, + dataset_key: str, + summary: dict, + ui_theme: str | None, + loc: str, + *, + plot_settings: dict | None = None, +) -> html.Div: + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "FTIR", dataset_key) + except Exception: + curves = {} + + wavenumber = curves.get("temperature", []) + raw_signal = curves.get("raw_signal", []) + smoothed = curves.get("smoothed", []) + baseline = curves.get("baseline", []) + corrected = curves.get("corrected", []) + normalized = curves.get("normalized", []) + peaks = curves.get("peaks", []) + diagnostics = curves.get("diagnostics") or {} + settings = normalize_spectral_plot_settings(plot_settings) + + has_corrected = bool(corrected and len(corrected) == len(wavenumber)) + has_smoothed = bool(smoothed and len(smoothed) == len(wavenumber)) + has_normalized_curve = bool(normalized and len(normalized) == len(wavenumber)) + has_baseline = bool(baseline and len(baseline) == len(wavenumber)) + has_raw = bool(raw_signal and len(raw_signal) == len(wavenumber)) + plot_norm_primary = diagnostics.get("plot_normalized_primary_axis") is not False + show_normalized_trace = bool(settings["show_normalized"] and has_normalized_curve and plot_norm_primary) + show_corrected_trace = bool(settings["show_corrected"] and has_corrected) + show_intermediate_smoothed = bool(settings["show_smoothed"] and has_smoothed and not show_corrected_trace) + show_raw_trace = bool(settings["show_raw"] and has_raw) + show_baseline_trace = bool(has_baseline and has_corrected) + + has_overlay = bool( + show_baseline_trace + or show_intermediate_smoothed + or show_corrected_trace + or show_normalized_trace + or (show_raw_trace and (show_corrected_trace or show_intermediate_smoothed)) + ) + + if not wavenumber: + return no_data_figure_msg(locale_data=loc) + + sample_name = resolve_sample_name(summary, {}, fallback_display_name=dataset_key, locale_data=loc) + tone = normalize_ui_theme(ui_theme) + pt = PLOT_THEME[tone] + muted = "#66645E" if tone == "light" else "#9E9A93" + legend_bg = "rgba(255,255,255,0.9)" if tone == "light" else "rgba(26,25,23,0.94)" + hover_bg = "rgba(255,255,255,0.96)" if tone == "light" else "rgba(34,33,30,0.96)" + hover_fg = "#1C1A1A" if tone == "light" else "#EEEDEA" + + dominant_signal = corrected if show_corrected_trace else smoothed if show_intermediate_smoothed else raw_signal if show_raw_trace else [] + legend_query = translate_ui(loc, "dash.analysis.figure.legend_query_spectrum") + legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed_spectrum") + legend_imported = translate_ui(loc, "dash.analysis.figure.legend_imported_spectrum") + legend_baseline = translate_ui(loc, "dash.analysis.figure.legend_estimated_baseline") + legend_normalized = translate_ui(loc, "dash.analysis.ftir.legend_normalized_spectrum") + + if diagnostics.get("inverted_for_transmittance"): + suffix = " (inverted)" + legend_smooth += suffix + legend_query += suffix + legend_normalized += suffix + + y_series_for_range = [dominant_signal] + if show_raw_trace: + y_series_for_range.append(raw_signal) + if show_baseline_trace: + y_series_for_range.append(baseline) + if show_intermediate_smoothed: + y_series_for_range.append(smoothed) + if show_normalized_trace: + y_series_for_range.append(normalized) + y_range = _y_axis_range(*y_series_for_range) + if settings["y_range_enabled"] and settings["y_min"] is not None and settings["y_max"] is not None: + y_range = [settings["y_min"], settings["y_max"]] + + fig = go.Figure() + line_scale = float(settings["line_width_scale"]) + marker_scale = float(settings["marker_size_scale"]) + + if show_baseline_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=baseline, + mode="lines", + name=legend_baseline, + line=dict(color=_FTIR_FIGURE_COLORS["baseline"], width=1.3 * line_scale, dash="dash"), + opacity=0.65, + ) + ) + + if show_raw_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=raw_signal, + mode="lines", + name=legend_imported, + line=dict(color=_FTIR_FIGURE_COLORS["raw"], width=1.6 * line_scale), + opacity=0.45 if has_overlay else 0.95, + ) + ) + + if show_intermediate_smoothed: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=smoothed, + mode="lines", + name=legend_smooth, + line=dict(color=_FTIR_FIGURE_COLORS["smoothed"], width=2.0 * line_scale), + opacity=0.95, + ) + ) + + if show_corrected_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=corrected, + mode="lines", + name=legend_query, + line=dict(color=_FTIR_FIGURE_COLORS["query"], width=3.2 * line_scale), + opacity=0.95 if show_normalized_trace else 1.0, + ) + ) + + if show_normalized_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=normalized, + mode="lines", + name=legend_normalized, + line=dict(color=_FTIR_FIGURE_COLORS["normalized"], width=2.4 * line_scale), + ) + ) + + def _peak_display_y(index: int) -> float | None: + if has_corrected and index < len(corrected): + return float(corrected[index]) + if show_intermediate_smoothed and index < len(smoothed): + return float(smoothed[index]) + if raw_signal and index < len(raw_signal): + return float(raw_signal[index]) + return None + + # Peak annotations (top 8 only to avoid clutter) + _ANNOTATION_MIN_SEP = 20.0 + annotated_positions: list[float] = [] + peak_count = len(peaks) + for i, peak in enumerate(peaks[:_FTIR_MAX_PEAK_CARDS] if settings["show_peaks"] else []): + pos = peak.get("position") + intensity = peak.get("intensity") + if pos is None or not wavenumber: + continue + idx = min(range(len(wavenumber)), key=lambda i: abs(wavenumber[i] - pos)) + too_close = any(abs(pos - p) < _ANNOTATION_MIN_SEP for p in annotated_positions) + label = "" if too_close else f"{pos:.0f}" + y_at = _peak_display_y(idx) + if y_at is None: + continue + fig.add_trace( + go.Scatter( + x=[wavenumber[idx]], + y=[y_at], + mode="markers+text", + marker=dict(size=7 * marker_scale, color="#DC2626", symbol="diamond", line=dict(color="white", width=1)), + text=[label], + textposition="top center", + textfont=dict(size=8, color="#DC2626"), + name=f"Peak {pos:.0f}", + showlegend=False, + ) + ) + if label: + annotated_positions.append(pos) + + title_main = translate_ui(loc, "dash.analysis.figure.title_ftir_main") + show_legend, legend_layout = spectral_legend_layout(len(fig.data), settings, theme=pt, legend_bg=legend_bg) + x_axis: dict[str, Any] = { + "showgrid": settings["show_grid"], + "showspikes": settings["show_spikes"], + "gridcolor": pt["grid"], + "linecolor": pt["grid"], + "tickfont": dict(size=12, color=pt["text"]), + "title_font": dict(size=13, color=pt["text"]), + "zeroline": False, + } + if settings["x_range_enabled"] and settings["x_min"] is not None and settings["x_max"] is not None: + x_range = [settings["x_min"], settings["x_max"]] + if settings["reverse_x_axis"]: + x_range = list(reversed(x_range)) + x_axis["range"] = x_range + elif settings["reverse_x_axis"]: + x_axis["autorange"] = "reversed" + y_axis = { + "range": y_range, + "showgrid": settings["show_grid"], + "showspikes": settings["show_spikes"], + "gridcolor": pt["grid"], + "linecolor": pt["grid"], + "tickfont": dict(size=12, color=pt["text"]), + "title_font": dict(size=13, color=pt["text"]), + "zeroline": False, + } + fig.update_layout( + title=(f"{title_main}
{sample_name}"), + paper_bgcolor=pt["paper_bg"], + plot_bgcolor=pt["plot_bg"], + hovermode="x unified", + xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_wavenumber"), + yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_signal_au"), + xaxis=x_axis, + yaxis=y_axis, + margin=dict(l=64, r=112 if show_legend and legend_layout.get("x") == 1.02 else 28, t=82, b=56), + height=460 if settings["compact"] else 520, + title_font=dict(size=20, color=pt["text"]), + title_x=0.01, + showlegend=show_legend, + legend=legend_layout, + hoverlabel=dict(bgcolor=hover_bg, font=dict(color=hover_fg)), + ) + fig.update_layout(meta={"plot_display_settings": settings}) + fig.update_layout(template=pt["template"]) + + peak_count_disp = summary.get("peak_count", peak_count) + top_match_name = summary.get("top_match_name") + match_status = _match_status_label(loc, summary.get("match_status")) + confidence = _confidence_band_label(loc, summary.get("confidence_band")) + na = translate_ui(loc, "dash.analysis.na") + match_str = f"{top_match_name}" if top_match_name else na + + run_caption = translate_ui( + loc, + "dash.analysis.ftir.figure.run_summary", + peaks=str(peak_count_disp), + match=match_str, + status=match_status, + confidence=confidence, + ) + + diag_notes: list[str] = [] + if diagnostics.get("inverted_for_transmittance"): + diag_notes.append("Signal interpreted as transmittance; inverted for analysis.") + if diagnostics.get("baseline_suppressed"): + diag_notes.append(f"Baseline suppressed: {diagnostics.get('baseline_suppression_reason', '')}") + if diagnostics.get("normalization_skipped"): + diag_notes.append(f"Normalization skipped: {diagnostics.get('normalization_skip_reason', '')}") + if diagnostics.get("peak_detection_fallback"): + diag_notes.append(f"Peak detection fallback: {diagnostics.get('peak_detection_reason', '')}") + if diagnostics.get("peak_detection_no_peaks"): + diag_notes.append(f"No peaks detected: {diagnostics.get('peak_detection_reason', '')}") + + diag_children = [html.P(note, className="small text-warning mb-1") for note in diag_notes] + + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.ftir.figure.section_title"), className="mb-2"), + html.P(run_caption, className="small text-muted mb-2"), + dcc.Graph(figure=fig, config=build_spectral_plotly_config(settings, filename="materialscope_ftir_spectrum"), className="ta-plot"), + *diag_children, + ] + ) + + +def _build_top_match_panel(summary: dict, rows: list, loc: str) -> html.Div: + if not rows: + if str(summary.get("match_status") or "").lower() == "library_unavailable": + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.ftir.library.reference_title"), className="mb-3"), + dbc.Alert( + translate_ui(loc, "dash.analysis.ftir.library.not_configured_for_run"), + color="info", + className="mb-0 small", + ), + ] + ) + body = translate_ui(loc, "dash.analysis.state.no_library_matches") + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.ftir.top_match.title"), className="mb-3"), + html.P(body, className="text-muted"), + ] + ) + + top = rows[0] + score = top.get("normalized_score", 0.0) + band = str(top.get("confidence_band", "no_match")).lower() + color = _CONFIDENCE_COLORS.get(band, "#6B7280") + candidate_name = top.get("candidate_name", translate_ui(loc, "dash.analysis.unknown_candidate")) + candidate_id = top.get("candidate_id", "") + provider = top.get("library_provider", "") + package = top.get("library_package", "") + evidence = top.get("evidence", {}) + shared = evidence.get("shared_peak_count", "--") + observed = evidence.get("observed_peak_count", "--") + + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.ftir.top_match.title"), className="mb-3"), + dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className="bi bi-trophy me-2", style={"color": color, "fontSize": "1.1rem"}), + html.Strong(candidate_name, className="me-2"), + html.Span( + _confidence_band_label(loc, band), + className="badge", + style={"backgroundColor": color, "color": "white", "fontSize": "0.75rem"}, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.score"), className="text-muted d-block"), html.Span(f"{score:.4f}")], md=3), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.provider"), className="text-muted d-block"), html.Span(provider or "--")], md=3), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.package"), className="text-muted d-block"), html.Span(package or "--")], md=3), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.peak_overlap"), className="text-muted d-block"), html.Span(f"{shared}/{observed}")], md=3), + ], + className="g-2", + ), + *( + [html.P(translate_ui(loc, "dash.analysis.id_label", id=candidate_id), className="text-muted small mb-0 mt-1")] + if candidate_id + else [] + ), + ] + ), + className="mb-3", + ), + ] + ) + + +def _build_peak_cards_from_curves(project_id: str, dataset_key: str, summary: dict, loc: str) -> html.Div: + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "FTIR", dataset_key) + except Exception: + curves = {} + + peaks = curves.get("peaks", []) + if not peaks: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.ftir.peaks.title"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_peaks"), className="text-muted"), + ] + ) + + total = len(peaks) + truncated = total >= _FTIR_TRUNCATE_PEAK_CARDS_WHEN + shown = peaks[:_FTIR_MAX_PEAK_CARDS] if truncated else peaks + + cards: list[Any] = [html.H5(translate_ui(loc, "dash.analysis.ftir.peaks.title"), className="mb-3")] + for idx, peak in enumerate(shown): + pos = peak.get("position") + intensity = peak.get("intensity") + cards.append( + dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className="bi bi-activity me-2", style={"color": "#DC2626", "fontSize": "1.1rem"}), + html.Strong(translate_ui(loc, "dash.analysis.label.peak_n", n=idx + 1), className="me-2"), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.position"), className="text-muted d-block"), html.Span(f"{pos:.1f}" if pos is not None else "--")], md=6), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.intensity"), className="text-muted d-block"), html.Span(f"{intensity:.4f}" if intensity is not None else "--")], md=6), + ], + className="g-2", + ), + ] + ), + className="mb-2", + ) + ) + if truncated: + cards.append( + html.P( + translate_ui(loc, "dash.analysis.ftir.peaks.truncation_note", shown=len(shown), total=total), + className="small text-muted mb-1", + ) + ) + return html.Div(cards) + + +def _build_match_table(rows: list, loc: str, *, summary: dict | None = None) -> html.Div: + if not rows: + summary = summary or {} + if str(summary.get("match_status") or "").lower() == "library_unavailable": + return html.Div(className="d-none") + body = translate_ui(loc, "dash.analysis.state.no_match_data") + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.match_data_table"), className="mb-3"), + html.P(body, className="text-muted"), + ] + ) + + columns = [ + "rank", + "candidate_id", + "candidate_name", + "normalized_score", + "confidence_band", + "library_provider", + "library_package", + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.match_data_table"), className="mb-3"), + dataset_table(rows, columns, table_id="ftir-matches-table"), + ] + ) diff --git a/dash_app/pages/home.py b/dash_app/pages/home.py new file mode 100644 index 00000000..3ea153b9 --- /dev/null +++ b/dash_app/pages/home.py @@ -0,0 +1,1541 @@ +"""Import page -- modality-first, multi-step import wizard with rich dataset cards.""" + +from __future__ import annotations + +import base64 + +import dash +import dash_bootstrap_components as dbc +from dash import ALL, Input, Output, State, callback, clientside_callback, dcc, html + +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import ( + dataset_table, + metadata_list, + metric_cards, + original_columns_list, + quick_plot, + stats_table, +) +from dash_app.components.page_guidance import ( + guidance_block, + next_step_block, + prereq_or_empty_help, + typical_workflow_block, +) +from dash_app.components.stepper import stepper_indicator +from dash_app.import_preview import build_import_preview +from dash_app.sample_data import list_sample_specs, resolve_sample_request +from utils.i18n import TRANSLATIONS, normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/", title="Import - MaterialScope") + +_NONE_VALUE = "__NONE__" + +_MODALITY_OPTIONS = ["DSC", "TGA", "DTA", "FTIR", "RAMAN", "XRD"] + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _modality_axis_label(loc: str, modality: str) -> str: + tok = (modality or "").strip().upper() + if not tok: + return translate_ui(loc, "dash.home.axis_column_generic") + key = f"dash.home.modality_axis.{tok.lower()}" + if key in TRANSLATIONS: + return translate_ui(loc, key) + return translate_ui(loc, "dash.home.axis_column_generic") + + +def _modality_signal_label(loc: str, modality: str) -> str: + tok = (modality or "").strip().upper() + if not tok: + return translate_ui(loc, "dash.home.signal_column_generic") + key = f"dash.home.modality_signal.{tok.lower()}" + if key in TRANSLATIONS: + return translate_ui(loc, key) + return translate_ui(loc, "dash.home.signal_column_generic") + + +def _modality_desc(loc: str, modality: str) -> str: + tok = (modality or "").strip().upper() + if not tok: + return "" + key = f"dash.home.modality_desc.{tok.lower()}" + if key in TRANSLATIONS: + return translate_ui(loc, key) + return "" + + +def _wizard_steps(loc: str) -> list[dict[str, str]]: + out: list[dict[str, str]] = [] + for i in range(1, 7): + out.append( + { + "label": translate_ui(loc, f"dash.home.stepper.step{i}_label"), + "description": translate_ui(loc, f"dash.home.stepper.step{i}_desc"), + } + ) + return out + + +def _mapping_options(columns: list[str], loc: str) -> list[dict[str, str]]: + return [{"label": translate_ui(loc, "dash.home.mapping_none"), "value": _NONE_VALUE}] + [ + {"label": column, "value": column} + for column in columns + ] + + +def _summary_card(label: str, value: str) -> dbc.Card: + return dbc.Card( + dbc.CardBody([html.Small(label, className="text-muted text-uppercase"), html.H4(value, className="mb-0")]) + ) + + +def _build_metrics(datasets: list[dict], loc: str) -> dbc.Row: + vendors = {item.get("vendor", "Generic") for item in datasets} + by_type = { + token: sum(1 for item in datasets if item.get("data_type") == token) + for token in _MODALITY_OPTIONS + } + type_summary = " / ".join(str(by_type[token]) for token in _MODALITY_OPTIONS) + return dbc.Row( + [ + dbc.Col(_summary_card(translate_ui(loc, "dash.home.metric_loaded_runs"), str(len(datasets))), md=4), + dbc.Col(_summary_card(translate_ui(loc, "dash.home.metric_type_breakdown"), type_summary), md=4), + dbc.Col(_summary_card(translate_ui(loc, "dash.home.metric_vendors"), str(len(vendors))), md=4), + ], + className="g-3 mb-4", + ) + + +def _sample_buttons() -> list[dbc.Col]: + cols: list[dbc.Col] = [] + for spec in list_sample_specs(): + cols.append( + dbc.Col( + dbc.Button( + spec["label"], + id={"type": "sample-load", "sample_id": spec["id"]}, + color="secondary", + className="w-100", + ), + md=6, + className="mb-2", + ) + ) + return cols + + +def _modality_select_buttons() -> html.Div: + """Render modality selection as large button group (descriptions filled by locale callback).""" + buttons = [] + for token in _MODALITY_OPTIONS: + buttons.append( + dbc.Col( + dbc.Button( + [ + html.Div(token, className="fw-bold fs-5"), + html.Small( + id={"type": "modality-desc", "modality": token}, + className="d-block mt-1 text-start", + style={"fontSize": "0.7rem"}, + children="", + ), + ], + id={"type": "modality-select", "modality": token}, + color="outline-secondary", + className="w-100 text-start p-3 modality-btn", + ), + md=4, + className="mb-2", + ) + ) + return dbc.Row(buttons) + + +def _validation_status_badge(status: str, loc: str) -> html.Span: + color_map = { + "pass": "success", + "pass_with_review": "info", + "warn": "warning", + "fail": "danger", + } + color = color_map.get(status, "secondary") + badge_key = f"dash.home.validation_badge.{status}" + if badge_key in TRANSLATIONS: + label = translate_ui(loc, badge_key) + else: + label = translate_ui(loc, "dash.home.validation_badge.unknown") + return dbc.Badge(label, color=color, className="fs-6") + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = html.Div( + [ + # -- Stores -- + dcc.Store(id="import-wizard-step", data=0), + dcc.Store(id="import-selected-modality", data=""), + dcc.Store(id="pending-upload-files", data=[]), + dcc.Store(id="pending-import-preview"), + dcc.Store(id="import-review-data"), + dcc.Store(id="home-refresh", data=0), + + html.Div(id="home-hero-slot"), + html.Div(id="home-guidance-slot", className="mb-2"), + + # -- Wizard stepper indicator -- + html.Div(id="wizard-stepper-display"), + html.Div(id="import-metrics"), + + # ============================================= + # STEP 1: Modality Selection + # ============================================= + html.Div( + id="wizard-step-1", + children=[ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="home-step1-title", children="", className="mb-3"), + html.P(id="home-step1-intro", children="", className="text-muted"), + _modality_select_buttons(), + html.Div(id="modality-select-status", className="mt-2"), + ] + ), + className="mb-4", + ), + ], + ), + + # ============================================= + # STEP 2: File Upload + Sample Data + # ============================================= + html.Div( + id="wizard-step-2", + children=[ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="step2-title", children="", className="mb-3"), + html.Div(id="step2-modality-badge", className="mb-3"), + dcc.Upload( + id="file-upload", + children=html.Div( + [ + html.I(className="bi bi-cloud-arrow-up fs-1 d-block mb-2 text-muted"), + html.Div(id="home-upload-caption", className="text-center"), + ], + className="text-center py-4", + ), + className="upload-zone", + multiple=True, + ), + html.Div(id="upload-status", className="mt-3"), + dbc.Select(id="pending-file-select", className="mt-3"), + html.Div(id="pending-file-help", className="small text-muted mt-2"), + html.Hr(className="my-4"), + html.H5(id="home-sample-title", children="", className="mb-3"), + html.P(id="home-sample-intro", children="", className="text-muted"), + dbc.Row(_sample_buttons()), + html.Div(id="sample-status", className="mt-3"), + ] + ), + className="mb-4", + ), + dbc.Row( + [ + dbc.Col( + dbc.Button("", id="step2-prev-btn", color="secondary", outline=True), + width="auto", + ), + dbc.Col( + dbc.Button("", id="step2-next-btn", color="primary"), + width="auto", + ), + ], + className="g-2", + ), + ], + style={"display": "none"}, + ), + + # ============================================= + # STEP 3: Raw Preview + # ============================================= + html.Div( + id="wizard-step-3", + children=[ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="home-step3-title", children="", className="mb-3"), + html.Div(id="mapping-preview-status", className="mb-3"), + html.Div(id="mapping-preview-table"), + ] + ), + className="mb-4", + ), + dbc.Row( + [ + dbc.Col( + dbc.Button("", id="step3-prev-btn", color="secondary", outline=True), + width="auto", + ), + dbc.Col( + dbc.Button("", id="step3-next-btn", color="primary"), + width="auto", + ), + ], + className="g-2", + ), + ], + style={"display": "none"}, + ), + + # ============================================= + # STEP 4: Column Mapping + # ============================================= + html.Div( + id="wizard-step-4", + children=[ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="home-step4-title", children="", className="mb-3"), + html.P(id="home-step4-intro", children="", className="text-muted"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="mapping-axis-label", children="Axis Column", className="mt-3"), + dbc.Select(id="mapping-temp-select"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="mapping-signal-label", children="Signal Column", className="mt-3"), + dbc.Select(id="mapping-signal-select"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="home-mapping-time-label", children="", className="mt-3"), + dbc.Select(id="mapping-time-select"), + ], + md=4, + ), + ], + className="g-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="home-mapping-sample-name-label", children="", className="mt-3"), + dbc.Input(id="mapping-sample-name", type="text"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="home-mapping-sample-mass-label", children="", className="mt-3"), + dbc.Input(id="mapping-sample-mass", type="number", value=0), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="mapping-rate-label", children="Heating Rate (°C/min)", className="mt-3"), + dbc.Input(id="mapping-heating-rate", type="number", value=10), + ], + md=4, + ), + ], + className="g-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="home-mapping-xrd-label", children="", className="mt-3"), + dbc.Input(id="mapping-xrd-wavelength", type="number", value=1.5406), + ], + md=4, + ), + ], + className="g-3", + id="xrd-wavelength-row", + ), + ] + ), + className="mb-4", + ), + dbc.Row( + [ + dbc.Col( + dbc.Button("", id="step4-prev-btn", color="secondary", outline=True), + width="auto", + ), + dbc.Col( + dbc.Button("", id="step4-next-btn", color="primary"), + width="auto", + ), + ], + className="g-2", + ), + ], + style={"display": "none"}, + ), + + # ============================================= + # STEP 5: Unit / Metadata Review + # ============================================= + html.Div( + id="wizard-step-5", + children=[ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="home-step5-title", children="", className="mb-3"), + html.Div(id="review-unit-status"), + html.Div(id="review-metadata-summary"), + html.Div(id="review-warnings-list"), + ] + ), + className="mb-4", + ), + dbc.Row( + [ + dbc.Col( + dbc.Button("", id="step5-prev-btn", color="secondary", outline=True), + width="auto", + ), + dbc.Col( + dbc.Button("", id="step5-next-btn", color="primary"), + width="auto", + ), + ], + className="g-2", + ), + ], + style={"display": "none"}, + ), + + # ============================================= + # STEP 6: Validation Summary + Confirm + # ============================================= + html.Div( + id="wizard-step-6", + children=[ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="home-step6-title", children="", className="mb-3"), + html.Div(id="validation-summary-status"), + html.Div(id="validation-summary-warnings"), + html.Div(id="validation-summary-details"), + html.Hr(className="my-3"), + dbc.Button( + "", + id="import-mapped-btn", + color="success", + size="lg", + className="w-100", + ), + ] + ), + className="mb-4", + ), + dbc.Row( + [ + dbc.Col( + dbc.Button("", id="step6-prev-btn", color="secondary", outline=True), + width="auto", + ), + ], + className="g-2", + ), + ], + style={"display": "none"}, + ), + + # ============================================= + # Loaded Datasets Panel (always visible) + # ============================================= + html.Hr(className="my-4"), + html.H5(id="home-loaded-title", children="", className="mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Card( + dbc.CardBody( + [ + html.Div(id="datasets-table"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="home-active-dataset-label", children=""), + dbc.Select(id="active-dataset-select"), + ], + md=9, + ), + dbc.Col( + dbc.Button( + "", + id="remove-dataset-btn", + color="secondary", + className="ta-btn-remove w-100", + ), + md=3, + className="d-flex align-items-center", + ), + ], + className="g-2 align-items-center", + ), + html.Div(id="dataset-action-status", className="mt-3"), + ] + ), + className="mb-4", + ), + ], + md=6, + ), + dbc.Col( + [ + dbc.Card( + dbc.CardBody([html.Div(id="dataset-detail-panel")]), + className="mb-4", + ), + ], + md=6, + ), + ] + ), + ] +) + + +@callback( + Output("home-hero-slot", "children"), + Output("home-guidance-slot", "children"), + Output("home-step1-title", "children"), + Output("home-step1-intro", "children"), + Output("step2-title", "children"), + Output("home-upload-caption", "children"), + Output("home-sample-title", "children"), + Output("home-sample-intro", "children"), + Output("step2-prev-btn", "children"), + Output("step2-next-btn", "children"), + Output("home-step3-title", "children"), + Output("step3-prev-btn", "children"), + Output("step3-next-btn", "children"), + Output("home-step4-title", "children"), + Output("home-step4-intro", "children"), + Output("step4-prev-btn", "children"), + Output("step4-next-btn", "children"), + Output("home-step5-title", "children"), + Output("step5-prev-btn", "children"), + Output("step5-next-btn", "children"), + Output("home-step6-title", "children"), + Output("step6-prev-btn", "children"), + Output("import-mapped-btn", "children"), + Output("home-loaded-title", "children"), + Output("home-mapping-time-label", "children"), + Output("home-mapping-sample-name-label", "children"), + Output("home-mapping-sample-mass-label", "children"), + Output("home-mapping-xrd-label", "children"), + Output("home-active-dataset-label", "children"), + Output("remove-dataset-btn", "children"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def render_home_locale_chrome(locale_data): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.home.title"), + translate_ui(loc, "dash.home.caption"), + badge=translate_ui(loc, "dash.home.badge"), + ) + guidance = html.Div( + [ + guidance_block( + translate_ui(loc, "dash.home.guidance_title"), + body=translate_ui(loc, "dash.home.guidance_body"), + ), + ] + ) + upload_caption = [ + translate_ui(loc, "dash.home.upload_drop"), + html.A(translate_ui(loc, "dash.home.upload_browse"), className="ta-link-emphasis"), + ] + return ( + hero, + guidance, + translate_ui(loc, "dash.home.step1_title"), + translate_ui(loc, "dash.home.step1_intro"), + translate_ui(loc, "dash.home.step2_title"), + upload_caption, + translate_ui(loc, "dash.home.sample_section_title"), + translate_ui(loc, "dash.home.sample_section_intro"), + translate_ui(loc, "dash.home.btn_back"), + translate_ui(loc, "dash.home.btn_next_preview"), + translate_ui(loc, "dash.home.step3_title"), + translate_ui(loc, "dash.home.btn_back"), + translate_ui(loc, "dash.home.btn_next_map"), + translate_ui(loc, "dash.home.step4_title"), + translate_ui(loc, "dash.home.step4_intro"), + translate_ui(loc, "dash.home.btn_back"), + translate_ui(loc, "dash.home.btn_next_review"), + translate_ui(loc, "dash.home.step5_title"), + translate_ui(loc, "dash.home.btn_back"), + translate_ui(loc, "dash.home.btn_next_confirm"), + translate_ui(loc, "dash.home.step6_title"), + translate_ui(loc, "dash.home.btn_back"), + translate_ui(loc, "dash.home.btn_confirm_import"), + translate_ui(loc, "dash.home.loaded_datasets_title"), + translate_ui(loc, "dash.home.label_time_optional"), + translate_ui(loc, "dash.home.label_sample_name"), + translate_ui(loc, "dash.home.label_sample_mass"), + translate_ui(loc, "dash.home.label_xrd_wavelength"), + translate_ui(loc, "dash.home.label_active_dataset"), + translate_ui(loc, "dash.home.btn_remove"), + ) + + +@callback( + Output({"type": "modality-desc", "modality": ALL}, "children"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def render_home_modality_descriptions(locale_data): + loc = _loc(locale_data) + return [translate_ui(loc, f"dash.home.modality_desc.{token.lower()}") for token in _MODALITY_OPTIONS] + + +# --------------------------------------------------------------------------- +# Step 1: Modality Selection +# --------------------------------------------------------------------------- + +@callback( + Output("import-selected-modality", "data"), + Output("import-wizard-step", "data", allow_duplicate=True), + Output("modality-select-status", "children"), + Input({"type": "modality-select", "modality": ALL}, "n_clicks"), + State({"type": "modality-select", "modality": ALL}, "id"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def select_modality(_clicks, ids, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + triggered = ctx.triggered_id + if not isinstance(triggered, dict): + raise dash.exceptions.PreventUpdate + modality = triggered.get("modality", "") + if modality not in _MODALITY_OPTIONS: + raise dash.exceptions.PreventUpdate + status = dbc.Alert( + translate_ui(loc, "dash.home.modality_selected", modality=modality, desc=_modality_desc(loc, modality)), + color="success", + dismissable=True, + ) + return modality, 1, status + + +# --------------------------------------------------------------------------- +# Wizard Navigation (step visibility) +# --------------------------------------------------------------------------- + +@callback( + Output("wizard-stepper-display", "children"), + Output("wizard-step-1", "style"), + Output("wizard-step-2", "style"), + Output("wizard-step-3", "style"), + Output("wizard-step-4", "style"), + Output("wizard-step-5", "style"), + Output("wizard-step-6", "style"), + Output("step2-modality-badge", "children"), + Output("mapping-axis-label", "children"), + Output("mapping-signal-label", "children"), + Output("mapping-rate-label", "children"), + Input("import-wizard-step", "data"), + Input("ui-locale", "data"), + State("import-selected-modality", "data"), + prevent_initial_call=False, +) +def update_wizard_visibility(step, locale_data, modality): + loc = _loc(locale_data) + step = int(step or 0) + modality = modality or "" + display = {"display": "block"} + hidden = {"display": "none"} + styles = [hidden] * 6 + if 0 <= step < 6: + styles[step] = display + + stepper = stepper_indicator(_wizard_steps(loc), step) + badge = dbc.Badge(modality, color="primary", className="fs-6") if modality else "" + axis_label = _modality_axis_label(loc, modality) + signal_label = _modality_signal_label(loc, modality) + rate_label = translate_ui(loc, "dash.home.heating_rate_label") + + return ( + stepper, + styles[0], styles[1], styles[2], styles[3], styles[4], styles[5], + badge, + axis_label, + signal_label, + rate_label, + ) + + +@callback( + Output("import-wizard-step", "data", allow_duplicate=True), + Input("step2-prev-btn", "n_clicks"), + Input("step2-next-btn", "n_clicks"), + Input("step3-prev-btn", "n_clicks"), + Input("step3-next-btn", "n_clicks"), + Input("step4-prev-btn", "n_clicks"), + Input("step4-next-btn", "n_clicks"), + Input("step5-prev-btn", "n_clicks"), + Input("step5-next-btn", "n_clicks"), + Input("step6-prev-btn", "n_clicks"), + State("import-wizard-step", "data"), + prevent_initial_call=True, +) +def navigate_wizard(c2p, c2n, c3p, c3n, c4p, c4n, c5p, c5n, c6p, step): + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + step = int(step or 0) + triggered_id = ctx.triggered_id + step_map = { + "step2-prev-btn": 0, "step2-next-btn": 2, + "step3-prev-btn": 1, "step3-next-btn": 3, + "step4-prev-btn": 2, "step4-next-btn": 4, + "step5-prev-btn": 3, "step5-next-btn": 5, + "step6-prev-btn": 4, + } + target = step_map.get(triggered_id) + if target is None: + raise dash.exceptions.PreventUpdate + return target + + +# --------------------------------------------------------------------------- +# Step 2: File Upload +# --------------------------------------------------------------------------- + +@callback( + Output("upload-status", "children"), + Output("pending-upload-files", "data"), + Output("pending-file-select", "value"), + Input("file-upload", "contents"), + State("file-upload", "filename"), + State("pending-upload-files", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def collect_pending_uploads(contents_list, filenames, pending_files, locale_data): + loc = _loc(locale_data) + if not contents_list: + return dash.no_update, dash.no_update, dash.no_update + + pending_files = list(pending_files or []) + existing = {item["file_name"] for item in pending_files} + added = [] + for content, file_name in zip(contents_list, filenames): + _, content_string = content.split(",", 1) + if file_name in existing: + continue + pending_files.append({"file_name": file_name, "file_base64": content_string}) + added.append(file_name) + + if not added: + return dbc.Alert(translate_ui(loc, "dash.home.upload_queued_dup"), color="info"), pending_files, dash.no_update + + return ( + dbc.Alert(translate_ui(loc, "dash.home.upload_queued_ok", files=", ".join(added)), color="success", dismissable=True), + pending_files, + added[0], + ) + + +@callback( + Output("pending-file-select", "options"), + Output("pending-file-help", "children"), + Input("pending-upload-files", "data"), + Input("ui-locale", "data"), +) +def pending_file_options(pending_files, locale_data): + loc = _loc(locale_data) + items = pending_files or [] + options = [{"label": item["file_name"], "value": item["file_name"]} for item in items] + help_text = ( + translate_ui(loc, "dash.home.pending_help_empty") + if not items + else translate_ui(loc, "dash.home.pending_help_count", count=len(items)) + ) + return options, help_text + + +# --------------------------------------------------------------------------- +# Steps 3-4: Preview + Column Mapping (modality-aware) +# --------------------------------------------------------------------------- + +@callback( + Output("pending-import-preview", "data"), + Output("mapping-preview-status", "children"), + Output("mapping-preview-table", "children"), + Output("mapping-temp-select", "options"), + Output("mapping-temp-select", "value"), + Output("mapping-signal-select", "options"), + Output("mapping-signal-select", "value"), + Output("mapping-time-select", "options"), + Output("mapping-time-select", "value"), + Output("mapping-sample-name", "value"), + Output("mapping-sample-mass", "value"), + Output("mapping-heating-rate", "value"), + Output("mapping-xrd-wavelength", "value"), + Output("xrd-wavelength-row", "style"), + Input("pending-file-select", "value"), + Input("import-selected-modality", "data"), + State("pending-upload-files", "data"), + State("ui-locale", "data"), + prevent_initial_call=False, +) +def build_pending_preview(selected_file, modality, pending_files, locale_data): + loc = _loc(locale_data) + modality = modality or "" + empty_options = _mapping_options([], loc) + empty_result = ( + None, + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_select_file_body"), + tone="secondary", + title=translate_ui(loc, "dash.home.prereq_select_file_title"), + locale=loc, + ), + "", + empty_options, _NONE_VALUE, + empty_options, _NONE_VALUE, + empty_options, _NONE_VALUE, + "", 0, 10, 1.5406, + {"display": "none"}, + ) + + if not selected_file: + return empty_result + + pending = next((item for item in (pending_files or []) if item["file_name"] == selected_file), None) + if pending is None: + raise dash.exceptions.PreventUpdate + + try: + preview = build_import_preview( + pending["file_name"], + pending["file_base64"], + modality=modality or None, + ) + except Exception as exc: + return ( + None, + dbc.Alert(translate_ui(loc, "dash.home.preview_failed", error=str(exc)), color="danger"), + "", + empty_options, _NONE_VALUE, + empty_options, _NONE_VALUE, + empty_options, _NONE_VALUE, + "", 0, 10, 1.5406, + {"display": "block" if modality == "XRD" else "none"}, + ) + + guessed = preview.get("guessed_mapping") or {} + columns = preview["columns"] + options = _mapping_options(columns, loc) + + suggested_type = str( + guessed.get("inferred_analysis_type") + or guessed.get("data_type") + or modality + or "DSC" + ).upper() + if suggested_type not in set(_MODALITY_OPTIONS): + suggested_type = modality or "DSC" + + preview_rows = preview["preview_rows"] + table = dataset_table(preview_rows, columns, page_size=min(10, len(preview_rows)), table_id="raw-preview-table") + + confidence = (guessed.get("confidence") or {}).get("overall", "review") + warnings = guessed.get("warnings") or [] + status_color = "success" if confidence == "high" else ("warning" if confidence == "medium" else "info") + status_text = translate_ui( + loc, + "dash.home.preview_status", + file=preview["file_name"], + rows=preview["row_count"], + dtype=suggested_type, + conf=confidence, + ) + if warnings: + status_text += translate_ui(loc, "dash.home.preview_warnings_suffix", n=len(warnings)) + status = dbc.Alert(status_text, color=status_color) + + def _pick(column_name: str | None) -> str: + return column_name if column_name in columns else _NONE_VALUE + + xrd_style = {"display": "block"} if modality == "XRD" else {"display": "none"} + + return ( + preview, + status, + table, + options, _pick(guessed.get("temperature")), + options, _pick(guessed.get("signal")), + options, _pick(guessed.get("time")), + "", 0, 10, 1.5406, + xrd_style, + ) + + +# --------------------------------------------------------------------------- +# Step 5: Review units/metadata +# --------------------------------------------------------------------------- + +@callback( + Output("review-unit-status", "children"), + Output("review-metadata-summary", "children"), + Output("review-warnings-list", "children"), + Output("import-review-data", "data"), + Input("import-wizard-step", "data"), + State("pending-import-preview", "data"), + State("import-selected-modality", "data"), + State("mapping-temp-select", "value"), + State("mapping-signal-select", "value"), + State("mapping-time-select", "value"), + State("mapping-sample-name", "value"), + State("mapping-sample-mass", "value"), + State("mapping-heating-rate", "value"), + State("mapping-xrd-wavelength", "value"), + State("ui-locale", "data"), + prevent_initial_call=False, +) +def build_review_data( + step, preview, modality, + temp_col, signal_col, time_col, + sample_name, sample_mass, heating_rate, xrd_wavelength, + locale_data, +): + loc = _loc(locale_data) + step = int(step or 0) + if step != 4: + raise dash.exceptions.PreventUpdate + + if not preview: + return ( + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_no_preview_for_review_body"), + title=translate_ui(loc, "dash.home.title_no_data"), + locale=loc, + ), + "", + "", + None, + ) + + guessed = preview.get("guessed_mapping") or {} + modality = modality or "" + confidence = (guessed.get("confidence") or {}).get("overall", "review") + warnings = guessed.get("warnings") or [] + + # Unit review + detected_x_unit = guessed.get("inferred_signal_unit", "unknown") + columns = preview.get("columns", []) + + # Build review display + unit_rows = [] + if temp_col and temp_col != _NONE_VALUE: + unit_rows.append(html.Tr([html.Td(translate_ui(loc, "dash.home.review_td_axis")), html.Td(temp_col)])) + if signal_col and signal_col != _NONE_VALUE: + unit_rows.append(html.Tr([html.Td(translate_ui(loc, "dash.home.review_td_signal")), html.Td(signal_col)])) + if time_col and time_col != _NONE_VALUE: + unit_rows.append(html.Tr([html.Td(translate_ui(loc, "dash.home.review_td_time")), html.Td(time_col)])) + + unit_table = dbc.Table( + [ + html.Thead( + html.Tr( + [ + html.Th(translate_ui(loc, "dash.home.review_th_role")), + html.Th(translate_ui(loc, "dash.home.review_th_column")), + ] + ) + ) + ] + + [html.Tbody(unit_rows)], + bordered=True, + size="sm", + className="mt-2", + ) + + # Suspicious unit combos via modality specs + spec_warnings = [] + if modality: + try: + from core.modality_specs import check_suspicious_unit_combo + x_unit = guessed.get("inferred_x_unit", "") + y_unit = guessed.get("inferred_signal_unit", "") + spec_warnings = check_suspicious_unit_combo(modality, x_unit, y_unit) + except ImportError: + pass + + all_warnings = warnings + spec_warnings + warning_items = [] + if all_warnings: + for w in all_warnings: + warning_items.append(html.Li(w, className="text-warning")) + warning_list = html.Ul(warning_items, className="mt-2") + else: + warning_list = html.P(translate_ui(loc, "dash.home.no_warnings"), className="text-success") + + # Confidence badge + conf_color = {"high": "success", "medium": "warning", "review": "info"}.get(confidence, "secondary") + conf_badge = dbc.Badge(translate_ui(loc, "dash.home.confidence_badge", value=confidence), color=conf_color, className="me-2") + + # Metadata summary + meta_items = [] + meta_items.append( + html.Tr([html.Td(translate_ui(loc, "dash.home.meta_modality")), html.Td(dbc.Badge(modality, color="primary"))]) + ) + meta_items.append( + html.Tr( + [ + html.Td(translate_ui(loc, "dash.home.meta_sample_name")), + html.Td(sample_name or translate_ui(loc, "dash.home.meta_unknown")), + ] + ) + ) + mass_display = ( + translate_ui(loc, "dash.home.meta_mass_fmt", value=sample_mass) + if sample_mass + else translate_ui(loc, "dash.home.meta_not_set") + ) + meta_items.append(html.Tr([html.Td(translate_ui(loc, "dash.home.meta_sample_mass")), html.Td(mass_display)])) + if modality in {"DSC", "TGA", "DTA"}: + rate_display = ( + translate_ui(loc, "dash.home.meta_rate_fmt", value=heating_rate) + if heating_rate + else translate_ui(loc, "dash.home.meta_not_set") + ) + meta_items.append(html.Tr([html.Td(translate_ui(loc, "dash.home.meta_heating_rate")), html.Td(rate_display)])) + if modality == "XRD": + wl_display = ( + translate_ui(loc, "dash.home.meta_wavelength_fmt", value=xrd_wavelength) + if xrd_wavelength + else translate_ui(loc, "dash.home.meta_not_set") + ) + meta_items.append(html.Tr([html.Td(translate_ui(loc, "dash.home.meta_wavelength")), html.Td(wl_display)])) + + meta_table = dbc.Table( + [ + html.Thead( + html.Tr( + [ + html.Th(translate_ui(loc, "dash.home.meta_th_field")), + html.Th(translate_ui(loc, "dash.home.meta_th_value")), + ] + ) + ) + ] + + [html.Tbody(meta_items)], + bordered=True, + size="sm", + className="mt-2", + ) + + review_data = { + "modality": modality, + "confidence": confidence, + "temp_col": temp_col, + "signal_col": signal_col, + "time_col": time_col, + "sample_name": sample_name, + "sample_mass": sample_mass, + "heating_rate": heating_rate, + "xrd_wavelength": xrd_wavelength, + "warnings": all_warnings, + } + + return ( + html.Div([conf_badge, html.Span(translate_ui(loc, "dash.home.label_unit_review")), unit_table]), + html.Div([html.Strong(translate_ui(loc, "dash.home.label_metadata_summary")), meta_table]), + html.Div([html.Strong(translate_ui(loc, "dash.home.label_warnings_flags")), warning_list]), + review_data, + ) + + +# --------------------------------------------------------------------------- +# Step 6: Validation summary +# --------------------------------------------------------------------------- + +@callback( + Output("validation-summary-status", "children"), + Output("validation-summary-warnings", "children"), + Output("validation-summary-details", "children"), + Input("import-wizard-step", "data"), + State("import-review-data", "data"), + State("ui-locale", "data"), + prevent_initial_call=False, +) +def build_validation_summary(step, review_data, locale_data): + loc = _loc(locale_data) + step = int(step or 0) + if step != 5: + raise dash.exceptions.PreventUpdate + + if not review_data: + return ( + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_no_review_data_body"), + title=translate_ui(loc, "dash.home.title_no_data"), + locale=loc, + ), + "", + "", + ) + + modality = review_data.get("modality", "") + confidence = review_data.get("confidence", "review") + warnings = review_data.get("warnings") or [] + temp_col = review_data.get("temp_col", "") + signal_col = review_data.get("signal_col", "") + + # Determine validation status + has_blocking = temp_col == _NONE_VALUE or signal_col == _NONE_VALUE + if has_blocking: + status = "fail" + elif confidence == "review" or any("suspicious" in w.lower() or "unusual" in w.lower() for w in warnings): + status = "pass_with_review" + elif warnings: + status = "warn" + else: + status = "pass" + + status_badge = _validation_status_badge(status, loc) + vt_key = f"dash.home.validation_text.{status}" + status_text = translate_ui(loc, vt_key) if vt_key in TRANSLATIONS else "" + + status_display = html.Div( + [ + html.H6(translate_ui(loc, "dash.home.label_import_status"), className="d-inline me-2"), + status_badge, + html.P(status_text, className="mt-2"), + ], + className="mb-3", + ) + + warning_display = "" + if warnings: + items = [html.Li(w, className="text-warning") for w in warnings] + warning_display = html.Div([html.Strong(translate_ui(loc, "dash.home.label_warnings_block")), html.Ul(items, className="mt-1")]) + + details = html.Div( + [ + html.Strong(translate_ui(loc, "dash.home.label_summary")), + html.Ul( + [ + html.Li(translate_ui(loc, "dash.home.summary_li_technique", value=modality)), + html.Li(translate_ui(loc, "dash.home.summary_li_axis", value=temp_col)), + html.Li(translate_ui(loc, "dash.home.summary_li_signal", value=signal_col)), + html.Li(translate_ui(loc, "dash.home.summary_li_confidence", value=confidence)), + ] + ), + ] + ) + + return status_display, warning_display, details + + +# --------------------------------------------------------------------------- +# Confirm Import +# --------------------------------------------------------------------------- + +@callback( + Output("upload-status", "children", allow_duplicate=True), + Output("pending-upload-files", "data", allow_duplicate=True), + Output("pending-file-select", "value", allow_duplicate=True), + Output("home-refresh", "data", allow_duplicate=True), + Output("import-wizard-step", "data", allow_duplicate=True), + Input("import-mapped-btn", "n_clicks"), + State("project-id", "data"), + State("pending-import-preview", "data"), + State("pending-upload-files", "data"), + State("pending-file-select", "value"), + State("import-selected-modality", "data"), + State("mapping-temp-select", "value"), + State("mapping-signal-select", "value"), + State("mapping-time-select", "value"), + State("mapping-sample-name", "value"), + State("mapping-sample-mass", "value"), + State("mapping-heating-rate", "value"), + State("mapping-xrd-wavelength", "value"), + State("home-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def import_with_mapping( + n_clicks, + project_id, + preview, + pending_files, + selected_file, + modality, + temp_col, + signal_col, + time_col, + sample_name, + sample_mass, + heating_rate, + xrd_wavelength, + refresh_value, + locale_data, +): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + + if not project_id: + return ( + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_workspace_import_body"), + title=translate_ui(loc, "dash.home.prereq_workspace_import_title"), + locale=loc, + ), + dash.no_update, dash.no_update, dash.no_update, dash.no_update, + ) + + if not preview: + return ( + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_preview_required_body"), + tone="secondary", + title=translate_ui(loc, "dash.home.prereq_preview_required_title"), + locale=loc, + ), + dash.no_update, dash.no_update, dash.no_update, dash.no_update, + ) + + if temp_col == _NONE_VALUE or signal_col == _NONE_VALUE: + return ( + dbc.Alert(translate_ui(loc, "dash.home.import_axis_signal_required"), color="warning"), + dash.no_update, dash.no_update, dash.no_update, dash.no_update, + ) + + available_columns = set(preview.get("columns", [])) + if temp_col not in available_columns or signal_col not in available_columns: + return ( + dbc.Alert(translate_ui(loc, "dash.home.import_mapping_stale"), color="warning"), + dash.no_update, dash.no_update, dash.no_update, dash.no_update, + ) + + from dash_app.api_client import dataset_import as api_dataset_import + + data_type = modality or "DSC" + metadata = { + "sample_name": sample_name or "Unknown", + "sample_mass": float(sample_mass) if sample_mass not in (None, "", 0, 0.0) else None, + "heating_rate": float(heating_rate) if data_type not in {"XRD", "FTIR", "RAMAN"} and heating_rate not in (None, "", 0, 0.0) else None, + "xrd_wavelength_angstrom": float(xrd_wavelength) if data_type == "XRD" and xrd_wavelength not in (None, "", 0, 0.0) else None, + } + column_mapping = { + "temperature": temp_col, + "signal": signal_col, + } + if time_col and time_col != _NONE_VALUE: + column_mapping["time"] = time_col + + try: + result = api_dataset_import( + project_id, + preview["file_name"], + preview["file_base64"], + data_type=data_type, + column_mapping=column_mapping, + metadata=metadata, + ) + except Exception as exc: + exc_msg = str(exc) + hint = "" + if "thermal-analysis bounds" in exc_msg or "Temperature range" in exc_msg: + hint = translate_ui(loc, "dash.home.import_hint_wavenumber") + elif "strictly increasing" in exc_msg: + hint = translate_ui(loc, "dash.home.import_hint_monotonic") + return ( + dbc.Alert(translate_ui(loc, "dash.home.import_failed", error=exc_msg) + hint, color="danger", dismissable=True), + dash.no_update, dash.no_update, dash.no_update, dash.no_update, + ) + + remaining = [item for item in (pending_files or []) if item["file_name"] != selected_file] + next_selected = remaining[0]["file_name"] if remaining else None + ds = result.get("dataset", {}) + return ( + dbc.Alert( + [ + translate_ui( + loc, + "dash.home.import_success", + name=ds.get("display_name", preview["file_name"]), + dtype=ds.get("data_type", "?"), + ), + " ", + translate_ui(loc, "dash.home.import_success_next"), + ], + color="success", + dismissable=True, + ), + remaining, + next_selected, + int(refresh_value or 0) + 1, + 0, # Reset wizard to step 0 + ) + + +# --------------------------------------------------------------------------- +# Sample Data +# --------------------------------------------------------------------------- + +@callback( + Output("sample-status", "children"), + Output("home-refresh", "data", allow_duplicate=True), + Input({"type": "sample-load", "sample_id": ALL}, "n_clicks"), + State({"type": "sample-load", "sample_id": ALL}, "id"), + State("project-id", "data"), + State("home-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def load_sample(_clicks, ids, project_id, refresh_value, locale_data): + loc = _loc(locale_data) + if not project_id: + return ( + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_workspace_sample_body"), + title=translate_ui(loc, "dash.home.prereq_workspace_import_title"), + locale=loc, + ), + dash.no_update, + ) + ctx = dash.callback_context + triggered = ctx.triggered_id + if not triggered: + raise dash.exceptions.PreventUpdate + + button_id = triggered.get("sample_id") if isinstance(triggered, dict) else None + sample_path, dtype = resolve_sample_request(button_id or "") + if sample_path is None or dtype is None: + raise dash.exceptions.PreventUpdate + + if not sample_path.exists(): + return dbc.Alert(translate_ui(loc, "dash.home.sample_not_found", name=sample_path.name), color="warning"), dash.no_update + + from dash_app.api_client import dataset_import + + try: + result = dataset_import( + project_id, + sample_path.name, + base64.b64encode(sample_path.read_bytes()).decode("ascii"), + data_type=dtype, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.home.sample_load_failed", error=str(exc)), color="danger"), dash.no_update + + dataset = result.get("dataset", {}) + return ( + dbc.Alert( + translate_ui(loc, "dash.home.sample_loaded", name=dataset.get("display_name", sample_path.name)), + color="success", + dismissable=True, + ), + int(refresh_value or 0) + 1, + ) + + +# --------------------------------------------------------------------------- +# Loaded Datasets Panel +# --------------------------------------------------------------------------- + +@callback( + Output("import-metrics", "children"), + Output("datasets-table", "children"), + Output("active-dataset-select", "options"), + Output("active-dataset-select", "value"), + Input("project-id", "data"), + Input("home-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def load_workspace_datasets(project_id, _refresh, _ui_theme, locale_data): + loc = _loc(locale_data) + if not project_id: + return ( + "", + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_workspace_import_body"), + title=translate_ui(loc, "dash.home.prereq_workspace_import_title"), + locale=loc, + ), + [], + None, + ) + + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + error = html.P([translate_ui(loc, "dash.home.error_prefix"), " ", str(exc)], className="text-danger") + return "", error, [], None + + datasets = payload.get("datasets", []) + if not datasets: + return ( + _build_metrics([], loc), + prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_no_datasets_body"), + tone="secondary", + title=translate_ui(loc, "dash.home.prereq_no_datasets_title"), + locale=loc, + ), + [], + None, + ) + + rows = [ + { + "key": item.get("key"), + "display_name": item.get("display_name"), + "data_type": item.get("data_type"), + "vendor": item.get("vendor"), + "sample_name": item.get("sample_name"), + "points": item.get("points"), + "validation_status": item.get("validation_status"), + } + for item in datasets + ] + table = dataset_table(rows, ["key", "display_name", "data_type", "vendor", "sample_name", "points", "validation_status"], table_id="datasets-summary-table") + options = [{"label": item.get("display_name", item.get("key")), "value": item.get("key")} for item in datasets] + return _build_metrics(datasets, loc), table, options, payload.get("active_dataset") + + +@callback( + Output("home-refresh", "data", allow_duplicate=True), + Input("active-dataset-select", "value"), + State("project-id", "data"), + State("home-refresh", "data"), + prevent_initial_call=True, +) +def set_active_dataset(dataset_key, project_id, refresh_value): + if not dataset_key or not project_id: + raise dash.exceptions.PreventUpdate + from dash_app.api_client import workspace_set_active_dataset + + workspace_set_active_dataset(project_id, dataset_key) + return int(refresh_value or 0) + 1 + + +@callback( + Output("dataset-action-status", "children"), + Output("home-refresh", "data", allow_duplicate=True), + Input("remove-dataset-btn", "n_clicks"), + State("active-dataset-select", "value"), + State("project-id", "data"), + State("home-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def remove_dataset(n_clicks, dataset_key, project_id, refresh_value, locale_data): + loc = _loc(locale_data) + if not n_clicks or not dataset_key or not project_id: + raise dash.exceptions.PreventUpdate + + from dash_app.api_client import workspace_delete_dataset + + try: + workspace_delete_dataset(project_id, dataset_key) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.home.remove_fail", error=str(exc)), color="danger"), dash.no_update + return dbc.Alert(translate_ui(loc, "dash.home.remove_ok", key=dataset_key), color="warning"), int(refresh_value or 0) + 1 + + +@callback( + Output("dataset-detail-panel", "children"), + Input("project-id", "data"), + Input("active-dataset-select", "value"), + Input("home-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + prevent_initial_call=False, +) +def load_active_dataset_detail(project_id, dataset_key, _refresh, ui_theme, locale_data): + loc = _loc(locale_data) + if not project_id: + return prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_workspace_detail_body"), + title=translate_ui(loc, "dash.home.prereq_workspace_import_title"), + locale=loc, + ) + if not dataset_key: + return prereq_or_empty_help( + translate_ui(loc, "dash.home.prereq_select_dataset_body"), + tone="secondary", + title=translate_ui(loc, "dash.home.prereq_select_dataset_title"), + locale=loc, + ) + + from dash_app.api_client import workspace_dataset_data, workspace_dataset_detail + + try: + detail = workspace_dataset_detail(project_id, dataset_key) + data_payload = workspace_dataset_data(project_id, dataset_key) + except Exception as exc: + return html.P([translate_ui(loc, "dash.home.error_prefix"), " ", str(exc)], className="text-danger") + + rows = data_payload.get("rows", []) + columns = data_payload.get("columns", []) + preview_rows = rows[:10] + + return html.Div( + [ + html.H5(detail.get("dataset", {}).get("display_name", dataset_key), className="mb-3"), + metric_cards(detail), + dbc.Accordion( + [ + dbc.AccordionItem(metadata_list(detail), title=translate_ui(loc, "dash.home.detail_metadata")), + dbc.AccordionItem(original_columns_list(detail), title=translate_ui(loc, "dash.home.detail_columns")), + dbc.AccordionItem( + dataset_table(preview_rows, columns, page_size=min(10, len(preview_rows) or 1), table_id="active-dataset-table"), + title=translate_ui(loc, "dash.home.detail_preview"), + ), + dbc.AccordionItem(stats_table(rows, columns), title=translate_ui(loc, "dash.home.detail_stats")), + ], + start_collapsed=True, + always_open=True, + className="mb-3", + ), + html.H6(translate_ui(loc, "dash.home.detail_quick_view"), className="mb-2"), + quick_plot(rows, detail, ui_theme=ui_theme), + ] + ) diff --git a/dash_app/pages/project.py b/dash_app/pages/project.py new file mode 100644 index 00000000..8693ce82 --- /dev/null +++ b/dash_app/pages/project.py @@ -0,0 +1,655 @@ +"""Project workspace page -- parity-focused workspace operations.""" + +from __future__ import annotations + +import base64 + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html + +from core.project_io import PROJECT_EXTENSION +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.page_guidance import ( + guidance_block, + next_step_block, + prereq_or_empty_help, + typical_workflow_block, +) +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/project", title="Project - MaterialScope") + +LEGACY_PROJECT_EXTENSION = ".thermozip" + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _allowed_project_archive_filename(file_name: str | None) -> bool: + if not file_name: + return False + lower = file_name.lower() + return lower.endswith(".scopezip") or lower.endswith(".thermozip") + + +def _metric_card(label: str, value: str) -> dbc.Card: + return dbc.Card(dbc.CardBody([html.Small(label, className="text-muted text-uppercase"), html.H4(value, className="mb-0")])) + + +def _next_step(loc: str, summary: dict, compare_workspace: dict) -> str: + if summary.get("dataset_count", 0) <= 0: + return translate_ui(loc, "project.dash.next_step_import", import_nav=translate_ui(loc, "nav.import")) + if summary.get("result_count", 0) <= 0: + return translate_ui(loc, "project.dash.next_step_results") + if not (compare_workspace or {}).get("selected_datasets"): + return translate_ui(loc, "project.dash.next_step_compare", compare_nav=translate_ui(loc, "nav.compare")) + return translate_ui(loc, "project.dash.next_step_report", report_nav=translate_ui(loc, "nav.report")) + + +def _can_prepare_archive(summary: dict) -> bool: + return any( + summary.get(key, 0) > 0 + for key in ("result_count", "figure_count", "analysis_history_count") + ) + + +layout = html.Div( + [ + dcc.Store(id="project-page-refresh", data=0), + dcc.Store(id="pending-project-upload"), + dcc.Store(id="project-confirm-action"), + dcc.Store(id="project-save-eligibility", data={"can_prepare_archive": False}), + html.Div(id="project-header-slot"), + html.Div(id="project-guidance-slot", className="mb-2"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Card(dbc.CardBody(html.Div(id="workspace-summary")), className="mb-4"), + dbc.Card( + dbc.CardBody( + [ + html.Div(id="project-confirm-message"), + html.Div( + id="project-confirm-actions", + style={"display": "none"}, + children=[ + dbc.Row( + [ + dbc.Col( + dbc.Button( + translate_ui("en", "project.dash.confirm_generic"), + id="project-confirm-btn", + color="danger", + className="w-100", + ), + md=4, + ), + dbc.Col( + dbc.Button( + translate_ui("en", "project.dash.prepare_archive_first"), + id="project-prepare-first-btn", + color="primary", + className="w-100 d-none", + disabled=True, + n_clicks=0, + ), + md=4, + ), + dbc.Col( + dbc.Button( + translate_ui("en", "project.dash.cancel"), + id="project-cancel-btn", + color="secondary", + className="w-100", + ), + md=4, + ), + ], + className="g-2", + ), + ], + ), + ] + ), + className="mb-4", + ), + dbc.Card(dbc.CardBody(html.Div(id="workspace-datasets-panel")), className="mb-4"), + dbc.Card(dbc.CardBody(html.Div(id="workspace-results-panel")), className="mb-4"), + ], + md=8, + ), + dbc.Col( + [ + dbc.Card( + dbc.CardBody( + [ + html.H5(id="project-qa-title", className="mb-3"), + dbc.Button(id="new-workspace-btn", color="secondary", className="w-100 mb-2"), + dbc.Button(id="save-project-btn", color="primary", className="w-100 mb-3"), + html.Div(id="save-project-output"), + dcc.Download(id="project-download"), + html.Hr(), + html.H5(id="project-load-title", className="mb-3"), + dcc.Upload( + id="project-upload", + children=html.Div( + [ + html.I(className="bi bi-folder2-open me-2"), + html.Span(id="project-upload-caption"), + ], + className="text-center py-3", + ), + className="upload-zone", + ), + html.Div(id="selected-project-upload", className="small text-muted mt-2"), + dbc.Button(id="load-project-btn", color="primary", className="w-100 mt-3"), + html.Div(id="load-project-output", className="mt-3"), + ] + ), + className="mb-4", + ), + dbc.Card(dbc.CardBody(html.Div(id="compare-summary-panel")), className="mb-4"), + ], + md=4, + ), + ] + ), + ] +) + + +@callback( + Output("project-header-slot", "children"), + Output("project-guidance-slot", "children"), + Input("ui-locale", "data"), +) +def render_project_locale_chrome(locale_data): + loc = _loc(locale_data) + header = page_header( + translate_ui(loc, "project.title"), + translate_ui(loc, "project.caption"), + badge=translate_ui(loc, "project.hero_badge"), + ) + guidance = html.Div( + [ + guidance_block( + translate_ui(loc, "project.dash.guidance_title"), + body=translate_ui(loc, "project.dash.guidance_body"), + ), + typical_workflow_block( + [ + translate_ui(loc, "project.dash.workflow_step1"), + translate_ui(loc, "project.dash.workflow_step2"), + translate_ui(loc, "project.dash.workflow_step3"), + ], + title=translate_ui(loc, "project.dash.workflow_title"), + ), + ] + ) + return header, guidance + + +@callback( + Output("project-qa-title", "children"), + Output("new-workspace-btn", "children"), + Output("save-project-btn", "children"), + Output("project-load-title", "children"), + Output("project-upload-caption", "children"), + Output("load-project-btn", "children"), + Input("ui-locale", "data"), +) +def render_project_quick_action_labels(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "project.dash.quick_actions"), + translate_ui(loc, "project.dash.start_new_workspace"), + translate_ui(loc, "project.dash.prepare_and_download"), + translate_ui(loc, "sidebar.project.load"), + translate_ui(loc, "project.dash.upload_cta"), + translate_ui(loc, "sidebar.project.load_selected"), + ) + + +@callback( + Output("project-prepare-first-btn", "children"), + Output("project-cancel-btn", "children"), + Input("ui-locale", "data"), +) +def sync_prepare_and_cancel_labels(locale_data): + loc = _loc(locale_data) + return translate_ui(loc, "project.dash.prepare_archive_first"), translate_ui(loc, "project.dash.cancel") + + +@callback( + Output("pending-project-upload", "data"), + Output("selected-project-upload", "children"), + Input("project-upload", "contents"), + State("project-upload", "filename"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def stage_project_upload(contents, file_name, locale_data): + if not contents: + raise dash.exceptions.PreventUpdate + loc = _loc(locale_data) + _, content_string = contents.split(",", 1) + name = file_name or f"project{PROJECT_EXTENSION}" + if not _allowed_project_archive_filename(name): + return None, dbc.Alert(translate_ui(loc, "project.dash.invalid_archive_extension"), color="warning", className="mb-0 py-2") + payload = {"file_name": name, "archive_base64": content_string} + prefix = translate_ui(loc, "project.dash.selected_archive_prefix") + return payload, f"{prefix} {payload['file_name']}" + + +@callback( + Output("workspace-summary", "children"), + Output("workspace-datasets-panel", "children"), + Output("workspace-results-panel", "children"), + Output("compare-summary-panel", "children"), + Output("project-save-eligibility", "data"), + Input("project-id", "data"), + Input("project-page-refresh", "data"), + Input("workspace-refresh", "data"), + Input("ui-locale", "data"), +) +def load_workspace(project_id, _refresh, _global_refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + empty = prereq_or_empty_help( + translate_ui(loc, "project.dash.workspace_required_body"), + title=translate_ui(loc, "project.dash.workspace_required_title"), + ) + return empty, empty, empty, empty, {"can_prepare_archive": False} + + from dash_app.api_client import workspace_context, workspace_datasets, workspace_results + + try: + context = workspace_context(project_id) + datasets_payload = workspace_datasets(project_id) + results_payload = workspace_results(project_id) + except Exception as exc: + error = html.P( + f"{translate_ui(loc, 'project.dash.error_prefix')} {exc}", + className="text-danger", + ) + return error, error, error, error, {"can_prepare_archive": False} + + summary = context.get("summary", {}) + compare_workspace = context.get("compare_workspace") or {} + can_prepare_archive = _can_prepare_archive(summary) + metrics = dbc.Row( + [ + dbc.Col(_metric_card(translate_ui(loc, "project.dash.metric_datasets"), str(summary.get("dataset_count", 0))), md=3), + dbc.Col( + _metric_card(translate_ui(loc, "project.dash.metric_saved_results"), str(summary.get("result_count", 0))), + md=3, + ), + dbc.Col(_metric_card(translate_ui(loc, "project.dash.metric_figures"), str(summary.get("figure_count", 0))), md=3), + dbc.Col( + _metric_card(translate_ui(loc, "project.dash.metric_history_steps"), str(summary.get("analysis_history_count", 0))), + md=3, + ), + ], + className="g-3 mb-3", + ) + active_name = (context.get("active_dataset") or {}).get("display_name") or translate_ui(loc, "project.dash.none_label") + cmp_state = ( + translate_ui(loc, "project.dash.compare_ready") + if compare_workspace.get("selected_datasets") + else translate_ui(loc, "project.dash.compare_empty") + ) + arch_detail = ( + translate_ui(loc, "project.dash.archive_ready_detail") + if can_prepare_archive + else translate_ui(loc, "project.dash.archive_needs_detail") + ) + status_lines = html.Ul( + [ + html.Li(translate_ui(loc, "project.dash.active_dataset", name=active_name)), + html.Li(translate_ui(loc, "project.dash.compare_workspace_status", state=cmp_state)), + html.Li(translate_ui(loc, "project.dash.archive_status", detail=arch_detail)), + ], + className="mb-3", + ) + summary_block = html.Div( + [metrics, next_step_block(_next_step(loc, summary, compare_workspace), locale=loc), status_lines] + ) + + dataset_rows = datasets_payload.get("datasets", []) + if dataset_rows: + dataset_table_view = html.Div( + [ + html.H5(translate_ui(loc, "project.dash.loaded_runs"), className="mb-3"), + dataset_table( + dataset_rows, + ["key", "display_name", "data_type", "vendor", "sample_name", "heating_rate", "points", "validation_status"], + table_id="project-datasets-table", + ), + ] + ) + else: + dataset_table_view = html.Div( + [ + html.H5(translate_ui(loc, "project.dash.loaded_runs"), className="mb-3"), + prereq_or_empty_help( + translate_ui(loc, "project.dash.no_loaded_runs_body"), + tone="secondary", + title=translate_ui(loc, "project.dash.no_loaded_runs_title"), + ), + ] + ) + + result_rows = results_payload.get("results", []) + result_issues = results_payload.get("issues", []) + if result_rows: + results_content = [ + html.H5(translate_ui(loc, "project.dash.saved_result_records"), className="mb-3"), + dataset_table( + result_rows, + ["id", "analysis_type", "status", "dataset_key", "workflow_template", "saved_at_utc"], + table_id="project-results-table", + ), + ] + if result_issues: + results_content.insert( + 1, + dbc.Alert( + [ + html.Div(translate_ui(loc, "project.dash.results_incomplete_hint"), className="fw-semibold mb-1"), + html.Ul([html.Li(issue) for issue in result_issues], className="mb-0"), + ], + color="warning", + className="mb-3", + ), + ) + results_view = html.Div([*results_content]) + else: + results_view = html.Div( + [ + html.H5(translate_ui(loc, "project.dash.saved_result_records"), className="mb-3"), + prereq_or_empty_help( + translate_ui(loc, "project.dash.no_saved_results_body"), + tone="secondary", + title=translate_ui(loc, "project.dash.no_saved_results_title"), + ), + ] + ) + + none_l = translate_ui(loc, "project.dash.none_label") + na_l = translate_ui(loc, "project.dash.na_label") + compare_view = html.Div( + [ + html.H5(translate_ui(loc, "project.dash.compare_workspace"), className="mb-3"), + html.P(f"{translate_ui(loc, 'project.dash.analysis_type')} {compare_workspace.get('analysis_type', na_l)}", className="mb-1"), + html.P( + f"{translate_ui(loc, 'project.dash.selected_runs')} " + f"{', '.join(compare_workspace.get('selected_datasets') or []) or none_l}", + className="mb-1", + ), + html.P( + f"{translate_ui(loc, 'project.dash.saved_figure')} {compare_workspace.get('figure_key') or none_l}", + className="mb-1", + ), + html.P(compare_workspace.get("notes") or translate_ui(loc, "project.dash.no_compare_notes"), className="text-muted"), + ] + ) + + return summary_block, dataset_table_view, results_view, compare_view, {"can_prepare_archive": can_prepare_archive} + + +@callback( + Output("project-confirm-action", "data"), + Output("project-confirm-message", "children"), + Output("project-confirm-actions", "style"), + Output("project-prepare-first-btn", "disabled"), + Output("project-prepare-first-btn", "className"), + Output("project-confirm-btn", "children"), + Output("load-project-output", "children"), + Input("new-workspace-btn", "n_clicks"), + Input("load-project-btn", "n_clicks"), + State("project-id", "data"), + State("pending-project-upload", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def request_project_action(new_clicks, load_clicks, project_id, pending_upload, locale_data): + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + + button_id = ctx.triggered[0]["prop_id"].split(".")[0] + loc = _loc(locale_data) + from dash_app.api_client import workspace_context + + context = workspace_context(project_id) if project_id else {"summary": {}} + summary = context.get("summary", {}) + has_content = any( + summary.get(key, 0) > 0 + for key in ("dataset_count", "result_count", "figure_count", "analysis_history_count") + ) or bool((context.get("compare_workspace") or {}).get("selected_datasets")) + can_prepare = _can_prepare_archive(summary) + + if button_id == "new-workspace-btn": + action = {"action": "new"} + alert_body = ( + translate_ui(loc, "project.dash.clear_workspace_warning") + if has_content + else translate_ui(loc, "project.dash.clear_workspace_confirm") + ) + panel_msg = dbc.Alert(alert_body, color="warning") + prepare_cls = "w-100" if (has_content and can_prepare) else "w-100 d-none" + prepare_disabled = not (has_content and can_prepare) + confirm_label = ( + translate_ui(loc, "project.dash.confirm_clear") + if has_content + else translate_ui(loc, "project.dash.confirm_generic") + ) + return ( + action, + panel_msg, + {"display": "block"}, + prepare_disabled, + prepare_cls, + confirm_label, + dash.no_update, + ) + + if not pending_upload: + return ( + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dbc.Alert(translate_ui(loc, "project.dash.choose_archive_first"), color="warning"), + ) + if has_content: + action = {"action": "load"} + panel_msg = dbc.Alert(translate_ui(loc, "project.dash.load_replace_warning"), color="warning") + return ( + action, + panel_msg, + {"display": "block"}, + True, + "w-100 d-none", + translate_ui(loc, "project.dash.continue_loading"), + dash.no_update, + ) + action = {"action": "load"} + panel_msg = dbc.Alert(translate_ui(loc, "project.dash.load_confirm_simple"), color="warning") + return ( + action, + panel_msg, + {"display": "block"}, + True, + "w-100 d-none", + translate_ui(loc, "project.dash.continue_loading"), + dash.no_update, + ) + + +@callback( + Output("project-id", "data", allow_duplicate=True), + Output("project-page-refresh", "data", allow_duplicate=True), + Output("project-confirm-message", "children", allow_duplicate=True), + Output("project-confirm-actions", "style", allow_duplicate=True), + Output("project-prepare-first-btn", "disabled", allow_duplicate=True), + Output("project-prepare-first-btn", "className", allow_duplicate=True), + Output("project-confirm-btn", "children", allow_duplicate=True), + Output("project-confirm-action", "data", allow_duplicate=True), + Output("pending-project-upload", "data", allow_duplicate=True), + Output("load-project-output", "children", allow_duplicate=True), + Input("project-confirm-btn", "n_clicks"), + Input("project-cancel-btn", "n_clicks"), + State("project-confirm-action", "data"), + State("pending-project-upload", "data"), + State("project-page-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def resolve_project_action(confirm_clicks, cancel_clicks, action, pending_upload, refresh_value, locale_data): + ctx = dash.callback_context + if not ctx.triggered or not action: + raise dash.exceptions.PreventUpdate + + button_id = ctx.triggered[0]["prop_id"].split(".")[0] + loc = _loc(locale_data) + + def _cleared_panel(): + return ( + "", + {"display": "none"}, + True, + "w-100 d-none", + translate_ui(loc, "project.dash.confirm_generic"), + ) + + if button_id == "project-cancel-btn": + msg, style, prep_dis, prep_cls, confirm_lbl = _cleared_panel() + return ( + dash.no_update, + dash.no_update, + msg, + style, + prep_dis, + prep_cls, + confirm_lbl, + None, + dash.no_update, + dbc.Alert(translate_ui(loc, "project.dash.action_cancelled"), color="secondary"), + ) + + from dash_app.api_client import project_load, workspace_new + + if action.get("action") == "new": + result = workspace_new() + msg, style, prep_dis, prep_cls, confirm_lbl = _cleared_panel() + return ( + result.get("project_id"), + int(refresh_value or 0) + 1, + msg, + style, + prep_dis, + prep_cls, + confirm_lbl, + None, + dash.no_update, + dbc.Alert(translate_ui(loc, "project.dash.workspace_cleared"), color="success"), + ) + + if action.get("action") == "load" and pending_upload: + result = project_load(pending_upload["archive_base64"]) + summary = result.get("summary", {}) + msg, style, prep_dis, prep_cls, confirm_lbl = _cleared_panel() + return ( + result.get("project_id"), + int(refresh_value or 0) + 1, + msg, + style, + prep_dis, + prep_cls, + confirm_lbl, + None, + None, + dbc.Alert( + translate_ui( + loc, + "project.dash.project_loaded", + datasets=summary.get("dataset_count", 0), + results=summary.get("result_count", 0), + ), + color="success", + ), + ) + + raise dash.exceptions.PreventUpdate + + +def _run_project_save(project_id: str | None, save_eligibility: dict | None, loc: str): + if not project_id: + return ( + prereq_or_empty_help( + translate_ui(loc, "project.dash.no_workspace_save_body"), + title=translate_ui(loc, "project.dash.no_workspace_save_title"), + ), + dash.no_update, + ) + if not bool((save_eligibility or {}).get("can_prepare_archive")): + return ( + dbc.Alert(translate_ui(loc, "project.dash.archive_eligibility_warning"), color="warning"), + dash.no_update, + ) + + from dash_app.api_client import project_save + + try: + result = project_save(project_id) + except Exception as exc: + return ( + dbc.Alert(translate_ui(loc, "project.dash.save_failed", error=exc), color="danger"), + dash.no_update, + ) + + archive_b64 = result.get("archive_base64", "") + file_name = result.get("file_name", f"materialscope_project{PROJECT_EXTENSION}") + archive_bytes = base64.b64decode(archive_b64) + return ( + dbc.Alert(translate_ui(loc, "project.dash.archive_downloading"), color="success"), + dcc.send_bytes(archive_bytes, file_name), + ) + + +@callback( + Output("save-project-output", "children"), + Output("project-download", "data"), + Input("save-project-btn", "n_clicks"), + Input("project-prepare-first-btn", "n_clicks"), + State("project-id", "data"), + State("project-save-eligibility", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_project(save_clicks, prepare_clicks, project_id, save_eligibility, locale_data): + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + prop = ctx.triggered[0]["prop_id"] + trigger = prop.split(".")[0] + if trigger not in ("save-project-btn", "project-prepare-first-btn"): + raise dash.exceptions.PreventUpdate + loc = _loc(locale_data) + return _run_project_save(project_id, save_eligibility, loc) + + +@callback( + Output("save-project-btn", "disabled"), + Input("project-id", "data"), + Input("project-save-eligibility", "data"), +) +def toggle_save_project_button(project_id, save_eligibility): + return not (project_id and bool((save_eligibility or {}).get("can_prepare_archive"))) diff --git a/dash_app/pages/raman.py b/dash_app/pages/raman.py new file mode 100644 index 00000000..5fc33b44 --- /dev/null +++ b/dash_app/pages/raman.py @@ -0,0 +1,2894 @@ +"""RAMAN analysis page -- product-grade implementation aligned with other modality Dash analysis pages. + +Left column tabs: + - Setup: dataset, workflow template, workflow guide + - Processing: undo/redo/reset, presets, smoothing, baseline, normalization, + peak detection, similarity matching + - Run: execute analysis + +Right column results surface: + 1. analysis summary + 2. result metrics + 3. validation and quality + 4. main RAMAN figure + 5. top-match hero summary + 6. key spectral peaks / feature cards + 7. full match table + 8. applied processing summary + 9. raw metadata + 10. literature compare + +User-visible labels for presets, processing, baseline window, validation, and library status are read +from ``dash.analysis.raman.*`` keys in ``utils/i18n.py`` (not thermal/TGA copy). +""" + +from __future__ import annotations + +import base64 +import copy +import json +import math +from datetime import datetime, timezone +from typing import Any + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import plotly.graph_objects as go + +from dash_app.components.analysis_boilerplate import ( + build_collapsible_section, + build_load_saveas_preset_card, + build_processing_history_card, + build_split_raw_metadata_panel, + build_validation_quality_card, +) +from dash_app.components.analysis_page import ( + analysis_page_stores, + capture_result_figure_from_layout, + register_result_figure_from_layout_children, + dataset_selection_card, + dataset_selector_block, + eligible_datasets, + empty_result_msg, + execute_card, + interpret_run_result, + metrics_row, + no_data_figure_msg, + processing_details_section, + resolve_sample_name, + result_placeholder_card, + workflow_template_card, +) +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.figure_artifacts import ( + FIGURE_ARTIFACT_PREVIEW_MAX_EDGE, + FIGURE_ARTIFACT_PREVIEW_TILES, + build_figure_artifact_surface, + build_figure_artifacts_panel, + figure_action_from_trigger, + figure_action_metadata, + figure_action_status_alert, + figure_artifact_button_labels, + ordered_figure_preview_keys, +) +from dash_app.components.raman_explore import ( + MAX_RAMAN_UNDO_DEPTH, + append_undo_after_edit, + raman_draft_processing_equal, + perform_redo, + perform_undo, +) +from dash_app.components.literature_compare_ui import ( + LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + build_literature_compare_card, + coerce_literature_max_claims, + literature_compare_status_alert, + literature_t, + render_literature_output, +) +from dash_app.components.processing_inputs import ( + coerce_float_non_negative as _coerce_float_non_negative, + coerce_float_positive as _coerce_float_positive, + coerce_int_positive as _coerce_int_positive, +) +from dash_app.components.spectral_explore import ( + build_spectral_raw_quality_panel, + compute_spectral_raw_quality_stats, + downsample_spectral_rows, +) +from dash_app.components.spectral_plot_settings import ( + build_plotly_config as build_spectral_plotly_config, + build_spectral_plot_settings_card, + normalize_spectral_plot_settings, + spectral_legend_layout, + spectral_plot_settings_chrome, + spectral_plot_settings_from_controls, +) +from dash_app.theme import PLOT_THEME, normalize_ui_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/raman", title="RAMAN Analysis - MaterialScope") + +_RAMAN_WORKFLOW_TEMPLATES = [ + {"id": "raman.general", "label": "General Raman"}, + {"id": "raman.polymorph_screening", "label": "Polymorph Screening"}, +] +_RAMAN_TEMPLATE_IDS = [entry["id"] for entry in _RAMAN_WORKFLOW_TEMPLATES] +_TEMPLATE_OPTIONS = [{"label": entry["label"], "value": entry["id"]} for entry in _RAMAN_WORKFLOW_TEMPLATES] +_RAMAN_ELIGIBLE_TYPES = {"RAMAN", "UNKNOWN"} + +_RAMAN_PRESET_ANALYSIS_TYPE = "RAMAN" +_RAMAN_LITERATURE_PREFIX = "dash.analysis.raman.literature" + +_RAMAN_RESULT_CARD_ROLES = { + "context": "ms-result-context", + "hero": "ms-result-hero", + "support": "ms-result-support", + "secondary": "ms-result-secondary", +} + +_RAMAN_USER_FACING_METADATA_KEYS: frozenset[str] = frozenset({ + "sample_name", + "display_name", + "instrument", + "vendor", + "file_name", + "source_data_hash", +}) + +_RAMAN_SMOOTH_METHODS = frozenset({"savgol", "moving_average", "gaussian"}) +_RAMAN_SMOOTHING_DEFAULTS: dict[str, dict[str, Any]] = { + "savgol": {"method": "savgol", "window_length": 11, "polyorder": 3}, + "moving_average": {"method": "moving_average", "window_length": 11}, + "gaussian": {"method": "gaussian", "sigma": 2.0}, +} + +_RAMAN_BASELINE_METHODS = frozenset({"asls", "linear", "rubberband"}) +_RAMAN_BASELINE_DEFAULTS: dict[str, dict[str, Any]] = { + "asls": {"method": "asls", "lam": 1e6, "p": 0.01, "region": None}, + "linear": {"method": "linear", "region": None}, + "rubberband": {"method": "rubberband", "region": None}, +} + +_RAMAN_NORMALIZATION_MODES = frozenset({"vector", "max", "snv"}) +_RAMAN_NORMALIZATION_DEFAULTS: dict[str, Any] = {"method": "vector"} +_RAMAN_SIMILARITY_METRICS = frozenset({"cosine", "pearson"}) +_RAMAN_TEMPLATE_SIMILARITY_METRICS: dict[str, str] = { + "raman.general": "cosine", + "raman.polymorph_screening": "pearson", +} + +_RAMAN_PEAK_DETECTION_DEFAULTS: dict[str, Any] = { + "prominence": 0.035, + "distance": 5, + "max_peaks": 12, +} + +_RAMAN_SIMILARITY_MATCHING_DEFAULTS: dict[str, Any] = { + "metric": "cosine", + "top_n": 3, + "minimum_score": 0.45, +} + +_RAMAN_MAX_PEAK_CARDS = 8 +_RAMAN_TRUNCATE_PEAK_CARDS_WHEN = 9 + + +# --------------------------------------------------------------------------- +# Coercion helpers +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Processing draft model +# --------------------------------------------------------------------------- + + +def _default_raman_similarity_metric(template_id: str | None = None) -> str: + token = str(template_id or "").strip().lower() + return _RAMAN_TEMPLATE_SIMILARITY_METRICS.get(token, "cosine") + + +def _default_raman_processing_draft(template_id: str | None = None) -> dict[str, Any]: + return { + "smoothing": copy.deepcopy(_RAMAN_SMOOTHING_DEFAULTS["savgol"]), + "baseline": copy.deepcopy(_RAMAN_BASELINE_DEFAULTS["asls"]), + "normalization": copy.deepcopy(_RAMAN_NORMALIZATION_DEFAULTS), + "peak_detection": copy.deepcopy(_RAMAN_PEAK_DETECTION_DEFAULTS), + "similarity_matching": { + **copy.deepcopy(_RAMAN_SIMILARITY_MATCHING_DEFAULTS), + "metric": _default_raman_similarity_metric(template_id), + }, + } + + +def _normalize_smoothing_values(method: str | None, window_length, polyorder, sigma) -> dict[str, Any]: + token = str(method or "savgol").strip().lower() + if token not in _RAMAN_SMOOTH_METHODS: + token = "savgol" + if token == "savgol": + wl = _coerce_int_positive(window_length, default=11, minimum=5) + if wl % 2 == 0: + wl += 1 + po = _coerce_int_positive(polyorder, default=3, minimum=1) + po = min(po, max(wl - 2, 1)) + return {"method": "savgol", "window_length": wl, "polyorder": po} + if token == "moving_average": + wl = _coerce_int_positive(window_length, default=11, minimum=3) + if wl % 2 == 0: + wl += 1 + return {"method": "moving_average", "window_length": wl} + sg = _coerce_float_positive(sigma, default=2.0, minimum=0.1) + return {"method": "gaussian", "sigma": sg} + + +def _normalize_baseline_region(enabled, rmin, rmax) -> list[float] | None: + if not enabled: + return None + try: + lower = float(rmin) + upper = float(rmax) + except (TypeError, ValueError): + return None + if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper: + return None + return [lower, upper] + + +def _normalize_baseline_values(method: str | None, lam, p, region_enabled=None, region_min=None, region_max=None) -> dict[str, Any]: + token = str(method or "asls").strip().lower() + if token not in _RAMAN_BASELINE_METHODS: + token = "asls" + region = _normalize_baseline_region(region_enabled, region_min, region_max) + if token == "asls": + lam_value = _coerce_float_positive(lam, default=1e6, minimum=1e-3) + p_value = _coerce_float_positive(p, default=0.01, minimum=1e-4) + p_value = min(p_value, 0.5) + return {"method": "asls", "lam": lam_value, "p": p_value, "region": region} + return {"method": token, "region": region} + + +def _normalize_normalization_values(method: str | None) -> dict[str, Any]: + token = str(method or "vector").strip().lower() + if token not in _RAMAN_NORMALIZATION_MODES: + token = "vector" + return {"method": token} + + +def _normalize_peak_detection_values(prominence, distance, max_peaks) -> dict[str, Any]: + prom = _coerce_float_non_negative(prominence, default=0.035) + dist = _coerce_int_positive(distance, default=5, minimum=1) + mp = _coerce_int_positive(max_peaks, default=10, minimum=1) + return {"prominence": prom, "distance": dist, "max_peaks": mp} + + +def _normalize_similarity_matching_values(metric, top_n, minimum_score, *, template_id: str | None = None) -> dict[str, Any]: + metric_token = str(metric or "").strip().lower() + if metric_token not in _RAMAN_SIMILARITY_METRICS: + metric_token = _default_raman_similarity_metric(template_id) + tn = _coerce_int_positive(top_n, default=3, minimum=1) + ms = _coerce_float_non_negative(minimum_score, default=0.45) + return {"metric": metric_token, "top_n": tn, "minimum_score": ms} + + +def _normalize_raman_processing_draft(draft: dict | None, *, template_id: str | None = None) -> dict[str, Any]: + d = dict(draft or {}) + sm = d.get("smoothing") + bl = d.get("baseline") + nm = d.get("normalization") + pk = d.get("peak_detection") + sim = d.get("similarity_matching") + + if isinstance(sm, dict): + sm = _normalize_smoothing_values(sm.get("method"), sm.get("window_length"), sm.get("polyorder"), sm.get("sigma")) + else: + sm = copy.deepcopy(_RAMAN_SMOOTHING_DEFAULTS["savgol"]) + + if isinstance(bl, dict): + bl = _normalize_baseline_values(bl.get("method"), bl.get("lam"), bl.get("p"), bl.get("region") is not None, (bl.get("region") or [None, None])[0], (bl.get("region") or [None, None])[1]) + else: + bl = copy.deepcopy(_RAMAN_BASELINE_DEFAULTS["asls"]) + + if isinstance(nm, dict): + nm = _normalize_normalization_values(nm.get("method")) + else: + nm = copy.deepcopy(_RAMAN_NORMALIZATION_DEFAULTS) + + if isinstance(pk, dict): + pk = _normalize_peak_detection_values(pk.get("prominence"), pk.get("distance"), pk.get("max_peaks")) + else: + pk = copy.deepcopy(_RAMAN_PEAK_DETECTION_DEFAULTS) + + if isinstance(sim, dict): + sim = _normalize_similarity_matching_values( + sim.get("metric"), + sim.get("top_n"), + sim.get("minimum_score"), + template_id=template_id, + ) + else: + sim = _normalize_similarity_matching_values(None, None, None, template_id=template_id) + + return { + "smoothing": sm, + "baseline": bl, + "normalization": nm, + "peak_detection": pk, + "similarity_matching": sim, + } + + +def _raman_draft_from_control_values( + smooth_method, + smooth_window, + smooth_poly, + smooth_sigma, + baseline_method, + baseline_lam, + baseline_p, + baseline_region_enabled, + baseline_region_min, + baseline_region_max, + norm_method, + peak_prominence, + peak_distance, + peak_max_peaks, + sim_metric, + sim_top_n, + sim_minimum_score, + *, + template_id: str | None = None, +) -> dict[str, Any]: + return { + "smoothing": _normalize_smoothing_values(smooth_method, smooth_window, smooth_poly, smooth_sigma), + "baseline": _normalize_baseline_values(baseline_method, baseline_lam, baseline_p, baseline_region_enabled, baseline_region_min, baseline_region_max), + "normalization": _normalize_normalization_values(norm_method), + "peak_detection": _normalize_peak_detection_values(peak_prominence, peak_distance, peak_max_peaks), + "similarity_matching": _normalize_similarity_matching_values( + sim_metric, + sim_top_n, + sim_minimum_score, + template_id=template_id, + ), + } + + +def _raman_overrides_from_draft(draft: dict | None, *, template_id: str | None = None) -> dict[str, Any]: + norm = _normalize_raman_processing_draft(draft, template_id=template_id) + return { + "smoothing": copy.deepcopy(norm["smoothing"]), + "baseline": copy.deepcopy(norm["baseline"]), + "normalization": copy.deepcopy(norm["normalization"]), + "peak_detection": copy.deepcopy(norm["peak_detection"]), + "similarity_matching": copy.deepcopy(norm["similarity_matching"]), + } + + +def _raman_draft_from_loaded_processing(processing: dict | None) -> dict[str, Any]: + if not isinstance(processing, dict): + return copy.deepcopy(_default_raman_processing_draft()) + template_id = str(processing.get("workflow_template_id") or "").strip() or None + sp = processing.get("signal_pipeline") or {} + ast = processing.get("analysis_steps") or {} + sm = sp.get("smoothing") if isinstance(sp.get("smoothing"), dict) else processing.get("smoothing") + bl = sp.get("baseline") if isinstance(sp.get("baseline"), dict) else processing.get("baseline") + nm = sp.get("normalization") if isinstance(sp.get("normalization"), dict) else processing.get("normalization") + pk = ast.get("peak_detection") if isinstance(ast.get("peak_detection"), dict) else processing.get("peak_detection") + sim = ast.get("similarity_matching") if isinstance(ast.get("similarity_matching"), dict) else processing.get("similarity_matching") + return _normalize_raman_processing_draft( + { + "smoothing": sm, + "baseline": bl, + "normalization": nm, + "peak_detection": pk, + "similarity_matching": sim, + }, + template_id=template_id, + ) + + +def _raman_preset_processing_body_for_save(draft: dict | None, *, template_id: str | None = None) -> dict[str, Any]: + norm = _normalize_raman_processing_draft(draft, template_id=template_id) + return { + "smoothing": copy.deepcopy(norm["smoothing"]), + "baseline": copy.deepcopy(norm["baseline"]), + "normalization": copy.deepcopy(norm["normalization"]), + "peak_detection": copy.deepcopy(norm["peak_detection"]), + "similarity_matching": copy.deepcopy(norm["similarity_matching"]), + } + + +def _raman_ui_snapshot_dict(template_id: str | None, draft: dict | None) -> dict[str, Any]: + + tid = template_id if template_id in _RAMAN_TEMPLATE_IDS else "raman.general" + norm = _normalize_raman_processing_draft(draft, template_id=tid) + return { + "workflow_template_id": tid, + "smoothing": norm["smoothing"], + "baseline": norm["baseline"], + "normalization": norm["normalization"], + "peak_detection": norm["peak_detection"], + "similarity_matching": norm["similarity_matching"], + } + + +def _raman_snapshots_equal(a: dict | None, b: dict | None) -> bool: + if not isinstance(a, dict) or not isinstance(b, dict): + return False + return json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str) + + +# --------------------------------------------------------------------------- +# i18n +# --------------------------------------------------------------------------- + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +# --------------------------------------------------------------------------- +# Layout primitives +# --------------------------------------------------------------------------- + + +def _raman_result_section(child: Any, *, role: str = "support") -> html.Div: + role_class = _RAMAN_RESULT_CARD_ROLES.get(role, _RAMAN_RESULT_CARD_ROLES["support"]) + return html.Div(child, className=f"ms-result-section {role_class}") + + +def _raman_library_unavailable(summary: dict | None) -> bool: + return str((summary or {}).get("match_status") or "").lower() == "library_unavailable" + + +def _raman_collapsible_section( + loc: str, + title_key: str, + body: Any, + *, + open: bool = False, + summary_suffix: Any | None = None, +) -> html.Details: + return build_collapsible_section(loc, title_key, body, open=open, summary_suffix=summary_suffix) + + +# --------------------------------------------------------------------------- +# Left-column cards +# --------------------------------------------------------------------------- + + +def _raman_workflow_guide_block() -> html.Details: + return html.Details( + [ + html.Summary( + [html.Span(className="ta-details-chevron"), html.Span(id="raman-workflow-guide-title", className="ms-1")], + className="ta-details-summary", + ), + html.Div(id="raman-workflow-guide-body", className="ta-details-body mt-2 small"), + ], + className="ta-ms-details mb-3", + open=False, + ) + + +def _raman_raw_quality_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H6(id="raman-raw-quality-card-title", className="card-title mb-1"), + html.P(id="raman-raw-quality-card-hint", className="small text-muted mb-2"), + html.Div(id="raman-raw-quality-panel", className="ms-spectral-raw-quality-panel"), + ] + ), + className="mb-3", + ) + + +def _raman_plot_settings_card() -> dbc.Card: + return build_spectral_plot_settings_card("raman") + + +def _raman_processing_history_card() -> dbc.Card: + return build_processing_history_card( + title_id="raman-processing-history-title", + hint_id="raman-processing-history-hint", + undo_button_id="raman-processing-undo-btn", + redo_button_id="raman-processing-redo-btn", + reset_button_id="raman-processing-reset-btn", + status_id="raman-history-status", + ) + + +def _raman_preset_card() -> dbc.Card: + return build_load_saveas_preset_card(id_prefix="raman") + + +def _raman_smoothing_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="raman-smoothing-card-title", className="card-title mb-2"), + html.P(id="raman-smoothing-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-smooth-method-label", html_for="raman-smooth-method", className="mb-1"), + dbc.Select( + id="raman-smooth-method", + options=[ + {"label": "Savitzky–Golay", "value": "savgol"}, + {"label": "Moving average", "value": "moving_average"}, + {"label": "Gaussian", "value": "gaussian"}, + ], + value="savgol", + ), + ], + md=12, + ), + ], + className="g-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-smooth-window-label", html_for="raman-smooth-window", className="mb-1"), + dbc.Input(id="raman-smooth-window", type="number", min=3, step=2, value=11), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="raman-smooth-polyorder-label", html_for="raman-smooth-polyorder", className="mb-1"), + dbc.Input(id="raman-smooth-polyorder", type="number", min=1, max=7, step=1, value=3), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="raman-smooth-sigma-label", html_for="raman-smooth-sigma", className="mb-1"), + dbc.Input(id="raman-smooth-sigma", type="number", min=0.1, step=0.1, value=2.0), + ], + md=4, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _raman_baseline_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="raman-baseline-card-title", className="card-title mb-2"), + html.P(id="raman-baseline-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-baseline-method-label", html_for="raman-baseline-method", className="mb-1"), + dbc.Select( + id="raman-baseline-method", + options=[ + {"label": "AsLS", "value": "asls"}, + {"label": "Linear", "value": "linear"}, + {"label": "Rubberband", "value": "rubberband"}, + ], + value="asls", + ), + ], + md=12, + ), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-baseline-lam-label", html_for="raman-baseline-lam", className="mb-1"), + dbc.Input(id="raman-baseline-lam", type="number", min=1e-3, step=1e5, value=1e6), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="raman-baseline-p-label", html_for="raman-baseline-p", className="mb-1"), + dbc.Input(id="raman-baseline-p", type="number", min=1e-4, max=0.5, step=0.005, value=0.01), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + html.H6(id="raman-baseline-region-section-title", className="mt-2 mb-2 small text-muted text-uppercase"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Checkbox(id="raman-baseline-region-enabled", value=False, label=" "), + html.Small(id="raman-baseline-region-enable-hint", className="form-text text-muted d-block mt-1"), + ], + md=12, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-baseline-region-min-label", html_for="raman-baseline-region-min", className="mb-1"), + dbc.Input(id="raman-baseline-region-min", type="number", value=None), + ], + md=6, + ), + dbc.Col( + [ + dbc.Label(id="raman-baseline-region-max-label", html_for="raman-baseline-region-max", className="mb-1"), + dbc.Input(id="raman-baseline-region-max", type="number", value=None), + ], + md=6, + ), + ], + className="g-2 mb-2", + ), + ] + ), + className="mb-3", + ) + + +def _raman_normalization_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="raman-normalization-card-title", className="card-title mb-2"), + html.P(id="raman-normalization-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-norm-method-label", html_for="raman-norm-method", className="mb-1"), + dbc.Select( + id="raman-norm-method", + options=[ + {"label": "Vector", "value": "vector"}, + {"label": "Max", "value": "max"}, + {"label": "SNV", "value": "snv"}, + ], + value="vector", + ), + ], + md=12, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _raman_peak_detection_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="raman-peak-card-title", className="card-title mb-2"), + html.P(id="raman-peak-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-peak-prominence-label", html_for="raman-peak-prominence", className="mb-1"), + dbc.Input(id="raman-peak-prominence", type="number", min=0, step=0.001, value=0.035), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="raman-peak-distance-label", html_for="raman-peak-distance", className="mb-1"), + dbc.Input(id="raman-peak-distance", type="number", min=1, step=1, value=5), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="raman-peak-max-peaks-label", html_for="raman-peak-max-peaks", className="mb-1"), + dbc.Input(id="raman-peak-max-peaks", type="number", min=1, step=1, value=10), + ], + md=4, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _raman_similarity_matching_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="raman-similarity-card-title", className="card-title mb-2"), + html.P(id="raman-similarity-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="raman-sim-metric-label", html_for="raman-sim-metric", className="mb-1"), + dbc.Select(id="raman-sim-metric", options=[], value=_default_raman_similarity_metric()), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="raman-sim-top-n-label", html_for="raman-sim-top-n", className="mb-1"), + dbc.Input(id="raman-sim-top-n", type="number", min=1, step=1, value=3), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="raman-sim-minimum-score-label", html_for="raman-sim-minimum-score", className="mb-1"), + dbc.Input(id="raman-sim-minimum-score", type="number", min=0, max=1, step=0.01, value=0.45), + ], + md=4, + ), + ], + className="g-3", + ), + ] + ), + className="mb-3", + ) + + +# --------------------------------------------------------------------------- +# Left-column tabs +# --------------------------------------------------------------------------- + + +def _raman_left_column_tabs() -> dbc.Tabs: + return dbc.Tabs( + [ + dbc.Tab( + [ + dataset_selection_card("raman-dataset-selector-area", card_title_id="raman-dataset-card-title"), + workflow_template_card( + "raman-template-select", + "raman-template-description", + [], + "raman.general", + card_title_id="raman-workflow-card-title", + ), + _raman_workflow_guide_block(), + _raman_raw_quality_card(), + ], + tab_id="raman-tab-setup", + label_class_name="ta-tab-label", + id="raman-tab-setup-shell", + ), + dbc.Tab( + [ + _raman_processing_history_card(), + _raman_preset_card(), + _raman_smoothing_controls_card(), + _raman_baseline_controls_card(), + _raman_normalization_controls_card(), + _raman_peak_detection_controls_card(), + _raman_similarity_matching_controls_card(), + _raman_plot_settings_card(), + ], + tab_id="raman-tab-processing", + label_class_name="ta-tab-label", + id="raman-tab-processing-shell", + ), + dbc.Tab( + [ + execute_card("raman-run-status", "raman-run-btn", card_title_id="raman-execute-card-title"), + ], + tab_id="raman-tab-run", + label_class_name="ta-tab-label", + id="raman-tab-run-shell", + ), + ], + id="raman-left-tabs", + active_tab="raman-tab-setup", + className="mb-3", + ) + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = html.Div( + analysis_page_stores("raman-refresh", "raman-latest-result-id") + + [ + dcc.Store(id="raman-figure-captured", data={}), + dcc.Store(id="raman-figure-artifact-refresh", data=0), + dcc.Store(id="raman-processing-default", data=copy.deepcopy(_default_raman_processing_draft())), + dcc.Store(id="raman-processing-draft", data=copy.deepcopy(_default_raman_processing_draft())), + dcc.Store(id="raman-processing-undo-stack", data=[]), + dcc.Store(id="raman-processing-redo-stack", data=[]), + dcc.Store(id="raman-history-hydrate", data=0), + dcc.Store(id="raman-preset-refresh", data=0), + dcc.Store(id="raman-preset-hydrate", data=0), + dcc.Store(id="raman-preset-loaded-name", data=""), + dcc.Store(id="raman-preset-snapshot", data=None), + dcc.Store(id="raman-plot-settings", data=normalize_spectral_plot_settings(None)), + html.Div(id="raman-hero-slot"), + dbc.Row( + [ + dbc.Col( + [_raman_left_column_tabs()], + md=4, + ), + dbc.Col( + [ + _raman_result_section(result_placeholder_card("raman-result-analysis-summary"), role="context"), + _raman_result_section(html.Div(id="raman-result-metrics", className="mb-2"), role="context"), + _raman_result_section(html.Div(id="raman-result-quality", className="mb-2"), role="support"), + _raman_result_section(build_figure_artifact_surface("raman"), role="hero"), + _raman_result_section(html.Div(id="raman-result-top-match", className="mb-2"), role="support"), + _raman_result_section(html.Div(id="raman-result-peak-cards", className="mb-2"), role="support"), + _raman_result_section(html.Div(id="raman-result-match-table", className="mb-2"), role="support"), + _raman_result_section(html.Div(id="raman-result-processing", className="mb-2"), role="support"), + _raman_result_section(html.Div(id="raman-result-raw-metadata", className="mb-2"), role="support"), + _raman_result_section(build_literature_compare_card(id_prefix="raman"), role="secondary"), + ], + md=8, + className="ms-results-surface", + ), + ] + ), + ], + className="raman-page", +) + + +# --------------------------------------------------------------------------- +# Locale / chrome callbacks +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-hero-slot", "children"), + Output("raman-dataset-card-title", "children"), + Output("raman-workflow-card-title", "children"), + Output("raman-execute-card-title", "children"), + Output("raman-run-btn", "children"), + Output("raman-template-select", "options"), + Output("raman-template-select", "value"), + Output("raman-template-description", "children"), + Input("ui-locale", "data"), + Input("raman-template-select", "value"), +) +def render_raman_locale_chrome(locale_data, template_id): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.analysis.raman.title"), + translate_ui(loc, "dash.analysis.raman.caption"), + badge=translate_ui(loc, "dash.analysis.badge"), + ) + opts = [{"label": translate_ui(loc, f"dash.analysis.raman.template.{tid}.label"), "value": tid} for tid in _RAMAN_TEMPLATE_IDS] + valid = {o["value"] for o in opts} + tid = template_id if template_id in valid else "raman.general" + desc_key = f"dash.analysis.raman.template.{tid}.desc" + desc = translate_ui(loc, desc_key) + if desc == desc_key: + desc = translate_ui(loc, "dash.analysis.raman.workflow_fallback") + return ( + hero, + translate_ui(loc, "dash.analysis.dataset_selection_title"), + translate_ui(loc, "dash.analysis.workflow_template_title"), + translate_ui(loc, "dash.analysis.execute_title"), + translate_ui(loc, "dash.analysis.raman.run_btn"), + opts, + tid, + desc, + ) + + +@callback( + Output("raman-tab-setup-shell", "label"), + Output("raman-tab-processing-shell", "label"), + Output("raman-tab-run-shell", "label"), + Input("ui-locale", "data"), +) +def render_raman_tab_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.raman.tab.setup"), + translate_ui(loc, "dash.analysis.raman.tab.processing"), + translate_ui(loc, "dash.analysis.raman.tab.run"), + ) + + +@callback( + Output("raman-workflow-guide-title", "children"), + Output("raman-workflow-guide-body", "children"), + Input("ui-locale", "data"), +) +def render_raman_workflow_guide_chrome(locale_data): + loc = _loc(locale_data) + pfx = "dash.analysis.raman.workflow_guide" + body = html.Div( + [ + html.P(translate_ui(loc, f"{pfx}.intro"), className="mb-2"), + html.Ul( + [ + html.Li(translate_ui(loc, f"{pfx}.step1"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step2"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step3"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step4"), className="mb-0"), + ], + className="ps-3 mb-0", + ), + ] + ) + return translate_ui(loc, f"{pfx}.title"), body + + +@callback( + Output("raman-raw-quality-card-title", "children"), + Output("raman-raw-quality-card-hint", "children"), + Input("ui-locale", "data"), +) +def render_raman_raw_quality_chrome(locale_data): + loc = _loc(locale_data) + return translate_ui(loc, "dash.analysis.raman.raw_quality.card_title"), translate_ui(loc, "dash.analysis.raman.raw_quality.card_hint") + + +@callback( + Output("raman-raw-quality-panel", "children"), + Input("project-id", "data"), + Input("raman-dataset-select", "value"), + Input("raman-refresh", "data"), + Input("ui-locale", "data"), +) +def render_raman_raw_quality_panel(project_id, dataset_key, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id or not dataset_key: + return html.P(translate_ui(loc, "dash.analysis.raman.raw_quality.pick_dataset"), className="text-muted small mb-0") + + from dash_app.api_client import workspace_dataset_data, workspace_dataset_detail + + try: + detail = workspace_dataset_detail(project_id, dataset_key) + data = workspace_dataset_data(project_id, dataset_key) + except Exception as exc: + return html.P(translate_ui(loc, "dash.analysis.raman.raw_quality.load_failed", error=str(exc)), className="text-danger small mb-0") + + rows = data.get("rows") or [] + columns = data.get("columns") or [] + axis, signal = downsample_spectral_rows(rows, columns) + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + stats = compute_spectral_raw_quality_stats(axis, signal, validation=validation) + units = detail.get("units") if isinstance(detail.get("units"), dict) else {} + signal_unit = str(units.get("signal") or "") + return build_spectral_raw_quality_panel( + stats, + loc, + i18n_prefix="dash.analysis.raman.raw_quality", + signal_unit=signal_unit, + ) + + +@callback( + Output("raman-plot-card-title", "children"), + Output("raman-plot-card-hint", "children"), + Output("raman-plot-legend-mode-label", "children"), + Output("raman-plot-legend-mode", "options"), + Output("raman-plot-compact", "label"), + Output("raman-plot-show-grid", "label"), + Output("raman-plot-show-spikes", "label"), + Output("raman-plot-reverse-x-axis", "label"), + Output("raman-plot-export-scale-label", "children"), + Output("raman-plot-line-width-label", "children"), + Output("raman-plot-marker-size-label", "children"), + Output("raman-plot-show-raw", "label"), + Output("raman-plot-show-smoothed", "label"), + Output("raman-plot-show-corrected", "label"), + Output("raman-plot-show-normalized", "label"), + Output("raman-plot-show-peaks", "label"), + Output("raman-plot-x-range-enabled", "label"), + Output("raman-plot-x-min", "placeholder"), + Output("raman-plot-x-max", "placeholder"), + Output("raman-plot-y-range-enabled", "label"), + Output("raman-plot-y-min", "placeholder"), + Output("raman-plot-y-max", "placeholder"), + Input("ui-locale", "data"), +) +def render_raman_plot_settings_chrome(locale_data): + chrome = spectral_plot_settings_chrome(_loc(locale_data)) + return ( + chrome["card_title"], + chrome["card_hint"], + chrome["legend_label"], + chrome["legend_options"], + chrome["compact_label"], + chrome["show_grid_label"], + chrome["show_spikes_label"], + chrome["reverse_x_axis_label"], + chrome["export_scale_label"], + chrome["line_width_label"], + chrome["marker_size_label"], + chrome["show_raw_label"], + chrome["show_smoothed_label"], + chrome["show_corrected_label"], + chrome["show_normalized_label"], + chrome["show_peaks_label"], + chrome["x_lock_label"], + chrome["x_min_placeholder"], + chrome["x_max_placeholder"], + chrome["y_lock_label"], + chrome["y_min_placeholder"], + chrome["y_max_placeholder"], + ) + + +@callback( + Output("raman-plot-settings", "data"), + Input("raman-plot-legend-mode", "value"), + Input("raman-plot-compact", "value"), + Input("raman-plot-show-grid", "value"), + Input("raman-plot-show-spikes", "value"), + Input("raman-plot-line-width-scale", "value"), + Input("raman-plot-marker-size-scale", "value"), + Input("raman-plot-export-scale", "value"), + Input("raman-plot-reverse-x-axis", "value"), + Input("raman-plot-show-raw", "value"), + Input("raman-plot-show-smoothed", "value"), + Input("raman-plot-show-corrected", "value"), + Input("raman-plot-show-normalized", "value"), + Input("raman-plot-show-peaks", "value"), + Input("raman-plot-x-range-enabled", "value"), + Input("raman-plot-x-min", "value"), + Input("raman-plot-x-max", "value"), + Input("raman-plot-y-range-enabled", "value"), + Input("raman-plot-y-min", "value"), + Input("raman-plot-y-max", "value"), +) +def update_raman_plot_settings( + legend_mode, + compact, + show_grid, + show_spikes, + line_width_scale, + marker_size_scale, + export_scale, + reverse_x_axis, + show_raw, + show_smoothed, + show_corrected, + show_normalized, + show_peaks, + x_range_enabled, + x_min, + x_max, + y_range_enabled, + y_min, + y_max, +): + return spectral_plot_settings_from_controls( + legend_mode, + compact, + show_grid, + show_spikes, + line_width_scale, + marker_size_scale, + export_scale, + reverse_x_axis, + show_raw, + show_smoothed, + show_corrected, + show_normalized, + show_peaks, + x_range_enabled, + x_min, + x_max, + y_range_enabled, + y_min, + y_max, + ) + + +# --------------------------------------------------------------------------- +# Dataset loading +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-dataset-selector-area", "children"), + Output("raman-run-btn", "disabled"), + Input("project-id", "data"), + Input("raman-refresh", "data"), + Input("ui-locale", "data"), +) +def load_eligible_datasets(project_id, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + return html.P(translate_ui(loc, "dash.analysis.workspace_inactive"), className="text-muted"), True + + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_datasets", error=str(exc)), color="danger"), True + + all_datasets = payload.get("datasets", []) + return dataset_selector_block( + selector_id="raman-dataset-select", + empty_msg=translate_ui(loc, "dash.analysis.raman.empty_import"), + eligible=eligible_datasets(all_datasets, _RAMAN_ELIGIBLE_TYPES), + all_datasets=all_datasets, + eligible_types=_RAMAN_ELIGIBLE_TYPES, + active_dataset=payload.get("active_dataset"), + locale_data=locale_data, + ) + + +# --------------------------------------------------------------------------- +# Preset callbacks +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-preset-card-title", "children"), + Output("raman-preset-help", "children"), + Output("raman-preset-select-label", "children"), + Output("raman-preset-load-btn", "children"), + Output("raman-preset-delete-btn", "children"), + Output("raman-preset-save-name-label", "children"), + Output("raman-preset-save-name", "placeholder"), + Output("raman-preset-save-btn", "children"), + Output("raman-preset-saveas-btn", "children"), + Output("raman-preset-save-hint", "children"), + Input("ui-locale", "data"), +) +def render_raman_preset_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.raman.presets.title"), + translate_ui(loc, "dash.analysis.raman.presets.help.overview"), + translate_ui(loc, "dash.analysis.raman.presets.select_label"), + translate_ui(loc, "dash.analysis.raman.presets.load_btn"), + translate_ui(loc, "dash.analysis.raman.presets.delete_btn"), + translate_ui(loc, "dash.analysis.raman.presets.save_name_label"), + translate_ui(loc, "dash.analysis.raman.presets.save_name_placeholder"), + translate_ui(loc, "dash.analysis.raman.presets.save_btn"), + translate_ui(loc, "dash.analysis.raman.presets.saveas_btn"), + translate_ui(loc, "dash.analysis.raman.presets.save_hint"), + ) + + +@callback( + Output("raman-preset-select", "options"), + Output("raman-preset-caption", "children"), + Input("raman-preset-refresh", "data"), + Input("ui-locale", "data"), +) +def refresh_raman_preset_options(_refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + try: + payload = api_client.list_analysis_presets(_RAMAN_PRESET_ANALYSIS_TYPE) + except Exception as exc: + message = translate_ui(loc, "dash.analysis.raman.presets.list_failed").format(error=str(exc)) + return [], message + + presets = payload.get("presets") or [] + options = [ + {"label": item.get("preset_name", ""), "value": item.get("preset_name", "")} + for item in presets + if isinstance(item, dict) and item.get("preset_name") + ] + caption = translate_ui(loc, "dash.analysis.raman.presets.caption").format( + analysis_type=payload.get("analysis_type", _RAMAN_PRESET_ANALYSIS_TYPE), + count=int(payload.get("count", len(options)) or 0), + max_count=int(payload.get("max_count", 10) or 10), + ) + return options, caption + + +@callback( + Output("raman-preset-load-btn", "disabled"), + Output("raman-preset-delete-btn", "disabled"), + Output("raman-preset-save-btn", "disabled"), + Input("raman-preset-select", "value"), +) +def toggle_raman_preset_action_buttons(selected_name): + has_selection = bool(str(selected_name or "").strip()) + return (not has_selection, not has_selection, not has_selection) + + +@callback( + Output("raman-processing-draft", "data", allow_duplicate=True), + Output("raman-template-select", "value", allow_duplicate=True), + Output("raman-preset-status", "children", allow_duplicate=True), + Output("raman-preset-hydrate", "data", allow_duplicate=True), + Output("raman-preset-loaded-name", "data", allow_duplicate=True), + Output("raman-preset-snapshot", "data", allow_duplicate=True), + Output("raman-left-tabs", "active_tab", allow_duplicate=True), + Output("raman-processing-undo-stack", "data", allow_duplicate=True), + Output("raman-processing-redo-stack", "data", allow_duplicate=True), + Input("raman-preset-load-btn", "n_clicks"), + State("raman-preset-select", "value"), + State("raman-preset-hydrate", "data"), + State("raman-processing-draft", "data"), + State("raman-processing-undo-stack", "data"), + State("raman-processing-redo-stack", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def apply_raman_preset(n_clicks, selected_name, hydrate_val, current_draft, undo_stack, redo_stack, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.raman.presets.select_required"), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + try: + payload = api_client.load_analysis_preset(_RAMAN_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.raman.presets.load_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + + processing = dict(payload.get("processing") or {}) + draft = _raman_draft_from_loaded_processing(processing) + template_id_raw = str(payload.get("workflow_template_id") or "").strip() + template_out = template_id_raw if template_id_raw in _RAMAN_TEMPLATE_IDS else dash.no_update + resolved_tid = template_id_raw if template_id_raw in _RAMAN_TEMPLATE_IDS else "raman.general" + snap = _raman_ui_snapshot_dict(resolved_tid, draft) + status = translate_ui(loc, "dash.analysis.raman.presets.loaded").format(preset=name) + old_norm = _normalize_raman_processing_draft(current_draft, template_id=resolved_tid) + new_norm = _normalize_raman_processing_draft(draft, template_id=resolved_tid) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return ( + draft, + template_out, + status, + int(hydrate_val or 0) + 1, + name, + snap, + "raman-tab-run", + past2, + fut2, + ) + + +@callback( + Output("raman-preset-refresh", "data", allow_duplicate=True), + Output("raman-preset-save-name", "value", allow_duplicate=True), + Output("raman-preset-status", "children", allow_duplicate=True), + Output("raman-preset-snapshot", "data", allow_duplicate=True), + Output("raman-left-tabs", "active_tab", allow_duplicate=True), + Input("raman-preset-save-btn", "n_clicks"), + Input("raman-preset-saveas-btn", "n_clicks"), + State("raman-preset-select", "value"), + State("raman-preset-save-name", "value"), + State("raman-processing-draft", "data"), + State("raman-template-select", "value"), + State("raman-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_raman_preset(n_save, n_saveas, selected_name, save_name, draft, template_id, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + if trig == "raman-preset-save-btn": + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.raman.presets.select_required"), + dash.no_update, + dash.no_update, + ) + clear_name = dash.no_update + elif trig == "raman-preset-saveas-btn": + name = str(save_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.raman.presets.save_name_required"), + dash.no_update, + dash.no_update, + ) + clear_name = "" + else: + raise dash.exceptions.PreventUpdate + + processing_body = _raman_preset_processing_body_for_save(draft, template_id=template_id) + try: + response = api_client.save_analysis_preset( + _RAMAN_PRESET_ANALYSIS_TYPE, + name, + workflow_template_id=str(template_id or "").strip() or None, + processing=processing_body, + ) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.raman.presets.save_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + resolved_template = str(response.get("workflow_template_id") or template_id or "") + snap = _raman_ui_snapshot_dict(str(template_id or "").strip() or None, draft) + status = translate_ui(loc, "dash.analysis.raman.presets.saved").format(preset=name, template=resolved_template) + return int(refresh_token or 0) + 1, clear_name, status, snap, "raman-tab-run" + + +@callback( + Output("raman-preset-refresh", "data", allow_duplicate=True), + Output("raman-preset-select", "value", allow_duplicate=True), + Output("raman-preset-status", "children", allow_duplicate=True), + Output("raman-preset-loaded-name", "data", allow_duplicate=True), + Output("raman-preset-snapshot", "data", allow_duplicate=True), + Input("raman-preset-delete-btn", "n_clicks"), + State("raman-preset-select", "value"), + State("raman-preset-loaded-name", "data"), + State("raman-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def delete_raman_preset(n_clicks, selected_name, loaded_name, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.raman.presets.select_required"), dash.no_update, dash.no_update + try: + api_client.delete_analysis_preset(_RAMAN_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.raman.presets.delete_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + status = translate_ui(loc, "dash.analysis.raman.presets.deleted").format(preset=name) + loaded = str(loaded_name or "").strip() + if loaded == name: + return int(refresh_token or 0) + 1, None, status, "", None + return int(refresh_token or 0) + 1, None, status, dash.no_update, dash.no_update + + +@callback( + Output("raman-preset-loaded-line", "children"), + Input("raman-preset-loaded-name", "data"), + Input("ui-locale", "data"), +) +def render_raman_preset_loaded_line(loaded_name, locale_data): + loc = _loc(locale_data) + name = str(loaded_name or "").strip() + if not name: + return "" + return translate_ui(loc, "dash.analysis.raman.presets.loaded_line").format(preset=name) + + +@callback( + Output("raman-preset-dirty-flag", "children"), + Input("ui-locale", "data"), + Input("raman-template-select", "value"), + Input("raman-smooth-method", "value"), + Input("raman-smooth-window", "value"), + Input("raman-smooth-polyorder", "value"), + Input("raman-smooth-sigma", "value"), + Input("raman-baseline-method", "value"), + Input("raman-baseline-lam", "value"), + Input("raman-baseline-p", "value"), + Input("raman-baseline-region-enabled", "value"), + Input("raman-baseline-region-min", "value"), + Input("raman-baseline-region-max", "value"), + Input("raman-norm-method", "value"), + Input("raman-peak-prominence", "value"), + Input("raman-peak-distance", "value"), + Input("raman-peak-max-peaks", "value"), + Input("raman-sim-metric", "value"), + Input("raman-sim-top-n", "value"), + Input("raman-sim-minimum-score", "value"), + State("raman-preset-snapshot", "data"), +) +def render_raman_preset_dirty_flag( + locale_data, + template_id, + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + sim_metric, sim_tn, sim_ms, + snapshot, +): + loc = _loc(locale_data) + if not isinstance(snapshot, dict): + return html.Span(translate_ui(loc, "dash.analysis.raman.presets.dirty_no_baseline"), className="text-muted") + current = _raman_ui_snapshot_dict( + template_id, + _raman_draft_from_control_values( + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + sim_metric, sim_tn, sim_ms, + template_id=template_id, + ), + ) + if _raman_snapshots_equal(snapshot, current): + return html.Span(translate_ui(loc, "dash.analysis.raman.presets.clean"), className="text-success") + return html.Span(translate_ui(loc, "dash.analysis.raman.presets.dirty"), className="text-warning") + + +# --------------------------------------------------------------------------- +# Processing controls chrome +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-processing-history-title", "children"), + Output("raman-processing-history-hint", "children"), + Output("raman-processing-undo-btn", "children"), + Output("raman-processing-redo-btn", "children"), + Output("raman-processing-reset-btn", "children"), + Input("ui-locale", "data"), +) +def render_raman_processing_history_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.raman.processing.history_title"), + translate_ui(loc, "dash.analysis.raman.processing.history_hint"), + translate_ui(loc, "dash.analysis.raman.processing.undo_btn"), + translate_ui(loc, "dash.analysis.raman.processing.redo_btn"), + translate_ui(loc, "dash.analysis.raman.processing.reset_btn"), + ) + + +@callback( + Output("raman-smoothing-card-title", "children"), + Output("raman-smoothing-card-hint", "children"), + Output("raman-smooth-method-label", "children"), + Output("raman-smooth-window-label", "children"), + Output("raman-smooth-polyorder-label", "children"), + Output("raman-smooth-sigma-label", "children"), + Output("raman-smooth-method", "options"), + Input("ui-locale", "data"), +) +def render_raman_smoothing_chrome(locale_data): + loc = _loc(locale_data) + smooth_opts = [ + {"label": translate_ui(loc, "dash.analysis.raman.processing.smooth.savgol"), "value": "savgol"}, + {"label": translate_ui(loc, "dash.analysis.raman.processing.smooth.moving_average"), "value": "moving_average"}, + {"label": translate_ui(loc, "dash.analysis.raman.processing.smooth.gaussian"), "value": "gaussian"}, + ] + return ( + translate_ui(loc, "dash.analysis.raman.processing.smoothing_card_title"), + translate_ui(loc, "dash.analysis.raman.processing.smoothing_card_hint"), + translate_ui(loc, "dash.analysis.raman.processing.smooth.method"), + translate_ui(loc, "dash.analysis.raman.processing.smooth.window"), + translate_ui(loc, "dash.analysis.raman.processing.smooth.polyorder"), + translate_ui(loc, "dash.analysis.raman.processing.smooth.sigma"), + smooth_opts, + ) + + +@callback( + Output("raman-baseline-card-title", "children"), + Output("raman-baseline-card-hint", "children"), + Output("raman-baseline-method-label", "children"), + Output("raman-baseline-lam-label", "children"), + Output("raman-baseline-p-label", "children"), + Output("raman-baseline-region-section-title", "children"), + Output("raman-baseline-region-enable-hint", "children"), + Output("raman-baseline-region-min-label", "children"), + Output("raman-baseline-region-max-label", "children"), + Input("ui-locale", "data"), +) +def render_raman_baseline_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.raman.baseline.title"), + translate_ui(loc, "dash.analysis.raman.baseline.help.method"), + translate_ui(loc, "dash.analysis.raman.baseline.method"), + translate_ui(loc, "dash.analysis.raman.baseline.lam"), + translate_ui(loc, "dash.analysis.raman.baseline.p"), + translate_ui(loc, "dash.analysis.raman.baseline.region_section"), + translate_ui(loc, "dash.analysis.raman.baseline.help.enable_region"), + translate_ui(loc, "dash.analysis.raman.baseline.region_min"), + translate_ui(loc, "dash.analysis.raman.baseline.region_max"), + ) + + +@callback( + Output("raman-normalization-card-title", "children"), + Output("raman-normalization-card-hint", "children"), + Output("raman-norm-method-label", "children"), + Output("raman-norm-method", "options"), + Input("ui-locale", "data"), +) +def render_raman_normalization_chrome(locale_data): + loc = _loc(locale_data) + opts = [ + {"label": translate_ui(loc, "dash.analysis.raman.norm.vector"), "value": "vector"}, + {"label": translate_ui(loc, "dash.analysis.raman.norm.max"), "value": "max"}, + {"label": translate_ui(loc, "dash.analysis.raman.norm.snv"), "value": "snv"}, + ] + return ( + translate_ui(loc, "dash.analysis.raman.normalization.title"), + translate_ui(loc, "dash.analysis.raman.normalization.hint"), + translate_ui(loc, "dash.analysis.raman.normalization.method"), + opts, + ) + + +@callback( + Output("raman-peak-card-title", "children"), + Output("raman-peak-card-hint", "children"), + Output("raman-peak-prominence-label", "children"), + Output("raman-peak-distance-label", "children"), + Output("raman-peak-max-peaks-label", "children"), + Input("ui-locale", "data"), +) +def render_raman_peak_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.raman.peaks.title"), + translate_ui(loc, "dash.analysis.raman.peaks.hint"), + translate_ui(loc, "dash.analysis.raman.peaks.prominence"), + translate_ui(loc, "dash.analysis.raman.peaks.distance"), + translate_ui(loc, "dash.analysis.raman.peaks.max_peaks"), + ) + + +@callback( + Output("raman-similarity-card-title", "children"), + Output("raman-similarity-card-hint", "children"), + Output("raman-sim-metric-label", "children"), + Output("raman-sim-metric", "options"), + Output("raman-sim-top-n-label", "children"), + Output("raman-sim-minimum-score-label", "children"), + Input("ui-locale", "data"), +) +def render_raman_similarity_chrome(locale_data): + loc = _loc(locale_data) + metric_options = [ + {"label": translate_ui(loc, "dash.analysis.raman.similarity.metric.cosine"), "value": "cosine"}, + {"label": translate_ui(loc, "dash.analysis.raman.similarity.metric.pearson"), "value": "pearson"}, + ] + return ( + translate_ui(loc, "dash.analysis.raman.similarity.title"), + translate_ui(loc, "dash.analysis.raman.similarity.hint"), + translate_ui(loc, "dash.analysis.raman.similarity.metric"), + metric_options, + translate_ui(loc, "dash.analysis.raman.similarity.top_n"), + translate_ui(loc, "dash.analysis.raman.similarity.minimum_score"), + ) + + +# --------------------------------------------------------------------------- +# Toggle inputs based on method +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-smooth-window", "disabled"), + Output("raman-smooth-polyorder", "disabled"), + Output("raman-smooth-sigma", "disabled"), + Input("raman-smooth-method", "value"), +) +def toggle_raman_smoothing_inputs(method): + token = str(method or "savgol").strip().lower() + if token == "savgol": + return False, False, True + if token == "moving_average": + return False, True, True + return True, True, False + + +@callback( + Output("raman-baseline-lam", "disabled"), + Output("raman-baseline-p", "disabled"), + Input("raman-baseline-method", "value"), +) +def toggle_raman_baseline_inputs(method): + token = str(method or "asls").strip().lower() + if token == "asls": + return False, False + return True, True + + +@callback( + Output("raman-baseline-region-min", "disabled"), + Output("raman-baseline-region-max", "disabled"), + Input("raman-baseline-region-enabled", "value"), +) +def toggle_raman_baseline_region_inputs(enabled): + return (not bool(enabled), not bool(enabled)) + + +# --------------------------------------------------------------------------- +# Hydrate controls from draft +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-smooth-method", "value"), + Output("raman-smooth-window", "value"), + Output("raman-smooth-polyorder", "value"), + Output("raman-smooth-sigma", "value"), + Output("raman-baseline-method", "value"), + Output("raman-baseline-lam", "value"), + Output("raman-baseline-p", "value"), + Output("raman-baseline-region-enabled", "value"), + Output("raman-baseline-region-min", "value"), + Output("raman-baseline-region-max", "value"), + Output("raman-norm-method", "value"), + Output("raman-peak-prominence", "value"), + Output("raman-peak-distance", "value"), + Output("raman-peak-max-peaks", "value"), + Output("raman-sim-metric", "value"), + Output("raman-sim-top-n", "value"), + Output("raman-sim-minimum-score", "value"), + Input("raman-preset-hydrate", "data"), + Input("raman-history-hydrate", "data"), + Input("raman-template-select", "value"), + State("raman-processing-draft", "data"), +) +def hydrate_raman_processing_controls(_preset_hydrate, _history_hydrate, template_id, draft): + d = _normalize_raman_processing_draft(draft, template_id=template_id) + sm = d["smoothing"] + bl = d["baseline"] + nm = d["normalization"] + pk = d["peak_detection"] + sim = d["similarity_matching"] + + method = str(sm.get("method") or "savgol") + wl = int(sm.get("window_length", 11)) + po = int(sm.get("polyorder", 3)) + sigma = float(sm.get("sigma", 2.0)) + + bl_method = str(bl.get("method") or "asls") + lam = float(bl.get("lam", 1e6)) + p = float(bl.get("p", 0.01)) + region = bl.get("region") + enabled = isinstance(region, (list, tuple)) and len(region) == 2 + region_min = region[0] if enabled else None + region_max = region[1] if enabled else None + + norm_method = str(nm.get("method") or "vector") + + prom = float(pk.get("prominence", 0.035)) + dist = int(pk.get("distance", 5)) + mp = int(pk.get("max_peaks", 10)) + + metric = str(sim.get("metric") or _default_raman_similarity_metric(template_id)) + top_n = int(sim.get("top_n", 3)) + min_score = float(sim.get("minimum_score", 0.45)) + + return ( + method, wl, po, sigma, + bl_method, lam, p, bool(enabled), region_min, region_max, + norm_method, + prom, dist, mp, + metric, top_n, min_score, + ) + + +# --------------------------------------------------------------------------- +# Sync draft from controls + history +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-processing-draft", "data", allow_duplicate=True), + Output("raman-processing-undo-stack", "data", allow_duplicate=True), + Output("raman-processing-redo-stack", "data", allow_duplicate=True), + Input("raman-smooth-method", "value"), + Input("raman-smooth-window", "value"), + Input("raman-smooth-polyorder", "value"), + Input("raman-smooth-sigma", "value"), + Input("raman-baseline-method", "value"), + Input("raman-baseline-lam", "value"), + Input("raman-baseline-p", "value"), + Input("raman-baseline-region-enabled", "value"), + Input("raman-baseline-region-min", "value"), + Input("raman-baseline-region-max", "value"), + Input("raman-norm-method", "value"), + Input("raman-peak-prominence", "value"), + Input("raman-peak-distance", "value"), + Input("raman-peak-max-peaks", "value"), + Input("raman-template-select", "value"), + Input("raman-sim-metric", "value"), + Input("raman-sim-top-n", "value"), + Input("raman-sim-minimum-score", "value"), + State("raman-processing-draft", "data"), + State("raman-processing-undo-stack", "data"), + State("raman-processing-redo-stack", "data"), + prevent_initial_call="initial_duplicate", +) +def sync_raman_processing_draft_from_controls( + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + template_id, sim_metric, sim_tn, sim_ms, + prev_draft, undo_stack, redo_stack, +): + ctx = dash.callback_context + metric_value = None if ctx.triggered_id == "raman-template-select" else sim_metric + new_draft = _raman_draft_from_control_values( + sm_m, sm_w, sm_p, sm_s, + bl_m, bl_l, bl_p, bl_re, bl_rmin, bl_rmax, + nm_m, + pk_pr, pk_dist, pk_mp, + metric_value, sim_tn, sim_ms, + template_id=template_id, + ) + old_norm = _normalize_raman_processing_draft(prev_draft, template_id=template_id) + new_norm = _normalize_raman_processing_draft(new_draft, template_id=template_id) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return new_norm, past2, fut2 + + +@callback( + Output("raman-processing-draft", "data", allow_duplicate=True), + Output("raman-processing-undo-stack", "data", allow_duplicate=True), + Output("raman-processing-redo-stack", "data", allow_duplicate=True), + Output("raman-history-hydrate", "data", allow_duplicate=True), + Output("raman-history-status", "children", allow_duplicate=True), + Input("raman-processing-undo-btn", "n_clicks"), + Input("raman-processing-redo-btn", "n_clicks"), + Input("raman-processing-reset-btn", "n_clicks"), + State("raman-processing-draft", "data"), + State("raman-processing-undo-stack", "data"), + State("raman-processing-redo-stack", "data"), + State("raman-history-hydrate", "data"), + State("raman-processing-default", "data"), + State("raman-template-select", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def raman_processing_history_actions(n_undo, n_redo, n_reset, draft, undo_stack, redo_stack, hist_hydrate, defaults, template_id, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + cur = _normalize_raman_processing_draft(draft, template_id=template_id) + past = undo_stack or [] + fut = redo_stack or [] + h = int(hist_hydrate or 0) + + if trig == "raman-processing-undo-btn": + if not n_undo: + raise dash.exceptions.PreventUpdate + res = perform_undo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + prev, pl, fl = res + return prev, pl, fl, h + 1, translate_ui(loc, "dash.analysis.raman.processing.history_status_undo") + + if trig == "raman-processing-redo-btn": + if not n_redo: + raise dash.exceptions.PreventUpdate + res = perform_redo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + nxt, pl, fl = res + return nxt, pl, fl, h + 1, translate_ui(loc, "dash.analysis.raman.processing.history_status_redo") + + if trig == "raman-processing-reset-btn": + if not n_reset: + raise dash.exceptions.PreventUpdate + default_seed = copy.deepcopy(defaults or _default_raman_processing_draft(template_id)) + if isinstance(default_seed.get("similarity_matching"), dict): + default_seed["similarity_matching"] = dict(default_seed["similarity_matching"]) + default_seed["similarity_matching"].pop("metric", None) + default_draft = _normalize_raman_processing_draft(default_seed, template_id=template_id) + if raman_draft_processing_equal(cur, default_draft): + raise dash.exceptions.PreventUpdate + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + past_list.append(copy.deepcopy(cur)) + if len(past_list) > MAX_RAMAN_UNDO_DEPTH: + past_list = past_list[-MAX_RAMAN_UNDO_DEPTH:] + return default_draft, past_list, [], h + 1, translate_ui(loc, "dash.analysis.raman.processing.history_status_reset") + + raise dash.exceptions.PreventUpdate + + +@callback( + Output("raman-processing-undo-btn", "disabled"), + Output("raman-processing-redo-btn", "disabled"), + Input("raman-processing-undo-stack", "data"), + Input("raman-processing-redo-stack", "data"), +) +def toggle_raman_processing_history_buttons(undo_stack, redo_stack): + u = undo_stack or [] + r = redo_stack or [] + return len(u) == 0, len(r) == 0 + + +# --------------------------------------------------------------------------- +# Run analysis +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-run-status", "children"), + Output("raman-refresh", "data", allow_duplicate=True), + Output("raman-latest-result-id", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("raman-run-btn", "n_clicks"), + State("project-id", "data"), + State("raman-dataset-select", "value"), + State("raman-template-select", "value"), + State("raman-processing-draft", "data"), + State("raman-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def run_raman_analysis(n_clicks, project_id, dataset_key, template_id, processing_draft, refresh_val, global_refresh, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id or not dataset_key: + raise dash.exceptions.PreventUpdate + + from dash_app.api_client import analysis_run + + overrides = _raman_overrides_from_draft(processing_draft, template_id=template_id) + try: + result = analysis_run( + project_id=project_id, + dataset_key=dataset_key, + analysis_type="RAMAN", + workflow_template_id=template_id, + processing_overrides=overrides or None, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.analysis_failed", error=str(exc)), color="danger"), dash.no_update, dash.no_update, dash.no_update + + alert, saved, result_id = interpret_run_result(result, locale_data=locale_data) + refresh = (refresh_val or 0) + 1 + if saved: + return alert, refresh, result_id, (global_refresh or 0) + 1 + return alert, refresh, dash.no_update, dash.no_update + + +# --------------------------------------------------------------------------- +# Display result (right-column surface) +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-result-analysis-summary", "children"), + Output("raman-result-metrics", "children"), + Output("raman-result-quality", "children"), + Output("raman-result-figure", "children"), + Output("raman-result-top-match", "children"), + Output("raman-result-peak-cards", "children"), + Output("raman-result-match-table", "children"), + Output("raman-result-processing", "children"), + Output("raman-result-raw-metadata", "children"), + Input("raman-latest-result-id", "data"), + Input("raman-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + Input("raman-plot-settings", "data"), + State("project-id", "data"), +) +def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + summary_empty = html.P(translate_ui(loc, "dash.analysis.raman.summary.empty"), className="text-muted") + quality_empty = _raman_collapsible_section( + loc, + "dash.analysis.raman.quality.card_title", + html.P(translate_ui(loc, "dash.analysis.raman.quality.empty"), className="text-muted mb-0"), + open=False, + ) + raw_meta_empty = _raman_collapsible_section( + loc, + "dash.analysis.raman.raw_metadata.card_title", + html.P(translate_ui(loc, "dash.analysis.raman.raw_metadata.empty"), className="text-muted mb-0"), + open=False, + ) + _deferred_hidden = html.Div(className="d-none") + metrics_hint = html.P(translate_ui(loc, "dash.analysis.raman.empty_results_hint"), className="text-muted mb-0") + if not result_id or not project_id: + return ( + summary_empty, + metrics_hint, + quality_empty, + empty_msg, + _deferred_hidden, + _deferred_hidden, + _deferred_hidden, + _deferred_hidden, + raw_meta_empty, + ) + + from dash_app.api_client import workspace_dataset_detail, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + err = dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_result", error=str(exc)), color="danger") + return summary_empty, err, quality_empty, empty_msg, _deferred_hidden, _deferred_hidden, _deferred_hidden, _deferred_hidden, raw_meta_empty + + summary = detail.get("summary", {}) + result_meta = detail.get("result", {}) + processing = detail.get("processing", {}) + rows = detail.get("rows_preview", []) + dataset_key = result_meta.get("dataset_key") + + dataset_detail: dict = {} + if dataset_key: + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + + analysis_summary = _build_raman_analysis_summary( + dataset_detail, + summary, + result_meta, + loc, + locale_data=locale_data, + ) + quality_panel = _build_raman_quality_card(detail, result_meta, loc) + raw_metadata_panel = _build_raman_raw_metadata_panel((dataset_detail or {}).get("metadata"), loc) + + peak_count = summary.get("peak_count", 0) + match_status = _match_status_label(loc, summary.get("match_status")) + top_score = summary.get("top_match_score", 0.0) + sample_name = resolve_sample_name(summary, result_meta, locale_data=locale_data) + na = translate_ui(loc, "dash.analysis.na") + lib_unavailable = _raman_library_unavailable(summary) + top_score_str = ( + translate_ui(loc, "dash.analysis.raman.metric.score_not_applicable") + if lib_unavailable + else (f"{float(top_score):.4f}" if top_score else na) + ) + + metrics = metrics_row( + [ + ("dash.analysis.metric.peaks", str(peak_count)), + ("dash.analysis.metric.match_status", match_status), + ("dash.analysis.metric.top_score", top_score_str), + ("dash.analysis.metric.sample", sample_name), + ], + locale_data=locale_data, + ) + + figure_area = empty_msg + top_match_area = empty_msg + peak_cards_area = empty_msg + if dataset_key: + figure_area = _build_figure(project_id, dataset_key, summary, ui_theme, loc, plot_settings=plot_settings) + top_match_area = _build_top_match_panel(summary, rows, loc) + peak_cards_area = _build_peak_cards_from_curves(project_id, dataset_key, summary, loc) + + table_area = _build_match_table(rows, loc, summary=summary) + + proc_view = processing_details_section( + processing, + extra_lines=[ + html.P(translate_ui(loc, "dash.analysis.raman.baseline", detail=processing.get("signal_pipeline", {}).get("baseline", {}))), + html.P(translate_ui(loc, "dash.analysis.raman.normalization", detail=processing.get("signal_pipeline", {}).get("normalization", {}))), + html.P(translate_ui(loc, "dash.analysis.raman.peak_detection", detail=processing.get("analysis_steps", {}).get("peak_detection", {}))), + html.P(translate_ui(loc, "dash.analysis.raman.similarity_matching", detail=processing.get("analysis_steps", {}).get("similarity_matching", {}))), + html.P( + translate_ui( + loc, + "dash.analysis.raman.library", + mode=processing.get("method_context", {}).get("library_access_mode", na), + source=processing.get("method_context", {}).get("library_result_source", na), + ), + className="mb-0", + ), + ], + locale_data=locale_data, + ) + + return ( + analysis_summary, + metrics, + quality_panel, + figure_area, + top_match_area, + peak_cards_area, + table_area, + proc_view, + raw_metadata_panel, + ) + + +# --------------------------------------------------------------------------- +# Literature callbacks +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-literature-card-title", "children"), + Output("raman-literature-hint", "children"), + Output("raman-literature-max-claims-label", "children"), + Output("raman-literature-persist-label", "children"), + Output("raman-literature-compare-btn", "children"), + Input("ui-locale", "data"), + Input("raman-latest-result-id", "data"), +) +def render_raman_literature_chrome(locale_data, result_id): + loc = _loc(locale_data) + if result_id: + hint = literature_t( + loc, + f"{_RAMAN_LITERATURE_PREFIX}.ready", + "Compare the saved RAMAN result to literature sources.", + ) + else: + hint = literature_t( + loc, + f"{_RAMAN_LITERATURE_PREFIX}.empty", + "Run an RAMAN analysis first to enable literature comparison.", + ) + return ( + literature_t(loc, f"{_RAMAN_LITERATURE_PREFIX}.title", "Literature Compare"), + hint, + literature_t(loc, f"{_RAMAN_LITERATURE_PREFIX}.max_claims", "Max Claims"), + literature_t(loc, f"{_RAMAN_LITERATURE_PREFIX}.persist", "Persist to project"), + literature_t(loc, f"{_RAMAN_LITERATURE_PREFIX}.compare_btn", "Compare"), + ) + + +@callback( + Output("raman-literature-compare-btn", "disabled"), + Input("raman-latest-result-id", "data"), +) +def toggle_raman_literature_compare_button(result_id): + return not bool(result_id) + + +@callback( + Output("raman-literature-output", "children"), + Output("raman-literature-status", "children"), + Input("raman-literature-compare-btn", "n_clicks"), + State("project-id", "data"), + State("raman-latest-result-id", "data"), + State("raman-literature-max-claims", "value"), + State("raman-literature-persist", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def compare_raman_literature(n_clicks, project_id, result_id, max_claims, persist_values, locale_data): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + if not project_id or not result_id: + msg = literature_t( + loc, + f"{_RAMAN_LITERATURE_PREFIX}.missing_result", + "Run an RAMAN analysis first.", + ) + return dash.no_update, dbc.Alert(msg, color="warning", className="py-1 small") + + claims_limit = coerce_literature_max_claims(max_claims, default=3) + persist = bool(persist_values) and "persist" in (persist_values or []) + + from dash_app.api_client import literature_compare + + try: + payload = literature_compare( + project_id, + result_id, + max_claims=claims_limit, + persist=persist, + ) + except Exception as exc: + err = dbc.Alert( + literature_t( + loc, + f"{_RAMAN_LITERATURE_PREFIX}.error", + "Literature compare failed: {error}", + ).replace("{error}", str(exc)), + color="danger", + className="py-1 small", + ) + return dash.no_update, err + + return ( + render_literature_output( + payload, + loc, + i18n_prefix=_RAMAN_LITERATURE_PREFIX, + evidence_preview_limit=LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + alternative_preview_limit=LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + ), + literature_compare_status_alert(payload, loc, i18n_prefix=_RAMAN_LITERATURE_PREFIX), + ) + + +def _raman_fetch_figure_preview_data_urls(project_id: str, result_id: str, figure_artifacts: dict) -> dict[str, str]: + from dash_app.api_client import fetch_result_figure_png + + out: dict[str, str] = {} + for label in ordered_figure_preview_keys(figure_artifacts)[:FIGURE_ARTIFACT_PREVIEW_TILES]: + try: + raw = fetch_result_figure_png(project_id, result_id, label, max_edge=FIGURE_ARTIFACT_PREVIEW_MAX_EDGE) + out[label] = "data:image/png;base64," + base64.standard_b64encode(bytes(raw)).decode("ascii") if raw else "" + except Exception: + out[label] = "" + return out + + +@callback( + Output("raman-figure-save-snapshot-btn", "children"), + Output("raman-figure-use-report-btn", "children"), + Output("raman-figure-artifacts-summary", "children"), + Input("ui-locale", "data"), +) +def render_raman_figure_artifact_button_labels(locale_data): + return figure_artifact_button_labels(_loc(locale_data)) + + +@callback( + Output("raman-figure-save-snapshot-btn", "disabled"), + Output("raman-figure-use-report-btn", "disabled"), + Input("raman-latest-result-id", "data"), +) +def toggle_raman_figure_artifact_buttons(result_id): + disabled = not bool(result_id) + return disabled, disabled + + +@callback( + Output("raman-result-figure-artifacts", "children"), + Input("raman-latest-result-id", "data"), + Input("raman-figure-artifact-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def refresh_raman_figure_artifacts_panel(result_id, _artifact_refresh, locale_data, project_id): + loc = _loc(locale_data) + if not result_id or not project_id: + return "" + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + return "" + artifacts = detail.get("figure_artifacts") if isinstance(detail.get("figure_artifacts"), dict) else {} + previews = _raman_fetch_figure_preview_data_urls(project_id, result_id, artifacts) if ordered_figure_preview_keys(artifacts) else None + return build_figure_artifacts_panel(artifacts, loc, previews=previews) + + +@callback( + Output("raman-figure-artifact-status", "children"), + Output("raman-figure-artifact-refresh", "data"), + Input("raman-figure-save-snapshot-btn", "n_clicks"), + Input("raman-figure-use-report-btn", "n_clicks"), + Input("raman-latest-result-id", "data"), + State("project-id", "data"), + State("raman-result-figure", "children"), + State("ui-locale", "data"), + State("raman-figure-artifact-refresh", "data"), + prevent_initial_call=True, +) +def raman_figure_snapshot_or_report_figure(_snap_clicks, _report_clicks, latest_result_id, project_id, figure_children, locale_data, refresh_value): + loc = _loc(locale_data) + triggered_id = getattr(dash.callback_context, "triggered_id", None) + if triggered_id == "raman-latest-result-id": + return "", dash.no_update + action = figure_action_from_trigger( + triggered_id, + snapshot_button_id="raman-figure-save-snapshot-btn", + report_button_id="raman-figure-use-report-btn", + ) + if action is None: + raise dash.exceptions.PreventUpdate + if not project_id or not latest_result_id: + return ( + figure_action_status_alert(loc, action=action, status="missing", reason="missing_project_or_result", class_prefix="raman"), + dash.no_update, + ) + + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, latest_result_id) + except Exception as exc: + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(exc), class_prefix="raman"), + dash.no_update, + ) + result_meta = detail.get("result", {}) or {} + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + meta = figure_action_metadata( + action, + analysis_type="RAMAN", + dataset_key=result_meta.get("dataset_key"), + result_id=latest_result_id, + snapshot_stamp=stamp, + ) + outcome = register_result_figure_from_layout_children( + figure_children=figure_children, + project_id=project_id, + result_id=latest_result_id, + label=str(meta.get("label") or ""), + replace=bool(meta.get("replace")), + ) + if outcome.get("status") == "ok": + key = str(outcome.get("figure_key") or meta.get("label") or "") + return ( + figure_action_status_alert(loc, action=action, status="ok", figure_key=key, class_prefix="raman"), + (refresh_value or 0) + 1, + ) + if outcome.get("status") == "error": + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(outcome.get("reason") or ""), class_prefix="raman"), + dash.no_update, + ) + return ( + figure_action_status_alert(loc, action=action, status="skipped", reason=str(outcome.get("reason") or ""), class_prefix="raman"), + dash.no_update, + ) + + +# --------------------------------------------------------------------------- +# Figure capture +# --------------------------------------------------------------------------- + + +@callback( + Output("raman-figure-captured", "data"), + Input("raman-latest-result-id", "data"), + Input("project-id", "data"), + Input("raman-result-figure", "children"), + State("raman-figure-captured", "data"), + prevent_initial_call=True, +) +def capture_raman_figure(result_id, project_id, figure_children, captured): + return capture_result_figure_from_layout( + result_id=result_id, + project_id=project_id, + figure_children=figure_children, + captured=captured, + analysis_type="RAMAN", + ) + + +# --------------------------------------------------------------------------- +# RAMAN-specific result builders +# --------------------------------------------------------------------------- + + +def _format_dataset_metadata_value(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, float): + if value != value: + return None + text = f"{value:g}" + else: + text = str(value).strip() + return text or None + + +def _match_status_label(loc: str, raw: str | None) -> str: + token = str(raw or "no_match").lower().replace(" ", "_") + key = f"dash.analysis.match_status.{token}" + text = translate_ui(loc, key) + if text == key: + s = str(raw or "").replace("_", " ").strip() + return s.title() if s else translate_ui(loc, "dash.analysis.na") + return text + + +def _confidence_band_label(loc: str, band: str | None) -> str: + token = str(band or "no_match").lower().replace(" ", "_") + key = f"dash.analysis.confidence.{token}" + text = translate_ui(loc, key) + if text == key: + return str(band).replace("_", " ").title() + return text + + +_CONFIDENCE_COLORS = { + "high_confidence": "#059669", + "moderate_confidence": "#D97706", + "low_confidence": "#DC2626", + "no_match": "#6B7280", +} + +_RAMAN_FIGURE_COLORS = { + "query": "#0F172A", + "smoothed": "#0E7490", + "raw": "#94A3B8", + "baseline": "#B45309", + "normalized": "#7C3AED", + "grid": "rgba(148, 163, 184, 0.18)", + "axis": "#475569", + "panel": "#FCFDFE", +} + + +def _build_raman_analysis_summary( + dataset_detail: dict, + summary: dict, + result_meta: dict, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + metadata = (dataset_detail or {}).get("metadata") or {} + dataset_summary = (dataset_detail or {}).get("dataset") or {} + na = translate_ui(loc, "dash.analysis.na") + + dataset_label = ( + _format_dataset_metadata_value(metadata.get("file_name")) + or _format_dataset_metadata_value(dataset_summary.get("display_name")) + or _format_dataset_metadata_value(result_meta.get("dataset_key")) + or na + ) + fallback_display_name = _format_dataset_metadata_value(dataset_summary.get("display_name")) + sample_label = resolve_sample_name( + summary or {}, + result_meta or {}, + fallback_display_name=fallback_display_name, + locale_data=locale_data, + ) or na + + instrument = _format_dataset_metadata_value(metadata.get("instrument")) or na + vendor = _format_dataset_metadata_value(metadata.get("vendor")) or na + + def _meta_value(value: str) -> html.Span: + return html.Span(value, className="ms-meta-value", title=value) + + dl_rows: list[Any] = [ + html.Dt(translate_ui(loc, "dash.analysis.raman.summary.dataset_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(dataset_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.raman.summary.sample_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.raman.summary.instrument_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(instrument), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.raman.summary.vendor_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(vendor), className="col-sm-8 ms-meta-def"), + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.raman.summary.card_title"), className="mb-3"), + html.Dl(dl_rows, className="row mb-0"), + ] + ) + + +def _build_raman_quality_card(detail: dict, result_meta: dict, loc: str) -> html.Details: + return build_validation_quality_card( + detail, + result_meta, + loc, + i18n_prefix="dash.analysis.raman.quality", + collapsible_builder=_raman_collapsible_section, + derive_counts_from_lists=True, + open_when_attention=True, + include_attention_badges=True, + ) + + +def _build_raman_raw_metadata_panel(metadata: dict | None, loc: str) -> html.Details: + return build_split_raw_metadata_panel( + metadata, + loc, + i18n_prefix="dash.analysis.raman.raw_metadata", + user_facing_keys=_RAMAN_USER_FACING_METADATA_KEYS, + value_formatter=_format_dataset_metadata_value, + collapsible_builder=_raman_collapsible_section, + ) + + +def _finite_series(values: list | None) -> list[float]: + series: list[float] = [] + for value in values or []: + if value is None: + continue + numeric = float(value) + if math.isfinite(numeric): + series.append(numeric) + return series + + +def _y_axis_range(*series: list | None) -> list[float] | None: + values: list[float] = [] + for entry in series: + values.extend(_finite_series(entry)) + if not values: + return None + y_min = min(values) + y_max = max(values) + span = y_max - y_min + padding = span * 0.08 if span > 0 else max(abs(y_max) * 0.12, 0.05) + return [y_min - padding, y_max + padding] + + +def _build_figure( + project_id: str, + dataset_key: str, + summary: dict, + ui_theme: str | None, + loc: str, + *, + plot_settings: dict | None = None, +) -> html.Div: + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "RAMAN", dataset_key) + except Exception: + curves = {} + + wavenumber = curves.get("temperature", []) + raw_signal = curves.get("raw_signal", []) + smoothed = curves.get("smoothed", []) + baseline = curves.get("baseline", []) + corrected = curves.get("corrected", []) + normalized = curves.get("normalized", []) + peaks = curves.get("peaks", []) + diagnostics = curves.get("diagnostics") or {} + settings = normalize_spectral_plot_settings(plot_settings) + + has_corrected = bool(corrected and len(corrected) == len(wavenumber)) + has_smoothed = bool(smoothed and len(smoothed) == len(wavenumber)) + has_normalized_curve = bool(normalized and len(normalized) == len(wavenumber)) + has_baseline = bool(baseline and len(baseline) == len(wavenumber)) + has_raw = bool(raw_signal and len(raw_signal) == len(wavenumber)) + plot_norm_primary = diagnostics.get("plot_normalized_primary_axis") is not False + show_normalized_trace = bool(settings["show_normalized"] and has_normalized_curve and plot_norm_primary) + show_corrected_trace = bool(settings["show_corrected"] and has_corrected) + show_intermediate_smoothed = bool(settings["show_smoothed"] and has_smoothed and not show_corrected_trace) + show_raw_trace = bool(settings["show_raw"] and has_raw) + show_baseline_trace = bool(has_baseline and has_corrected) + + has_overlay = bool( + show_baseline_trace + or show_intermediate_smoothed + or show_corrected_trace + or show_normalized_trace + or (show_raw_trace and (show_corrected_trace or show_intermediate_smoothed)) + ) + + if not wavenumber: + return no_data_figure_msg(locale_data=loc) + + sample_name = resolve_sample_name(summary, {}, fallback_display_name=dataset_key, locale_data=loc) + tone = normalize_ui_theme(ui_theme) + pt = PLOT_THEME[tone] + muted = "#66645E" if tone == "light" else "#9E9A93" + legend_bg = "rgba(255,255,255,0.9)" if tone == "light" else "rgba(26,25,23,0.94)" + hover_bg = "rgba(255,255,255,0.96)" if tone == "light" else "rgba(34,33,30,0.96)" + hover_fg = "#1C1A1A" if tone == "light" else "#EEEDEA" + + dominant_signal = corrected if show_corrected_trace else smoothed if show_intermediate_smoothed else raw_signal if show_raw_trace else [] + legend_query = translate_ui(loc, "dash.analysis.figure.legend_query_spectrum") + legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed_spectrum") + legend_imported = translate_ui(loc, "dash.analysis.figure.legend_imported_spectrum") + legend_baseline = translate_ui(loc, "dash.analysis.figure.legend_estimated_baseline") + legend_normalized = translate_ui(loc, "dash.analysis.raman.legend_normalized_spectrum") + + if diagnostics.get("inverted_for_transmittance"): + suffix = " (inverted)" + legend_smooth += suffix + legend_query += suffix + legend_normalized += suffix + + y_series_for_range = [dominant_signal] + if show_raw_trace: + y_series_for_range.append(raw_signal) + if show_baseline_trace: + y_series_for_range.append(baseline) + if show_intermediate_smoothed: + y_series_for_range.append(smoothed) + if show_normalized_trace: + y_series_for_range.append(normalized) + y_range = _y_axis_range(*y_series_for_range) + if settings["y_range_enabled"] and settings["y_min"] is not None and settings["y_max"] is not None: + y_range = [settings["y_min"], settings["y_max"]] + + fig = go.Figure() + line_scale = float(settings["line_width_scale"]) + marker_scale = float(settings["marker_size_scale"]) + + if show_baseline_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=baseline, + mode="lines", + name=legend_baseline, + line=dict(color=_RAMAN_FIGURE_COLORS["baseline"], width=1.3 * line_scale, dash="dash"), + opacity=0.65, + ) + ) + + if show_raw_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=raw_signal, + mode="lines", + name=legend_imported, + line=dict(color=_RAMAN_FIGURE_COLORS["raw"], width=1.6 * line_scale), + opacity=0.45 if has_overlay else 0.95, + ) + ) + + if show_intermediate_smoothed: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=smoothed, + mode="lines", + name=legend_smooth, + line=dict(color=_RAMAN_FIGURE_COLORS["smoothed"], width=2.0 * line_scale), + opacity=0.95, + ) + ) + + if show_corrected_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=corrected, + mode="lines", + name=legend_query, + line=dict(color=_RAMAN_FIGURE_COLORS["query"], width=3.2 * line_scale), + opacity=0.95 if show_normalized_trace else 1.0, + ) + ) + + if show_normalized_trace: + fig.add_trace( + go.Scatter( + x=wavenumber, + y=normalized, + mode="lines", + name=legend_normalized, + line=dict(color=_RAMAN_FIGURE_COLORS["normalized"], width=2.4 * line_scale), + ) + ) + + def _peak_display_y(index: int) -> float | None: + if has_corrected and index < len(corrected): + return float(corrected[index]) + if show_intermediate_smoothed and index < len(smoothed): + return float(smoothed[index]) + if raw_signal and index < len(raw_signal): + return float(raw_signal[index]) + return None + + # Peak annotations (top 8 only to avoid clutter) + _ANNOTATION_MIN_SEP = 20.0 + annotated_positions: list[float] = [] + peak_count = len(peaks) + for i, peak in enumerate(peaks[:_RAMAN_MAX_PEAK_CARDS] if settings["show_peaks"] else []): + pos = peak.get("position") + intensity = peak.get("intensity") + if pos is None or not wavenumber: + continue + idx = min(range(len(wavenumber)), key=lambda i: abs(wavenumber[i] - pos)) + too_close = any(abs(pos - p) < _ANNOTATION_MIN_SEP for p in annotated_positions) + label = "" if too_close else f"{pos:.0f}" + y_at = _peak_display_y(idx) + if y_at is None: + continue + fig.add_trace( + go.Scatter( + x=[wavenumber[idx]], + y=[y_at], + mode="markers+text", + marker=dict(size=7 * marker_scale, color="#DC2626", symbol="diamond", line=dict(color="white", width=1)), + text=[label], + textposition="top center", + textfont=dict(size=8, color="#DC2626"), + name=f"Peak {pos:.0f}", + showlegend=False, + ) + ) + if label: + annotated_positions.append(pos) + + title_main = translate_ui(loc, "dash.analysis.figure.title_raman_main") + show_legend, legend_layout = spectral_legend_layout(len(fig.data), settings, theme=pt, legend_bg=legend_bg) + x_axis: dict[str, Any] = { + "showgrid": settings["show_grid"], + "showspikes": settings["show_spikes"], + "gridcolor": pt["grid"], + "linecolor": pt["grid"], + "tickfont": dict(size=12, color=pt["text"]), + "title_font": dict(size=13, color=pt["text"]), + "zeroline": False, + } + if settings["x_range_enabled"] and settings["x_min"] is not None and settings["x_max"] is not None: + x_range = [settings["x_min"], settings["x_max"]] + if settings["reverse_x_axis"]: + x_range = list(reversed(x_range)) + x_axis["range"] = x_range + elif settings["reverse_x_axis"]: + x_axis["autorange"] = "reversed" + y_axis = { + "range": y_range, + "showgrid": settings["show_grid"], + "showspikes": settings["show_spikes"], + "gridcolor": pt["grid"], + "linecolor": pt["grid"], + "tickfont": dict(size=12, color=pt["text"]), + "title_font": dict(size=13, color=pt["text"]), + "zeroline": False, + } + fig.update_layout( + title=(f"{title_main}
{sample_name}"), + paper_bgcolor=pt["paper_bg"], + plot_bgcolor=pt["plot_bg"], + hovermode="x unified", + xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_raman_shift"), + yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_intensity_au"), + xaxis=x_axis, + yaxis=y_axis, + margin=dict(l=64, r=112 if show_legend and legend_layout.get("x") == 1.02 else 28, t=82, b=56), + height=460 if settings["compact"] else 520, + title_font=dict(size=20, color=pt["text"]), + title_x=0.01, + showlegend=show_legend, + legend=legend_layout, + hoverlabel=dict(bgcolor=hover_bg, font=dict(color=hover_fg)), + ) + fig.update_layout(meta={"plot_display_settings": settings}) + fig.update_layout(template=pt["template"]) + + peak_count_disp = summary.get("peak_count", peak_count) + top_match_name = summary.get("top_match_name") + match_status = _match_status_label(loc, summary.get("match_status")) + confidence = _confidence_band_label(loc, summary.get("confidence_band")) + na = translate_ui(loc, "dash.analysis.na") + match_str = f"{top_match_name}" if top_match_name else na + + run_caption = translate_ui( + loc, + "dash.analysis.raman.figure.run_summary", + peaks=str(peak_count_disp), + match=match_str, + status=match_status, + confidence=confidence, + ) + + diag_notes: list[str] = [] + if diagnostics.get("inverted_for_transmittance"): + diag_notes.append("Signal interpreted as transmittance; inverted for analysis.") + if diagnostics.get("baseline_suppressed"): + diag_notes.append(f"Baseline suppressed: {diagnostics.get('baseline_suppression_reason', '')}") + if diagnostics.get("normalization_skipped"): + diag_notes.append(f"Normalization skipped: {diagnostics.get('normalization_skip_reason', '')}") + if diagnostics.get("peak_detection_fallback"): + diag_notes.append(f"Peak detection fallback: {diagnostics.get('peak_detection_reason', '')}") + if diagnostics.get("peak_detection_no_peaks"): + diag_notes.append(f"No peaks detected: {diagnostics.get('peak_detection_reason', '')}") + + diag_children = [html.P(note, className="small text-warning mb-1") for note in diag_notes] + + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.raman.figure.section_title"), className="mb-2"), + html.P(run_caption, className="small text-muted mb-2"), + dcc.Graph(figure=fig, config=build_spectral_plotly_config(settings, filename="materialscope_raman_spectrum"), className="ta-plot"), + *diag_children, + ] + ) + + +def _build_top_match_panel(summary: dict, rows: list, loc: str) -> html.Div: + if not rows: + if str(summary.get("match_status") or "").lower() == "library_unavailable": + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.raman.library.reference_title"), className="mb-3"), + dbc.Alert( + translate_ui(loc, "dash.analysis.raman.library.not_configured_for_run"), + color="info", + className="mb-0 small", + ), + ] + ) + body = translate_ui(loc, "dash.analysis.state.no_library_matches") + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.raman.top_match.title"), className="mb-3"), + html.P(body, className="text-muted"), + ] + ) + + top = rows[0] + score = top.get("normalized_score", 0.0) + band = str(top.get("confidence_band", "no_match")).lower() + color = _CONFIDENCE_COLORS.get(band, "#6B7280") + candidate_name = top.get("candidate_name", translate_ui(loc, "dash.analysis.unknown_candidate")) + candidate_id = top.get("candidate_id", "") + provider = top.get("library_provider", "") + package = top.get("library_package", "") + evidence = top.get("evidence", {}) + shared = evidence.get("shared_peak_count", "--") + observed = evidence.get("observed_peak_count", "--") + + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.raman.top_match.title"), className="mb-3"), + dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className="bi bi-trophy me-2", style={"color": color, "fontSize": "1.1rem"}), + html.Strong(candidate_name, className="me-2"), + html.Span( + _confidence_band_label(loc, band), + className="badge", + style={"backgroundColor": color, "color": "white", "fontSize": "0.75rem"}, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.score"), className="text-muted d-block"), html.Span(f"{score:.4f}")], md=3), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.provider"), className="text-muted d-block"), html.Span(provider or "--")], md=3), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.package"), className="text-muted d-block"), html.Span(package or "--")], md=3), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.peak_overlap"), className="text-muted d-block"), html.Span(f"{shared}/{observed}")], md=3), + ], + className="g-2", + ), + *( + [html.P(translate_ui(loc, "dash.analysis.id_label", id=candidate_id), className="text-muted small mb-0 mt-1")] + if candidate_id + else [] + ), + ] + ), + className="mb-3", + ), + ] + ) + + +def _build_peak_cards_from_curves(project_id: str, dataset_key: str, summary: dict, loc: str) -> html.Div: + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "RAMAN", dataset_key) + except Exception: + curves = {} + + peaks = curves.get("peaks", []) + if not peaks: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.raman.peaks.title"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_peaks"), className="text-muted"), + ] + ) + + total = len(peaks) + truncated = total >= _RAMAN_TRUNCATE_PEAK_CARDS_WHEN + shown = peaks[:_RAMAN_MAX_PEAK_CARDS] if truncated else peaks + + cards: list[Any] = [html.H5(translate_ui(loc, "dash.analysis.raman.peaks.title"), className="mb-3")] + for idx, peak in enumerate(shown): + pos = peak.get("position") + intensity = peak.get("intensity") + cards.append( + dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className="bi bi-activity me-2", style={"color": "#DC2626", "fontSize": "1.1rem"}), + html.Strong(translate_ui(loc, "dash.analysis.label.peak_n", n=idx + 1), className="me-2"), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.position"), className="text-muted d-block"), html.Span(f"{pos:.1f}" if pos is not None else "--")], md=6), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.label.intensity"), className="text-muted d-block"), html.Span(f"{intensity:.4f}" if intensity is not None else "--")], md=6), + ], + className="g-2", + ), + ] + ), + className="mb-2", + ) + ) + if truncated: + cards.append( + html.P( + translate_ui(loc, "dash.analysis.raman.peaks.truncation_note", shown=len(shown), total=total), + className="small text-muted mb-1", + ) + ) + return html.Div(cards) + + +def _build_match_table(rows: list, loc: str, *, summary: dict | None = None) -> html.Div: + if not rows: + summary = summary or {} + if str(summary.get("match_status") or "").lower() == "library_unavailable": + return html.Div(className="d-none") + body = translate_ui(loc, "dash.analysis.state.no_match_data") + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.match_data_table"), className="mb-3"), + html.P(body, className="text-muted"), + ] + ) + + columns = [ + "rank", + "candidate_id", + "candidate_name", + "normalized_score", + "confidence_band", + "library_provider", + "library_package", + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.match_data_table"), className="mb-3"), + dataset_table(rows, columns, table_id="raman-matches-table"), + ] + ) + diff --git a/dash_app/pages/tga.py b/dash_app/pages/tga.py new file mode 100644 index 00000000..6e80dc3d --- /dev/null +++ b/dash_app/pages/tga.py @@ -0,0 +1,2416 @@ +"""TGA analysis page -- backend-driven first analysis slice. + +Lets the user: + 1. Use **Setup / Processing / Run** tabs (aligned with DSC and DTA) + 2. **Setup:** dataset, unit mode (auto / percent / absolute_mass), workflow template + 3. **Processing:** presets and separate **Smoothing** / **Step detection** cards + (parameters flow into ``processing_overrides`` and preset payloads) + 4. **Run:** execute via the backend ``/analysis/run`` endpoint + 5. View analysis summary, validation, main mass trace, DTG preview, steps, + processing, raw metadata, literature compare, and auto-refresh workspace state +""" + +from __future__ import annotations + +import base64 +import copy +import json +import math +from datetime import datetime, timezone +from typing import Any + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html +import plotly.graph_objects as go + +from dash_app.components.analysis_boilerplate import ( + build_collapsible_section, + build_load_saveas_preset_card, + build_processing_history_card, +) +from dash_app.components.analysis_page import ( + analysis_page_stores, + capture_result_figure_from_layout, + register_result_figure_from_layout_children, + dataset_selection_card, + dataset_selector_block, + eligible_datasets, + empty_result_msg, + execute_card, + interpret_run_result, + metrics_row, + no_data_figure_msg, + processing_details_section, + resolve_sample_name, + result_placeholder_card, + workflow_template_card, +) +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.figure_artifacts import ( + FIGURE_ARTIFACT_PREVIEW_MAX_EDGE, + FIGURE_ARTIFACT_PREVIEW_TILES, + build_figure_artifact_surface, + build_figure_artifacts_panel, + figure_action_from_trigger, + figure_action_metadata, + figure_action_status_alert, + figure_artifact_button_labels, + ordered_figure_preview_keys, +) +from dash_app.components.literature_compare_ui import ( + LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + build_literature_compare_card, + coerce_literature_max_claims, + literature_compare_status_alert, + literature_t, + render_literature_output, +) +from dash_app.components.tga_explore import ( + MAX_TGA_UNDO_DEPTH, + append_undo_after_edit, + build_tga_raw_quality_panel, + compute_tga_raw_exploration_stats, + downsample_rows, + format_tga_step_reference_callout, + perform_redo, + perform_undo, + tga_draft_processing_equal, +) +from dash_app.components.processing_inputs import ( + coerce_float_non_negative as _coerce_float_non_negative, + coerce_float_positive as _coerce_float_positive, + coerce_int_positive as _coerce_int_positive, +) +from dash_app.theme import PLOT_THEME, apply_figure_theme, normalize_ui_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/tga", title="TGA Analysis - MaterialScope") + +_TGA_TEMPLATE_IDS = ["tga.general", "tga.single_step_decomposition", "tga.multi_step_decomposition"] +_TGA_UNIT_MODE_IDS = ["auto", "percent", "absolute_mass"] +_TGA_ELIGIBLE_TYPES = {"TGA", "UNKNOWN"} + +_TGA_RESULT_CARD_ROLES = { + "context": "ms-result-context", + "hero": "ms-result-hero", + "support": "ms-result-support", + "secondary": "ms-result-secondary", +} +_TGA_LITERATURE_PREFIX = "dash.analysis.tga.literature" + +_TGA_USER_FACING_METADATA_KEYS: frozenset[str] = frozenset({ + "sample_name", + "display_name", + "sample_mass", + "heating_rate", + "instrument", + "vendor", + "file_name", + "source_data_hash", +}) + +_TGA_MAX_STEP_CARDS = 6 +_TGA_TRUNCATE_STEP_CARDS_WHEN = 7 + +_TGA_PRESET_ANALYSIS_TYPE = "TGA" +_TGA_SMOOTH_METHODS = frozenset({"savgol", "moving_average", "gaussian"}) +_TGA_SMOOTHING_DEFAULTS: dict[str, dict[str, Any]] = { + "savgol": {"method": "savgol", "window_length": 11, "polyorder": 3}, + "moving_average": {"method": "moving_average", "window_length": 11}, + "gaussian": {"method": "gaussian", "sigma": 2.0}, +} +_TGA_STEP_DETECTION_DEFAULTS: dict[str, Any] = { + "method": "dtg_peaks", + "prominence": None, + "min_mass_loss": 0.5, + "search_half_width": 80, +} + + +def _default_tga_processing_draft() -> dict[str, Any]: + return { + "smoothing": copy.deepcopy(_TGA_SMOOTHING_DEFAULTS["savgol"]), + "step_detection": copy.deepcopy(_TGA_STEP_DETECTION_DEFAULTS), + } + + +def _merge_tga_smoothing_defaults(values: dict | None) -> dict[str, Any]: + base = copy.deepcopy(_TGA_SMOOTHING_DEFAULTS["savgol"]) + if isinstance(values, dict): + method = str(values.get("method") or "savgol").strip().lower() + if method in _TGA_SMOOTH_METHODS: + base = copy.deepcopy(_TGA_SMOOTHING_DEFAULTS.get(method, _TGA_SMOOTHING_DEFAULTS["savgol"])) + for k, v in values.items(): + if k == "method": + continue + if v is not None: + base[k] = copy.deepcopy(v) + return _normalize_tga_smoothing_section(base) + + +def _merge_tga_step_defaults(values: dict | None) -> dict[str, Any]: + base = copy.deepcopy(_TGA_STEP_DETECTION_DEFAULTS) + if isinstance(values, dict): + for k, v in values.items(): + if k == "method": + continue + if v is not None or k == "prominence": + base[k] = copy.deepcopy(v) + return _normalize_tga_step_section(base) + + +def _normalize_tga_smoothing_section(smoothing: dict[str, Any]) -> dict[str, Any]: + method = str(smoothing.get("method") or "savgol").strip().lower() + if method not in _TGA_SMOOTH_METHODS: + method = "savgol" + if method == "savgol": + wl = _coerce_int_positive(smoothing.get("window_length"), default=11, minimum=5) + if wl % 2 == 0: + wl += 1 + po = _coerce_int_positive(smoothing.get("polyorder"), default=3, minimum=1) + po = min(po, max(wl - 2, 1)) + return {"method": "savgol", "window_length": wl, "polyorder": po} + if method == "moving_average": + wl = _coerce_int_positive(smoothing.get("window_length"), default=11, minimum=3) + if wl % 2 == 0: + wl += 1 + return {"method": "moving_average", "window_length": wl} + sigma = _coerce_float_positive(smoothing.get("sigma"), default=2.0, minimum=0.1) + return {"method": "gaussian", "sigma": sigma} + + +def _normalize_tga_step_section(step: dict[str, Any]) -> dict[str, Any]: + prom_raw = step.get("prominence", _TGA_STEP_DETECTION_DEFAULTS["prominence"]) + prominence: float | None + if prom_raw in (None, ""): + prominence = None + else: + try: + pv = float(prom_raw) + except (TypeError, ValueError): + prominence = None + else: + prominence = None if not math.isfinite(pv) or pv <= 0 else pv + min_ml = _coerce_float_non_negative(step.get("min_mass_loss"), default=float(_TGA_STEP_DETECTION_DEFAULTS["min_mass_loss"])) + if min_ml <= 0: + min_ml = float(_TGA_STEP_DETECTION_DEFAULTS["min_mass_loss"]) + half = _coerce_int_positive(step.get("search_half_width"), default=80, minimum=3) + return { + "method": "dtg_peaks", + "prominence": prominence, + "min_mass_loss": min_ml, + "search_half_width": half, + } + + +def _normalize_tga_processing_draft(draft: dict | None) -> dict[str, Any]: + d = dict(draft or {}) + sm = d.get("smoothing") + st = d.get("step_detection") + return { + "smoothing": _merge_tga_smoothing_defaults(sm if isinstance(sm, dict) else None), + "step_detection": _merge_tga_step_defaults(st if isinstance(st, dict) else None), + } + + +def _tga_overrides_from_draft(draft: dict | None) -> dict[str, Any]: + norm = _normalize_tga_processing_draft(draft) + return { + "smoothing": copy.deepcopy(norm["smoothing"]), + "step_detection": copy.deepcopy(norm["step_detection"]), + } + + +def _tga_draft_and_unit_from_loaded_processing(processing: dict | None) -> tuple[dict[str, Any], str]: + if not isinstance(processing, dict): + return copy.deepcopy(_default_tga_processing_draft()), "auto" + sp = processing.get("signal_pipeline") or {} + ast = processing.get("analysis_steps") or {} + sm = sp.get("smoothing") if isinstance(sp.get("smoothing"), dict) else processing.get("smoothing") + st = ast.get("step_detection") if isinstance(ast.get("step_detection"), dict) else processing.get("step_detection") + mc = processing.get("method_context") if isinstance(processing.get("method_context"), dict) else {} + unit = str(mc.get("tga_unit_mode_declared") or "auto").strip().lower() + if unit not in _TGA_UNIT_MODE_IDS: + unit = "auto" + draft = { + "smoothing": _merge_tga_smoothing_defaults(sm if isinstance(sm, dict) else None), + "step_detection": _merge_tga_step_defaults(st if isinstance(st, dict) else None), + } + return draft, unit + + +def _tga_preset_processing_body_for_save(draft: dict | None, unit_mode: str | None) -> dict[str, Any]: + from core.processing_schema import get_tga_unit_modes + + norm = _normalize_tga_processing_draft(draft) + mode = str(unit_mode or "auto").strip().lower() + if mode not in _TGA_UNIT_MODE_IDS: + mode = "auto" + labels = {entry["id"]: entry["label"] for entry in get_tga_unit_modes()} + label = labels.get(mode, mode.replace("_", " ").title()) + return { + "smoothing": copy.deepcopy(norm["smoothing"]), + "step_detection": copy.deepcopy(norm["step_detection"]), + "method_context": { + "tga_unit_mode_declared": mode, + "tga_unit_mode_label": label, + }, + } + + +def _tga_ui_snapshot_dict(template_id: str | None, unit_mode: str | None, draft: dict | None) -> dict[str, Any]: + tid = template_id if template_id in _TGA_TEMPLATE_IDS else "tga.general" + u = unit_mode if unit_mode in _TGA_UNIT_MODE_IDS else "auto" + norm = _normalize_tga_processing_draft(draft) + return { + "workflow_template_id": tid, + "unit_mode": u, + "smoothing": norm["smoothing"], + "step_detection": norm["step_detection"], + } + + +def _tga_snapshots_equal(a: dict | None, b: dict | None) -> bool: + if not isinstance(a, dict) or not isinstance(b, dict): + return False + return json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str) + + +_TGA_QUALITY_CHECK_ORDER: tuple[str, ...] = ( + "import_review_required", + "import_confidence", + "inferred_analysis_type", + "inferred_signal_unit", + "tga_unit_mode_resolved", + "tga_unit_inference_basis", + "tga_unit_interpretation_status", + "tga_unit_auto_inference_used", + "unit_plausibility", + "axis_direction", + "temperature_min", + "temperature_max", + "vendor_detection_confidence", +) + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _tga_result_section(child: Any, *, role: str = "support") -> html.Div: + role_class = _TGA_RESULT_CARD_ROLES.get(role, _TGA_RESULT_CARD_ROLES["support"]) + return html.Div(child, className=f"ms-result-section {role_class}") + + +def _tga_collapsible_section( + loc: str, + title_key: str, + body: Any, + *, + open: bool = False, + summary_suffix: Any | None = None, +) -> html.Details: + return build_collapsible_section(loc, title_key, body, open=open, summary_suffix=summary_suffix) + + +def _literature_compare_card() -> dbc.Card: + return build_literature_compare_card(id_prefix="tga") + + +def _tga_workflow_guide_block() -> html.Details: + return html.Details( + [ + html.Summary( + [html.Span(className="ta-details-chevron"), html.Span(id="tga-workflow-guide-title", className="ms-1")], + className="ta-details-summary", + ), + html.Div(id="tga-workflow-guide-body", className="ta-details-body mt-2 small"), + ], + className="ta-ms-details mb-3", + open=False, + ) + + +def _tga_raw_quality_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H6(id="tga-raw-quality-card-title", className="card-title mb-1"), + html.P(id="tga-raw-quality-card-hint", className="small text-muted mb-2"), + html.Div(id="tga-raw-quality-panel", className="tga-raw-quality-panel"), + ] + ), + className="mb-3", + ) + + +def _tga_processing_history_card() -> dbc.Card: + return build_processing_history_card( + title_id="tga-processing-history-title", + hint_id="tga-processing-history-hint", + undo_button_id="tga-processing-undo-btn", + redo_button_id="tga-processing-redo-btn", + reset_button_id="tga-processing-reset-btn", + status_id="tga-history-status", + ) + + +def _step_card(step: dict, idx: int, loc: str) -> dbc.Card: + onset = step.get("onset_temperature") + midpoint = step.get("midpoint_temperature") + endset = step.get("endset_temperature") + mass_loss = step.get("mass_loss_percent") + residual = step.get("residual_percent") + mass_loss_mg = step.get("mass_loss_mg") + return dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className="bi bi-arrow-down-circle me-2", style={"color": "#059669", "fontSize": "1.1rem"}), + html.Strong(translate_ui(loc, "dash.analysis.label.step_n", n=idx + 1), className="me-2"), + html.Span( + f"{mass_loss:.2f} %" if mass_loss is not None else "--", + className="badge", + style={"backgroundColor": "#059669", "color": "white", "fontSize": "0.75rem"}, + ), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.onset"), className="text-muted d-block"), + html.Span(f"{onset:.1f} C" if onset is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.midpoint"), className="text-muted d-block"), + html.Span(f"{midpoint:.1f} C" if midpoint is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.endset"), className="text-muted d-block"), + html.Span(f"{endset:.1f} C" if endset is not None else "--"), + ], + md=3, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.mass_loss"), className="text-muted d-block"), + html.Span(f"{mass_loss:.2f} %" if mass_loss is not None else "--"), + html.Small(f" {translate_ui(loc, 'dash.analysis.label.residual')}", className="text-muted ms-1"), + html.Span(f"{residual:.1f} %" if residual is not None else "--"), + ], + md=3, + ), + ], + className="g-2", + ), + *( + [html.P(translate_ui(loc, "dash.analysis.tga.mass_loss_mg", v=mass_loss_mg), className="text-muted small mb-0 mt-1")] + if mass_loss_mg is not None + else [] + ), + format_tga_step_reference_callout(midpoint, loc), + ] + ), + className="mb-2", + ) + + +def _unit_mode_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="tga-unit-card-title", children="", className="mb-3"), + dbc.Select(id="tga-unit-mode-select", options=[], value="auto"), + html.P("", className="text-muted small mt-2", id="tga-unit-mode-description"), + ] + ), + className="mb-3", + ) + + +def _tga_preset_card() -> dbc.Card: + return build_load_saveas_preset_card(id_prefix="tga") + + +def _tga_smoothing_controls_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="tga-smoothing-card-title", className="card-title mb-2"), + html.P(id="tga-smoothing-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="tga-smooth-method-label", html_for="tga-smooth-method", className="mb-1"), + dbc.Select( + id="tga-smooth-method", + options=[ + {"label": "Savitzky–Golay", "value": "savgol"}, + {"label": "Moving average", "value": "moving_average"}, + {"label": "Gaussian", "value": "gaussian"}, + ], + value="savgol", + ), + ], + md=12, + ), + ], + className="g-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="tga-smooth-window-label", html_for="tga-smooth-window", className="mb-1"), + dbc.Input(id="tga-smooth-window", type="number", min=3, step=2, value=11), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="tga-smooth-polyorder-label", html_for="tga-smooth-polyorder", className="mb-1"), + dbc.Input(id="tga-smooth-polyorder", type="number", min=1, max=7, step=1, value=3), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="tga-smooth-sigma-label", html_for="tga-smooth-sigma", className="mb-1"), + dbc.Input(id="tga-smooth-sigma", type="number", min=0.1, step=0.1, value=2.0), + ], + md=4, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _tga_step_detection_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="tga-step-card-title", className="card-title mb-2"), + html.P(id="tga-step-card-hint", className="small text-muted mb-3"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label(id="tga-step-prominence-label", html_for="tga-step-prominence", className="mb-1"), + dbc.Input(id="tga-step-prominence", type="text", value="", placeholder=""), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="tga-step-min-mass-label", html_for="tga-step-min-mass", className="mb-1"), + dbc.Input(id="tga-step-min-mass", type="number", min=0, step=0.05, value=0.5), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label(id="tga-step-half-width-label", html_for="tga-step-half-width", className="mb-1"), + dbc.Input(id="tga-step-half-width", type="number", min=3, step=1, value=80), + ], + md=4, + ), + ], + className="g-2", + ), + ] + ), + className="mb-3", + ) + + +def _tga_left_column_tabs() -> dbc.Tabs: + """Setup / Processing / Run tabs — same structure as DSC and DTA left columns.""" + return dbc.Tabs( + [ + dbc.Tab( + [ + dataset_selection_card("tga-dataset-selector-area", card_title_id="tga-dataset-card-title"), + _unit_mode_card(), + workflow_template_card( + "tga-template-select", + "tga-template-description", + [], + "tga.general", + card_title_id="tga-workflow-card-title", + ), + _tga_workflow_guide_block(), + _tga_raw_quality_card(), + ], + tab_id="tga-tab-setup", + label_class_name="ta-tab-label", + id="tga-tab-setup-shell", + ), + dbc.Tab( + [ + _tga_processing_history_card(), + _tga_preset_card(), + _tga_smoothing_controls_card(), + _tga_step_detection_card(), + ], + tab_id="tga-tab-processing", + label_class_name="ta-tab-label", + id="tga-tab-processing-shell", + ), + dbc.Tab( + [ + execute_card("tga-run-status", "tga-run-btn", card_title_id="tga-execute-card-title"), + ], + tab_id="tga-tab-run", + label_class_name="ta-tab-label", + id="tga-tab-run-shell", + ), + ], + id="tga-left-tabs", + active_tab="tga-tab-setup", + className="mb-3", + ) + + +layout = html.Div( + analysis_page_stores("tga-refresh", "tga-latest-result-id") + + [ + dcc.Store(id="tga-figure-captured", data={}), + dcc.Store(id="tga-figure-artifact-refresh", data=0), + dcc.Store(id="tga-processing-draft", data=copy.deepcopy(_default_tga_processing_draft())), + dcc.Store(id="tga-processing-undo-stack", data=[]), + dcc.Store(id="tga-processing-redo-stack", data=[]), + dcc.Store(id="tga-history-hydrate", data=0), + dcc.Store(id="tga-preset-refresh", data=0), + dcc.Store(id="tga-preset-hydrate", data=0), + dcc.Store(id="tga-preset-loaded-name", data=""), + dcc.Store(id="tga-preset-snapshot", data=None), + html.Div(id="tga-hero-slot"), + dbc.Row( + [ + dbc.Col( + [_tga_left_column_tabs()], + md=4, + ), + dbc.Col( + [ + _tga_result_section(result_placeholder_card("tga-result-analysis-summary"), role="context"), + _tga_result_section(result_placeholder_card("tga-result-metrics"), role="context"), + _tga_result_section(result_placeholder_card("tga-result-quality"), role="support"), + _tga_result_section(build_figure_artifact_surface("tga"), role="hero"), + _tga_result_section(result_placeholder_card("tga-result-dtg"), role="support"), + _tga_result_section(result_placeholder_card("tga-result-step-cards"), role="support"), + _tga_result_section(result_placeholder_card("tga-result-table"), role="support"), + _tga_result_section(result_placeholder_card("tga-result-processing"), role="support"), + _tga_result_section(result_placeholder_card("tga-result-raw-metadata"), role="support"), + _tga_result_section(_literature_compare_card(), role="secondary"), + ], + md=8, + className="ms-results-surface", + ), + ] + ), + ], + className="tga-page", +) + + +@callback( + Output("tga-hero-slot", "children"), + Output("tga-dataset-card-title", "children"), + Output("tga-unit-card-title", "children"), + Output("tga-workflow-card-title", "children"), + Output("tga-execute-card-title", "children"), + Output("tga-run-btn", "children"), + Output("tga-template-select", "options"), + Output("tga-template-select", "value"), + Output("tga-template-description", "children"), + Output("tga-unit-mode-select", "options"), + Output("tga-unit-mode-select", "value"), + Input("ui-locale", "data"), + Input("tga-template-select", "value"), + Input("tga-unit-mode-select", "value"), +) +def render_tga_locale_chrome(locale_data, template_id, unit_mode): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.analysis.tga.title"), + translate_ui(loc, "dash.analysis.tga.caption"), + badge=translate_ui(loc, "dash.analysis.badge"), + ) + opts = [{"label": translate_ui(loc, f"dash.analysis.tga.template.{tid}.label"), "value": tid} for tid in _TGA_TEMPLATE_IDS] + valid_t = {o["value"] for o in opts} + tid = template_id if template_id in valid_t else "tga.general" + desc_key = f"dash.analysis.tga.template.{tid}.desc" + desc = translate_ui(loc, desc_key) + if desc == desc_key: + desc = translate_ui(loc, "dash.analysis.tga.workflow_fallback") + + unit_opts = [{"label": translate_ui(loc, f"dash.analysis.tga.unit.{m}.label"), "value": m} for m in _TGA_UNIT_MODE_IDS] + valid_u = {o["value"] for o in unit_opts} + uval = unit_mode if unit_mode in valid_u else "auto" + + return ( + hero, + translate_ui(loc, "dash.analysis.dataset_selection_title"), + translate_ui(loc, "dash.analysis.unit_mode_title"), + translate_ui(loc, "dash.analysis.workflow_template_title"), + translate_ui(loc, "dash.analysis.execute_title"), + translate_ui(loc, "dash.analysis.tga.run_btn"), + opts, + tid, + desc, + unit_opts, + uval, + ) + + +@callback( + Output("tga-tab-setup-shell", "label"), + Output("tga-tab-processing-shell", "label"), + Output("tga-tab-run-shell", "label"), + Input("ui-locale", "data"), +) +def render_tga_tab_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.tga.tab.setup"), + translate_ui(loc, "dash.analysis.tga.tab.processing"), + translate_ui(loc, "dash.analysis.tga.tab.run"), + ) + + +@callback( + Output("tga-unit-mode-description", "children"), + Input("ui-locale", "data"), + Input("tga-unit-mode-select", "value"), +) +def update_tga_unit_mode_description(locale_data, unit_mode): + loc = _loc(locale_data) + mid = unit_mode or "auto" + key = f"dash.analysis.tga.unit.{mid}.desc" + text = translate_ui(loc, key) + if text == key: + text = translate_ui(loc, "dash.analysis.tga.unit.fallback") + return text + + +def _tga_draft_from_control_values( + smooth_method, + smooth_window, + smooth_poly, + smooth_sigma, + step_prominence, + step_min_mass, + step_half_width, +) -> dict[str, Any]: + token = str(smooth_method or "savgol").strip().lower() + if token not in _TGA_SMOOTH_METHODS: + token = "savgol" + smooth: dict[str, Any] = {"method": token} + if token == "savgol": + smooth["window_length"] = smooth_window + smooth["polyorder"] = smooth_poly + elif token == "moving_average": + smooth["window_length"] = smooth_window + else: + smooth["sigma"] = smooth_sigma + step: dict[str, Any] = { + "method": "dtg_peaks", + "prominence": step_prominence, + "min_mass_loss": step_min_mass, + "search_half_width": step_half_width, + } + return _normalize_tga_processing_draft({"smoothing": smooth, "step_detection": step}) + + +@callback( + Output("tga-preset-card-title", "children"), + Output("tga-preset-help", "children"), + Output("tga-preset-select-label", "children"), + Output("tga-preset-load-btn", "children"), + Output("tga-preset-delete-btn", "children"), + Output("tga-preset-save-name-label", "children"), + Output("tga-preset-save-name", "placeholder"), + Output("tga-preset-save-btn", "children"), + Output("tga-preset-saveas-btn", "children"), + Output("tga-preset-save-hint", "children"), + Input("ui-locale", "data"), +) +def render_tga_preset_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.tga.presets.title"), + translate_ui(loc, "dash.analysis.tga.presets.help.overview"), + translate_ui(loc, "dash.analysis.tga.presets.select_label"), + translate_ui(loc, "dash.analysis.tga.presets.load_btn"), + translate_ui(loc, "dash.analysis.tga.presets.delete_btn"), + translate_ui(loc, "dash.analysis.tga.presets.save_name_label"), + translate_ui(loc, "dash.analysis.tga.presets.save_name_placeholder"), + translate_ui(loc, "dash.analysis.tga.presets.save_btn"), + translate_ui(loc, "dash.analysis.tga.presets.saveas_btn"), + translate_ui(loc, "dash.analysis.tga.presets.save_hint"), + ) + + +@callback( + Output("tga-smoothing-card-title", "children"), + Output("tga-smoothing-card-hint", "children"), + Output("tga-step-card-title", "children"), + Output("tga-step-card-hint", "children"), + Output("tga-smooth-method-label", "children"), + Output("tga-smooth-window-label", "children"), + Output("tga-smooth-polyorder-label", "children"), + Output("tga-smooth-sigma-label", "children"), + Output("tga-step-prominence-label", "children"), + Output("tga-step-prominence", "placeholder"), + Output("tga-step-min-mass-label", "children"), + Output("tga-step-half-width-label", "children"), + Output("tga-smooth-method", "options"), + Input("ui-locale", "data"), +) +def render_tga_processing_chrome(locale_data): + loc = _loc(locale_data) + smooth_opts = [ + {"label": translate_ui(loc, "dash.analysis.tga.processing.smooth.savgol"), "value": "savgol"}, + {"label": translate_ui(loc, "dash.analysis.tga.processing.smooth.moving_average"), "value": "moving_average"}, + {"label": translate_ui(loc, "dash.analysis.tga.processing.smooth.gaussian"), "value": "gaussian"}, + ] + return ( + translate_ui(loc, "dash.analysis.tga.processing.smoothing_card_title"), + translate_ui(loc, "dash.analysis.tga.processing.smoothing_card_hint"), + translate_ui(loc, "dash.analysis.tga.processing.step_card_title"), + translate_ui(loc, "dash.analysis.tga.processing.step_card_hint"), + translate_ui(loc, "dash.analysis.tga.processing.smooth.method"), + translate_ui(loc, "dash.analysis.tga.processing.smooth.window"), + translate_ui(loc, "dash.analysis.tga.processing.smooth.polyorder"), + translate_ui(loc, "dash.analysis.tga.processing.smooth.sigma"), + translate_ui(loc, "dash.analysis.tga.processing.step.prominence"), + translate_ui(loc, "dash.analysis.tga.processing.step.prominence_ph"), + translate_ui(loc, "dash.analysis.tga.processing.step.min_mass"), + translate_ui(loc, "dash.analysis.tga.processing.step.half_width"), + smooth_opts, + ) + + +@callback( + Output("tga-preset-select", "options"), + Output("tga-preset-caption", "children"), + Input("tga-preset-refresh", "data"), + Input("ui-locale", "data"), +) +def refresh_tga_preset_options(_refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + try: + payload = api_client.list_analysis_presets(_TGA_PRESET_ANALYSIS_TYPE) + except Exception as exc: + message = translate_ui(loc, "dash.analysis.tga.presets.list_failed").format(error=str(exc)) + return [], message + + presets = payload.get("presets") or [] + options = [ + {"label": item.get("preset_name", ""), "value": item.get("preset_name", "")} + for item in presets + if isinstance(item, dict) and item.get("preset_name") + ] + caption = translate_ui(loc, "dash.analysis.tga.presets.caption").format( + analysis_type=payload.get("analysis_type", _TGA_PRESET_ANALYSIS_TYPE), + count=int(payload.get("count", len(options)) or 0), + max_count=int(payload.get("max_count", 10) or 10), + ) + return options, caption + + +@callback( + Output("tga-preset-load-btn", "disabled"), + Output("tga-preset-delete-btn", "disabled"), + Output("tga-preset-save-btn", "disabled"), + Input("tga-preset-select", "value"), +) +def toggle_tga_preset_action_buttons(selected_name): + has_selection = bool(str(selected_name or "").strip()) + return (not has_selection, not has_selection, not has_selection) + + +@callback( + Output("tga-processing-draft", "data", allow_duplicate=True), + Output("tga-template-select", "value", allow_duplicate=True), + Output("tga-unit-mode-select", "value", allow_duplicate=True), + Output("tga-preset-status", "children", allow_duplicate=True), + Output("tga-preset-hydrate", "data", allow_duplicate=True), + Output("tga-preset-loaded-name", "data", allow_duplicate=True), + Output("tga-preset-snapshot", "data", allow_duplicate=True), + Output("tga-left-tabs", "active_tab", allow_duplicate=True), + Output("tga-processing-undo-stack", "data", allow_duplicate=True), + Output("tga-processing-redo-stack", "data", allow_duplicate=True), + Input("tga-preset-load-btn", "n_clicks"), + State("tga-preset-select", "value"), + State("tga-preset-hydrate", "data"), + State("tga-processing-draft", "data"), + State("tga-processing-undo-stack", "data"), + State("tga-processing-redo-stack", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def apply_tga_preset(n_clicks, selected_name, hydrate_val, current_draft, undo_stack, redo_stack, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.tga.presets.select_required"), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + try: + payload = api_client.load_analysis_preset(_TGA_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.tga.presets.load_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + + processing = dict(payload.get("processing") or {}) + draft, unit_mode = _tga_draft_and_unit_from_loaded_processing(processing) + template_id_raw = str(payload.get("workflow_template_id") or "").strip() + template_out = template_id_raw if template_id_raw in _TGA_TEMPLATE_IDS else dash.no_update + unit_out = unit_mode if unit_mode in _TGA_UNIT_MODE_IDS else dash.no_update + resolved_tid = template_id_raw if template_id_raw in _TGA_TEMPLATE_IDS else "tga.general" + snap = _tga_ui_snapshot_dict(resolved_tid, unit_mode, draft) + status = translate_ui(loc, "dash.analysis.tga.presets.loaded").format(preset=name) + old_norm = _normalize_tga_processing_draft(current_draft) + new_norm = _normalize_tga_processing_draft(draft) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return ( + draft, + template_out, + unit_out, + status, + int(hydrate_val or 0) + 1, + name, + snap, + "tga-tab-run", + past2, + fut2, + ) + + +@callback( + Output("tga-preset-refresh", "data", allow_duplicate=True), + Output("tga-preset-save-name", "value", allow_duplicate=True), + Output("tga-preset-status", "children", allow_duplicate=True), + Output("tga-preset-snapshot", "data", allow_duplicate=True), + Output("tga-left-tabs", "active_tab", allow_duplicate=True), + Input("tga-preset-save-btn", "n_clicks"), + Input("tga-preset-saveas-btn", "n_clicks"), + State("tga-preset-select", "value"), + State("tga-preset-save-name", "value"), + State("tga-processing-draft", "data"), + State("tga-template-select", "value"), + State("tga-unit-mode-select", "value"), + State("tga-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_tga_preset(n_save, n_saveas, selected_name, save_name, draft, template_id, unit_mode, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + if trig == "tga-preset-save-btn": + name = str(selected_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.tga.presets.select_required"), + dash.no_update, + dash.no_update, + ) + clear_name = dash.no_update + elif trig == "tga-preset-saveas-btn": + name = str(save_name or "").strip() + if not name: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.tga.presets.save_name_required"), + dash.no_update, + dash.no_update, + ) + clear_name = "" + else: + raise dash.exceptions.PreventUpdate + + processing_body = _tga_preset_processing_body_for_save(draft, unit_mode) + try: + response = api_client.save_analysis_preset( + _TGA_PRESET_ANALYSIS_TYPE, + name, + workflow_template_id=str(template_id or "").strip() or None, + processing=processing_body, + ) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.tga.presets.save_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + resolved_template = str(response.get("workflow_template_id") or template_id or "") + snap = _tga_ui_snapshot_dict(str(template_id or "").strip() or None, unit_mode, draft) + status = translate_ui(loc, "dash.analysis.tga.presets.saved").format(preset=name, template=resolved_template) + return int(refresh_token or 0) + 1, clear_name, status, snap, "tga-tab-run" + + +@callback( + Output("tga-preset-refresh", "data", allow_duplicate=True), + Output("tga-preset-select", "value", allow_duplicate=True), + Output("tga-preset-status", "children", allow_duplicate=True), + Output("tga-preset-loaded-name", "data", allow_duplicate=True), + Output("tga-preset-snapshot", "data", allow_duplicate=True), + Input("tga-preset-delete-btn", "n_clicks"), + State("tga-preset-select", "value"), + State("tga-preset-loaded-name", "data"), + State("tga-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def delete_tga_preset(n_clicks, selected_name, loaded_name, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.tga.presets.select_required"), dash.no_update, dash.no_update + try: + api_client.delete_analysis_preset(_TGA_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.tga.presets.delete_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + ) + status = translate_ui(loc, "dash.analysis.tga.presets.deleted").format(preset=name) + loaded = str(loaded_name or "").strip() + if loaded == name: + return int(refresh_token or 0) + 1, None, status, "", None + return int(refresh_token or 0) + 1, None, status, dash.no_update, dash.no_update + + +@callback( + Output("tga-smooth-method", "value"), + Output("tga-smooth-window", "value"), + Output("tga-smooth-polyorder", "value"), + Output("tga-smooth-sigma", "value"), + Output("tga-step-prominence", "value"), + Output("tga-step-min-mass", "value"), + Output("tga-step-half-width", "value"), + Input("tga-preset-hydrate", "data"), + Input("tga-history-hydrate", "data"), + State("tga-processing-draft", "data"), +) +def hydrate_tga_processing_controls(_preset_hydrate, _history_hydrate, draft): + d = _normalize_tga_processing_draft(draft) + sm = d["smoothing"] + st = d["step_detection"] + method = str(sm.get("method") or "savgol") + wl = int(sm.get("window_length", 11)) + po = int(sm.get("polyorder", 3)) + sigma = float(sm.get("sigma", 2.0)) + prom = st.get("prominence") + prom_s = "" if prom in (None, "") else str(prom) + min_ml = float(st.get("min_mass_loss", 0.5)) + half = int(st.get("search_half_width", 80)) + return method, wl, po, sigma, prom_s, min_ml, half + + +@callback( + Output("tga-processing-draft", "data", allow_duplicate=True), + Output("tga-processing-undo-stack", "data", allow_duplicate=True), + Output("tga-processing-redo-stack", "data", allow_duplicate=True), + Input("tga-smooth-method", "value"), + Input("tga-smooth-window", "value"), + Input("tga-smooth-polyorder", "value"), + Input("tga-smooth-sigma", "value"), + Input("tga-step-prominence", "value"), + Input("tga-step-min-mass", "value"), + Input("tga-step-half-width", "value"), + State("tga-processing-draft", "data"), + State("tga-processing-undo-stack", "data"), + State("tga-processing-redo-stack", "data"), + prevent_initial_call="initial_duplicate", +) +def sync_tga_processing_draft_from_controls(sm_m, sm_w, sm_p, sm_s, st_pr, st_min, st_half, prev_draft, undo_stack, redo_stack): + new_draft = _tga_draft_from_control_values(sm_m, sm_w, sm_p, sm_s, st_pr, st_min, st_half) + old_norm = _normalize_tga_processing_draft(prev_draft) + new_norm = _normalize_tga_processing_draft(new_draft) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return new_norm, past2, fut2 + + +@callback( + Output("tga-processing-draft", "data", allow_duplicate=True), + Output("tga-processing-undo-stack", "data", allow_duplicate=True), + Output("tga-processing-redo-stack", "data", allow_duplicate=True), + Output("tga-history-hydrate", "data", allow_duplicate=True), + Output("tga-history-status", "children", allow_duplicate=True), + Input("tga-processing-undo-btn", "n_clicks"), + Input("tga-processing-redo-btn", "n_clicks"), + Input("tga-processing-reset-btn", "n_clicks"), + State("tga-processing-draft", "data"), + State("tga-processing-undo-stack", "data"), + State("tga-processing-redo-stack", "data"), + State("tga-history-hydrate", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def tga_processing_history_actions(n_undo, n_redo, n_reset, draft, undo_stack, redo_stack, hist_hydrate, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + cur = _normalize_tga_processing_draft(draft) + past = undo_stack or [] + fut = redo_stack or [] + h = int(hist_hydrate or 0) + + if trig == "tga-processing-undo-btn": + if not n_undo: + raise dash.exceptions.PreventUpdate + res = perform_undo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + prev, pl, fl = res + return prev, pl, fl, h + 1, translate_ui(loc, "dash.analysis.tga.processing.history_status_undo") + + if trig == "tga-processing-redo-btn": + if not n_redo: + raise dash.exceptions.PreventUpdate + res = perform_redo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + nxt, pl, fl = res + return nxt, pl, fl, h + 1, translate_ui(loc, "dash.analysis.tga.processing.history_status_redo") + + if trig == "tga-processing-reset-btn": + if not n_reset: + raise dash.exceptions.PreventUpdate + default_draft = _normalize_tga_processing_draft(copy.deepcopy(_default_tga_processing_draft())) + if tga_draft_processing_equal(cur, default_draft): + raise dash.exceptions.PreventUpdate + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + past_list.append(copy.deepcopy(cur)) + if len(past_list) > MAX_TGA_UNDO_DEPTH: + past_list = past_list[-MAX_TGA_UNDO_DEPTH:] + return default_draft, past_list, [], h + 1, translate_ui(loc, "dash.analysis.tga.processing.history_status_reset") + + raise dash.exceptions.PreventUpdate + + +@callback( + Output("tga-processing-undo-btn", "disabled"), + Output("tga-processing-redo-btn", "disabled"), + Input("tga-processing-undo-stack", "data"), + Input("tga-processing-redo-stack", "data"), +) +def toggle_tga_processing_history_buttons(undo_stack, redo_stack): + u = undo_stack or [] + r = redo_stack or [] + return len(u) == 0, len(r) == 0 + + +@callback( + Output("tga-workflow-guide-title", "children"), + Output("tga-workflow-guide-body", "children"), + Input("ui-locale", "data"), +) +def render_tga_workflow_guide_chrome(locale_data): + loc = _loc(locale_data) + pfx = "dash.analysis.tga.workflow_guide" + body = html.Div( + [ + html.P(translate_ui(loc, f"{pfx}.intro"), className="mb-2"), + html.Ul( + [ + html.Li(translate_ui(loc, f"{pfx}.step1"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step2"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step3"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step4"), className="mb-0"), + ], + className="ps-3 mb-0", + ), + ] + ) + return translate_ui(loc, f"{pfx}.title"), body + + +@callback( + Output("tga-raw-quality-card-title", "children"), + Output("tga-raw-quality-card-hint", "children"), + Input("ui-locale", "data"), +) +def render_tga_raw_quality_chrome(locale_data): + loc = _loc(locale_data) + return translate_ui(loc, "dash.analysis.tga.raw_quality.card_title"), translate_ui(loc, "dash.analysis.tga.raw_quality.card_hint") + + +@callback( + Output("tga-processing-history-title", "children"), + Output("tga-processing-history-hint", "children"), + Input("ui-locale", "data"), +) +def render_tga_processing_history_chrome(locale_data): + loc = _loc(locale_data) + return translate_ui(loc, "dash.analysis.tga.processing.history_title"), translate_ui(loc, "dash.analysis.tga.processing.history_hint") + + +@callback( + Output("tga-processing-undo-btn", "children"), + Output("tga-processing-redo-btn", "children"), + Output("tga-processing-reset-btn", "children"), + Input("ui-locale", "data"), +) +def render_tga_processing_history_button_labels(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.tga.processing.undo_btn"), + translate_ui(loc, "dash.analysis.tga.processing.redo_btn"), + translate_ui(loc, "dash.analysis.tga.processing.reset_btn"), + ) + + +@callback( + Output("tga-raw-quality-panel", "children"), + Input("project-id", "data"), + Input("tga-dataset-select", "value"), + Input("tga-refresh", "data"), + Input("ui-locale", "data"), +) +def render_tga_raw_quality_panel(project_id, dataset_key, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id or not dataset_key: + return html.P(translate_ui(loc, "dash.analysis.tga.raw_quality.pick_dataset"), className="text-muted small mb-0") + from dash_app.api_client import workspace_dataset_data, workspace_dataset_detail + + try: + detail = workspace_dataset_detail(project_id, dataset_key) + data = workspace_dataset_data(project_id, dataset_key) + except Exception as exc: + return html.P(translate_ui(loc, "dash.analysis.tga.raw_quality.load_failed", error=str(exc)), className="text-danger small mb-0") + + rows = data.get("rows") or [] + columns = data.get("columns") or [] + t_arr, s_arr = downsample_rows(rows, columns) + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + stats = compute_tga_raw_exploration_stats(t_arr, s_arr, validation=validation) + units = detail.get("units") or {} + temp_u = str(units.get("temperature") or "°C") + sig_u = str(units.get("signal") or "") + return build_tga_raw_quality_panel(stats, loc, temp_unit=temp_u, signal_unit=sig_u) + + +@callback( + Output("tga-smooth-window", "disabled"), + Output("tga-smooth-polyorder", "disabled"), + Output("tga-smooth-sigma", "disabled"), + Input("tga-smooth-method", "value"), +) +def toggle_tga_smoothing_inputs(method): + token = str(method or "savgol").strip().lower() + if token == "savgol": + return False, False, True + if token == "moving_average": + return False, True, True + return True, True, False + + +@callback( + Output("tga-preset-loaded-line", "children"), + Input("tga-preset-loaded-name", "data"), + Input("ui-locale", "data"), +) +def render_tga_preset_loaded_line(loaded_name, locale_data): + loc = _loc(locale_data) + name = str(loaded_name or "").strip() + if not name: + return "" + return translate_ui(loc, "dash.analysis.tga.presets.loaded_line").format(preset=name) + + +@callback( + Output("tga-preset-dirty-flag", "children"), + Input("ui-locale", "data"), + Input("tga-template-select", "value"), + Input("tga-unit-mode-select", "value"), + Input("tga-smooth-method", "value"), + Input("tga-smooth-window", "value"), + Input("tga-smooth-polyorder", "value"), + Input("tga-smooth-sigma", "value"), + Input("tga-step-prominence", "value"), + Input("tga-step-min-mass", "value"), + Input("tga-step-half-width", "value"), + State("tga-preset-snapshot", "data"), +) +def render_tga_preset_dirty_flag(locale_data, template_id, unit_mode, sm_m, sm_w, sm_p, sm_s, st_pr, st_min, st_half, snapshot): + loc = _loc(locale_data) + if not isinstance(snapshot, dict): + return html.Span(translate_ui(loc, "dash.analysis.tga.presets.dirty_no_baseline"), className="text-muted") + current = _tga_ui_snapshot_dict( + template_id, + unit_mode, + _tga_draft_from_control_values(sm_m, sm_w, sm_p, sm_s, st_pr, st_min, st_half), + ) + if _tga_snapshots_equal(snapshot, current): + return html.Span(translate_ui(loc, "dash.analysis.tga.presets.clean"), className="text-success") + return html.Span(translate_ui(loc, "dash.analysis.tga.presets.dirty"), className="text-warning") + + +@callback( + Output("tga-dataset-selector-area", "children"), + Output("tga-run-btn", "disabled"), + Input("project-id", "data"), + Input("tga-refresh", "data"), + Input("ui-locale", "data"), +) +def load_eligible_datasets(project_id, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + return html.P(translate_ui(loc, "dash.analysis.workspace_inactive"), className="text-muted"), True + + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_datasets", error=str(exc)), color="danger"), True + + all_datasets = payload.get("datasets", []) + return dataset_selector_block( + selector_id="tga-dataset-select", + empty_msg=translate_ui(loc, "dash.analysis.tga.empty_import"), + eligible=eligible_datasets(all_datasets, _TGA_ELIGIBLE_TYPES), + all_datasets=all_datasets, + eligible_types=_TGA_ELIGIBLE_TYPES, + active_dataset=payload.get("active_dataset"), + locale_data=locale_data, + ) + + +@callback( + Output("tga-run-status", "children"), + Output("tga-refresh", "data", allow_duplicate=True), + Output("tga-latest-result-id", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("tga-run-btn", "n_clicks"), + State("project-id", "data"), + State("tga-dataset-select", "value"), + State("tga-template-select", "value"), + State("tga-unit-mode-select", "value"), + State("tga-processing-draft", "data"), + State("tga-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def run_tga_analysis(n_clicks, project_id, dataset_key, template_id, unit_mode, processing_draft, refresh_val, global_refresh, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id or not dataset_key: + raise dash.exceptions.PreventUpdate + + from dash_app.api_client import analysis_run + + overrides = _tga_overrides_from_draft(processing_draft) + try: + result = analysis_run( + project_id=project_id, + dataset_key=dataset_key, + analysis_type="TGA", + workflow_template_id=template_id, + unit_mode=unit_mode if unit_mode and unit_mode != "auto" else None, + processing_overrides=overrides or None, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.analysis_failed", error=str(exc)), color="danger"), dash.no_update, dash.no_update, dash.no_update + + alert, saved, result_id = interpret_run_result(result, locale_data=locale_data) + refresh = (refresh_val or 0) + 1 + if saved: + return alert, refresh, result_id, (global_refresh or 0) + 1 + return alert, refresh, dash.no_update, dash.no_update + + +@callback( + Output("tga-result-analysis-summary", "children"), + Output("tga-result-metrics", "children"), + Output("tga-result-quality", "children"), + Output("tga-result-figure", "children"), + Output("tga-result-dtg", "children"), + Output("tga-result-step-cards", "children"), + Output("tga-result-table", "children"), + Output("tga-result-processing", "children"), + Output("tga-result-raw-metadata", "children"), + Input("tga-latest-result-id", "data"), + Input("tga-refresh", "data"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def display_result(result_id, _refresh, ui_theme, locale_data, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + summary_empty = html.P(translate_ui(loc, "dash.analysis.tga.summary.empty"), className="text-muted") + quality_empty = _tga_collapsible_section( + loc, + "dash.analysis.tga.quality.card_title", + html.P(translate_ui(loc, "dash.analysis.tga.quality.empty"), className="text-muted mb-0"), + open=False, + ) + raw_meta_empty = _tga_collapsible_section( + loc, + "dash.analysis.tga.raw_metadata.card_title", + html.P(translate_ui(loc, "dash.analysis.tga.raw_metadata.empty"), className="text-muted mb-0"), + open=False, + ) + if not result_id or not project_id: + return ( + summary_empty, + empty_msg, + quality_empty, + empty_msg, + html.Div(), + empty_msg, + empty_msg, + empty_msg, + raw_meta_empty, + ) + + from dash_app.api_client import workspace_dataset_detail, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + err = dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_result", error=str(exc)), color="danger") + return summary_empty, err, quality_empty, empty_msg, html.Div(), empty_msg, empty_msg, empty_msg, raw_meta_empty + + summary = detail.get("summary", {}) + result_meta = detail.get("result", {}) + processing = detail.get("processing", {}) + rows = detail.get("rows_preview", []) + dataset_key = result_meta.get("dataset_key") + + dataset_detail: dict = {} + if dataset_key: + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + + analysis_summary = _build_tga_analysis_summary( + dataset_detail, + summary, + result_meta, + processing, + loc, + locale_data=locale_data, + ) + quality_panel = _build_tga_quality_card(detail, result_meta, loc) + raw_metadata_panel = _build_tga_raw_metadata_panel((dataset_detail or {}).get("metadata"), loc) + + step_count = summary.get("step_count", 0) + total_mass_loss = summary.get("total_mass_loss_percent") + residue = summary.get("residue_percent") + na = translate_ui(loc, "dash.analysis.na") + + total_loss_str = f"{total_mass_loss:.2f} %" if total_mass_loss is not None else na + residue_str = f"{residue:.1f} %" if residue is not None else na + unit_metric = _tga_resolved_unit_label(processing, loc) + validation_metric = _tga_validation_metric_value(detail, result_meta, loc) + + metrics = metrics_row( + [ + ("dash.analysis.metric.steps", str(step_count)), + ("dash.analysis.metric.total_mass_loss", total_loss_str), + ("dash.analysis.metric.residue", residue_str), + ("dash.analysis.metric.tga_unit_mode", unit_metric), + ("dash.analysis.metric.validation_status", validation_metric), + ], + locale_data=locale_data, + ) + + step_cards = _build_step_cards(rows, loc) + + figure_area = empty_msg + dtg_area = html.Div() + if dataset_key: + figure_area = _build_figure(project_id, dataset_key, summary, rows, ui_theme, loc) + dtg_area = _build_tga_dtg_panel(project_id, dataset_key, ui_theme, loc, locale_data=locale_data) + + table_area = _build_step_table(rows, loc) + + proc_view = processing_details_section( + processing, + extra_lines=[ + html.P(translate_ui(loc, "dash.analysis.tga.step_detection", detail=processing.get("analysis_steps", {}).get("step_detection", {}))), + ], + locale_data=locale_data, + ) + + return ( + analysis_summary, + metrics, + quality_panel, + figure_area, + dtg_area, + step_cards, + table_area, + proc_view, + raw_metadata_panel, + ) + + +@callback( + Output("tga-literature-card-title", "children"), + Output("tga-literature-hint", "children"), + Output("tga-literature-max-claims-label", "children"), + Output("tga-literature-persist-label", "children"), + Output("tga-literature-compare-btn", "children"), + Input("ui-locale", "data"), + Input("tga-latest-result-id", "data"), +) +def render_tga_literature_chrome(locale_data, result_id): + loc = _loc(locale_data) + if result_id: + hint = literature_t( + loc, + f"{_TGA_LITERATURE_PREFIX}.ready", + "Compare the saved TGA result to literature sources.", + ) + else: + hint = literature_t( + loc, + f"{_TGA_LITERATURE_PREFIX}.empty", + "Run a TGA analysis first to enable literature comparison.", + ) + return ( + literature_t(loc, f"{_TGA_LITERATURE_PREFIX}.title", "Literature Compare"), + hint, + literature_t(loc, f"{_TGA_LITERATURE_PREFIX}.max_claims", "Max Claims"), + literature_t(loc, f"{_TGA_LITERATURE_PREFIX}.persist", "Persist to project"), + literature_t(loc, f"{_TGA_LITERATURE_PREFIX}.compare_btn", "Compare"), + ) + + +@callback( + Output("tga-literature-compare-btn", "disabled"), + Input("tga-latest-result-id", "data"), +) +def toggle_tga_literature_compare_button(result_id): + return not bool(result_id) + + +@callback( + Output("tga-literature-output", "children"), + Output("tga-literature-status", "children"), + Input("tga-literature-compare-btn", "n_clicks"), + State("project-id", "data"), + State("tga-latest-result-id", "data"), + State("tga-literature-max-claims", "value"), + State("tga-literature-persist", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def compare_tga_literature(n_clicks, project_id, result_id, max_claims, persist_values, locale_data): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + if not project_id or not result_id: + msg = literature_t( + loc, + f"{_TGA_LITERATURE_PREFIX}.missing_result", + "Run a TGA analysis first.", + ) + return dash.no_update, dbc.Alert(msg, color="warning", className="py-1 small") + + claims_limit = coerce_literature_max_claims(max_claims, default=3) + persist = bool(persist_values) and "persist" in (persist_values or []) + + from dash_app.api_client import literature_compare + + try: + payload = literature_compare( + project_id, + result_id, + max_claims=claims_limit, + persist=persist, + ) + except Exception as exc: + err = dbc.Alert( + literature_t( + loc, + f"{_TGA_LITERATURE_PREFIX}.error", + "Literature compare failed: {error}", + ).replace("{error}", str(exc)), + color="danger", + className="py-1 small", + ) + return dash.no_update, err + + return ( + render_literature_output( + payload, + loc, + i18n_prefix=_TGA_LITERATURE_PREFIX, + evidence_preview_limit=LITERATURE_COMPACT_EVIDENCE_PREVIEW_LIMIT, + alternative_preview_limit=LITERATURE_COMPACT_ALTERNATIVE_PREVIEW_LIMIT, + ), + literature_compare_status_alert(payload, loc, i18n_prefix=_TGA_LITERATURE_PREFIX), + ) + + +def _tga_fetch_figure_preview_data_urls(project_id: str, result_id: str, figure_artifacts: dict) -> dict[str, str]: + from dash_app.api_client import fetch_result_figure_png + + out: dict[str, str] = {} + for label in ordered_figure_preview_keys(figure_artifacts)[:FIGURE_ARTIFACT_PREVIEW_TILES]: + try: + raw = fetch_result_figure_png(project_id, result_id, label, max_edge=FIGURE_ARTIFACT_PREVIEW_MAX_EDGE) + out[label] = "data:image/png;base64," + base64.standard_b64encode(bytes(raw)).decode("ascii") if raw else "" + except Exception: + out[label] = "" + return out + + +@callback( + Output("tga-figure-save-snapshot-btn", "children"), + Output("tga-figure-use-report-btn", "children"), + Output("tga-figure-artifacts-summary", "children"), + Input("ui-locale", "data"), +) +def render_tga_figure_artifact_button_labels(locale_data): + return figure_artifact_button_labels(_loc(locale_data)) + + +@callback( + Output("tga-figure-save-snapshot-btn", "disabled"), + Output("tga-figure-use-report-btn", "disabled"), + Input("tga-latest-result-id", "data"), +) +def toggle_tga_figure_artifact_buttons(result_id): + disabled = not bool(result_id) + return disabled, disabled + + +@callback( + Output("tga-result-figure-artifacts", "children"), + Input("tga-latest-result-id", "data"), + Input("tga-figure-artifact-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def refresh_tga_figure_artifacts_panel(result_id, _artifact_refresh, locale_data, project_id): + loc = _loc(locale_data) + if not result_id or not project_id: + return "" + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + return "" + artifacts = detail.get("figure_artifacts") if isinstance(detail.get("figure_artifacts"), dict) else {} + previews = _tga_fetch_figure_preview_data_urls(project_id, result_id, artifacts) if ordered_figure_preview_keys(artifacts) else None + return build_figure_artifacts_panel(artifacts, loc, previews=previews) + + +@callback( + Output("tga-figure-artifact-status", "children"), + Output("tga-figure-artifact-refresh", "data"), + Input("tga-figure-save-snapshot-btn", "n_clicks"), + Input("tga-figure-use-report-btn", "n_clicks"), + Input("tga-latest-result-id", "data"), + State("project-id", "data"), + State("tga-result-figure", "children"), + State("ui-locale", "data"), + State("tga-figure-artifact-refresh", "data"), + prevent_initial_call=True, +) +def tga_figure_snapshot_or_report_figure(_snap_clicks, _report_clicks, latest_result_id, project_id, figure_children, locale_data, refresh_value): + loc = _loc(locale_data) + triggered_id = getattr(dash.callback_context, "triggered_id", None) + if triggered_id == "tga-latest-result-id": + return "", dash.no_update + action = figure_action_from_trigger( + triggered_id, + snapshot_button_id="tga-figure-save-snapshot-btn", + report_button_id="tga-figure-use-report-btn", + ) + if action is None: + raise dash.exceptions.PreventUpdate + if not project_id or not latest_result_id: + return ( + figure_action_status_alert(loc, action=action, status="missing", reason="missing_project_or_result", class_prefix="tga"), + dash.no_update, + ) + + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, latest_result_id) + except Exception as exc: + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(exc), class_prefix="tga"), + dash.no_update, + ) + result_meta = detail.get("result", {}) or {} + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + meta = figure_action_metadata( + action, + analysis_type="TGA", + dataset_key=result_meta.get("dataset_key"), + result_id=latest_result_id, + snapshot_stamp=stamp, + ) + outcome = register_result_figure_from_layout_children( + figure_children=figure_children, + project_id=project_id, + result_id=latest_result_id, + label=str(meta.get("label") or ""), + replace=bool(meta.get("replace")), + ) + if outcome.get("status") == "ok": + key = str(outcome.get("figure_key") or meta.get("label") or "") + return ( + figure_action_status_alert(loc, action=action, status="ok", figure_key=key, class_prefix="tga"), + (refresh_value or 0) + 1, + ) + if outcome.get("status") == "error": + return ( + figure_action_status_alert(loc, action=action, status="error", reason=str(outcome.get("reason") or ""), class_prefix="tga"), + dash.no_update, + ) + return ( + figure_action_status_alert(loc, action=action, status="skipped", reason=str(outcome.get("reason") or ""), class_prefix="tga"), + dash.no_update, + ) + + +@callback( + Output("tga-figure-captured", "data"), + Input("tga-latest-result-id", "data"), + Input("project-id", "data"), + Input("tga-result-figure", "children"), + State("tga-figure-captured", "data"), + prevent_initial_call=True, +) +def capture_tga_figure(result_id, project_id, figure_children, captured): + return capture_result_figure_from_layout( + result_id=result_id, + project_id=project_id, + figure_children=figure_children, + captured=captured, + analysis_type="TGA", + ) + + +def _format_dataset_metadata_value(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, float): + if value != value: + return None + text = f"{value:g}" + else: + text = str(value).strip() + return text or None + + +def _tga_step_significance_key(row: dict) -> tuple[float, float]: + """Sort key: larger mass loss magnitude first, then lower midpoint temperature.""" + m = row.get("mass_loss_percent") + try: + mag = abs(float(m)) if m is not None else 0.0 + except (TypeError, ValueError): + mag = 0.0 + mid = row.get("midpoint_temperature") + try: + midv = float(mid) if mid is not None else float("nan") + except (TypeError, ValueError): + midv = float("nan") + if not math.isfinite(midv): + midv = float("inf") + return (-mag, midv) + + +def _tga_steps_ranked_for_display(rows: list) -> list[dict]: + dict_rows = [r for r in rows if isinstance(r, dict)] + return sorted(dict_rows, key=_tga_step_significance_key) + + +def _tga_curated_step_rows_for_ui(rows: list) -> tuple[list[dict], int, bool]: + """Same ranking and cap as key-step cards; use for figure midpoint markers.""" + ranked = _tga_steps_ranked_for_display(rows) + total = len(ranked) + truncated = total >= _TGA_TRUNCATE_STEP_CARDS_WHEN + shown = ranked[:_TGA_MAX_STEP_CARDS] if truncated else ranked + return shown, total, truncated + + +def _tga_resolved_unit_label(processing: dict, loc: str) -> str: + method_context = (processing or {}).get("method_context") or {} + return ( + _format_dataset_metadata_value(method_context.get("tga_unit_mode_resolved_label")) + or _format_dataset_metadata_value(method_context.get("tga_unit_mode_label")) + or translate_ui(loc, "dash.analysis.na") + ) + + +def _tga_validation_metric_value(detail: dict, result_meta: dict, loc: str) -> str: + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + status = str(validation.get("status") or result_meta.get("validation_status") or "unknown") + warnings_list = validation.get("warnings") if isinstance(validation.get("warnings"), list) else [] + issues_list = validation.get("issues") if isinstance(validation.get("issues"), list) else [] + wc = int(validation.get("warning_count", len(warnings_list)) or 0) + ic = int(validation.get("issue_count", len(issues_list)) or 0) + status_token = status.strip().lower() + if status_token in {"ok", "pass", "valid"} and wc == 0 and ic == 0: + return translate_ui(loc, "dash.analysis.tga.metric.validation_ok") + parts: list[str] = [status] + if wc: + parts.append(translate_ui(loc, "dash.analysis.tga.metric.validation_warnings", n=wc)) + if ic: + parts.append(translate_ui(loc, "dash.analysis.tga.metric.validation_issues", n=ic)) + return " · ".join(parts) + + +def _build_tga_analysis_summary( + dataset_detail: dict, + summary: dict, + result_meta: dict, + _processing: dict, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + metadata = (dataset_detail or {}).get("metadata") or {} + dataset_summary = (dataset_detail or {}).get("dataset") or {} + na = translate_ui(loc, "dash.analysis.na") + + dataset_label = ( + _format_dataset_metadata_value(metadata.get("file_name")) + or _format_dataset_metadata_value(dataset_summary.get("display_name")) + or _format_dataset_metadata_value(result_meta.get("dataset_key")) + or na + ) + fallback_display_name = _format_dataset_metadata_value(dataset_summary.get("display_name")) + sample_label = resolve_sample_name( + summary or {}, + result_meta or {}, + fallback_display_name=fallback_display_name, + locale_data=locale_data, + ) or na + + sample_mass = _format_dataset_metadata_value(summary.get("sample_mass")) or _format_dataset_metadata_value(metadata.get("sample_mass")) + if sample_mass: + sample_mass = f"{sample_mass} {translate_ui(loc, 'dash.analysis.tga.summary.mass_unit')}" + else: + sample_mass = na + + heating_rate = _format_dataset_metadata_value(summary.get("heating_rate")) or _format_dataset_metadata_value( + metadata.get("heating_rate") + ) + if heating_rate: + heating_rate = f"{heating_rate} {translate_ui(loc, 'dash.analysis.tga.summary.heating_rate_unit')}" + else: + heating_rate = na + + def _meta_value(value: str) -> html.Span: + return html.Span(value, className="ms-meta-value", title=value) + + dl_rows: list[Any] = [ + html.Dt(translate_ui(loc, "dash.analysis.tga.summary.dataset_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(dataset_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.tga.summary.sample_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.tga.summary.mass_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_mass), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.tga.summary.heating_rate_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(heating_rate), className="col-sm-8 ms-meta-def"), + ] + atmosphere = _format_dataset_metadata_value(metadata.get("atmosphere")) + if atmosphere: + dl_rows.extend( + [ + html.Dt(translate_ui(loc, "dash.analysis.tga.summary.atmosphere_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(atmosphere), className="col-sm-8 ms-meta-def"), + ] + ) + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.tga.summary.card_title"), className="mb-3"), + html.Dl(dl_rows, className="row mb-0"), + ] + ) + + +def _build_tga_quality_card(detail: dict, result_meta: dict, loc: str) -> html.Details: + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + processing = detail.get("processing") if isinstance(detail.get("processing"), dict) else {} + method_context = processing.get("method_context") or {} + status = str(validation.get("status") or result_meta.get("validation_status") or "unknown") + warnings_list = validation.get("warnings") if isinstance(validation.get("warnings"), list) else [] + issues_list = validation.get("issues") if isinstance(validation.get("issues"), list) else [] + wc = int(validation.get("warning_count", len(warnings_list)) or 0) + ic = int(validation.get("issue_count", len(issues_list)) or 0) + + status_token = status.strip().lower() + if status_token in {"ok", "pass", "valid"} and wc == 0 and ic == 0: + alert_color = "success" + elif ic == 0: + alert_color = "warning" + else: + alert_color = "danger" + + body_children: list[Any] = [ + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.tga.quality.status_label")), + f" {status}", + ], + className="mb-2", + ), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.tga.quality.warnings_label")), + f" {wc}", + ], + className="mb-2", + ), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.tga.quality.issues_label")), + f" {ic}", + ], + className="mb-2", + ), + ] + if warnings_list: + body_children.append( + html.Div( + [ + html.H6(translate_ui(loc, "dash.analysis.tga.quality.major_warnings_heading"), className="small mb-1"), + html.Ul([html.Li(str(w)) for w in warnings_list[:12]], className="small mb-0"), + ], + className="mb-2", + ) + ) + if issues_list: + body_children.append(html.Ul([html.Li(str(w)) for w in issues_list[:12]], className="small mb-0 mt-2")) + + cal_state = method_context.get("calibration_state") + ref_state = method_context.get("reference_state") + ref_name = method_context.get("reference_name") + cal_text = _format_dataset_metadata_value(cal_state) or translate_ui(loc, "dash.analysis.tga.quality.context_na") + ref_bits = [x for x in (_format_dataset_metadata_value(ref_state), _format_dataset_metadata_value(ref_name)) if x] + ref_text = " | ".join(ref_bits) if ref_bits else translate_ui(loc, "dash.analysis.tga.quality.context_na") + + body_children.append( + html.Div( + [ + html.H6(translate_ui(loc, "dash.analysis.tga.quality.calibration_reference_heading"), className="small mt-2 mb-1"), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.tga.quality.calibration_label")), + f" {cal_text}", + ], + className="small mb-1", + ), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.tga.quality.reference_label")), + f" {ref_text}", + ], + className="small mb-0", + ), + ], + className="mb-2", + ) + ) + + checks = validation.get("checks") + check_items = _tga_quality_check_entries(checks) + if check_items: + technical_checks = html.Ul([html.Li(item, className="small") for item in check_items], className="small mb-0 ps-3") + body_children.append( + html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span( + translate_ui(loc, "dash.analysis.tga.quality.technical_validation_title"), + className="ms-1 small", + ), + ], + className="ta-details-summary", + ), + html.Div(technical_checks, className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0 mt-2", + open=False, + ) + ) + + inner = dbc.Alert(body_children, color=alert_color, className="mb-0 ta-quality-alert") + has_attention = wc > 0 or ic > 0 + badges: list[Any] = [] + if wc: + badges.append( + dbc.Badge( + translate_ui(loc, "dash.analysis.tga.quality.badge_warnings", n=wc), + color="warning", + text_color="dark", + className="ms-2", + pill=True, + ) + ) + if ic: + badges.append( + dbc.Badge( + translate_ui(loc, "dash.analysis.tga.quality.badge_issues", n=ic), + color="danger", + className="ms-2", + pill=True, + ) + ) + return _tga_collapsible_section( + loc, + "dash.analysis.tga.quality.card_title", + inner, + open=has_attention, + summary_suffix=badges if badges else None, + ) + + +def _tga_quality_check_entries(checks: Any) -> list[str]: + if not isinstance(checks, dict) or not checks: + return [] + seen: set[str] = set() + lines: list[str] = [] + for key in (*_TGA_QUALITY_CHECK_ORDER,): + if key in checks and key not in seen: + val = checks[key] + if isinstance(val, (dict, list)): + text = json.dumps(val, ensure_ascii=False) + else: + text = str(val) + lines.append(f"{key}: {text}") + seen.add(key) + for key in sorted(checks.keys(), key=lambda k: str(k).lower()): + if key in seen: + continue + val = checks[key] + if isinstance(val, (dict, list)): + text = json.dumps(val, ensure_ascii=False) + else: + text = str(val) + lines.append(f"{key}: {text}") + if len(lines) >= 28: + break + return lines + + +def _build_tga_raw_metadata_panel(metadata: dict | None, loc: str) -> html.Details: + meta = metadata if isinstance(metadata, dict) else {} + if not meta: + inner = html.P(translate_ui(loc, "dash.analysis.tga.raw_metadata.empty"), className="text-muted mb-0") + else: + user_keys = sorted( + [k for k in meta if k in _TGA_USER_FACING_METADATA_KEYS], + key=lambda k: str(k).lower(), + ) + tech_keys = sorted( + [k for k in meta if k not in _TGA_USER_FACING_METADATA_KEYS], + key=lambda k: str(k).lower(), + ) + + def _make_rows(keys: list[str]) -> list[Any]: + rows: list[Any] = [] + for key in keys: + value = meta[key] + if isinstance(value, (dict, list)): + text = json.dumps(value, ensure_ascii=False, indent=2) + else: + fv = _format_dataset_metadata_value(value) + text = fv if fv is not None else str(value) + rows.extend( + [ + html.Dt(str(key), className="col-sm-4 text-muted small"), + html.Dd(html.Pre(text, className="small mb-0 ta-code-block p-2 rounded"), className="col-sm-8 mb-2"), + ] + ) + return rows + + body_parts: list[Any] = [] + if user_keys: + body_parts.append(html.Dl(_make_rows(user_keys), className="row mb-0")) + + if tech_keys: + tech_collapsible = html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span( + translate_ui(loc, "dash.analysis.tga.raw_metadata.technical_details") or "Technical details", + className="ms-1", + ), + ], + className="ta-details-summary", + ), + html.Div(html.Dl(_make_rows(tech_keys), className="row mb-0"), className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=False, + ) + body_parts.append(html.Div(tech_collapsible, className="mt-2")) + + inner = ( + html.Div(body_parts) + if body_parts + else html.P(translate_ui(loc, "dash.analysis.tga.raw_metadata.empty"), className="text-muted mb-0") + ) + return _tga_collapsible_section(loc, "dash.analysis.tga.raw_metadata.card_title", inner, open=False) + + +def _coerce_float_pair(tx: Any, dx: Any) -> tuple[float, float] | None: + try: + pt = float(tx) + pd = float(dx) + except (TypeError, ValueError): + return None + if not math.isfinite(pt) or not math.isfinite(pd): + return None + return pt, pd + + +def _build_tga_dtg_panel( + project_id: str, + dataset_key: str, + ui_theme: str | None, + loc: str, + *, + locale_data: str | None = None, +) -> html.Div: + _ld = locale_data if locale_data is not None else loc + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "TGA", dataset_key) + except Exception: + curves = {} + + if not curves.get("has_dtg") and not curves.get("dtg"): + return html.Div() + + raw_temperature = curves.get("temperature") or [] + raw_dtg = curves.get("dtg") or [] + if not raw_temperature or not raw_dtg or len(raw_temperature) != len(raw_dtg): + return html.Div() + + temperature: list[float] = [] + dtg: list[float] = [] + for tx, dx in zip(raw_temperature, raw_dtg): + pair = _coerce_float_pair(tx, dx) + if pair is None: + continue + temperature.append(pair[0]) + dtg.append(pair[1]) + if len(temperature) < 3: + return html.Div() + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=temperature, + y=dtg, + mode="lines", + name=translate_ui(_ld, "dash.analysis.tga.dtg.trace_name"), + line=dict(color="#DC2626", width=1.8), + ) + ) + fig.update_layout( + title=dict( + text=translate_ui(_ld, "dash.analysis.tga.dtg.title"), + x=0.01, + xanchor="left", + font=dict(size=14), + ), + xaxis_title=translate_ui(_ld, "dash.analysis.figure.axis_temperature_c"), + yaxis_title=translate_ui(_ld, "dash.analysis.figure.axis_dtg"), + height=280, + margin=dict(l=56, r=18, t=48, b=44), + showlegend=False, + ) + apply_figure_theme(fig, ui_theme) + graph = dcc.Graph( + figure=fig, + config={ + "displaylogo": False, + "responsive": True, + "modeBarButtonsToRemove": ["lasso2d", "select2d", "toggleSpikelines", "hoverCompareCartesian"], + }, + className="ta-plot tga-derivative-graph", + ) + return html.Div( + [ + html.H6(translate_ui(_ld, "dash.analysis.tga.dtg.card_title"), className="mb-2"), + html.P(translate_ui(_ld, "dash.analysis.tga.dtg.caption"), className="small text-muted mb-2"), + graph, + ], + className="tga-derivative-helper", + ) + + +def _build_step_cards(rows: list, loc: str) -> html.Div: + if not rows: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.tga_key_steps"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_steps"), className="text-muted"), + ] + ) + + display_rows, total, truncated = _tga_curated_step_rows_for_ui(rows) + + cards: list[Any] = [html.H5(translate_ui(loc, "dash.analysis.section.tga_key_steps"), className="mb-3")] + for idx, row in enumerate(display_rows): + cards.append(_step_card(row, idx, loc)) + if truncated: + cards.append( + html.P( + translate_ui(loc, "dash.analysis.tga.steps.truncation_note", shown=len(display_rows), total=total), + className="small text-muted mb-1", + ) + ) + cards.append( + html.P( + translate_ui(loc, "dash.analysis.tga.steps.table_authority_note"), + className="small text-muted mb-0 fst-italic", + ) + ) + return html.Div(cards) + + +def _build_figure(project_id: str, dataset_key: str, summary: dict, step_rows: list, ui_theme: str | None, loc: str) -> html.Div: + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "TGA", dataset_key) + except Exception: + curves = {} + + temperature = curves.get("temperature", []) + raw_signal = curves.get("raw_signal", []) + smoothed = curves.get("smoothed", []) + has_smoothed = curves.get("has_smoothed") + + if not temperature: + return no_data_figure_msg(locale_data=loc) + + na = translate_ui(loc, "dash.analysis.na") + sample_name = resolve_sample_name(summary, {}, fallback_display_name=dataset_key, locale_data=loc) + + fig = go.Figure() + + raw_alpha = 0.35 if has_smoothed else 1.0 + raw_width = 1.0 if has_smoothed else 1.5 + fig.add_trace( + go.Scatter( + x=temperature, + y=raw_signal, + mode="lines", + name=translate_ui(loc, "dash.analysis.figure.legend_raw_mass"), + line=dict(color="#94A3B8", width=raw_width), + opacity=raw_alpha, + ) + ) + + if smoothed and len(smoothed) == len(temperature): + fig.add_trace( + go.Scatter( + x=temperature, + y=smoothed, + mode="lines", + name=translate_ui(loc, "dash.analysis.figure.legend_smoothed_mass"), + line=dict(color="#0E7490", width=1.5), + ) + ) + + # Midpoint markers only on the main mass axis; DTG is shown in the dedicated card below. + # Use the same curated ranked subset as key-step cards so markers match the UI. + n_steps = len(step_rows) + marker_rows, _, _ = _tga_curated_step_rows_for_ui(step_rows) + + _ANNOTATION_MIN_SEP = 18.0 if n_steps > _TGA_MAX_STEP_CARDS else 15.0 + annotated_temps: list[float] = [] + + for row in marker_rows: + midpoint = row.get("midpoint_temperature") + if midpoint is not None and temperature: + idx = min(range(len(temperature)), key=lambda i: abs(temperature[i] - midpoint)) + too_close = any(abs(midpoint - t) < _ANNOTATION_MIN_SEP for t in annotated_temps) + text_str = f"{midpoint:.1f}" if not too_close else "" + y_at = raw_signal[idx] if idx < len(raw_signal or []) else None + if y_at is None and smoothed and idx < len(smoothed): + y_at = smoothed[idx] + if y_at is not None: + fig.add_trace( + go.Scatter( + x=[temperature[idx]], + y=[y_at], + mode="markers+text", + marker=dict(size=9, color="#059669", symbol="diamond"), + text=[text_str], + textposition="bottom center", + textfont=dict(size=9, color="#059669"), + name=translate_ui(loc, "dash.analysis.figure.step_mid", v=f"{midpoint:.1f}"), + showlegend=False, + ) + ) + if text_str: + annotated_temps.append(midpoint) + + # Keep vertical guides readable: full onset/endset lines only for small step counts. + show_step_vlines = n_steps <= 4 + annotate_onset_endset = n_steps <= 4 + + if show_step_vlines: + for row in step_rows: + onset = row.get("onset_temperature") + endset = row.get("endset_temperature") + if onset is not None: + ann_text = translate_ui(loc, "dash.analysis.figure.annot_on", v=f"{onset:.1f}") if annotate_onset_endset else "" + fig.add_vline( + x=onset, + line=dict(color="#F59E0B", width=1, dash="dot"), + annotation_text=ann_text or None, + annotation_position="top left", + ) + if endset is not None: + ann_text = translate_ui(loc, "dash.analysis.figure.annot_end", v=f"{endset:.1f}") if annotate_onset_endset else "" + fig.add_vline( + x=endset, + line=dict(color="#F59E0B", width=1, dash="dot"), + annotation_text=ann_text or None, + annotation_position="top left", + ) + + fig.update_layout( + title=translate_ui(loc, "dash.analysis.figure.title_tga", name=sample_name), + xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_temperature_c"), + yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_mass_pct"), + margin=dict(l=56, r=24, t=56, b=48), + height=480, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + ) + apply_figure_theme(fig, ui_theme) + ink = PLOT_THEME[normalize_ui_theme(ui_theme)]["text"] + fig.update_layout( + yaxis=dict(title=dict(text=translate_ui(loc, "dash.analysis.figure.axis_mass_pct"), font=dict(color=ink)), tickfont=dict(color=ink)) + ) + + step_count_disp = summary.get("step_count", n_steps) + total_mass_loss = summary.get("total_mass_loss_percent") + residue_pct = summary.get("residue_percent") + loss_str = f"{total_mass_loss:.2f} %" if total_mass_loss is not None else na + res_str = f"{residue_pct:.1f} %" if residue_pct is not None else na + run_caption = translate_ui( + loc, + "dash.analysis.tga.figure.run_summary", + steps=str(step_count_disp), + loss=loss_str, + residue=res_str, + ) + + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.tga.figure.section_title"), className="mb-2"), + html.P(run_caption, className="small text-muted mb-2"), + dcc.Graph( + figure=fig, + config={"displaylogo": False, "responsive": True}, + className="ta-plot", + ), + ] + ) + + +def _build_step_table(rows: list, loc: str) -> html.Div: + if not rows: + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.step_table"), className="mb-3"), + html.P(translate_ui(loc, "dash.analysis.state.no_step_data"), className="text-muted"), + ] + ) + + columns = [ + "onset_temperature", + "midpoint_temperature", + "endset_temperature", + "mass_loss_percent", + "mass_loss_mg", + "residual_percent", + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.section.step_table"), className="mb-3"), + dataset_table(rows, columns, table_id="tga-steps-table"), + ] + ) diff --git a/dash_app/pages/xrd.py b/dash_app/pages/xrd.py new file mode 100644 index 00000000..8aef5dad --- /dev/null +++ b/dash_app/pages/xrd.py @@ -0,0 +1,2507 @@ +"""XRD analysis page — mature Dash shell aligned with Raman/FTIR (Setup / Processing / Run).""" + +from __future__ import annotations + +import base64 +import copy +import math +from datetime import datetime, timezone +from typing import Any + +import dash +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html + +from dash_app.components.analysis_boilerplate import ( + build_collapsible_section, + build_load_saveas_preset_card, + build_processing_history_card, +) +from dash_app.components.analysis_page import ( + analysis_page_stores, + capture_result_figure_from_layout, + register_result_figure_from_layout_children, + dataset_selection_card, + dataset_selector_block, + eligible_datasets, + empty_result_msg, + execute_card, + finalized_validation_warning_issue_counts, + interpret_run_result, + metrics_row, + no_data_figure_msg, + processing_details_section, + resolve_sample_name, + result_placeholder_card, + workflow_template_card, +) +from dash_app.components.chrome import page_header +from dash_app.components.data_preview import dataset_table +from dash_app.components.figure_artifacts import ( + FIGURE_ARTIFACT_PREVIEW_MAX_EDGE, + FIGURE_ARTIFACT_PREVIEW_TILES, + build_figure_artifact_surface, + build_figure_artifacts_panel, + figure_action_from_trigger, + figure_action_metadata, + figure_action_status_alert, + figure_artifact_button_labels, + ordered_figure_preview_keys, + primary_report_figure_label, +) +from dash_app.components.literature_compare_ui import ( + build_literature_compare_card, + coerce_literature_max_claims, + literature_compare_status_alert, + literature_t, + render_literature_output, +) +from dash_app.components.xrd_explore import ( + MAX_XRD_UNDO_DEPTH, + append_undo_after_edit, + perform_redo, + perform_undo, + xrd_draft_processing_equal, +) +from dash_app.components.xrd_processing_draft import ( + default_xrd_draft_for_template, + normalize_xrd_processing_draft, + xrd_draft_from_control_values, + xrd_draft_from_loaded_processing, + xrd_overrides_from_draft, + xrd_preset_processing_body_for_save, + xrd_snapshots_equal, + xrd_template_ids, + xrd_ui_snapshot_dict, +) +from dash_app.components.xrd_result_plot import build_xrd_result_figure +from dash_app.theme import normalize_ui_theme +from utils.i18n import normalize_ui_locale, translate_ui + +dash.register_page(__name__, path="/xrd", title="XRD Analysis - MaterialScope") + +_XRD_TEMPLATE_IDS = list(xrd_template_ids()) +# Streamlit-era template list shape + static select options (tests / external imports). +_XRD_WORKFLOW_TEMPLATES = [{"id": tid} for tid in _XRD_TEMPLATE_IDS] +_TEMPLATE_OPTIONS = [{"label": tid, "value": tid} for tid in _XRD_TEMPLATE_IDS] +_XRD_ELIGIBLE_TYPES = {"XRD", "UNKNOWN"} +_XRD_PRESET_ANALYSIS_TYPE = "XRD" +_XRD_LITERATURE_PREFIX = "dash.analysis.xrd.literature" +MAX_XRD_FIGURE_PREVIEW_TILES = FIGURE_ARTIFACT_PREVIEW_TILES +# Long edge cap for inline data-URL previews (server GET ``max_edge``; Slice 6). +MAX_XRD_FIGURE_PREVIEW_MAX_EDGE = FIGURE_ARTIFACT_PREVIEW_MAX_EDGE + +_XRD_RESULT_CARD_ROLES = { + "context": "ms-result-context", + "hero": "ms-result-hero", + "support": "ms-result-support", + "secondary": "ms-result-secondary", +} + +_XRD_LEFT_PANEL_CARD = "xrd-left-panel-card mb-2" + +_XRD_USER_FACING_METADATA_KEYS: frozenset[str] = frozenset({ + "sample_name", + "display_name", + "instrument", + "vendor", + "file_name", + "source_data_hash", +}) + +_CONFIDENCE_COLORS = { + "high_confidence": "#059669", + "moderate_confidence": "#D97706", + "low_confidence": "#DC2626", + "no_match": "#6B7280", +} + + +def _loc(locale_data: str | None) -> str: + return normalize_ui_locale(locale_data) + + +def _coerce_float(value) -> float | None: + try: + if value in (None, ""): + return None + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def _confidence_band_label(loc: str, band: str) -> str: + token = str(band or "no_match").lower().replace(" ", "_") + key = f"dash.analysis.confidence.{token}" + text = translate_ui(loc, key) + if text == key: + return str(band).replace("_", " ").title() + return text + + +def _match_status_label(loc: str, raw: str | None) -> str: + token = str(raw or "no_match").lower().replace(" ", "_") + key = f"dash.analysis.match_status.{token}" + text = translate_ui(loc, key) + if text == key: + s = str(raw or "").replace("_", " ").strip() + return s.title() if s else translate_ui(loc, "dash.analysis.na") + return text + + +def _display_candidate_name(row: dict, loc: str) -> str: + for key in ("display_name_unicode", "display_name", "candidate_name", "phase_name", "candidate_id"): + value = str(row.get(key) or "").strip() + if value: + return value + return translate_ui(loc, "dash.analysis.xrd.candidate_unknown") + + +def _xrd_result_section(child: Any, *, role: str = "support") -> html.Div: + """Wraps one band on the right-hand results column; spacing comes from ``xrd-result-surface-block`` in CSS.""" + role_class = _XRD_RESULT_CARD_ROLES.get(role, _XRD_RESULT_CARD_ROLES["support"]) + return html.Div(child, className=f"ms-result-section xrd-result-surface-block {role_class}") + + +def _xrd_collapsible_section( + loc: str, + title_key: str, + body: Any, + *, + open: bool = False, + summary_suffix: Any | None = None, +) -> html.Details: + return build_collapsible_section(loc, title_key, body, open=open, summary_suffix=summary_suffix) + + +def _match_card(row: dict, idx: int, loc: str = "en") -> dbc.Card: + score = _coerce_float(row.get("normalized_score")) or 0.0 + confidence = str(row.get("confidence_band", "no_match")).lower() + color = _CONFIDENCE_COLORS.get(confidence, "#6B7280") + evidence = row.get("evidence", {}) + shared_peaks = evidence.get("shared_peak_count", "--") + overlap_score = evidence.get("weighted_overlap_score", "--") + mean_delta = evidence.get("mean_delta_position", "--") + coverage_ratio = evidence.get("coverage_ratio", "--") + provider = str(row.get("library_provider") or "--") + formula = str(row.get("formula_unicode") or row.get("formula_pretty") or row.get("formula") or "--") + candidate = _display_candidate_name(row, loc) + + return dbc.Card( + dbc.CardBody( + [ + html.Div( + [ + html.I(className="bi bi-bullseye me-1", style={"color": color, "fontSize": "0.9rem", "opacity": 0.9}), + html.Strong(translate_ui(loc, "dash.analysis.label.candidate_n", n=idx + 1), className="me-2 small text-body-secondary"), + html.Span( + _confidence_band_label(loc, confidence), + className="badge", + style={"backgroundColor": color, "color": "white", "fontSize": "0.65rem", "fontWeight": 500}, + ), + ], + className="mb-1", + ), + dbc.Row( + [ + dbc.Col( + [html.Small(translate_ui(loc, "dash.analysis.label.phase"), className="text-muted d-block"), html.Span(candidate, className="small")], + md=5, + ), + dbc.Col( + [html.Small(translate_ui(loc, "dash.analysis.label.score"), className="text-muted d-block"), html.Span(f"{score:.3f}", className="small")], + md=2, + ), + dbc.Col( + [ + html.Small(translate_ui(loc, "dash.analysis.label.shared_peaks"), className="text-muted d-block"), + html.Span(shared_peaks, className="small"), + ], + md=2, + ), + dbc.Col( + [html.Small(translate_ui(loc, "dash.analysis.label.provider"), className="text-muted d-block"), html.Span(provider, className="small text-break")], + md=3, + ), + ], + className="g-1", + ), + html.Div( + [ + html.Small(translate_ui(loc, "dash.analysis.label.formula"), className="text-muted me-1"), + html.Span(formula, className="small font-monospace"), + ], + className="mt-1", + ), + html.P( + translate_ui( + loc, + "dash.analysis.xrd.match_detail_line", + overlap=overlap_score, + coverage=coverage_ratio, + delta=mean_delta, + ), + className="mb-0 text-muted small mt-1 lh-sm", + ), + ], + className="py-2 px-2 xrd-candidate-card-body", + ), + outline=True, + className="mb-2 shadow-none xrd-candidate-card border-secondary-subtle", + ) + + +def _xrd_workflow_guide_block() -> html.Details: + return html.Details( + [ + html.Summary( + [html.Span(className="ta-details-chevron"), html.Span(id="xrd-workflow-guide-title", className="ms-1")], + className="ta-details-summary", + ), + html.Div(id="xrd-workflow-guide-body", className="ta-details-body mt-2 small"), + ], + className="ta-ms-details mb-3", + open=False, + ) + + +def _xrd_setup_review_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H6(id="xrd-setup-review-title", className="card-title mb-2"), + html.P(id="xrd-setup-review-hint", className="small text-muted mb-2"), + dbc.Checkbox(id="xrd-review-axis-ok", value=False, className="mb-2"), + dbc.Label(id="xrd-review-wavelength-label", html_for="xrd-review-wavelength", className="mb-1"), + dbc.Input(id="xrd-review-wavelength", type="number", step=0.0001, min=0, value=None), + html.Div(id="xrd-setup-import-warnings", className="small mt-2"), + html.Div(id="xrd-setup-validation", className="small mt-2"), + ], + className="xrd-left-panel-card-body", + ), + className="xrd-left-panel-card mb-3", + ) + + +def _xrd_processing_history_card() -> dbc.Card: + return build_processing_history_card( + title_id="xrd-processing-history-title", + hint_id="xrd-processing-history-hint", + undo_button_id="xrd-processing-undo-btn", + redo_button_id="xrd-processing-redo-btn", + reset_button_id="xrd-processing-reset-btn", + status_id="xrd-history-status", + card_class_name=_XRD_LEFT_PANEL_CARD, + body_class_name="xrd-left-panel-card-body", + ) + + +def _xrd_preset_card() -> dbc.Card: + return build_load_saveas_preset_card( + id_prefix="xrd", + card_class_name=_XRD_LEFT_PANEL_CARD, + body_class_name="xrd-left-panel-card-body", + ) + + +def _axis_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="xrd-axis-card-title", className="card-title mb-2"), + html.P(id="xrd-axis-card-hint", className="small text-muted mb-2"), + dbc.Checkbox(id="xrd-axis-sort", value=True, className="mb-2"), + dbc.Label(id="xrd-axis-dedup-label", html_for="xrd-axis-dedup", className="mb-1"), + dbc.Select( + id="xrd-axis-dedup", + options=[ + {"label": "first", "value": "first"}, + {"label": "last", "value": "last"}, + {"label": "mean", "value": "mean"}, + ], + value="first", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-axis-min-label", html_for="xrd-axis-min", className="mb-1"), dbc.Input(id="xrd-axis-min", type="number", value=None)], md=6), + dbc.Col([dbc.Label(id="xrd-axis-max-label", html_for="xrd-axis-max", className="mb-1"), dbc.Input(id="xrd-axis-max", type="number", value=None)], md=6), + ], + className="g-2 mt-2", + ), + ], + className="xrd-left-panel-card-body", + ), + className=_XRD_LEFT_PANEL_CARD, + ) + + +def _smooth_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="xrd-smooth-card-title", className="card-title mb-2"), + html.P(id="xrd-smooth-card-hint", className="small text-muted mb-2"), + dbc.Label(id="xrd-smooth-method-label", html_for="xrd-smooth-method", className="mb-1"), + dbc.Select( + id="xrd-smooth-method", + options=[ + {"label": "Savitzky–Golay", "value": "savgol"}, + {"label": "Moving average", "value": "moving_average"}, + ], + value="savgol", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-smooth-window-label", html_for="xrd-smooth-window", className="mb-1"), dbc.Input(id="xrd-smooth-window", type="number", min=3, step=2, value=11)], md=6), + dbc.Col([dbc.Label(id="xrd-smooth-poly-label", html_for="xrd-smooth-poly", className="mb-1"), dbc.Input(id="xrd-smooth-poly", type="number", min=1, max=7, value=3)], md=6), + ], + className="g-2 mt-2", + ), + ], + className="xrd-left-panel-card-body", + ), + className=_XRD_LEFT_PANEL_CARD, + ) + + +def _baseline_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="xrd-baseline-card-title", className="card-title mb-2"), + html.P(id="xrd-baseline-card-hint", className="small text-muted mb-2"), + dbc.Label(id="xrd-baseline-method-label", html_for="xrd-baseline-method", className="mb-1"), + dbc.Select( + id="xrd-baseline-method", + options=[ + {"label": "Rolling minimum", "value": "rolling_minimum"}, + {"label": "Linear", "value": "linear"}, + ], + value="rolling_minimum", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-baseline-window-label", html_for="xrd-baseline-window", className="mb-1"), dbc.Input(id="xrd-baseline-window", type="number", min=3, step=2, value=31)], md=6), + dbc.Col([dbc.Label(id="xrd-baseline-smooth-label", html_for="xrd-baseline-smooth", className="mb-1"), dbc.Input(id="xrd-baseline-smooth", type="number", min=3, step=2, value=9)], md=6), + ], + className="g-2 mt-2", + ), + ], + className="xrd-left-panel-card-body", + ), + className=_XRD_LEFT_PANEL_CARD, + ) + + +def _peak_card() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="xrd-peak-card-title", className="card-title mb-2"), + html.P(id="xrd-peak-card-hint", className="small text-muted mb-2"), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-peak-prom-label", html_for="xrd-peak-prom", className="mb-1"), dbc.Input(id="xrd-peak-prom", type="number", min=0, step=0.01, value=0.08)], md=3), + dbc.Col([dbc.Label(id="xrd-peak-dist-label", html_for="xrd-peak-dist", className="mb-1"), dbc.Input(id="xrd-peak-dist", type="number", min=1, step=1, value=6)], md=3), + dbc.Col([dbc.Label(id="xrd-peak-width-label", html_for="xrd-peak-width", className="mb-1"), dbc.Input(id="xrd-peak-width", type="number", min=1, step=1, value=2)], md=3), + dbc.Col([dbc.Label(id="xrd-peak-max-label", html_for="xrd-peak-max", className="mb-1"), dbc.Input(id="xrd-peak-max", type="number", min=1, step=1, value=12)], md=3), + ], + className="g-2", + ), + ], + className="xrd-left-panel-card-body", + ), + className=_XRD_LEFT_PANEL_CARD, + ) + + +def _match_card_controls() -> dbc.Card: + return dbc.Card( + dbc.CardBody( + [ + html.H5(id="xrd-match-card-title", className="card-title mb-2"), + html.P(id="xrd-match-card-hint", className="small text-muted mb-2"), + dbc.Label(id="xrd-match-metric-label", html_for="xrd-match-metric", className="mb-1"), + dbc.Select( + id="xrd-match-metric", + options=[{"label": "Peak overlap (weighted)", "value": "peak_overlap_weighted"}], + value="peak_overlap_weighted", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-match-tol-label", html_for="xrd-match-tol", className="mb-1"), dbc.Input(id="xrd-match-tol", type="number", min=0.01, step=0.01, value=0.28)], md=4), + dbc.Col([dbc.Label(id="xrd-match-topn-label", html_for="xrd-match-topn", className="mb-1"), dbc.Input(id="xrd-match-topn", type="number", min=1, step=1, value=5)], md=4), + dbc.Col([dbc.Label(id="xrd-match-min-label", html_for="xrd-match-min", className="mb-1"), dbc.Input(id="xrd-match-min", type="number", min=0, max=1, step=0.01, value=0.42)], md=4), + ], + className="g-2 mt-2", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-match-iw-label", html_for="xrd-match-iw", className="mb-1"), dbc.Input(id="xrd-match-iw", type="number", min=0, max=1, step=0.01, value=0.35)], md=6), + dbc.Col([dbc.Label(id="xrd-match-maj-label", html_for="xrd-match-maj", className="mb-1"), dbc.Input(id="xrd-match-maj", type="number", min=0, max=1, step=0.01, value=0.4)], md=6), + ], + className="g-2 mt-2", + ), + ], + className="xrd-left-panel-card-body", + ), + className=_XRD_LEFT_PANEL_CARD, + ) + + +def _xrd_plot_settings_advanced_block() -> html.Details: + """Plot appearance controls — collapsed by default to reduce Processing-tab noise.""" + inner = dbc.Card( + dbc.CardBody( + [ + html.P(id="xrd-plot-advanced-hint", className="small text-muted mb-2"), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id="xrd-plot-labels", value=True), md=6), + dbc.Col(dbc.Checkbox(id="xrd-plot-matched", value=False), md=6), + ], + className="g-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id="xrd-plot-uobs", value=False), md=6), + dbc.Col(dbc.Checkbox(id="xrd-plot-uref", value=False), md=6), + ], + className="g-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id="xrd-plot-conn", value=False), md=6), + dbc.Col(dbc.Checkbox(id="xrd-plot-mlab", value=False), md=6), + ], + className="g-2", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Checkbox(id="xrd-plot-intermediate", value=False, className="me-2"), + html.Span(id="xrd-plot-intermediate-label", className="small align-middle"), + ], + width="auto", + className="d-flex align-items-center", + ), + ], + className="g-2 mb-1", + ), + html.H6(id="xrd-plot-advanced-title", className="mt-2 mb-2 small text-muted text-uppercase"), + dbc.Label(id="xrd-plot-density-label", html_for="xrd-plot-density", className="mb-1"), + dbc.Select( + id="xrd-plot-density", + options=[ + {"label": "smart", "value": "smart"}, + {"label": "all", "value": "all"}, + {"label": "selected", "value": "selected"}, + ], + value="smart", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-plot-maxlab-label", html_for="xrd-plot-maxlab", className="mb-1"), dbc.Input(id="xrd-plot-maxlab", type="number", min=1, max=60, value=8)], md=4), + dbc.Col([dbc.Label(id="xrd-plot-minratio-label", html_for="xrd-plot-minratio", className="mb-1"), dbc.Input(id="xrd-plot-minratio", type="number", min=0, max=1, step=0.01, value=0.12)], md=4), + dbc.Col([dbc.Label(id="xrd-plot-msize-label", html_for="xrd-plot-msize", className="mb-1"), dbc.Input(id="xrd-plot-msize", type="number", min=4, max=20, value=8)], md=4), + ], + className="g-2 mt-2", + ), + dbc.Row( + [ + dbc.Col([dbc.Label(id="xrd-plot-pospr-label", html_for="xrd-plot-pospr", className="mb-1"), dbc.Input(id="xrd-plot-pospr", type="number", min=1, max=5, value=2)], md=4), + dbc.Col([dbc.Label(id="xrd-plot-intpr-label", html_for="xrd-plot-intpr", className="mb-1"), dbc.Input(id="xrd-plot-intpr", type="number", min=0, max=4, value=0)], md=4), + dbc.Col([dbc.Label(id="xrd-plot-style-label", html_for="xrd-plot-style", className="mb-1"), dbc.Select(id="xrd-plot-style", options=[], value="color_shape")], md=4), + ], + className="g-2 mt-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id="xrd-plot-xlock", value=False), md=4), + dbc.Col([dbc.Label(id="xrd-plot-xmin-label", html_for="xrd-plot-xmin", className="mb-1"), dbc.Input(id="xrd-plot-xmin", type="number", value=None)], md=4), + dbc.Col([dbc.Label(id="xrd-plot-xmax-label", html_for="xrd-plot-xmax", className="mb-1"), dbc.Input(id="xrd-plot-xmax", type="number", value=None)], md=4), + ], + className="g-2 mt-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id="xrd-plot-ylock", value=False), md=4), + dbc.Col([dbc.Label(id="xrd-plot-ymin-label", html_for="xrd-plot-ymin", className="mb-1"), dbc.Input(id="xrd-plot-ymin", type="number", value=None)], md=4), + dbc.Col([dbc.Label(id="xrd-plot-ymax-label", html_for="xrd-plot-ymax", className="mb-1"), dbc.Input(id="xrd-plot-ymax", type="number", value=None)], md=4), + ], + className="g-2 mt-2", + ), + dbc.Row( + [ + dbc.Col(dbc.Checkbox(id="xrd-plot-logy", value=False), md=6), + dbc.Col([dbc.Label(id="xrd-plot-lw-label", html_for="xrd-plot-lw", className="mb-1"), dbc.Input(id="xrd-plot-lw", type="number", min=0.8, max=5, step=0.1, value=2.0)], md=6), + ], + className="g-2 mt-2", + ), + ], + className="py-2 px-2 xrd-left-panel-card-body", + ), + className="border-0 bg-transparent shadow-none", + ) + return html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(id="xrd-plot-advanced-summary", className="ms-1 fw-semibold"), + ], + className="ta-details-summary py-2", + ), + html.Div(inner, className="ta-details-body mt-1"), + ], + className="ta-ms-details mb-1", + open=False, + ) + + +def _left_tabs() -> dbc.Tabs: + return dbc.Tabs( + [ + dbc.Tab( + [ + dataset_selection_card("xrd-dataset-selector-area", card_title_id="xrd-dataset-card-title"), + workflow_template_card( + "xrd-template-select", + "xrd-template-description", + [], + "xrd.general", + card_title_id="xrd-workflow-card-title", + ), + _xrd_workflow_guide_block(), + _xrd_setup_review_card(), + ], + tab_id="xrd-tab-setup", + label_class_name="ta-tab-label", + id="xrd-tab-setup-shell", + ), + dbc.Tab( + [ + html.Div( + [ + _xrd_processing_history_card(), + _xrd_preset_card(), + _axis_card(), + _smooth_card(), + _baseline_card(), + _peak_card(), + _match_card_controls(), + _xrd_plot_settings_advanced_block(), + ], + className="xrd-processing-tab-pane", + ) + ], + tab_id="xrd-tab-processing", + label_class_name="ta-tab-label", + id="xrd-tab-processing-shell", + ), + dbc.Tab( + [execute_card("xrd-run-status", "xrd-run-btn", card_title_id="xrd-execute-card-title")], + tab_id="xrd-tab-run", + label_class_name="ta-tab-label", + id="xrd-tab-run-shell", + ), + ], + id="xrd-left-tabs", + active_tab="xrd-tab-setup", + className="mb-3", + ) + + +layout = html.Div( + analysis_page_stores("xrd-refresh", "xrd-latest-result-id") + + [ + dcc.Store(id="xrd-figure-captured", data={}), + dcc.Store(id="xrd-figure-artifact-refresh", data=0), + dcc.Store(id="xrd-processing-default", data=copy.deepcopy(default_xrd_draft_for_template("xrd.general"))), + dcc.Store(id="xrd-processing-draft", data=copy.deepcopy(default_xrd_draft_for_template("xrd.general"))), + dcc.Store(id="xrd-processing-undo-stack", data=[]), + dcc.Store(id="xrd-processing-redo-stack", data=[]), + dcc.Store(id="xrd-history-hydrate", data=0), + dcc.Store(id="xrd-preset-refresh", data=0), + dcc.Store(id="xrd-preset-hydrate", data=0), + dcc.Store(id="xrd-preset-loaded-name", data=""), + dcc.Store(id="xrd-preset-snapshot", data=None), + dcc.Store(id="xrd-result-cache", data=None), + html.Div(id="xrd-hero-slot"), + dbc.Row( + [ + dbc.Col([_left_tabs()], md=4), + dbc.Col( + [ + _xrd_result_section(result_placeholder_card("xrd-result-analysis-summary"), role="context"), + _xrd_result_section(html.Div(id="xrd-result-metrics", className="mb-0"), role="context"), + _xrd_result_section(html.Div(id="xrd-result-quality", className="mb-0"), role="support"), + _xrd_result_section( + build_figure_artifact_surface( + "xrd", + figure_host_class="ta-xrd-figure-host mb-0", + control_slot_id="xrd-result-figure-controls", + ), + role="hero", + ), + _xrd_result_section(html.Div(id="xrd-result-top-match", className="mb-0"), role="support"), + _xrd_result_section(html.Div(id="xrd-result-candidate-cards", className="mb-0"), role="support"), + _xrd_result_section(html.Div(id="xrd-result-table", className="mb-0"), role="support"), + _xrd_result_section(html.Div(id="xrd-result-processing", className="mb-0"), role="support"), + _xrd_result_section(html.Div(id="xrd-result-raw-metadata", className="mb-0"), role="support"), + _xrd_result_section( + build_literature_compare_card( + id_prefix="xrd", + class_name="xrd-literature-card mb-0 border-0 shadow-none bg-transparent", + compact_toolbar=True, + ), + role="secondary", + ), + ], + md=8, + className="ms-results-surface", + ), + ] + ), + ], + className="xrd-page", +) + +# --- Callbacks: locale / tabs / guide --- + + +@callback( + Output("xrd-hero-slot", "children"), + Output("xrd-dataset-card-title", "children"), + Output("xrd-workflow-card-title", "children"), + Output("xrd-execute-card-title", "children"), + Output("xrd-run-btn", "children"), + Output("xrd-template-select", "options"), + Output("xrd-template-select", "value"), + Output("xrd-template-description", "children"), + Input("ui-locale", "data"), + Input("xrd-template-select", "value"), +) +def render_xrd_locale_chrome(locale_data, template_id): + loc = _loc(locale_data) + hero = page_header( + translate_ui(loc, "dash.analysis.xrd.title"), + translate_ui(loc, "dash.analysis.xrd.caption"), + badge=translate_ui(loc, "dash.analysis.badge"), + ) + opts = [{"label": translate_ui(loc, f"dash.analysis.xrd.template.{tid}.label"), "value": tid} for tid in _XRD_TEMPLATE_IDS] + valid = {o["value"] for o in opts} + tid = template_id if template_id in valid else "xrd.general" + desc_key = f"dash.analysis.xrd.template.{tid}.desc" + desc = translate_ui(loc, desc_key) + if desc == desc_key: + desc = translate_ui(loc, "dash.analysis.xrd.workflow_fallback") + return ( + hero, + translate_ui(loc, "dash.analysis.dataset_selection_title"), + translate_ui(loc, "dash.analysis.workflow_template_title"), + translate_ui(loc, "dash.analysis.execute_title"), + translate_ui(loc, "dash.analysis.xrd.run_btn"), + opts, + tid, + desc, + ) + + +@callback( + Output("xrd-tab-setup-shell", "label"), + Output("xrd-tab-processing-shell", "label"), + Output("xrd-tab-run-shell", "label"), + Input("ui-locale", "data"), +) +def render_xrd_tab_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.xrd.tab.setup"), + translate_ui(loc, "dash.analysis.xrd.tab.processing"), + translate_ui(loc, "dash.analysis.xrd.tab.run"), + ) + + +@callback( + Output("xrd-workflow-guide-title", "children"), + Output("xrd-workflow-guide-body", "children"), + Input("ui-locale", "data"), +) +def render_xrd_workflow_guide_chrome(locale_data): + loc = _loc(locale_data) + pfx = "dash.analysis.xrd.workflow_guide" + body = html.Div( + [ + html.P(translate_ui(loc, f"{pfx}.intro"), className="mb-2"), + html.Ul( + [ + html.Li(translate_ui(loc, f"{pfx}.step1"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step2"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step3"), className="mb-1"), + html.Li(translate_ui(loc, f"{pfx}.step4"), className="mb-0"), + ], + className="ps-3 mb-0", + ), + ] + ) + return translate_ui(loc, f"{pfx}.title"), body + + +# --- Dataset + setup review --- + + +@callback( + Output("xrd-dataset-selector-area", "children"), + Output("xrd-run-btn", "disabled"), + Input("project-id", "data"), + Input("xrd-refresh", "data"), + Input("ui-locale", "data"), +) +def load_eligible_datasets(project_id, _refresh, locale_data): + loc = _loc(locale_data) + if not project_id: + return html.P(translate_ui(loc, "dash.analysis.workspace_inactive"), className="text-muted"), True + from dash_app.api_client import workspace_datasets + + try: + payload = workspace_datasets(project_id) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_datasets", error=str(exc)), color="danger"), True + all_datasets = payload.get("datasets", []) + return dataset_selector_block( + selector_id="xrd-dataset-select", + empty_msg=translate_ui(loc, "dash.analysis.xrd.empty_import"), + eligible=eligible_datasets(all_datasets, _XRD_ELIGIBLE_TYPES), + all_datasets=all_datasets, + eligible_types=_XRD_ELIGIBLE_TYPES, + active_dataset=payload.get("active_dataset"), + locale_data=locale_data, + ), False + + +@callback( + Output("xrd-setup-review-title", "children"), + Output("xrd-setup-review-hint", "children"), + Output("xrd-review-axis-ok", "label"), + Output("xrd-review-wavelength-label", "children"), + Input("ui-locale", "data"), +) +def render_xrd_setup_review_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.xrd.setup.review_title"), + translate_ui(loc, "dash.analysis.xrd.setup.review_hint"), + translate_ui(loc, "dash.analysis.xrd.setup.axis_confirm"), + translate_ui(loc, "dash.analysis.xrd.setup.wavelength_label"), + ) + + +@callback( + Output("xrd-review-wavelength", "value"), + Output("xrd-review-axis-ok", "value"), + Output("xrd-setup-import-warnings", "children"), + Output("xrd-setup-validation", "children"), + Input("project-id", "data"), + Input("xrd-dataset-select", "value"), + Input("ui-locale", "data"), +) +def hydrate_xrd_setup_from_dataset(project_id, dataset_key, locale_data): + loc = _loc(locale_data) + if not project_id or not dataset_key: + return None, False, "", "" + from dash_app.api_client import workspace_dataset_detail + + try: + detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + return None, False, "", "" + meta = detail.get("metadata") or {} + wl = meta.get("xrd_wavelength_angstrom") + try: + wl_out = float(wl) if wl not in (None, "") else None + except (TypeError, ValueError): + wl_out = None + axis_ok = not bool(meta.get("xrd_axis_mapping_review_required")) + warns = meta.get("import_warnings") if isinstance(meta.get("import_warnings"), list) else [] + warn_children: list = [] + if warns: + warn_children = [ + html.Strong(translate_ui(loc, "dash.analysis.xrd.setup.import_warnings"), className="d-block mb-1"), + html.Ul([html.Li(str(w), className="small") for w in warns], className="mb-0 ps-3"), + ] + val = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + val_children: Any = "" + if val: + st = str(val.get("status") or "") + issues = val.get("issues") if isinstance(val.get("issues"), list) else [] + warns_v = val.get("warnings") if isinstance(val.get("warnings"), list) else [] + if issues or warns_v or st: + parts = [html.Strong(translate_ui(loc, "dash.analysis.xrd.setup.validation_hint"), className="d-block mb-1")] + parts.append(html.Span(f"{translate_ui(loc, 'dash.analysis.xrd.quality.status_label')}: {st}", className="small d-block")) + if issues: + parts.append(html.Ul([html.Li(str(i), className="small text-danger") for i in issues], className="mb-1 ps-3")) + if warns_v: + parts.append(html.Ul([html.Li(str(w), className="small text-warning") for w in warns_v], className="mb-0 ps-3")) + val_children = html.Div(parts) + return wl_out, axis_ok, (html.Div(warn_children) if warn_children else ""), val_children + + +# --- Template switching --- + + +@callback( + Output("xrd-processing-default", "data"), + Output("xrd-processing-draft", "data", allow_duplicate=True), + Output("xrd-processing-undo-stack", "data", allow_duplicate=True), + Output("xrd-processing-redo-stack", "data", allow_duplicate=True), + Output("xrd-history-hydrate", "data", allow_duplicate=True), + Input("xrd-template-select", "value"), + State("xrd-processing-draft", "data"), + State("xrd-processing-undo-stack", "data"), + State("xrd-processing-redo-stack", "data"), + State("xrd-history-hydrate", "data"), + prevent_initial_call=True, +) +def xrd_on_template_change(template_id, draft, undo_stack, redo_stack, hist_hydrate): + tid = template_id if template_id in _XRD_TEMPLATE_IDS else "xrd.general" + new_default = copy.deepcopy(default_xrd_draft_for_template(tid)) + old_norm = normalize_xrd_processing_draft(draft) + new_norm = normalize_xrd_processing_draft(new_default) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return new_default, new_norm, past2, fut2, int(hist_hydrate or 0) + 1 + + +# --- Presets (Raman pattern) --- + + +@callback( + Output("xrd-preset-card-title", "children"), + Output("xrd-preset-help", "children"), + Output("xrd-preset-select-label", "children"), + Output("xrd-preset-load-btn", "children"), + Output("xrd-preset-delete-btn", "children"), + Output("xrd-preset-save-name-label", "children"), + Output("xrd-preset-save-name", "placeholder"), + Output("xrd-preset-save-btn", "children"), + Output("xrd-preset-saveas-btn", "children"), + Output("xrd-preset-save-hint", "children"), + Input("ui-locale", "data"), +) +def render_xrd_preset_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.xrd.presets.title"), + translate_ui(loc, "dash.analysis.xrd.presets.help.overview"), + translate_ui(loc, "dash.analysis.xrd.presets.select_label"), + translate_ui(loc, "dash.analysis.xrd.presets.load_btn"), + translate_ui(loc, "dash.analysis.xrd.presets.delete_btn"), + translate_ui(loc, "dash.analysis.xrd.presets.save_name_label"), + translate_ui(loc, "dash.analysis.xrd.presets.save_name_placeholder"), + translate_ui(loc, "dash.analysis.xrd.presets.save_btn"), + translate_ui(loc, "dash.analysis.xrd.presets.saveas_btn"), + translate_ui(loc, "dash.analysis.xrd.presets.save_hint"), + ) + + +@callback( + Output("xrd-preset-select", "options"), + Output("xrd-preset-caption", "children"), + Input("xrd-preset-refresh", "data"), + Input("ui-locale", "data"), +) +def refresh_xrd_preset_options(_refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + try: + payload = api_client.list_analysis_presets(_XRD_PRESET_ANALYSIS_TYPE) + except Exception as exc: + message = translate_ui(loc, "dash.analysis.xrd.presets.list_failed").format(error=str(exc)) + return [], message + presets = payload.get("presets") or [] + options = [ + {"label": item.get("preset_name", ""), "value": item.get("preset_name", "")} + for item in presets + if isinstance(item, dict) and item.get("preset_name") + ] + caption = translate_ui(loc, "dash.analysis.xrd.presets.caption").format( + analysis_type=payload.get("analysis_type", _XRD_PRESET_ANALYSIS_TYPE), + count=int(payload.get("count", len(options)) or 0), + max_count=int(payload.get("max_count", 10) or 10), + ) + return options, caption + + +@callback( + Output("xrd-preset-load-btn", "disabled"), + Output("xrd-preset-delete-btn", "disabled"), + Output("xrd-preset-save-btn", "disabled"), + Input("xrd-preset-select", "value"), +) +def toggle_xrd_preset_action_buttons(selected_name): + has_selection = bool(str(selected_name or "").strip()) + return (not has_selection, not has_selection, not has_selection) + + +@callback( + Output("xrd-processing-draft", "data", allow_duplicate=True), + Output("xrd-template-select", "value", allow_duplicate=True), + Output("xrd-preset-status", "children", allow_duplicate=True), + Output("xrd-preset-hydrate", "data", allow_duplicate=True), + Output("xrd-preset-loaded-name", "data", allow_duplicate=True), + Output("xrd-preset-snapshot", "data", allow_duplicate=True), + Output("xrd-left-tabs", "active_tab", allow_duplicate=True), + Output("xrd-processing-undo-stack", "data", allow_duplicate=True), + Output("xrd-processing-redo-stack", "data", allow_duplicate=True), + Input("xrd-preset-load-btn", "n_clicks"), + State("xrd-preset-select", "value"), + State("xrd-preset-hydrate", "data"), + State("xrd-processing-draft", "data"), + State("xrd-processing-undo-stack", "data"), + State("xrd-processing-redo-stack", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def apply_xrd_preset(n_clicks, selected_name, hydrate_val, current_draft, undo_stack, redo_stack, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return (dash.no_update,) * 9 + try: + payload = api_client.load_analysis_preset(_XRD_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return ( + dash.no_update, + dash.no_update, + translate_ui(loc, "dash.analysis.xrd.presets.load_failed").format(error=str(exc)), + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + dash.no_update, + ) + processing = dict(payload.get("processing") or {}) + draft = xrd_draft_from_loaded_processing(processing) + template_id_raw = str(payload.get("workflow_template_id") or "").strip() + template_out = template_id_raw if template_id_raw in _XRD_TEMPLATE_IDS else dash.no_update + resolved_tid = template_id_raw if template_id_raw in _XRD_TEMPLATE_IDS else "xrd.general" + snap = xrd_ui_snapshot_dict(resolved_tid, draft) + status = translate_ui(loc, "dash.analysis.xrd.presets.loaded").format(preset=name) + old_norm = normalize_xrd_processing_draft(current_draft) + new_norm = normalize_xrd_processing_draft(draft) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return ( + draft, + template_out, + status, + int(hydrate_val or 0) + 1, + name, + snap, + "xrd-tab-run", + past2, + fut2, + ) + + +@callback( + Output("xrd-preset-refresh", "data", allow_duplicate=True), + Output("xrd-preset-save-name", "value", allow_duplicate=True), + Output("xrd-preset-status", "children", allow_duplicate=True), + Output("xrd-preset-snapshot", "data", allow_duplicate=True), + Output("xrd-left-tabs", "active_tab", allow_duplicate=True), + Input("xrd-preset-save-btn", "n_clicks"), + Input("xrd-preset-saveas-btn", "n_clicks"), + State("xrd-preset-select", "value"), + State("xrd-preset-save-name", "value"), + State("xrd-processing-draft", "data"), + State("xrd-template-select", "value"), + State("xrd-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def save_xrd_preset(n_save, n_saveas, selected_name, save_name, draft, template_id, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + if trig == "xrd-preset-save-btn": + name = str(selected_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.xrd.presets.select_required"), dash.no_update, dash.no_update + clear_name = dash.no_update + elif trig == "xrd-preset-saveas-btn": + name = str(save_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.xrd.presets.save_name_required"), dash.no_update, dash.no_update + clear_name = "" + else: + raise dash.exceptions.PreventUpdate + processing_body = xrd_preset_processing_body_for_save(draft) + try: + response = api_client.save_analysis_preset( + _XRD_PRESET_ANALYSIS_TYPE, + name, + workflow_template_id=str(template_id or "").strip() or None, + processing=processing_body, + ) + except Exception as exc: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.xrd.presets.save_failed").format(error=str(exc)), dash.no_update, dash.no_update + resolved_template = str(response.get("workflow_template_id") or template_id or "") + snap = xrd_ui_snapshot_dict(str(template_id or "").strip() or None, draft) + status = translate_ui(loc, "dash.analysis.xrd.presets.saved").format(preset=name, template=resolved_template) + return int(refresh_token or 0) + 1, clear_name, status, snap, "xrd-tab-run" + + +@callback( + Output("xrd-preset-refresh", "data", allow_duplicate=True), + Output("xrd-preset-select", "value", allow_duplicate=True), + Output("xrd-preset-status", "children", allow_duplicate=True), + Output("xrd-preset-loaded-name", "data", allow_duplicate=True), + Output("xrd-preset-snapshot", "data", allow_duplicate=True), + Input("xrd-preset-delete-btn", "n_clicks"), + State("xrd-preset-select", "value"), + State("xrd-preset-loaded-name", "data"), + State("xrd-preset-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def delete_xrd_preset(n_clicks, selected_name, loaded_name, refresh_token, locale_data): + from dash_app import api_client + + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + name = str(selected_name or "").strip() + if not name: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.xrd.presets.select_required"), dash.no_update, dash.no_update + try: + api_client.delete_analysis_preset(_XRD_PRESET_ANALYSIS_TYPE, name) + except Exception as exc: + return dash.no_update, dash.no_update, translate_ui(loc, "dash.analysis.xrd.presets.delete_failed").format(error=str(exc)), dash.no_update, dash.no_update + status = translate_ui(loc, "dash.analysis.xrd.presets.deleted").format(preset=name) + loaded = str(loaded_name or "").strip() + if loaded == name: + return int(refresh_token or 0) + 1, None, status, "", None + return int(refresh_token or 0) + 1, None, status, dash.no_update, dash.no_update + + +@callback( + Output("xrd-preset-loaded-line", "children"), + Input("xrd-preset-loaded-name", "data"), + Input("ui-locale", "data"), +) +def render_xrd_preset_loaded_line(loaded_name, locale_data): + loc = _loc(locale_data) + name = str(loaded_name or "").strip() + if not name: + return "" + return translate_ui(loc, "dash.analysis.xrd.presets.loaded_line").format(preset=name) + + +@callback( + Output("xrd-preset-dirty-flag", "children"), + Input("ui-locale", "data"), + Input("xrd-template-select", "value"), + Input("xrd-processing-draft", "data"), + State("xrd-preset-snapshot", "data"), +) +def render_xrd_preset_dirty_flag(locale_data, template_id, draft, snapshot): + loc = _loc(locale_data) + if not isinstance(snapshot, dict): + return html.Span(translate_ui(loc, "dash.analysis.xrd.presets.dirty_no_baseline"), className="text-muted") + current = xrd_ui_snapshot_dict(template_id, draft) + if xrd_snapshots_equal(snapshot, current): + return html.Span(translate_ui(loc, "dash.analysis.xrd.presets.clean"), className="text-success") + return html.Span(translate_ui(loc, "dash.analysis.xrd.presets.dirty"), className="text-warning") + + +# --- Processing chrome, hydrate, sync, history, run --- + + +@callback( + Output("xrd-processing-history-title", "children"), + Output("xrd-processing-history-hint", "children"), + Output("xrd-processing-undo-btn", "children"), + Output("xrd-processing-redo-btn", "children"), + Output("xrd-processing-reset-btn", "children"), + Input("ui-locale", "data"), +) +def render_xrd_processing_history_chrome(locale_data): + loc = _loc(locale_data) + return ( + translate_ui(loc, "dash.analysis.xrd.processing.history_title"), + translate_ui(loc, "dash.analysis.xrd.processing.history_hint"), + translate_ui(loc, "dash.analysis.xrd.processing.undo_btn"), + translate_ui(loc, "dash.analysis.xrd.processing.redo_btn"), + translate_ui(loc, "dash.analysis.xrd.processing.reset_btn"), + ) + + +@callback( + Output("xrd-axis-card-title", "children"), + Output("xrd-axis-card-hint", "children"), + Output("xrd-axis-sort", "label"), + Output("xrd-axis-dedup-label", "children"), + Output("xrd-axis-min-label", "children"), + Output("xrd-axis-max-label", "children"), + Output("xrd-smooth-card-title", "children"), + Output("xrd-smooth-card-hint", "children"), + Output("xrd-smooth-method-label", "children"), + Output("xrd-smooth-window-label", "children"), + Output("xrd-smooth-poly-label", "children"), + Output("xrd-smooth-method", "options"), + Output("xrd-baseline-card-title", "children"), + Output("xrd-baseline-card-hint", "children"), + Output("xrd-baseline-method-label", "children"), + Output("xrd-baseline-window-label", "children"), + Output("xrd-baseline-smooth-label", "children"), + Output("xrd-baseline-method", "options"), + Output("xrd-peak-card-title", "children"), + Output("xrd-peak-card-hint", "children"), + Output("xrd-peak-prom-label", "children"), + Output("xrd-peak-dist-label", "children"), + Output("xrd-peak-width-label", "children"), + Output("xrd-peak-max-label", "children"), + Output("xrd-match-card-title", "children"), + Output("xrd-match-card-hint", "children"), + Output("xrd-match-metric-label", "children"), + Output("xrd-match-tol-label", "children"), + Output("xrd-match-topn-label", "children"), + Output("xrd-match-min-label", "children"), + Output("xrd-match-iw-label", "children"), + Output("xrd-match-maj-label", "children"), + Output("xrd-plot-advanced-summary", "children"), + Output("xrd-plot-advanced-hint", "children"), + Output("xrd-plot-intermediate-label", "children"), + Output("xrd-plot-advanced-title", "children"), + Output("xrd-plot-density-label", "children"), + Output("xrd-plot-maxlab-label", "children"), + Output("xrd-plot-minratio-label", "children"), + Output("xrd-plot-msize-label", "children"), + Output("xrd-plot-pospr-label", "children"), + Output("xrd-plot-intpr-label", "children"), + Output("xrd-plot-style-label", "children"), + Output("xrd-plot-style", "options"), + Output("xrd-plot-xmin-label", "children"), + Output("xrd-plot-xmax-label", "children"), + Output("xrd-plot-ymin-label", "children"), + Output("xrd-plot-ymax-label", "children"), + Output("xrd-plot-lw-label", "children"), + Output("xrd-plot-labels", "label"), + Output("xrd-plot-matched", "label"), + Output("xrd-plot-uobs", "label"), + Output("xrd-plot-uref", "label"), + Output("xrd-plot-conn", "label"), + Output("xrd-plot-mlab", "label"), + Output("xrd-plot-xlock", "label"), + Output("xrd-plot-ylock", "label"), + Output("xrd-plot-logy", "label"), + Input("ui-locale", "data"), +) +def render_xrd_processing_cards_chrome(locale_data): + loc = _loc(locale_data) + smooth_opts = [ + {"label": translate_ui(loc, "dash.analysis.xrd.smoothing.savgol"), "value": "savgol"}, + {"label": translate_ui(loc, "dash.analysis.xrd.smoothing.moving_average"), "value": "moving_average"}, + ] + bl_opts = [ + {"label": translate_ui(loc, "dash.analysis.xrd.baseline.rolling"), "value": "rolling_minimum"}, + {"label": translate_ui(loc, "dash.analysis.xrd.baseline.linear"), "value": "linear"}, + ] + style_opts = [ + {"label": translate_ui(loc, "dash.analysis.xrd.plot.style.color_shape"), "value": "color_shape"}, + {"label": translate_ui(loc, "dash.analysis.xrd.plot.style.color_only"), "value": "color_only"}, + {"label": translate_ui(loc, "dash.analysis.xrd.plot.style.shape_only"), "value": "shape_only"}, + ] + return ( + translate_ui(loc, "dash.analysis.xrd.axis.card_title"), + translate_ui(loc, "dash.analysis.xrd.axis.hint"), + translate_ui(loc, "dash.analysis.xrd.axis.sort"), + translate_ui(loc, "dash.analysis.xrd.axis.dedup"), + translate_ui(loc, "dash.analysis.xrd.axis.min"), + translate_ui(loc, "dash.analysis.xrd.axis.max"), + translate_ui(loc, "dash.analysis.xrd.smoothing.card_title"), + translate_ui(loc, "dash.analysis.xrd.smoothing.hint"), + translate_ui(loc, "dash.analysis.xrd.smoothing.method"), + translate_ui(loc, "dash.analysis.xrd.smoothing.window"), + translate_ui(loc, "dash.analysis.xrd.smoothing.polyorder"), + smooth_opts, + translate_ui(loc, "dash.analysis.xrd.baseline.card_title"), + translate_ui(loc, "dash.analysis.xrd.baseline.hint"), + translate_ui(loc, "dash.analysis.xrd.baseline.method"), + translate_ui(loc, "dash.analysis.xrd.baseline.window"), + translate_ui(loc, "dash.analysis.xrd.baseline.smoothing_window"), + bl_opts, + translate_ui(loc, "dash.analysis.xrd.peak.card_title"), + translate_ui(loc, "dash.analysis.xrd.peak.hint"), + translate_ui(loc, "dash.analysis.xrd.peak.prominence"), + translate_ui(loc, "dash.analysis.xrd.peak.distance"), + translate_ui(loc, "dash.analysis.xrd.peak.width"), + translate_ui(loc, "dash.analysis.xrd.peak.max_peaks"), + translate_ui(loc, "dash.analysis.xrd.match.card_title"), + translate_ui(loc, "dash.analysis.xrd.match.hint"), + translate_ui(loc, "dash.analysis.xrd.match.metric"), + translate_ui(loc, "dash.analysis.xrd.match.tolerance"), + translate_ui(loc, "dash.analysis.xrd.match.top_n"), + translate_ui(loc, "dash.analysis.xrd.match.minimum_score"), + translate_ui(loc, "dash.analysis.xrd.match.intensity_weight"), + translate_ui(loc, "dash.analysis.xrd.match.major_fraction"), + translate_ui(loc, "dash.analysis.xrd.plot.advanced_section"), + translate_ui(loc, "dash.analysis.xrd.plot.advanced_hint"), + translate_ui(loc, "dash.analysis.xrd.plot.show_intermediate"), + translate_ui(loc, "dash.analysis.xrd.plot.advanced_title"), + translate_ui(loc, "dash.analysis.xrd.plot.label_density"), + translate_ui(loc, "dash.analysis.xrd.plot.max_labels"), + translate_ui(loc, "dash.analysis.xrd.plot.min_intensity_ratio"), + translate_ui(loc, "dash.analysis.xrd.plot.marker_size"), + translate_ui(loc, "dash.analysis.xrd.plot.label_pos_precision"), + translate_ui(loc, "dash.analysis.xrd.plot.label_int_precision"), + translate_ui(loc, "dash.analysis.xrd.plot.style_preset"), + style_opts, + translate_ui(loc, "dash.analysis.xrd.plot.x_min"), + translate_ui(loc, "dash.analysis.xrd.plot.x_max"), + translate_ui(loc, "dash.analysis.xrd.plot.y_min"), + translate_ui(loc, "dash.analysis.xrd.plot.y_max"), + translate_ui(loc, "dash.analysis.xrd.plot.line_width"), + translate_ui(loc, "dash.analysis.xrd.plot.show_peak_labels"), + translate_ui(loc, "dash.analysis.xrd.plot.show_matched"), + translate_ui(loc, "dash.analysis.xrd.plot.show_unmatched_obs"), + translate_ui(loc, "dash.analysis.xrd.plot.show_unmatched_ref"), + translate_ui(loc, "dash.analysis.xrd.plot.show_connectors"), + translate_ui(loc, "dash.analysis.xrd.plot.show_match_labels"), + translate_ui(loc, "dash.analysis.xrd.plot.x_lock"), + translate_ui(loc, "dash.analysis.xrd.plot.y_lock"), + translate_ui(loc, "dash.analysis.xrd.plot.log_y"), + ) + + +@callback( + Output("xrd-smooth-poly", "disabled"), + Input("xrd-smooth-method", "value"), +) +def toggle_xrd_smooth_poly(method): + return str(method or "").strip().lower() != "savgol" + + +@callback( + Output("xrd-baseline-window", "disabled"), + Output("xrd-baseline-smooth", "disabled"), + Input("xrd-baseline-method", "value"), +) +def toggle_xrd_baseline_windows(method): + off = str(method or "").strip().lower() != "rolling_minimum" + return off, off + + +@callback( + Output("xrd-axis-sort", "value"), + Output("xrd-axis-dedup", "value"), + Output("xrd-axis-min", "value"), + Output("xrd-axis-max", "value"), + Output("xrd-smooth-method", "value"), + Output("xrd-smooth-window", "value"), + Output("xrd-smooth-poly", "value"), + Output("xrd-baseline-method", "value"), + Output("xrd-baseline-window", "value"), + Output("xrd-baseline-smooth", "value"), + Output("xrd-peak-prom", "value"), + Output("xrd-peak-dist", "value"), + Output("xrd-peak-width", "value"), + Output("xrd-peak-max", "value"), + Output("xrd-match-metric", "value"), + Output("xrd-match-tol", "value"), + Output("xrd-match-topn", "value"), + Output("xrd-match-min", "value"), + Output("xrd-match-iw", "value"), + Output("xrd-match-maj", "value"), + Output("xrd-plot-labels", "value"), + Output("xrd-plot-matched", "value"), + Output("xrd-plot-uobs", "value"), + Output("xrd-plot-uref", "value"), + Output("xrd-plot-conn", "value"), + Output("xrd-plot-mlab", "value"), + Output("xrd-plot-density", "value"), + Output("xrd-plot-maxlab", "value"), + Output("xrd-plot-minratio", "value"), + Output("xrd-plot-msize", "value"), + Output("xrd-plot-pospr", "value"), + Output("xrd-plot-intpr", "value"), + Output("xrd-plot-style", "value"), + Output("xrd-plot-xlock", "value"), + Output("xrd-plot-xmin", "value"), + Output("xrd-plot-xmax", "value"), + Output("xrd-plot-ylock", "value"), + Output("xrd-plot-ymin", "value"), + Output("xrd-plot-ymax", "value"), + Output("xrd-plot-logy", "value"), + Output("xrd-plot-lw", "value"), + Output("xrd-plot-intermediate", "value"), + Input("xrd-preset-hydrate", "data"), + Input("xrd-history-hydrate", "data"), + State("xrd-processing-draft", "data"), +) +def hydrate_xrd_processing_controls(_p, _h, draft): + d = normalize_xrd_processing_draft(draft) + ax = d["axis_normalization"] + sm = d["smoothing"] + bl = d["baseline"] + pk = d["peak_detection"] + mc = d["method_context"] + ps = mc.get("xrd_plot_settings") if isinstance(mc.get("xrd_plot_settings"), dict) else {} + return ( + bool(ax.get("sort_axis", True)), + str(ax.get("deduplicate") or "first"), + ax.get("axis_min"), + ax.get("axis_max"), + str(sm.get("method") or "savgol"), + int(sm.get("window_length", 11)), + int(sm.get("polyorder", 3)), + str(bl.get("method") or "rolling_minimum"), + int(bl.get("window_length", 31)), + int(bl.get("smoothing_window", 9)), + float(pk.get("prominence", 0.08)), + int(pk.get("distance", 6)), + int(pk.get("width", 2)), + int(pk.get("max_peaks", 12)), + str(mc.get("xrd_match_metric") or "peak_overlap_weighted"), + float(mc.get("xrd_match_tolerance_deg", 0.28)), + int(mc.get("xrd_match_top_n", 5)), + float(mc.get("xrd_match_minimum_score", 0.42)), + float(mc.get("xrd_match_intensity_weight", 0.35)), + float(mc.get("xrd_match_major_peak_fraction", 0.4)), + bool(ps.get("show_peak_labels", True)), + bool(ps.get("show_matched_peaks", False)), + bool(ps.get("show_unmatched_observed", False)), + bool(ps.get("show_unmatched_reference", False)), + bool(ps.get("show_match_connectors", False)), + bool(ps.get("show_match_labels", False)), + str(ps.get("label_density_mode") or "smart"), + int(ps.get("max_labels", 10)), + float(ps.get("min_label_intensity_ratio", 0.12)), + int(ps.get("marker_size", 8)), + int(ps.get("label_position_precision", 2)), + int(ps.get("label_intensity_precision", 0)), + str(ps.get("style_preset") or "color_shape"), + bool(ps.get("x_range_enabled", False)), + ps.get("x_min"), + ps.get("x_max"), + bool(ps.get("y_range_enabled", False)), + ps.get("y_min"), + ps.get("y_max"), + bool(ps.get("log_y", False)), + float(ps.get("line_width", 2.0)), + bool(ps.get("show_intermediate_traces", False)), + ) + + +@callback( + Output("xrd-processing-draft", "data", allow_duplicate=True), + Output("xrd-processing-undo-stack", "data", allow_duplicate=True), + Output("xrd-processing-redo-stack", "data", allow_duplicate=True), + Input("xrd-axis-sort", "value"), + Input("xrd-axis-dedup", "value"), + Input("xrd-axis-min", "value"), + Input("xrd-axis-max", "value"), + Input("xrd-smooth-method", "value"), + Input("xrd-smooth-window", "value"), + Input("xrd-smooth-poly", "value"), + Input("xrd-baseline-method", "value"), + Input("xrd-baseline-window", "value"), + Input("xrd-baseline-smooth", "value"), + Input("xrd-peak-prom", "value"), + Input("xrd-peak-dist", "value"), + Input("xrd-peak-width", "value"), + Input("xrd-peak-max", "value"), + Input("xrd-match-metric", "value"), + Input("xrd-match-tol", "value"), + Input("xrd-match-topn", "value"), + Input("xrd-match-min", "value"), + Input("xrd-match-iw", "value"), + Input("xrd-match-maj", "value"), + Input("xrd-review-axis-ok", "value"), + Input("xrd-review-wavelength", "value"), + Input("xrd-plot-labels", "value"), + Input("xrd-plot-matched", "value"), + Input("xrd-plot-uobs", "value"), + Input("xrd-plot-uref", "value"), + Input("xrd-plot-conn", "value"), + Input("xrd-plot-mlab", "value"), + Input("xrd-plot-density", "value"), + Input("xrd-plot-maxlab", "value"), + Input("xrd-plot-minratio", "value"), + Input("xrd-plot-msize", "value"), + Input("xrd-plot-pospr", "value"), + Input("xrd-plot-intpr", "value"), + Input("xrd-plot-style", "value"), + Input("xrd-plot-xlock", "value"), + Input("xrd-plot-xmin", "value"), + Input("xrd-plot-xmax", "value"), + Input("xrd-plot-ylock", "value"), + Input("xrd-plot-ymin", "value"), + Input("xrd-plot-ymax", "value"), + Input("xrd-plot-logy", "value"), + Input("xrd-plot-lw", "value"), + Input("xrd-plot-intermediate", "value"), + State("xrd-processing-draft", "data"), + State("xrd-processing-undo-stack", "data"), + State("xrd-processing-redo-stack", "data"), + prevent_initial_call="initial_duplicate", +) +def sync_xrd_processing_draft( + axis_sort, + axis_dedup, + axis_min, + axis_max, + sm_m, + sm_w, + sm_p, + bl_m, + bl_w, + bl_sw, + pk_pr, + pk_di, + pk_wi, + pk_mx, + mm_met, + mm_tol, + mm_tn, + mm_mi, + mm_iw, + mm_mj, + rev_ax, + rev_wl, + pl_lab, + pl_ma, + pl_uo, + pl_ur, + pl_co, + pl_ml, + pl_den, + pl_mxlb, + pl_mnr, + pl_ms, + pl_pp, + pl_ip, + pl_st, + pl_xe, + pl_xa, + pl_xb, + pl_ye, + pl_ya, + pl_yb, + pl_log, + pl_lw, + pl_intermediate, + prev_draft, + undo_stack, + redo_stack, +): + new_draft = xrd_draft_from_control_values( + axis_sort=axis_sort, + axis_dedup=axis_dedup, + axis_min=axis_min, + axis_max=axis_max, + sm_method=sm_m, + sm_window=sm_w, + sm_poly=sm_p, + bl_method=bl_m, + bl_window=bl_w, + bl_smooth_window=bl_sw, + pk_prom=pk_pr, + pk_dist=pk_di, + pk_width=pk_wi, + pk_max=pk_mx, + match_metric=mm_met, + match_tol=mm_tol, + match_top_n=mm_tn, + match_min_score=mm_mi, + match_iw=mm_iw, + match_maj=mm_mj, + review_axis_ok=rev_ax, + review_wavelength=rev_wl, + plot_show_labels=pl_lab, + plot_matched=pl_ma, + plot_u_obs=pl_uo, + plot_u_ref=pl_ur, + plot_conn=pl_co, + plot_m_lbl=pl_ml, + plot_density=pl_den, + plot_max_labels=pl_mxlb, + plot_min_ratio=pl_mnr, + plot_msize=pl_ms, + plot_pos_prec=pl_pp, + plot_int_prec=pl_ip, + plot_style=pl_st, + plot_x_en=pl_xe, + plot_x_min=pl_xa, + plot_x_max=pl_xb, + plot_y_en=pl_ye, + plot_y_min=pl_ya, + plot_y_max=pl_yb, + plot_log_y=pl_log, + plot_lw=pl_lw, + plot_show_intermediate=bool(pl_intermediate), + ) + old_norm = normalize_xrd_processing_draft(prev_draft) + new_norm = normalize_xrd_processing_draft(new_draft) + past2, fut2 = append_undo_after_edit(undo_stack, redo_stack, old_norm, new_norm) + return new_norm, past2, fut2 + + +@callback( + Output("xrd-processing-draft", "data", allow_duplicate=True), + Output("xrd-processing-undo-stack", "data", allow_duplicate=True), + Output("xrd-processing-redo-stack", "data", allow_duplicate=True), + Output("xrd-history-hydrate", "data", allow_duplicate=True), + Output("xrd-history-status", "children", allow_duplicate=True), + Input("xrd-processing-undo-btn", "n_clicks"), + Input("xrd-processing-redo-btn", "n_clicks"), + Input("xrd-processing-reset-btn", "n_clicks"), + State("xrd-processing-draft", "data"), + State("xrd-processing-undo-stack", "data"), + State("xrd-processing-redo-stack", "data"), + State("xrd-history-hydrate", "data"), + State("xrd-processing-default", "data"), + State("xrd-template-select", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def xrd_processing_history_actions(n_undo, n_redo, n_reset, draft, undo_stack, redo_stack, hist_hydrate, defaults, template_id, locale_data): + loc = _loc(locale_data) + ctx = dash.callback_context + if not ctx.triggered: + raise dash.exceptions.PreventUpdate + trig = ctx.triggered_id + cur = normalize_xrd_processing_draft(draft) + past = undo_stack or [] + fut = redo_stack or [] + h = int(hist_hydrate or 0) + if trig == "xrd-processing-undo-btn": + if not n_undo: + raise dash.exceptions.PreventUpdate + res = perform_undo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + prev, pl, fl = res + return prev, pl, fl, h + 1, translate_ui(loc, "dash.analysis.xrd.processing.history_status_undo") + if trig == "xrd-processing-redo-btn": + if not n_redo: + raise dash.exceptions.PreventUpdate + res = perform_redo(past, fut, cur) + if res is None: + raise dash.exceptions.PreventUpdate + nxt, pl, fl = res + return nxt, pl, fl, h + 1, translate_ui(loc, "dash.analysis.xrd.processing.history_status_redo") + if trig == "xrd-processing-reset-btn": + if not n_reset: + raise dash.exceptions.PreventUpdate + tid = template_id if template_id in _XRD_TEMPLATE_IDS else "xrd.general" + base = copy.deepcopy(defaults) if isinstance(defaults, dict) and defaults else default_xrd_draft_for_template(tid) + default_draft = normalize_xrd_processing_draft(base) + if xrd_draft_processing_equal(cur, default_draft): + raise dash.exceptions.PreventUpdate + past_list = [copy.deepcopy(x) for x in past if isinstance(x, dict)] + past_list.append(copy.deepcopy(cur)) + if len(past_list) > MAX_XRD_UNDO_DEPTH: + past_list = past_list[-MAX_XRD_UNDO_DEPTH:] + return default_draft, past_list, [], h + 1, translate_ui(loc, "dash.analysis.xrd.processing.history_status_reset") + raise dash.exceptions.PreventUpdate + + +@callback( + Output("xrd-processing-undo-btn", "disabled"), + Output("xrd-processing-redo-btn", "disabled"), + Input("xrd-processing-undo-stack", "data"), + Input("xrd-processing-redo-stack", "data"), +) +def toggle_xrd_processing_history_buttons(undo_stack, redo_stack): + u = undo_stack or [] + r = redo_stack or [] + return len(u) == 0, len(r) == 0 + + +@callback( + Output("xrd-run-status", "children"), + Output("xrd-refresh", "data", allow_duplicate=True), + Output("xrd-latest-result-id", "data", allow_duplicate=True), + Output("workspace-refresh", "data", allow_duplicate=True), + Input("xrd-run-btn", "n_clicks"), + State("project-id", "data"), + State("xrd-dataset-select", "value"), + State("xrd-template-select", "value"), + State("xrd-processing-draft", "data"), + State("xrd-refresh", "data"), + State("workspace-refresh", "data"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def run_xrd_analysis(n_clicks, project_id, dataset_key, template_id, processing_draft, refresh_val, global_refresh, locale_data): + loc = _loc(locale_data) + if not n_clicks or not project_id or not dataset_key: + raise dash.exceptions.PreventUpdate + from dash_app.api_client import analysis_run + + overrides = xrd_overrides_from_draft(processing_draft) + try: + result = analysis_run( + project_id=project_id, + dataset_key=dataset_key, + analysis_type="XRD", + workflow_template_id=template_id, + processing_overrides=overrides or None, + ) + except Exception as exc: + return dbc.Alert(translate_ui(loc, "dash.analysis.analysis_failed", error=str(exc)), color="danger"), dash.no_update, dash.no_update, dash.no_update + alert, saved, result_id = interpret_run_result(result, locale_data=locale_data) + refresh = (refresh_val or 0) + 1 + if saved: + return alert, refresh, result_id, (global_refresh or 0) + 1 + return alert, refresh, dash.no_update, dash.no_update + + +def _format_dataset_metadata_value(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, float): + if value != value: + return None + text = f"{value:g}" + else: + text = str(value).strip() + return text or None + + +def _xrd_ordered_figure_preview_keys(fa: dict) -> list[str]: + return ordered_figure_preview_keys(fa) + + +def _xrd_fetch_figure_preview_data_urls( + project_id: str, + result_id: str, + fa: dict, + *, + max_tiles: int, + max_edge: int = MAX_XRD_FIGURE_PREVIEW_MAX_EDGE, +) -> dict[str, str]: + """Fetch PNG bytes via authenticated API and return data URLs for inline ``html.Img``.""" + from dash_app.api_client import fetch_result_figure_png + + ordered = _xrd_ordered_figure_preview_keys(fa)[: max(0, int(max_tiles))] + out: dict[str, str] = {} + for label in ordered: + try: + raw = fetch_result_figure_png(project_id, result_id, label, max_edge=max_edge) + if not raw: + out[label] = "" + continue + out[label] = "data:image/png;base64," + base64.standard_b64encode(bytes(raw)).decode("ascii") + except Exception: + out[label] = "" + return out + + +def _build_xrd_figure_artifacts_panel( + figure_artifacts: dict | None, + loc: str, + *, + previews: dict[str, str] | None = None, +) -> html.Div: + return build_figure_artifacts_panel( + figure_artifacts, + loc, + previews=previews, + i18n_prefix="dash.analysis.xrd.figure", + class_prefix="xrd", + max_preview_tiles=MAX_XRD_FIGURE_PREVIEW_TILES, + ) + + +def _build_xrd_analysis_summary(dataset_detail: dict, summary: dict, result_meta: dict, loc: str, *, locale_data: str | None) -> html.Div: + metadata = (dataset_detail or {}).get("metadata") or {} + dataset_summary = (dataset_detail or {}).get("dataset") or {} + na = translate_ui(loc, "dash.analysis.na") + dataset_label = ( + _format_dataset_metadata_value(metadata.get("file_name")) + or _format_dataset_metadata_value(dataset_summary.get("display_name")) + or _format_dataset_metadata_value(result_meta.get("dataset_key")) + or na + ) + fallback_display_name = _format_dataset_metadata_value(dataset_summary.get("display_name")) + sample_label = resolve_sample_name(summary or {}, result_meta or {}, fallback_display_name=fallback_display_name, locale_data=locale_data) or na + instrument = _format_dataset_metadata_value(metadata.get("instrument")) or na + vendor = _format_dataset_metadata_value(metadata.get("vendor")) or na + + def _meta_value(value: str) -> html.Span: + return html.Span(value, className="ms-meta-value", title=value) + + dl_rows: list[Any] = [ + html.Dt(translate_ui(loc, "dash.analysis.xrd.summary.dataset_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(dataset_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.xrd.summary.sample_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(sample_label), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.xrd.summary.instrument_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(instrument), className="col-sm-8 ms-meta-def"), + html.Dt(translate_ui(loc, "dash.analysis.xrd.summary.vendor_label"), className="col-sm-4 text-muted ms-meta-term"), + html.Dd(_meta_value(vendor), className="col-sm-8 ms-meta-def"), + ] + return html.Div( + [ + html.H5(translate_ui(loc, "dash.analysis.xrd.summary.card_title"), className="mb-2"), + html.Dl(dl_rows, className="row mb-0"), + ] + ) + + +def _build_xrd_quality_card(detail: dict, result_meta: dict, loc: str) -> html.Details: + validation = detail.get("validation") if isinstance(detail.get("validation"), dict) else {} + status = str(validation.get("status") or result_meta.get("validation_status") or "unknown") + warnings_list = validation.get("warnings") if isinstance(validation.get("warnings"), list) else [] + issues_list = validation.get("issues") if isinstance(validation.get("issues"), list) else [] + wc, ic = finalized_validation_warning_issue_counts(validation) + status_token = status.strip().lower() + if status_token in {"ok", "pass", "valid"} and wc == 0 and ic == 0: + alert_color = "success" + elif ic == 0: + alert_color = "warning" + else: + alert_color = "danger" + body_children: list[Any] = [ + html.P([html.Strong(translate_ui(loc, "dash.analysis.xrd.quality.status_label")), f" {status}"], className="mb-2"), + html.P( + [ + html.Strong(translate_ui(loc, "dash.analysis.xrd.quality.warnings_label")), + f" {wc} · ", + html.Strong(translate_ui(loc, "dash.analysis.xrd.quality.issues_label")), + f" {ic}", + ], + className="mb-2 small", + ), + ] + if warnings_list: + body_children.append(html.H6(translate_ui(loc, "dash.analysis.xrd.setup.import_warnings"), className="small mt-2")) + body_children.append(html.Ul([html.Li(str(w), className="small") for w in warnings_list], className="ps-3 mb-2")) + if issues_list: + body_children.append(html.Ul([html.Li(str(i), className="small text-danger") for i in issues_list], className="ps-3 mb-2")) + checks = validation.get("checks") + if isinstance(checks, dict) and checks: + tech = html.Ul([html.Li(f"{k}: {v}", className="small") for k, v in checks.items()], className="mb-0 ps-3") + body_children.append( + _xrd_collapsible_section(loc, "dash.analysis.xrd.quality.checks_title", tech, open=False), + ) + badge_row = html.Div( + [ + dbc.Badge(f"{wc} warnings", color="warning", className="me-1") if wc else "", + dbc.Badge(f"{ic} issues", color="danger", className="me-1") if ic else "", + ], + className="mb-2", + ) + summary_line = html.Div( + [badge_row, html.Div(body_children, className="small")], + className="p-2 border rounded", + style={"borderColor": "#e5e7eb"}, + ) + return html.Details( + [ + html.Summary( + [ + html.Span(className="ta-details-chevron"), + html.Span(translate_ui(loc, "dash.analysis.xrd.quality.card_title"), className="ms-1"), + ], + className="ta-details-summary", + ), + html.Div(summary_line, className="ta-details-body mt-2"), + ], + className="ta-ms-details mb-0", + open=bool(wc or ic), + ) + + +def _build_xrd_raw_metadata_panel(metadata: dict | None, loc: str) -> html.Details: + meta = metadata if isinstance(metadata, dict) else {} + if not meta: + return _xrd_collapsible_section( + loc, + "dash.analysis.xrd.raw_metadata.card_title", + html.P(translate_ui(loc, "dash.analysis.xrd.raw_metadata.empty"), className="text-muted mb-0"), + open=False, + ) + user_keys = [k for k in sorted(meta.keys()) if k in _XRD_USER_FACING_METADATA_KEYS] + tech_keys = [k for k in sorted(meta.keys()) if k not in _XRD_USER_FACING_METADATA_KEYS] + user_rows: list[Any] = [] + for key in user_keys: + user_rows.extend( + [ + html.Dt(str(key), className="col-sm-4 text-muted small ms-meta-term"), + html.Dd(str(meta.get(key)), className="col-sm-8 small ms-meta-def"), + ] + ) + tech_body = html.Ul([html.Li(f"{k}: {meta.get(k)}", className="small") for k in tech_keys], className="mb-0 ps-3") + body = html.Div( + [ + html.H6(translate_ui(loc, "dash.analysis.xrd.raw_metadata.user_section"), className="small text-muted"), + html.Dl(user_rows, className="row mb-2") if user_rows else html.P(translate_ui(loc, "dash.analysis.na"), className="small text-muted"), + html.H6(translate_ui(loc, "dash.analysis.xrd.raw_metadata.technical_section"), className="small text-muted"), + tech_body if tech_keys else html.P("—", className="small text-muted mb-0"), + ] + ) + return _xrd_collapsible_section(loc, "dash.analysis.xrd.raw_metadata.card_title", body, open=False) + + +def _build_xrd_top_match_hero(summary: dict, rows: list, loc: str) -> html.Div: + if not rows: + return html.P(translate_ui(loc, "dash.analysis.xrd.top_match.empty"), className="text-muted mb-0") + top = rows[0] + name = _display_candidate_name(top, loc) + formula = str(top.get("formula_unicode") or top.get("formula") or "--") + band = _confidence_band_label(loc, str(top.get("confidence_band") or "no_match")) + score = _coerce_float(top.get("normalized_score")) + score_s = f"{score:.4f}" if score is not None else translate_ui(loc, "dash.analysis.na") + prov = str(top.get("library_provider") or "--") + pkg = str(top.get("library_package") or "--") + ev = top.get("evidence") if isinstance(top.get("evidence"), dict) else {} + shared = ev.get("shared_peak_count", "--") + overlap = ev.get("weighted_overlap_score", "--") + cov = ev.get("coverage_ratio", "--") + md = ev.get("mean_delta_position", "--") + caution = str(summary.get("caution_message") or "").strip() + rejected = str(summary.get("match_status") or "").lower() in {"no_match", "library_unavailable"} + badge_color = "secondary" if rejected else "success" + return html.Div( + [ + html.H5( + translate_ui(loc, "dash.analysis.xrd.section.top_match"), + className="mb-2 small text-uppercase fw-semibold text-muted xrd-top-match-section-title", + ), + dbc.Card( + dbc.CardBody( + [ + html.Div([dbc.Badge(band, color=badge_color, className="me-2"), html.Strong(name, className="h5 mb-0")], className="mb-2"), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.formula"), className="text-muted d-block"), html.Span(formula)], md=4), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.score"), className="text-muted d-block"), html.Span(score_s)], md=4), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.confidence"), className="text-muted d-block"), html.Span(band)], md=4), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.provider"), className="text-muted d-block"), html.Span(prov)], md=4), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.package"), className="text-muted d-block"), html.Span(pkg)], md=4), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.shared_peaks"), className="text-muted d-block"), html.Span(shared)], md=4), + ], + className="g-2 mb-2", + ), + dbc.Row( + [ + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.weighted_overlap"), className="text-muted d-block"), html.Span(overlap)], md=4), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.coverage"), className="text-muted d-block"), html.Span(cov)], md=4), + dbc.Col([html.Small(translate_ui(loc, "dash.analysis.xrd.hero.mean_delta"), className="text-muted d-block"), html.Span(md)], md=4), + ], + className="g-2", + ), + dbc.Alert(translate_ui(loc, "dash.analysis.xrd.top_match.rejected"), color="warning", className="mt-2 mb-0 py-1 small") + if rejected + else (dbc.Alert(caution, color="warning", className="mt-2 mb-0 py-1 small") if caution else html.Div()), + ] + ), + className="border border-primary-subtle shadow-sm xrd-top-match-hero-card", + ), + ] + ) + + +def _build_match_cards(rows: list, summary: dict, loc: str = "en") -> html.Div: + cards: list = [ + html.H6( + translate_ui(loc, "dash.analysis.section.candidate_matches"), + className="mb-2 text-muted text-uppercase small fw-semibold xrd-candidates-section-title", + ), + ] + caution_message = str(summary.get("caution_message") or "").strip() + if caution_message: + cards.append(dbc.Alert(caution_message, color="warning", className="mb-2 py-1 small")) + top_name = str(summary.get("top_candidate_name") or "").strip() + if top_name: + cards.append( + html.P(translate_ui(loc, "dash.analysis.xrd.top_candidate", name=top_name), className="mb-2 text-muted") + ) + if not rows: + cards.append(html.P(translate_ui(loc, "dash.analysis.state.no_candidate_matches"), className="text-muted")) + return html.Div(cards) + for idx, row in enumerate(rows): + cards.append(_match_card(row, idx, loc)) + return html.Div(cards) + + +def _flatten_xrd_table_row(row: dict) -> dict: + out = dict(row) + ev = row.get("evidence") if isinstance(row.get("evidence"), dict) else {} + out["evidence_shared_peaks"] = ev.get("shared_peak_count", "") + out["evidence_overlap"] = ev.get("weighted_overlap_score", "") + out["evidence_coverage"] = ev.get("coverage_ratio", "") + out["evidence_mean_delta"] = ev.get("mean_delta_position", "") + return out + + +def _build_xrd_match_table(rows: list, loc: str = "en") -> html.Div: + if not rows: + return html.Div( + [ + html.H6( + translate_ui(loc, "dash.analysis.section.candidate_evidence_table"), + className="mb-2 text-body-secondary small fw-semibold xrd-evidence-table-title", + ), + html.P(translate_ui(loc, "dash.analysis.state.no_match_data"), className="text-muted small mb-0"), + ], + className="xrd-evidence-table-section", + ) + flat = [_flatten_xrd_table_row(r) if isinstance(r, dict) else r for r in rows] + columns = [ + "rank", + "candidate_id", + "display_name_unicode", + "formula_unicode", + "normalized_score", + "confidence_band", + "library_provider", + "library_package", + "evidence_shared_peaks", + "evidence_overlap", + "evidence_coverage", + "evidence_mean_delta", + ] + return html.Div( + [ + html.H6( + translate_ui(loc, "dash.analysis.section.candidate_evidence_table"), + className="mb-2 text-body-secondary small fw-semibold xrd-evidence-table-title", + ), + html.Div(dataset_table(flat, columns, table_id="xrd-matches-table"), className="xrd-evidence-table-wrap"), + ], + className="xrd-evidence-table-section", + ) + + +_build_match_table = _build_xrd_match_table + + +def _build_figure(project_id, dataset_key, summary, processing, ui_theme): + """Programmatic / test entry point mirroring the live result figure path.""" + from dash_app.api_client import analysis_state_curves + + loc = "en" + curves = analysis_state_curves(project_id, "XRD", dataset_key) or {} + axis = list(curves.get("temperature", []) or []) + raw_signal = list(curves.get("raw_signal", []) or []) + smoothed = list(curves.get("smoothed", []) or []) + baseline = list(curves.get("baseline", []) or []) + corrected = list(curves.get("corrected", []) or []) + peaks_raw = curves.get("peaks") or [] + peaks = peaks_raw if isinstance(peaks_raw, list) else [] + + n = len(axis) + has_corrected = bool(corrected and len(corrected) == n) + has_smoothed = bool(smoothed and len(smoothed) == n) + has_raw = bool(raw_signal and len(raw_signal) == n) + if not n or not (has_corrected or has_smoothed or has_raw): + return html.P(translate_ui(loc, "dash.analysis.xrd.no_plot_signal"), className="text-muted mb-0") + + proc = processing if isinstance(processing, dict) else {} + method_context = proc.get("method_context", {}) if isinstance(proc.get("method_context"), dict) else {} + plot_settings = method_context.get("xrd_plot_settings") or {} + axis_role = str(method_context.get("xrd_axis_role") or "two_theta").strip().lower() + if axis_role in {"two_theta", ""}: + axis_title = translate_ui(loc, "dash.analysis.figure.axis_two_theta") + else: + axis_title = translate_ui(loc, "dash.analysis.figure.axis_x_generic", role=axis_role) + + summ = summary if isinstance(summary, dict) else {} + sample_name = resolve_sample_name(summ, {}, fallback_display_name=dataset_key, locale_data=loc) + fig = build_xrd_result_figure( + axis=axis, + raw_signal=raw_signal, + smoothed=smoothed, + baseline=baseline, + corrected=corrected, + peaks=peaks, + selected_match=None, + plot_settings=plot_settings if isinstance(plot_settings, dict) else {}, + ui_theme=ui_theme, + loc=loc, + sample_name=sample_name, + axis_title=axis_title, + ) + return dcc.Graph(figure=fig, config={"displaylogo": False, "responsive": True}, className="ta-plot") + + +@callback( + Output("xrd-result-analysis-summary", "children"), + Output("xrd-result-metrics", "children"), + Output("xrd-result-quality", "children"), + Output("xrd-result-top-match", "children"), + Output("xrd-result-candidate-cards", "children"), + Output("xrd-result-table", "children"), + Output("xrd-result-processing", "children"), + Output("xrd-result-raw-metadata", "children"), + Output("xrd-result-figure-controls", "children"), + Output("xrd-result-cache", "data"), + Input("xrd-latest-result-id", "data"), + Input("xrd-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def display_xrd_result(result_id, _refresh, locale_data, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + summary_empty = html.P(translate_ui(loc, "dash.analysis.xrd.summary.empty"), className="text-muted") + quality_empty = _xrd_collapsible_section( + loc, + "dash.analysis.xrd.quality.card_title", + html.P(translate_ui(loc, "dash.analysis.xrd.quality.empty"), className="text-muted mb-0"), + open=False, + ) + raw_empty = _build_xrd_raw_metadata_panel({}, loc) + _hidden = html.Div(className="d-none") + metrics_hint = html.P(translate_ui(loc, "dash.analysis.xrd.empty_results_hint"), className="text-muted mb-0") + if not result_id or not project_id: + return (summary_empty, metrics_hint, quality_empty, _hidden, _hidden, _hidden, empty_msg, raw_empty, "", None) + from dash_app.api_client import workspace_dataset_detail, workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception as exc: + err = dbc.Alert(translate_ui(loc, "dash.analysis.error_loading_result", error=str(exc)), color="danger") + return (summary_empty, err, quality_empty, _hidden, _hidden, _hidden, empty_msg, raw_empty, "", None) + summary = detail.get("summary", {}) + result_meta = detail.get("result", {}) + processing = detail.get("processing", {}) + rows = detail.get("rows") or detail.get("rows_preview") or [] + dataset_key = result_meta.get("dataset_key") + dataset_detail: dict = {} + if dataset_key: + try: + dataset_detail = workspace_dataset_detail(project_id, dataset_key) + except Exception: + dataset_detail = {} + analysis_summary = _build_xrd_analysis_summary(dataset_detail, summary, result_meta, loc, locale_data=locale_data) + quality_panel = _build_xrd_quality_card(detail, result_meta, loc) + raw_panel = _build_xrd_raw_metadata_panel((dataset_detail or {}).get("metadata"), loc) + match_status = _match_status_label(loc, summary.get("match_status")) + top_score = _coerce_float(summary.get("top_candidate_score")) + na = translate_ui(loc, "dash.analysis.na") + top_score_str = f"{top_score:.4f}" if top_score is not None else na + peak_count = int(summary.get("peak_count") or 0) + candidate_count = int(summary.get("candidate_count") or len(rows or [])) + sample_name = resolve_sample_name(summary, result_meta, locale_data=locale_data) + metrics = metrics_row( + [ + ("dash.analysis.metric.match_status", match_status), + ("dash.analysis.metric.top_candidate_score", top_score_str), + ("dash.analysis.metric.detected_peaks", str(peak_count)), + ("dash.analysis.metric.candidates", str(candidate_count)), + ("dash.analysis.metric.sample", sample_name), + ], + locale_data=locale_data, + ) + top_hero = _build_xrd_top_match_hero(summary, rows, loc) + cards = _build_match_cards(rows, summary, loc) + table = _build_xrd_match_table(rows, loc) + method_context = processing.get("method_context", {}) + provenance_state = str(summary.get("xrd_provenance_state") or method_context.get("xrd_provenance_state") or "unknown") + provenance_warning = str(summary.get("xrd_provenance_warning") or method_context.get("xrd_provenance_warning") or "").strip() + axis_role = str(method_context.get("xrd_axis_role") or "two_theta") + wavelength = method_context.get("xrd_wavelength_angstrom") + wl_display = str(wavelength) if wavelength not in (None, "") else translate_ui(loc, "dash.analysis.xrd.wavelength_not_provided") + proc_extra = [ + html.P(translate_ui(loc, "dash.analysis.xrd.axis_role_note", role=axis_role)), + html.P(translate_ui(loc, "dash.analysis.xrd.wavelength_line", value=wl_display)), + html.P(translate_ui(loc, "dash.analysis.xrd.provenance_state", state=provenance_state)), + ] + if provenance_warning: + proc_extra.append(html.P(translate_ui(loc, "dash.analysis.xrd.provenance_warning", warning=provenance_warning))) + proc_extra.extend( + [ + html.P(translate_ui(loc, "dash.analysis.xrd.qualitative_notice")), + html.P(translate_ui(loc, "dash.analysis.xrd.peak_detection", detail=processing.get("analysis_steps", {}).get("peak_detection", {})), className="mb-0"), + html.P( + translate_ui(loc, "dash.analysis.xrd.processing.match_metric", raw=str(method_context.get("xrd_match_metric") or "")), + className="small text-muted mb-0", + ), + ] + ) + proc_view = html.Div( + processing_details_section(processing, extra_lines=proc_extra, locale_data=locale_data), + className="xrd-processing-details-wrap", + ) + opts = [{"label": f"#{i + 1} {_display_candidate_name(r, loc)[:48]}", "value": i} for i, r in enumerate(rows) if isinstance(r, dict)] + controls = html.Div( + className="d-flex flex-wrap align-items-center gap-2 w-100 xrd-overlay-toolbar-inner", + children=[ + dbc.Label( + translate_ui(loc, "dash.analysis.xrd.figure.overlay_label"), + className="small text-muted mb-0 text-nowrap flex-shrink-0 xrd-overlay-label", + ), + html.Div( + dcc.Dropdown( + id="xrd-candidate-overlay", + options=opts, + value=0, + clearable=False, + className="xrd-overlay-dropdown-dash", + style={"minWidth": "min(100%, 10rem)", "maxWidth": "100%"}, + ), + className="flex-grow-1", + style={"minWidth": "min(100%, 10rem)"}, + ), + ], + ) + cache = { + "project_id": project_id, + "dataset_key": dataset_key, + "summary": summary, + "processing": processing, + "rows": rows, + } + return ( + analysis_summary, + metrics, + quality_panel, + top_hero, + cards, + table, + proc_view, + raw_panel, + controls, + cache, + ) + + +@callback( + Output("xrd-result-figure", "children"), + Input("xrd-result-cache", "data"), + Input("xrd-candidate-overlay", "value"), + Input("ui-theme", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def render_xrd_result_figure_area(cache, overlay_idx, ui_theme, locale_data, project_id): + loc = _loc(locale_data) + empty_msg = empty_result_msg(locale_data=locale_data) + if not cache or not project_id: + return empty_msg + dataset_key = cache.get("dataset_key") + summary = cache.get("summary") or {} + processing = cache.get("processing") or {} + rows = cache.get("rows") or [] + if not dataset_key: + return empty_msg + idx = int(overlay_idx) if overlay_idx is not None else 0 + selected = rows[idx] if 0 <= idx < len(rows) else None + from dash_app.api_client import analysis_state_curves + + try: + curves = analysis_state_curves(project_id, "XRD", dataset_key) + except Exception: + curves = {} + axis = curves.get("temperature", []) + raw_signal = curves.get("raw_signal", []) + smoothed = curves.get("smoothed", []) + baseline = curves.get("baseline", []) + corrected = curves.get("corrected", []) + peaks = curves.get("peaks") or [] + if not axis: + return no_data_figure_msg(locale_data=locale_data) + plot_settings = (processing.get("method_context") or {}).get("xrd_plot_settings") or {} + method_context = processing.get("method_context", {}) + axis_role = str(method_context.get("xrd_axis_role") or "two_theta").strip().lower() + if axis_role in {"two_theta", ""}: + axis_title = translate_ui(loc, "dash.analysis.figure.axis_two_theta") + else: + axis_title = translate_ui(loc, "dash.analysis.figure.axis_x_generic", role=axis_role) + sample_name = resolve_sample_name(summary, {}, fallback_display_name=dataset_key, locale_data=locale_data) + fig = build_xrd_result_figure( + axis=axis, + raw_signal=raw_signal, + smoothed=smoothed, + baseline=baseline, + corrected=corrected, + peaks=peaks if isinstance(peaks, list) else [], + selected_match=selected if isinstance(selected, dict) else None, + plot_settings=plot_settings, + ui_theme=ui_theme, + loc=loc, + sample_name=sample_name, + axis_title=axis_title, + ) + return dcc.Graph(figure=fig, config={"displaylogo": False, "responsive": True}, className="ta-plot") + + +@callback( + Output("xrd-literature-card-title", "children"), + Output("xrd-literature-hint", "children"), + Output("xrd-literature-max-claims-label", "children"), + Output("xrd-literature-persist-label", "children"), + Output("xrd-literature-compare-btn", "children"), + Output("xrd-literature-options-summary", "children"), + Input("ui-locale", "data"), + Input("xrd-latest-result-id", "data"), +) +def render_xrd_literature_chrome(locale_data, result_id): + loc = _loc(locale_data) + if result_id: + hint = literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.ready", "Compare the saved XRD result to literature sources.") + else: + hint = literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.empty", "Run an XRD analysis first to enable literature comparison.") + return ( + literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.title", "Literature Compare"), + hint, + literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.max_claims", "Max Claims"), + literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.persist", "Persist to project"), + literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.compare_btn", "Compare"), + literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.options_summary", "Compare options"), + ) + + +@callback(Output("xrd-literature-compare-btn", "disabled"), Input("xrd-latest-result-id", "data")) +def toggle_xrd_literature_compare_button(result_id): + return not bool(result_id) + + +@callback( + Output("xrd-literature-output", "children"), + Output("xrd-literature-status", "children"), + Input("xrd-literature-compare-btn", "n_clicks"), + State("project-id", "data"), + State("xrd-latest-result-id", "data"), + State("xrd-literature-max-claims", "value"), + State("xrd-literature-persist", "value"), + State("ui-locale", "data"), + prevent_initial_call=True, +) +def compare_xrd_literature(n_clicks, project_id, result_id, max_claims, persist_values, locale_data): + loc = _loc(locale_data) + if not n_clicks: + raise dash.exceptions.PreventUpdate + if not project_id or not result_id: + msg = literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.missing_result", "Run an XRD analysis first.") + return dash.no_update, dbc.Alert(msg, color="warning", className="py-1 small") + claims_limit = coerce_literature_max_claims(max_claims, default=3) + persist = bool(persist_values) and "persist" in (persist_values or []) + from dash_app.api_client import literature_compare + + try: + payload = literature_compare(project_id, result_id, max_claims=claims_limit, persist=persist) + except Exception as exc: + err = dbc.Alert( + literature_t(loc, f"{_XRD_LITERATURE_PREFIX}.error", "Literature compare failed: {error}").replace("{error}", str(exc)), + color="danger", + className="py-1 small", + ) + return dash.no_update, err + return ( + render_literature_output( + payload, + loc, + i18n_prefix=_XRD_LITERATURE_PREFIX, + evidence_preview_limit=1, + alternative_preview_limit=1, + collapse_retained_evidence=True, + ), + literature_compare_status_alert(payload, loc, i18n_prefix=_XRD_LITERATURE_PREFIX), + ) + + +def _xrd_primary_report_figure_label(dataset_key: str | None, result_id: str | None) -> str: + return primary_report_figure_label("XRD", dataset_key, result_id) + + +@callback( + Output("xrd-figure-save-snapshot-btn", "children"), + Output("xrd-figure-use-report-btn", "children"), + Output("xrd-figure-artifacts-summary", "children"), + Input("ui-locale", "data"), +) +def render_xrd_figure_artifact_button_labels(locale_data): + loc = _loc(locale_data) + return figure_artifact_button_labels(loc, i18n_prefix="dash.analysis.xrd.figure") + + +@callback( + Output("xrd-figure-save-snapshot-btn", "disabled"), + Output("xrd-figure-use-report-btn", "disabled"), + Input("xrd-latest-result-id", "data"), +) +def toggle_xrd_figure_artifact_buttons(result_id): + dis = not bool(result_id) + return dis, dis + + +@callback( + Output("xrd-result-figure-artifacts", "children"), + Input("xrd-latest-result-id", "data"), + Input("xrd-figure-artifact-refresh", "data"), + Input("ui-locale", "data"), + State("project-id", "data"), +) +def refresh_xrd_figure_artifacts_panel(result_id, _artifact_refresh, locale_data, project_id): + loc = _loc(locale_data) + if not result_id or not project_id: + return "" + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, result_id) + except Exception: + return "" + fa = detail.get("figure_artifacts") if isinstance(detail.get("figure_artifacts"), dict) else {} + if not _xrd_ordered_figure_preview_keys(fa): + return _build_xrd_figure_artifacts_panel(fa, loc, previews=None) + previews = _xrd_fetch_figure_preview_data_urls(project_id, result_id, fa, max_tiles=MAX_XRD_FIGURE_PREVIEW_TILES) + return _build_xrd_figure_artifacts_panel(fa, loc, previews=previews) + + +@callback( + Output("xrd-figure-artifact-status", "children"), + Output("xrd-figure-artifact-refresh", "data"), + Input("xrd-figure-save-snapshot-btn", "n_clicks"), + Input("xrd-figure-use-report-btn", "n_clicks"), + Input("xrd-latest-result-id", "data"), + State("project-id", "data"), + State("xrd-result-figure", "children"), + State("ui-locale", "data"), + State("xrd-figure-artifact-refresh", "data"), + prevent_initial_call=True, +) +def xrd_figure_snapshot_or_report_figure(snap_clicks, report_clicks, latest_result_id, project_id, figure_children, locale_data, refresh_value): + loc = _loc(locale_data) + ctx = dash.callback_context + triggered_id = getattr(ctx, "triggered_id", None) + if triggered_id is None: + raise dash.exceptions.PreventUpdate + if triggered_id == "xrd-latest-result-id": + return "", dash.no_update + action = figure_action_from_trigger( + triggered_id, + snapshot_button_id="xrd-figure-save-snapshot-btn", + report_button_id="xrd-figure-use-report-btn", + ) + if action is None: + raise dash.exceptions.PreventUpdate + if not project_id or not latest_result_id: + return ( + figure_action_status_alert( + loc, + i18n_prefix="dash.analysis.xrd.figure", + action=action, + status="missing", + reason="missing_project_or_result", + class_prefix="xrd", + ), + dash.no_update, + ) + + from dash_app.api_client import workspace_result_detail + + try: + detail = workspace_result_detail(project_id, latest_result_id) + except Exception as exc: + return ( + figure_action_status_alert( + loc, + i18n_prefix="dash.analysis.xrd.figure", + action=action, + status="error", + reason=str(exc), + class_prefix="xrd", + ), + dash.no_update, + ) + result_meta = detail.get("result", {}) or {} + dataset_key = result_meta.get("dataset_key") + + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + meta = figure_action_metadata( + action, + analysis_type="XRD", + dataset_key=str(dataset_key or "").strip() or None, + result_id=latest_result_id, + snapshot_stamp=stamp, + ) + + outcome = register_result_figure_from_layout_children( + figure_children=figure_children, + project_id=project_id, + result_id=latest_result_id, + label=str(meta.get("label") or ""), + replace=bool(meta.get("replace")), + ) + if outcome.get("status") == "ok": + key = str(outcome.get("figure_key") or meta.get("label") or "") + return ( + figure_action_status_alert( + loc, + i18n_prefix="dash.analysis.xrd.figure", + action=action, + status="ok", + figure_key=key, + class_prefix="xrd", + ), + (refresh_value or 0) + 1, + ) + if outcome.get("status") == "error": + return ( + figure_action_status_alert( + loc, + i18n_prefix="dash.analysis.xrd.figure", + action=action, + status="error", + reason=str(outcome.get("reason") or ""), + class_prefix="xrd", + ), + dash.no_update, + ) + return ( + figure_action_status_alert( + loc, + i18n_prefix="dash.analysis.xrd.figure", + action=action, + status="skipped", + reason=str(outcome.get("reason") or ""), + class_prefix="xrd", + ), + dash.no_update, + ) + + +@callback( + Output("xrd-figure-captured", "data"), + Input("xrd-latest-result-id", "data"), + Input("project-id", "data"), + Input("xrd-result-figure", "children"), + State("xrd-figure-captured", "data"), + prevent_initial_call=True, +) +def capture_xrd_figure(result_id, project_id, figure_children, captured): + return capture_result_figure_from_layout( + result_id=result_id, + project_id=project_id, + figure_children=figure_children, + captured=captured, + analysis_type="XRD", + ) + + diff --git a/dash_app/sample_data.py b/dash_app/sample_data.py new file mode 100644 index 00000000..997fe77a --- /dev/null +++ b/dash_app/sample_data.py @@ -0,0 +1,58 @@ +"""Sample data helpers for the Dash UI.""" + +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SAMPLE_DATA_FILES: dict[str, dict[str, str]] = { + "load-sample-dsc": { + "label": "DSC - Polymer Melting", + "file_name": "dsc_polymer_melting.csv", + "data_type": "DSC", + }, + "load-sample-tga": { + "label": "TGA - Calcium Oxalate", + "file_name": "tga_calcium_oxalate.csv", + "data_type": "TGA", + }, + "load-sample-dsc-kissinger": { + "label": "DSC - Multi-Rate Kissinger", + "file_name": "dsc_multirate_kissinger.csv", + "data_type": "DSC", + }, + "load-sample-dta": { + "label": "DTA - TNAA (5 °C/min)", + "file_name": "dta_tnaa_5c_mendeley.csv", + "data_type": "DTA", + }, + "load-sample-ftir": { + "label": "FTIR - Particleboard", + "file_name": "ftir_particleboard_50g_figshare.csv", + "data_type": "FTIR", + }, + "load-sample-raman": { + "label": "RAMAN - CNT Spectrum", + "file_name": "raman_cnt_figshare.csv", + "data_type": "RAMAN", + }, + "load-sample-xrd": { + "label": "XRD - 2024-0304", + "file_name": "xrd_2024_0304_zenodo.csv", + "data_type": "XRD", + }, +} + + +def list_sample_specs() -> list[dict[str, str]]: + return [{"id": key, **value} for key, value in SAMPLE_DATA_FILES.items()] + + +def resolve_sample_request(button_id: str) -> tuple[Path | None, str | None]: + sample = SAMPLE_DATA_FILES.get(button_id) + if sample is None: + return None, None + file_name = sample["file_name"] + data_type = sample["data_type"] + return REPO_ROOT / "sample_data" / file_name, data_type diff --git a/dash_app/server.py b/dash_app/server.py new file mode 100644 index 00000000..79a19337 --- /dev/null +++ b/dash_app/server.py @@ -0,0 +1,57 @@ +"""Combined FastAPI + Dash server entrypoint. + +Run with: python -m dash_app.server +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import uvicorn +from dotenv import load_dotenv +from a2wsgi import WSGIMiddleware + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run MaterialScope (Dash + FastAPI).") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8050) + parser.add_argument("--token", default="") + return parser.parse_args() + + +def create_combined_app(*, api_token: str | None = None): + """Create a FastAPI app with the Dash UI mounted as WSGI fallback.""" + from backend.app import create_app as create_backend + from dash_app.app import create_dash_app + + api = create_backend(api_token=api_token) + dash = create_dash_app() + api.mount("/", WSGIMiddleware(dash.server)) + return api + + +def main() -> None: + load_dotenv(dotenv_path=REPO_ROOT / ".env", override=False) + args = parse_args() + + from core.library_combined_bootstrap import ( + apply_combined_dash_server_library_env, + sanitize_library_path_env_vars, + ) + + for line in sanitize_library_path_env_vars(): + print(line, flush=True) + for line in apply_combined_dash_server_library_env(listen_host=args.host, listen_port=args.port): + print(line, flush=True) + + app = create_combined_app(api_token=args.token or None) + print(f"MaterialScope (Dash) starting on http://{args.host}:{args.port}", flush=True) + uvicorn.run(app, host=args.host, port=args.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/dash_app/theme.py b/dash_app/theme.py new file mode 100644 index 00000000..57d7b989 --- /dev/null +++ b/dash_app/theme.py @@ -0,0 +1,95 @@ +"""Theme tokens for Dash / Plotly styling (aligned with ``assets/style.css`` variables).""" + +from __future__ import annotations + +THEME_TOKENS: dict[str, dict[str, str]] = { + "light": { + "ink": "#1C1A1A", + "muted": "#66645E", + "border": "#E0DDD6", + "panel": "#FFFFFF", + "panel_strong": "#F7F6F3", + "accent": "#EBDBB7", + "accent_strong": "#D9C9A3", + "accent_ink": "#1C1A1A", + "bg_top": "#FFFFFF", + "bg_bottom": "#FFFFFF", + "sidebar_bg": "#1C1A1A", + "sidebar_text": "#F2F0EB", + "sidebar_muted": "#A8A59C", + "input_bg": "#FFFFFF", + "input_border": "#D4D1CA", + }, + "dark": { + "ink": "#EEEDEA", + "muted": "#9E9A93", + "border": "#3D3B38", + "panel": "#1A1917", + "panel_strong": "#22211E", + "accent": "#CBB896", + "accent_strong": "#B8A382", + "accent_ink": "#121110", + "bg_top": "#121110", + "bg_bottom": "#121110", + "sidebar_bg": "#1C1A1A", + "sidebar_text": "#F2F0EB", + "sidebar_muted": "#9E9A93", + "input_bg": "#1A1917", + "input_border": "#3D3B38", + }, +} + +FONT_FAMILY = "'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif" +MONO_FAMILY = "'IBM Plex Mono', 'Consolas', 'Monaco', monospace" + +PLOT_THEME = { + "light": { + "template": "plotly_white", + "text": "#1C1A1A", + "paper_bg": "#FFFFFF", + "plot_bg": "#FFFFFF", + "grid": "#E0DDD6", + }, + "dark": { + "template": "plotly_dark", + "text": "#EEEDEA", + "paper_bg": "#1A1917", + "plot_bg": "#121110", + "grid": "#3D3B38", + }, +} + + +def normalize_ui_theme(theme: str | None) -> str: + return theme if theme in ("light", "dark") else "light" + + +def apply_figure_theme(fig, theme: str | None) -> None: + """Set paper/plot/font/legend and axis grid colors from ``PLOT_THEME`` (matches Dash CSS tokens).""" + pt = PLOT_THEME[normalize_ui_theme(theme)] + grid = pt["grid"] + text = pt["text"] + fig.update_layout( + template=pt["template"], + paper_bgcolor=pt["paper_bg"], + plot_bgcolor=pt["plot_bg"], + font=dict(color=text, family=FONT_FAMILY), + title=dict(font=dict(color=text, family=FONT_FAMILY)), + legend=dict(font=dict(color=text, size=12), bgcolor="rgba(0,0,0,0)"), + ) + fig.update_xaxes( + gridcolor=grid, + showgrid=True, + linecolor=grid, + zerolinecolor=grid, + tickfont=dict(color=text, size=12), + title_font=dict(color=text, size=13), + ) + fig.update_yaxes( + gridcolor=grid, + showgrid=True, + linecolor=grid, + zerolinecolor=grid, + tickfont=dict(color=text, size=12), + title_font=dict(color=text, size=13), + ) diff --git a/desktop/backend_bundle/README.md b/desktop/backend_bundle/README.md index c4311b9b..8cca90d8 100644 --- a/desktop/backend_bundle/README.md +++ b/desktop/backend_bundle/README.md @@ -16,7 +16,7 @@ python .\desktop\backend_bundle\build_backend.py --clean Expected output: -- `desktop/backend_bundle/dist/thermoanalyzer_backend/thermoanalyzer_backend.exe` +- `desktop/backend_bundle/dist/materialscope_backend/materialscope_backend.exe` ## Notes diff --git a/desktop/backend_bundle/backend_entrypoint.py b/desktop/backend_bundle/backend_entrypoint.py index ab2a10ad..c52ba97b 100644 --- a/desktop/backend_bundle/backend_entrypoint.py +++ b/desktop/backend_bundle/backend_entrypoint.py @@ -1,4 +1,4 @@ -"""PyInstaller entrypoint for ThermoAnalyzer desktop backend.""" +"""PyInstaller entrypoint for MaterialScope desktop backend.""" from backend.main import main diff --git a/desktop/backend_bundle/build_backend.py b/desktop/backend_bundle/build_backend.py index 08341074..63f62aab 100644 --- a/desktop/backend_bundle/build_backend.py +++ b/desktop/backend_bundle/build_backend.py @@ -10,7 +10,7 @@ def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Build ThermoAnalyzer desktop backend executable.") + parser = argparse.ArgumentParser(description="Build MaterialScope desktop backend executable.") parser.add_argument("--clean", action="store_true", help="Clean previous build/dist outputs before building.") return parser.parse_args() @@ -55,7 +55,7 @@ def main() -> None: "--clean", "--onedir", "--name", - "thermoanalyzer_backend", + "materialscope_backend", "--distpath", str(dist_dir), "--workpath", @@ -80,8 +80,8 @@ def main() -> None: ] run_command(command, cwd=repo_root) - exe_name = "thermoanalyzer_backend.exe" if sys.platform.startswith("win") else "thermoanalyzer_backend" - built_exe = dist_dir / "thermoanalyzer_backend" / exe_name + exe_name = "materialscope_backend.exe" if sys.platform.startswith("win") else "materialscope_backend" + built_exe = dist_dir / "materialscope_backend" / exe_name if not built_exe.exists(): # pragma: no cover - environment dependent raise RuntimeError(f"Backend build did not produce expected executable: {built_exe}") diff --git a/desktop/electron/CLEAN_MACHINE_SMOKE_CHECKLIST.md b/desktop/electron/CLEAN_MACHINE_SMOKE_CHECKLIST.md index fe923a90..87ffbcb2 100644 --- a/desktop/electron/CLEAN_MACHINE_SMOKE_CHECKLIST.md +++ b/desktop/electron/CLEAN_MACHINE_SMOKE_CHECKLIST.md @@ -1,4 +1,4 @@ -# ThermoAnalyzer Desktop - Clean Machine Smoke Checklist +# MaterialScope Desktop - Clean Machine Smoke Checklist Use this checklist on a fresh Windows machine before professor demo handoff. @@ -6,15 +6,15 @@ Use this checklist on a fresh Windows machine before professor demo handoff. - Windows 10/11 x64 machine with no Python requirement. - Build artifact available: - - `ThermoAnalyzer-Setup--x64.exe` (NSIS installer) + - `MaterialScope-Setup--x64.exe` (NSIS installer) - At least one known-good DSC/TGA CSV sample file ready for import. ## Smoke Steps 1. Install and launch app -- Action: Run `ThermoAnalyzer-Setup--x64.exe`, complete `Next -> Install -> Finish`, then launch from Start Menu shortcut. +- Action: Run `MaterialScope-Setup--x64.exe`, complete `Next -> Install -> Finish`, then launch from Start Menu shortcut. - Verify: - - Main window opens as `ThermoAnalyzer Desktop`. + - Main window opens as `MaterialScope Desktop`. - No immediate backend startup error dialog appears. 2. Backend startup @@ -43,7 +43,7 @@ Use this checklist on a fresh Windows machine before professor demo handoff. - Result detail panel shows summary/processing/validation/provenance. 6. Save and reload project archive -- Action: Click `Save Workspace`, save `.thermozip`, then `Open .thermozip` and load that same file. +- Action: Click `Save Workspace`, save `.scopezip`, then `Open Project Archive` and load that same file (`.scopezip` or legacy `.thermozip`). - Verify: - Workspace reloads successfully. - Dataset/result lists and details remain available after reload. @@ -74,5 +74,5 @@ Use this checklist on a fresh Windows machine before professor demo handoff. ## If Startup Fails - Capture dialog text and diagnostics log path shown by the app. -- Collect `startup-*.log` from `%APPDATA%\\ThermoAnalyzer Desktop\\logs`. +- Collect `startup-*.log` from `%APPDATA%\\MaterialScope Desktop\\logs`. - Share both the log and exact build filename. diff --git a/desktop/electron/README.md b/desktop/electron/README.md index 04cfc84a..4f5d56bd 100644 --- a/desktop/electron/README.md +++ b/desktop/electron/README.md @@ -5,12 +5,12 @@ This directory contains the brownfield desktop workflow shell for migration tran What it does: - launches local backend in two modes: - development mode: Python source backend (`backend/main.py`) - - packaged mode: bundled backend executable (`resources/backend/thermoanalyzer_backend.exe`) + - packaged mode: bundled backend executable (`resources/backend/materialscope_backend.exe`) - waits for `/health` - opens an Electron window - shows backend status + version - supports a minimal workflow: - - create/load workspace (`.thermozip`) + - create/load workspace (`.scopezip`, legacy `.thermozip` import supported) - list datasets and results - inspect dataset details (metadata, units, validation, data preview) - inspect result details (summary, processing, validation, provenance, review) @@ -24,7 +24,7 @@ What it does: - generate/download DOCX report for selected saved results - import a dataset file - run one DSC/TGA analysis on a selected dataset -- save workspace to `.thermozip` +- save workspace to `.scopezip` - writes startup diagnostics logs and shows a clear failure dialog path when backend launch fails - provides a desktop product shell with grouped navigation: - `Primary`: Home/Import, Compare, DSC, TGA, Export, Project @@ -55,7 +55,7 @@ Optional environment variable: On startup failure, the app shows a dialog with a diagnostics log path. Logs are written to: -- `%APPDATA%\\ThermoAnalyzer Desktop\\logs\\startup-*.log` (packaged Windows app) +- `%APPDATA%\\MaterialScope Desktop\\logs\\startup-*.log` (packaged Windows app) - `desktop/electron` runtime user-data path when running from `npm start` Each log includes: @@ -90,8 +90,8 @@ npm run build:win:nsis ``` Expected outputs: -- bundled backend: `desktop/backend_bundle/dist/thermoanalyzer_backend/thermoanalyzer_backend.exe` -- installer: `release/electron/ThermoAnalyzer-Setup-0.1.0-x64.exe` +- bundled backend: `desktop/backend_bundle/dist/materialscope_backend/materialscope_backend.exe` +- installer: `release/electron/MaterialScope-Setup-0.1.0-x64.exe` Optional unpacked build output: diff --git a/desktop/electron/RELEASE_HANDOFF_CHECKLIST.md b/desktop/electron/RELEASE_HANDOFF_CHECKLIST.md index 4276f12b..20e66d42 100644 --- a/desktop/electron/RELEASE_HANDOFF_CHECKLIST.md +++ b/desktop/electron/RELEASE_HANDOFF_CHECKLIST.md @@ -1,4 +1,4 @@ -# ThermoAnalyzer Desktop - Release Handoff Checklist +# MaterialScope Desktop - Release Handoff Checklist Run this checklist before sending a demo build to professors. @@ -7,11 +7,11 @@ Run this checklist before sending a demo build to professors. 1. Build bundled backend: - `cd desktop/electron` - `npm run build:backend` -- Verify: `desktop/backend_bundle/dist/thermoanalyzer_backend/thermoanalyzer_backend.exe` exists. +- Verify: `desktop/backend_bundle/dist/materialscope_backend/materialscope_backend.exe` exists. 2. Build NSIS installer artifact: - `npm run build:win:nsis` -- Verify: `release/electron/ThermoAnalyzer-Setup--x64.exe` exists. +- Verify: `release/electron/MaterialScope-Setup--x64.exe` exists. 3. Optional unpacked validation build: - `npm run build:win:dir` @@ -47,4 +47,4 @@ Include in handoff message: - build date - known scope statement: stable DSC/TGA desktop workflow + batch/export/report prep - known non-goals: preview modules not included, no installer signing in this build -- troubleshooting note: startup diagnostics logs are under `%APPDATA%\\ThermoAnalyzer Desktop\\logs` +- troubleshooting note: startup diagnostics logs are under `%APPDATA%\\MaterialScope Desktop\\logs` diff --git a/desktop/electron/backend_locator.js b/desktop/electron/backend_locator.js index f94b5880..0d87b037 100644 --- a/desktop/electron/backend_locator.js +++ b/desktop/electron/backend_locator.js @@ -5,7 +5,7 @@ function _defaultPython(platform) { } function _backendExecutableName(platform) { - return platform === "win32" ? "thermoanalyzer_backend.exe" : "thermoanalyzer_backend"; + return platform === "win32" ? "materialscope_backend.exe" : "materialscope_backend"; } function resolveBackendLaunch(options) { @@ -36,7 +36,7 @@ function resolveBackendLaunch(options) { candidates.push(env.TA_BACKEND_EXE.trim()); } else { candidates.push(path.join(resourcesPath, "backend", exeName)); - candidates.push(path.join(resourcesPath, "backend", "thermoanalyzer_backend", exeName)); + candidates.push(path.join(resourcesPath, "backend", "materialscope_backend", exeName)); } const check = typeof existsSync === "function" ? existsSync : () => true; diff --git a/desktop/electron/index.html b/desktop/electron/index.html index 80a912e4..594930de 100644 --- a/desktop/electron/index.html +++ b/desktop/electron/index.html @@ -3,7 +3,7 @@ - ThermoAnalyzer Desktop + MaterialScope Desktop