diff --git a/README.md b/README.md index bdb4a9f..5edf47b 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ LamellarSAXS2D reads calibrated detector frames (CBF, EDF, TIFF, NPY/NPZ, HDF5), builds physical `q`, `chi`, `qx`, and `qy` from a PONI file through pyFAI, traces observed butterfly arcs, and reports what the image actually supports: -| A typical frame reports | Only when an interior ellipse is supported | +| A typical frame reports | Only when a radial reflection or interior ellipse is supported | | --- | --- | -| First-order `q*` and **L ring** = `2π/q*` | Apparent `a`, `b/a`, `θ` | -| Occupied sides, quality, and flags | Unpublished **Ln / Lz / L major** candidates | +| q-ring profiles, observed petal trajectories, quality, and flags | First-order `q*` and **L ring** = `2π/q*` | +| Occupied sides and supported branches | Apparent `a`, `b/a`, `θ` | +| Missing lobes/rings remain missing | Unpublished **Ln / Lz / L major** candidates | | `ring` when the fit sits on a bound or the major axis runs away | Never a silent overwrite of user bounds or of ring L into the Ln column | `success=True` is not scientific acceptance. Pixel-q never invents a physical period. Opposite quadrants are never fabricated. @@ -25,11 +26,14 @@ Synthetic demonstration (pixel-q): the Wang/Grubb double ellipse drawn on a gene ## What it does - **Butterfly arcs** (`ridge_method=butterfly_curvature`): curvature ridges, branch/side labels (QI+QIII vs QII+QIV), first-order family vs harmonics, sparse-ring fill. See the [butterfly arc guide](docs/butterfly_arcs_zh.md). +- **Annular butterfly trajectories**: the new workbench session defaults to fixed-q annuli and angular profiles `I(χ)`, connecting up to four observed lobe maxima into long butterfly petals. Each ring retains its raw profile, counts, and coverage; missing lobes/rings remain missing. See the [annular trajectory guide](docs/annular_trajectories_zh.md). +- **Independent radial diagnostics**: `radial_sector` measures fixed-χ `I(q)` profiles for a separate check. Its `q*` values are not the default butterfly trajectory or the primary ellipse input. See the [radial-sector guide](docs/sector_peaks_zh.md). - **Honest ellipse publication**: `flat_ellipse` (editable `b/a` bounds, default `0.005–0.35`) and `very_flat_ellipse`. A cap, floor, or major axis longer than the observed first-order ridge is **ring-only** — `a`, tilt, and ellipticity stay unpublished. - **Batch review**: independent frames, cancel/progress, checkpoints, streaming CSV/JSON/NPZ. The table shows `WARN · ring` / `WARN · ellipse`, L ring, and candidate-period tooltips. Warm-start stays quality-gated. -- **Workbench**: Identify arcs → Evaluate; bilingual UI; first-order ring overlay instead of a capped tilted ellipse; lamellar studio and 0.4 publication artboards are schematics, not a unique inversion ([studio](docs/lamellar_workbench_zh.md), [figures](docs/publication_figures_zh.md)). +- **Workbench**: Identify trajectories → Evaluate; bilingual UI; first-order ring overlay instead of a capped tilted ellipse; lamellar studio and 0.4 publication artboards are schematics, not a unique inversion ([studio](docs/lamellar_workbench_zh.md), [figures](docs/publication_figures_zh.md)). - **Optional `full2d`**: empirical whole-pixel intensity refinement. It is a different model from the butterfly geometry measurement. - **Measurement and fit figures**: export fixed-size SVG/PDF and high-resolution TIFF/PNG with source arrays, curve/profile CSVs, and checksums. Inspect measured data, candidate ellipses, overlays, and actual `full2d` predictions without promoting a candidate to a scientifically accepted result. See the [figure export guide](docs/butterfly_figures_zh.md). +- **Figure delivery**: choose column width and resolution in one export window, then browse the complete figure bundle offline through `index.html`. Annular/radial source CSV and technical figure checks accompany the images; formatting checks do not imply scientific acceptance. - **Peak diagnostics**: locate the raw brightest pixel separately from supported lobe peaks; inspect measured/model peak positions, local zooms, angular/radial profiles, and clean overlays from each fit source. Peak coordinates and support flags also travel through batch exports. - **Preflight and P3/P4 gates**: read-only package checks and evidence reports. They do not freeze science or replace named human review. @@ -98,6 +102,8 @@ bsaxs preflight data/package --manifest manifest.csv \ --poni geometry.poni --mask mask.npy -o results/preflight ``` +For an unattended package run, use `bsaxs batch "data/package/images/*.edf" --unattended data/package --manifest data/package/manifest.csv --poni data/package/geometry.poni --mask data/package/mask.npy -o results/unattended_001`. This performs preflight before fitting, writes a checkpoint and streams batch evidence. A red preflight blocks fitting; warnings or failed frames return a nonzero exit status. Keep the output outside the raw package and use `--resume` with the same inputs and settings after interruption. + `bsaxs analyze ... --full2d` is the optional empirical intensity fit. `bsaxs-gui` is the crash-visible desktop entry (same as `启动_LamellarSAXS2D.cmd`); `bsaxs gui` remains a supported CLI alias that opens the workbench. Agents (and any non-interactive operator) should start with `bsaxs describe` or a bare `bsaxs`. That prints a JSON catalog of commands, exit codes, and scientific invariants. Environment checks: `bsaxs doctor --json` (same as `bsaxs-doctor`). Failed commands emit a JSON error envelope on stdout and a human `错误:` line on stderr. See [AGENTS.md](AGENTS.md). @@ -118,6 +124,8 @@ Agents (and any non-interactive operator) should start with `bsaxs describe` or | Agent / automation CLI contract | [AGENTS.md](AGENTS.md) | | CLI, TOML, batch, masks, exports | [docs/user_guide_zh.md](docs/user_guide_zh.md) | | Butterfly arcs and publication rules | [docs/butterfly_arcs_zh.md](docs/butterfly_arcs_zh.md) | +| Fixed-q annular butterfly trajectories | [docs/annular_trajectories_zh.md](docs/annular_trajectories_zh.md) | +| Fixed-χ radial sector peaks | [docs/sector_peaks_zh.md](docs/sector_peaks_zh.md) | | Measurement / peak figures | [docs/butterfly_figures_zh.md](docs/butterfly_figures_zh.md) | | Symbols, units, and interpretation limits | [docs/scientific_basis_zh.md](docs/scientific_basis_zh.md) | | Architecture | [docs/architecture_zh.md](docs/architecture_zh.md) | @@ -126,21 +134,15 @@ Agents (and any non-interactive operator) should start with `bsaxs describe` or ## Scientific scope -The double ellipse is an **empirical reciprocal-space measurement**, following the Wang/Grubb picture of a butterfly as a pair of origin-centred ellipses. One 2D pattern does not uniquely recover a 3D lamellar stack or a deformation mechanism. - -- [Wang, Murthy & Grubb (2007)](https://doi.org/10.1016/j.polymer.2007.04.026) -- [Grubb, Murthy & Francescangeli (2016)](https://doi.org/10.1002/polb.23930) -- [Grubb et al. (2021)](https://doi.org/10.1016/j.polymer.2021.123566) - -The papers are not redistributed here. +The double ellipse is an **empirical reciprocal-space measurement**. The current annular engineering route is documented against Murthy & Grubb (2024), especially §4.1 on z-slices, minimum-curvature peak trajectories, and the two-ellipse butterfly description: [IUCr article](https://journals.iucr.org/j/issues/2024/04/00/tu5052/). It is not claimed as a reproduction of the 2021 algorithm; the 2007 and 2021 full texts were not obtained and read for this tuning. One 2D pattern does not uniquely recover a 3D lamellar stack or a deformation mechanism. --- ## 中文说明 -LamellarSAXS2D 面向取向层片的各向异性二维 SAXS 蝴蝶纹:用 PONI(pyFAI)得到物理 `q/chi/qx/qy`,识别观测弧,并只发表图像真正支持的量。 +LamellarSAXS2D 面向取向层片的各向异性二维 SAXS 蝴蝶纹:用 PONI(pyFAI)得到物理 `q/chi/qx/qy`,提取固定 q 环的 `I(χ)` 花瓣轨迹,并只发表图像真正支持的量。 -多数实验帧给出的是**一阶环周期**(`q*` 与 **环 L**)。只有内凹椭圆真正成立时,才显示表观 `a`、`b/a`、`θ`,以及未发表的 **Ln / Lz / 长轴 L** 候选。贴在 `flat_ellipse` 上下界、或长轴超出一阶脊线范围的解,按 **仅环** 处理,不会把求解器的倾角或环 L 写进 Ln 列。`success=True` 不是科学验收;像素 q 不能冒充物理周期;缺失象限不会被镜像补齐。 +只有在径向反射峰有明确支持、单位有效且级次解释另有依据时,才把 `q*` 换算为一阶环周期 **L = 2π/q***。annular 的 `q_annulus` 只是固定 q 环采样坐标。只有内凹椭圆真正成立时,才显示表观 `a`、`b/a`、`θ`,以及未发表的 **Ln / Lz / 长轴 L** 候选。贴在 `flat_ellipse` 上下界、或长轴超出观测支持的解,按 **仅环/候选限制** 处理,不会把求解器的倾角或环 L 写进 Ln 列。`success=True` 不是科学验收;像素 q 不能冒充物理周期;缺失象限不会被镜像补齐。 ### 安装与启动 @@ -158,7 +160,11 @@ py -3.13 -m venv .venv-project .\启动_LamellarSAXS2D.cmd ``` -蝴蝶页建议顺序:**识别弧 → 评估**。详见[首次启动](docs/first_run_zh.md)。 +蝴蝶页建议顺序:**识别 → 评估**(曲率模式下识别步骤显示为“识别弧”)。详见[首次启动](docs/first_run_zh.md)。 + +新建 GUI 会话默认按固定 q 环计算角向 `I(χ)`,用 q 环上的角向峰连接四条花瓣轨迹;默认 q 环数 40、方位角分箱 72。每个 q 环最多保留四个实际支持峰,少于四个就保留少数峰,不镜像补象限;缺环造成的轨迹断点也不桥接。历史项目缺少 `trace_method` 时保持曲率路径兼容,历史 `ridge_method=butterfly_curvature` 的分支/象限 family 仍可复现。固定 χ 的 `radial_sector` 只作为独立径向诊断,不是默认主椭圆输入。详见[固定 q 环花瓣轨迹](docs/annular_trajectories_zh.md)与[径向扇区诊断](docs/sector_peaks_zh.md)。 + +annular 点的 `q_annulus` 是预设 q 环的采样坐标,不是径向反射峰 `q*`,不能换算成 `2π/q`。椭圆拟合使用固定参考轴的对角配对,不能逐个花瓣重新指派;外层 q 窗口边界也不能冒充真实长轴尖端。 评估后可在「叠加图层」分别检查实测谱、观测轨迹、几何候选和全像素模型椭圆;峰位表区分原始最亮点 G 与受支持峰 P,选中行即可定位。导出同时包含干净叠加图、峰位图、局部放大、剖面及 CSV/NPZ 源数据。匹配差或参数不稳定时会保留明确提示,不能仅凭曲线看起来像蝴蝶判定拟合正确。详见[测量图与峰位导出](docs/butterfly_figures_zh.md)。 @@ -171,6 +177,10 @@ bsaxs inspect data/frame_0001.edf --poni geometry/detector.poni --mask masks/det bsaxs analyze data/frame_0001.edf --poni geometry/detector.poni --mask masks/detector.npy \ --ridge-method butterfly_curvature --ellipse-preset flat_ellipse \ --butterfly-stage evaluate --butterfly-resamples 0 -o results/frame_0001 +bsaxs analyze data/frame_0001.edf --poni geometry/detector.poni --mask masks/detector.npy \ + --butterfly-stage evaluate --butterfly-trace-method annular_peak \ + --annular-rings 40 --annular-angles 72 --butterfly-resamples 0 \ + -o results/frame_0001_annular bsaxs batch "data/frame_*.edf" --poni geometry/detector.poni --mask masks/detector.npy \ --ridge-method butterfly_curvature --ellipse-preset flat_ellipse \ --butterfly-stage evaluate --mode independent -o results/batch diff --git a/docs/annular_trajectories_zh.md b/docs/annular_trajectories_zh.md new file mode 100644 index 0000000..e414aa1 --- /dev/null +++ b/docs/annular_trajectories_zh.md @@ -0,0 +1,81 @@ +# 固定 q 环的 `I(χ)` 花瓣轨迹 + +`annular_peak` 是当前 GUI 新会话的主蝴蝶路径。它把 q 窗口划成一组固定的 q 环,在每个 q 环上计算角向 profile `I(χ)`,从每个 profile 中提取实际支持的角向峰,再沿 q 方向连接成最多四条、支持充分时呈四条的长花瓣弧。这个观测定义对应用户在二维图上看到的 4 条蓝色花瓣轨迹;它不再把每个 bar 的局部最亮点直接当作主轨迹。 + +固定 χ 的 `radial_sector` 仍可用于独立的 `I(q)` 径向诊断,但其 `q*` 不属于默认 annular 主轨迹,也不是默认主椭圆输入。局部二维曲率 `curvature` 保留为历史项目和高级诊断路径。 + +## 默认采样与原始 profile + +GUI 新建会话默认使用 40 个 q 环和 72 个方位角分箱。实际有效 q 像素会按 q 环和 χ 分箱;每个 q 环的原始角向强度定义为: + +```text +I_raw(χₗ | qₖ) = sum(Iₚ) / count(Iₚ) +``` + +其中 `p` 只包括当前 mask、q 窗口和有限强度允许的像素。每个 q 环的 `raw_mean`、`raw_sum`、`counts` 和 `coverage` 都保留在结果中;`raw_sum` 不应脱离 count 单独比较。空分箱保持为空,不用零或镜像数据填充。 + +环积分层不额外施加探测器效率、固角、偏振或绝对强度校正,也不把输入强度重新解释为另一种 pyFAI 校正积分。输入数据已有的校正和强度单位随上下文记录。q 环中心来自 PONI 的 q 坐标;若只有 pixel-q,只能按采样坐标解释。 + +每个 q 环最多保留四个实际观测峰,每个参考象限最多一个 dominant 峰。少于四个时保留实际存在的峰,不补齐缺失象限;某个 q 环没有可靠峰时保留 profile 和缺环状态,不跨过缺环强行连接。当前默认最小连续轨迹长度为 3 个 q 环,短于该长度的候选不升级为连续花瓣弧。 + +## 峰筛选和轨迹连接 + +一个角向候选需要在该环上有足够 coverage、连续的角向支持、局部突出度和噪声支持,并通过有效像素贡献与热像素主导检查。相邻候选峰过于接近或强度证据不足时保留歧义/拒绝原因;程序不会为了得到四瓣而强行选峰。 + +轨迹只连接相邻 q 环中同一参考象限的实际峰,并限制角度跳变。mask、beam stop、探测器缝隙或低 coverage 造成的断开会原样记录。`I(χ)` profile 以及每个环的 candidates 仍可在 GUI 和离线图包中检查,即使该环最终没有进入花瓣弧。 + +当前真实 frame110/120 中看到的 point 数和 4 条 arc 只是某一组 q 环/角度分箱下的调校示例。它们不应写成最终验证数字、普适结构计数或科学接受结论;改变 q 窗口、mask、PONI 或分箱会改变可见支持。 + +## 固定对角配对与椭圆拟合 + +轨迹建立前先按用户给定的参考轴固定象限和 branch: + +- family 0:参考轴内的 QI 与 QIII; +- family 1:参考轴内的 QII 与 QIV。 + +这是一种固定的对角配对。拟合接收已经分配好的 branch、side 和 arc 身份,不能在优化过程中把某一条花瓣重新指派给另一个 individual petal,也不能靠最近距离替换缺失象限。配对、缺口和支撑状态会写入结果诊断。 + +拟合得到的双椭圆仍是观测几何候选,不是唯一的三维结构反演。长轴、倾角和 `Ln/Lz` 只有在真实二维支持足够且参数可辨识时才可解释;求解器成功、残差较小或图形完整都不等于科学验收。 + +## q 环坐标和长轴边界 + +`q_annulus` 是预先规定的 q 环采样坐标,只说明该点来自哪个 q 环。它不是径向强度峰 `q*`,不能用 `2π/q_annulus` 换算层片间距,也不能自动命名为一阶反射。 + +分析 q 窗口的外边界是采样范围,不是真实长轴尖端。annular 路径已禁用共享的 `observed_tip_constraint`,避免把 q 窗口边界塞进长轴估计;用户明确给出的参数上下界仍然保留。若 q 支持不足以辨识很长的 `a`,结果应标记为不可辨识、ring-only 或候选限制状态,不强报长轴、`Ln` 或 `L`。 + +## GUI、CLI 与旧项目 + +新建 GUI 会话的识别方式是 `annular_peak`,控制栏默认显示 q 环数量 40 和方位角分箱 72。历史项目配方缺少 `trace_method` 时,继续使用历史 `curvature` 语义;历史 `ridge_method=butterfly_curvature` 的 family、象限和分支标记仍保留。需要切换到 annular 时应明确选择方法并重新识别。 + +CLI 的主路径写法为: + +```powershell +bsaxs analyze data/frame_0001.edf ` + --poni geometry/detector.poni ` + --mask masks/detector.npy ` + --butterfly-stage evaluate ` + --butterfly-trace-method annular_peak ` + --annular-rings 40 ` + --annular-angles 72 ` + --butterfly-resamples 0 ` + -o results/frame_0001_annular +``` + +`--annular-rings` 是请求的 q 环数量,实际数量仍受 q 像素采样限制;`--annular-angles` 是每个 q 环的方位角分箱数。显式指定 `--butterfly-trace-method` 时,CLI 会选择蝴蝶工作流;不要与其他 `--ridge-method` 混用,除非该值也是 `butterfly_curvature`。`radial_sector` 的 `--sector-width`/`--sector-step` 只改变独立径向诊断。 + +## 文献边界 + +本路线的文献依据只采用已核读的 Murthy 与 Grubb 2024 年 Journal of Applied Crystallography 文章[《Evolution of elliptical SAXS patterns in aligned systems》](https://journals.iucr.org/j/issues/2024/04/00/tu5052/),尤其是 §4.1:先用 z slices 观察层片反射峰位,再以强度曲面的 minimum-curvature 轨迹扩展椭圆拟合;butterfly 需要两条椭圆。该文支持“峰位轨迹可以呈椭圆、蝴蝶需要两个椭圆”的物理和几何背景,不等同于本项目的 annular 工程算法。 + +本项目的 `annular_peak` 是固定 q 环、逐环 `I(χ)`、实际峰支持和连续轨迹连接的工程实现,不能称作对 2021 年算法的复刻。2007/2021 文献全文在本次调校中未取得并逐篇核读,因此不把它们写成已验证的实现依据。最终椭圆参数、反射级次和材料结构解释仍需独立实验与人工审查。 + +## 图稿和源数据 + +含 `annular_peaks` 的图包会新增: + +- `annular_qchi.svg/pdf/png/tiff`:q–χ 原始均值和受支持角向峰; +- `annular_profiles.svg/pdf/png/tiff`:代表 q 环的原始/平滑 `I(χ)`、count 和 coverage; +- `annular_profiles.csv`、`annular_peaks.csv`、`annular_profiles.npz`:profile、候选峰、选择状态、覆盖和原始数组; +- `annular_caption.txt`、`annular_manifest.json`:方法定义、解释边界和文件哈希。 + +导出器只消费已完成的 annular 结果,不在图稿阶段重积分、补象限、跨缺环桥接或重新拟合椭圆。Nature 单栏/双栏尺寸和矢量输出有助于排版与复核,但图件生成成功不保证几何模型适用、参数可发表或科学验收。 diff --git a/docs/butterfly_figures_zh.md b/docs/butterfly_figures_zh.md index 2c0a24b..dc23832 100644 --- a/docs/butterfly_figures_zh.md +++ b/docs/butterfly_figures_zh.md @@ -2,18 +2,22 @@ `butterfly_saxs.butterfly_figure` 从观测强度、逐像素 `qx/qy` 和蝴蝶页 `current_result` 直接生成科研图,不依赖 Qt。主图使用输入的二维 q 坐标网格,因此支持非仿射网格和坐标递增、递减的情况;不会用图像尺寸推算 q 轴。 +结果可以来自新 GUI 默认的 `annular_peak` 固定 q 环 `I(χ)` 花瓣轨迹、历史 `curvature` 脊线,或独立的 `radial_sector` 径向 profile。三者的点定义不能混用:annular 点来自逐环角向峰,`q_annulus` 是采样坐标;radial `q*` 来自固定 χ 的 `I(q)` profile,属于独立诊断,不是默认主椭圆输入。annular 原始强度与 radial profile 都按有效像素 `sum / count` 均值保存,并保留 counts/coverage;导出不额外施加 detector correction,输入强度的已有校正和单位按上下文记录。方法细节见[固定 q 环花瓣轨迹](annular_trajectories_zh.md)和[径向扇区诊断](sector_peaks_zh.md)。 + 主图只叠加结果提供的脊线点,以及满足来源条件的已观测弧段。每个点都必须带有效的源像素坐标,且落在调用方提供的有效域和当前显示 q 窗口内;仅有 q 坐标的旧结果不会猜测像素位置。缺少像素坐标、超出图像边界或落在无效像素上的点不绘制,但会原样保留在结果文件和逐点 CSV,并记录省略原因。弧段必须显式标记为有效、包含唯一且明确接受的有序源点,并且整段路径不能穿过显示有效域外的像素。接受、拒绝和状态缺失使用不同符号表示;未知支路或侧别保持原样,不镜像补点。主图不绘制候选椭圆。 同一导出目录另含四面板比较图:观测强度、单独的候选椭圆曲线、观测强度叠加测得轨迹与候选曲线、以及已有拟合残差诊断。有限的 ring-only、边界受限或未收敛几何可以作为虚线候选诊断曲线导出,并明确标记为“候选 / 非定量 / 非科学接受”;缺失、非有限或不物理的几何不画。候选曲线完整范围不是观测弧支持。若没有逐点投影/法向残差或可用法向强度 profile,诊断面板明确显示不可用,不构造残差。另有单独的 ellipse-only SVG/PDF/PNG/TIFF,供独立排版。 ## 判断拟合是否可信,以及定位最强点 -蝴蝶页现在可分别选择实测谱、观测轨迹、几何候选、全像素强度模型椭圆及两者对比。几何椭圆来自脊线点;强度模型椭圆使用当前优化结果的参数与参考轴。它们对应不同目标函数,不应混称为同一个拟合。角向包络、径向展宽和分量叠加都会使模型强度最大值偏离名义椭圆曲线。 +蝴蝶页现在可分别选择实测谱、annular 观测轨迹、历史曲率候选、几何候选、全像素强度模型椭圆及两者对比。几何椭圆使用已经固定 branch/family 的观测轨迹;强度模型椭圆使用当前优化结果的参数与参考轴。它们对应不同目标函数,不应混称为同一个拟合。角向包络、径向展宽和分量叠加都会使模型强度最大值偏离名义椭圆曲线。 页面显示几何残差相对于定位误差的比值,并沿用分析结果中已有的筛查限值。超过限值时明确显示匹配较差;优化器收敛不代表科学验收。强度模型的条件数、参数边界和参数来源随图一起记录。改变分析条件后,旧的峰标记与模型图层失效,重新计算后才能用于当前结果。 峰位表中的 `G` 是所选有效分析域内的原始最亮像素,可能位于低 q 尾部或窗口边缘。`P1–P4` 是受像素覆盖、角向局部突出程度及噪声检查支持的局部信号峰,最多四个,不会补造缺失峰。峰位用实际像素的 qx/qy 标定坐标表示;平滑只用于稳健定位,原始强度同时保留。选择表格行可定位相应标记并检查角向、径向曲线。P 编号按本帧方位排序,不能直接作为跨帧跟踪标识。 +上段的 G/P 说明适用于历史曲率和像素峰诊断。使用 `annular_peak` 时,sector 表不再是主轨迹证据;图包会列出每个 q 环的完整 `I(χ)` profile、count、coverage、候选峰和实际选择的 0–4 个角向峰。少于四个峰和缺环都保留,不镜像补象限、不跨缺环桥接。使用 `radial_sector` 时,表中仍列出固定 χ 的 `I(q)` profile;没有受支持径向峰的角度仍可查看失败原因,不生成假 q 空间点。`q_annulus` 不是 `q*`,两者都不能仅凭导出自动等同于一阶周期或 `Ln`。 + 局部信号峰默认使用当前径向提示给出的信号带;G 的搜索仍覆盖完整分析窗口。导出记录两者各自的域与提示来源。G、局部像素峰、径向曲线峰值和 q* 是不同量,不会把一个最亮像素自动换算成层片周期。 v2 峰位诊断先用有效像素的径向中位数估计各向同性参考,将参考采样到同一个 q 网格,并经过相同的掩膜和平滑操作。用于识别的角向曲线是观测平滑剖面减去这个参考,避免像素采样把圆环变成四瓣。原始和平滑观测曲线仍完整保留;参考及检测差值随图、CSV、NPZ 一起输出。该参考用于识别,不替代实验背景扣除。局部角向噪声默认在一分箱尺度估计,保留原有四倍噪声突出度门槛与像素噪声传播下界;估计尺度及独立像素假设写入方法记录。 @@ -34,9 +38,21 @@ v2 峰位诊断先用有效像素的径向中位数估计各向同性参考, - `peak_zooms.*`:峰附近的局部放大图。 - `peak_landmarks.csv/json`、`peak_profiles.csv/npz`:原始/平滑强度、像素与 q 坐标、覆盖、警示和实测—模型位移。批量测量 CSV 也包含独立的 `raw_pixel_maximum` 与 `supported_lobe_pixel` 记录,其 q 半径不冒充 q*。 - `fit_assessment.json`、`fit_source_parameters.csv`、`fit_overlay_curves.npz`:两种拟合的参数来源、状态和可重绘曲线。 +- 若结果含 `annular_peaks`,另有 `annular_qchi.svg/pdf/png/tiff`(q–χ 原始均值与角向峰轨迹)、`annular_profiles.svg/pdf/png/tiff`(代表 q 环的 `I(χ)`、count/coverage 诊断)、`annular_profiles.csv`、`annular_peaks.csv`、`annular_profiles.npz`、`annular_caption.txt` 和 `annular_manifest.json`。这些图件消费已计算的 annular 结果,不在导出阶段重新积分、补象限或跨缺环连接。 +- 若结果含 `sector_peaks`,另有 `sector_qchi.svg/pdf/png/tiff`(固定 χ 径向诊断热图)、`sector_profiles.svg/pdf/png/tiff`(径向 profile 与 count/coverage 诊断)、`sector_peaks.csv`、`sector_profiles.csv`、`sector_profiles.npz`、`sector_caption.txt` 和 `sector_manifest.json`。这些图件属于独立 `radial_sector` 诊断,不能代替 annular 主轨迹。 `*` 图件均有 SVG、PDF、PNG、TIFF。画板宽度和可编辑文字遵循 [Nature 官方科研图指南](https://research-figure-guide.nature.com/figures/building-and-exporting-figure-panels/) 的单/双栏规格;排版合规与实验模型有效性分别检查。 +## 从界面直接生成图包 + +完成“识别轨迹 → 评估”并检查实测叠加后,点击“导出图稿”。在同一个窗口选择保存位置、单栏 89 mm 或双栏 183 mm,以及 300 / 600 / 1200 dpi。尺寸示意仅表示纸面比例;不会为了预览重新拟合或改变观测数据。SVG/PDF 保留矢量文字,DPI 主要决定嵌入图像与 PNG/TIFF 的像素数。通常可先用 600 dpi,再按目标期刊和具体图件要求调整。 + +输出写入新的图包子目录;已有目录不会被覆盖。后台导出期间可取消。完成后可打开图包中的 `index.html`,离线浏览实测图、annular q–χ 花瓣轨迹、各环 profile、历史曲率/像素峰诊断、独立径向 profile 与可用模型图,并访问对应图注、矢量文件和源数据。页面不依赖联网脚本或远程字体。 + +图包同时提供 `radial_profile.csv`,保存主图径向剖面的分箱边界、q 中心、原始强度和、均值及有效像素数。空分箱的均值留空,像素数不表示独立重复实验数,也不作为误差棒或置信区间。 + +`figure_qa.json` 核对导出配置并记录科学解释边界;它不逐个解析生成的图像或验证版面。配置检查通过不证明模型适用、结构参数可发表或所有标签均无重叠;投稿前仍应核对实际文件,并在最终版面尺寸下检查所有图件。Nature 各子刊要求可能不同,应以目标期刊的最新说明为准。 + ## Python ```python @@ -69,7 +85,7 @@ bsaxs analyze frame.edf --poni geometry.poni --mask mask.npy ` --figure-width 183 --figure-dpi 600 ``` -当配置未指定其他脊线方法时,图稿导出使用 `butterfly_curvature`;如果用户明确配置了另一种方法,导出请求会在分析前被拒绝。图稿尺寸选项独立于分析先验:默认的 `b/a` 先验仍由分析配置控制(当前默认范围为 `0.005–0.35`),固定栏宽、字体或分辨率不会改变拟合结果。 +图稿导出按 `current_result` 中实际记录的 `annular_peak`、`radial_sector` 或 `curvature` 方法生成对应章节;不会把一个方法的点重新解释成另一个方法的点。若从 CLI 新算结果,应显式选择目标方法。图稿尺寸选项独立于分析先验;annular 路径不会把 q 窗口外边界当成真实长轴尖端,用户明确给出的参数上下界仍按配置保留。 ## 文件与解释 @@ -84,6 +100,8 @@ bsaxs analyze frame.edf --poni geometry.poni --mask mask.npy ` - `ellipse_curves.csv` / `ellipse_curves.npz`:源 `candidate_fit` 生成的曲线坐标、角度与来源支路标识;无可用候选时保存空曲线数组及 CSV 表头。 - `point_residuals.csv`:逐点保存实际 `projection_residual_q` / `normal_residual_q` 等字段;没有数据的字段留空。 - `normal_profiles.csv`:仅当输入结果包含可配对的 offset、raw 与 fit profile 时输出。诊断图若使用一个代表 profile,按“有效已接受点中 q 半径最接近中位数者”选取并写入设置,避免任意选择首条。 +- `annular_qchi.*`、`annular_profiles.*` 与对应 CSV/NPZ/caption/manifest:固定 q 环 `I(χ)`、最多四个实际角向峰、branch/family 轨迹支持和缺环状态。`q_annulus` 是采样坐标,不转换为 `2π/q`。 +- `sector_qchi.*`、`sector_profiles.*` 与对应 CSV/NPZ/caption/manifest:仅在结果含 `sector_peaks` 时生成,属于固定 χ `I(q)` 独立诊断。 - 若传入 `model`,另有 `model_comparison.svg/pdf/png/tiff`、`model_comparison_data.npz` 和 `model_comparison_caption.txt`。 - `source_data.npz`:原始 `observed`、`qx`、`qy`、调用方提供的选择 mask、完整有效像素 mask、数组自身的掩码、q 单位,以及径向分箱边缘、raw 强度和、raw 均值与计数。调用方的 `valid_mask` 可能已合并探测器、q 窗口、ROI 或其他分析域限制;导出器不把它称为探测器专用 mask。旧键 `valid_mask` 保留为兼容别名;新键 `supplied_valid_mask` 与 role 字段明确其来源;数组可用 `numpy.load(..., allow_pickle=False)` 读取。 - `ridge_points.csv`:逐点 q/像素坐标、来源标记、侧别、接受状态、是否绘出、未绘出的原因及完整源记录 JSON。 @@ -93,4 +111,6 @@ bsaxs analyze frame.edf --poni geometry.poni --mask mask.npy ` 默认 `log1p` 变换仅用于主图显示,并保留负强度符号;原始阵列不会改写。颜色限值取显示区域有效像素变换后强度的 0.5 与 99.5 百分位,裁剪计数写入设置。辅助图 b 是以输入 q 坐标原点为中心的等宽径向分箱,显示未经强度变换的逐像素均值及每箱有效像素数;图中 `n` 表示每箱有效源像素数,纵轴使用有记录阈值的 symlog,原始有符号均值和各箱强度和均随 NPZ 导出。该曲线不是各向同性拟合,也不对像素面积或噪声进行额外加权。未知或像素 q 单位原样标在轴上,不据此换算层片周期。 +径向扇区图中的 FWHM 是 `I(q)` profile 峰宽,annular 图中的角向 FWHM 是 `I(χ)` 峰宽;bin 分辨率是采样限制,二者都不是置信区间。annular 的 q 环与角向分箱是预设采样坐标,不能把 q 环中心或角向峰自动换算为层片周期。Nature 尺寸、矢量文字和离线源数据包便于排版与复核,但图稿格式检查或导出成功本身不保证模型适用、结构参数可发表或科学验收。 + 版式采用固定单栏 89 mm 或双栏 183 mm,画板高度不超过 170 mm,正文 6.5 pt 无衬线字体,面板字母为粗体小写 8 pt。该导出是可审阅的图稿工件;目标期刊的最终规范、图注、实验判断与科学验收仍需独立核对。 diff --git a/docs/first_run_zh.md b/docs/first_run_zh.md index a4ce5eb..6c236e4 100644 --- a/docs/first_run_zh.md +++ b/docs/first_run_zh.md @@ -67,13 +67,25 @@ bsaxs doctor --json 1. **打开图像**:确认帧和 HDF5/NPZ dataset 选择正确。 2. **加载 PONI**:只有物理 `q` 坐标建立后,间距和 reciprocal-space 尺度才可解释。`pixel-q` 仅用于算法检查或合成数据。 3. **设置 mask、ROI 和 q 范围**:先排除 beam stop、坏点、探测器缝隙和无效边界,再确定分析区间。 -4. **识别弧(Identify)**:在蝴蝶页提取观测弧、分支/象限标签和 ridge 点;这是几何测量,不是 full2d 强度拟合。 -5. **评估(Evaluate)**:在已识别弧上拟合镜像约束双椭圆,并查看占用边、flags 与质量诊断。贴在 `flat_ellipse` 边界上的解按仅环处理。 -6. **检查叠加与峰位**:核对实测图、观测轨迹、几何候选和(若已运行)full2d 椭圆;峰位表区分原始最亮点与受支持峰。 +4. **识别(Identify)**:新建会话默认在固定 q 环上计算角向 `I(χ)`(默认 40 个 q 环、72 个角度分箱),从每个环提取最多四个实际支持峰并沿 q 连接花瓣轨迹;旧项目缺少 `trace_method` 时仍按历史曲率脊线处理。这是观测测量,不是 `full2d` 强度拟合。 +5. **评估(Evaluate)**:在固定参考轴和对角 family 配对的观测轨迹上拟合镜像约束双椭圆,并查看缺环、占用边、flags 与质量诊断。拟合不能重新指派 individual petals;贴在先验边界上的解仍按候选限制或仅环处理。 +6. **检查叠加与峰位**:核对实测图、q 环 `I(χ)` profile、观测花瓣轨迹、几何候选和(若已运行)`full2d` 椭圆。每个环少于四个峰时保留实际支持,不补象限;缺环不桥接。固定 χ 的 `radial_sector` 只作为独立 `I(q)` 诊断,不能与 annular 主轨迹混用。 7. **可选 Preview / Optimize**:高级强度页的 Preview/Optimize 只服务经验 `full2d` 模型,不能代替 Identify → Evaluate,也不能当作科学验收。 8. **人工接受或拒绝**:具名 `Accept/Reject` 仅记录当前会话审核,不等于 P3/P4 科学证据门通过。 9. **导出图包或进入批处理**:跨帧比较必须保持 PONI、mask、q 范围、权重和配置一致。 +固定 q 环路径的原始角向强度定义为有效像素的 `sum / count`,并同时保存 count 和 coverage;本测量层不追加固角、偏振或探测器效率校正。每个 q 环最多四个实际支持峰,按参考轴固定为 QI+QIII 与 QII+QIV 两个对角 family;dominant 选择和至少连续 3 个 q 环的轨迹门槛用于阻止孤立噪声,少于四瓣或缺环都保留原状。`q_annulus` 是采样坐标,不是 `q*`,不能直接换算 `2π/q`。 + +annular 路径不把 q 窗口外边界当成长轴真实尖端,已禁用共享 `observed_tip_constraint`,但保留用户明确给出的参数上下界。长轴不可辨识时标记限制状态,不强报 `a`、`Ln` 或 `L`。完整判据与 CLI 选项见[固定 q 环花瓣轨迹](annular_trajectories_zh.md)。 + +固定 χ 的径向诊断见[径向扇区](sector_peaks_zh.md):它默认使用 10° 扇区宽度、5° 步长,允许 profile-only、边界截断、低覆盖、歧义、热像素和宽峰诊断;FWHM 与 bin 分辨率不是置信区间。真实 frame110/120 当前显示的 points 与 4 条 arcs 只是当前分箱示例,不能写成最终验证数字或科学接受结论。 + +页面上方的质量摘要集中显示当前阶段、工程质量、观测支持与结果限制。只有当前分析明确提供且单位有效的 `q*` 和环周期才显示数值;未标定或过期的结果不显示旧的物理周期。较长的参数状态和原因可把指针停留在表格单元格上查看。摘要中的“候选”和工程 `PASS` 都不等于科学验收。 + +计算异常或取消会使旧测量失效;本次计算已返回但质量不通过的结果,仍保留为当前诊断,允许带失败标记导出。图稿导出失败不会改变已有测量质量。切换帧或设置后,已完成的旧图包会明确标为此前快照。 + +图稿导出窗口把保存位置、栏宽和分辨率放在一起;完成后可打开离线图包查看图件、图注与源数据,详见[测量图导出](butterfly_figures_zh.md)。图稿统一采用显示有效域的 0.5–99.5% 颜色范围,这与工作台临时调整的显示百分位可能不同;原始数值不会因此改变。 + 右侧控制栏已改为可滚动布局;在 980×680 或笔记本屏幕上,底部 ROI、人工审核和快照控件仍可访问。 ## 5. 结果解释边界 diff --git a/docs/sector_peaks_zh.md b/docs/sector_peaks_zh.md new file mode 100644 index 0000000..9d7db4b --- /dev/null +++ b/docs/sector_peaks_zh.md @@ -0,0 +1,78 @@ +# 固定 χ 径向扇区:独立 `I(q)` 诊断 + +`radial_sector` 保留了“在一个方位角附近沿 q 方向看 profile”的测量,用来检查某个方向上的径向峰形、窗口截断和 q 位置稳定性。它是独立诊断路径,不是新 GUI 的主蝴蝶轨迹,也不替代默认的固定 q 环 `I(χ)` 花瓣轨迹;默认主路径见[固定 q 环花瓣轨迹](annular_trajectories_zh.md)。 + +## 何时使用 + +在 GUI 的识别方式中显式选择“扇区径向 `I(q)`”后,程序会在固定 χ 附近取一个有限角宽的扇区,将有效像素按 q 分箱,得到原始和平滑 profile。每个 profile 可以有受支持的径向峰,也可以只作为 profile-only 诊断保留。它适合检查: + +- 某个花瓣方向的径向峰是否真的有两侧回落; +- q 窗口、mask 或探测器边界是否截断了峰; +- 低 q 尾部、宽峰、双峰或热像素是否让“最强点”不可靠; +- annular 主轨迹的某个 q 区间是否需要额外的方向性核对。 + +径向诊断产生的 `q*` 不自动进入默认主椭圆输入,也不自动分配反射级次。若用户明确选择它,结果必须与 annular 或历史曲率结果分开保存和解释。 + +## 计算定义 + +对中心角 `χ₀`、完整角宽 `w` 和 q 分箱 `Bⱼ`,程序使用 PONI 产生的逐像素 q/χ 坐标,并只保留有效 mask、有限强度和当前 q 窗口内的像素: + +```text +I_raw(qⱼ, χ₀) = sum(Iₚ) / count(Iₚ), p ∈ Bⱼ ∩ sector(χ₀, w) +``` + +原始 profile 的强度是有效像素的 `sum / count` 均值;`raw_sum`、`raw_count`、coverage 和几何覆盖也会保存。不同 q 分箱的像素数量不同,因此 `raw_sum` 不能直接当作强度比较。空分箱保持为空,不用零填充。 + +这条测量层不额外施加探测器效率、固角、偏振或绝对强度校正,也不把输入强度重新变成 pyFAI 校正积分。输入文件已经完成的校正和单位仍随上下文记录;q 轴没有 PONI 时只能按 pixel-q 解释。 + +## 角度设置与支持判据 + +径向模式默认扇区完整角宽为 10°,中心角步长为 5°。相邻 profile 因扇区重叠而相关,不能视为独立重复实验。q 分箱受到局部 q 像素步长限制,`sampling_sigma_q` 和 `half_bin_resolution` 是采样/分箱分辨率指标,不是置信区间。 + +程序不会强制每个角度都有 `q*`。候选峰需要有连续 q 支持、足够 coverage、相对于稳健原始 profile 基线的突出度和高度,并有可检查的两侧回落;还会检查有效贡献像素数和单个像素是否主导。常见的 profile-only 状态包括: + +- 峰贴近 q 窗口边界,左侧或右侧回落不可见; +- mask、beam stop、探测器缝隙造成低覆盖或支持断裂; +- 相近候选峰无法区分,结果保持歧义; +- 一个热像素或极少数像素制造尖峰; +- profile 只有低 q 尾部、负向起伏或不足以支持峰的对比度。 + +连续支持足够时,宽峰可以保留;默认不会用窗口比例硬门槛把宽峰一概剔除。平滑曲线只服务定位和显示,原始均值、count、coverage 始终保留。没有峰的 profile 仍可在 GUI、CSV、NPZ 和离线图包中查看,不生成假 q 空间点。 + +## q* 的解释边界 + +径向模式的 `q*` 是该固定 χ profile 的观测峰位。它没有自动的反射级次;`2π/q*` 只有在单位、峰级次和物理模型另有证据时才可作相应长度解释。单独的径向 profile 不足以宣布一阶环、`Ln`、`Lz` 或长轴参数。 + +径向 FWHM 描述 profile 峰形,`sampling_sigma_q`/bin 分辨率描述采样限制;二者都不是置信区间,不能直接替代重复测量或实验误差分析。工程状态、SNR 或图件导出成功也不等于科学验收。 + +## GUI、CLI 与旧项目 + +新 GUI 会话默认是 `annular_peak`。旧项目配方没有 `trace_method` 时仍按历史 `curvature` 解释;历史 `ridge_method=butterfly_curvature` 的分支、象限和 family 语义继续保留。要运行径向诊断,必须在 GUI 明确切换方法,或通过 CLI 显式指定: + +```powershell +bsaxs analyze data/frame_0001.edf ` + --poni geometry/detector.poni ` + --mask masks/detector.npy ` + --butterfly-stage trace ` + --butterfly-trace-method radial_sector ` + --sector-width 10 ` + --sector-step 5 ` + --butterfly-resamples 0 ` + -o results/frame_0001_radial_diagnostic +``` + +`--butterfly-trace-method` 接受 `curvature`、`radial_sector` 和 `annular_peak`。显式指定它时 CLI 选择蝴蝶工作流;不要再传入其他 `--ridge-method`,除非该值也是 `butterfly_curvature`。径向参数是 `--sector-width` 和 `--sector-step`;固定 q 环主路径的参数则是 `--annular-rings` 和 `--annular-angles`。 + +## 输出与人工检查 + +结果保留每个角度的 q 边界/中心、原始均值、平滑均值、count、coverage、候选峰、支持状态和失败原因。选中的峰会记录 prominence、SNR、径向 FWHM、代表支持像素和采样分辨率;代表像素只是二维图定位锚点,`q*` 来自 profile 统计量,不是像素最大值。 + +建议检查: + +1. 原始 profile 在峰两侧是否有真实回落; +2. count/coverage 是否连续,是否被 q 边界或 mask 截断; +3. 相邻 5° profile 的变化是否考虑了重叠相关性; +4. 是否存在热像素、双峰歧义或低 q 尾部假峰; +5. PONI、q_unit、mask、q 窗口和输入强度校正是否与实验记录一致。 + +真实数据的窗口必须由目标峰和探测器覆盖决定。某一帧在 `q_max=0.5 nm^-1` 时出现的边界截断,不能推出所有帧都应使用 `0.8 nm^-1`;扩大窗口后也不能把不同窗口下的 SNR 直接比较。 diff --git a/docs/user_guide_zh.md b/docs/user_guide_zh.md index 8f0eaa2..86ce8f2 100644 --- a/docs/user_guide_zh.md +++ b/docs/user_guide_zh.md @@ -242,6 +242,23 @@ PowerShell 中若使用通配符,建议加引号让 CLI 自己展开;CLI 也 --series hold_375C --range 60:120:2 --stream ``` +无人值守分析使用同一 `batch` 入口,加 `--unattended PACKAGE` 串联逐帧预检、逐环花瓣脊线识别、双椭圆候选评估、流式批次导出和检查点。`PACKAGE` 是原始数据包根目录,应包含所选图像、PONI 与 mask;输出必须在数据包外。首次运行指定新的输出目录,恢复时原命令加 `--resume`: + +```powershell +.\.venv-project\Scripts\python.exe -m butterfly_saxs batch "data\package\images\*.edf" ` + --unattended data\package --poni data\package\geometry\detector.poni ` + --mask data\package\masks\detector.npy --manifest data\package\sequence.csv ` + -o results\unattended_001 --mode independent + +# 中断或失败后:仅在输入、PONI、mask 与科学配置未改变时恢复;失败帧会重试。 +.\.venv-project\Scripts\python.exe -m butterfly_saxs batch "data\package\images\*.edf" ` + --unattended data\package --poni data\package\geometry\detector.poni ` + --mask data\package\masks\detector.npy --manifest data\package\sequence.csv ` + -o results\unattended_001 --mode independent --resume +``` + +该模式缺省选择 `butterfly_curvature` 工作流、`annular_peak` 逐环提峰、`evaluate` 阶段与 0 次重采样;TOML 或显式 CLI 科学参数仍可覆盖具体追踪设置,但不能把无人值守工作流切换为非蝴蝶方法或仅 trace。`--full2d` 仍须显式指定,表示另做整幅像素强度拟合。可用 `-c project.toml` 固定 q 窗口等分析参数,`--preflight-context` 提供数据包背景。运行时预检只读取原始数据,将证据写入 `preflight/`;红灯阻止拟合并返回 2,黄灯继续保留批次证据但最终返回 1。`checkpoint.json`、CSV/JSONL、`results.npz` 与 `evolution.png` 写到输出目录;stdout 只列逐帧状态与证据路径,完整数值读导出文件。返回 0 只表示预检无警告且所有帧通过工程质量门,**不表示科学接受或发表许可**。 + `manifest` 可用 CSV/JSON 提供 `path`、`frame_id`、`time`、`order`、`dataset`、零基 `frame`(或 `frame_index`)等元数据。manifest 文件中的相对 `path` 按 manifest 所在目录解析。它可以让同一个 HDF5/NPZ/TIFF 容器中的不同 dataset/frame 成为独立批处理记录;这些选择器会传给实际读取器并进入 checkpoint 身份。没有 manifest 时使用自然文件名排序。`checkpoint` 记录输入内容 hash、配置 hash、模式和每帧控制状态;`--resume` 只有 hash/mode 相符时才恢复。 配置 hash 还绑定 PONI、mask、valid-mask、sigma、weights 和 uncertainty 文件的当前 SHA-256 内容;文件被替换即使路径不变也会拒绝恢复。输入/校准/mask 源保持只读。 @@ -249,6 +266,8 @@ PowerShell 中若使用通配符,建议加引号让 CLI 自己展开;CLI 也 `--stream` 逐帧把 CSV、JSONL 和 NPZ 数组写入临时证据包,并释放已处理帧的 detector 数组;适合 1679×1475 或更大探测器的长序列。流式 checkpoint 仍只保存数组摘要。resume 会先验证上一轮 manifest、NPZ 元数据和声明的数组成员,再保留已恢复帧的原始数组,只替换实际重跑的帧;如果上一轮 bundle 没有可验证的 manifest/数组,会拒绝恢复。参数、ridge、lobe、椭圆和 flags 仍逐帧保留。取消请求会在当前帧结束后停止,并把 `cancelled`、`processed_count`、`elapsed_s` 与最后 checkpoint 写出。 +当椭圆的 `quantitative_parameters` 未给出全部可用的轴长与倾角时,批次参数表中由该椭圆推得的 `L_N`、`L_z` 和长轴周期的 `value` 留空;原始拟合数值仅写入带 `identifiability_status=undetermined` 的 `candidate_value`。径向反射环的独立周期字段不受这条椭圆门槛影响。 + 恢复只接受 checkpoint 中 `status=ok` 且质量检查通过的帧;失败、质量 FAIL 或不完整帧会在恢复时重新读取和拟合,这是预期的 failed-frame retry 行为。若所有选中帧都能从已验证 checkpoint 恢复,stream exporter 会走 no-op fast path,保留原 NPZ 成员而不重新压缩;这只优化 I/O,不把质量 WARN/FAIL 改写成 PASS。 Qt/service 批处理还支持 `stage = "geometry"`(或 `full2d = false`)只提取 observed ridge/lobe/椭圆;省略这两个字段时保持旧的 full2d 优化行为。geometry 阶段的 `parameters` 是实际测得的椭圆参数,full2d 强度参数保存在独立的 `intensity_parameters` 字段,不会覆盖用户的强度初值。流式导出额外写出 `lobe_measurements.csv`,其中每个观测 lobe 的角度、radial `q_star`、FWHM、SNR、面积、coverage、valid/reason 和 q 单位都是标量列;这张表不把 azimuthal annulus q 冒充 radial 峰。 diff --git a/pyproject.toml b/pyproject.toml index b7cb760..848417d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "butterfly-saxs" -version = "0.4.1" +version = "0.4.2" description = "Identify and parameterize butterfly-pattern 2D SAXS: first-order ring L, constrained double ellipses, and in-situ batch review" readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/scripts/benchmark_sparse_ring.py b/scripts/benchmark_sparse_ring.py new file mode 100644 index 0000000..4fbc591 --- /dev/null +++ b/scripts/benchmark_sparse_ring.py @@ -0,0 +1,222 @@ +"""Benchmark the sparse first-order sector kernel before and after packing. + +The benchmark uses a deterministic synthetic ring and an in-script copy of +the pre-optimization sector scan. It measures the 16-sector peak kernel; +point/arc bookkeeping and scientific acceptance are intentionally excluded. +Results are evidence for engineering performance only. +""" + +from __future__ import annotations + +import argparse +import json +import platform +import sys +import time +from pathlib import Path + +import numpy as np + +from butterfly_saxs.butterfly_ridge import ( + METHOD_VERSION, + _prepare_sparse_first_order_samples, + _sector_first_order_peak, +) + + +SECTORS = tuple( + 30.0 + quadrant + offset + for quadrant in (0.0, 90.0, 180.0, 270.0) + for offset in (30.0, 45.0, 60.0, 75.0) +) + + +def legacy_sector_first_order_peak( + qx: np.ndarray, + qy: np.ndarray, + q: np.ndarray, + intensity: np.ndarray, + valid: np.ndarray, + *, + sector_deg: float, + halfwidth_deg: float, + hint: float, +) -> dict[str, float] | None: + """Reference sector scan corresponding to the pre-optimization path.""" + + if not (np.isfinite(hint) and hint > 0.0): + return None + ang = np.degrees(np.arctan2(qy, qx)) + delta = np.abs(((ang - float(sector_deg) + 180.0) % 360.0) - 180.0) + q_lo, q_hi = 0.70 * hint, 1.45 * hint + selected = ( + np.asarray(valid, dtype=bool) + & np.isfinite(q) + & np.isfinite(intensity) + & (delta <= float(halfwidth_deg)) + & (q >= q_lo) + & (q <= q_hi) + ) + if int(np.count_nonzero(selected)) < 12: + return None + radii = np.asarray(q[selected], dtype=float) + values = np.asarray(intensity[selected], dtype=float) + edges = np.linspace(q_lo, q_hi, 9) + profile = np.full(edges.size - 1, np.nan, dtype=float) + counts = np.zeros(edges.size - 1, dtype=int) + idx = np.digitize(radii, edges) - 1 + for bin_i in range(edges.size - 1): + in_bin = idx == bin_i + counts[bin_i] = int(np.count_nonzero(in_bin)) + if counts[bin_i] >= 2: + profile[bin_i] = float(np.nanmedian(values[in_bin])) + usable = np.isfinite(profile) + if int(np.count_nonzero(usable)) < 3: + return None + peak_i = int(np.nanargmax(np.where(usable, profile, -np.inf))) + peak = float(profile[peak_i]) + baseline = float(np.nanmedian(profile[usable])) + if not (np.isfinite(peak) and np.isfinite(baseline) and baseline > 0 and peak >= 1.30 * baseline): + return None + q_star = float(0.5 * (edges[peak_i] + edges[peak_i + 1])) + in_bin = selected & (q >= edges[peak_i]) & (q < edges[peak_i + 1]) + if int(np.count_nonzero(in_bin)) < 3: + in_bin = selected + median_qx = float(np.nanmedian(qx[in_bin])) + median_qy = float(np.nanmedian(qy[in_bin])) + if not (np.isfinite(median_qx) and np.isfinite(median_qy)): + return None + rows, cols = np.nonzero(in_bin) + nearest = int(np.argmin((qx[in_bin] - median_qx) ** 2 + (qy[in_bin] - median_qy) ** 2)) + return { + "qx": median_qx, + "qy": median_qy, + "q_star": q_star, + "intensity": peak, + "contrast": peak / baseline, + "pixel_x": float(cols[nearest]), + "pixel_y": float(rows[nearest]), + "sector_deg": float(sector_deg), + } + + +def _result_key(result: dict[str, float] | None) -> tuple[tuple[str, float], ...] | None: + if result is None: + return None + return tuple(sorted((key, float(value)) for key, value in result.items())) + + +def _benchmark_data(size: int) -> tuple[np.ndarray, ...]: + axis = np.linspace(-1.0, 1.0, size) + qx, qy = np.meshgrid(axis, axis) + q = np.hypot(qx, qy) + valid = (q >= 0.49) & (q <= 0.51) + image = 0.2 + 4.0 * np.exp(-0.5 * ((q - 0.50) / 0.03) ** 2) + return qx, qy, q, image, valid + + +def _time_call(callable_: object, repeats: int) -> float: + samples = [] + for _ in range(repeats): + started = time.perf_counter() + callable_() + samples.append(time.perf_counter() - started) + return float(np.median(samples)) + + +def run_benchmark(*, size: int, rounds: int, repeats: int) -> dict[str, object]: + qx, qy, q, image, valid = _benchmark_data(size) + hint = 0.50 + + def legacy_sweep() -> tuple[dict[str, float] | None, ...]: + return tuple( + legacy_sector_first_order_peak( + qx, qy, q, image, valid, sector_deg=sector, halfwidth_deg=7.5, hint=hint + ) + for sector in SECTORS + ) + + def optimized_sweep() -> tuple[dict[str, float] | None, ...]: + prepared = _prepare_sparse_first_order_samples(qx, qy, q, image, valid, hint=hint) + return tuple( + _sector_first_order_peak( + qx, + qy, + q, + image, + valid, + sector_deg=sector, + halfwidth_deg=7.5, + hint=hint, + prepared=prepared, + ) + for sector in SECTORS + ) + + legacy_reference = tuple(map(_result_key, legacy_sweep())) + optimized_reference = tuple(map(_result_key, optimized_sweep())) + if legacy_reference != optimized_reference: + raise AssertionError("optimized sparse-ring sector results differ from the legacy oracle") + + legacy_times: list[float] = [] + optimized_times: list[float] = [] + for round_index in range(rounds): + first, second = (legacy_sweep, optimized_sweep) if round_index % 2 == 0 else (optimized_sweep, legacy_sweep) + first_time = _time_call(first, repeats) + second_time = _time_call(second, repeats) + if first is legacy_sweep: + legacy_times.append(first_time) + optimized_times.append(second_time) + else: + optimized_times.append(first_time) + legacy_times.append(second_time) + legacy_median = float(np.median(legacy_times)) + optimized_median = float(np.median(optimized_times)) + return { + "schema_version": "sparse_ring_benchmark.v1", + "scope": "16-sector sparse first-order peak kernel; point/arc bookkeeping excluded", + "method_version": METHOD_VERSION, + "scientific_acceptance": False, + "python": sys.version, + "implementation": platform.python_implementation(), + "numpy": np.__version__, + "shape": list(q.shape), + "effective_pixels": int(np.count_nonzero(valid)), + "hint": hint, + "sector_count": len(SECTORS), + "rounds": rounds, + "repeats_per_round": repeats, + "legacy_times_s": legacy_times, + "optimized_times_s": optimized_times, + "legacy_median_s": legacy_median, + "optimized_median_s": optimized_median, + "speedup": legacy_median / optimized_median, + "input": "deterministic synthetic ring; no experimental acceptance claim", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--size", type=int, default=1024) + parser.add_argument("--rounds", type=int, default=3) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument( + "--output", + type=Path, + default=Path("outputs/benchmarks/sparse_ring.json"), + ) + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + if args.size < 32 or args.rounds < 1 or args.repeats < 1: + parser.error("size must be >= 32 and rounds/repeats must be positive") + if args.output.exists() and not args.force: + parser.error(f"output exists; pass --force to overwrite: {args.output}") + report = run_benchmark(size=args.size, rounds=args.rounds, repeats=args.repeats) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps({"output": str(args.output), **report}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/butterfly_saxs/__init__.py b/src/butterfly_saxs/__init__.py index 6518fd2..c2ea14c 100644 --- a/src/butterfly_saxs/__init__.py +++ b/src/butterfly_saxs/__init__.py @@ -11,7 +11,7 @@ from importlib import import_module from typing import Any -__version__ = "0.4.1" +__version__ = "0.4.2" _LAZY_EXPORTS: dict[str, tuple[str, str]] = { "PublicationStyle": (".publication_models", "PublicationStyle"), diff --git a/src/butterfly_saxs/annular_peak_figures.py b/src/butterfly_saxs/annular_peak_figures.py new file mode 100644 index 0000000..aca80bd --- /dev/null +++ b/src/butterfly_saxs/annular_peak_figures.py @@ -0,0 +1,710 @@ +"""Publication assets for annulus-wise angular SAXS peak trajectories. + +The annular measurement is performed upstream. This module is intentionally +only a renderer and evidence writer: it consumes ``result['annular_peaks']`` +and never re-integrates pixels, chooses a different peak, bridges a missing +ring, or fits an ellipse. A point's ``q`` coordinate is the centre of its +sampled q annulus. It is therefore not a radial ``q*`` and is never converted +to ``2*pi/q`` here. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +import csv +import hashlib +import json +from pathlib import Path +from typing import Any + +import matplotlib as mpl +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure +from matplotlib.lines import Line2D +import numpy as np + +from .cancellation import raise_if_cancelled +from .csv_utils import safe_csv_cell +from .figure_support import _EXPORT_LOCK, _FIGURE_RC +from .serialization import json_safe + + +ANNULAR_FIGURE_METHOD_VERSION = "annular-peak-figures-v1" +ANNULAR_FIGURE_SCHEMA_VERSION = "annular-peak-figure-export-v1" + +_COLORS = ("#0072B2", "#D55E00", "#009E73") +_TARGET_Q = (0.25, 0.40, 0.60) + + +def _finite(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return None + return number if np.isfinite(number) else None + + +def _number(value: Any, default: float = float("nan")) -> float: + number = _finite(value) + return default if number is None else number + + +def _csv_number(value: Any) -> str: + number = _finite(value) + return "" if number is None else format(number, ".17g") + + +def _array(value: Any, *, dtype: Any = float) -> np.ndarray: + """Read a result array without changing the supplied result.""" + + if value is None: + return np.asarray([], dtype=dtype) + try: + values = value.tolist() if isinstance(value, np.ndarray) else value + if dtype is float: + return np.asarray( + [np.nan if item is None else float(item) for item in values], + dtype=np.float64, + ) + return np.asarray(values, dtype=dtype) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("annular result arrays must be one-dimensional numeric values") from exc + + +def _mapping(value: Any, name: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise TypeError(f"{name} must be a mapping") + return value + + +def _payload(result: Mapping[str, Any]) -> Mapping[str, Any]: + """Accept either the annular mapping or a full page result.""" + + if not isinstance(result, Mapping): + raise TypeError("result must be a mapping") + nested = result.get("annular_peaks") + if isinstance(nested, Mapping): + return nested + return result + + +def _angle_delta(left: float, right: float) -> float: + return abs((float(left) - float(right) + 180.0) % 360.0 - 180.0) + + +def _normalise(result: Mapping[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Normalize the already-measured annuli for rendering and tabulation.""" + + payload = _payload(result) + q_unit = str(payload.get("q_unit", "unknown") or "unknown") + q_edges = _array(payload.get("q_edges"), dtype=float) + angle_centers = _array(payload.get("angle_centers_deg"), dtype=float) + raw_annuli = payload.get("annuli", ()) + if raw_annuli is None: + raw_annuli = () + if isinstance(raw_annuli, (str, bytes)) or not isinstance(raw_annuli, Sequence): + raise TypeError("annular_peaks.annuli must be a sequence") + if angle_centers.size and not np.all(np.isfinite(angle_centers)): + raise ValueError("angle_centers_deg must contain finite values") + + rows: list[dict[str, Any]] = [] + for fallback_index, raw in enumerate(raw_annuli): + annulus = dict(_mapping(raw, f"annular_peaks.annuli[{fallback_index}]")) + index_value = annulus.get("annulus_index", fallback_index) + try: + annulus_index = int(index_value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("annulus_index must be an integer") from exc + angles = _array(annulus.get("angle_centers_deg"), dtype=float) + if angles.size == 0: + angles = angle_centers.copy() + if angles.size and not np.all(np.isfinite(angles)): + raise ValueError(f"annulus {annulus_index} angle centers must be finite") + raw_mapping = annulus.get("raw") + raw_mapping = raw_mapping if isinstance(raw_mapping, Mapping) else {} + arrays = { + "raw_mean": _array(annulus.get("raw_mean", raw_mapping.get("mean"))), + "raw_sum": _array(annulus.get("raw_sum", raw_mapping.get("sum"))), + "counts": _array(annulus.get("counts", raw_mapping.get("count")), dtype=np.int64), + "geometry_counts": _array( + annulus.get("geometry_counts", annulus.get("geometric_counts")), + dtype=np.int64, + ), + "coverage": _array(annulus.get("coverage")), + "smoothed_intensity": _array(annulus.get("smoothed_intensity")), + } + lengths = {key: len(value) for key, value in arrays.items() if len(value)} + if angles.size == 0 and lengths: + raise ValueError(f"annulus {annulus_index} needs angle_centers_deg") + if lengths and len(set(lengths.values())) != 1: + raise ValueError(f"annulus {annulus_index} arrays have inconsistent lengths: {lengths}") + length = len(angles) + if lengths and next(iter(lengths.values())) != length: + raise ValueError(f"annulus {annulus_index} arrays must match angle_centers_deg") + for key, values in tuple(arrays.items()): + if len(values) == 0 and length: + fill: Any = np.zeros(length, dtype=np.int64) if key in {"counts", "geometry_counts"} else np.full(length, np.nan) + arrays[key] = fill + selected = annulus.get("selected_peaks", ()) + if selected is None: + selected = () + if isinstance(selected, (str, bytes)) or not isinstance(selected, Sequence): + raise TypeError(f"annulus {annulus_index} selected_peaks must be a sequence") + selected_rows = [dict(_mapping(item, f"annulus {annulus_index} selected peak")) for item in selected] + if len(selected_rows) > 4: + raise ValueError(f"annulus {annulus_index} contains more than four selected peaks") + candidates = annulus.get("candidates", ()) + if candidates is None: + candidates = () + if isinstance(candidates, (str, bytes)) or not isinstance(candidates, Sequence): + raise TypeError(f"annulus {annulus_index} candidates must be a sequence") + candidate_rows = [dict(_mapping(item, f"annulus {annulus_index} candidate")) for item in candidates] + annulus.update( + { + "annulus_index": annulus_index, + "q_center": _number(annulus.get("q_center")), + "q_min": _number(annulus.get("q_min")), + "q_max": _number(annulus.get("q_max")), + "profile_id": str(annulus.get("profile_id", f"annulus-{annulus_index}")), + "angle_centers_deg": angles, + **arrays, + "selected_peaks": selected_rows, + "candidates": candidate_rows, + "status": str(annulus.get("status", "unknown")), + "reason": str(annulus.get("reason") or ""), + } + ) + rows.append(annulus) + rows.sort(key=lambda row: row["annulus_index"]) + if q_edges.size not in {0, len(rows) + 1}: + raise ValueError("q_edges must have one more value than annuli") + if q_edges.size and not np.all(np.isfinite(q_edges)): + raise ValueError("q_edges must contain finite values") + if angle_centers.size == 0 and rows: + angle_centers = np.asarray(rows[0]["angle_centers_deg"], dtype=float) + if angle_centers.size and any(len(row["angle_centers_deg"]) != len(angle_centers) for row in rows): + raise ValueError("all annuli must use the same angle-centre grid") + # The detector accumulator may use a conventional -180..180 grid while + # refined point angles are reported as 0..360. Canonicalize only the + # display grid and carry every profile array along with it. The source + # arrays remain untouched for CSV/NPZ auditability; ``_display_*`` fields + # are internal render-only views. + display_angles = np.mod(np.asarray(angle_centers, dtype=float), 360.0) + angle_order = np.argsort(display_angles, kind="stable") if display_angles.size else np.asarray([], dtype=int) + display_angles = display_angles[angle_order] + for row in rows: + source_angles = np.asarray(row["angle_centers_deg"], dtype=float) + source_display_angles = np.mod(source_angles, 360.0) + source_order = np.argsort(source_display_angles, kind="stable") if source_display_angles.size else np.asarray([], dtype=int) + for key in ("raw_mean", "raw_sum", "counts", "geometry_counts", "coverage", "smoothed_intensity"): + values = np.asarray(row[key]) + if len(values) and len(source_order) == len(values): + row[f"_display_{key}"] = values[source_order] + else: + row[f"_display_{key}"] = values.copy() + row["_source_angle_centers_deg"] = source_angles.copy() + row["_display_angle_centers_deg"] = source_display_angles[source_order] + display_indices: list[int | None] = [] + for peak in row["selected_peaks"]: + source_index = peak.get("angular_bin_index") + try: + source_index_int = int(source_index) + except (TypeError, ValueError, OverflowError): + source_index_int = -1 + if 0 <= source_index_int < len(source_order): + display_index = int(np.flatnonzero(source_order == source_index_int)[0]) + else: + chi = _finite(peak.get("chi_deg")) + if chi is None or not len(row["_display_angle_centers_deg"]): + display_index = None + else: + display_index = int(np.argmin([_angle_delta(value, chi) for value in row["_display_angle_centers_deg"]])) + display_indices.append(display_index) + row["_selected_display_indices"] = display_indices + payload_norm = { + "q_unit": q_unit, + "q_edges": q_edges, + "angle_centers_deg": np.asarray(angle_centers, dtype=float), + "display_angle_centers_deg": display_angles, + "settings": payload.get("settings") if isinstance(payload.get("settings"), Mapping) else {}, + "method_version": payload.get("method_version"), + "q_window": payload.get("q_window"), + "sector_overlap": payload.get("sector_overlap"), + } + return payload_norm, rows + + +def _intensity_unit(data: Mapping[str, Any]) -> tuple[str, str]: + context = data.get("context") + if isinstance(context, Mapping): + metadata = context.get("metadata") + if isinstance(metadata, Mapping): + header = metadata.get("header") + if isinstance(header, Mapping): + value = header.get("IntensityUnit") + if value is not None and str(value).strip(): + return str(value).strip(), "context.metadata.header.IntensityUnit" + return "input intensity units", "fallback: caller-supplied intensity scale; no unit inferred" + + +def _q_bounds(payload: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> tuple[float | None, float | None]: + edges = np.asarray(payload.get("q_edges", ()), dtype=float) + finite = edges[np.isfinite(edges)] + if finite.size: + return float(np.min(finite)), float(np.max(finite)) + centers = np.asarray([_number(row.get("q_center")) for row in rows], dtype=float) + centers = centers[np.isfinite(centers)] + if centers.size: + return float(np.min(centers)), float(np.max(centers)) + return None, None + + +def _metadata(data: Mapping[str, Any], payload: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + intensity_unit, intensity_source = _intensity_unit(data) + q_min, q_max = _q_bounds(payload, rows) + settings = payload.get("settings") if isinstance(payload.get("settings"), Mapping) else {} + overlap = payload.get("sector_overlap") if isinstance(payload.get("sector_overlap"), Mapping) else {} + selected_count = sum(len(row["selected_peaks"]) for row in rows) + return { + "schema_version": ANNULAR_FIGURE_SCHEMA_VERSION, + "method_version": ANNULAR_FIGURE_METHOD_VERSION, + "source_measurement_method_version": payload.get("method_version"), + "q_unit": str(payload.get("q_unit", "unknown")), + "intensity_unit": intensity_unit, + "intensity_unit_source": intensity_source, + "q_range": [q_min, q_max] if q_min is not None and q_max is not None else None, + "settings": json_safe(settings), + "sector_overlap": json_safe(overlap), + "overlap_correlation_warning": str(overlap.get("correlation_statement", "overlapping q annuli may share detector pixels and are correlated; they are not independent samples")), + "annulus_count": len(rows), + "angle_bin_count": int(len(payload.get("angle_centers_deg", ()))), + "selected_peak_count": selected_count, + "max_selected_peaks_per_annulus": 4, + "q_coordinate_is_annulus_center": True, + "radial_q_star_not_used": True, + "spacing_conversion_not_performed": True, + "quadrants_are_not_synthesized": True, + "missing_bins_are_left_blank": True, + "smooth_is_locator_only": True, + "no_ellipse_fitted_or_drawn": True, + "candidate_selection_repeated_at_export": False, + } + + +def _q_edges_for_rows(payload: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> np.ndarray: + provided = np.asarray(payload.get("q_edges", ()), dtype=float) + if len(provided) == len(rows) + 1 and np.all(np.isfinite(provided)): + return provided.copy() + centers = np.asarray([_number(row.get("q_center")) for row in rows], dtype=float) + if not len(rows): + return np.asarray([0.0, 1.0]) + edges = np.full(len(rows) + 1, np.nan, dtype=float) + for index, row in enumerate(rows): + left = _finite(row.get("q_min")) + right = _finite(row.get("q_max")) + if left is not None: + edges[index] = left + if right is not None: + edges[index + 1] = right + finite_centers = np.isfinite(centers) + if np.any(finite_centers): + for index in range(len(edges)): + if np.isfinite(edges[index]): + continue + if index == 0: + edges[index] = centers[finite_centers][0] + elif index == len(edges) - 1: + edges[index] = centers[finite_centers][-1] + else: + edges[index] = np.nanmean(centers[max(0, index - 1): min(len(centers), index + 1)]) + else: + edges = np.arange(len(rows) + 1, dtype=float) + return edges + + +def _angle_edges(centers: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + if centers.size == 0: + return np.asarray([0.0, 360.0]), np.asarray([], dtype=int) + order = np.argsort(centers) + sorted_centers = centers[order] + if sorted_centers.size == 1: + half = 1.0 + return np.asarray([sorted_centers[0] - half, sorted_centers[0] + half]), order + gaps = np.diff(sorted_centers) + first_gap = float(gaps[0]) + last_gap = float(gaps[-1]) + edges = np.empty(sorted_centers.size + 1, dtype=float) + edges[1:-1] = 0.5 * (sorted_centers[:-1] + sorted_centers[1:]) + edges[0] = sorted_centers[0] - 0.5 * first_gap + edges[-1] = sorted_centers[-1] + 0.5 * last_gap + return edges, order + + +def _selected_intensity(peak: Mapping[str, Any]) -> float | None: + for key in ("raw_intensity", "intensity", "peak_intensity"): + value = _finite(peak.get(key)) + if value is not None: + return value + return None + + +def _build_qchi(payload: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any], *, width_mm: float, dpi: int) -> Figure: + height_mm = 108.0 if width_mm == 183.0 else 132.0 + fig = Figure(figsize=(width_mm / 25.4, height_mm / 25.4), dpi=dpi, facecolor="white", edgecolor="white") + FigureCanvasAgg(fig) + if width_mm == 183.0: + ax = fig.add_axes([0.10, 0.20, 0.77, 0.64]) + cax = fig.add_axes([0.90, 0.27, 0.018, 0.48]) + footer_y = 0.055 + else: + ax = fig.add_axes([0.17, 0.25, 0.67, 0.56]) + cax = fig.add_axes([0.88, 0.34, 0.027, 0.38]) + footer_y = 0.045 + if not rows: + ax.text(0.5, 0.5, "No annular profiles supplied", transform=ax.transAxes, ha="center", va="center", fontsize=5.5, color="#555555") + ax.set_axis_off() + fig.suptitle("Raw annular I(q, χ) · no data", y=0.96, fontsize=7.0) + return fig + n_angles = max(len(row["_display_angle_centers_deg"]) for row in rows) + if n_angles == 0: + ax.text(0.5, 0.5, "No angular bins supplied", transform=ax.transAxes, ha="center", va="center", fontsize=5.5, color="#555555") + ax.set_axis_off() + return fig + matrix = np.full((len(rows), n_angles), np.nan, dtype=float) + for row_index, row in enumerate(rows): + values = np.asarray(row["_display_raw_mean"], dtype=float) + matrix[row_index, : len(values)] = values + q_edges = _q_edges_for_rows(payload, rows) + angles = np.asarray(payload.get("display_angle_centers_deg", rows[0]["_display_angle_centers_deg"]), dtype=float) + angle_edges, angle_order = _angle_edges(angles) + matrix = matrix[:, angle_order].T + masked = np.ma.masked_invalid(matrix) + finite = masked.compressed() + if finite.size: + low, high = np.nanpercentile(finite, [0.5, 99.5]) + if not np.isfinite(low) or not np.isfinite(high) or high <= low: + low, high = float(np.nanmin(finite)), float(np.nanmax(finite)) + if high <= low: + low, high = low - 0.5, high + 0.5 + mesh = ax.pcolormesh(q_edges, angle_edges, masked, shading="auto", cmap="cividis", vmin=float(low), vmax=float(high), rasterized=True) + cbar = fig.colorbar(mesh, cax=cax) + cbar.set_label(f"raw mean I ({metadata['intensity_unit']})", fontsize=5.5, labelpad=2.0) + cbar.ax.tick_params(labelsize=5.0, length=1.2, width=0.4, pad=1.0) + for row in rows: + q_center = _finite(row.get("q_center")) + if q_center is None: + continue + for peak in row["selected_peaks"]: + chi = _finite(peak.get("chi_deg")) + if chi is not None: + ax.scatter([q_center], [float(np.mod(chi, 360.0))], s=15, marker="o", facecolors="none", edgecolors="#D55E00", linewidths=0.75, zorder=5) + ax.set_xlabel(f"q annulus centre ({metadata['q_unit']})") + ax.set_ylabel("Azimuth χ (degree)") + ax.set_title("Raw annular intensity I(q, χ) + observed angular maxima", fontsize=7.0) + ax.tick_params(direction="out", length=2.0, width=0.5, pad=1.5) + for spine in ax.spines.values(): + spine.set_linewidth(0.5) + marker = Line2D([0], [0], marker="o", markerfacecolor="none", markeredgecolor="#D55E00", linestyle="None", markersize=3.0, label="observed angular maximum (≤4 per q annulus)") + # Keep the marker key below the title so it does not collide with the q + # xlabel or the two-line scientific boundary footer on the narrow panel. + fig.legend(handles=[marker], loc="upper center", bbox_to_anchor=(0.5, 0.925), frameon=False, fontsize=5.0, handletextpad=0.35) + footer = "Blank cells = missing measured angular support; no missing quadrant is synthesized.\nq is a sampled annulus coordinate; no 2π/q conversion or ellipse fit is applied." + fig.text(0.5, footer_y, footer, ha="center", va="center", fontsize=5.0, linespacing=1.25, color="#555555") + return fig + + +def _representative_rows(rows: Sequence[Mapping[str, Any]]) -> tuple[list[Mapping[str, Any]], list[dict[str, Any]]]: + finite_rows = [row for row in rows if _finite(row.get("q_center")) is not None] + if not finite_rows: + return [], [] + q_values = np.asarray([float(row["q_center"]) for row in finite_rows], dtype=float) + q_low, q_high = float(np.min(q_values)), float(np.max(q_values)) + selected: list[Mapping[str, Any]] = [] + info: list[dict[str, Any]] = [] + used: set[int] = set() + for index, target in enumerate(_TARGET_Q): + in_range = q_low <= target <= q_high + effective = target if in_range else float(np.quantile(q_values, (0.2, 0.5, 0.8)[index])) + order = sorted(range(len(finite_rows)), key=lambda item: abs(q_values[item] - effective)) + choice = next((item for item in order if item not in used), order[0]) + used.add(choice) + selected.append(finite_rows[choice]) + info.append({"requested_q": target, "effective_q": effective, "annulus_index": int(finite_rows[choice]["annulus_index"]), "selection": "target_q" if in_range else "effective_q_quantile"}) + return selected, info + + +def _build_profiles(payload: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any], *, width_mm: float, dpi: int) -> Figure: + height_mm = 120.0 if width_mm == 183.0 else 145.0 + fig = Figure(figsize=(width_mm / 25.4, height_mm / 25.4), dpi=dpi, facecolor="white", edgecolor="white") + FigureCanvasAgg(fig) + reps, _ = _representative_rows(rows) + fig.suptitle("Representative annular I(χ) · raw mean + locator-only smooth" if width_mm == 183.0 else "Representative annular I(χ)\nraw mean + locator-only smooth", x=0.5, y=0.965, fontsize=7.0, linespacing=1.05) + legend_handles = [Line2D([0], [0], color="#555555", marker="o", markerfacecolor="none", linewidth=0.75, markersize=2.5, label="raw annular mean"), Line2D([0], [0], color="#555555", linestyle="--", linewidth=0.95, label="locator-only smooth")] + fig.legend(handles=legend_handles, loc="upper center", bbox_to_anchor=(0.5, 0.91), frameon=False, ncol=2, fontsize=5.0, handlelength=1.3, handletextpad=0.3, columnspacing=0.7) + if not reps: + ax = fig.add_axes([0.18, 0.24, 0.68, 0.52]) + ax.text(0.5, 0.5, "No finite annular q centres supplied", transform=ax.transAxes, ha="center", va="center", fontsize=5.5, color="#555555") + ax.set_axis_off() + else: + left, right = (0.16, 0.89) if width_mm == 183.0 else (0.21, 0.86) + bottom = 0.14 + top = 0.82 + gap = 0.055 + height = (top - bottom - gap * (len(reps) - 1)) / max(1, len(reps)) + for index, row in enumerate(reps): + y = top - (index + 1) * height - index * gap + ax = fig.add_axes([left, y, right - left, height]) + angles = np.asarray(row["_display_angle_centers_deg"], dtype=float) + raw = np.asarray(row["_display_raw_mean"], dtype=float) + smooth = np.asarray(row["_display_smoothed_intensity"], dtype=float) + color = _COLORS[index % len(_COLORS)] + finite_raw = np.isfinite(raw) + if finite_raw.any(): + # Keep NaN bins in the plotted arrays so Matplotlib breaks the + # trace at an unsupported angular bin instead of connecting + # measurements across a masked gap. + ax.plot(angles, raw, color=color, linewidth=0.7, marker="o", markersize=1.4, markerfacecolor="none", label="raw annular mean") + finite_smooth = np.isfinite(smooth) + if finite_smooth.any(): + ax.plot(angles, smooth, color=color, linewidth=1.0, linestyle="--", label="locator-only smooth") + peak_x: list[float] = [] + peak_y: list[float] = [] + for peak in row["selected_peaks"]: + chi = _finite(peak.get("chi_deg")) + intensity = _selected_intensity(peak) + if intensity is None and chi is not None and angles.size: + # A few measurement payloads carry the selected angle but + # omit its duplicate intensity. Read the supplied raw + # bin at that angle for display; this does not reselect a + # peak or alter the stored selected record. + nearest = int(np.argmin([_angle_delta(value, chi) for value in angles])) + if nearest < len(raw): + intensity = _finite(raw[nearest]) + if chi is not None and intensity is not None: + peak_x.append(float(np.mod(chi, 360.0))) + peak_y.append(intensity) + if peak_x: + ax.scatter(peak_x, peak_y, s=17, marker="o", facecolors="white", edgecolors=color, linewidths=0.9, zorder=6) + q_min, q_max = _finite(row.get("q_min")), _finite(row.get("q_max")) + q_center = _finite(row.get("q_center")) + q_text = f"q annulus [{q_min:.4g}, {q_max:.4g}]" if q_min is not None and q_max is not None else f"q annulus centre {q_center:.4g}" if q_center is not None else "q annulus" + ax.set_title(f"{q_text} · {len(row['selected_peaks'])} observed angular maxima", fontsize=5.4, loc="left", pad=1.5, color=color) + ax.set_ylabel(f"I ({metadata['intensity_unit']})", fontsize=5.4) + if index == len(reps) - 1: + ax.set_xlabel("Azimuth χ (degree)", fontsize=5.4) + else: + ax.tick_params(labelbottom=False) + # The display convention is a full azimuthal turn even when the + # first/last bin centres sit half a bin inside 0 and 360 degrees. + ax.set_xlim(0.0, 360.0 if angles.size else 1.0) + ax.tick_params(direction="out", length=2.0, width=0.5, pad=1.3, labelsize=5.0) + ax.grid(axis="y", color="#dddddd", linewidth=0.3) + for spine in ax.spines.values(): + spine.set_linewidth(0.5) + footer = ( + "Raw I(χ) and locator-only smooth are copied from the result; markers = selected peaks.\n" + "q is an annulus coordinate; no 2π/q conversion or gap filling." + if width_mm == 89.0 + else "Raw I(χ) and locator-only smoothing are copied from the supplied annular result; markers are supplied selected peaks.\n" + "q is an annulus coordinate, not radial q*; no missing peaks or quadrants are filled." + ) + fig.text(0.5, 0.045, footer, ha="center", va="center", fontsize=5.0, linespacing=1.25, color="#555555") + return fig + + +def _selected_for_angle(row: Mapping[str, Any], angle: float) -> list[Mapping[str, Any]]: + centers = np.asarray(row["_display_angle_centers_deg"], dtype=float) + if centers.size == 0: + return [] + target_index = int(np.argmin([_angle_delta(value, angle) for value in centers])) + peaks = [] + display_indices = row.get("_selected_display_indices", ()) + for peak_index, peak in enumerate(row["selected_peaks"]): + if peak_index < len(display_indices) and display_indices[peak_index] is not None: + if int(display_indices[peak_index]) == target_index: + peaks.append(peak) + continue + chi = _finite(peak.get("chi_deg")) + if chi is not None: + nearest_index = int(np.argmin([_angle_delta(value, chi) for value in centers])) + if nearest_index == target_index: + peaks.append(peak) + return peaks + + +def _selected_for_source_angle(row: Mapping[str, Any], angle: float) -> list[Mapping[str, Any]]: + """Associate supplied peaks with a source bin for the audit CSV.""" + + centers = np.asarray(row["_source_angle_centers_deg"], dtype=float) + if centers.size == 0: + return [] + target_index = int(np.argmin([_angle_delta(value, angle) for value in centers])) + peaks = [] + for peak in row["selected_peaks"]: + source_index = peak.get("angular_bin_index") + try: + source_index_int = int(source_index) + except (TypeError, ValueError, OverflowError): + source_index_int = -1 + if 0 <= source_index_int < len(centers): + if source_index_int == target_index: + peaks.append(peak) + continue + chi = _finite(peak.get("chi_deg")) + if chi is not None: + nearest_index = int(np.argmin([_angle_delta(value, chi) for value in centers])) + if nearest_index == target_index: + peaks.append(peak) + return peaks + + +_PROFILE_FIELDS = ( + "record_type", "annulus_index", "profile_id", "q_unit", "intensity_unit", "q_min", "q_max", "q_center", "angle_index", "chi_deg", "raw_mean", "raw_sum", "counts", "geometry_counts", "coverage", "smoothed_intensity", "status", "reason", "selected", "selected_point_ids", "candidate_count", "candidate_json", +) + + +def _write_profiles_csv(path: Path, rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any]) -> None: + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=_PROFILE_FIELDS, extrasaction="ignore") + writer.writeheader() + for row in rows: + angles = np.asarray(row["_source_angle_centers_deg"], dtype=float) + for index, angle in enumerate(angles): + selected = _selected_for_source_angle(row, float(angle)) + values = { + "record_type": "profile", "annulus_index": row["annulus_index"], "profile_id": row["profile_id"], "q_unit": metadata["q_unit"], "intensity_unit": metadata["intensity_unit"], "q_min": _csv_number(row.get("q_min")), "q_max": _csv_number(row.get("q_max")), "q_center": _csv_number(row.get("q_center")), "angle_index": index, "chi_deg": _csv_number(angle), + "raw_mean": _csv_number(row["raw_mean"][index]), "raw_sum": _csv_number(row["raw_sum"][index]), "counts": int(row["counts"][index]) if len(row["counts"]) > index else "", "geometry_counts": int(row["geometry_counts"][index]) if len(row["geometry_counts"]) > index else "", "coverage": _csv_number(row["coverage"][index]), "smoothed_intensity": _csv_number(row["smoothed_intensity"][index]), "status": row["status"], "reason": row["reason"], "selected": bool(selected), "selected_point_ids": json.dumps([str(peak.get("point_id", "")) for peak in selected], ensure_ascii=False), "candidate_count": len(row["candidates"]), "candidate_json": json.dumps(json_safe(row["candidates"]), ensure_ascii=False, sort_keys=True, allow_nan=False), + } + writer.writerow({key: safe_csv_cell(value) for key, value in values.items()}) + + +_PEAK_FIELDS = ( + "record_type", "annulus_index", "profile_id", "q_unit", "intensity_unit", "q_min", "q_max", "q_center", "point_id", "trajectory_id", "chi_deg", "qx", "qy", "accepted", "status", "reason", "raw_intensity", "intensity", "prominence", "snr", "candidate_count", "selected_count", "candidate_json", +) + + +def _write_peaks_csv(path: Path, rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any]) -> None: + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=_PEAK_FIELDS, extrasaction="ignore") + writer.writeheader() + for row in rows: + base = {"annulus_index": row["annulus_index"], "profile_id": row["profile_id"], "q_unit": metadata["q_unit"], "intensity_unit": metadata["intensity_unit"], "q_min": _csv_number(row.get("q_min")), "q_max": _csv_number(row.get("q_max")), "q_center": _csv_number(row.get("q_center")), "candidate_count": len(row["candidates"]), "selected_count": len(row["selected_peaks"])} + for record_type, values in (("selected", row["selected_peaks"]), ("candidate", row["candidates"])): + for peak in values: + output = {**base, "record_type": record_type, "point_id": peak.get("point_id", ""), "trajectory_id": peak.get("trajectory_id", ""), "chi_deg": _csv_number(peak.get("chi_deg")), "qx": _csv_number(peak.get("qx")), "qy": _csv_number(peak.get("qy")), "accepted": peak.get("accepted", ""), "status": peak.get("status", row["status"]), "reason": peak.get("reason", row["reason"]), "raw_intensity": _csv_number(peak.get("raw_intensity")), "intensity": _csv_number(peak.get("intensity")), "prominence": _csv_number(peak.get("prominence")), "snr": _csv_number(peak.get("snr")), "candidate_json": json.dumps(json_safe(peak), ensure_ascii=False, sort_keys=True, allow_nan=False)} + writer.writerow({key: safe_csv_cell(value) for key, value in output.items()}) + + +def _write_npz(path: Path, payload: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any]) -> None: + n = len(rows) + m = max((len(row["angle_centers_deg"]) for row in rows), default=0) + def matrix(key: str, *, dtype: Any = float, fill: Any = np.nan) -> np.ndarray: + output = np.full((n, m), fill, dtype=dtype) + for index, row in enumerate(rows): + values = np.asarray(row[key], dtype=dtype) + output[index, : min(m, len(values))] = values[:m] + return output + np.savez_compressed(path, annulus_index=np.asarray([row["annulus_index"] for row in rows], dtype=np.int64), profile_id=np.asarray([str(row["profile_id"]) for row in rows], dtype="U128"), q_center=np.asarray([_number(row.get("q_center")) for row in rows], dtype=float), q_min=np.asarray([_number(row.get("q_min")) for row in rows], dtype=float), q_max=np.asarray([_number(row.get("q_max")) for row in rows], dtype=float), q_edges=np.asarray(payload.get("q_edges", ()), dtype=float), angle_centers_deg=np.asarray(payload.get("angle_centers_deg", ()), dtype=float), display_angle_centers_deg=np.asarray(payload.get("display_angle_centers_deg", ()), dtype=float), raw_mean=matrix("raw_mean"), raw_sum=matrix("raw_sum"), counts=matrix("counts", dtype=np.int64, fill=0), geometry_counts=matrix("geometry_counts", dtype=np.int64, fill=0), coverage=matrix("coverage"), smoothed_intensity=matrix("smoothed_intensity"), selected_peak_count=np.asarray([len(row["selected_peaks"]) for row in rows], dtype=np.int64), q_unit=np.asarray(str(metadata["q_unit"])), intensity_unit=np.asarray(str(metadata["intensity_unit"])), schema_version=np.asarray(ANNULAR_FIGURE_SCHEMA_VERSION)) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path: Path, payload: Any) -> None: + with path.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(json_safe(payload), stream, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + stream.write("\n") + + +def _caption(metadata: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], representative_info: Sequence[Mapping[str, Any]]) -> str: + q_range = metadata.get("q_range") + q_text = f"[{q_range[0]:.6g}, {q_range[1]:.6g}] {metadata['q_unit']}" if q_range else f"unknown range in {metadata['q_unit']}" + selected = metadata["selected_peak_count"] + rep_text = ", ".join(f"{item['effective_q']:.5g} ({item['selection']})" for item in representative_info) or "none" + return ( + "Annular angular-peak trajectory evidence. Each raw profile is the supplied arithmetic-mean I(χ) in one sampled q annulus; " + f"the q range is {q_text} and {len(rows)} annuli contribute {selected} supplied selected angular maxima. " + "The q–χ panel retains finite measured means and leaves missing support blank. At most four selected peaks per q annulus are drawn; " + "candidate lists and rejected annuli remain in the CSV/JSON evidence, and no missing peak or quadrant is synthesized. " + "The representative profiles use target q values 0.25, 0.40 and 0.60 when in range, otherwise the nearest effective q quantiles " + f"({rep_text}). Smoothing is locator-only and is copied from the supplied result. A peak q is the sampled annulus coordinate, not a radial q*; " + "no 2π/q spacing conversion, ellipse fit, or scientific acceptance is performed. Counts and coverage are support statistics, and overlapping annuli may be correlated. " + f"The source intensity scale is {metadata['intensity_unit']} ({metadata['intensity_unit_source']})." + ) + + +def render_annular_peak_figures(result: Mapping[str, Any], *, data: Mapping[str, Any] | None = None, width_mm: float = 183.0, dpi: int = 600) -> dict[str, Figure]: + if not isinstance(result, Mapping): + raise TypeError("result must be an annular_peaks mapping") + width = float(width_mm) + if width not in {89.0, 183.0}: + raise ValueError("width_mm must be 89 or 183") + if isinstance(dpi, bool) or int(dpi) != dpi or int(dpi) <= 0: + raise ValueError("dpi must be a positive integer") + payload, rows = _normalise(result) + metadata = _metadata(data or {}, payload, rows) + with _EXPORT_LOCK, mpl.rc_context(_FIGURE_RC): + figures = {"annular_qchi": _build_qchi(payload, rows, metadata, width_mm=width, dpi=int(dpi)), "annular_profiles": _build_profiles(payload, rows, metadata, width_mm=width, dpi=int(dpi))} + for figure in figures.values(): + figure.canvas.draw() + return figures + + +def export_annular_peak_figures(stage: str | Path, *, data: Mapping[str, Any], result: Mapping[str, Any], cancel_event: Any = None) -> tuple[dict[str, Path], dict[str, Any]]: + """Export annular assets into a caller-owned existing stage directory.""" + + raise_if_cancelled(cancel_event, "annular-peak-figures:validate") + stage_path = Path(stage).expanduser().resolve(strict=True) + if not stage_path.is_dir(): + raise ValueError("stage must be an existing directory") + if not isinstance(data, Mapping) or not isinstance(result, Mapping): + raise TypeError("data and result must be mappings") + width = float(data.get("width_mm", 183.0)) + dpi_raw = data.get("dpi", 600) + if width not in {89.0, 183.0}: + raise ValueError("data.width_mm must be 89 or 183") + if isinstance(dpi_raw, bool) or int(dpi_raw) != dpi_raw or int(dpi_raw) <= 0: + raise ValueError("data.dpi must be a positive integer") + dpi = int(dpi_raw) + payload, rows = _normalise(result) + metadata = _metadata(data, payload, rows) + _, representative_info = _representative_rows(rows) + outputs = {key: stage_path / name for key, name in {"annular_qchi_svg": "annular_qchi.svg", "annular_qchi_pdf": "annular_qchi.pdf", "annular_qchi_png": "annular_qchi.png", "annular_qchi_tiff": "annular_qchi.tiff", "annular_profiles_svg": "annular_profiles.svg", "annular_profiles_pdf": "annular_profiles.pdf", "annular_profiles_png": "annular_profiles.png", "annular_profiles_tiff": "annular_profiles.tiff", "annular_profiles_csv": "annular_profiles.csv", "annular_peaks_csv": "annular_peaks.csv", "annular_profiles_npz": "annular_profiles.npz", "annular_caption": "annular_caption.txt", "annular_manifest": "annular_manifest.json"}.items()} + existing = next((path for path in outputs.values() if path.exists()), None) + if existing is not None: + raise FileExistsError(f"annular figure output already exists in stage: {existing.name}") + # Keep the normalized rows for tabulation, but pass the caller's original + # result back through the renderer. ``payload`` intentionally contains + # only shared metadata and does not duplicate the annulus arrays. + figures = render_annular_peak_figures(result, data=data, width_mm=width, dpi=dpi) + try: + with _EXPORT_LOCK, mpl.rc_context(_FIGURE_RC): + for stem, figure in figures.items(): + raise_if_cancelled(cancel_event, f"annular-peak-figures:render-{stem}") + for suffix, file_format in (("svg", "svg"), ("pdf", "pdf"), ("png", "png"), ("tiff", "tiff")): + raise_if_cancelled(cancel_event, f"annular-peak-figures:save-{stem}-{suffix}") + figure.savefig(outputs[f"{stem}_{suffix}"], format=file_format, dpi=dpi if suffix in {"png", "tiff"} else None, facecolor="white", transparent=False) + figure.clear() + finally: + for figure in figures.values(): + figure.clear() + raise_if_cancelled(cancel_event, "annular-peak-figures:write-data") + _write_profiles_csv(outputs["annular_profiles_csv"], rows, metadata) + _write_peaks_csv(outputs["annular_peaks_csv"], rows, metadata) + _write_npz(outputs["annular_profiles_npz"], payload, rows, metadata) + outputs["annular_caption"].write_text(_caption(metadata, rows, representative_info) + "\n", encoding="utf-8", newline="\n") + for path in outputs.values(): + if path.parent != stage_path: + raise RuntimeError("annular exporter attempted to write outside its caller-owned stage") + file_hashes = {path.name: _sha256(path) for key, path in outputs.items() if key != "annular_manifest"} + manifest = {**metadata, "width_mm": width, "dpi": dpi, "representative_profiles": json_safe(representative_info), "files": list(file_hashes), "sha256": file_hashes, "manifest_excluded_from_own_sha256": True, "caption_file": "annular_caption.txt", "profiles_csv_file": "annular_profiles.csv", "peaks_csv_file": "annular_peaks.csv", "profiles_npz_file": "annular_profiles.npz", "figure_files": ["annular_qchi.svg", "annular_qchi.pdf", "annular_qchi.png", "annular_qchi.tiff", "annular_profiles.svg", "annular_profiles.pdf", "annular_profiles.png", "annular_profiles.tiff"]} + _write_json(outputs["annular_manifest"], manifest) + return outputs, json_safe(manifest) + + +__all__ = ["ANNULAR_FIGURE_METHOD_VERSION", "ANNULAR_FIGURE_SCHEMA_VERSION", "export_annular_peak_figures", "render_annular_peak_figures"] diff --git a/src/butterfly_saxs/annular_trace.py b/src/butterfly_saxs/annular_trace.py new file mode 100644 index 0000000..f20eef5 --- /dev/null +++ b/src/butterfly_saxs/annular_trace.py @@ -0,0 +1,236 @@ +"""Track observed azimuthal maxima outwards through calibrated q annuli. + +The rings are prescribed sampling coordinates, not radial reflection peaks. +No fitted ellipse selects the points and missing lobes remain missing. +""" +from __future__ import annotations + +from collections import defaultdict +import math +import time +from typing import Any + +import numpy as np + +from .arc_support import freeze_observed_support +from .butterfly_ridge import ( + _apply_edits, _apply_seeds, _arc_topology, _assign_reference_branches, + _normalise_options, _parse_q_window, _point_signature, _public_point, + _refresh_arc_identity, +) +from .cancellation import raise_if_cancelled +from .ridge_inputs import canonical_inputs +from .sector_peaks import _local_representative_q_step +from .settings import strict_int + +METHOD_VERSION = "butterfly-annular-trajectory-v1.0" + + +def _select_and_connect(points, *, angle_step_deg, min_track_points=3): + """One supported, unambiguous peak per observed quadrant and annulus. + + Ambiguous competitors remain visible as rejected candidates. Chains join + only consecutive annuli with a bounded angular displacement, so neither + masks nor a missing ring are silently bridged. + """ + choices = defaultdict(list) + for i, point in enumerate(points): + if point["accepted"] and point["branch_id"] in (0, 1): + choices[(point["annulus_index"], point["quadrant"])].append(i) + elif point["accepted"]: + point.update(accepted=False, valid=False, reason="reference_axis_or_streak_region") + chosen = {} + for key, indices in choices.items(): + ordered = sorted(indices, key=lambda i: points[i]["prominence"], reverse=True) + ambiguous = len(ordered) > 1 and points[ordered[0]]["prominence"] < 1.35 * points[ordered[1]]["prominence"] + for rank, index in enumerate(ordered): + point = points[index] + point["candidate_rank_in_quadrant"] = rank + 1 + if ambiguous or rank: + point.update(accepted=False, valid=False, + reason="ambiguous_angular_peaks" if ambiguous else "weaker_angular_competitor") + else: + chosen[key] = index + groups, edges, used = [], [], set() + for key, start in sorted(chosen.items()): + if start in used: + continue + component = [start] + used.add(start) + ring, quadrant = key + while (ring + 1, quadrant) in chosen: + next_index = chosen[(ring + 1, quadrant)] + a, b = points[component[-1]], points[next_index] + angular_step = abs((b["chi_deg"] - a["chi_deg"] + 180) % 360 - 180) + if angular_step > 2.5 * angle_step_deg: + b["topology_flags"].append("angular_track_jump") + break + edges.append((component[-1], next_index)) + component.append(next_index) + used.add(next_index) + ring += 1 + track_id = len(groups) + for index in component: + point = points[index] + point["trajectory_id"] = track_id + if len(component) < min_track_points: + point.update(accepted=False, valid=False, reason="short_annular_track") + for offset, index in enumerate(component): + left = points[component[max(0, offset - 1)]] + right = points[component[min(len(component) - 1, offset + 1)]] + dx, dy = right["qx"] - left["qx"], right["qy"] - left["qy"] + norm = math.hypot(dx, dy) + if norm: + points[index].update(tangent_qx=dx / norm, tangent_qy=dy / norm) + groups.append(component) + return groups, edges + + +def trace_butterfly_annuli(image, qmap, q_window, *, mask=None, reference_axis_deg=0., + options=None, edits=None, cancel_event=None) -> dict[str, Any]: + from .observables import _azimuthal_peak_ridges, _q_unit + + started = time.perf_counter() + data, qx, qy, q, invalid = canonical_inputs(image, qmap, mask=mask) + window = _parse_q_window(q_window, q) + options = dict(options or {}) + edits = list(options.get("edits", []) if edits is None else edits) + finite = np.isfinite(qx) & np.isfinite(qy) & np.isfinite(q) + geometry = finite & (q >= window[0]) & (q <= window[1]) + valid, applied, seeds = _apply_edits(geometry & ~invalid & np.isfinite(data), qx, qy, edits) + step, step_source, step_details = _local_representative_q_step( + qx, qy, q, finite, *window, q_is_supplied=True) + requested = strict_int(options.get("annular_radial_bins", 40), "annular_radial_bins", minimum=4) + requested_angles = strict_int(options.get("annular_angle_bins", 72), "annular_angle_bins", minimum=16) + angle_bins = requested_angles + if step and np.any(valid): + smallest_radius = float(np.min(q[valid])) + angular_pixel_step = math.degrees(math.atan2(step, smallest_radius)) + angle_bins = min(requested_angles, int(math.floor(360 / angular_pixel_step))) + if angle_bins < 16: + raise ValueError("q domain is too close to the origin for resolved angular profiles") + n_annuli = min(requested, max(1, int((window[1] - window[0]) / step))) if step else requested + if n_annuli < 4: + raise ValueError("q window is too narrow to resolve at least four annuli; widen the analysis window") + accumulator = {} + measured, *_ = _azimuthal_peak_ridges( + data, qmap, window, mask=~valid, n_annuli=n_annuli, n_angle_bins=angle_bins, + snr_threshold=float(options.get("annular_snr_threshold", 3.)), + min_peak_fraction=float(options.get("annular_min_peak_fraction", .2)), + min_coverage=float(options.get("annular_min_coverage", .5)), + min_bin_count=2, diagnostics=accumulator, cancel_event=cancel_event, + ) + signature = _point_signature(data, {**options, "trace_method": "annular_peak"}, window) + excluded = {edit.get("point_id") for edit in edits if edit.get("type") == "exclude_point"} + unit = _q_unit(qmap) + edges_q = np.asarray(accumulator["q_edges"]) + centers_q = np.asarray(accumulator["q_centers"]) + angle_order = np.argsort(np.asarray(accumulator["angle_centers_deg"]) % 360) + display_bin = np.argsort(angle_order) + angles_deg = (np.asarray(accumulator["angle_centers_deg"]) % 360)[angle_order] + dq = float(edges_q[1] - edges_q[0]) + angle_step = 360. / angle_bins + points = [] + for source in measured: + raise_if_cancelled(cancel_event, "annular-trace:points") + ring = int(source.metadata["annulus_index"]) + point_id = f"annular-{signature}-{ring:03d}-{source.metadata['angular_bin_index']:03d}" + radius, chi = float(source.q), float(source.angle) + accepted = point_id not in excluded + points.append({ + "point_id": point_id, "profile_id": f"annulus-{signature}-{ring:03d}", + "annulus_index": ring, "angular_bin_index": int(source.metadata["angular_bin_index"]), + "q_annulus": radius, "chi_deg": math.degrees(chi) % 360, + "qx": radius * math.cos(chi), "qy": radius * math.sin(chi), + "pixel_x": source.metadata["pixel_x"], "pixel_y": source.metadata["pixel_y"], + "source_pixel_role": "representative supporting pixel; peak angle comes from annular I(chi)", + "source_method": "annular_peak", "q_unit": unit, + "intensity": source.metadata.get("raw_intensity", source.intensity), + "snr": float(source.snr), "prominence": float(source.metadata["prominence"]), + "azimuthal_fwhm_deg": math.degrees(source.azimuthal_fwhm), + "n_pixels": source.n_pixels, "coverage": source.metadata["angular_bin_coverage"], + "normal_qx": -math.sin(chi), "normal_qy": math.cos(chi), + "normal_basis": "azimuthal scan direction, not necessarily the intensity ridge normal", + "tangent_qx": math.cos(chi), "tangent_qy": math.sin(chi), + "q_normal_step": radius * math.radians(angle_step), + "sampling_sigma_q": radius * math.radians(angle_step) / math.sqrt(12), + "uncertainty_source": "angular bin resolution, not a confidence interval", + "normal_fwhm_q": float("nan"), "localization_sigma_q": float("nan"), + "radial_bin_width": dq, "scale": 1., "branch_id": -1, "side": "unknown", "arc_id": -1, + "accepted": accepted, "valid": accepted, + "reason": "accepted_annular_peak" if accepted else "excluded_point_edit", + "score": float(source.score), "trajectory_id": None, + "topology_flags": ["annular_angular_maximum", "prescribed_q_not_radial_peak"], + }) + topology = _normalise_options(options) + topology.update(reference_axis_deg=float(reference_axis_deg), min_arc_points=3) + _assign_reference_branches(points, topology) + groups, graph_edges = _select_and_connect(points, angle_step_deg=angle_step) + arcs = _arc_topology(groups, graph_edges, points, topology, dq) + seed_records = _apply_seeds(points, arcs, seeds, topology, dq) + _refresh_arc_identity(arcs, points) + for item in [*points, *arcs]: + item.pop("scale", None) + item.update(scale_stability=float("nan"), scale_stable=False, source_method="annular_peak") + profiles, annuli = {}, [] + for index, radius in enumerate(centers_q): + profile_id = f"annulus-{signature}-{index:03d}" + selected = [p for p in points if p["annulus_index"] == index and p["accepted"]] + candidates = accumulator["annuli"][index]["candidates"] + by_bin = {p["angular_bin_index"]: p for p in points if p["annulus_index"] == index} + for candidate in candidates: + point = by_bin.get(candidate.get("angular_bin_index")) + candidate["trace_accepted"] = bool(point and point["accepted"]) + candidate["trace_reason"] = point["reason"] if point else candidate["reason"] + candidate["point_id"] = point["point_id"] if point else None + candidate["source_angular_bin_index"] = candidate["angular_bin_index"] + candidate["angular_bin_index"] = int(display_bin[candidate["angular_bin_index"]]) + candidate["chi_deg"] = float(candidate["chi_deg"]) % 360 + for point in by_bin.values(): + point["source_angular_bin_index"] = point["angular_bin_index"] + point["angular_bin_index"] = int(display_bin[point["angular_bin_index"]]) + row = { + "annulus_index": index, "profile_id": profile_id, "point_id": profile_id, + "q_center": float(radius), "q_min": float(edges_q[index]), "q_max": float(edges_q[index + 1]), + "raw_mean": accumulator["raw_mean"][index][angle_order], "raw_sum": accumulator["raw_sum"][index][angle_order], + "counts": accumulator["counts"][index][angle_order], "geometry_counts": accumulator["geometry_counts"][index][angle_order], + "coverage": accumulator["coverage"][index][angle_order], "smoothed_intensity": accumulator["smoothed"][index][angle_order], + "candidates": candidates, "selected_peaks": [_public_point(p) for p in selected], + "status": "tracked" if selected else "no_tracked_peak", + "reason": "four_observed_lobes" if len(selected) == 4 else "partial_or_missing_lobe_support", + "profile_only": True, "source_method": "annular_peak", + } + annuli.append(row) + profiles[profile_id] = { + "profile_axis": "azimuthal", "angle_deg": angles_deg, + "raw_intensity": row["raw_mean"], "smoothed_intensity": row["smoothed_intensity"], + "counts": row["counts"], "coverage": row["coverage"], + "peak_angles_deg": [p["chi_deg"] for p in selected], "q_center": row["q_center"], + "q_min": row["q_min"], "q_max": row["q_max"], "q_unit": unit, "reason": row["reason"], + } + support = freeze_observed_support(points, arcs, summary_only=True, cancel_event=cancel_event) + settings = {"requested_radial_bins": requested, "effective_radial_bins": n_annuli, + "requested_angle_bins": requested_angles, "angle_bins": angle_bins, + "q_bin_width": dq, "angle_bin_width_deg": angle_step, + "minimum_track_rings": 3, "competing_peak_ratio": 1.35, + "maximum_track_angle_jump_deg": 2.5 * angle_step, + "q_step": step, "q_step_source": step_source, "q_step_details": step_details, + **accumulator.get("settings", {})} + return { + "method_version": METHOD_VERSION, "q_unit": unit, + "branch_pairing": {"reference_axis_deg": reference_axis_deg, + "families": {"0": ["QI", "QIII"], "1": ["QII", "QIV"]}, + "memberships_frozen_before_fit": True}, + "points": [_public_point(p) for p in points], "arcs": arcs, "profiles": profiles, + "annular_peaks": {"method_version": METHOD_VERSION, "q_unit": unit, "q_window": list(window), + "q_edges": edges_q, "angle_centers_deg": angles_deg, + "settings": settings, "annuli": annuli}, + "diagnostics": {"method": "fixed_q_annulus_angular_peak_trajectory", "q_window": list(window), + "reference_axis_deg": reference_axis_deg, "n_annuli": n_annuli, + "n_points": len(points), "n_accepted_points": sum(p["accepted"] for p in points), + "n_arcs": len(arcs), "n_raw_candidates": sum(len(r["candidates"]) for r in annuli), + "observed_support": support, "applied_edits": applied, "seed_actions": seed_records, + "first_order_q_hint": {"selection_status": "not_used", "q_star": None, + "reason": "annuli_are_sampling_coordinates_not_radial_peaks"}, + "elapsed_s": time.perf_counter() - started}, + } diff --git a/src/butterfly_saxs/arc_geometry.py b/src/butterfly_saxs/arc_geometry.py index 551cc4c..8c3bf10 100644 --- a/src/butterfly_saxs/arc_geometry.py +++ b/src/butterfly_saxs/arc_geometry.py @@ -601,6 +601,7 @@ def _project_ellipse_arc( endpoint_left = (a * np.cos(t_min) - u) ** 2 + (b * np.sin(t_min) - v) ** 2 endpoint_right = (a * np.cos(t_max) - u) ** 2 + (b * np.sin(t_max) - v) ** 2 + active = np.ones(t.shape, dtype=bool) for _ in range(16): sine_t = np.sin(t) cosine_t = np.cos(t) @@ -612,10 +613,11 @@ def _project_ellipse_arc( candidate_squared = (a * np.cos(candidate) - u) ** 2 + (b * np.sin(candidate) - v) ** 2 improve = np.isfinite(candidate_squared) & (candidate_squared <= current) new_t = np.where(improve, candidate, t) - if np.all(np.abs(new_t - t) <= 1.0e-13): - t = new_t + converged = active & (np.abs(new_t - t) <= 1.0e-13) + t = np.where(active, new_t, t) + active &= ~converged + if not np.any(active): break - t = new_t squared_refined = (a * np.cos(t) - u) ** 2 + (b * np.sin(t) - v) ** 2 choose_left = endpoint_left < squared_refined @@ -648,6 +650,15 @@ def _side_domain(side: str) -> tuple[float, float]: return (0.0, math.pi) if side == "upper" else (math.pi, _TAU) +def _projection_domain( + side: str, + manual_intervals: Sequence[tuple[float, float]] | None, +) -> list[tuple[float, float]]: + if manual_intervals is None: + return [_side_domain(side)] + return list(manual_intervals) if manual_intervals else [] + + def _ellipse_linear_coefficients( geometry: EllipseGeometry, tangent: np.ndarray, @@ -900,41 +911,42 @@ def _project_point_to_support( rectangles: Sequence[Mapping[str, Any]], *, include_global_oracle: bool = False, + precomputed_unconstrained: Sequence[Mapping[str, Any]] | None = None, ) -> dict[str, Any]: - domain = list(manual_intervals) if manual_intervals is not None else [ _side_domain(side) ] - if manual_intervals is not None and not manual_intervals: - domain = [] + domain = _projection_domain(side, manual_intervals) candidates: list[dict[str, Any]] = [] local_candidates: list[tuple[float, float, dict[str, Any]]] = [] - unconstrained: list[dict[str, Any]] = [] - point_array = np.asarray([point], dtype=float) - local_u, local_v = _local_coordinates(point_array, geometry) if domain else (None, None) + unconstrained: list[dict[str, Any]] = list(precomputed_unconstrained or ()) + local_u, local_v = None, None + if domain and precomputed_unconstrained is None: + local_u, local_v = _local_coordinates(np.asarray([point], dtype=float), geometry) if rectangles: # Fast path for the common case: the unconstrained closest point in a # manual/side domain already lies inside an observed rectangle. A # point that is feasible for the global domain is necessarily the # constrained optimum, so no sinusoid boundary enumeration is needed. - u, v = local_u, local_v - for domain_index, (domain_lo, domain_hi) in enumerate(domain): - raw_projection = _project_ellipse_arc( - u, - v, - geometry.a, - geometry.b, - np.asarray([domain_lo]), - np.asarray([domain_hi]), - include_global_oracle=include_global_oracle, - ) - unconstrained.append( - { - "t": float(raw_projection["t"][0]), - "distance": float(raw_projection["distance"][0]), - "global_distance": float(raw_projection["global_distance"][0]), - "at_endpoint": bool(raw_projection["at_endpoint"][0]), - "endpoint_clipped": bool(raw_projection["endpoint_clipped"][0]), - "domain_index": domain_index, - } - ) + if precomputed_unconstrained is None: + u, v = local_u, local_v + for domain_index, (domain_lo, domain_hi) in enumerate(domain): + raw_projection = _project_ellipse_arc( + u, + v, + geometry.a, + geometry.b, + np.asarray([domain_lo]), + np.asarray([domain_hi]), + include_global_oracle=include_global_oracle, + ) + unconstrained.append( + { + "t": float(raw_projection["t"][0]), + "distance": float(raw_projection["distance"][0]), + "global_distance": float(raw_projection["global_distance"][0]), + "at_endpoint": bool(raw_projection["at_endpoint"][0]), + "endpoint_clipped": bool(raw_projection["endpoint_clipped"][0]), + "domain_index": domain_index, + } + ) scale = max(1.0, abs(geometry.a), abs(geometry.b)) reachable = [ rectangle @@ -980,28 +992,46 @@ def _project_point_to_support( }, ) ) - for lo, hi, metadata in local_candidates: - # The helper above works in a branch-local frame. Convert the observed - # q point to that frame before evaluating it. - projection = _project_ellipse_arc( - local_u, - local_v, - geometry.a, - geometry.b, - np.asarray([lo]), - np.asarray([hi]), - include_global_oracle=include_global_oracle, - ) - candidates.append( - { - "t": float(projection["t"][0]), - "distance": float(projection["distance"][0]), - "global_distance": float(projection["global_distance"][0]), - "at_endpoint": bool(projection["at_endpoint"][0]), - "endpoint_clipped": bool(projection["endpoint_clipped"][0]), - "metadata": metadata, + if local_candidates: + if precomputed_unconstrained is None: + for lo, hi, metadata in local_candidates: + # The helper above works in a branch-local frame. Convert the observed + # q point to that frame before evaluating it. + projection = _project_ellipse_arc( + local_u, + local_v, + geometry.a, + geometry.b, + np.asarray([lo]), + np.asarray([hi]), + include_global_oracle=include_global_oracle, + ) + candidates.append( + { + "t": float(projection["t"][0]), + "distance": float(projection["distance"][0]), + "global_distance": float(projection["global_distance"][0]), + "at_endpoint": bool(projection["at_endpoint"][0]), + "endpoint_clipped": bool(projection["endpoint_clipped"][0]), + "metadata": metadata, + } + ) + else: + metadata_by_index = { + index: metadata for index, (_, _, metadata) in enumerate(local_candidates) } - ) + for projection in unconstrained: + metadata = metadata_by_index[int(projection["domain_index"])] + candidates.append( + { + "t": projection["t"], + "distance": projection["distance"], + "global_distance": projection["global_distance"], + "at_endpoint": projection["at_endpoint"], + "endpoint_clipped": projection["endpoint_clipped"], + "metadata": metadata, + } + ) if not candidates: if unconstrained: # Rectangle intersection can be empty while the side/manual domain @@ -1048,6 +1078,65 @@ def _project_point_to_support( } +def _batched_unconstrained_arc_projections( + points: np.ndarray, + rows: np.ndarray, + geometry: EllipseGeometry, + sides: np.ndarray, + interval_specs: Sequence[Mapping[str, Any]], + *, + include_global_oracle: bool, +) -> dict[int, list[dict[str, Any]]]: + """Project all supported rows for one branch in one vectorized call.""" + + flat_rows: list[int] = [] + flat_domain_indices: list[int] = [] + flat_domains: list[tuple[float, float]] = [] + for row in rows: + spec = interval_specs[int(row)] + manual_intervals = spec.get("manual_intervals") + rectangles = spec.get("rectangles", ()) + # Rows with neither explicit bounds nor observed support are handled by + # the legacy finite penalty path. Precomputing a side projection for + # them would change that contract. + if manual_intervals is None and not rectangles: + continue + domain = _projection_domain(str(sides[row]), manual_intervals) + for domain_index, interval in enumerate(domain): + flat_rows.append(int(row)) + flat_domain_indices.append(domain_index) + flat_domains.append(interval) + if not flat_rows: + return {} + + flat_points = np.asarray(points[np.asarray(flat_rows, dtype=int)], dtype=float) + local_u, local_v = _local_coordinates(flat_points, geometry) + t_min = np.asarray([interval[0] for interval in flat_domains], dtype=float) + t_max = np.asarray([interval[1] for interval in flat_domains], dtype=float) + projection = _project_ellipse_arc( + local_u, + local_v, + geometry.a, + geometry.b, + t_min, + t_max, + include_global_oracle=include_global_oracle, + ) + grouped: dict[int, list[dict[str, Any]]] = {} + for index, row in enumerate(flat_rows): + grouped.setdefault(row, []).append( + { + "t": float(projection["t"][index]), + "distance": float(projection["distance"][index]), + "global_distance": float(projection["global_distance"][index]), + "at_endpoint": bool(projection["at_endpoint"][index]), + "endpoint_clipped": bool(projection["endpoint_clipped"][index]), + "domain_index": flat_domain_indices[index], + } + ) + return grouped + + def _symmetric_arc_projection( points: np.ndarray, values: Mapping[str, float], @@ -1088,6 +1177,14 @@ def _symmetric_arc_projection( geometry.axis_ratio, reference_axis + sign * geometry.theta, ) + unconstrained_by_row = _batched_unconstrained_arc_projections( + points, + np.flatnonzero(mask), + branch_geometry, + sides, + interval_specs, + include_global_oracle=include_global_oracle, + ) for row in np.flatnonzero(mask): spec = interval_specs[int(row)] projection = _project_point_to_support( @@ -1097,6 +1194,7 @@ def _symmetric_arc_projection( spec.get("manual_intervals"), spec.get("rectangles", ()), include_global_oracle=include_global_oracle, + precomputed_unconstrained=unconstrained_by_row.get(int(row)), ) for name in ( "t", "distance", "global_distance", "at_endpoint", "endpoint_clipped", @@ -1626,6 +1724,7 @@ def fit_arc_ellipses( cancel_event: Any = None, observed_support: Mapping[Any, Any] | None = None, reference_center: Sequence[float] | None = None, + observed_tip_constraint: bool = True, ) -> dict[str, Any]: """Fit a shared-centre mirror pair to labelled bounded ellipse arcs. @@ -1640,6 +1739,8 @@ def fit_arc_ellipses( reference_axis = math.radians(float(reference_axis_deg)) if not np.isfinite(reference_axis): raise ValueError("reference_axis_deg must be finite") + if not isinstance(observed_tip_constraint, (bool, np.bool_)): + raise ValueError("observed_tip_constraint must be boolean") if isinstance(max_nfev, (bool, np.bool_)) or not isinstance(max_nfev, Integral) or int(max_nfev) < 1: raise ValueError("max_nfev must be an integer >= 1") multistart_count = _validate_multistart_count(multistart) @@ -1684,7 +1785,8 @@ def fit_arc_ellipses( ] parameter_set = _parameter_set_for_arcs(xy, parameters, reference_axis) parameter_set = _algebraic_arc_seed(xy, source_labels, parameter_set, reference_axis) - parameter_set = _observed_radius_seed(xy, parameter_set) + if observed_tip_constraint: + parameter_set = _observed_radius_seed(xy, parameter_set) sigma_weights = 1.0 / sigmas def objective(candidate: ParameterSet, labels: np.ndarray) -> np.ndarray: diff --git a/src/butterfly_saxs/batch.py b/src/butterfly_saxs/batch.py index 70cfe0c..92452b9 100644 --- a/src/butterfly_saxs/batch.py +++ b/src/butterfly_saxs/batch.py @@ -229,10 +229,20 @@ def _file_content_fingerprint(value: Any) -> dict[str, Any] | None: return record -def _config_with_file_fingerprints(value: Any, *, key: str | None = None) -> Any: +def _config_with_file_fingerprints( + value: Any, + *, + key: str | None = None, + require_content_hash: bool = False, +) -> Any: """Copy config while binding geometry/mask/uncertainty file contents.""" - return _config_with_file_fingerprints_cached(value, key=key, cache={}) + return _config_with_file_fingerprints_cached( + value, + key=key, + cache={}, + require_content_hash=require_content_hash, + ) def _config_with_file_fingerprints_cached( @@ -240,6 +250,7 @@ def _config_with_file_fingerprints_cached( *, key: str | None = None, cache: dict[str, dict[str, Any] | None], + require_content_hash: bool = False, ) -> Any: """Recursive implementation with one bounded digest per canonical path.""" @@ -284,20 +295,46 @@ def _config_with_file_fingerprints_cached( fingerprint = _file_content_fingerprint(candidate) if cache_key is not None: cache[cache_key] = fingerprint + candidate_is_file_path = ( + isinstance(candidate, (str, os.PathLike, Path)) + and str(candidate).strip().casefold() not in {"in-memory", "in_memory"} + ) + if ( + require_content_hash + and candidate_is_file_path + and (fingerprint is None or fingerprint.get("sha256") is None) + ): + raise ValueError( + "configured analysis file SHA-256 is unavailable; " + f"refusing checkpoint/resume for {candidate!s}" + ) if fingerprint is not None: return {"value": _json_safe(value), "content": fingerprint} if isinstance(value, Mapping): return { - str(name): _config_with_file_fingerprints_cached(item, key=str(name), cache=cache) + str(name): _config_with_file_fingerprints_cached( + item, + key=str(name), + cache=cache, + require_content_hash=require_content_hash, + ) for name, item in value.items() } if is_dataclass(value): return _config_with_file_fingerprints_cached( - {item.name: getattr(value, item.name) for item in fields(value)} - , cache=cache + {item.name: getattr(value, item.name) for item in fields(value)}, + cache=cache, + require_content_hash=require_content_hash, ) if isinstance(value, (list, tuple, set, frozenset)): - return [_config_with_file_fingerprints_cached(item, cache=cache) for item in value] + return [ + _config_with_file_fingerprints_cached( + item, + cache=cache, + require_content_hash=require_content_hash, + ) + for item in value + ] return _json_safe(value) @@ -896,6 +933,7 @@ def input_fingerprint( *, progress: Callable[[Mapping[str, Any]], Any] | None = None, cancel_event: Any = None, + require_content_hash: bool = False, ) -> str: """SHA-256 identity of every selected frame file. @@ -906,6 +944,7 @@ def input_fingerprint( items = list(refs) records: list[dict[str, Any]] = [] + unavailable: list[str] = [] total = len(items) for index, ref in enumerate(items): if _is_cancelled(cancel_event): @@ -947,6 +986,16 @@ def input_fingerprint( stat["content_hash_algorithm"] = "sha256" stat["content_sha256"] = None stat["content_hash_unavailable"] = True + else: + stat.update( + { + "content_hash_algorithm": "sha256", + "content_sha256": None, + "content_hash_unavailable": True, + } + ) + if require_content_hash and stat.get("content_sha256") is None: + unavailable.append(str(path)) ref_record = ref.to_dict() ref_record["path"] = _canonical_path(path) records.append({"ref": ref_record, "file": stat}) @@ -958,14 +1007,27 @@ def input_fingerprint( "total": total, } ) + if unavailable: + raise ValueError( + "input content SHA-256 is unavailable; refusing checkpoint/resume for: " + + ", ".join(unavailable[:5]) + ) return _hash_json(records) -def config_fingerprint(config: Any = None, *, mode: str = "independent") -> str: +def config_fingerprint( + config: Any = None, + *, + mode: str = "independent", + require_content_hash: bool = False, +) -> str: return _hash_json( { "mode": mode, - "config": _config_with_file_fingerprints(config), + "config": _config_with_file_fingerprints( + config, + require_content_hash=require_content_hash, + ), } ) @@ -1224,6 +1286,13 @@ def _quality_failure_reason(result: Any) -> str | None: if result is None: return "result=None" + quality_status = _named_value(result, "quality_status") + if _is_failure_status(quality_status): + return f"quality_status={quality_status}" + quality = _named_value(result, "quality") + nested_quality_status = _named_value(quality, "status") + if _is_failure_status(nested_quality_status): + return f"quality.status={nested_quality_status}" butterfly = _butterfly_payload(result) if butterfly is None: butterfly = _named_value(result, "butterfly") @@ -1705,6 +1774,9 @@ def run_batch( ) callback = progress if progress is not None else on_progress started_batch = __import__("time").perf_counter() + # Checkpointed runs must carry content identities from the start. A + # resumed run repeats the same gate before comparing the stored hashes. + require_content_hash = checkpoint_file is not None or resume def cancelled() -> bool: return _is_cancelled(cancel_event) @@ -1739,11 +1811,16 @@ def hash_progress(payload: Mapping[str, Any]) -> None: refs, progress=hash_progress if callback is not None else None, cancel_event=cancelled, + require_content_hash=require_content_hash, ) if cancelled(): raise AnalysisCancelled("batch cancelled while hashing inputs") emit_phase("config_fingerprint") - config_hash = config_fingerprint(fingerprint_config, mode=mode) + config_hash = config_fingerprint( + fingerprint_config, + mode=mode, + require_content_hash=require_content_hash, + ) if cancelled(): raise AnalysisCancelled("batch cancelled while hashing config") emit_phase("analyze") diff --git a/src/butterfly_saxs/butterfly.py b/src/butterfly_saxs/butterfly.py index b19d2f2..118ac91 100644 --- a/src/butterfly_saxs/butterfly.py +++ b/src/butterfly_saxs/butterfly.py @@ -18,7 +18,9 @@ from .butterfly_quality import PARAMETERS, evaluate_arc_evidence from .cancellation import raise_if_cancelled from .public_ellipse import canonical_ellipse_payload, observed_arc_radius_period +from .ridge_inputs import canonical_inputs from .serialization import strict_jsonable +from .settings import canonical_q_unit def _read(value, name, default=None): @@ -43,15 +45,6 @@ def _qmap_unit(qmap: Any) -> str: return str(unit or "unknown") -def _image(frame): - if isinstance(frame, np.ndarray): - return np.asarray(frame, dtype=float) - value = _read(frame, "data", _read(frame, "image", None)) - if value is None: - raise ValueError("butterfly analysis requires a two-dimensional image") - return np.asarray(value, dtype=float) - - def _attach_candidate_diagnostics(candidate, source): """Retain observed/arc diagnostics when no optimizer result exists.""" @@ -126,10 +119,14 @@ def _fit_trace(trace, *, parameters, reference, multistart, unit, cancel_event, ) try: reference_center = _trace_reference_center(trace) + sampling_options = ( + {"observed_tip_constraint": False} + if trace.get("method_version", "").startswith("butterfly-annular-") else {} + ) result = fit_arc_ellipses(trace["points"], parameters=parameters, reference_axis_deg=reference, multistart=multistart, max_nfev=max_nfev, cancel_event=cancel_event, - reference_center=reference_center) + reference_center=reference_center, **sampling_options) except ValueError as exc: # Keep the observed trace and candidate diagnostics available to the # correction/export boundary when the optimizer rejects its input. @@ -194,6 +191,10 @@ def _fit_trace(trace, *, parameters, reference, multistart, unit, cancel_event, q_star, radius_period, radius_flags = observed_arc_radius_period( supported, unit, first_order_q=hint ) + annular = trace.get("method_version", "").startswith("butterfly-annular-") + if annular: + q_star, radius_period = None, None + radius_flags = ("prescribed_q_not_radial_peak",) bound_flags = getattr(fit, "bound_flags", {}) or {} extra_flags = list(radius_flags) if bound_flags.get("axis_ratio"): @@ -201,7 +202,7 @@ def _fit_trace(trace, *, parameters, reference, multistart, unit, cancel_event, payload["q_star_from_arcs"] = q_star payload["L_from_observed_radius_nm"] = radius_period payload["q_star_source"] = ( - "first_order_iq" + "unavailable_prescribed_annuli" if annular else "first_order_iq" if "spacing_from_first_order_iq" in radius_flags else "observed_arc_radius" ) @@ -686,10 +687,16 @@ def _sensitivity(image, qmap, window, *, mask, options, parameters, reference, if qstep is not None and window[1] - window[0] > 4 * qstep: variants.extend([("q_contract", [window[0] + qstep, window[1] - qstep], {}), ("q_expand", [max(0., window[0] - qstep), window[1] + qstep], {})]) - scales = options.get("smoothing_scales", options.get("scales_px", [1.2, 2.2, 3.6])) - variants.extend((f"smoothing_{factor}", window, - {"smoothing_scales": [float(x) * factor for x in scales]}) - for factor in (0.75, 1.25)) + if options.get("trace_method") == "annular_peak": + variants.extend((f"annular_bins_{factor}", window, + {"annular_radial_bins": max(4, round(options.get("annular_radial_bins", 40) * factor)), + "annular_angle_bins": max(16, round(options.get("annular_angle_bins", 72) * factor))}) + for factor in (.75, 1.25)) + else: + scales = options.get("smoothing_scales", options.get("scales_px", [1.2, 2.2, 3.6])) + variants.extend((f"smoothing_{factor}", window, + {"smoothing_scales": [float(x) * factor for x in scales]}) + for factor in (0.75, 1.25)) if qstep is not None: for axis in ("center_qx", "center_qy"): for sign in (-1, 1): @@ -843,6 +850,90 @@ def _sensitivity(image, qmap, window, *, mask, options, parameters, reference, "center_perturbation_kind": "half_pixel_sensitivity_not_instrument_uncertainty"} +def _sector_measurement_summary(trace: Mapping[str, Any], q_unit: Any, edits: Any) -> dict[str, Any]: + """Summarize selected radial-sector peaks without changing fit fields. + + The sector tracer measures one profile per finite azimuthal footprint. Its + median is therefore an observed-sector statistic, rather than the radius + of a fitted ellipse or a claim that every azimuth contains one identical + ring. Keep this summary separate from ``candidate_fit`` so the historical + ``q_star_from_arcs`` field continues to describe the value actually + produced by the arc-fit path. + """ + + bundle = trace.get("sector_peaks") if isinstance(trace, Mapping) else None + bundle = bundle if isinstance(bundle, Mapping) else {} + sectors = bundle.get("sectors") + sectors = sectors if isinstance(sectors, (list, tuple)) else [] + excluded_ids = { + str(edit.get("point_id")) + for edit in (edits if isinstance(edits, (list, tuple)) else []) + if isinstance(edit, Mapping) + and str(edit.get("type", "")) == "exclude_point" + and edit.get("point_id") not in (None, "") + } + selected_before: list[float] = [] + selected_after: list[float] = [] + for sector in sectors: + if not isinstance(sector, Mapping): + continue + peak = sector.get("selected_peak") + if isinstance(peak, Mapping): + q_value = peak.get("q_star", sector.get("selected_peak_q", sector.get("q_star"))) + else: + q_value = sector.get("selected_peak_q", sector.get("q_star")) + try: + q_value = float(q_value) + except (TypeError, ValueError): + continue + if not np.isfinite(q_value) or q_value <= 0.0: + continue + selected_before.append(q_value) + point_id = str(sector.get("point_id", "")) + manually_excluded = point_id in excluded_ids or str( + sector.get("geometry_reason", sector.get("reason", "")) + ) == "excluded_point_edit" + if not manually_excluded: + selected_after.append(q_value) + + q_median = float(np.median(np.asarray(selected_after, dtype=float))) if selected_after else None + try: + canonical_unit = canonical_q_unit(q_unit) + except (TypeError, ValueError): + canonical_unit = str(q_unit or "unknown") + if canonical_unit == "nm⁻¹": + q_to_nm_inverse = 1.0 + elif canonical_unit == "Å⁻¹": + q_to_nm_inverse = 10.0 + else: + q_to_nm_inverse = None + apparent_period = ( + float(2.0 * np.pi / (q_median * q_to_nm_inverse)) + if q_median is not None and q_to_nm_inverse is not None + else None + ) + overlap = bundle.get("sector_overlap") + overlap = dict(overlap) if isinstance(overlap, Mapping) else {} + return { + "q_star_sector_median": q_median, + "q_star_sector_median_unit": str(q_unit or "unknown"), + "q_unit": str(q_unit or "unknown"), + "q_star_source": "selected_sector_peak_median", + "apparent_period_from_sector_median_nm": apparent_period, + "apparent_period_unit": "nm" if apparent_period is not None else None, + "n_selected_sector_peaks": len(selected_after), + "n_selected_sector_peaks_before_manual_exclusion": len(selected_before), + "n_manual_excluded_sector_peaks": len(selected_before) - len(selected_after), + "aggregation": "median of selected finite sector-profile peaks after manual exclusion", + "peak_order": "unassigned", + "interpretation": ( + "selected-sector q* median; it does not assert one ring at every angle; " + "overlapping sectors are correlated and reflection order is unassigned" + ), + "sector_overlap": overlap, + } + + def analyze_butterfly(image, qmap, q_window, *, mask=None, options=None, parameters=None, reference_axis_deg=0., multistart=7, cancel_event=None, _radial_hint_cache=None): @@ -853,9 +944,9 @@ def analyze_butterfly(image, qmap, q_window, *, mask=None, options=None, settings = normalize_butterfly_settings(options) settings.setdefault("snr_threshold", DEFAULT_ANALYSIS_SETTINGS["ridge_snr_threshold"]) settings.setdefault("max_nfev", DEFAULT_ANALYSIS_SETTINGS["max_nfev"]) - image = _image(image) - if image.ndim != 2: - raise ValueError("butterfly analysis requires a 2D image") + image, _qx, _qy, _q, input_invalid = canonical_inputs(image, qmap, mask=mask) + mask = input_invalid + base_invalid = np.asarray(input_invalid, dtype=bool) q_unit = _qmap_unit(qmap) for qname, pname in (("center_qx", "cx"), ("center_qy", "cy")): if isinstance(parameters, Mapping) and pname in parameters: @@ -864,15 +955,37 @@ def analyze_butterfly(image, qmap, q_window, *, mask=None, options=None, radial_hint_cache = _radial_hint_cache if ( radial_hint_cache is None + and settings["trace_method"] == "curvature" and settings["stage"] == "evaluate" and settings["resamples"] > 0 ): radial_hint_cache = _RadialHintGeometryCache() raise_if_cancelled(cancel_event, "butterfly:trace") - trace = trace_butterfly_ridges(image, qmap, q_window, mask=mask, - reference_axis_deg=reference_axis_deg, options=settings, - edits=settings["edits"], cancel_event=cancel_event, - radial_hint_cache=radial_hint_cache) + if settings["trace_method"] == "annular_peak": + from .annular_trace import trace_butterfly_annuli + + trace = trace_butterfly_annuli( + image, qmap, q_window, mask=mask, reference_axis_deg=reference_axis_deg, + options=settings, edits=settings["edits"], cancel_event=cancel_event, + ) + elif settings["trace_method"] == "radial_sector": + from .sector_trace import trace_butterfly_sector_peaks + + trace = trace_butterfly_sector_peaks( + image, qmap, q_window, mask=mask, reference_axis_deg=reference_axis_deg, + options=settings, edits=settings["edits"], cancel_event=cancel_event, + ) + else: + # UI-only sector controls must not change the historical curvature + # point identity or invalidate saved point exclusions for that method. + curvature_settings = { + key: value for key, value in settings.items() + if key != "trace_method" and not key.startswith(("sector_", "annular_")) + } + trace = trace_butterfly_ridges(image, qmap, q_window, mask=mask, + reference_axis_deg=reference_axis_deg, options=curvature_settings, + edits=settings["edits"], cancel_event=cancel_event, + radial_hint_cache=radial_hint_cache) candidate = _empty_candidate(q_unit, reference_axis_deg, diagnostics=trace) uncertainty = {"intervals": {}, "coverage_calibrated": False, "status": "not_run"} sensitivity = {"completed": False, "records": [], "held_out_arcs": []} @@ -886,7 +999,18 @@ def analyze_butterfly(image, qmap, q_window, *, mask=None, options=None, def refit(perturbed, overrides): overrides = dict(overrides) perturbed_qmap = overrides.pop("qmap", qmap) - perturbed_mask = overrides.pop("mask", mask) + override_mask = overrides.pop("mask", None) + if override_mask is None: + perturbed_mask = base_invalid + else: + try: + override_mask = np.asarray( + np.broadcast_to(np.asarray(override_mask, dtype=bool), base_invalid.shape), + dtype=bool, + ) + except (TypeError, ValueError) as exc: + raise ValueError("resampling mask must broadcast to image shape") from exc + perturbed_mask = base_invalid | override_mask replicate_radial_cache = radial_hint_cache qmap_perturbation = overrides.get("qmap_perturbation") if isinstance(qmap_perturbation, Mapping): @@ -925,13 +1049,55 @@ def refit(perturbed, overrides): for row in evidence["quantitative_parameters"].values(): row.update(status="not_evaluated", empirical_status="not_evaluated", reason="geometry_not_evaluated", reasons=["geometry_not_evaluated"]) + method_version = trace["method_version"] if settings["trace_method"] != "curvature" else METHOD_VERSION + sector_measurement = None + if settings["trace_method"] == "radial_sector": + candidate["flags"] = list(dict.fromkeys([ + *(candidate.get("flags") or ()), "peak_order_unassigned", "finite_sector_footprint", + ])) + evidence["quality"]["measurement_definition"] = "dominant peak of a finite azimuthal-sector radial mean profile" + # Keep this observed-sector statistic independent from the historical + # arc-fit aliases. In particular, do not relabel a fitted + # ``q_star_from_arcs`` value as a sector median when the two happen to + # be numerically close. + sector_measurement = _sector_measurement_summary( + trace, q_unit, settings.get("edits", []) + ) + if settings["trace_method"] == "annular_peak": + candidate["flags"] = list(dict.fromkeys([ + *(candidate.get("flags") or ()), "prescribed_q_not_radial_peak", "angular_scan_not_normal_ridge", + ])) + evidence["quality"]["measurement_definition"] = "azimuthal maxima on successive prescribed q annuli" + candidate["major_axis_prior"] = { + "observed_tip_constraint": False, + "reason": "the analysis q boundary is not evidence of an ellipse tip", + "explicit_user_bounds_preserved": True, + } + observed_radii = [float(np.hypot(p["qx"], p["qy"])) for p in trace["points"] if p.get("accepted")] + extent = max(observed_radii, default=0.) + major = candidate.get("a") + extrapolated = bool(major is not None and extent > 0 and major > 3 * extent) + candidate["axis_identifiability"] = { + "major_axis_status": "not_identified" if extrapolated else "requires_sensitivity_review", + "observed_radius_max_q": extent or None, + "a_over_observed_radius_max": float(major / extent) if major is not None and extent else None, + "sampling_scale_is_not_measurement_uncertainty": True, + "optimizer_weighting": "inverse angular-bin sampling scale; not detector-error weighting", + } payload = {**trace, **evidence, "candidate_fit": candidate, "uncertainty": uncertainty, "sensitivity": sensitivity, "edits": settings["edits"], "settings": settings, - "method_version": METHOD_VERSION} + "method_version": method_version} + if sector_measurement is not None: + payload["measurement_summary"] = sector_measurement + payload["q_star_sector_median"] = sector_measurement["q_star_sector_median"] + payload["q_star_sector_median_unit"] = sector_measurement["q_star_sector_median_unit"] + payload["apparent_period_from_sector_median_nm"] = sector_measurement[ + "apparent_period_from_sector_median_nm" + ] payload["ellipse_local"] = ellipse_local_views(trace.get("points", []), candidate) payload["recipe_sha256"] = hashlib.sha256(json.dumps(settings, sort_keys=True, ensure_ascii=True, allow_nan=False).encode()).hexdigest() - candidate.update({**evidence, "method_version": METHOD_VERSION, + candidate.update({**evidence, "method_version": method_version, "uncertainty": uncertainty, "sensitivity": sensitivity}) return strict_jsonable(payload) @@ -955,7 +1121,6 @@ def measure_butterfly_observables(frame, qmap, q_window, *, mask=None, options=N # Landmarks are independent display/measurement diagnostics. Calculate # them once at this public adapter, never in each uncertainty refit. from .peak_landmarks import compute_peak_landmarks - from .ridge_inputs import canonical_inputs from .butterfly_ridge import _parse_q_window, _apply_edits peak_image, peak_qx, peak_qy, peak_q, peak_invalid = canonical_inputs(frame, qmap, mask=mask) @@ -964,6 +1129,7 @@ def measure_butterfly_observables(frame, qmap, q_window, *, mask=None, options=N & np.isfinite(peak_qy) & np.isfinite(peak_q) & (peak_q >= peak_window[0]) & (peak_q <= peak_window[1])) peak_valid, peak_edits, _ = _apply_edits(peak_valid, peak_qx, peak_qy, result.get("edits", [])) + companion_mask = ~peak_valid peak_hint = result.get("diagnostics", {}).get("first_order_q_hint", {}) signal_window = peak_hint.get("band") if peak_hint.get("selection_status") == "selected" else None peak_options = dict(settings.get("peak_landmark_options") or {}) @@ -977,17 +1143,27 @@ def measure_butterfly_observables(frame, qmap, q_window, *, mask=None, options=N options=peak_options, cancel_event=cancel_event, ) result["peak_landmarks"]["domain"]["applied_polygon_edits"] = peak_edits - unit = str(_read(qmap, "q_unit", "unknown")) + unit = _qmap_unit(qmap) if settings.get("companion_observables", True): raise_if_cancelled(cancel_event, "butterfly:angular-spectrum") - angular = measure_angular_spectrum(frame, qmap, q_window, n_bins=n_angular_bins, mask=mask) + angular = measure_angular_spectrum( + frame, qmap, q_window, n_bins=n_angular_bins, mask=companion_mask + ) raise_if_cancelled(cancel_event, "butterfly:angular-spectrum") lobes = measure_four_lobe_peaks(angular, symmetric_refine=True, reference_axis_deg=draw_axis_deg - 90.) raise_if_cancelled(cancel_event, "butterfly:lobe-peaks") - profiles, radial_peaks = _measure_lobe_radial_observables(frame, qmap, q_window, lobes, - n_radial_bins=n_radial_bins, snr_threshold=float(result["settings"]["snr_threshold"]), - min_coverage=0., mask=mask, cancel_event=cancel_event) + profiles, radial_peaks = _measure_lobe_radial_observables( + frame, + qmap, + q_window, + lobes, + n_radial_bins=n_radial_bins, + snr_threshold=float(result["settings"]["snr_threshold"]), + min_coverage=0., + mask=companion_mask, + cancel_event=cancel_event, + ) raise_if_cancelled(cancel_event, "butterfly:lobe-profiles") tilt, spread = apparent_lamellar_tilt(lobes, draw_axis_deg=draw_axis_deg) raise_if_cancelled(cancel_event, "butterfly:lobe-summary") @@ -1008,9 +1184,10 @@ def measure_butterfly_observables(frame, qmap, q_window, *, mask=None, options=N profiles, radial_peaks = [], [] tilt, spread = float("nan"), float("nan") points = result.get("points", []) - ridge = {"points": points, "q_unit": unit, "flags": [METHOD_VERSION], + ridge = {"points": points, "q_unit": unit, + "flags": [str(result.get("method_version") or METHOD_VERSION)], "valid_fraction": sum(bool(p.get("accepted")) for p in points) / max(1, len(points)), - "method": "butterfly_curvature"} + "method": result["settings"]["trace_method"] if result["settings"]["trace_method"] != "curvature" else "butterfly_curvature"} return ObservableSet(angular=angular, lobes=lobes, ridge=ridge, ellipse=result["candidate_fit"], phi_app_deg=tilt, phi_app_std_deg=spread, draw_axis_deg=draw_axis_deg, q_unit=unit, lobe_radial_profiles=profiles, diff --git a/src/butterfly_saxs/butterfly_figure.py b/src/butterfly_saxs/butterfly_figure.py index 3f0db59..1d13ed5 100644 --- a/src/butterfly_saxs/butterfly_figure.py +++ b/src/butterfly_saxs/butterfly_figure.py @@ -11,6 +11,7 @@ from collections.abc import Callable, Mapping, Sequence import csv import hashlib +import html import importlib.metadata import json import os @@ -40,6 +41,9 @@ FIGURE_METHOD_VERSION = "butterfly-measurement-figure-v1" +NATURE_FIGURE_GUIDE_URL = ( + "https://research-figure-guide.nature.com/figures/building-and-exporting-figure-panels/" +) _RIDGE_CSV_FIELDS = ( @@ -61,6 +65,17 @@ "source_json", ) +_RADIAL_PROFILE_FIELDS = ( + "bin_index", + "q_unit", + "edge_left", + "edge_right", + "center", + "raw_sum", + "raw_mean", + "count", +) + def _notify( progress: Callable[[int, str], Any] | None, percent: int, phase: str @@ -72,6 +87,8 @@ def _notify( def _draw_radial_diagnostic( ax_profile: Any, ax_count: Any, data: Mapping[str, Any], *, counts_bottom: bool ) -> None: + from matplotlib.ticker import MaxNLocator, ScalarFormatter + centers = data["radial_centers"] means = data["radial_mean"] counts = data["radial_counts"] @@ -79,6 +96,31 @@ def _draw_radial_diagnostic( centers, means, color="#0072b2", linewidth=0.85, marker="o", markersize=1.6 ) ax_profile.set_yscale("symlog", linthresh=data["radial_linthresh"]) + # Matplotlib's default SymmetricalLogLocator only places major ticks at + # powers of ten. A narrow profile can therefore have a valid visible + # range with no labelled major tick at all (for example 3.2--4.2). Keep + # the symlog transform, but use a bounded linear-in-data locator/formatter + # as a display fallback when fewer than two major ticks are visible. + y_low, y_high = (float(value) for value in ax_profile.get_ylim()) + locator = ax_profile.yaxis.get_major_locator() + try: + major_ticks = np.asarray( + locator.tick_values(y_low, y_high), dtype=np.float64 + ) + except (AttributeError, TypeError, ValueError, OverflowError): + major_ticks = np.asarray(ax_profile.get_yticks(), dtype=np.float64) + visible_ticks = major_ticks[ + np.isfinite(major_ticks) + & (major_ticks >= y_low) + & (major_ticks <= y_high) + ] + if visible_ticks.size < 2: + ax_profile.yaxis.set_major_locator( + MaxNLocator(nbins=4, steps=[1, 2, 2.5, 5, 10], prune="both") + ) + formatter = ScalarFormatter(useOffset=False) + formatter.set_powerlimits((-3, 3)) + ax_profile.yaxis.set_major_formatter(formatter) ax_profile.set_ylabel("Mean input intensity (symlog)") ax_profile.set_xlim(float(data["radial_edges"][0]), float(data["radial_edges"][-1])) ax_profile.tick_params(direction="out", length=2.0, width=0.5, pad=1.5) @@ -184,6 +226,15 @@ def _software_version() -> str: return "unknown" +def _q_axis_interpretation(data: Mapping[str, Any]) -> str: + """Keep pixel-q labels explicitly non-physical at the export boundary.""" + + unit = str(data["q_unit"]).strip().lower() + if unit in {"pixel-q", "pixel_q"}: + return "uncalibrated pixel-q; no physical period is inferred" + return str(data["q_axis_interpretation"]) + + def _settings(data: Mapping[str, Any]) -> dict[str, Any]: return { "figure_method_version": FIGURE_METHOD_VERSION, @@ -208,7 +259,8 @@ def _settings(data: Mapping[str, Any]) -> dict[str, Any]: }, "q": { "unit": data["q_unit"], - "axis_interpretation": data["q_axis_interpretation"], + "axis_interpretation": _q_axis_interpretation(data), + "intensity_unit": "raw input scale (caller-supplied; not inferred)", "image_coordinates": "supplied qx/qy mesh; no affine reconstruction or sorting", "radial_origin": [0.0, 0.0], "radial_aggregation": "raw observed intensity sum and unweighted mean across valid pixels in equal-width radial bins", @@ -233,7 +285,8 @@ def _settings(data: Mapping[str, Any]) -> dict[str, Any]: "color_clip_low_pixel_count": data["color_clip_low_count"], "color_clip_high_pixel_count": data["color_clip_high_count"], "negative_values_preserved_by_signed_transform": data["display_scale"] - == "log1p", + in {"log1p", "asinh"}, + "negative_values_preserved": True, "invalid_pixels_masked_in_display": True, "display_transform_changes_source_data": False, }, @@ -321,17 +374,408 @@ def _write_ridge_csv( ) +def _csv_number(value: Any) -> str: + """Serialize one finite numeric value without changing its measurement.""" + + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return "" + if not np.isfinite(number): + return "" + return format(number, ".17g") + + +def _write_radial_profile_csv(path: Path, data: Mapping[str, Any]) -> None: + """Write the raw, one-pixel-per-sample radial aggregation. + + ``count`` is the number of valid source pixels in each bin. Empty bins + retain their exact edges and center, while the undefined raw sum/mean are + left blank rather than serializing a misleading zero or NaN. + """ + + edges = np.asarray(data["radial_edges"], dtype=np.float64) + centers = np.asarray(data["radial_centers"], dtype=np.float64) + raw_sum = np.asarray(data["radial_sum"], dtype=np.float64) + raw_mean = np.asarray(data["radial_mean"], dtype=np.float64) + counts = np.asarray(data["radial_counts"], dtype=np.int64) + bin_count = len(centers) + if len(edges) != bin_count + 1 or len(raw_sum) != bin_count: + raise ValueError("radial aggregation arrays have inconsistent lengths") + if len(raw_mean) != bin_count or len(counts) != bin_count: + raise ValueError("radial aggregation arrays have inconsistent lengths") + + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=_RADIAL_PROFILE_FIELDS) + writer.writeheader() + for index in range(bin_count): + count = int(counts[index]) + writer.writerow( + { + "bin_index": index, + "q_unit": str(data["q_unit"]), + "edge_left": _csv_number(edges[index]), + "edge_right": _csv_number(edges[index + 1]), + "center": _csv_number(centers[index]), + "raw_sum": _csv_number(raw_sum[index]) if count else "", + "raw_mean": _csv_number(raw_mean[index]) if count else "", + "count": count, + } + ) + + +def _figure_qa( + data: Mapping[str, Any], *, model_supplied: bool +) -> dict[str, Any]: + """Describe export integrity and scientific limits without accepting data.""" + + dpi = int(data["dpi"]) + low_dpi = dpi < 300 + width_mm = float(data["width_mm"]) + height_mm = float(data["height_mm"]) + pixel_width = int(width_mm / 25.4 * dpi) + pixel_height = int(height_mm / 25.4 * dpi) + display_scale = str(data["display_scale"]) + qa_status = "WARN" if low_dpi else "PASS" + result_safe = data["result_safe"] + result_quality = result_safe.get("quality") + result_quality = result_quality if isinstance(result_quality, Mapping) else {} + source_scientific_status = str( + result_safe.get("scientific_status") + or result_quality.get("scientific_status") + or "NOT_ACCEPTED" + ) + dpi_check: dict[str, Any] = { + "status": "WARN" if low_dpi else "PASS", + "dpi": dpi, + "minimum_for_raster_submission": 300, + } + if low_dpi: + dpi_check["message"] = ( + "Raster DPI is below 300; use a higher DPI for publication raster output." + ) + + scientific_boundary = { + "status": "recorded_only", + "scientific_acceptance": "not_assessed", + "source_scientific_status": source_scientific_status, + "scientific_acceptance_inferred": False, + "fabricated_points": False, + "main_measurement_panel_candidate_ellipse_drawn": False, + "candidate_assets_separately_labeled": True, + "source_provided_ridge_points_only": True, + "mirrored_or_synthesized_points": False, + "candidate_fit_preserved_in_result_json": True, + "model_overlay_is_diagnostic": True, + "caller_model_supplied": bool(model_supplied), + "fitted_intensity_or_residual_map_inferred": False, + "statement": ( + "Figure QA records export integrity and provenance boundaries; it does " + "not constitute scientific acceptance." + ), + } + no_fabricated_points = { + "status": "PASS", + "value": True, + "source_ridge_point_count": len(data["points"]), + "drawn_source_ridge_point_count": len(data["plot_points"]), + "omitted_source_ridge_point_count": len(data["points"]) + - len(data["plot_points"]), + "mirrored_or_interpolated_points": False, + "unsupported_points_retained_with_reasons": True, + } + editable_text = { + "status": "configured", + "verification": "matplotlib export settings recorded; vector files are not parsed by this QA step", + "svg_fonttype": _FIGURE_RC["svg.fonttype"], + "pdf_fonttype": _FIGURE_RC["pdf.fonttype"], + "editable_svg_text": _FIGURE_RC["svg.fonttype"] == "none", + "editable_pdf_text_setting": _FIGURE_RC["pdf.fonttype"] == 42, + "text_as_paths": False, + } + physical_size = { + "status": "configured", + "verification": "derived from the requested Matplotlib canvas geometry; output files are not parsed by this QA step", + "width_mm": width_mm, + "height_mm": height_mm, + "width_in": width_mm / 25.4, + "height_in": height_mm / 25.4, + "raster_pixel_width": pixel_width, + "raster_pixel_height": pixel_height, + } + q_unit = str(data["q_unit"]) + q_units = { + "status": "recorded", + "unit": q_unit, + "axis_interpretation": _q_axis_interpretation(data), + "physical_period_conversion_performed": False, + "pixel_q_or_unknown_is_not_physical_period": q_unit.strip().lower() + in {"", "unknown", "pixel", "pixel_q", "pixel-q", "px"}, + } + return { + "schema_version": "butterfly.figure_qa.v1", + "status": qa_status, + "status_scope": "export_integrity_only", + "warnings": [dpi_check["message"]] if low_dpi else [], + "scope": { + "measurement_panel": "butterfly_figure.*", + "candidate_assets": "ellipse_only.* and comparison diagnostics", + "model_assets": "model_comparison.* and fit overlay diagnostics when supplied", + "all_bundle_assets_hashed_in_manifest": True, + }, + "physical_size_mm": { + "width": width_mm, + "height": height_mm, + }, + "dpi": dpi, + "editable_text": editable_text, + "q_unit": q_unit, + "display_scale": display_scale, + "radial_profile": { + "file": "radial_profile.csv", + "fields": list(_RADIAL_PROFILE_FIELDS), + "aggregation": "raw observed intensity sum and unweighted mean", + "raw_intensity_unit": "raw input scale (caller-supplied; not inferred)", + "count_definition": "one valid source pixel contributes one count; not a statistical replicate count", + "display_transform_used": False, + "empty_bin_raw_sum_and_mean": "blank", + }, + "scientific_acceptance": "not_assessed", + "source_scientific_status": source_scientific_status, + "no_fabricated_points": True, + "fabricated_points": False, + "nature_reference": NATURE_FIGURE_GUIDE_URL, + "target_specification": { + "reference": NATURE_FIGURE_GUIDE_URL, + "supported_width_mm": [89.0, 183.0], + "selected_width_mm": width_mm, + "body_font_pt": 6.5, + "panel_label_font_pt": 8.0, + "editable_text_requested": True, + "acceptance_claim": False, + }, + "figure": { + "physical_size": physical_size, + "dpi": dpi_check, + "display_scale": display_scale, + "q_unit": q_unit, + }, + "checks": { + "physical_size": physical_size, + "dpi": dpi_check, + "editable_text": editable_text, + "q_units": q_units, + "scientific_boundary": scientific_boundary, + "no_fabricated_points": no_fabricated_points, + }, + "scientific_boundary": scientific_boundary, + } + + +def _html_link(path: Path, *, label: str | None = None) -> str: + """Return a local-only link using a basename, never a private path.""" + + name = html.escape(path.name, quote=True) + text = html.escape(label or path.name) + return f'{text}' + + +def _write_index_html( + path: Path, + *, + data: Mapping[str, Any], + outputs: Mapping[str, Path], + qa: Mapping[str, Any], + caption: str, +) -> None: + """Write a dependency-free, local figure-bundle browser.""" + + # Keep paths that will be written later in the same transaction (notably + # manifest.json) so the browser exposes every promised download link. + available = {item.name: item for item in outputs.values()} + measured_names = ( + "butterfly_figure.png", + "butterfly_figure.svg", + "butterfly_figure.pdf", + "butterfly_figure.tiff", + "measured_only.png", + "measured_only.svg", + "measured_only.pdf", + "measured_only.tiff", + ) + candidate_files = { + path.name + for key, path in outputs.items() + if key.startswith("ellipse_only") + or key.startswith("ellipse_curves") + or key.startswith("comparison") + or key.startswith("geometry_overlay") + or key in {"point_residuals_csv", "normal_profiles_csv"} + } + model_files = { + path.name + for key, path in outputs.items() + if key.startswith("model_comparison") + or key.startswith("intensity_model_overlay") + or key.startswith("fit_") + } + sector_files = { + path.name + for key, path in outputs.items() + if key.startswith("sector_") + } + measured_files = [name for name in measured_names if name in available] + + def links(names: Sequence[str] | set[str]) -> str: + selected = [available[name] for name in names if name in available] + if not selected: + return 'No file in this bundle.' + return " ".join(_html_link(item) for item in selected) + + def preview(name: str, alt: str) -> str: + image = available.get(name) + if image is None or not image.is_file(): + return '
Preview unavailable
' + return ( + f'' + f'{html.escape(alt, quote=True)}' + ) + + result_safe = data["result_safe"] + measurement_status = html.escape(str(result_safe.get("measurement_status", "unknown"))) + candidate_status = html.escape( + str((result_safe.get("candidate_fit") or {}).get("status", "not_available")) + ) + quality = result_safe.get("quality") + quality = quality if isinstance(quality, Mapping) else {} + scientific_status = html.escape( + str( + result_safe.get("scientific_status") + or quality.get("scientific_status") + or "NOT_ACCEPTED" + ) + ) + caption_text = html.escape(caption.strip()) + q_unit = html.escape(str(data["q_unit"])) + qa_status = html.escape(str(qa["status"])) + measured_files_text = links(measured_files) + candidate_files_text = links(sorted(candidate_files)) + model_files_text = links(sorted(model_files)) + sector_files_text = links(sorted(sector_files)) + has_sector_figures = bool(sector_files) + annular_files = tuple(name for name in available if name.startswith("annular_")) + annular_files_text = links(annular_files) + provenance_names = ( + "source_data.npz", + "radial_profile.csv", + "ridge_points.csv", + "result.json", + "settings.json", + "figure_qa.json", + "caption.txt", + "manifest.json", + "sector_qchi.svg", + "sector_qchi.pdf", + "sector_qchi.png", + "sector_qchi.tiff", + "sector_profiles.svg", + "sector_profiles.pdf", + "sector_profiles.png", + "sector_profiles.tiff", + "sector_profiles.csv", + "sector_peaks.csv", + "sector_profiles.npz", + "sector_caption.txt", + "sector_manifest.json", + ) + provenance_links = links(provenance_names) + html_text = f""" + + + + +Butterfly SAXS figure bundle + + + +

Butterfly SAXS measurement figure bundle / 蝴蝶花样测量图包

+

Engineering export · scientific acceptance not assessed · QA {qa_status}

+

This offline browser uses only local relative files. 实测、候选几何和模型/诊断资产分开标记;图包 QA 不等于科学验收。

+ + + + + + + + +
Measured status{measurement_status}
Candidate geometry status{candidate_status}
Scientific status{scientific_status}
q unit{q_unit}
Display scale{html.escape(str(data["display_scale"]))}
Physical size{data["width_mm"]} × {data["height_mm"]} mm
Raster resolution{data["dpi"]} DPI
+{f'''

Annular flower trajectories · 逐环花瓣轨迹

+

Fixed-q angular maxima

{preview("annular_qchi.png", "Azimuthal peaks on successive q rings")}
+

I(χ) on individual rings

{preview("annular_profiles.png", "Raw angular profiles of individual annuli")}
+

Each ring supplies observed angular peaks to the outward petal tracks. Missing lobes remain missing. Ring q is a sampling coordinate, not a measured radial reflection position.

''' if annular_files else ""} +

Measured / observed · 实测

+

Native measurement figure

{preview("butterfly_figure.png", "Measured butterfly SAXS figure")}

Observed intensity, source-provided ridge points, supported observed segments, and raw radial profile.

+

Candidate geometry · 候选

+

Candidate ellipse diagnostics

{preview("ellipse_only.png", "Candidate ellipse diagnostic")}

Candidate fits remain candidates and are not presented as accepted measurements.

+

Model / diagnostic · 模型

+

Caller model and fit diagnostics

{preview("model_comparison.png", "Caller model comparison diagnostic")}

Model or residual assets are diagnostic comparisons and do not add measured points.

+{f'''

Sector-integrated primary peaks · 扇区积分主峰

+

Raw I(q, χ) sector field

{preview("sector_qchi.png", "Raw sector-integrated q-chi intensity")}

Representative I(q | χ)

{preview("sector_profiles.png", "Representative sector-integrated profiles")}
+

The q–χ field and I(q|χ) profiles are copied from the existing sector measurement result. Raw means remain separate from locator-only smoothing; blank bins are missing support, overlapping sectors are correlated, and candidates are not scientific acceptance.

''' if has_sector_figures else ""} +

Pixel-brightness ancillary diagnostics · 像素亮点附属诊断

+

Pixel landmark maps

{preview("peak_map.png", "Pixel-brightness ancillary diagnostic")}

G/P labels are pixel-brightness diagnostics only; they are not sector-integrated peak positions and must not be mixed with the I(q|χ) candidates.

+

Source data and caption · 源数据与图注

+ +
Figure caption
{caption_text}
+ + +""" + path.write_text(html_text, encoding="utf-8", newline="\n") + + def _caption(data: Mapping[str, Any]) -> str: unit = data["q_unit"] + display_scale = str(data["display_scale"]) + if display_scale == "log1p": + display_description = ( + "The image display uses the signed log1p transform " + "sign(I) * log1p(abs(I)); this transform preserves the sign of negative values." + ) + elif display_scale == "asinh": + display_description = ( + "The image display uses the asinh(I) transform; radial values remain " + "the signed raw observed intensities." + ) + else: + display_description = ( + "The image display uses the linear, untransformed intensity; radial values " + "remain the signed raw observed intensities." + ) return ( "Butterfly SAXS measurement figure. (a) Observed two-dimensional intensity on the supplied qx/qy " f"coordinate mesh ({unit}), with source-provided ridge points and supported observed arc segments. " "Accepted and rejected points retain their source status; unspecified status is shown separately. " - "No opposite quadrants or branch sides are synthesized. (b) Pixel-weighted mean of the raw observed " - "intensity in equal-width bins of q radius from the supplied q=(0, 0) origin; bars show n, the number " + "No opposite quadrants or branch sides are synthesized. (b) Unweighted mean of the raw observed " + "intensity in the caller-supplied raw input scale across valid source pixels in equal-width bins of q radius from the supplied q=(0, 0) origin; bars show n, the number " "of valid source pixels per bin. The radial profile uses a recorded symlog display scale but retains signed raw means. " - "Image color limits use the 0.5th–99.5th percentiles of valid pixels; signed log1p preserves negative " - "values in the display transform. Candidate fits are retained in result.json but are not drawn or " + "Image color limits use the 0.5th–99.5th percentiles of valid pixels. " + f"{display_description} Candidate fits are retained in result.json but are not drawn or " "represented as scientifically accepted. No fitted intensity or residual map is inferred from ellipse geometry. " "The caller-supplied selection mask and coordinate arrays are preserved in source_data.npz. " "Ridge points without valid source-pixel support are omitted from the overlay but retained with omission " @@ -445,6 +889,9 @@ def export_butterfly_figure( "result": "result.json", "settings": "settings.json", "caption": "caption.txt", + "radial_profile": "radial_profile.csv", + "figure_qa": "figure_qa.json", + "index": "index.html", "manifest": "manifest.json", } staged = {key: stage / name for key, name in output_names.items()} @@ -531,19 +978,80 @@ def export_butterfly_figure( "display_scale": data["display_scale"]}, landmarks=landmarks, model=model, cancel_event=cancel_event, ) + sector_outputs: dict[str, Path] = {} + sector_metadata: dict[str, Any] = {} + # Sector figures are a pure export of the caller's existing sector + # measurement. They are intentionally kept separate from the + # curvature/landmark calculations above: no image re-integration or + # replacement peak selection occurs in the figure writer. + if "sector_peaks" in result: + sector_result = result.get("sector_peaks") + if not isinstance(sector_result, Mapping): + raise TypeError("result.sector_peaks must be a mapping when supplied") + _notify(progress, 66, "sector-peaks") + raise_if_cancelled(cancel_event, "butterfly-figure:sector-peaks") + from .sector_peak_figures import export_sector_peak_figures + + sector_outputs, sector_metadata = export_sector_peak_figures( + stage, + data={ + "q_unit": data["q_unit"], + "context": data["context"], + "width_mm": data["width_mm"], + "dpi": data["dpi"], + }, + result=sector_result, + cancel_event=cancel_event, + ) figure_settings = _settings(data) + annular_outputs: dict[str, Path] = {} + annular_metadata: dict[str, Any] = {} + if "annular_peaks" in result: + from .annular_peak_figures import export_annular_peak_figures + + annular_outputs, annular_metadata = export_annular_peak_figures( + stage, data={"q_unit": data["q_unit"], "context": data["context"], + "width_mm": data["width_mm"], "dpi": data["dpi"]}, + result=result["annular_peaks"], cancel_event=cancel_event, + ) + figure_settings["annular_peaks"] = annular_metadata figure_settings["comparison"] = comparison_metadata figure_settings["fit_sources"] = overlay_metadata figure_settings["peak_landmarks"] = peak_metadata + if sector_metadata: + figure_settings["sector_peaks"] = sector_metadata _write_json(staged["settings"], figure_settings) caption = _caption(data) caption += "\n\nComparison assets: see comparison_caption.txt." if model is not None: caption += " A model-comparison plate is also supplied from the caller-provided model array." + if sector_outputs: + caption += "\n\nSector-integrated primary peaks: see sector_caption.txt. Pixel-brightness landmark diagnostics remain ancillary and are not sector peak positions." + if annular_outputs: + caption += "\n\nFixed-q annular trajectories: see annular_caption.txt. Angular peak positions trace each petal outwards; the sampled ring radii are not radial reflection peaks." staged["caption"].write_text(caption + "\n", encoding="utf-8") + _notify(progress, 68, "package") + raise_if_cancelled(cancel_event, "butterfly-figure:package") + _write_radial_profile_csv(staged["radial_profile"], data) + figure_qa = _figure_qa(data, model_supplied=model is not None) + _write_json(staged["figure_qa"], figure_qa) + all_staged = { + **staged, + **comparison_outputs, + **overlay_outputs, + **peak_outputs, + **sector_outputs, + **annular_outputs, + } + _write_index_html( + staged["index"], + data=data, + outputs=all_staged, + qa=figure_qa, + caption=caption, + ) _notify(progress, 72, "hash") raise_if_cancelled(cancel_event, "butterfly-figure:hash") - all_staged = {**staged, **comparison_outputs, **overlay_outputs, **peak_outputs} file_hashes = { path.name: _sha256(path) for key, path in all_staged.items() @@ -561,10 +1069,28 @@ def export_butterfly_figure( "q_window": data["q_window"], "ridge_point_count": len(data["points"]), "ridge_point_overlay_count": len(data["plot_points"]), + "radial_profile_file": "radial_profile.csv", + "radial_profile_fields": list(_RADIAL_PROFILE_FIELDS), + "figure_qa_file": "figure_qa.json", + "index_file": "index.html", + "figure_qa_status": figure_qa["status"], + "atomic_publish": { + "staging_directory": True, + "same_volume_rename": True, + "target_must_not_exist": True, + }, "files": list(file_hashes), "sha256": file_hashes, "manifest_excluded_from_own_sha256": True, } + if sector_outputs: + manifest["sector_manifest_file"] = "sector_manifest.json" + manifest["sector_peak_figure_method_version"] = sector_metadata.get( + "method_version" + ) + if annular_outputs: + manifest["annular_manifest_file"] = "annular_manifest.json" + manifest["annular_peak_figure_method_version"] = annular_metadata.get("method_version") _write_json(staged["manifest"], manifest) _notify(progress, 100, "publish") raise_if_cancelled(cancel_event, "butterfly-figure:publish") @@ -585,6 +1111,7 @@ def export_butterfly_figure( __all__ = [ "FIGURE_METHOD_VERSION", + "NATURE_FIGURE_GUIDE_URL", "export_butterfly_figure", "render_butterfly_figure", ] diff --git a/src/butterfly_saxs/butterfly_ridge.py b/src/butterfly_saxs/butterfly_ridge.py index ac547f1..e0de1de 100644 --- a/src/butterfly_saxs/butterfly_ridge.py +++ b/src/butterfly_saxs/butterfly_ridge.py @@ -1569,6 +1569,43 @@ def _sparse_first_order_coverage( return {"sparse": sparse, "n": len(identity), "max_dev_deg": float(max_dev)} +def _prepare_sparse_first_order_samples( + qx: np.ndarray, + qy: np.ndarray, + q: np.ndarray, + intensity: np.ndarray, + valid: np.ndarray, + *, + hint: float, +) -> dict[str, np.ndarray | float]: + """Pack finite first-order-band pixels for reuse across azimuth sectors.""" + + q_lo, q_hi = 0.70 * hint, 1.45 * hint + qx_array = np.asarray(qx) + qy_array = np.asarray(qy) + q_array = np.asarray(q) + intensity_array = np.asarray(intensity) + band = ( + np.asarray(valid, dtype=bool) + & np.isfinite(q_array) + & np.isfinite(intensity_array) + & (q_array >= q_lo) + & (q_array <= q_hi) + ) + rows, cols = np.nonzero(band) + return { + "qx": qx_array[band], + "qy": qy_array[band], + "q": q_array[band], + "intensity": intensity_array[band], + "angle_deg": np.degrees(np.arctan2(qy_array[band], qx_array[band])), + "rows": rows, + "cols": cols, + "q_lo": float(q_lo), + "q_hi": float(q_hi), + } + + def _sector_first_order_peak( qx: np.ndarray, qy: np.ndarray, @@ -1579,30 +1616,36 @@ def _sector_first_order_peak( sector_deg: float, halfwidth_deg: float, hint: float, + prepared: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: """Return one observed radial peak in an azimuth sector, or None.""" if not (np.isfinite(hint) and hint > 0.0): return None - ang = np.degrees(np.arctan2(qy, qx)) - delta = np.abs(((ang - float(sector_deg) + 180.0) % 360.0) - 180.0) q_lo, q_hi = 0.70 * hint, 1.45 * hint - selected = ( - np.asarray(valid, dtype=bool) - & np.isfinite(q) - & np.isfinite(intensity) - & (delta <= float(halfwidth_deg)) - & (q >= q_lo) - & (q <= q_hi) + if prepared is None: + prepared = _prepare_sparse_first_order_samples(qx, qy, q, intensity, valid, hint=hint) + # ``prepared`` is a compact, C-order subset of the finite q band. Keeping + # this subset in the caller lets all sectors reuse the expensive detector + # angle and validity work without retaining a frame-wide cache. + sample_qx = prepared["qx"] + sample_qy = prepared["qy"] + sample_q = prepared["q"] + sample_intensity = prepared["intensity"] + delta = np.abs( + ((prepared["angle_deg"] - float(sector_deg) + 180.0) % 360.0) - 180.0 ) + selected = delta <= float(halfwidth_deg) if int(np.count_nonzero(selected)) < 12: return None - radii = np.asarray(q[selected], dtype=float) - values = np.asarray(intensity[selected], dtype=float) + radii = np.asarray(sample_q[selected], dtype=float) + values = np.asarray(sample_intensity[selected], dtype=float) edges = np.linspace(q_lo, q_hi, 9) profile = np.full(edges.size - 1, np.nan, dtype=float) counts = np.zeros(edges.size - 1, dtype=int) - idx = np.digitize(radii, edges) - 1 + # Clip the right edge into the last bin: np.digitize returns one past the + # final bin for q == q_hi, which previously discarded those pixels. + idx = np.clip(np.digitize(radii, edges) - 1, 0, profile.size - 1) for bin_i in range(edges.size - 1): in_bin = idx == bin_i counts[bin_i] = int(np.count_nonzero(in_bin)) @@ -1617,18 +1660,23 @@ def _sector_first_order_peak( if not (np.isfinite(peak) and np.isfinite(baseline) and baseline > 0 and peak >= 1.30 * baseline): return None q_star = float(0.5 * (edges[peak_i] + edges[peak_i + 1])) - in_bin = selected & (q >= edges[peak_i]) & (q < edges[peak_i + 1]) + in_bin = selected & (sample_q >= edges[peak_i]) + if peak_i == profile.size - 1: + in_bin &= sample_q <= edges[peak_i + 1] + else: + in_bin &= sample_q < edges[peak_i + 1] if int(np.count_nonzero(in_bin)) < 3: in_bin = selected - sample_qx = float(np.nanmedian(qx[in_bin])) - sample_qy = float(np.nanmedian(qy[in_bin])) - if not (np.isfinite(sample_qx) and np.isfinite(sample_qy)): + median_qx = float(np.nanmedian(sample_qx[in_bin])) + median_qy = float(np.nanmedian(sample_qy[in_bin])) + if not (np.isfinite(median_qx) and np.isfinite(median_qy)): return None - rows, cols = np.nonzero(in_bin) - nearest = int(np.argmin((qx[in_bin] - sample_qx) ** 2 + (qy[in_bin] - sample_qy) ** 2)) + rows = prepared["rows"][in_bin] + cols = prepared["cols"][in_bin] + nearest = int(np.argmin((sample_qx[in_bin] - median_qx) ** 2 + (sample_qy[in_bin] - median_qy) ** 2)) return { - "qx": sample_qx, - "qy": sample_qy, + "qx": median_qx, + "qy": median_qy, "q_star": q_star, "intensity": peak, "contrast": peak / baseline, @@ -1696,6 +1744,14 @@ def _fill_sparse_first_order_ring( continue existing_angles.append(math.degrees(math.atan2(float(point["qy"]), float(point["qx"])))) added: list[dict[str, Any]] = [] + prepared = _prepare_sparse_first_order_samples( + qx, + qy, + q, + intensity, + valid, + hint=hint_q, + ) for quadrant in (0.0, 90.0, 180.0, 270.0): for offset in (30.0, 45.0, 60.0, 75.0): sector = reference + quadrant + offset @@ -1710,6 +1766,7 @@ def _fill_sparse_first_order_ring( sector_deg=sector, halfwidth_deg=7.5, hint=hint_q, + prepared=prepared, ) if peak is None: continue diff --git a/src/butterfly_saxs/butterfly_settings.py b/src/butterfly_saxs/butterfly_settings.py index 8a4f151..1342fc8 100644 --- a/src/butterfly_saxs/butterfly_settings.py +++ b/src/butterfly_saxs/butterfly_settings.py @@ -26,6 +26,25 @@ def normalize_butterfly_settings(settings=None) -> dict: result.setdefault("seed", 20260906) result.setdefault("edits", []) result.setdefault("sensitivity", True) + # Existing project recipes retain their historical curvature observable. + # Sector-profile peak measurement is an explicit, separately versioned choice. + result.setdefault("trace_method", "curvature") + if result["trace_method"] not in {"curvature", "radial_sector", "annular_peak"}: + raise ValueError("butterfly trace_method must be curvature, radial_sector or annular_peak") + for name, minimum, maximum in (("annular_radial_bins", 4, 192), ("annular_angle_bins", 16, 720)): + if name in result: + result[name] = strict_int(result[name], name, minimum=minimum) + if result[name] > maximum: + raise ValueError(f"{name} must be <= {maximum}") + for name in ("sector_width_deg", "sector_step_deg"): + if name in result: + value = result[name] + if isinstance(value, bool): + raise ValueError(f"{name} must be a positive finite angle") + value = float(value) + if not math.isfinite(value) or not 0 < value <= 180: + raise ValueError(f"{name} must be in (0, 180] degrees") + result[name] = value if result["stage"] not in {"trace", "evaluate"}: raise ValueError("butterfly stage must be trace or evaluate") for key in ("resamples", "seed"): diff --git a/src/butterfly_saxs/cli.py b/src/butterfly_saxs/cli.py index 2b5cdd7..5f877eb 100644 --- a/src/butterfly_saxs/cli.py +++ b/src/butterfly_saxs/cli.py @@ -68,6 +68,15 @@ def _config(value: str | None) -> ProjectConfig | None: return load_project(source).resolve_paths(source.parent) +def _unattended_source_path(value: str | os.PathLike[str], package_root: Path) -> str: + """Resolve a CLI source path once for preflight and batch fitting.""" + + candidate = Path(value).expanduser() + if not candidate.is_absolute() and not candidate.exists(): + candidate = package_root / candidate + return str(candidate.resolve(strict=False)) + + def _analysis_overrides(args: argparse.Namespace) -> dict[str, Any]: """Collect explicit CLI refinement controls without overriding TOML defaults.""" @@ -120,12 +129,20 @@ def _analysis_overrides(args: argparse.Namespace) -> dict[str, Any]: mapping["ellipse"] = ellipse butterfly = {} for argument, key in (("butterfly_stage", "stage"), ("butterfly_resamples", "resamples"), - ("butterfly_seed", "seed"), ("butterfly_sensitivity", "sensitivity")): + ("butterfly_seed", "seed"), ("butterfly_sensitivity", "sensitivity"), + ("butterfly_trace_method", "trace_method"), + ("sector_width", "sector_width_deg"), ("sector_step", "sector_step_deg"), + ("annular_rings", "annular_radial_bins"), ("annular_angles", "annular_angle_bins")): value = getattr(args, argument, None) if value is not None: butterfly[key] = value if butterfly: mapping["butterfly"] = butterfly + if getattr(args, "butterfly_trace_method", None) is not None: + selected = mapping.get("ridge_method") + if selected is not None and selected != "butterfly_curvature": + raise ValueError("--butterfly-trace-method requires the butterfly workflow; omit --ridge-method or use butterfly_curvature") + mapping["ridge_method"] = "butterfly_curvature" return mapping @@ -166,6 +183,12 @@ def _add_refinement_options(parser: argparse.ArgumentParser) -> None: help="observed ridge method, including side-aware butterfly_curvature", ) parser.add_argument("--butterfly-stage", choices=("trace", "evaluate"), default=None) + parser.add_argument("--butterfly-trace-method", choices=("curvature", "radial_sector", "annular_peak"), + help="butterfly observable: fixed-q angular tracks, radial sector peaks, or curvature candidates") + parser.add_argument("--sector-width", type=float, help="radial-sector full angular width in degrees (default 10)") + parser.add_argument("--annular-rings", type=int, help="number of fixed-q annuli (default 40; limited by pixel q sampling)") + parser.add_argument("--annular-angles", type=int, help="angular bins per q annulus (default 72)") + parser.add_argument("--sector-step", type=float, help="radial-sector angular sampling step in degrees (default 5)") parser.add_argument("--butterfly-resamples", type=int, default=None, help="image-level resampling count; zero skips empirical intervals") parser.add_argument("--butterfly-seed", type=int, default=None) @@ -373,6 +396,11 @@ def build_parser() -> argparse.ArgumentParser: batch_parser.add_argument("--checkpoint", help="批量检查点 JSON 路径") batch_parser.add_argument("--resume", action="store_true", help="从已有检查点恢复") batch_parser.add_argument("--force", action="store_true", help="允许覆盖已有输出") + batch_parser.add_argument( + "--unattended", metavar="PACKAGE", + help="先预检数据包,再以流式导出和自动检查点运行;预检红灯阻止拟合", + ) + batch_parser.add_argument("--preflight-context", help="无人值守预检的 project_context.yaml/yml") batch_parser.add_argument( "--stream", action="store_true", @@ -661,6 +689,7 @@ def _handle_batch(args: argparse.Namespace) -> int: mode = args.mode or str(analysis.get("batch_mode", analysis.get("mode", "independent"))) checkpoint = args.checkpoint or analysis.get("checkpoint") resume = bool(args.resume or analysis.get("resume", False)) + unattended = args.unattended is not None series = args.series if args.series is not None else analysis.get("series") start = args.start if args.start is not None else analysis.get("start") stop = args.stop if args.stop is not None else analysis.get("stop") @@ -685,13 +714,29 @@ def _handle_batch(args: argparse.Namespace) -> int: else analysis.get("frame_range") ) output_dir = Path(args.output or (config.output_dir if config else "results")) + poni = args.poni or (config.poni_path if config else None) + if unattended: + if not args.output and not (config and config.output_dir): + raise PipelineError("unattended batch requires an explicit output directory") + if poni is None: + raise PipelineError("unattended batch requires a PONI calibration") + package_root = Path(args.unattended).expanduser().resolve(strict=False) + poni = _unattended_source_path(poni, package_root) + output_root = output_dir.expanduser().resolve(strict=False) + if output_root == package_root or output_root.is_relative_to(package_root): + raise PipelineError("unattended output must be outside the raw package") + if checkpoint is None: + checkpoint = output_dir / "checkpoint.json" + checkpoint_root = Path(checkpoint).expanduser().resolve(strict=False) + if not checkpoint_root.is_relative_to(output_root): + raise PipelineError("unattended checkpoint must be inside the output directory") + stream = bool(args.stream or unattended) # A resumed run has already validated input/config/mode hashes before its # exports are written. It may therefore refresh its own known bundle # targets; a fresh run still refuses a non-empty directory by default. if output_dir.exists() and any(output_dir.iterdir()) and not args.force and not resume: raise FileExistsError(f"输出目录已有内容,未覆盖:{output_dir}(需要 --force)") - poni = args.poni or (config.poni_path if config else None) full2d = args.full2d or (config.full2d if config else False) # CLI path overrides are part of the batch configuration identity. This @@ -706,6 +751,11 @@ def _handle_batch(args: argparse.Namespace) -> int: ): if value is not None: path_analysis[name] = value + if unattended: + for name in ("mask", "valid_mask"): + source_path = path_analysis.get(name) + if isinstance(source_path, (str, os.PathLike)): + path_analysis[name] = _unattended_source_path(source_path, package_root) if explicit_sequence and frame_range is None: path_analysis.pop("frame_range", None) for name, value in ( @@ -717,6 +767,20 @@ def _handle_batch(args: argparse.Namespace) -> int: ): if value is not None: path_analysis[name] = value + if unattended: + selected_method = path_analysis.setdefault("ridge_method", "butterfly_curvature") + if selected_method != "butterfly_curvature": + raise PipelineError("unattended butterfly analysis requires ridge_method=butterfly_curvature") + configured_recipe = path_analysis.get("butterfly") or {} + if not isinstance(configured_recipe, Mapping): + raise PipelineError("analysis.butterfly must be a mapping") + butterfly_recipe = dict(configured_recipe) + butterfly_recipe.setdefault("stage", "evaluate") + butterfly_recipe.setdefault("trace_method", "annular_peak") + butterfly_recipe.setdefault("resamples", 0) + if butterfly_recipe["stage"] != "evaluate": + raise PipelineError("unattended butterfly analysis requires butterfly.stage=evaluate") + path_analysis["butterfly"] = butterfly_recipe path_analysis["stage"] = "full2d" if full2d else "geometry" if isinstance(config, ProjectConfig) and ( poni != config.poni_path or path_analysis != config.analysis @@ -773,19 +837,121 @@ def _handle_batch(args: argparse.Namespace) -> int: for name, value in (("frame", args.frame), ("dataset", args.dataset)) if value is not None } - if config is None: - batch_config = {"analysis": selector_config} - else: + if isinstance(batch_config, ProjectConfig): batch_config = ProjectConfig( input_paths=batch_config.input_paths, poni_path=batch_config.poni_path, - output_dir=config.output_dir, - q_unit=config.q_unit, + output_dir=batch_config.output_dir, + q_unit=batch_config.q_unit, full2d=full2d, analysis=deep_merge_mapping(batch_config.analysis, selector_config), export=batch_config.export, metadata=batch_config.metadata, ) + else: + batch_config = {"analysis": deep_merge_mapping(path_analysis, selector_config)} + + preflight_summary: dict[str, Any] | None = None + if unattended: + from .service import ButterflyAnalysisService + + allow_mixed = bool( + series is not None + or path_analysis.get("allow_mixed_series", path_analysis.get("independent_series", False)) + ) + selected_refs = batch_module.build_frame_refs( + batch_inputs, manifest=batch_manifest, allow_mixed_series=allow_mixed, + ) + preflight_start, preflight_stop, preflight_stride = start, stop, stride + if frame_range is not None: + preflight_start, preflight_stop, preflight_stride = batch_module.parse_frame_range(frame_range) + if start is not None or stop is not None or stride != 1: + raise ValueError("frame_range cannot be combined with start/stop/stride") + selected_refs = batch_module.select_frame_refs( + selected_refs, + series=series, start=preflight_start, stop=preflight_stop, + stride=preflight_stride, + ) + if not selected_refs: + raise ValueError("batch selection matched no frames") + mask = path_analysis.get("mask") + valid_mask = path_analysis.get("valid_mask") + if mask is not None and valid_mask is not None: + raise PipelineError("unattended preflight cannot represent mask and valid_mask together") + q_window = path_analysis.get("q_window") + if q_window is None and path_analysis.get("q_min") is not None and path_analysis.get("q_max") is not None: + q_window = (path_analysis["q_min"], path_analysis["q_max"]) + if q_window is None and (path_analysis.get("q_min") is None) != (path_analysis.get("q_max") is None): + raise PipelineError("unattended preflight requires both q_min and q_max when q_window is absent") + preflight_dir = output_dir / "preflight" + preflight_report = ButterflyAnalysisService().preflight( + args.unattended, + manifest=[ + {**ref.to_dict(), "path": str(ref.path.expanduser().resolve(strict=False))} + for ref in selected_refs + ], + poni=poni, + mask=mask if mask is not None else valid_mask, + mask_convention=("1_valid_0_invalid" if valid_mask is not None else "0_valid_1_invalid"), + mask_frame=path_analysis.get("mask_frame"), + mask_dataset=path_analysis.get("mask_dataset"), + q_window=q_window, + context=( + _unattended_source_path(args.preflight_context, package_root) + if args.preflight_context else None + ), + output=preflight_dir, + force=bool(args.force or resume), + ) + if not isinstance(preflight_report, Mapping): + raise PipelineError("preflight returned no report") + preflight_status = preflight_report.get("status") + if not isinstance(preflight_status, Mapping): + raise PipelineError("preflight returned no status envelope") + preflight_color = preflight_status.get("status_color") + preflight_code = preflight_status.get("exit_code") + if {"green": 0, "yellow": 1, "red": 2}.get(preflight_color) != preflight_code: + raise PipelineError("preflight returned an inconsistent status envelope") + selector = preflight_report.get("selector") + selected_mask = selector.get("mask") if isinstance(selector, Mapping) else None + if isinstance(selected_mask, Mapping): + selected_mask_path = selected_mask.get("path") + expected_mask = mask if mask is not None else valid_mask + if expected_mask is None and selected_mask_path is not None: + raise PipelineError("preflight context selected a mask absent from the batch recipe") + if expected_mask is not None: + if selected_mask_path is None: + raise PipelineError("preflight did not apply the batch mask") + actual = Path(selected_mask_path) + actual = actual if actual.is_absolute() else package_root / actual + if actual.resolve(strict=False) != Path(expected_mask).expanduser().resolve(strict=False): + raise PipelineError("preflight mask differs from the batch mask") + geometry = preflight_report.get("geometry") + if isinstance(geometry, Mapping) and q_window is None: + observed_window = geometry.get("q_window") + full_range = geometry.get("q_range") + if isinstance(observed_window, Mapping) and isinstance(full_range, Mapping): + for edge in ("min", "max"): + observed = float(observed_window[edge]) + expected = float(full_range[edge]) + if abs(observed - expected) > max(1e-9, 1e-6 * abs(expected)): + raise PipelineError("preflight context q_window differs from the batch recipe") + preflight_summary = { + "status_color": preflight_color, + "scientific_status": preflight_status.get("scientific_status"), + "exit_code": preflight_code, + "report": str(preflight_dir / "preflight.json"), + "selected_frames": len(selected_refs), + } + if preflight_color == "red": + blocked = annotate_report( + {"unattended": True, "blocked_stage": "preflight", "preflight": preflight_summary, + "n_frames": 0, "n_success": 0, "n_failed": 0, + "outputs": {"preflight": str(preflight_dir / "preflight.json")}}, + command="batch", exit_code=2, + ) + _print_json(blocked) + return 2 geometry_cache: dict[Any, Any] = {} @@ -807,15 +973,15 @@ def analyze_for_batch(frame_ref: Any, initial_parameters: Any = None, config: An initial_parameters=initial_parameters, frame=selected_frame, dataset=selected_dataset, - mask=args.mask, - mask_frame=args.mask_frame, - mask_dataset=args.mask_dataset, - valid_mask=args.valid_mask, + mask=path_analysis.get("mask") if unattended else args.mask, + mask_frame=path_analysis.get("mask_frame") if unattended else args.mask_frame, + mask_dataset=path_analysis.get("mask_dataset") if unattended else args.mask_dataset, + valid_mask=path_analysis.get("valid_mask") if unattended else args.valid_mask, geometry_cache=geometry_cache, ) stream_writer = None - if args.stream: + if stream: stream_writer = export_module.StreamingBatchExporter( output_dir, provenance={"command": "bsaxs batch", "full2d": full2d, "stream": True}, @@ -866,7 +1032,7 @@ def analyze_for_batch(frame_ref: Any, initial_parameters: Any = None, config: An # boundary; lossless arrays remain in results.npz and the object # returned by the Python API. result_mapping = item.result.to_mapping() - if args.stream and isinstance(result_mapping, Mapping): + if stream and isinstance(result_mapping, Mapping): # Stream mode already writes detector/profile arrays to NPZ; # keep stdout bounded to longitudinal diagnostics and rows. result_mapping = { @@ -879,7 +1045,7 @@ def analyze_for_batch(frame_ref: Any, initial_parameters: Any = None, config: An if key in result_mapping } record["result"] = result_mapping - elif args.stream and isinstance(item.result, Mapping): + elif stream and isinstance(item.result, Mapping): record["result"] = { key: item.result.get(key) for key in ( @@ -889,6 +1055,10 @@ def analyze_for_batch(frame_ref: Any, initial_parameters: Any = None, config: An ) if key in item.result } + if unattended: + # The streamed bundle is the evidence archive. Keep headless + # stdout bounded to per-frame control state for long acquisitions. + record.pop("result", None) compact_records.append(record) report = { "mode": run.mode, @@ -906,7 +1076,12 @@ def analyze_for_batch(frame_ref: Any, initial_parameters: Any = None, config: An "total_count": run.total_count, "outputs": {key: str(path) for key, path in exports.items()}, } - exit_code = 1 if run.failures else 0 + if unattended: + report["unattended"] = True + report["preflight"] = preflight_summary + report["outputs"]["preflight"] = str(output_dir / "preflight" / "preflight.json") + exit_code = 1 if (run.failures or run.cancelled or + (preflight_summary is not None and preflight_summary["status_color"] != "green")) else 0 report = annotate_report(report, command="batch", exit_code=exit_code) _print_json(report) # Partial exports remain available for inspection, while automation gets @@ -1136,8 +1311,8 @@ def _handle_project(args: argparse.Namespace) -> int: compact_records.append(record) if args.legacy_json: _print_json(compact_records) - return 1 if run.failures else 0 - exit_code = 1 if run.failures else 0 + return 1 if run.failures or run.cancelled else 0 + exit_code = 1 if run.failures or run.cancelled else 0 report = annotate_report( { "schema_version": "lamellarsaxs2d.project_run.v2", @@ -1148,6 +1323,11 @@ def _handle_project(args: argparse.Namespace) -> int: "n_frames": len(run.frame_results), "n_success": len(run.successful), "n_failed": len(run.failures), + "cancelled": run.cancelled, + "selection": run.selection, + "processed_count": run.processed_count, + "total_count": run.total_count, + "elapsed_s": run.elapsed_s, "checkpoint": ( str(run.checkpoint) if run.checkpoint is not None else None ), diff --git a/src/butterfly_saxs/cli_contract.py b/src/butterfly_saxs/cli_contract.py index fc721e2..19d3f4d 100644 --- a/src/butterfly_saxs/cli_contract.py +++ b/src/butterfly_saxs/cli_contract.py @@ -195,6 +195,12 @@ def agent_guidance( failed = report.get("n_failed") if isinstance(failed, int) and failed > 0: next_steps.append("Inspect failed frames in stdout JSON and the output directory; remaining frames were isolated.") + if report.get("blocked_stage") == "preflight": + next_steps.append("Read preflight/preflight.json; fitting did not start.") + elif report.get("unattended"): + next_steps.append("Read preflight/preflight.json and frame_summary.csv; require complete NPZ metadata before using the batch evidence.") + if report.get("cancelled"): + next_steps.append("The batch was cancelled; resume only with the same inputs and scientific configuration.") next_steps.append("Keep PONI, mask, q-window, and config identical across the series.") elif command == "synthetic": next_steps.append("Synthetic arrays are empirical fixtures with pixel-q unless a PONI is supplied later.") @@ -274,6 +280,7 @@ def agent_manifest() -> dict[str, Any]: "bsaxs analyze synthetic.npz --ridge-method butterfly_curvature " "--ellipse-preset flat_ellipse --butterfly-stage evaluate --butterfly-resamples 0", "bsaxs preflight PACKAGE --manifest MANIFEST --poni PONI --mask MASK -o results/preflight", + "bsaxs batch 'PACKAGE/images/*.edf' --unattended PACKAGE --manifest PACKAGE/manifest.csv --poni PACKAGE/geometry.poni --mask PACKAGE/mask.npy -o results/unattended_001", ], "commands": [ { @@ -305,7 +312,7 @@ def agent_manifest() -> dict[str, Any]: }, { "name": "batch", - "purpose": "Independent or quality-gated warm-start series with fail-closed overwrite.", + "purpose": "Independent or quality-gated warm-start series; --unattended adds preflight, streaming evidence and a checkpoint.", "stdout": BATCH_RUN_SCHEMA, "exit_codes": EXIT_CODES, }, diff --git a/src/butterfly_saxs/export.py b/src/butterfly_saxs/export.py index ba461bc..ad9c531 100644 --- a/src/butterfly_saxs/export.py +++ b/src/butterfly_saxs/export.py @@ -486,7 +486,8 @@ def _parameters(value: Any) -> list[dict[str, Any]]: ) ellipse = _value(value, "ellipse_fit", "ellipse", default={}) evidence = _value(ellipse, "quantitative_parameters", default={}) - if isinstance(evidence, Mapping) and evidence: + evidence = evidence if isinstance(evidence, Mapping) else {} + if evidence: aliases = {"semi_major": "a", "semi_minor": "b", "axes_ratio": "axis_ratio", "ellipse_axis_tilt_deg": "theta_deg", "angle_deg": "theta_deg", "eccentricity": "axis_ratio", "ellipticity": "axis_ratio"} @@ -515,6 +516,26 @@ def _parameters(value: Any) -> list[dict[str, Any]]: row["interval_kind"] = check.get("interval_kind", "") if not row.get("unit"): row["unit"] = "dimensionless" if name in ("eccentricity", "ellipticity") else check.get("unit", "") + shape_available = all( + isinstance(evidence.get(name), Mapping) + and evidence[name].get("status") == "available" + for name in ("a", "b", "axis_ratio", "theta_deg") + ) + if not shape_available: + # These periods are derived from the candidate ellipse. A raw fit + # scalar must not become a published Ln/Lz when quantitative evidence + # is missing or any required ellipse dimension is undetermined. + for row in rows: + if row["parameter"] not in { + "L_N", "L_z", "Ln_from_minor_axis_nm", "Lz_from_draw_axis_nm", + "L_from_major_axis_nm", + }: + continue + row["candidate_value"] = row["value"] + row["value"] = "" + row["identifiability_status"] = "undetermined" + row["identifiability_reason"] = "ellipse_shape_not_quantitatively_available" + row["parameter_source"] = "candidate_only" return rows @@ -1336,6 +1357,7 @@ def finalize(self, batch: BatchRunResult | None = None) -> dict[str, Path]: "arrays": list(self._array_names), "frame_count": len(self._compact_results), "complete": not self._missing_frames + and not self._quality_failed_frames and not (isinstance(batch, BatchRunResult) and batch.cancelled), "artifact_complete": not self._missing_frames and not (isinstance(batch, BatchRunResult) and batch.cancelled), diff --git a/src/butterfly_saxs/figure_support.py b/src/butterfly_saxs/figure_support.py index e28e889..ce2ac8b 100644 --- a/src/butterfly_saxs/figure_support.py +++ b/src/butterfly_saxs/figure_support.py @@ -13,6 +13,7 @@ import numpy as np from .serialization import json_safe +from .settings import canonical_q_unit from .visualization import _diagnostic_q_axis_labels, _display_transform _SUPPORTED_WIDTHS_MM = (89.0, 183.0) @@ -247,6 +248,7 @@ def _prepare_inputs( ) unit = str(q_unit or "unknown") + canonical_unit = canonical_q_unit(unit) result_safe = json_safe(result) point_rows = result.get("points", ()) if not isinstance(point_rows, Sequence) or isinstance(point_rows, (str, bytes)): @@ -353,7 +355,7 @@ def _prepare_inputs( "color_clip_high_count": display_high_count, "q_axis_interpretation": ( "unknown unit; no physical period is inferred" - if unit.strip().lower() in {"", "unknown", "pixel", "pixel_q", "px"} + if canonical_unit in {"unknown", "pixel-q"} else "caller-supplied unit; no period conversion is performed" ), } @@ -587,11 +589,19 @@ def _draw_map( "rejected": [], "unspecified": [], } + annular_pairing = "annular_peaks" in data.get("result_safe", {}) + if annular_pairing: + plotted.update(accepted_0=[], accepted_1=[]) for point in data["plot_points"]: coordinates = _point_coordinates(point) if coordinates is not None: - plotted[_point_display_status(point)].append(coordinates) + status = _point_display_status(point) + if annular_pairing and status == "accepted" and point.get("branch_id") in (0, 1): + status = f"accepted_{point['branch_id']}" + plotted[status].append(coordinates) styles = { + "accepted_0": {"marker": "o", "color": "#0072b2", "label": "A: QI + QIII"}, + "accepted_1": {"marker": "^", "color": "#d55e00", "label": "B: QII + QIV"}, "accepted": { "marker": "o", "color": "#0072b2", diff --git a/src/butterfly_saxs/observables.py b/src/butterfly_saxs/observables.py index 9d7e3ce..f25054f 100644 --- a/src/butterfly_saxs/observables.py +++ b/src/butterfly_saxs/observables.py @@ -808,6 +808,132 @@ def _robust_noise(values: np.ndarray) -> float: return noise +def _circular_supported_runs(support: np.ndarray) -> list[np.ndarray]: + """Return contiguous supported angular-bin runs without crossing gaps. + + The angular coordinate is periodic, so the first and last bins belong to + the same run when both are supported. A run is deliberately kept as an + explicit index array: callers can smooth or estimate noise within that + run without ever borrowing a value across a masked detector gap. + """ + + support = np.asarray(support, dtype=bool).ravel() + n_bins = int(support.size) + if n_bins == 0 or not np.any(support): + return [] + if np.all(support): + return [np.arange(n_bins, dtype=int)] + starts = np.flatnonzero(support & ~np.roll(support, 1)) + runs: list[np.ndarray] = [] + for start in starts.tolist(): + indices: list[int] = [] + index = int(start) + while support[index]: + indices.append(index) + index = (index + 1) % n_bins + if index == int(start): + break + runs.append(np.asarray(indices, dtype=int)) + return runs + + +def _smooth_supported_angular_profile( + profile: np.ndarray, + support: np.ndarray, + *, + sigma: float = 1.0, +) -> np.ndarray: + """Smooth only within contiguous supported angular bins. + + ``gaussian_filter1d(..., mode='wrap')`` is valid for a complete angular + profile, but it silently leaks intensity across a masked gap. This helper + uses periodic wrapping only for a fully supported profile and otherwise + processes each contiguous run independently. + """ + + profile = np.asarray(profile, dtype=float).ravel() + support = np.asarray(support, dtype=bool).ravel() & np.isfinite(profile) + smoothed = np.full(profile.shape, np.nan, dtype=float) + if not np.any(support): + return smoothed + if gaussian_filter1d is None or not np.isfinite(float(sigma)) or float(sigma) <= 0.0: + smoothed[support] = profile[support] + return smoothed + runs = _circular_supported_runs(support) + if len(runs) == 1 and runs[0].size == profile.size: + smoothed[:] = gaussian_filter1d(profile, float(sigma), mode="wrap") + return smoothed + for run in runs: + values = profile[run] + if values.size == 1: + smoothed[run] = values + else: + # ``nearest`` remains inside the supported run. It never samples + # the opposite side of a missing/masked angular interval. + smoothed[run] = gaussian_filter1d(values, float(sigma), mode="nearest") + return smoothed + + +def _supported_angular_noise( + profile: np.ndarray, + support: np.ndarray, +) -> tuple[float, str, dict[str, int]]: + """Estimate detector noise from local angular changes, not lobe height. + + The MAD of an entire angular profile measures the physical four-lobe + contrast as if it were detector noise. Robust adjacent differences are + therefore preferred; a local three-point detrended residual is a fallback + for short supported runs. Differences are added across the periodic + boundary only when that boundary is actually supported. + """ + + profile = np.asarray(profile, dtype=float).ravel() + support = np.asarray(support, dtype=bool).ravel() & np.isfinite(profile) + runs = _circular_supported_runs(support) + differences: list[np.ndarray] = [] + residuals: list[np.ndarray] = [] + for run in runs: + values = profile[run] + if values.size >= 2: + differences.append(np.diff(values)) + if run.size == profile.size: + differences.append(np.asarray([values[0] - values[-1]], dtype=float)) + if values.size >= 3: + residuals.append(values[1:-1] - 0.5 * (values[:-2] + values[2:])) + diff_values = ( + np.concatenate(differences).astype(float, copy=False) + if differences + else np.asarray([], dtype=float) + ) + residual_values = ( + np.concatenate(residuals).astype(float, copy=False) + if residuals + else np.asarray([], dtype=float) + ) + if diff_values.size >= 2: + noise = _robust_noise(diff_values) / np.sqrt(2.0) + if np.isfinite(noise) and noise > np.finfo(float).eps: + return float(noise), "adjacent_difference_mad", { + "difference_count": int(diff_values.size), + "detrended_residual_count": int(residual_values.size), + } + if residual_values.size >= 2: + noise = _robust_noise(residual_values) / np.sqrt(6.0) + if np.isfinite(noise) and noise > np.finfo(float).eps: + return float(noise), "local_detrended_residual_mad", { + "difference_count": int(diff_values.size), + "detrended_residual_count": int(residual_values.size), + } + # Preserve a finite floor for the peak detector without falling back to + # the full profile MAD: that would re-introduce the physical lobe contrast + # as detector noise. A constant or locally linear profile has no local + # prominence and therefore remains safely peak-free at this floor. + return float(np.finfo(float).eps), "local_difference_floor_short_or_constant", { + "difference_count": int(diff_values.size), + "detrended_residual_count": int(residual_values.size), + } + + def _wrap_distance(angle: np.ndarray | float, centre: float) -> np.ndarray: return np.abs(np.angle(np.exp(1j * (np.asarray(angle) - float(centre))))) @@ -2173,7 +2299,10 @@ def _azimuthal_peak_ridges( snr_threshold: float = 2.0, min_peak_fraction: float = 0.0, min_coverage: float = 0.0, + min_bin_count: int = 1, mask: Any = None, + diagnostics: dict[str, Any] | None = None, + cancel_event: Any = None, ) -> tuple[list[RidgePoint], np.ndarray, np.ndarray, float, float, float, tuple[str, ...]]: """Extract directly observed angular maxima in sampled q annuli. @@ -2195,8 +2324,20 @@ def _azimuthal_peak_ridges( raise ValueError("ridge_min_peak_fraction must be in [0, 1]") if not np.isfinite(float(min_coverage)) or not 0.0 <= float(min_coverage) <= 1.0: raise ValueError("ridge_min_coverage must be in [0, 1]") + if isinstance(min_bin_count, (bool, np.bool_)) or int(min_bin_count) != min_bin_count or int(min_bin_count) < 1: + raise ValueError("min_bin_count must be a positive integer") + min_bin_count = int(min_bin_count) values, q, angle, valid = _extract_maps(frame, qmap, mask) + source_data = _array_field(frame, ("data", "intensity", "image", "values")) + source_shape = tuple(np.asarray(source_data).shape) if source_data is not None else () + if len(source_shape) >= 2: + source_indices = np.unravel_index(np.arange(values.size), source_shape) + pixel_y = np.asarray(source_indices[-2], dtype=float) + pixel_x = np.asarray(source_indices[-1], dtype=float) + else: + pixel_y = np.zeros(values.size, dtype=float) + pixel_x = np.arange(values.size, dtype=float) q_unit = _q_unit(qmap) q_min, q_max = _q_limits(q, q_window, q_range) q_edges = np.linspace(q_min, q_max, n_annuli + 1, dtype=float) @@ -2206,6 +2347,9 @@ def _azimuthal_peak_ridges( angle_centres = 0.5 * (angle_edges[:-1] + angle_edges[1:]) angle_step = float(angle_edges[1] - angle_edges[0]) + if diagnostics is not None: + diagnostics.clear() + q_index_float = ( (q - q_min) / max(q_max - q_min, np.finfo(float).eps) * n_annuli ) @@ -2217,13 +2361,12 @@ def _azimuthal_peak_ridges( ).astype(np.int64) angle_index = np.clip(angle_index, 0, n_angle_bins - 1) candidate = ( - np.isfinite(values) - & np.isfinite(q) + np.isfinite(q) & np.isfinite(angle) & (q >= q_min) & (q <= q_max) ) - selected = candidate & valid + selected = candidate & valid & np.isfinite(values) flat_candidate = q_index[candidate] * n_angle_bins + angle_index[candidate] flat_selected = q_index[selected] * n_angle_bins + angle_index[selected] total = n_annuli * n_angle_bins @@ -2256,37 +2399,55 @@ def _azimuthal_peak_ridges( ) points: list[RidgePoint] = [] annulus_indices: dict[int, list[int]] = {} + smoothed_matrix = np.full_like(profile, np.nan, dtype=float) + annulus_diagnostics: list[dict[str, Any]] = [] + all_candidates: list[dict[str, Any]] = [] rejected_boundary = False rejected_support = False for annulus in range(n_annuli): + raise_if_cancelled(cancel_event, "ridges:annulus") + annulus_diag: dict[str, Any] = { + "annulus_index": int(annulus), + "q_min": float(q_edges[annulus]), + "q_max": float(q_edges[annulus + 1]), + "q_center": float(q_centres[annulus]), + "candidate_count": int(annulus_candidate[annulus]), + "valid_count": int(annulus_valid[annulus]), + "coverage": float(annulus_coverage[annulus]), + "baseline": float("nan"), + "noise": float("nan"), + "noise_method": None, + "noise_details": {}, + "supported_bins": [], + "candidates": [], + "status": "not_evaluated", + } + annulus_diagnostics.append(annulus_diag) if annulus_candidate[annulus] <= 0 or annulus_coverage[annulus] < float(min_coverage): if annulus_candidate[annulus] > 0: rejected_support = True + annulus_diag["status"] = "rejected_low_annulus_coverage" + else: + annulus_diag["status"] = "no_geometry_support" continue values_angular = profile[annulus] finite_profile = np.isfinite(values_angular) & (counts[annulus] > 0) + support_mask = finite_profile & (coverage[annulus] >= float(min_coverage)) + annulus_diag["supported_bins"] = np.flatnonzero(support_mask).astype(int).tolist() if np.count_nonzero(finite_profile) < 3: + annulus_diag["status"] = "insufficient_angular_support" continue baseline = float(np.nanpercentile(values_angular[finite_profile], 10.0)) - noise = _robust_noise(values_angular[finite_profile] - baseline) - if not np.isfinite(noise) or noise <= np.finfo(float).eps: - noise = max(float(np.nanstd(values_angular[finite_profile])), np.finfo(float).eps) - if gaussian_filter1d is not None: - support_weights = finite_profile.astype(float) - numerator = gaussian_filter1d( - np.where(finite_profile, values_angular, 0.0), - 1.0, - mode="wrap", - ) - denominator = gaussian_filter1d(support_weights, 1.0, mode="wrap") - smoothed = np.divide( - numerator, - denominator, - out=np.full(n_angle_bins, np.nan, dtype=float), - where=denominator > 1e-9, - ) - else: # pragma: no cover - scipy is a declared dependency - smoothed = values_angular.copy() + noise, noise_method, noise_details = _supported_angular_noise(values_angular, support_mask) + smoothed = _smooth_supported_angular_profile(values_angular, support_mask, sigma=1.0) + smoothed_matrix[annulus] = smoothed + annulus_diag.update( + baseline=float(baseline), + noise=float(noise), + noise_method=str(noise_method), + noise_details=dict(noise_details), + status="profile_evaluated", + ) peak_input = np.where(np.isfinite(smoothed), smoothed, baseline) if find_peaks is None: # pragma: no cover peak_indices = np.asarray([int(np.nanargmax(peak_input))]) @@ -2318,14 +2479,37 @@ def _azimuthal_peak_ridges( peak_prominence_list.append(float(peak_input[int(peak)] - baseline)) peak_indices = np.asarray(peak_indices_list, dtype=int) peak_prominence = np.asarray(peak_prominence_list, dtype=float) - support_mask = finite_profile & (coverage[annulus] >= float(min_coverage)) finite_prominence = peak_prominence[np.isfinite(peak_prominence) & (peak_prominence > 0.0)] strongest_prominence = float(np.max(finite_prominence)) if finite_prominence.size else float("nan") for peak_position, index in enumerate(peak_indices.tolist()): index = int(index) - neighbours = support_mask[(index - 1) % n_angle_bins] and support_mask[(index + 1) % n_angle_bins] + prominence = float(peak_prominence[peak_position]) if peak_position < len(peak_prominence) else float(peak_input[index] - baseline) + height = float(peak_input[index] - baseline) + candidate_diag: dict[str, Any] = { + "annulus_index": int(annulus), + "angular_bin_index": int(index), + "chi_deg": float(np.degrees(angle_centres[index])), + "q_center": float(q_centres[annulus]), + "raw_intensity": float(values_angular[index]) if np.isfinite(values_angular[index]) else float("nan"), + "smoothed_intensity": float(smoothed[index]) if np.isfinite(smoothed[index]) else float("nan"), + "baseline": float(baseline), + "height_above_baseline": float(height), + "prominence": float(prominence), + "snr": float("nan"), + "n_pixels": int(counts[annulus, index]), + "coverage": float(coverage[annulus, index]), + "accepted": False, + "reason": "candidate", + } + all_candidates.append(candidate_diag) + annulus_diag["candidates"].append(candidate_diag) + neighbours = bool( + support_mask[(index - 1) % n_angle_bins] + and support_mask[(index + 1) % n_angle_bins] + ) if not support_mask[index] or not neighbours: rejected_boundary = True + candidate_diag["reason"] = "masked_gap_or_boundary" continue peak_angle, peak_intensity = _quadratic_peak( angle_centres, @@ -2333,15 +2517,59 @@ def _azimuthal_peak_ridges( index, period=2.0 * np.pi, ) - prominence = float(peak_prominence[peak_position]) if peak_position < len(peak_prominence) else float(peak_intensity - baseline) if ( np.isfinite(strongest_prominence) and prominence < float(min_peak_fraction) * strongest_prominence ): + candidate_diag["reason"] = "below_annulus_prominence_fraction" continue snr = float(max(prominence, peak_intensity - baseline) / max(noise, np.finfo(float).eps)) + candidate_diag["snr"] = float(snr) if not np.isfinite(snr) or snr < float(snr_threshold): + candidate_diag["reason"] = "below_snr_threshold" continue + bin_selected = selected & (q_index == annulus) & (angle_index == index) + bin_values = np.asarray(values[bin_selected], dtype=float) + bin_values = bin_values[np.isfinite(bin_values)] + excess = np.maximum(bin_values - baseline, 0.0) + excess_sum = float(np.sum(excess)) + max_excess_fraction = ( + float(np.max(excess) / excess_sum) + if excess.size and excess_sum > np.finfo(float).eps + else float("nan") + ) + n_eff = ( + float(excess_sum * excess_sum / np.sum(excess * excess)) + if excess.size and np.sum(excess * excess) > np.finfo(float).eps + else float(bin_values.size) + ) + candidate_diag.update( + effective_support_pixels=float(n_eff), + max_excess_fraction=float(max_excess_fraction), + ) + if int(bin_values.size) < int(min_bin_count): + rejected_support = True + candidate_diag["reason"] = "insufficient_bin_support" + continue + # A single hot pixel must not become an angular ridge. This is a + # conservative diagnostic gate only for a bin that is demonstrably + # dominated by one positive excess contribution; the raw mean is + # retained unchanged in the diagnostics. + if ( + bin_values.size >= 2 + and np.isfinite(n_eff) + and (n_eff < 2.0 or (np.isfinite(max_excess_fraction) and max_excess_fraction > 0.75)) + ): + rejected_support = True + candidate_diag["reason"] = "hot_pixel_dominated" + continue + support_positions = np.flatnonzero(bin_selected) + representative_x = representative_y = float("nan") + if support_positions.size: + xs, ys = pixel_x[support_positions], pixel_y[support_positions] + distance = (xs - np.median(xs)) ** 2 + (ys - np.median(ys)) ** 2 + nearest = int(np.argmin(distance)) + representative_x, representative_y = float(xs[nearest]), float(ys[nearest]) fwhm = _periodic_fwhm(angle_centres, smoothed, index, baseline) point = RidgePoint( angle=float(peak_angle), @@ -2373,6 +2601,8 @@ def _azimuthal_peak_ridges( ), method="azimuthal_peak", support=float(annulus_coverage[annulus]), + pixel_x=representative_x, + pixel_y=representative_y, accepted=True, reason="accepted", q_unit=q_unit, @@ -2385,11 +2615,30 @@ def _azimuthal_peak_ridges( annulus_q_max=float(q_edges[annulus + 1]), angular_bin_index=index, angular_bin_coverage=float(coverage[annulus, index]), + prominence=float(prominence), + raw_intensity=float(values_angular[index]), + smoothed_intensity=float(smoothed[index]), + raw_bin_chi=float(angle_centres[index]), + raw_bin_chi_deg=float(np.degrees(angle_centres[index])), + refinement="quadratic_on_supported_smoothed_profile", + representative_pixel_role="median_of_valid_support_pixels", + support_pixel_count=int(bin_values.size), + effective_support_pixels=float(n_eff), + max_excess_fraction=float(max_excess_fraction), + profile_noise=float(noise), + profile_noise_method=str(noise_method), area_definition="peak_height_times_angular_fwhm_approximation", ) point_index = len(points) points.append(point) annulus_indices.setdefault(annulus, []).append(point_index) + candidate_diag.update( + accepted=True, + reason="accepted", + point_index=int(point_index), + refined_angle=float(peak_angle), + refined_angle_deg=float(np.degrees(peak_angle)), + ) valid_annulus_fraction, continuity_fraction, continuity_score, continuity_flags = _annotate_azimuthal_tracks( points, @@ -2410,9 +2659,59 @@ def _azimuthal_peak_ridges( flags.append("low_peak_support") if not points: flags.append("no_azimuthal_peak") + final_flags = tuple(dict.fromkeys(flags)) + if diagnostics is not None: + diagnostics.update( + { + "schema_version": "azimuthal_peak_diagnostics.v1", + "q_unit": q_unit, + "q_edges": np.asarray(q_edges, dtype=float).copy(), + "q_centers": np.asarray(q_centres, dtype=float).copy(), + "q_centres": np.asarray(q_centres, dtype=float).copy(), + "angle_edges_deg": np.degrees(angle_edges).astype(float, copy=True), + "angle_centers_deg": np.degrees(angle_centres).astype(float, copy=True), + "angle_centres_deg": np.degrees(angle_centres).astype(float, copy=True), + "raw_mean": np.asarray(profile, dtype=float).copy(), + "raw_sum": np.asarray(sums, dtype=float).copy(), + "counts": np.asarray(counts, dtype=int).copy(), + "geometry_counts": np.asarray(candidate_counts, dtype=int).copy(), + "candidate_counts": np.asarray(candidate_counts, dtype=int).copy(), + "coverage": np.asarray(coverage, dtype=float).copy(), + "smoothed": np.asarray(smoothed_matrix, dtype=float).copy(), + "annuli": annulus_diagnostics, + "per_annulus": annulus_diagnostics, + "candidates": all_candidates, + "selected_points": [ + { + "point_index": int(index), + "annulus_index": int(point.metadata.get("annulus_index", -1)), + "angular_bin_index": int(point.metadata.get("angular_bin_index", -1)), + "q": float(point.q), + "chi_deg": float(np.degrees(point.angle)), + "prominence": float(point.metadata.get("prominence", float("nan"))), + } + for index, point in enumerate(points) + ], + "settings": { + "n_annuli": int(n_annuli), + "n_angle_bins": int(n_angle_bins), + "snr_threshold": float(snr_threshold), + "min_peak_fraction": float(min_peak_fraction), + "min_coverage": float(min_coverage), + "min_bin_count": int(min_bin_count), + "smoothing_sigma_bins": 1.0, + "noise_definition": "robust_adjacent_difference_or_local_detrended_residual", + "smoothing_definition": "within_contiguous_supported_angular_runs_only", + }, + "flags": final_flags, + "valid_annulus_fraction": float(valid_annulus_fraction), + "continuity_fraction": float(continuity_fraction), + "continuity_score": float(continuity_score), + } + ) return points, np.asarray([point.angle for point in points], dtype=float), np.asarray( [point.q for point in points], dtype=float - ), valid_annulus_fraction, continuity_fraction, continuity_score, tuple(dict.fromkeys(flags)) + ), valid_annulus_fraction, continuity_fraction, continuity_score, final_flags def measure_radial_ridges( diff --git a/src/butterfly_saxs/pipeline.py b/src/butterfly_saxs/pipeline.py index 43fb2e1..b7b4e16 100644 --- a/src/butterfly_saxs/pipeline.py +++ b/src/butterfly_saxs/pipeline.py @@ -2461,7 +2461,11 @@ def batch_analyze( values = _expand_inputs(inputs) if not values: raise PipelineError("没有可处理的输入帧") - refs = build_frame_refs(values, manifest=manifest) + refs = build_frame_refs( + values, + manifest=manifest, + allow_mixed_series=series is not None, + ) if frame_range is not None: range_start, range_stop, range_stride = parse_frame_range(frame_range) if start is not None or stop is not None or stride != 1: diff --git a/src/butterfly_saxs/sector_peak_figures.py b/src/butterfly_saxs/sector_peak_figures.py new file mode 100644 index 0000000..d5ccc97 --- /dev/null +++ b/src/butterfly_saxs/sector_peak_figures.py @@ -0,0 +1,811 @@ +"""Publication assets for measured azimuthal sector-integrated SAXS peaks. + +The sector measurement is performed upstream by :mod:`sector_peaks`. This +module is deliberately a *figure and evidence writer*: it consumes the +already-measured ``result['sector_peaks']`` mapping and never re-integrates +the detector image. Missing bins remain missing, overlapping sectors retain +their correlation warning, and a selected point is drawn at most once per +sector. + +The output is split into two figures. ``sector_qchi`` shows the complete raw +mean ``I(q, chi)`` field. ``sector_profiles`` shows a small set of readable +sector profiles with the raw mean and locator-only smoothing explicitly +separated. The latter is a diagnostic candidate display; smoothing is not a +fit and FWHM is not a confidence interval. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +import csv +import hashlib +import json +from pathlib import Path +from typing import Any + +import matplotlib as mpl +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure +from matplotlib.lines import Line2D +import numpy as np + +from .cancellation import raise_if_cancelled +from .csv_utils import safe_csv_cell +from .figure_support import _EXPORT_LOCK, _FIGURE_RC +from .serialization import json_safe + + +SECTOR_FIGURE_METHOD_VERSION = "sector-peak-figures-v1" +SECTOR_FIGURE_SCHEMA_VERSION = "sector-peak-figure-export-v1" + +_SECTOR_COLORS = ("#0072B2", "#D55E00", "#009E73", "#CC79A7") +_REPRESENTATIVE_ANGLES = (0.0, 45.0, 90.0, 135.0) + + +def _finite(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return None + return number if np.isfinite(number) else None + + +def _csv_number(value: Any) -> str: + number = _finite(value) + return "" if number is None else format(number, ".17g") + + +def _number(value: Any, default: float = float("nan")) -> float: + number = _finite(value) + return default if number is None else number + + +def _array(value: Any, *, dtype: Any = float) -> np.ndarray: + """Convert JSON-safe lists and native arrays while preserving holes.""" + + if value is None: + return np.asarray([], dtype=dtype) + if dtype is float: + if isinstance(value, np.ndarray): + raw = value.tolist() if value.dtype.kind not in "fiu" else value + else: + raw = value + try: + return np.asarray( + [np.nan if item is None else float(item) for item in raw], + dtype=np.float64, + ) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("sector profile values must be numeric or null") from exc + try: + return np.asarray(value, dtype=dtype) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("sector profile values have an invalid shape") from exc + + +def _mapping(value: Any, name: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise TypeError(f"{name} must be a mapping") + return value + + +def _sector_rows(result: Mapping[str, Any]) -> list[dict[str, Any]]: + """Normalize the existing sector result without calculating measurements.""" + + sectors_value = result.get("sectors", ()) + if sectors_value is None: + return [] + if isinstance(sectors_value, (str, bytes)) or not isinstance(sectors_value, Sequence): + raise TypeError("sector_peaks.sectors must be a sequence") + q_root = _array(result.get("q_centers"), dtype=float) + edge_root = _array(result.get("q_edges"), dtype=float) + rows: list[dict[str, Any]] = [] + for fallback_index, raw in enumerate(sectors_value): + sector = dict(_mapping(raw, f"sector_peaks.sectors[{fallback_index}]")) + index_value = sector.get("sector_index", fallback_index) + try: + sector_index = int(index_value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("sector_index must be an integer") from exc + angle = _number(sector.get("angle_deg", sector.get("center_angle_deg"))) + width = _number(sector.get("width_deg")) + q = _array(sector.get("q_centers", sector.get("q")), dtype=float) + if q.size == 0 and q_root.size: + q = q_root.copy() + edges = _array(sector.get("q_edges"), dtype=float) + if edges.size == 0 and edge_root.size: + edges = edge_root.copy() + raw_mapping = sector.get("raw") + raw_mapping = raw_mapping if isinstance(raw_mapping, Mapping) else {} + raw_mean = _array( + sector.get("raw_mean", sector.get("intensity", raw_mapping.get("mean"))), + dtype=float, + ) + raw_sum = _array(sector.get("raw_sum", raw_mapping.get("sum")), dtype=float) + raw_count = _array( + sector.get("raw_count", sector.get("counts", raw_mapping.get("count"))), + dtype=np.int64, + ) + geometric_count = _array( + sector.get("geometric_counts", sector.get("geometry_count")), dtype=np.int64 + ) + coverage = _array( + sector.get("coverage", sector.get("geometry_coverage")), dtype=float + ) + smooth = _array( + sector.get("smoothed_intensity", sector.get("smoothed")), dtype=float + ) + lengths = { + "q_centers": len(q), + "raw_mean": len(raw_mean), + "raw_sum": len(raw_sum), + "raw_count": len(raw_count), + "geometric_count": len(geometric_count), + "coverage": len(coverage), + "smoothed_intensity": len(smooth), + } + nonzero = {length for length in lengths.values() if length} + if len(nonzero) > 1: + raise ValueError( + f"sector {sector_index} profile arrays have inconsistent lengths: {lengths}" + ) + length = next(iter(nonzero), 0) + if len(q) == 0 and length: + raise ValueError(f"sector {sector_index} q_centers are required for export") + if len(edges) not in {0, length + 1}: + raise ValueError(f"sector {sector_index} q_edges must have len(q)+1 values") + if len(edges) == 0 and length: + edges = np.full(length + 1, np.nan, dtype=np.float64) + for key in ("raw_mean", "raw_sum", "raw_count", "geometric_count", "coverage", "smoothed_intensity"): + if len(sector.get(key, ())) == 0 and key not in sector: + # Fill absent optional arrays with explicit missing values. This + # keeps a malformed/legacy sector visible in the CSV instead of + # silently manufacturing a zero intensity profile. + fill = np.full(length, np.nan, dtype=np.float64) + if key in {"raw_count", "geometric_count"}: + fill = np.zeros(length, dtype=np.int64) + sector[key] = fill + supported = _array(sector.get("supported_bin_mask"), dtype=bool) + if supported.size == 0 and length: + supported = np.isfinite(raw_mean) & (raw_count > 0) + if supported.size not in {0, length}: + raise ValueError(f"sector {sector_index} supported_bin_mask length mismatch") + if supported.size == 0: + supported = np.zeros(length, dtype=bool) + sector.update( + { + "sector_index": sector_index, + "angle_deg": angle, + "width_deg": width, + "q_centers": q, + "q_edges": edges, + "raw_mean": raw_mean, + "raw_sum": raw_sum, + "raw_count": raw_count, + "geometric_counts": geometric_count, + "coverage": coverage, + "smoothed_intensity": smooth, + "supported_bin_mask": supported, + } + ) + rows.append(sector) + return rows + + +def _intensity_unit(data: Mapping[str, Any], result: Mapping[str, Any]) -> tuple[str, str]: + """Use only the explicit EDF header field when available. + + File names and paths are intentionally never inspected for units. The + fallback phrase describes the caller-supplied scale without guessing one. + """ + + context = data.get("context") + if isinstance(context, Mapping): + metadata = context.get("metadata") + if isinstance(metadata, Mapping): + header = metadata.get("header") + if isinstance(header, Mapping): + value = header.get("IntensityUnit") + if value is not None and str(value).strip(): + return str(value).strip(), "context.metadata.header.IntensityUnit" + return "input intensity units", "fallback: caller-supplied intensity scale; no unit inferred" + + +def _q_window(result: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> tuple[float | None, float | None]: + value = result.get("q_window") + if not isinstance(value, (str, bytes)): + try: + pair = list(value) + except (TypeError, ValueError): + pair = [] + else: + pair = [] + if len(pair) == 2: + low, high = _finite(pair[0]), _finite(pair[1]) + if low is not None and high is not None: + return low, high + edges: list[np.ndarray] = [np.asarray(row["q_edges"], dtype=float) for row in rows if len(row["q_edges"])] + finite = np.concatenate([array[np.isfinite(array)] for array in edges]) if edges else np.asarray([], dtype=float) + if finite.size: + return float(np.min(finite)), float(np.max(finite)) + return None, None + + +def _metadata(data: Mapping[str, Any], result: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + intensity_unit, intensity_source = _intensity_unit(data, result) + units = result.get("units") + units = units if isinstance(units, Mapping) else {} + q_unit = str(result.get("q_unit", units.get("q", data.get("q_unit", "unknown"))) or "unknown") + q_min, q_max = _q_window(result, rows) + settings = result.get("settings") + settings = settings if isinstance(settings, Mapping) else {} + sampling = result.get("sampling") + sampling = sampling if isinstance(sampling, Mapping) else {} + overlap = result.get("sector_overlap") + overlap = overlap if isinstance(overlap, Mapping) else {} + q_step = _finite( + sampling.get("effective_radial_bin_width", settings.get("q_bin_step")) + ) + representative_step = _finite( + sampling.get("representative_q_step", settings.get("representative_q_step")) + ) + return { + "schema_version": SECTOR_FIGURE_SCHEMA_VERSION, + "method_version": SECTOR_FIGURE_METHOD_VERSION, + "source_measurement_method_version": result.get("method_version"), + "q_unit": q_unit, + "intensity_unit": intensity_unit, + "intensity_unit_source": intensity_source, + "q_window": [q_min, q_max] if q_min is not None and q_max is not None else None, + "q_bin_width": q_step, + "representative_q_step": representative_step, + "settings": json_safe(settings), + "sector_overlap": json_safe(overlap), + "overlap_correlation_warning": str( + overlap.get( + "correlation_statement", + "overlapping sectors may share detector pixels and are correlated; they are not independent samples", + ) + ), + "sector_count": len(rows), + "selected_peak_count": sum( + isinstance(row.get("selected_peak"), Mapping) for row in rows + ), + "missing_bins_are_left_blank": True, + "quadrants_are_not_synthesized": True, + "smooth_is_locator_only": True, + "fwhm_is_not_confidence_interval": True, + } + + +def _angle_edges(angles: np.ndarray) -> np.ndarray: + if angles.size == 0: + return np.asarray([0.0, 360.0], dtype=float) + order = np.argsort(angles) + sorted_angles = np.asarray(angles[order], dtype=float) + if sorted_angles.size == 1: + width = 1.0 + return np.asarray([sorted_angles[0] - width, sorted_angles[0] + width]) + edges = np.empty(sorted_angles.size + 1, dtype=float) + edges[1:-1] = 0.5 * (sorted_angles[:-1] + sorted_angles[1:]) + edges[0] = sorted_angles[0] - 0.5 * (sorted_angles[1] - sorted_angles[0]) + edges[-1] = sorted_angles[-1] + 0.5 * (sorted_angles[-1] - sorted_angles[-2]) + return edges + + +def _selected(row: Mapping[str, Any]) -> Mapping[str, Any] | None: + value = row.get("selected_peak") + return value if isinstance(value, Mapping) else None + + +_STATUS_REASON_LABELS = { + "low_q_boundary_without_two_sided_support": "low-q boundary", + "insufficient_two_sided_support": "insufficient two-sided support", + "insufficient_two_sided_background_return": "no two-sided background return", + "ambiguous_multiple_peaks": "ambiguous candidates", + "flat_top_unresolved_peak": "flat-top unresolved", + "no_supported_bins_after_count_coverage_gate": "insufficient count/coverage", + "no_finite_intensity_support": "no finite intensity support", + "monotonic_profile": "monotonic profile", +} + + +def _compact_status(row: Mapping[str, Any]) -> str: + """Return a short visual status; the full reason remains in CSV/JSON.""" + + status = str(row.get("status") or "unknown").replace("_", " ") + reason = str(row.get("reason") or "") + if status == "selected": + return "selected" + if reason: + reason = _STATUS_REASON_LABELS.get(reason, reason.replace("_", " ")) + if len(reason) > 28: + reason = reason[:25].rstrip() + "..." + return f"{status} · {reason}" + return status + + +def _build_qchi_figure(rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any], *, width_mm: float, dpi: int) -> Figure: + height_mm = 108.0 if width_mm == 183.0 else 132.0 + fig = Figure(figsize=(width_mm / 25.4, height_mm / 25.4), dpi=dpi, facecolor="white", edgecolor="white") + FigureCanvasAgg(fig) + if width_mm == 183.0: + ax = fig.add_axes([0.10, 0.19, 0.78, 0.68]) + cax = fig.add_axes([0.90, 0.25, 0.018, 0.52]) + else: + ax = fig.add_axes([0.16, 0.24, 0.68, 0.59]) + cax = fig.add_axes([0.87, 0.32, 0.028, 0.40]) + if not rows: + ax.text(0.5, 0.5, "No sector profiles supplied", transform=ax.transAxes, ha="center", va="center", color="#555555") + ax.set_axis_off() + fig.suptitle("Sector-integrated I(q, χ) · no data", y=0.96, fontsize=7.0) + return fig + order = np.argsort([_number(row.get("angle_deg")) for row in rows]) + ordered = [rows[int(index)] for index in order] + angles = np.asarray([_number(row.get("angle_deg")) for row in ordered], dtype=float) + lengths = [len(row["q_centers"]) for row in ordered] + max_len = max(lengths, default=0) + q_reference = next((np.asarray(row["q_centers"], dtype=float) for row in ordered if len(row["q_centers"])), np.asarray([], dtype=float)) + q_edges = next((np.asarray(row["q_edges"], dtype=float) for row in ordered if len(row["q_edges"])), np.asarray([], dtype=float)) + matrix = np.full((len(ordered), max_len), np.nan, dtype=float) + for row_index, row in enumerate(ordered): + values = np.asarray(row["raw_mean"], dtype=float) + matrix[row_index, : len(values)] = values + masked = np.ma.masked_invalid(matrix) + if len(q_edges) == max_len + 1 and max_len: + angle_edges = _angle_edges(angles) + mesh = ax.pcolormesh(q_edges, angle_edges, masked, shading="auto", cmap="cividis", rasterized=True) + elif max_len: + mesh = ax.pcolormesh(q_reference, np.arange(len(ordered) + 1), masked, shading="nearest", cmap="cividis", rasterized=True) + ax.set_yticks(np.arange(len(ordered)) + 0.5, [f"{value:.1f}" for value in angles]) + else: + ax.text(0.5, 0.5, "No finite q bins supplied", transform=ax.transAxes, ha="center", va="center", color="#555555") + mesh = None + if mesh is not None: + cbar = fig.colorbar(mesh, cax=cax) + cbar.set_label(f"raw sector mean I ({metadata['intensity_unit']})", fontsize=5.8, labelpad=2.0) + cbar.ax.tick_params(labelsize=5.0, length=1.2, width=0.4, pad=1.0) + for row, angle in zip(ordered, angles, strict=True): + selected = _selected(row) + q_star = _finite(selected.get("q_star")) if selected is not None else None + if q_star is not None: + ax.scatter([q_star], [angle], s=17, marker="o", facecolors="none", edgecolors="#D55E00", linewidths=0.8, zorder=5) + q_unit = str(metadata["q_unit"]) + ax.set_xlabel(f"q ({q_unit})") + ax.set_ylabel("Azimuth χ (degree)") + ax.set_title("Raw sector-integrated intensity I(q, χ)", fontsize=7.0) + ax.tick_params(direction="out", length=2.0, width=0.5, pad=1.5) + ax.grid(False) + for spine in ax.spines.values(): + spine.set_linewidth(0.5) + if width_mm == 183.0: + footer = ( + "Blank cells = missing measured support; circles = one selected candidate per χ.\n" + "No missing quadrants are synthesized; overlapping sectors are correlated." + ) + else: + footer = ( + "Blank cells = missing measured support.\n" + "Circles = one selected candidate per χ; no missing quadrants are synthesized.\n" + "Overlapping sectors are correlated." + ) + fig.text( + 0.5, + 0.055 if width_mm == 183.0 else 0.045, + footer, + ha="center", + va="center", + fontsize=5.0, + linespacing=1.25, + color="#555555", + ) + return fig + + +def _representative_rows(rows: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: + if not rows: + return [] + remaining = list(rows) + selected_rows: list[Mapping[str, Any]] = [] + for target in _REPRESENTATIVE_ANGLES: + if not remaining: + break + index = min( + range(len(remaining)), + key=lambda item: abs(((_number(remaining[item].get("angle_deg")) - target + 180.0) % 360.0) - 180.0), + ) + selected_rows.append(remaining.pop(index)) + return selected_rows + + +def _build_profiles_figure(rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any], *, width_mm: float, dpi: int) -> Figure: + height_mm = 120.0 if width_mm == 183.0 else 145.0 + fig = Figure(figsize=(width_mm / 25.4, height_mm / 25.4), dpi=dpi, facecolor="white", edgecolor="white") + FigureCanvasAgg(fig) + if width_mm == 183.0: + profile_ax = fig.add_axes([0.09, 0.46, 0.84, 0.34]) + support_ax = fig.add_axes([0.09, 0.15, 0.84, 0.18], sharex=profile_ax) + else: + profile_ax = fig.add_axes([0.16, 0.47, 0.70, 0.33]) + support_ax = fig.add_axes([0.16, 0.15, 0.70, 0.18], sharex=profile_ax) + reps = _representative_rows(rows) + if not reps: + profile_ax.text(0.5, 0.5, "No sector profiles supplied", transform=profile_ax.transAxes, ha="center", va="center", color="#555555") + support_ax.set_axis_off() + else: + coverage_ax = support_ax.twinx() + handles: list[Any] = [] + for index, row in enumerate(reps): + color = _SECTOR_COLORS[index % len(_SECTOR_COLORS)] + angle = _number(row.get("angle_deg")) + q = np.asarray(row["q_centers"], dtype=float) + raw = np.asarray(row["raw_mean"], dtype=float) + smooth = np.asarray(row["smoothed_intensity"], dtype=float) + count = np.asarray(row["raw_count"], dtype=float) + coverage = np.asarray(row["coverage"], dtype=float) + q_len = min(len(q), len(raw), len(smooth)) + if q_len: + q, raw, smooth = q[:q_len], raw[:q_len], smooth[:q_len] + (raw_line,) = profile_ax.plot(q, raw, color=color, linewidth=0.65, marker="o", markersize=1.4, markerfacecolor="none", alpha=0.9, label=f"χ={angle:.1f}° raw sector mean") + (smooth_line,) = profile_ax.plot(q, smooth, color=color, linewidth=1.0, linestyle="--", alpha=0.95, label=f"χ={angle:.1f}° locator-only smooth") + handles.extend([raw_line, smooth_line]) + if len(count): + q_count = np.asarray(row["q_centers"], dtype=float)[: len(count)] + support_ax.plot(q_count, count, color=color, linewidth=0.65, alpha=0.8) + if len(coverage): + q_cov = np.asarray(row["q_centers"], dtype=float)[: len(coverage)] + coverage_ax.plot(q_cov, coverage[: len(q_cov)], color=color, linewidth=0.65, linestyle=":", alpha=0.85) + selected = _selected(row) + q_star = _finite(selected.get("q_star")) if selected is not None else None + if q_star is not None and q_len: + intensity = _finite(selected.get("raw_intensity")) + if intensity is None: + intensity = _finite(selected.get("intensity")) + if intensity is None: + finite = raw[np.isfinite(raw)] + intensity = float(np.max(finite)) if finite.size else None + if intensity is not None: + profile_ax.scatter([q_star], [intensity], s=18, marker="o", facecolors="white", edgecolors=color, linewidths=0.9, zorder=6) + profile_ax.text( + 0.985, + 0.96 - index * 0.13, + f"χ={angle:.1f}° · {_compact_status(row)}", + transform=profile_ax.transAxes, + ha="right", + va="top", + fontsize=5.0, + color=color, + clip_on=False, + ) + profile_ax.set_ylabel(f"Raw intensity ({metadata['intensity_unit']})") + support_ax.set_ylabel("valid count", fontsize=5.8) + coverage_ax.set_ylabel("coverage", fontsize=5.8, color="#555555") + coverage_ax.set_ylim(0.0, 1.05) + coverage_ax.tick_params(axis="y", labelsize=5.0, colors="#555555", length=1.5, width=0.4) + support_ax.set_xlabel(f"q ({metadata['q_unit']})") + support_ax.set_title("Support: count + coverage", fontsize=5.8, pad=2.0) + if handles: + # Keep the role legend at figure level so the support axes cannot + # cover it. Representative-sector colours are identified by the + # compact status labels in the profile panel. + legend_handles = [ + Line2D( + [0], [0], color="#555555", marker="o", + markerfacecolor="none", linewidth=0.75, markersize=2.5, + label="raw sector mean", + ), + Line2D( + [0], [0], color="#555555", linestyle="--", linewidth=0.95, + label="locator-only smooth", + ), + Line2D( + [0], [0], color="#555555", linestyle=":", linewidth=0.75, + label="coverage", + ), + ] + fig.legend( + handles=legend_handles, + loc="lower center", + bbox_to_anchor=(0.5, 0.355), + frameon=False, + ncol=1, + fontsize=5.0, + handlelength=1.3, + handletextpad=0.3, + columnspacing=0.7, + ) + for axis in (profile_ax, support_ax): + axis.tick_params(direction="out", length=2.0, width=0.5, pad=1.4, labelsize=5.2) + axis.grid(axis="y", color="#dddddd", linewidth=0.3) + for spine in axis.spines.values(): + spine.set_linewidth(0.5) + if width_mm == 183.0: + footer = ( + "Selected points are diagnostic candidates; smoothing is locator-only and FWHM is not a confidence interval.\n" + "Missing bins stay blank." + ) + else: + footer = ( + "Markers are diagnostic candidates; raw data remain primary.\n" + "Missing bins stay blank; no fit or confidence interval is added." + ) + fig.suptitle( + "Representative sector I(q | χ) · raw mean + locator-only smooth" + if width_mm == 183.0 + else "Representative sector I(q | χ)\nraw mean + locator-only smooth", + x=0.5, + y=0.965, + fontsize=7.0, + linespacing=1.05, + ) + fig.text( + 0.5, + 0.055 if width_mm == 183.0 else 0.045, + footer, + ha="center", + va="center", + fontsize=5.0, + linespacing=1.25, + color="#555555", + ) + return fig + + +def render_sector_peak_figures(result: Mapping[str, Any], *, data: Mapping[str, Any] | None = None, width_mm: float = 183.0, dpi: int = 600) -> dict[str, Figure]: + """Build the two sector figures from an existing sector result.""" + + if not isinstance(result, Mapping): + raise TypeError("result must be a sector_peaks mapping") + width = float(width_mm) + if width not in {89.0, 183.0}: + raise ValueError("width_mm must be 89 or 183") + if isinstance(dpi, bool) or int(dpi) != dpi or int(dpi) <= 0: + raise ValueError("dpi must be a positive integer") + data = data or {} + rows = _sector_rows(result) + metadata = _metadata(data, result, rows) + with _EXPORT_LOCK, mpl.rc_context(_FIGURE_RC): + figures = { + "sector_qchi": _build_qchi_figure(rows, metadata, width_mm=width, dpi=int(dpi)), + "sector_profiles": _build_profiles_figure(rows, metadata, width_mm=width, dpi=int(dpi)), + } + for figure in figures.values(): + figure.canvas.draw() + return figures + + +_PROFILE_FIELDS = ( + "sector_index", "angle_deg", "width_deg", "q_unit", "intensity_unit", + "q_window_min", "q_window_max", "q_bin_width", "representative_q_step", + "q_bin_index", "q_left", "q_right", "q_center", "raw_mean", "raw_sum", + "raw_count", "geometric_count", "coverage", "supported", "smoothed_intensity", + "candidate_count", "selected_q_star", "selected_prominence", "selected_snr", + "selected_radial_fwhm", "sector_status", "sector_reason", "overlap_fraction", + "overlapping_sectors", "overlap_correlation_statement", +) + +_PEAK_FIELDS = ( + "record_type", "sector_index", "angle_deg", "width_deg", "q_unit", "intensity_unit", + "q_window_min", "q_window_max", "q_bin_width", "representative_q_step", + "status", "reason", "selected", "q_star", "q_bin_center", "raw_intensity", + "smoothed_intensity", "prominence", "snr", "radial_fwhm", "sampling_sigma_q", + "half_bin_resolution_q", "peak_bin_index", "source_pixel_count", "pixel_x", + "pixel_y", "source_angle_deg", "candidate_count", "candidate_reason", + "candidate_json", "overlap_fraction", "overlapping_sectors", +) + + +def _safe_csv_row(row: Mapping[str, Any]) -> dict[str, Any]: + """Protect every CSV cell while leaving signed numeric values unchanged.""" + + return {key: safe_csv_cell(value) for key, value in row.items()} + + +def _write_profiles_csv(path: Path, rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any]) -> None: + q_window = metadata.get("q_window") or (None, None) + overlap = metadata.get("sector_overlap") or {} + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=_PROFILE_FIELDS, extrasaction="ignore") + writer.writeheader() + for row in rows: + q = np.asarray(row["q_centers"], dtype=float) + edges = np.asarray(row["q_edges"], dtype=float) + raw = np.asarray(row["raw_mean"], dtype=float) + sums = np.asarray(row["raw_sum"], dtype=float) + counts = np.asarray(row["raw_count"], dtype=float) + geometric = np.asarray(row["geometric_counts"], dtype=float) + coverage = np.asarray(row["coverage"], dtype=float) + supported = np.asarray(row["supported_bin_mask"], dtype=bool) + smooth = np.asarray(row["smoothed_intensity"], dtype=float) + length = max(map(len, (q, raw, sums, counts, geometric, coverage, supported, smooth)), default=0) + selected = _selected(row) + candidate_count = len(row.get("candidates", ())) if isinstance(row.get("candidates", ()), Sequence) else 0 + for index in range(length): + edge_left = edges[index] if index < len(edges) else np.nan + edge_right = edges[index + 1] if index + 1 < len(edges) else np.nan + writer.writerow(_safe_csv_row({ + "sector_index": row["sector_index"], "angle_deg": _csv_number(row.get("angle_deg")), "width_deg": _csv_number(row.get("width_deg")), + "q_unit": metadata["q_unit"], "intensity_unit": metadata["intensity_unit"], "q_window_min": _csv_number(q_window[0]), "q_window_max": _csv_number(q_window[1]), + "q_bin_width": _csv_number(metadata.get("q_bin_width")), "representative_q_step": _csv_number(metadata.get("representative_q_step")), "q_bin_index": index, + "q_left": _csv_number(edge_left), "q_right": _csv_number(edge_right), "q_center": _csv_number(q[index] if index < len(q) else np.nan), + "raw_mean": _csv_number(raw[index] if index < len(raw) else np.nan), "raw_sum": _csv_number(sums[index] if index < len(sums) else np.nan), + "raw_count": _csv_number(counts[index] if index < len(counts) else np.nan), "geometric_count": _csv_number(geometric[index] if index < len(geometric) else np.nan), + "coverage": _csv_number(coverage[index] if index < len(coverage) else np.nan), "supported": bool(supported[index]) if index < len(supported) else False, + "smoothed_intensity": _csv_number(smooth[index] if index < len(smooth) else np.nan), "candidate_count": candidate_count, + "selected_q_star": _csv_number(selected.get("q_star")) if selected else "", "selected_prominence": _csv_number(selected.get("prominence")) if selected else "", + "selected_snr": _csv_number(selected.get("snr")) if selected else "", "selected_radial_fwhm": _csv_number(selected.get("radial_fwhm")) if selected else "", + "sector_status": row.get("status", ""), "sector_reason": row.get("reason") or "", "overlap_fraction": _csv_number(overlap.get("overlap_fraction")), + "overlapping_sectors": bool(overlap.get("overlapping", False)), "overlap_correlation_statement": metadata["overlap_correlation_warning"], + })) + + +def _write_peaks_csv(path: Path, rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any]) -> None: + q_window = metadata.get("q_window") or (None, None) + overlap = metadata.get("sector_overlap") or {} + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=_PEAK_FIELDS, extrasaction="ignore") + writer.writeheader() + for row in rows: + candidates = row.get("candidates", ()) + if not isinstance(candidates, Sequence) or isinstance(candidates, (str, bytes)): + candidates = () + entries: list[tuple[str, Mapping[str, Any] | None]] = [("sector_summary", _selected(row))] + entries.extend(("candidate", item if isinstance(item, Mapping) else None) for item in candidates) + for record_type, candidate in entries: + candidate = candidate or {} + writer.writerow(_safe_csv_row({ + "record_type": record_type, "sector_index": row["sector_index"], "angle_deg": _csv_number(row.get("angle_deg")), "width_deg": _csv_number(row.get("width_deg")), + "q_unit": metadata["q_unit"], "intensity_unit": metadata["intensity_unit"], "q_window_min": _csv_number(q_window[0]), "q_window_max": _csv_number(q_window[1]), + "q_bin_width": _csv_number(metadata.get("q_bin_width")), "representative_q_step": _csv_number(metadata.get("representative_q_step")), + "status": candidate.get("status", row.get("status", "")), "reason": candidate.get("reason") or row.get("reason") or "", "selected": bool(candidate.get("selected", False)), + "q_star": _csv_number(candidate.get("q_star")), "q_bin_center": _csv_number(candidate.get("q_bin_center")), "raw_intensity": _csv_number(candidate.get("raw_intensity", candidate.get("intensity"))), + "smoothed_intensity": _csv_number(candidate.get("smoothed_intensity")), "prominence": _csv_number(candidate.get("prominence")), "snr": _csv_number(candidate.get("snr")), + "radial_fwhm": _csv_number(candidate.get("radial_fwhm")), "sampling_sigma_q": _csv_number(candidate.get("sampling_sigma_q")), "half_bin_resolution_q": _csv_number(candidate.get("half_bin_resolution_q")), + "peak_bin_index": candidate.get("peak_bin_index", ""), "source_pixel_count": candidate.get("source_pixel_count", ""), "pixel_x": candidate.get("pixel_x", ""), "pixel_y": candidate.get("pixel_y", ""), "source_angle_deg": _csv_number(candidate.get("source_angle_deg")), + "candidate_count": len(candidates), "candidate_reason": candidate.get("reason") or row.get("reason") or "", "candidate_json": json.dumps(json_safe(candidate), ensure_ascii=False, sort_keys=True, allow_nan=False), + "overlap_fraction": _csv_number(overlap.get("overlap_fraction")), "overlapping_sectors": bool(overlap.get("overlapping", False)), + })) + + +def _write_profiles_npz(path: Path, rows: Sequence[Mapping[str, Any]], metadata: Mapping[str, Any]) -> None: + max_len = max((len(row["q_centers"]) for row in rows), default=0) + max_edges = max((len(row["q_edges"]) for row in rows), default=max_len + 1) + def matrix(key: str, *, dtype: Any = float, fill: Any = np.nan) -> np.ndarray: + output = np.full((len(rows), max_len), fill, dtype=dtype) + for index, row in enumerate(rows): + values = np.asarray(row[key], dtype=dtype) + output[index, : min(max_len, len(values))] = values[:max_len] + return output + edges = np.full((len(rows), max_edges), np.nan, dtype=float) + for index, row in enumerate(rows): + values = np.asarray(row["q_edges"], dtype=float) + edges[index, : min(max_edges, len(values))] = values[:max_edges] + selected_q = np.asarray([_number((_selected(row) or {}).get("q_star")) for row in rows], dtype=float) + payload = { + "sector_index": np.asarray([int(row["sector_index"]) for row in rows], dtype=np.int64), + "angle_deg": np.asarray([_number(row.get("angle_deg")) for row in rows], dtype=float), + "width_deg": np.asarray([_number(row.get("width_deg")) for row in rows], dtype=float), + "q_centers": matrix("q_centers"), "q_edges": edges, "raw_mean": matrix("raw_mean"), "raw_sum": matrix("raw_sum"), + "raw_count": matrix("raw_count", dtype=np.int64, fill=0), "geometric_counts": matrix("geometric_counts", dtype=np.int64, fill=0), "coverage": matrix("coverage"), + "supported_bin_mask": matrix("supported_bin_mask", dtype=bool, fill=False), "smoothed_intensity": matrix("smoothed_intensity"), + "selected_q_star": selected_q, "sector_status": np.asarray([str(row.get("status", "")) for row in rows], dtype="U32"), + "sector_reason": np.asarray([str(row.get("reason") or "") for row in rows], dtype="U160"), "q_unit": np.asarray(str(metadata["q_unit"])), + "intensity_unit": np.asarray(str(metadata["intensity_unit"])), "q_window": np.asarray(metadata.get("q_window") or [np.nan, np.nan], dtype=float), + "q_bin_width": np.asarray(_number(metadata.get("q_bin_width"))), "representative_q_step": np.asarray(_number(metadata.get("representative_q_step"))), + "schema_version": np.asarray(SECTOR_FIGURE_SCHEMA_VERSION), "source_method_version": np.asarray(str(metadata.get("source_measurement_method_version") or "")), + } + np.savez_compressed(path, **payload) + + +def _caption(metadata: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> str: + window = metadata.get("q_window") + window_text = "unknown q window" if not window else f"[{window[0]:.6g}, {window[1]:.6g}] {metadata['q_unit']}" + selected = sum(_selected(row) is not None for row in rows) + return ( + "Sector-integrated butterfly SAXS evidence. The q–χ panel displays the raw arithmetic mean intensity " + f"for each measured azimuthal sector over q window {window_text}; bins without measured intensity remain blank. " + "Finite raw means remain visible even when count/coverage is insufficient for peak detection; " + "no opposite quadrant or missing sector is synthesized. The representative profiles show the same raw " + "sector mean and a separately named locator-only smooth copied from the supplied sector result. Smoothing is " + "not a fit. At most one selected diagnostic candidate is marked per sector (" + f"{selected} of {len(rows)} sectors in this bundle); a no_peak or ambiguous status is retained with its reason. " + f"The source intensity scale is {metadata['intensity_unit']} ({metadata['intensity_unit_source']}). " + "Counts and coverage are detector support statistics, not independent experimental replicates. Overlapping " + "sectors share pixels and are correlated. Candidate prominence, SNR, and FWHM are diagnostic fields; FWHM " + "is a radial width and not a confidence interval, and no scientific acceptance is inferred." + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path: Path, payload: Any) -> None: + with path.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(json_safe(payload), stream, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + stream.write("\n") + + +def export_sector_peak_figures(stage: str | Path, *, data: Mapping[str, Any], result: Mapping[str, Any], cancel_event: Any = None) -> tuple[dict[str, Path], dict[str, Any]]: + """Write sector assets into an existing caller-owned atomic stage.""" + + raise_if_cancelled(cancel_event, "sector-peak-figures:validate") + stage_path = Path(stage).expanduser().resolve(strict=True) + if not stage_path.is_dir(): + raise ValueError("stage must be an existing directory") + if not isinstance(data, Mapping) or not isinstance(result, Mapping): + raise TypeError("data and result must be mappings") + width_mm = float(data.get("width_mm", 183.0)) + dpi_raw = data.get("dpi", 600) + if width_mm not in {89.0, 183.0}: + raise ValueError("data.width_mm must be 89 or 183") + if isinstance(dpi_raw, bool) or int(dpi_raw) != dpi_raw or int(dpi_raw) <= 0: + raise ValueError("data.dpi must be a positive integer") + dpi = int(dpi_raw) + rows = _sector_rows(result) + metadata = _metadata(data, result, rows) + filenames = { + "sector_qchi_svg": "sector_qchi.svg", "sector_qchi_pdf": "sector_qchi.pdf", "sector_qchi_png": "sector_qchi.png", "sector_qchi_tiff": "sector_qchi.tiff", + "sector_profiles_svg": "sector_profiles.png.svg", "sector_profiles_pdf": "sector_profiles.png.pdf", "sector_profiles_png": "sector_profiles.png", "sector_profiles_tiff": "sector_profiles.tiff", + "sector_profiles_csv": "sector_profiles.csv", "sector_peaks_csv": "sector_peaks.csv", "sector_profiles_npz": "sector_profiles.npz", "sector_caption": "sector_caption.txt", "sector_manifest": "sector_manifest.json", + } + # Keep the source stem simple for consumers while avoiding a collision with + # the machine-readable CSV and NPZ files. + filenames["sector_profiles_svg"] = "sector_profiles.svg" + filenames["sector_profiles_pdf"] = "sector_profiles.pdf" + outputs = {key: stage_path / name for key, name in filenames.items()} + existing = next((path for path in outputs.values() if path.exists()), None) + if existing is not None: + raise FileExistsError(f"sector figure output already exists in stage: {existing.name}") + figures = render_sector_peak_figures(result, data=data, width_mm=width_mm, dpi=dpi) + try: + with _EXPORT_LOCK, mpl.rc_context(_FIGURE_RC): + for stem, figure in figures.items(): + raise_if_cancelled(cancel_event, f"sector-peak-figures:render-{stem}") + figure.canvas.draw() + for suffix, file_format in (("svg", "svg"), ("pdf", "pdf"), ("png", "png"), ("tiff", "tiff")): + raise_if_cancelled(cancel_event, f"sector-peak-figures:save-{stem}-{suffix}") + figure.savefig(outputs[f"{stem}_{suffix}"], format=file_format, dpi=dpi if suffix in {"png", "tiff"} else None, facecolor="white", transparent=False) + figure.clear() + finally: + for figure in figures.values(): + if figure.canvas is not None: + figure.clear() + raise_if_cancelled(cancel_event, "sector-peak-figures:write-data") + _write_profiles_csv(outputs["sector_profiles_csv"], rows, metadata) + _write_peaks_csv(outputs["sector_peaks_csv"], rows, metadata) + _write_profiles_npz(outputs["sector_profiles_npz"], rows, metadata) + caption = _caption(metadata, rows) + outputs["sector_caption"].write_text(caption + "\n", encoding="utf-8", newline="\n") + for path in outputs.values(): + if path.parent != stage_path: + raise RuntimeError("sector exporter attempted to write outside its caller-owned stage") + file_hashes = {path.name: _sha256(path) for key, path in outputs.items() if key != "sector_manifest"} + manifest = { + **metadata, + "width_mm": width_mm, + "dpi": dpi, + "representative_angles_deg": list(_REPRESENTATIVE_ANGLES), + "files": list(file_hashes), + "sha256": file_hashes, + "manifest_excluded_from_own_sha256": True, + "caption_file": "sector_caption.txt", + "profiles_csv_file": "sector_profiles.csv", + "peaks_csv_file": "sector_peaks.csv", + "profiles_npz_file": "sector_profiles.npz", + "figure_files": ["sector_qchi.svg", "sector_qchi.pdf", "sector_qchi.png", "sector_qchi.tiff", "sector_profiles.svg", "sector_profiles.pdf", "sector_profiles.png", "sector_profiles.tiff"], + } + _write_json(outputs["sector_manifest"], manifest) + return outputs, json_safe(manifest) + + +__all__ = [ + "SECTOR_FIGURE_METHOD_VERSION", + "SECTOR_FIGURE_SCHEMA_VERSION", + "export_sector_peak_figures", + "render_sector_peak_figures", +] diff --git a/src/butterfly_saxs/sector_peaks.py b/src/butterfly_saxs/sector_peaks.py new file mode 100644 index 0000000..1e05226 --- /dev/null +++ b/src/butterfly_saxs/sector_peaks.py @@ -0,0 +1,1021 @@ +"""Observed radial peaks from overlapping azimuthal sector integrals. + +This module is deliberately independent from the butterfly ridge and ellipse +fit paths. It answers a narrower measurement question: for each angular +sector, what radial features are present in the measured intensity profile? +The result is an audit-friendly collection of raw profiles and candidates. +It does not index a reflection, impose four lobes, fill masked quadrants, or +choose a peak from an empty/ambiguous profile. + +The sector profiles are formed from actual detector pixels. Smoothing is +performed separately on each contiguous supported radial run, so a detector +hole cannot be bridged into a synthetic peak. A selected peak reports a +representative source pixel from the measured support bin; its sub-bin q +coordinate is a localisation aid, not a fitted physical model. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import numpy as np + +from .cancellation import raise_if_cancelled +from .observables import _q_unit +from .ridge_inputs import Q_ALIASES, array_field, canonical_inputs, robust_noise + +try: # scipy is a declared project dependency, but retain a small fallback. + from scipy.ndimage import gaussian_filter1d + from scipy.signal import find_peaks +except Exception: # pragma: no cover - only useful in partial installs + gaussian_filter1d = None + find_peaks = None + + +SECTOR_PEAKS_SCHEMA_VERSION = "sector-peaks-v1" +SECTOR_PEAKS_METHOD_VERSION = "sector-peaks-v1.0" + +_EPS = np.finfo(float).eps + + +def _options_mapping(options: Any) -> dict[str, Any]: + if options is None: + return {} + if isinstance(options, Mapping): + return dict(options) + try: + return dict(vars(options)) + except TypeError as exc: + raise TypeError("options must be a mapping or expose attributes") from exc + + +def _option(values: Mapping[str, Any], name: str, default: Any, *aliases: str) -> Any: + for key in (name, *aliases): + if key in values: + return values[key] + return default + + +def _finite_float(value: Any, name: str, *, minimum: float | None = None) -> float: + if isinstance(value, (bool, np.bool_)): + raise ValueError(f"{name} must be a finite number, not bool") + try: + result = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a finite number") from exc + if not np.isfinite(result): + raise ValueError(f"{name} must be a finite number") + if minimum is not None and result < minimum: + raise ValueError(f"{name} must be >= {minimum:g}") + return result + + +def _strict_int(value: Any, name: str, *, minimum: int = 0) -> int: + """Parse an integer option without silently truncating floats or bools.""" + + if isinstance(value, (bool, np.bool_)): + raise ValueError(f"{name} must be an integer, not bool") + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer") from exc + if not np.isfinite(numeric) or not numeric.is_integer(): + raise ValueError(f"{name} must be an integer") + result = int(numeric) + if result < minimum: + raise ValueError(f"{name} must be >= {minimum}") + return result + + +def _q_window(value: Any) -> tuple[float, float]: + if isinstance(value, Mapping): + low = value.get("q_min", value.get("min", value.get("low", value.get("start")))) + high = value.get("q_max", value.get("max", value.get("high", value.get("stop")))) + else: + try: + low, high = value + except (TypeError, ValueError) as exc: + raise ValueError("q_window must be a finite (q_min, q_max) pair") from exc + low = _finite_float(low, "q_window[0]") + high = _finite_float(high, "q_window[1]") + if high <= low: + raise ValueError("q_window must have q_max > q_min") + return low, high + + +def _contiguous_runs(supported: np.ndarray) -> list[tuple[int, int]]: + """Return half-open runs of True values without bridging holes.""" + + indices = np.flatnonzero(np.asarray(supported, dtype=bool)) + if not indices.size: + return [] + breaks = np.flatnonzero(np.diff(indices) > 1) + starts = np.r_[0, breaks + 1] + stops = np.r_[breaks + 1, indices.size] + return [(int(indices[start]), int(indices[stop - 1]) + 1) for start, stop in zip(starts, stops)] + + +def _angle_delta_deg(angle: np.ndarray, centre: float) -> np.ndarray: + return np.abs((np.asarray(angle, dtype=float) - float(centre) + 180.0) % 360.0 - 180.0) + + +def _smooth_supported(profile: np.ndarray, supported: np.ndarray, sigma: float) -> np.ndarray: + """Smooth each supported run independently and leave holes as NaN.""" + + result = np.full(np.asarray(profile).shape, np.nan, dtype=float) + sigma = float(sigma) + for left, right in _contiguous_runs(supported): + values = np.asarray(profile[left:right], dtype=float) + if gaussian_filter1d is not None and sigma > 0.0 and values.size >= 3: + result[left:right] = gaussian_filter1d(values, sigma=sigma, mode="nearest") + else: + result[left:right] = values + return result + + +def _profile_noise(profile: np.ndarray, smooth: np.ndarray, supported: np.ndarray) -> float: + """Estimate profile noise from raw-minus-smooth and adjacent raw bins. + + The estimate is a profile-local robust scale, not an experimental + uncertainty or detector calibration sigma. Only contiguous supported + bins contribute, so masked gaps do not become a noise window bridge. + """ + + residuals: list[np.ndarray] = [] + differences: list[np.ndarray] = [] + for left, right in _contiguous_runs(supported): + raw = np.asarray(profile[left:right], dtype=float) + filtered = np.asarray(smooth[left:right], dtype=float) + finite = np.isfinite(raw) & np.isfinite(filtered) + if np.count_nonzero(finite) >= 2: + residuals.append(raw[finite] - filtered[finite]) + if raw.size >= 2: + adjacent = np.isfinite(raw[:-1]) & np.isfinite(raw[1:]) + if np.any(adjacent): + differences.append(np.diff(raw)[adjacent]) + candidates: list[float] = [] + if residuals: + value = robust_noise(np.concatenate(residuals)) + if np.isfinite(value): + candidates.append(float(value)) + if differences: + value = robust_noise(np.concatenate(differences)) / np.sqrt(2.0) + if np.isfinite(value): + candidates.append(float(value)) + if not candidates: + finite_values = np.asarray(profile, dtype=float)[np.asarray(supported, dtype=bool)] + value = robust_noise(finite_values) + if np.isfinite(value): + candidates.append(float(value)) + finite_values = np.asarray(profile, dtype=float)[np.asarray(supported, dtype=bool)] + scale = max(float(np.ptp(finite_values)) if finite_values.size else 0.0, 1.0) + return max(float(max(candidates, default=0.0)), _EPS * scale) + + +def _robust_raw_baseline(values: np.ndarray) -> float: + """Estimate a raw-profile baseline while trimming isolated outliers.""" + + finite = np.asarray(values, dtype=float) + finite = finite[np.isfinite(finite)] + if not finite.size: + return float("nan") + centre = float(np.median(finite)) + mad = robust_noise(finite - centre) + if np.isfinite(mad) and mad > _EPS: + inlier = np.abs(finite - centre) <= 6.0 * mad + trimmed = finite[inlier] + if trimmed.size >= max(3, int(np.ceil(0.5 * finite.size))): + finite = trimmed + return float(np.percentile(finite, 10.0)) + + +def _quadratic_localisation(q: np.ndarray, profile: np.ndarray, index: int) -> tuple[float, float]: + """Local parabolic interpolation on three supported bins only.""" + + index = int(index) + q_value = float(q[index]) + intensity = float(profile[index]) + if index <= 0 or index >= len(profile) - 1: + return q_value, intensity + left, centre, right = (float(profile[index - 1]), float(profile[index]), float(profile[index + 1])) + denominator = left - 2.0 * centre + right + if not np.isfinite(denominator) or abs(denominator) <= _EPS: + return q_value, intensity + offset = 0.5 * (left - right) / denominator + if not np.isfinite(offset): + return q_value, intensity + offset = float(np.clip(offset, -0.5, 0.5)) + step = float(q[index + 1] - q[index]) + if not np.isfinite(step) or step <= 0.0: + return q_value, intensity + q_value += offset * step + intensity -= 0.25 * (left - right) * offset + return q_value, intensity + + +def _manual_peak_indices(values: np.ndarray, prominence: float, distance: int) -> list[int]: + """Small fallback for environments without scipy.signal.""" + + if values.size < 3: + return [] + result: list[int] = [] + for index in range(1, values.size - 1): + if not (values[index] > values[index - 1] and values[index] >= values[index + 1]): + continue + left_min = float(np.min(values[: index + 1])) + right_min = float(np.min(values[index:])) + if values[index] - max(left_min, right_min) < prominence: + continue + if result and index - result[-1] < distance: + if values[index] > values[result[-1]]: + result[-1] = index + continue + result.append(index) + return result + + +def _side_background_return( + values: np.ndarray, + local_index: int, + baseline: float, + *, + fraction: float, +) -> tuple[bool, bool]: + """Check that each side returns toward its local background.""" + + height = float(values[local_index] - baseline) + if not np.isfinite(height) or height <= 0.0: + return False, False + threshold = float(values[local_index] - fraction * height) + left_return = bool(np.nanmin(values[:local_index]) <= threshold) if local_index else False + right_return = bool(np.nanmin(values[local_index + 1 :]) <= threshold) if local_index + 1 < values.size else False + return left_return, right_return + + +def _flat_top_metrics( + values: np.ndarray, + local_index: int, + baseline: float, + width_bins: float, + fraction: float, +) -> tuple[int, float]: + """Measure a flat top relative to the candidate's half-height width.""" + + peak = float(values[local_index]) + height = peak - float(baseline) + if not np.isfinite(height) or height <= 0.0: + return 0, 0.0 + near_peak_threshold = float(baseline + float(fraction) * height) + left = local_index + right = local_index + while left > 0 and float(values[left - 1]) >= near_peak_threshold: + left -= 1 + while right + 1 < values.size and float(values[right + 1]) >= near_peak_threshold: + right += 1 + top_width = int(right - left + 1) + ratio = float(top_width / max(float(width_bins), 1.0)) + return top_width, ratio + + +def _source_record( + *, + candidate: dict[str, Any], + q_edges: np.ndarray, + q_values: np.ndarray, + intensity_values: np.ndarray, + qx_values: np.ndarray, + qy_values: np.ndarray, + original_indices: np.ndarray, + image_shape: tuple[int, int], + baseline: float, + neighbourhood_bins: int, +) -> dict[str, Any]: + """Attach measured source-pixel coordinates and support statistics.""" + + bin_index = int(candidate["peak_bin_index"]) + in_bin = ( + np.isfinite(q_values) + & np.isfinite(intensity_values) + & (q_values >= q_edges[bin_index]) + & ((q_values < q_edges[bin_index + 1]) | (bin_index == len(q_edges) - 2)) + ) + indices = np.flatnonzero(in_bin) + if not indices.size: + candidate["source_pixel_count"] = 0 + candidate["pixel_x"] = None + candidate["pixel_y"] = None + candidate["representative_pixel"] = None + return candidate + q_subset = np.asarray(q_values[indices], dtype=float) + i_subset = np.asarray(intensity_values[indices], dtype=float) + target_q = float(candidate["q_star"]) + distance = np.abs(q_subset - target_q) + # A real source pixel is chosen by q proximity, then by observed intensity. + order = np.lexsort((-i_subset, distance)) + selected_local = int(indices[int(order[0])]) + source_flat = int(original_indices[selected_local]) + pixel_y, pixel_x = np.unravel_index(source_flat, image_shape) + source_angles = np.arctan2(qy_values[indices], qx_values[indices]) + source_angle = float(np.degrees(np.arctan2(np.mean(np.sin(source_angles)), np.mean(np.cos(source_angles)))) % 360.0) + neighbourhood_left = max(0, bin_index - int(neighbourhood_bins)) + neighbourhood_right = min(len(q_edges) - 2, bin_index + int(neighbourhood_bins)) + support = ( + np.isfinite(q_values) + & np.isfinite(intensity_values) + & (q_values >= q_edges[neighbourhood_left]) + & ((q_values < q_edges[neighbourhood_right + 1]) | (neighbourhood_right == len(q_edges) - 2)) + ) + positive_excess = np.maximum(np.asarray(intensity_values[support], dtype=float) - float(baseline), 0.0) + weight_sum = float(np.sum(positive_excess)) + weight_square_sum = float(np.sum(np.square(positive_excess))) + if weight_sum > _EPS and weight_square_sum > _EPS: + effective_pixels = float(weight_sum * weight_sum / weight_square_sum) + max_fraction = float(np.max(positive_excess) / weight_sum) + else: + effective_pixels = 0.0 + max_fraction = float("nan") + candidate.update( + { + "source_pixel_count": int(indices.size), + "pixel_x": int(pixel_x), + "pixel_y": int(pixel_y), + "pixel_q": float(q_values[selected_local]), + "pixel_intensity": float(intensity_values[selected_local]), + "source_q_mean": float(np.mean(q_subset)), + "source_q_std": float(np.std(q_subset)), + "source_angle_deg": source_angle, + "source_intensity_mean": float(np.mean(i_subset)), + "source_intensity_max": float(np.max(i_subset)), + "effective_pixel_count": effective_pixels, + "effective_sample_size": effective_pixels, + "n_eff": effective_pixels, + "max_contribution_fraction": max_fraction, + "positive_excess_pixel_count": int(np.count_nonzero(positive_excess > 0.0)), + "effective_support_bin_range": [int(neighbourhood_left), int(neighbourhood_right)], + "representative_pixel": {"pixel_x": int(pixel_x), "pixel_y": int(pixel_y)}, + } + ) + return candidate + + +def _detect_candidates( + *, + q_edges: np.ndarray, + q_centers: np.ndarray, + raw_mean: np.ndarray, + raw_count: np.ndarray, + coverage: np.ndarray, + smooth: np.ndarray, + options: Mapping[str, Any], + q_values: np.ndarray, + intensity_values: np.ndarray, + qx_values: np.ndarray, + qy_values: np.ndarray, + original_indices: np.ndarray, + image_shape: tuple[int, int], +) -> tuple[list[dict[str, Any]], dict[str, Any] | None, str, float]: + supported = ( + (np.asarray(raw_count, dtype=int) >= int(options["min_bin_count"])) + & (np.asarray(coverage, dtype=float) >= float(options["min_coverage"])) + & np.isfinite(raw_mean) + & np.isfinite(smooth) + ) + noise = _profile_noise(raw_mean, smooth, supported) + finite_values = raw_mean[supported] + if not finite_values.size: + raw_values = raw_mean[(np.asarray(raw_count, dtype=int) > 0) & np.isfinite(raw_mean)] + reason = "no_supported_bins_after_count_coverage_gate" if raw_values.size else "no_finite_intensity_support" + return [], None, reason, noise + + span = max(float(np.ptp(finite_values)), _EPS) + min_prominence = max( + float(options["min_prominence_sigma"]) * noise, + float(options["min_prominence_fraction"]) * span, + ) + min_side_bins = int(options["min_side_bins"]) + min_width_bins = float(options["min_width_bins"]) + max_width_fraction = options["max_width_fraction"] + min_effective_pixels = int(options["min_effective_pixels"]) + max_contribution_fraction = float(options["max_contribution_fraction"]) + effective_support_neighbourhood = int(options["effective_support_neighbourhood_bins"]) + min_distance = max(1, int(np.ceil(float(options["min_peak_separation_bins"])))) + baseline = _robust_raw_baseline(finite_values) + height_threshold = float(options["min_prominence_sigma"]) * noise + candidates: list[dict[str, Any]] = [] + + for left, right in _contiguous_runs(supported): + segment = np.asarray(smooth[left:right], dtype=float) + if segment.size < (2 * min_side_bins + 3): + continue + if find_peaks is not None: + local_indices, properties = find_peaks( + segment, + prominence=min_prominence, + distance=min_distance, + width=min_width_bins, + ) + prominences = np.asarray(properties.get("prominences", np.zeros(len(local_indices))), dtype=float) + widths = np.asarray(properties.get("widths", np.ones(len(local_indices))), dtype=float) + else: # pragma: no cover + local_indices = np.asarray(_manual_peak_indices(segment, min_prominence, min_distance), dtype=int) + prominences = np.full(local_indices.size, np.nan, dtype=float) + widths = np.ones(local_indices.size, dtype=float) + for local_pos, prominence, width in zip(local_indices, prominences, widths): + local_pos = int(local_pos) + global_pos = left + local_pos + record: dict[str, Any] = { + "peak_bin_index": global_pos, + "q_bin_center": float(q_centers[global_pos]), + "q_star": float(q_centers[global_pos]), + "intensity": float(raw_mean[global_pos]), + "raw_intensity": float(raw_mean[global_pos]), + "smoothed_intensity": float(smooth[global_pos]), + "prominence": float(prominence), + "snr": float(prominence / max(noise, _EPS)) if np.isfinite(prominence) else float("nan"), + "prominence_snr": float(prominence / max(noise, _EPS)) if np.isfinite(prominence) else float("nan"), + "prominence_threshold": float(min_prominence), + "raw_baseline": float(baseline), + "baseline_method": "10th percentile of robust-MAD-trimmed raw profile", + "height": float(smooth[global_pos] - baseline), + "height_threshold": height_threshold, + "height_snr": float((smooth[global_pos] - baseline) / max(noise, _EPS)), + "radial_fwhm_bins": float(width), + "radial_fwhm": float(width * (q_edges[1] - q_edges[0])), + "sampling_sigma_q": float((q_edges[1] - q_edges[0]) / np.sqrt(12.0)), + "half_bin_resolution_q": float(0.5 * (q_edges[1] - q_edges[0])), + "sampling_sigma_basis": "uniform-within-bin sampling resolution; sampling_resolution_not_CI", + "selected": False, + "status": "candidate", + "reason": None, + } + _source_record( + candidate=record, + q_edges=q_edges, + q_values=q_values, + intensity_values=intensity_values, + qx_values=qx_values, + qy_values=qy_values, + original_indices=original_indices, + image_shape=image_shape, + baseline=baseline, + neighbourhood_bins=effective_support_neighbourhood, + ) + if local_pos < min_side_bins or local_pos >= segment.size - min_side_bins: + record["status"] = "rejected" + record["reason"] = "insufficient_two_sided_support" + else: + left_return, right_return = _side_background_return( + segment, + local_pos, + baseline, + fraction=float(options["side_return_fraction"]), + ) + record["left_background_return"] = left_return + record["right_background_return"] = right_return + if not (left_return and right_return): + record["status"] = "rejected" + record["reason"] = "insufficient_two_sided_background_return" + else: + top_width, flat_ratio = _flat_top_metrics( + segment, + local_pos, + baseline, + float(width), + float(options["flat_top_fraction"]), + ) + record["flat_top_bins"] = top_width + record["flat_top_ratio"] = flat_ratio + record["flat_top_fraction"] = float(options["flat_top_fraction"]) + if ( + top_width >= int(options["flat_top_min_bins"]) + and flat_ratio >= float(options["flat_top_ratio_threshold"]) + ): + record["status"] = "rejected" + record["reason"] = "flat_top_unresolved_peak" + elif max_width_fraction is not None and width > max_width_fraction * segment.size: + # Optional user opt-in only. The default is None: + # broad but returned-to-background profiles remain + # measurable; width is otherwise a diagnostic. + record["status"] = "rejected" + record["reason"] = "width_gate_rejected_by_explicit_option" + if record["status"] == "candidate": + q_peak, smooth_peak = _quadratic_localisation(q_centers, smooth, global_pos) + record["q_star"] = float(q_peak) + record["smoothed_intensity"] = float(smooth_peak) + record["height"] = float(smooth_peak - baseline) + record["height_snr"] = float((smooth_peak - baseline) / max(noise, _EPS)) + if not record.get("source_pixel_count"): + record["status"] = "rejected" + record["reason"] = "no_source_pixels_in_peak_bin" + elif float(record["height"]) < height_threshold: + record["status"] = "rejected" + record["reason"] = "height_below_baseline_noise" + elif ( + float(record.get("n_eff", 0.0)) < min_effective_pixels + or float(record.get("max_contribution_fraction", np.inf)) > max_contribution_fraction + ): + record["status"] = "rejected" + record["reason"] = ( + "single_pixel_dominated_support" + if float(record.get("max_contribution_fraction", np.inf)) > max_contribution_fraction + else "insufficient_effective_pixel_support" + ) + candidates.append(record) + + usable = [candidate for candidate in candidates if candidate["status"] == "candidate"] + if not usable: + rejected_reasons = [str(candidate.get("reason")) for candidate in candidates] + if rejected_reasons and all(reason == "flat_top_unresolved_peak" for reason in rejected_reasons): + return candidates, None, "flat_top_unresolved_peak", noise + if rejected_reasons and all(reason == "width_gate_rejected_by_explicit_option" for reason in rejected_reasons): + return candidates, None, "width_gate_rejected_by_explicit_option", noise + if rejected_reasons and all(reason == "single_pixel_dominated_support" for reason in rejected_reasons): + return candidates, None, "single_pixel_dominated_support", noise + if rejected_reasons and all(reason == "insufficient_effective_pixel_support" for reason in rejected_reasons): + return candidates, None, "insufficient_effective_pixel_support", noise + if rejected_reasons and all(reason == "height_below_baseline_noise" for reason in rejected_reasons): + return candidates, None, "height_below_baseline_noise", noise + if rejected_reasons and all(reason == "insufficient_two_sided_background_return" for reason in rejected_reasons): + return candidates, None, "insufficient_two_sided_background_return", noise + if finite_values.size <= 4 * min_side_bins: + reason = "insufficient_two_sided_support" + else: + first = int(np.flatnonzero(supported)[0]) + last = int(np.flatnonzero(supported)[-1]) + max_index = int(np.nanargmax(np.where(supported, smooth, -np.inf))) + if max_index == first: + reason = "low_q_boundary_without_two_sided_support" + elif max_index == last: + reason = "q_window_boundary_without_two_sided_support" + elif span <= float(options["min_prominence_sigma"]) * noise: + reason = "background_or_noise_below_prominence" + else: + differences: list[np.ndarray] = [] + for left, right in _contiguous_runs(supported): + if right - left >= 2: + differences.append(np.diff(smooth[left:right])) + monotonic = False + if differences: + delta = np.concatenate(differences) + tolerance = max(_EPS, 0.01 * span) + monotonic = bool(np.all(delta >= -tolerance) or np.all(delta <= tolerance)) + reason = "monotonic_profile_no_interior_peak" if monotonic else "no_supported_local_peak" + return candidates, None, reason, noise + + usable.sort(key=lambda candidate: float(candidate["prominence"]), reverse=True) + selected: dict[str, Any] | None = None + reason = "selected" + if len(usable) > 1: + strongest, second = usable[0], usable[1] + ratio = float(strongest["prominence"]) / max(float(second["prominence"]), _EPS) + strongest["competitor_prominence_ratio"] = ratio + if ratio < float(options["selection_prominence_ratio"]): + reason = "ambiguous_multiple_peaks" + for candidate in usable: + candidate["status"] = "ambiguous" + candidate["reason"] = "no_clear_prominence_winner" + return candidates, None, reason, noise + selected = strongest + for candidate in usable[1:]: + candidate["status"] = "candidate_not_selected" + candidate["reason"] = "weaker_competitor" + else: + selected = usable[0] + if selected is not None: + selected["selected"] = True + selected["status"] = "selected" + selected["reason"] = "selected_dominant_local_peak" + return candidates, selected, reason, noise + + +def _resolve_settings(options: Mapping[str, Any], q_range: float, q_step: float | None) -> dict[str, Any]: + width = _finite_float(_option(options, "sector_width_deg", 10.0, "width_deg", "sector_width"), "sector_width_deg", minimum=_EPS) + step = _finite_float(_option(options, "sector_step_deg", 5.0, "step_deg", "sector_step"), "sector_step_deg", minimum=_EPS) + if width > 360.0 or step > 360.0: + raise ValueError("sector_width_deg and sector_step_deg must not exceed 360") + smoothing = _finite_float(_option(options, "smoothing_sigma_bins", 1.0, "smoothing_sigma"), "smoothing_sigma_bins", minimum=0.0) + min_prominence_sigma = _finite_float(_option(options, "min_prominence_sigma", 4.0, "prominence_sigma"), "min_prominence_sigma", minimum=0.0) + min_prominence_fraction = _finite_float(_option(options, "min_prominence_fraction", 0.02, "prominence_fraction"), "min_prominence_fraction", minimum=0.0) + min_side_bins = _strict_int(_option(options, "min_side_bins", 2), "min_side_bins", minimum=1) + min_bin_count = _strict_int(_option(options, "min_bin_count", 2), "min_bin_count", minimum=1) + min_coverage = _finite_float(_option(options, "min_coverage", 0.5), "min_coverage", minimum=0.0) + if min_coverage > 1.0: + raise ValueError("min_coverage must be <= 1") + min_width_bins = _finite_float(_option(options, "min_width_bins", 0.8), "min_width_bins", minimum=0.0) + max_width_raw = _option(options, "max_width_fraction", None) + max_width_fraction = None if max_width_raw is None else _finite_float(max_width_raw, "max_width_fraction", minimum=0.0) + if max_width_fraction is not None and max_width_fraction > 1.0: + raise ValueError("max_width_fraction must be <= 1") + flat_top_min_bins = _strict_int(_option(options, "flat_top_min_bins", 3), "flat_top_min_bins", minimum=1) + flat_top_ratio_threshold = _finite_float( + _option(options, "flat_top_ratio_threshold", 0.45), + "flat_top_ratio_threshold", + minimum=0.0, + ) + flat_top_fraction = _finite_float( + _option(options, "flat_top_fraction", 0.98), + "flat_top_fraction", + minimum=0.0, + ) + if flat_top_fraction > 1.0: + raise ValueError("flat_top_fraction must be <= 1") + side_return_fraction = _finite_float(_option(options, "side_return_fraction", 0.20), "side_return_fraction", minimum=0.0) + if side_return_fraction >= 1.0: + raise ValueError("side_return_fraction must be < 1") + min_peak_separation_bins = _finite_float(_option(options, "min_peak_separation_bins", 2.0), "min_peak_separation_bins", minimum=1.0) + selection_ratio = _finite_float(_option(options, "selection_prominence_ratio", 1.35), "selection_prominence_ratio", minimum=1.0) + min_effective_pixels = _strict_int( + _option(options, "min_effective_pixels", 2), + "min_effective_pixels", + minimum=2, + ) + max_contribution_fraction = _finite_float( + _option(options, "max_contribution_fraction", 0.75), + "max_contribution_fraction", + minimum=0.0, + ) + if max_contribution_fraction > 1.0: + raise ValueError("max_contribution_fraction must be <= 1") + effective_support_neighbourhood = _strict_int( + _option(options, "effective_support_neighbourhood_bins", 1), + "effective_support_neighbourhood_bins", + minimum=0, + ) + requested_bins_raw = _option(options, "radial_bins", None, "n_radial_bins") + requested_bins = None if requested_bins_raw is None else _strict_int(requested_bins_raw, "radial_bins", minimum=1) + requested_max = _strict_int(_option(options, "max_radial_bins", 192), "max_radial_bins", minimum=1) + max_radial_bins = min(requested_max, 192) + if q_step is not None and np.isfinite(q_step) and q_step > 0.0: + resolution_limited = max(1, int(np.floor(q_range / q_step + 1e-12))) + q_step_source = "ridge_inputs.representative_q_step" + else: + resolution_limited = None + q_step_source = "unavailable; explicit/default radial-bin cap only" + radial_limit = max_radial_bins if resolution_limited is None else min(max_radial_bins, resolution_limited) + effective_bins = radial_limit if requested_bins is None else min(requested_bins, radial_limit) + effective_bins = max(1, int(effective_bins)) + angle_offset = _finite_float(_option(options, "angle_offset_deg", 0.0), "angle_offset_deg") + return { + "sector_width_deg": width, + "sector_step_deg": step, + "angle_offset_deg": angle_offset, + "smoothing_sigma_bins": smoothing, + "min_prominence_sigma": min_prominence_sigma, + "min_prominence_fraction": min_prominence_fraction, + "min_side_bins": min_side_bins, + "min_bin_count": min_bin_count, + "min_coverage": min_coverage, + "min_width_bins": min_width_bins, + "max_width_fraction": max_width_fraction, + "flat_top_min_bins": flat_top_min_bins, + "flat_top_fraction": flat_top_fraction, + "flat_top_ratio_threshold": flat_top_ratio_threshold, + "side_return_fraction": side_return_fraction, + "min_peak_separation_bins": min_peak_separation_bins, + "selection_prominence_ratio": selection_ratio, + "min_effective_pixels": min_effective_pixels, + "max_contribution_fraction": max_contribution_fraction, + "effective_support_neighbourhood_bins": effective_support_neighbourhood, + "requested_radial_bins": requested_bins, + "requested_max_radial_bins": requested_max, + "max_radial_bins": max_radial_bins, + "effective_radial_bins": effective_bins, + "q_resolution_limited_bins": resolution_limited, + "representative_q_step": None if q_step is None else float(q_step), + "q_step_source": q_step_source, + } + + +def _local_representative_q_step( + qx: np.ndarray, + qy: np.ndarray, + q: np.ndarray, + finite_geometry: np.ndarray, + q_min: float, + q_max: float, + *, + q_is_supplied: bool, +) -> tuple[float | None, str, dict[str, Any]]: + """Estimate a conservative local q step from finite neighbor pairs. + + The old bbox-median estimate could hide a coarse detector region behind a + much larger fine region. Here the q-window is used only to select a local + bbox; within that bbox, each horizontal/vertical adjacent pair is kept + when both endpoints have finite geometry and at least one endpoint lies in + the requested q window. The maximum finite qx/qy vector step and, when a + q array was explicitly supplied, the maximum radial-q difference are + combined as a conservative upper bound. The maximum is intentionally + conservative: a bin is never presented as finer than the coarsest local + detector neighbor represented in the q-window domain. + """ + + window = finite_geometry & (q >= q_min) & (q <= q_max) + locations = np.argwhere(window) + if locations.size == 0: + return None, "no finite q-map pixels in q window", { + "neighbor_pairs": 0, + "vector_pair_count": 0, + "radial_pair_count": 0, + "vector_step_max": None, + "radial_step_max": None, + "statistic": "maximum finite local adjacent step", + } + row_min, col_min = np.min(locations, axis=0).astype(int) + row_max, col_max = np.max(locations, axis=0).astype(int) + row_min = max(0, row_min - 1) + col_min = max(0, col_min - 1) + row_max = min(qx.shape[0] - 1, row_max + 1) + col_max = min(qx.shape[1] - 1, col_max + 1) + qx_local = qx[row_min : row_max + 1, col_min : col_max + 1] + qy_local = qy[row_min : row_max + 1, col_min : col_max + 1] + q_local = q[row_min : row_max + 1, col_min : col_max + 1] + finite_local = finite_geometry[row_min : row_max + 1, col_min : col_max + 1] + window_local = window[row_min : row_max + 1, col_min : col_max + 1] + vector_steps: list[np.ndarray] = [] + radial_steps: list[np.ndarray] = [] + pair_count = 0 + radial_pair_count = 0 + for axis in (0, 1): + slicer_a: list[slice] = [slice(None), slice(None)] + slicer_b: list[slice] = [slice(None), slice(None)] + slicer_a[axis] = slice(0, -1) + slicer_b[axis] = slice(1, None) + finite_pair = finite_local[tuple(slicer_a)] & finite_local[tuple(slicer_b)] + in_domain_pair = window_local[tuple(slicer_a)] | window_local[tuple(slicer_b)] + pair = finite_pair & in_domain_pair + if not np.any(pair): + continue + qx_a, qx_b = qx_local[tuple(slicer_a)], qx_local[tuple(slicer_b)] + qy_a, qy_b = qy_local[tuple(slicer_a)], qy_local[tuple(slicer_b)] + vector = np.hypot(qx_b - qx_a, qy_b - qy_a)[pair] + vector = vector[np.isfinite(vector) & (vector > _EPS)] + if vector.size: + vector_steps.append(np.asarray(vector, dtype=float)) + pair_count += int(vector.size) + # q is always available after canonicalisation. A radial difference + # is explicitly reported as a second evidence family when qmap + # supplied a q/radius field; for derived q, the vector step remains + # the calibration-resolution family. + if q_is_supplied: + q_a, q_b = q_local[tuple(slicer_a)], q_local[tuple(slicer_b)] + radial = np.abs(q_b - q_a)[pair] + radial = radial[np.isfinite(radial) & (radial > _EPS)] + if radial.size: + radial_steps.append(np.asarray(radial, dtype=float)) + radial_pair_count += int(radial.size) + details = { + "neighbor_pairs": pair_count, + "vector_pair_count": pair_count, + "radial_pair_count": radial_pair_count, + "vector_step_max": None, + "radial_step_max": None, + "statistic": "maximum finite local adjacent step", + } + if vector_steps: + details["vector_step_max"] = float(np.max(np.concatenate(vector_steps))) + if radial_steps: + details["radial_step_max"] = float(np.max(np.concatenate(radial_steps))) + finite_maxima = [ + value + for value in (details["vector_step_max"], details["radial_step_max"]) + if value is not None and np.isfinite(value) and value > _EPS + ] + local_q_step = max(finite_maxima) if finite_maxima else None + source = "finite q-window adjacent geometry pairs; conservative maximum qx/qy vector step" + if q_is_supplied: + source += " and supplied radial-q difference" + if local_q_step is None: + source = "unavailable: no finite q-window adjacent geometry step" + return ( + local_q_step, + source, + details, + ) + + +def measure_sector_peaks( + image: Any, + qmap: Any, + q_window: Any, + *, + mask: Any = None, + options: Any = None, + cancel_event: Any = None, +) -> dict[str, Any]: + """Measure radial intensity profiles and dominant local peaks by angle. + + Parameters + ---------- + image, qmap: + The same image/q-map seam used by the main analysis. ``qmap`` must + expose qx and qy; q is taken from the map when supplied, otherwise it + is derived by the shared canonical adapter. + q_window: + Inclusive physical or declared q-unit interval. The unit is copied + from qmap metadata and never inferred here. + mask: + Detector-style invalid mask: ``True`` excludes a pixel from measured + sums and means. Geometry coverage still counts finite q-map pixels. + options: + Optional mapping. Defaults are 10 degree sector width, 5 degree + sector step, mask-aware smoothing sigma 1 bin, and a maximum of 192 + radial bins subject to the measured pixel q step. + + Returns + ------- + dict + A mapping containing all sector profiles, candidate diagnostics, + selected measured peaks (or ``None``), and explicit sampling/overlap + metadata. ``sampling_sigma_q`` is a bin-resolution indicator rather + than a statistical confidence interval. + """ + + raise_if_cancelled(cancel_event, "sector-peaks:inputs") + q_min, q_max = _q_window(q_window) + data, qx, qy, q, invalid = canonical_inputs(image, qmap, mask=mask) + q_unit = _q_unit(qmap) + shape = tuple(int(value) for value in data.shape) + + angle = np.mod(np.degrees(np.arctan2(qy, qx)), 360.0) + finite_geometry = np.isfinite(qx) & np.isfinite(qy) & np.isfinite(q) & np.isfinite(angle) + q_is_supplied = array_field(qmap, Q_ALIASES) is not None + q_step, q_step_source, q_step_details = _local_representative_q_step( + qx, + qy, + q, + finite_geometry, + q_min, + q_max, + q_is_supplied=q_is_supplied, + ) + settings = _resolve_settings(_options_mapping(options), q_max - q_min, q_step) + settings["q_step_source"] = q_step_source + settings["q_step_details"] = q_step_details + in_window = finite_geometry & (q >= q_min) & (q <= q_max) + window_indices = np.flatnonzero(in_window.ravel()) + q_values = np.asarray(q.ravel()[window_indices], dtype=float) + qx_values = np.asarray(qx.ravel()[window_indices], dtype=float) + qy_values = np.asarray(qy.ravel()[window_indices], dtype=float) + angle_values = np.asarray(angle.ravel()[window_indices], dtype=float) + image_values = np.asarray(data.ravel()[window_indices], dtype=float) + invalid_values = np.asarray(invalid.ravel()[window_indices], dtype=bool) + finite_intensity = np.isfinite(image_values) & ~invalid_values + + n_bins = int(settings["effective_radial_bins"]) + q_edges = np.linspace(q_min, q_max, n_bins + 1, dtype=float) + q_centers = 0.5 * (q_edges[:-1] + q_edges[1:]) + q_bin_width = float(q_edges[1] - q_edges[0]) if n_bins else float("nan") + bin_values = np.searchsorted(q_edges, q_values, side="right") - 1 + bin_values = np.clip(bin_values, 0, n_bins - 1).astype(np.int64, copy=False) + + width = float(settings["sector_width_deg"]) + step = float(settings["sector_step_deg"]) + offset = float(settings["angle_offset_deg"]) + centres = np.arange(0.0, 360.0, step, dtype=float) + offset + centres = np.mod(centres, 360.0) + # Avoid accidental duplicate sectors when a floating step lands on 360. + unique_centres, unique_indices = np.unique(np.round(centres, decimals=12), return_index=True) + centres = centres[np.sort(unique_indices)] + + sectors: list[dict[str, Any]] = [] + for sector_index, centre in enumerate(centres): + raise_if_cancelled(cancel_event, f"sector-peaks:sector-{sector_index}") + geometric = _angle_delta_deg(angle_values, float(centre)) <= 0.5 * width + 1e-12 + geometry_q = bin_values[geometric] + geometry_count = np.bincount(geometry_q, minlength=n_bins).astype(np.int64) + measured = geometric & finite_intensity + measured_q = bin_values[measured] + measured_i = image_values[measured] + raw_sum = np.bincount(measured_q, weights=measured_i, minlength=n_bins).astype(float) + raw_count = np.bincount(measured_q, minlength=n_bins).astype(np.int64) + raw_mean = np.full(n_bins, np.nan, dtype=float) + np.divide(raw_sum, raw_count, out=raw_mean, where=raw_count > 0) + coverage = np.divide(raw_count, geometry_count, out=np.zeros(n_bins, dtype=float), where=geometry_count > 0) + supported_bins = ( + (raw_count >= int(settings["min_bin_count"])) + & (coverage >= float(settings["min_coverage"])) + & np.isfinite(raw_mean) + ) + smooth = _smooth_supported(raw_mean, supported_bins, float(settings["smoothing_sigma_bins"])) + + candidates, selected, reason, noise = _detect_candidates( + q_edges=q_edges, + q_centers=q_centers, + raw_mean=raw_mean, + raw_count=raw_count, + coverage=coverage, + smooth=smooth, + options=settings, + q_values=q_values[measured], + intensity_values=image_values[measured], + qx_values=qx_values[measured], + qy_values=qy_values[measured], + original_indices=window_indices[measured], + image_shape=shape, + ) + if selected is not None: + selected = dict(selected) + selected["selected"] = True + geometry_total = int(np.sum(geometry_count)) + measured_total = int(np.sum(raw_count)) + sector_coverage = float(measured_total / geometry_total) if geometry_total else 0.0 + status = "selected" if selected is not None else ("ambiguous" if reason == "ambiguous_multiple_peaks" else "no_peak") + sector = { + "sector_index": int(sector_index), + "angle_deg": float(centre), + "center_angle_deg": float(centre), + "start_angle_deg": float((centre - 0.5 * width) % 360.0), + "end_angle_deg": float((centre + 0.5 * width) % 360.0), + "width_deg": width, + "q": q_centers.copy(), + "q_edges": q_edges.copy(), + "q_centers": q_centers.copy(), + "raw": {"mean": raw_mean, "sum": raw_sum, "count": raw_count}, + "raw_mean": raw_mean, + "raw_sum": raw_sum, + "raw_count": raw_count, + "counts": raw_count, + "intensity": raw_mean, + "smoothed": smooth, + "smoothed_intensity": smooth, + "geometry_count": geometry_count, + "geometric_counts": geometry_count, + "coverage": coverage, + "geometry_coverage": coverage, + "supported_bin_mask": supported_bins, + "geometry_total_count": geometry_total, + "measured_total_count": measured_total, + "sector_coverage": sector_coverage, + "profile_noise_sigma": float(noise), + "noise_provenance": { + "method": "robust MAD of raw-minus-within-run-smooth and adjacent raw-bin differences", + "window": "each contiguous supported radial run in this sector; masked gaps excluded", + "experimental_sigma": False, + "interpretation": "profile-local noise scale for detection gates, not a measurement uncertainty", + }, + "candidates": candidates, + "selected_peak": selected, + "status": status, + "reason": reason, + } + sectors.append(sector) + + overlap_fraction = max(0.0, min(1.0, 1.0 - step / width)) + result = { + "schema_version": SECTOR_PEAKS_SCHEMA_VERSION, + "method_version": SECTOR_PEAKS_METHOD_VERSION, + "interpretation": "unindexed dominant radial peak measured from observed sector-integrated intensity; no reflection assignment or four-lobe completion", + "q_unit": str(q_unit or "unknown"), + "angle_unit": "degree", + "intensity_unit": "input intensity units", + "q_window": [float(q_min), float(q_max)], + "q_edges": q_edges, + "q_centers": q_centers, + "sector_centers_deg": np.asarray(centres, dtype=float), + "settings": settings, + "effective_settings": settings, + "units": {"q": str(q_unit or "unknown"), "angle": "degree", "intensity": "input intensity units"}, + "sampling": { + "representative_q_step": None if q_step is None else float(q_step), + "radial_q_supplied": bool(q_is_supplied), + "q_step_details": q_step_details, + "effective_radial_bin_width": q_bin_width, + "sampling_sigma_q": q_bin_width / np.sqrt(12.0), + "half_bin_resolution_q": 0.5 * q_bin_width, + "sigma_basis": "uniform-within-bin sampling resolution; sampling_resolution_not_CI", + "precision_statement": "radial bins are not finer than the local representative detector q step when that step is available", + }, + "noise_provenance": { + "method": "robust MAD of raw-minus-within-run-smooth and adjacent raw-bin differences", + "window": "each contiguous supported radial run per sector", + "experimental_sigma": False, + "height_gate": "smoothed local peak minus robust raw-profile baseline must exceed min_prominence_sigma times this scale", + "baseline": "10th percentile of a robust-MAD-trimmed raw profile; smoothing valleys are not used as the baseline", + }, + "sector_overlap": { + "width_deg": width, + "step_deg": step, + "overlap_fraction": overlap_fraction, + "overlapping": bool(width > step), + "correlation_statement": "overlapping sectors share detector pixels and are correlated; sector-to-sector peaks are not independent samples", + }, + "coverage_definition": "coverage is measured finite intensity count divided by finite geometry count per q bin; masked or non-finite intensity is excluded only from the numerator", + "input": { + "shape": list(shape), + "geometry_pixel_count_in_q_window": int(window_indices.size), + "measured_pixel_count_in_q_window": int(np.count_nonzero(finite_intensity)), + "mask_true_is_excluded": True, + "input_arrays_mutated": False, + }, + "sectors": sectors, + } + raise_if_cancelled(cancel_event, "sector-peaks:complete") + return result + + +__all__ = [ + "SECTOR_PEAKS_METHOD_VERSION", + "SECTOR_PEAKS_SCHEMA_VERSION", + "measure_sector_peaks", +] diff --git a/src/butterfly_saxs/sector_trace.py b/src/butterfly_saxs/sector_trace.py new file mode 100644 index 0000000..70001b1 --- /dev/null +++ b/src/butterfly_saxs/sector_trace.py @@ -0,0 +1,242 @@ +"""Adapt measured sector-profile peaks to the shared observed-arc workflow. + +One angular sector contributes at most one measured peak. Missing, ambiguous +or excluded sectors are never joined across, and no ellipse is used to choose +or relocate the measured peaks. The finite sector footprint and bin scale are +recorded; neither is a calibrated confidence interval. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import math +import time +from typing import Any + +import numpy as np + +from .arc_support import freeze_observed_support +from .butterfly_ridge import ( + _apply_edits, + _apply_seeds, + _arc_topology, + _assign_reference_branches, + _normalise_options, + _parse_q_window, + _point_signature, + _public_point, + _refresh_arc_identity, +) +from .cancellation import raise_if_cancelled +from .ridge_inputs import canonical_inputs +from .sector_peaks import measure_sector_peaks +from .settings import strict_int + +METHOD_VERSION = "butterfly-radial-sector-v1.0" + + +def _sector_components(points, sector_count, *, maximum_jump): + """Return observed angular chains; a missing sector is an explicit gap.""" + + lookup = {p["sector_index"]: i for i, p in enumerate(points) if p["accepted"]} + neighbours = {i: [] for i in lookup.values()} + edges = [] + for sector, index in lookup.items(): + other = lookup.get((sector + 1) % sector_count) + if other is None or other == index: + continue + a, b = points[index], points[other] + if a["branch_id"] != b["branch_id"] or a["branch_id"] not in (0, 1): + continue + gap = (b["sector_center_deg"] - a["sector_center_deg"]) % 360.0 + if gap > 0.5 * (a["sector_width_deg"] + b["sector_width_deg"]) + 1e-10: + for point in (a, b): + point["topology_flags"].append("unmeasured_angular_gap") + continue + if abs(a["q_star"] - b["q_star"]) > maximum_jump: + for point in (a, b): + point["topology_flags"].append("adjacent_sector_peak_jump") + continue + edges.append((index, other)) + neighbours[index].append(other) + neighbours[other].append(index) + groups, visited = [], set() + for start in neighbours: + if start in visited: + continue + pending, component = [start], [] + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + component.append(current) + pending.extend(neighbours[current]) + groups.append(component) + # Estimate geometric tangents solely from adjacent measured peaks. + for current in component: + linked = neighbours[current] + if not linked: + continue + first = points[linked[0]] + last = points[linked[-1]] if len(linked) > 1 else points[current] + dx, dy = last["qx"] - first["qx"], last["qy"] - first["qy"] + norm = math.hypot(dx, dy) + if norm > np.finfo(float).eps: + points[current]["tangent_qx"] = dx / norm + points[current]["tangent_qy"] = dy / norm + return groups, edges + + +def trace_butterfly_sector_peaks( + image: Any, + qmap: Any, + q_window: Any, + *, + mask: Any = None, + reference_axis_deg: float = 0.0, + options: Mapping[str, Any] | None = None, + edits: Any = None, + cancel_event: Any = None, +) -> dict[str, Any]: + """Measure profiles first, then freeze supported peak-trajectory fragments.""" + + started = time.perf_counter() + raise_if_cancelled(cancel_event, "sector-trace:inputs") + data, qx, qy, q, invalid = canonical_inputs(image, qmap, mask=mask) + window = _parse_q_window(q_window, q) + options = dict(options or {}) + edits = list(options.get("edits", []) if edits is None else edits) + geometry = np.isfinite(qx) & np.isfinite(qy) & np.isfinite(q) + geometry &= (q >= window[0]) & (q <= window[1]) + valid = geometry & np.isfinite(data) & ~invalid + valid, applied_edits, seeds = _apply_edits(valid, qx, qy, edits) + measured = measure_sector_peaks( + data, qmap, window, mask=~valid, options=options, cancel_event=cancel_event + ) + bin_width = float(measured["sampling"]["effective_radial_bin_width"]) + sigma = float(measured["sampling"]["sampling_sigma_q"]) + topology_options = _normalise_options(options) + topology_options["reference_axis_deg"] = float(reference_axis_deg) + topology_options["min_arc_points"] = strict_int( + options.get("sector_min_arc_points", 3), "sector_min_arc_points", minimum=2 + ) + jump_bins = float(options.get("sector_max_jump_bins", 3.0)) + if not math.isfinite(jump_bins) or jump_bins <= 0: + raise ValueError("sector_max_jump_bins must be positive and finite") + signature = _point_signature(data, {**options, "trace_method": "radial_sector"}, window) + excluded = { + str(edit.get("point_id")) for edit in edits + if isinstance(edit, Mapping) and edit.get("type") == "exclude_point" + } + points, profiles = [], {} + for sector in measured["sectors"]: + raise_if_cancelled(cancel_event, "sector-trace:profiles") + index = int(sector["sector_index"]) + point_id = f"sector-{signature}-{index:03d}" + sector["point_id"] = point_id + peak = sector["selected_peak"] + sector.update( + sector_center_deg=sector["angle_deg"], sector_width_deg=sector["width_deg"], + selected_peak_q=peak["q_star"] if peak else None, + q_star=peak["q_star"] if peak else None, + profile_only=peak is None, accepted=bool(peak), valid=bool(peak), + source_method="radial_sector", + ) + profiles[point_id] = { + "point_id": point_id, + "profile_axis": "radial", + "method": "observed_sector_pixel_mean", + "q_unit": measured["q_unit"], + "sector_center_deg": sector["angle_deg"], + "sector_width_deg": sector["width_deg"], + "q": sector["q_centers"], + "raw_intensity": sector["raw_mean"], + "smoothed_intensity": sector["smoothed_intensity"], + "counts": sector["counts"], + "coverage": sector["coverage"], + "fit_intensity": [], + "selected_peak_q": peak["q_star"] if peak else None, + "radial_fwhm_q": peak["radial_fwhm"] if peak else None, + "sampling_sigma_q": sigma, + "uncertainty_source": measured["sampling"]["sigma_basis"], + "valid": bool(peak), + "reason": sector["reason"], + "candidates": sector["candidates"], + } + if peak is None: + continue + angle = math.radians(float(sector["angle_deg"])) + radius = float(peak["q_star"]) + accepted = point_id not in excluded + point = { + "point_id": point_id, "sector_index": index, + "sector_center_deg": sector["angle_deg"], "sector_width_deg": sector["width_deg"], + "qx": radius * math.cos(angle), "qy": radius * math.sin(angle), "q_star": radius, + "pixel_x": peak["pixel_x"], "pixel_y": peak["pixel_y"], + "source_pixel_role": "representative supporting pixel; q_star is a sector-profile statistic", + "source_method": "radial_sector", "q_unit": measured["q_unit"], + "intensity": peak["raw_intensity"], "snr": peak["snr"], + "prominence": peak["prominence"], "radial_fwhm": peak["radial_fwhm"], + "coverage": float(sector["coverage"][int(peak["peak_bin_index"])]), + "n_pixels": peak["source_pixel_count"], + "normal_qx": math.cos(angle), "normal_qy": math.sin(angle), + "normal_basis": "radial measurement direction, not a fitted ellipse normal", + "tangent_qx": -math.sin(angle), "tangent_qy": math.cos(angle), + "q_normal_step": bin_width, "sampling_sigma_q": sigma, + "uncertainty_source": measured["sampling"]["sigma_basis"], + "normal_fwhm_q": float("nan"), "localization_sigma_q": float("nan"), + "scale": 1.0, # Temporary compatibility input for the topology helper. + "branch_id": -1, "side": "unknown", "arc_id": -1, + "accepted": accepted, "valid": accepted, + "reason": "accepted_sector_peak" if accepted else "excluded_point_edit", + "score": peak["snr"], + "topology_flags": ["sector_profile_peak", "peak_order_unassigned", "finite_sector_footprint"], + } + points.append(point) + _assign_reference_branches(points, topology_options) + groups, edges = _sector_components( + points, len(measured["sectors"]), maximum_jump=jump_bins * bin_width + ) + arcs = _arc_topology(groups, edges, points, topology_options, bin_width) + seed_records = _apply_seeds(points, arcs, seeds, topology_options, bin_width) + _refresh_arc_identity(arcs, points) + # The graph helper's scale consistency is not multiscale evidence for a + # single-resolution sector measurement. Keep it explicitly unavailable. + for item in [*points, *arcs]: + item.pop("scale", None) + item["scale_stability"] = float("nan") + item["scale_stable"] = False + item["source_method"] = "radial_sector" + by_id = {point["point_id"]: point for point in points} + for sector in measured["sectors"]: + point = by_id.get(sector["point_id"]) + if point is not None: + for key in ("qx", "qy", "pixel_x", "pixel_y", "branch_id", "side", "arc_id", "accepted", "valid"): + sector[key] = point[key] + sector["geometry_reason"] = point["reason"] + support = freeze_observed_support(points, arcs, summary_only=True, cancel_event=cancel_event) + measured["interpretation"] += "; finite sector footprint can shift or broaden a peak" + return { + "points": [_public_point(point) for point in points], + "arcs": arcs, "profiles": profiles, "sector_peaks": measured, + "method_version": METHOD_VERSION, + "diagnostics": { + "method": "fixed_azimuth_sector_radial_peak", + "topology_before_ellipse_fit": True, "ellipse_fit": None, + "q_window": list(window), "reference_axis_deg": float(reference_axis_deg), + "center_q": [float(topology_options["center_qx"]), float(topology_options["center_qy"])], + "sector_origin_q": [0.0, 0.0], + "n_sectors": len(measured["sectors"]), "n_points": len(points), + "n_accepted_points": sum(bool(p["accepted"]) for p in points), + "n_arcs": len(arcs), "n_raw_candidates": sum(len(s["candidates"]) for s in measured["sectors"]), + "mask_fraction_in_q_window": 1.0 - float(valid.sum()) / max(1, int(geometry.sum())), + "observed_support": support, "applied_edits": applied_edits, + "seed_actions": seed_records, "maximum_adjacent_sector_jump_q": jump_bins * bin_width, + "first_order_q_hint": {"selection_status": "not_used", "q_star": None, + "reason": "radial_peak_order_is_not_assigned"}, + "sampling_sigma_basis": measured["sampling"]["sigma_basis"], + "sector_overlap": measured["sector_overlap"], + "elapsed_s": time.perf_counter() - started, + }, + } diff --git a/src/butterfly_saxs/service.py b/src/butterfly_saxs/service.py index 2124cb4..25af447 100644 --- a/src/butterfly_saxs/service.py +++ b/src/butterfly_saxs/service.py @@ -2579,6 +2579,11 @@ def analyze_with_state(frame: Any, initial: Any = None, *, warm_start: bool = Fa "dataset": dataset, "time": _read(item.frame, ("time",), None), "status": item.status, + "error": item.error, + "traceback": item.traceback, + "warm_start_from": item.warm_start_from, + "elapsed_s": item.elapsed_s, + "resumed": bool(item.resumed), "stage": str(_read(result, ("stage",), stage)), "parameter_stage": str( _read(result, ("parameter_stage",), stage) diff --git a/src/butterfly_saxs/ui/butterfly_summary.py b/src/butterfly_saxs/ui/butterfly_summary.py new file mode 100644 index 0000000..a933dfd --- /dev/null +++ b/src/butterfly_saxs/ui/butterfly_summary.py @@ -0,0 +1,900 @@ +"""Compact, evidence-bounded quality summary for the butterfly workbench. + +The summary is deliberately a view adapter. It reads the result fields that +the analysis service already produced and does not infer physical quantities or +scientific acceptance from a candidate fit. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +import math +from typing import Any + +from ..butterfly_quality import classify_ellipse_publication +from ..settings import canonical_q_unit +from .qt_compat import QT_AVAILABLE, QtWidgets, require_qt + + +def _read(source: Any, names: tuple[str, ...], default: Any = None) -> Any: + if isinstance(source, Mapping): + for name in names: + if name in source: + return source[name] + else: + for name in names: + if hasattr(source, name): + return getattr(source, name) + return default + + +def _finite(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _text_values(value: Any) -> list[str]: + if value in (None, ""): + return [] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + if isinstance(value, Mapping): + return [str(key) for key, active in value.items() if active] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [str(item) for item in value if item not in (None, "")] + return [str(value)] + + +def _dedupe(values: Sequence[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(str(value) for value in values if str(value).strip())) + + +def _normalise_q_unit(value: Any) -> str: + canonical = canonical_q_unit(value) + if canonical == "nm⁻¹": + return "nm^-1" + if canonical == "Å⁻¹": + return "Å^-1" + return canonical or "unknown" + + +def _is_physical_q_unit(unit: str) -> bool: + return unit in {"nm^-1", "Å^-1"} + + +def _published_scalar( + result: Mapping[str, Any], + names: tuple[str, ...], +) -> float | None: + """Read an existing observable without deriving it from another field. + + The service historically stores the observed first-order values directly + on ``candidate_fit``. They are accepted here as direct values, while + ``candidate_value`` rows and axis-derived expressions are intentionally + ignored. In particular, this function never computes ``L = 2*pi/q``. + """ + + blocks: list[Mapping[str, Any]] = [result] + for key in ("published", "public", "observables", "geometry_parameters", "candidate_fit"): + block = result.get(key) + if isinstance(block, Mapping): + blocks.append(block) + quantitative = result.get("quantitative_parameters") + if isinstance(quantitative, Mapping): + for name in names: + row = quantitative.get(name) + if isinstance(row, Mapping): + value = _finite(row.get("value")) + if value is not None: + return value + for block in blocks: + for name in names: + value = _finite(block.get(name)) + if value is not None: + return value + return None + + +def _published_scalar_with_unit( + result: Mapping[str, Any], + names: tuple[str, ...], + *, + fallback_unit: str, +) -> tuple[float | None, str]: + """Return a direct observable and the unit declared by its field.""" + + blocks: list[Mapping[str, Any]] = [result] + for key in ("published", "public", "observables", "geometry_parameters", "candidate_fit"): + block = result.get(key) + if isinstance(block, Mapping): + blocks.append(block) + quantitative = result.get("quantitative_parameters") + if isinstance(quantitative, Mapping): + for name in names: + row = quantitative.get(name) + if isinstance(row, Mapping): + value = _finite(row.get("value")) + if value is not None: + return value, _normalise_q_unit(row.get("unit", fallback_unit)) + for block in blocks: + for name in names: + value = _finite(block.get(name)) + if value is not None: + declared = "nm^-1" if name == "q_star_nm_inv" else block.get("q_unit", fallback_unit) + return value, _normalise_q_unit(declared) + return None, fallback_unit + + +def _sector_measurement_field(result: Mapping[str, Any], name: str) -> Any: + """Read the sector-profile statistic before any legacy fit aliases.""" + + summary = result.get("measurement_summary") + if isinstance(summary, Mapping) and name in summary: + return summary.get(name) + return result.get(name) + + +def _sector_measurement_scalar( + result: Mapping[str, Any], + *, + fallback_unit: str, +) -> tuple[float | None, str]: + """Return the selected-sector q* median and its declared q unit.""" + + value = _finite(_sector_measurement_field(result, "q_star_sector_median")) + declared = _sector_measurement_field(result, "q_star_sector_median_unit") + if declared in (None, ""): + summary = result.get("measurement_summary") + declared = summary.get("q_unit") if isinstance(summary, Mapping) else None + return value, _normalise_q_unit(declared or fallback_unit) + + +def _sector_measurement_period(result: Mapping[str, Any], *, unit: str) -> float | None: + """Return the explicitly reported apparent period for a physical q unit. + + A pixel-q or otherwise unknown unit must never acquire an ``nm`` label in + the view layer, even if an old or hand-built payload contains a stale + period value. + """ + + if not _is_physical_q_unit(unit): + return None + return _finite(_sector_measurement_field(result, "apparent_period_from_sector_median_nm")) + + +def _side_support(result: Mapping[str, Any]) -> str: + quality = result.get("quality") + quality = quality if isinstance(quality, Mapping) else {} + metrics = quality.get("metrics") + metrics = metrics if isinstance(metrics, Mapping) else {} + counts = metrics.get("side_counts") + if isinstance(counts, Mapping) and counts: + occupied = sum(1 for value in counts.values() if _finite(value) not in (None, 0.0)) + return f"{occupied}/4" + occupied = _finite(metrics.get("occupied_sides")) + if occupied is not None: + return f"{max(0, min(4, int(occupied)))}/4" + + points = result.get("points") + groups: set[tuple[int, str]] = set() + if isinstance(points, Sequence) and not isinstance(points, (str, bytes)): + for point in points: + if not isinstance(point, Mapping): + continue + if not bool(point.get("accepted", point.get("valid", False))) or not bool( + point.get("valid", True) + ): + continue + branch = point.get("branch_id") + side = str(point.get("side", "")).lower() + if branch in (0, 1) and side in {"upper", "lower"}: + groups.add((int(branch), side)) + return f"{len(groups)}/4" if groups else "—" + + +def _quality_flags(result: Mapping[str, Any]) -> list[str]: + quality = result.get("quality") + quality = quality if isinstance(quality, Mapping) else {} + candidate = result.get("candidate_fit") + candidate = candidate if isinstance(candidate, Mapping) else {} + flags: list[str] = [] + for source in (result, quality, candidate): + flags.extend(_text_values(source.get("flags"))) + bound_flags = candidate.get("bound_flags") + if isinstance(bound_flags, Mapping) and bound_flags.get("axis_ratio"): + flags.append("axis_ratio_at_bound") + metrics = quality.get("metrics") + if isinstance(metrics, Mapping): + flags.extend(_text_values(metrics.get("flags"))) + return list(_dedupe(flags)) + + +def _result_failed(result: Mapping[str, Any]) -> bool: + for source in (result, result.get("quality"), result.get("metrics")): + if not isinstance(source, Mapping): + continue + for key in ("status", "measurement_status", "solver_status", "quality_status"): + if str(source.get(key, "") or "").strip().lower() in { + "fail", + "failed", + "error", + "invalid", + }: + return True + if source.get("success") is False: + return True + return False + + +_REASON_TEXT: dict[str, tuple[str, str]] = { + "axis_ratio_at_bound": ("轴比触及边界,按仅一阶环处理", "axis ratio at bound; ring only"), + "axis_ratio_collapsed_to_line": ("轴比塌缩,椭圆不可分辨", "axis ratio collapsed; ellipse unresolved"), + "major_axis_exceeds_observed_extent": ("长轴超出观测范围", "major axis exceeds observed extent"), + "insufficient_occupied_sides": ("有效侧边不足", "insufficient occupied sides"), + "insufficient_independent_side_support": ("独立侧边支持不足", "insufficient independent side support"), + "solver_or_arc_support_unavailable": ("求解器或弧线支持不可用", "solver or arc support unavailable"), + "ill_conditioned_geometry": ("几何拟合病态", "ill-conditioned geometry"), + "residual_exceeds_localization_scale": ("残差超过定位不确定度尺度", "residual exceeds localization scale"), + "per_arc_support_evidence_unavailable": ("逐弧支持证据不可用", "per-arc support evidence unavailable"), + "poor_match": ("观测与拟合几何失配", "poor match between observed and fitted geometry"), + "arc_endpoint_or_manual_bound_dependent": ( + "依赖弧端点或手动边界", + "depends on arc endpoints or manual bounds", + ), + "disconnected_observed_support": ( + "观测弧支持不连续", + "observed arc support is disconnected", + ), + "observed_support_infeasible": ( + "部分拟合投影超出观测支持", + "some fitted projections exceed observed support", + ), + "geometry_not_evaluated": ("椭圆参数尚未评估", "ellipse parameters not evaluated"), + "uncalibrated_pixel_q": ("pixel-q 未标定,不能解释物理周期", "pixel-q is uncalibrated; physical period unavailable"), + "spacing_unavailable_unknown_q_unit": ("q 单位未确认,L ring 不可用", "q unit is unknown; L ring unavailable"), + "cancelled": ("任务已取消,旧结果已失效", "job cancelled; previous result invalidated"), +} + + +def _reason_text(code: str, *, english: bool) -> str: + pair = _REASON_TEXT.get(str(code)) + if pair is not None: + return pair[1 if english else 0] + return str(code).replace("_", " ") + + +def _reason_priority(code: str) -> tuple[int, str]: + token = str(code).lower() + if token == "poor_match": + return (0, token) + if "bound" in token or "collapsed" in token: + return (1, token) + if "support" in token or "occupied_sides" in token: + return (2, token) + if "residual" in token or "condition" in token: + return (3, token) + if "sensitivity" in token or "interval" in token: + return (8, token) + if "uncalibrated" in token or "unknown_q_unit" in token: + return (9, token) + if "not_evaluated" in token: + return (10, token) + return (5, token) + + +def _format_value(value: float | None) -> str: + if value is None: + return "—" + return f"{value:.6g}" + + +@dataclass(frozen=True) +class ButterflyQualitySummaryState: + """Language-neutral state rendered by :class:`ButterflyQualitySummary`.""" + + status_key: str + engineering_status: str + side_support: str + q_star: float | None + q_star_unit: str + l_ring: float | None + q_unit: str + calibrated: bool + reasons: tuple[str, ...] + next_step_key: str + scientific_acceptance: bool = False + trace_method: str = "curvature" + + def as_dict(self) -> dict[str, Any]: + return { + "status_key": self.status_key, + "status": self.status_key, + "engineering_status": self.engineering_status, + "side_support": self.side_support, + "q_star": self.q_star, + "q_star_unit": self.q_star_unit, + "l_ring": self.l_ring, + "q_unit": self.q_unit, + "calibrated": self.calibrated, + "reasons": list(self.reasons), + "next_step_key": self.next_step_key, + "scientific_acceptance": self.scientific_acceptance, + "trace_method": self.trace_method, + } + + def reasons_text(self, *, english: bool = False) -> str: + return ";".join(_reason_text(code, english=english) for code in self.reasons) + + +def build_butterfly_quality_summary( + result: Mapping[str, Any] | None = None, + *, + stage: str = "trace", + page_state: str = "ready", + result_fresh: bool | None = None, + data_ready: bool | None = None, + busy: bool = False, + q_unit: Any = None, + poor_match: bool = False, + error: Any = None, + trace_method: str | None = None, +) -> ButterflyQualitySummaryState: + """Build a compact view state from existing result evidence. + + ``result_fresh`` is separate from ``result`` on purpose: cancellation and + input edits can leave an old payload in memory while making it invalid for + display/export. The summary must then clear all old observables. + """ + + payload = dict(result) if isinstance(result, Mapping) else {} + settings = payload.get("settings") + settings = settings if isinstance(settings, Mapping) else {} + resolved_trace_method = str( + trace_method + or settings.get("trace_method") + or ( + "annular_peak" + if isinstance(payload.get("annular_peaks"), Mapping) + or str(payload.get("method_version", "")).startswith("butterfly-annular-") + else + "radial_sector" + if isinstance(payload.get("sector_peaks"), Mapping) + or str(payload.get("method_version", "")).startswith("butterfly-radial-sector-") + else "curvature" + ) + ).strip().lower() + if resolved_trace_method in { + "azimuthal_peak", + "annular_peaks", + "annular_trajectory", + "q_ring_trajectory", + }: + resolved_trace_method = "annular_peak" + if str(payload.get("ridge_method", "") or "").strip().lower() in { + "azimuthal_peak", + "annular_peak", + "annular_trajectory", + }: + resolved_trace_method = "annular_peak" + if result_fresh is None: + result_fresh = bool(payload) + if data_ready is None: + data_ready = bool(result_fresh) + state = str(page_state or "ready").lower() + active_stage = str(stage or "trace").lower() + # A stale payload may remain in memory while the current input is being + # rerun. Never let its quality, flags, or observables leak into the card. + visible_result = bool( + result_fresh and not busy and state not in {"running", "cancelling"} + ) + effective_payload = payload if visible_result else {} + quality = effective_payload.get("quality") + quality = quality if isinstance(quality, Mapping) else {} + quality_status = str( + quality.get("status", effective_payload.get("quality_status", "")) or "" + ).strip().upper() + engineering = str( + quality.get( + "engineering_status", + effective_payload.get("engineering_status", quality_status), + ) + or "" + ).strip().upper() + if state in {"failed", "error"} or _result_failed(effective_payload) or quality_status in {"FAIL", "FAILED", "INVALID"}: + engineering = "FAIL" + elif engineering in {"FAILED", "INVALID", "ERROR"}: + engineering = "FAIL" + elif quality_status == "WARN" or engineering == "WARN": + engineering = "WARN" + elif engineering in {"OK", "AVAILABLE"} or quality_status in {"OK", "PASS", "AVAILABLE"}: + engineering = "PASS" + elif quality_status in {"NOT_EVALUATED", "PENDING", "PENDING_EVALUATION"}: + engineering = "NOT_EVALUATED" + elif not engineering: + engineering = "NOT_EVALUATED" + + unit_value = q_unit + if unit_value in (None, ""): + unit_value = effective_payload.get("q_unit") + if unit_value in (None, ""): + metrics = quality.get("metrics") + unit_value = metrics.get("q_unit") if isinstance(metrics, Mapping) else None + if unit_value in (None, ""): + geometry = effective_payload.get("geometry_parameters") + unit_value = geometry.get("q_unit") if isinstance(geometry, Mapping) else None + if unit_value in (None, ""): + candidate_unit = effective_payload.get("candidate_fit") + unit_value = candidate_unit.get("q_unit") if isinstance(candidate_unit, Mapping) else None + unit = _normalise_q_unit(unit_value) + calibrated = _is_physical_q_unit(unit) + + flags = _quality_flags(effective_payload) + if poor_match: + flags.append("poor_match") + flags = list(_dedupe(flags)) + candidate = effective_payload.get("candidate_fit") + candidate = candidate if isinstance(candidate, Mapping) else {} + quantitative = effective_payload.get("quantitative_parameters") + quantitative = quantitative if isinstance(quantitative, Mapping) else {} + ratio_row = quantitative.get("axis_ratio") + published_ratio = ( + _finite(ratio_row.get("value")) if isinstance(ratio_row, Mapping) else None + ) + ratio = published_ratio if published_ratio is not None else _finite(candidate.get("axis_ratio")) + kind = classify_ellipse_publication( + quality_status=quality_status, + axis_ratio=ratio, + flags=flags, + ) + + if resolved_trace_method == "annular_peak": + # Annular angular maxima are trajectory coordinates. They are not a + # radial q* statistic and must not be converted to a spacing here. + q_star, q_star_unit = None, unit + l_ring = None + elif resolved_trace_method == "radial_sector": + q_star, q_star_unit = _sector_measurement_scalar( + effective_payload, fallback_unit=unit + ) + l_ring = _sector_measurement_period(effective_payload, unit=q_star_unit) + else: + q_star, q_star_unit = _published_scalar_with_unit( + effective_payload, + ("q_star_from_arcs", "q_star_nm_inv"), + fallback_unit=unit, + ) + l_ring = _published_scalar(effective_payload, ("L_from_observed_radius_nm", "L_ring_nm", "L_ring")) + reasons: list[str] = [] + + if busy or state in {"running", "cancelling"}: + status_key = "running" + next_step_key = "wait" + elif state in {"failed", "error"}: + status_key = "failed" + if error not in (None, ""): + reasons.append(str(error)) + reasons.extend(flags) + next_step_key = "retry" + elif not visible_result: + status_key = "ready" if data_ready else "empty" + next_step_key = "identify" if data_ready else "load" + if state in {"cancelled", "canceled", "ignored", "stale"}: + reasons.append("cancelled") + elif active_stage == "trace" or quality_status in { + "NOT_EVALUATED", + "PENDING", + "PENDING_EVALUATION", + }: + status_key = "pending_evaluation" + next_step_key = "evaluate" + reasons.append("geometry_not_evaluated") + elif _result_failed(effective_payload) or kind == "fail": + status_key = "failed" + next_step_key = "retry" + reasons.extend(flags) + elif kind == "ring": + status_key = "ring_only" + next_step_key = "ring_review" + reasons.extend(flags) + else: + status_key = "ellipse_candidate" + next_step_key = "ellipse_review" + reasons.extend(flags) + + if visible_result and not calibrated: + reasons.append("uncalibrated_pixel_q" if unit == "pixel-q" else "spacing_unavailable_unknown_q_unit") + l_ring = None + bound_flags = candidate.get("bound_flags") + if ( + kind == "ring" + and "axis_ratio_at_bound" not in reasons + and isinstance(bound_flags, Mapping) + and bound_flags.get("axis_ratio") + ): + reasons.append("axis_ratio_at_bound") + reasons = sorted(_dedupe(reasons), key=_reason_priority)[:3] + if not visible_result: + q_star = None + l_ring = None + side_support = "—" + else: + side_support = _side_support(effective_payload) + + return ButterflyQualitySummaryState( + status_key=status_key, + engineering_status=engineering, + side_support=side_support, + q_star=q_star, + q_star_unit=q_star_unit, + l_ring=l_ring, + q_unit=unit, + calibrated=calibrated, + reasons=tuple(reasons), + next_step_key=next_step_key, + trace_method=resolved_trace_method, + ) + + +# Short alias for callers/tests that prefer the noun form. +summarize_butterfly_result = build_butterfly_quality_summary + + +if QT_AVAILABLE: + + class ButterflyQualitySummary(QtWidgets.QFrame): + """A small, persistent status card kept next to the butterfly title.""" + + _STATUS_TEXT = { + "empty": ("待载入", "Load frame"), + "ready": ("待识别", "Ready to identify"), + "running": ("处理中", "Running"), + "pending_evaluation": ("待评估", "Awaiting evaluation"), + "ring_only": ("仅一阶环", "First-order ring only"), + "ellipse_candidate": ("椭圆候选", "Ellipse candidate"), + "failed": ("失败", "Failed"), + } + _NEXT_TEXT = { + "load": ("载入经 q 标定的二维帧", "Load a calibrated 2D frame"), + "identify": ("识别弧线以获得实际侧边支持", "Identify arcs to measure side support"), + "wait": ("等待当前任务完成或取消", "Wait for the current job to finish or cancel it"), + "evaluate": ("运行 Evaluate,完成椭圆质量评估", "Run Evaluate to assess ellipse quality"), + "retry": ("检查输入、掩膜和 q 范围后重试", "Check input, mask and q range, then retry"), + "ring_review": ("保留一阶环 L;独立支持前不发布 Ln/Lz", "Keep ring L; do not publish Ln/Lz without independent support"), + "ellipse_review": ("复核质量和支持后再导出;科学接受仍需外部证据", "Review quality/support before export; scientific acceptance still needs external evidence"), + } + + def __init__(self, parent: Any = None, *, language: str = "zh_CN") -> None: + super().__init__(parent) + self.setObjectName("butterflyQualitySummary") + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Maximum, + ) + self.setMinimumHeight(62) + self.setMaximumHeight(88) + self._language = str(language) + self._state = ButterflyQualitySummaryState( + "empty", "NOT_EVALUATED", "—", None, "unknown", None, "unknown", False, (), "load" + ) + outer = QtWidgets.QVBoxLayout(self) + outer.setContentsMargins(8, 4, 8, 4) + outer.setSpacing(2) + top = QtWidgets.QHBoxLayout() + top.setSpacing(8) + self.status_label = QtWidgets.QLabel(self) + self.status_label.setObjectName("butterflySummaryStatus") + self.status_label.setStyleSheet("font-weight: 700;") + top.addWidget(self.status_label) + self.engineering_label = QtWidgets.QLabel(self) + self.engineering_label.setObjectName("butterflySummaryEngineering") + top.addWidget(self.engineering_label) + top.addStretch(1) + self.scientific_label = QtWidgets.QLabel(self) + self.scientific_label.setObjectName("butterflySummaryScientific") + self.scientific_label.setStyleSheet("font-size: 10px;") + top.addWidget(self.scientific_label) + outer.addLayout(top) + + metrics = QtWidgets.QHBoxLayout() + metrics.setSpacing(12) + self.support_label = QtWidgets.QLabel(self) + self.support_label.setObjectName("butterflySummarySideSupport") + metrics.addWidget(self.support_label) + self.q_star_label = QtWidgets.QLabel(self) + self.q_star_label.setObjectName("butterflySummaryQStar") + metrics.addWidget(self.q_star_label) + self.l_ring_label = QtWidgets.QLabel(self) + self.l_ring_label.setObjectName("butterflySummaryLRing") + metrics.addWidget(self.l_ring_label) + self.unit_label = QtWidgets.QLabel(self) + self.unit_label.setObjectName("butterflySummaryUnit") + metrics.addWidget(self.unit_label) + metrics.addStretch(1) + self._metrics_layout = metrics + self._metric_labels = ( + self.support_label, + self.q_star_label, + self.l_ring_label, + self.unit_label, + ) + outer.addLayout(metrics) + + bottom = QtWidgets.QHBoxLayout() + bottom.setSpacing(10) + self.reason_label = QtWidgets.QLabel(self) + self.reason_label.setObjectName("butterflySummaryReasons") + self.reason_label.setWordWrap(True) + self.reason_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Ignored, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + bottom.addWidget(self.reason_label, 1) + self.next_label = QtWidgets.QLabel(self) + self.next_label.setObjectName("butterflySummaryNext") + self.next_label.setWordWrap(True) + self.next_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Ignored, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + bottom.addWidget(self.next_label, 1) + outer.addLayout(bottom) + self.setAccessibleName("Butterfly quality summary") + self._render() + + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt API + self._sync_metric_layout() + super().resizeEvent(event) + + @property + def state(self) -> ButterflyQualitySummaryState: + return self._state + + def snapshot(self) -> dict[str, Any]: + return self._state.as_dict() + + def set_language(self, language: str) -> None: + self._language = str(language) + self._render() + + def set_state( + self, + result: Mapping[str, Any] | None = None, + *, + stage: str = "trace", + page_state: str = "ready", + result_fresh: bool | None = None, + data_ready: bool | None = None, + busy: bool = False, + q_unit: Any = None, + poor_match: bool = False, + error: Any = None, + trace_method: str | None = None, + ) -> None: + self._state = build_butterfly_quality_summary( + result, + stage=stage, + page_state=page_state, + result_fresh=result_fresh, + data_ready=data_ready, + busy=busy, + q_unit=q_unit, + poor_match=poor_match, + error=error, + trace_method=trace_method, + ) + self._render() + + def clear(self, *, data_ready: bool = False) -> None: + self.set_state({}, data_ready=data_ready, result_fresh=False) + + def _render(self) -> None: + english = self._language.lower().startswith("en") + state = self._state + status_pair = self._STATUS_TEXT.get(state.status_key, (state.status_key, state.status_key)) + status_text = status_pair[1 if english else 0] + if state.trace_method in {"radial_sector", "annular_peak"} and state.status_key == "ring_only": + status_text = "Peaks only · ellipse unsupported" if english else "仅主峰轨迹 · 椭圆未支持" + engineering = state.engineering_status + if engineering == "NOT_EVALUATED": + engineering_text = "not evaluated" if english else "待评估" + elif engineering: + engineering_text = f"engineering {engineering}" if english else f"工程 {engineering}" + else: + engineering_text = "—" + side_text = ( + f"tracks {state.side_support}" + if english + else f"轨迹支持 {state.side_support}" + ) if state.trace_method == "annular_peak" else ( + f"sides {state.side_support}" if english else f"侧支持 {state.side_support}" + ) + q_text = _format_value(state.q_star) + if state.q_star is not None and state.q_star_unit: + q_text = f"{q_text} ({state.q_star_unit})" + l_text = _format_value(state.l_ring) + if state.l_ring is not None: + l_text = f"{l_text} nm" + unit_text = ( + f"q unit {state.q_unit}" + if english + else f"q 单位 {state.q_unit}" + ) + if not state.calibrated: + unit_text += " · uncalibrated" if english else " · 未标定" + next_pair = self._NEXT_TEXT.get(state.next_step_key, (state.next_step_key, state.next_step_key)) + if state.trace_method == "annular_peak": + next_pair = ( + ("复核各环峰位后,评估对角配对的双椭圆", "Review ring peaks, then evaluate the paired ellipses") + if state.next_step_key == "evaluate" else + ("检查双椭圆残差和长短轴稳定性;区分可测量值与外推", "Check ellipse residuals and axis stability; separate measured support from extrapolation") + ) + elif state.trace_method == "radial_sector" and state.next_step_key == "ring_review": + next_pair = ( + "检查扇区剖面;反射级次与椭圆解释尚未确认", + "Review sector profiles; reflection order and ellipse interpretation are unconfirmed", + ) + reason_text = state.reasons_text(english=english) + self.status_label.setText(status_text) + self.engineering_label.setText(engineering_text) + self.support_label.setText(side_text) + if state.trace_method == "annular_peak": + q_label = "pairing" if english else "配对" + q_text = "QI↔QIII; QII↔QIV" if english else "对角花瓣 A / B" + length_label = "spacing" if english else "周期" + l_text = "not reported" if english else "不由此方法给出" + else: + q_label = ( + "q* sector median" if english else "主峰 q*中位数" + ) if state.trace_method == "radial_sector" else "q*" + length_label = ( + ("2π/q* (apparent)" if english else "2π/q*(表观)") + if state.trace_method == "radial_sector" + else ("L ring" if english else "环 L") + ) + self.q_star_label.setText(f"{q_label} {q_text}") + self.l_ring_label.setText(f"{length_label} {l_text}") + self.unit_label.setText(unit_text) + self.reason_label.setText( + ("Reasons: " if english else "原因:") + (reason_text or ("—")) + ) + self.next_label.setText( + ("Next: " if english else "下一步:") + next_pair[1 if english else 0] + ) + self.reason_label.setToolTip(reason_text or ("—")) + self.next_label.setToolTip(next_pair[1 if english else 0]) + self.scientific_label.setText( + "Scientific acceptance: not inferred" + if english + else "科学接受:未自动判定" + ) + self._ensure_label_width(self.status_label) + self._ensure_label_width(self.engineering_label) + self._ensure_label_width(self.scientific_label) + for label in self._metric_labels: + self._ensure_label_width(label) + self._sync_metric_layout() + self.setProperty("summaryState", state.status_key) + self.style().unpolish(self) + self.style().polish(self) + if state.engineering_status == "FAIL" or "poor_match" in state.reasons: + style_key = "poor_match" + elif state.engineering_status == "WARN": + style_key = "warning" + else: + style_key = state.status_key + self.setStyleSheet( + "QFrame#butterflyQualitySummary {" + + { + "failed": "background:#fdeceb;color:#7f1d1d;border:1px solid #e0aaa5;", + "poor_match": "background:#fff0ee;color:#7f1d1d;border:1px solid #e0aaa5;", + "warning": "background:#fff5d6;color:#6b4d00;border:1px solid #e4c878;", + "ring_only": "background:#fff5d6;color:#6b4d00;border:1px solid #e4c878;", + "ellipse_candidate": "background:#eaf5ec;color:#165b2a;border:1px solid #a9d3b2;", + "pending_evaluation": "background:#fff8e6;color:#634d00;border:1px solid #e3cb8f;", + "running": "background:#eaf2ff;color:#124e8c;border:1px solid #9fc2ea;", + "ready": "background:#eef4f8;color:#173b56;border:1px solid #bfd0dc;", + "empty": "background:#f2f4f6;color:#3f4a54;border:1px solid #ccd3d9;", + }.get(style_key, "background:#f2f4f6;color:#27313a;border:1px solid #ccd3d9;") + + "border-radius:4px;}" + ) + for label in ( + self.status_label, + self.engineering_label, + self.scientific_label, + *self._metric_labels, + ): + label.updateGeometry() + self.layout().invalidate() + self.layout().activate() + self.updateGeometry() + self.setAccessibleDescription( + " | ".join( + ( + status_text, + engineering_text, + side_text, + self.q_star_label.text(), + self.l_ring_label.text(), + self.unit_label.text(), + self.reason_label.text(), + self.next_label.text(), + self.scientific_label.text(), + ) + ) + ) + + @staticmethod + def _ensure_label_width(label: Any) -> None: + """Keep a one-line readout wide enough for its complete value.""" + + try: + required = int(label.fontMetrics().horizontalAdvance(label.text())) + 8 + except (AttributeError, TypeError, ValueError): + return + label.setMinimumWidth(max(0, required)) + + def _sync_metric_layout(self) -> None: + """Use two compact wrapped rows only when the card is genuinely narrow.""" + + if not hasattr(self, "_metrics_layout"): + return + widths = [] + for label in self._metric_labels: + try: + widths.append(int(label.fontMetrics().horizontalAdvance(label.text())) + 8) + except (AttributeError, TypeError, ValueError): + widths.append(label.minimumWidth()) + required = sum(widths) + required += max(0, len(self._metric_labels) - 1) * 12 + 20 + top_labels = (self.status_label, self.engineering_label, self.scientific_label) + top_widths = [] + for label in top_labels: + try: + top_widths.append(int(label.fontMetrics().horizontalAdvance(label.text())) + 8) + except (AttributeError, TypeError, ValueError): + top_widths.append(label.minimumWidth()) + required = max(required, sum(top_widths) + 16) + parent = self.parentWidget() + available = parent.width() if parent is not None else self.width() + # Keep the hidden card soft while its parent is being laid out; + # otherwise its first long result can establish an oversized + # minimum width before the real page width is known. + compact = (not self.isVisible()) or ( + available > 0 and available < max(required, 760) + ) + for label, width in zip(self._metric_labels, widths): + label.setWordWrap(compact) + label.setMinimumWidth(0 if compact else width) + label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Ignored if compact else QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + for label, width in zip(top_labels, top_widths): + label.setWordWrap(compact) + label.setMinimumWidth(0 if compact else width) + label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Ignored if compact else QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + self._metrics_layout.setSpacing(6 if compact else 12) + self.setMinimumHeight(70 if compact else 62) + self.setMaximumHeight(96 if compact else 88) + +else: + + class ButterflyQualitySummary: + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + require_qt() + + +__all__ = [ + "ButterflyQualitySummary", + "ButterflyQualitySummaryState", + "build_butterfly_quality_summary", + "summarize_butterfly_result", +] diff --git a/src/butterfly_saxs/ui/butterfly_workbench.py b/src/butterfly_saxs/ui/butterfly_workbench.py index 3526096..00fbf28 100644 --- a/src/butterfly_saxs/ui/butterfly_workbench.py +++ b/src/butterfly_saxs/ui/butterfly_workbench.py @@ -18,10 +18,12 @@ from ..butterfly_quality import classify_ellipse_publication, unpublished_ellipse_shape from ..butterfly_settings import normalize_butterfly_settings from ..fit_overlays import fit_geometry_layers +from ..settings import canonical_q_unit from .qt_compat import QT_AVAILABLE, QtCore, QtGui, QtWidgets, require_qt from .qspace import QSpaceView from .butterfly_export import export_butterfly_analysis from .i18n import translate +from .butterfly_summary import ButterflyQualitySummary try: import numpy as _np @@ -36,6 +38,14 @@ DEFAULT_BUTTERFLY_SETTINGS: dict[str, Any] = { "stage": "trace", + # A fresh workbench follows the q-ring -> I(chi) -> four-lobe trajectory + # observable. Recipes/results without an explicit method are still + # interpreted as the historical curvature workflow below. + "trace_method": "annular_peak", + "sector_width_deg": 10.0, + "sector_step_deg": 5.0, + "annular_radial_bins": 40, + "annular_angle_bins": 72, "edits": [], "resamples": 0, "evaluation_resamples": 32, @@ -43,6 +53,69 @@ "sensitivity": True, } +_TRACE_METHOD_RADIAL_SECTOR = "radial_sector" +_TRACE_METHOD_ANNULAR_PEAK = "annular_peak" +_TRACE_METHOD_CURVATURE = "curvature" + + +def _canonical_trace_method(value: Any, *, default: str = _TRACE_METHOD_CURVATURE) -> str: + """Map persisted/UI aliases to the butterfly tracing choices.""" + + text = str(value or "").strip().lower().replace("-", "_").replace(" ", "_") + if text in { + "annular_peak", + "annular_peaks", + "annular_trajectory", + "q_ring_peak", + "q_ring_trajectory", + "azimuthal_peak", + }: + return _TRACE_METHOD_ANNULAR_PEAK + if text in {"radial_sector", "sector", "radial", "sector_peak"}: + return _TRACE_METHOD_RADIAL_SECTOR + if text in { + "curvature", + "butterfly_curvature", + "surface_curvature", + "curvature_ridge", + }: + return _TRACE_METHOD_CURVATURE + return default + + +def _finite_positive(value: Any, default: float) -> float: + try: + result = float(value) + except (TypeError, ValueError): + return float(default) + return result if math.isfinite(result) and result > 0.0 else float(default) + + +def _bounded_int(value: Any, default: int, *, minimum: int, maximum: int) -> int: + """Read a persisted integer control without accepting booleans/fractions.""" + + if isinstance(value, bool): + return int(default) + try: + number = int(value) + except (TypeError, ValueError): + return int(default) + if number != float(value) or not minimum <= number <= maximum: + return int(default) + return number + +_ANALYSIS_JOB_KINDS = frozenset( + { + "preview", + "optimize", + "measure_geometry", + "refine_geometry", + "trace", + "evaluate", + } +) +_DETACHED_JOB_KINDS = frozenset({"butterfly_figure_export"}) + def _read(source: Any, names: tuple[str, ...], default: Any = None) -> Any: if isinstance(source, Mapping): @@ -172,6 +245,8 @@ def set_language(self, *, english: bool) -> None: title = { "Angular peak signal": "方位峰信号", "Radial peak signal": "径向峰信号", + "Radial sector I(q)": "扇区积分 I(q)", + "Annular I(χ)": "q 环积分 I(χ)", }.get(title, title) title = { "Normal profile · raw / fit / residual": "法向剖面 · 原始 / 拟合 / 残差", @@ -198,6 +273,15 @@ def _display_series_names(self) -> list[str]: return [self._display_series_name(name) for name in self._series_names] def _display_series_name(self, name: str) -> str: + if name == "smoothed" and self._title in { + "Radial sector I(q)", + "Annular I(χ)", + }: + return ( + "smoothed (locator only)" + if self._english + else "平滑(仅用于定位)" + ) if name in { "raw", "smoothed", @@ -210,6 +294,14 @@ def _display_series_name(self, name: str) -> str: }: language = "en" if self._english else "zh_CN" return translate(language, f"profile.series.{name}") + if name == "counts": + return "valid pixels (count)" if self._english else "有效像素数(像素)" + if name == "coverage": + return ( + "coverage (dimensionless)" + if self._english + else "覆盖率(无量纲)" + ) return name def _update_data_accessibility(self) -> None: @@ -242,6 +334,7 @@ def set_series( x_label: str = "q offset", y_label: str = "value", markers: Sequence[tuple[float, str]] = (), + plot_names: Sequence[str] | None = None, ) -> None: values = [] if _np is not None: @@ -268,7 +361,7 @@ def set_series( self.plot.clear() colors = { "raw": (220, 230, 238), - "smoothed": (110, 211, 255), + "smoothed": (255, 166, 76), "isotropic_reference": (155, 139, 232), "detection": (255, 145, 106), "fit": (72, 190, 242), @@ -276,14 +369,30 @@ def set_series( "u": (72, 190, 242), "v": (243, 157, 73), } + plot_name_set = ( + None + if plot_names is None + else {str(name) for name in plot_names} + ) for name, y in series.items(): if not name: continue + if plot_name_set is not None and str(name) not in plot_name_set: + continue try: + pen_style = ( + QtCore.Qt.PenStyle.DashLine + if str(name) == "smoothed" + else QtCore.Qt.PenStyle.SolidLine + ) self.plot.plot( values, _np.asarray(y, dtype=float) if _np is not None else list(y), - pen=_pg.mkPen(colors.get(name, (190, 190, 200)), width=2), + pen=_pg.mkPen( + colors.get(name, (190, 190, 200)), + width=2, + style=pen_style, + ), symbol="o" if name == "raw" else None, symbolSize=4, name=self._display_series_name(str(name)), @@ -431,6 +540,15 @@ def __init__(self, parent: Any = None, *, language: str = "zh_CN") -> None: self._selected_landmark_id: str | None = None self._requested_q_window: tuple[float, float] | None = None self._landmark_zoomed = False + self._excluded_count = 0 + self._batch_success_count: int | None = None + self._batch_failure_items: list[Any] = [] + self._detached_status_restore: tuple[str, str, Any] | None = None + self._radial_sector_seen = False + self._auto_hidden_radial_landmarks = False + self._landmark_visibility_user_override = False + self._suppress_landmark_visibility_tracking = False + self._selected_profile_point_id: str | None = None self._manual_review: dict[str, Any] = { "manual_status": "unreviewed", "reviewed_by": "", @@ -458,6 +576,8 @@ def __init__(self, parent: Any = None, *, language: str = "zh_CN") -> None: self.status_label.setObjectName("butterflyStatusLabel") header.addWidget(self.status_label) root.addLayout(header) + self.quality_summary = ButterflyQualitySummary(self, language=self._language) + root.addWidget(self.quality_summary) self.workflow_hint_label = QtWidgets.QLabel(self) self.workflow_hint_label.setObjectName("butterflyWorkflowHint") self.workflow_hint_label.setWordWrap(True) @@ -577,8 +697,17 @@ def __init__(self, parent: Any = None, *, language: str = "zh_CN") -> None: ) right_panel = QtWidgets.QWidget(right_scroll) right_panel.setObjectName("butterflyControlsPanel") + # QGroupBox titles start at the frame's x=0 in the native style; + # shift them inward so the first glyph remains visible when the + # narrow scroll viewport clips the content widget's right side. + right_panel.setStyleSheet( + "QGroupBox::title { subcontrol-origin: margin; left: 6px; }" + ) right_layout = QtWidgets.QVBoxLayout(right_panel) - right_layout.setContentsMargins(4, 2, 4, 4) + # Leave a few extra pixels before group-box titles. The scroll + # viewport otherwise clips the first CJK glyph at its left edge + # on the narrow 980 px workbench layout. + right_layout.setContentsMargins(8, 2, 8, 4) right_layout.setSpacing(7) analysis_group = QtWidgets.QGroupBox("Analysis range", right_panel) @@ -606,6 +735,79 @@ def __init__(self, parent: Any = None, *, language: str = "zh_CN") -> None: self.reference_axis_spin.valueChanged.connect(self._on_analysis_range_changed) right_layout.addWidget(analysis_group) + trace_group = QtWidgets.QGroupBox("Trace localization", right_panel) + trace_group.setObjectName("butterflyTraceLocalization") + trace_form = QtWidgets.QFormLayout(trace_group) + self._trace_form = trace_form + self.trace_method_combo = QtWidgets.QComboBox(trace_group) + self.trace_method_combo.setObjectName("butterflyTraceMethod") + self.trace_method_combo.addItem( + "Annular q-ring angular peaks · I(χ)", + _TRACE_METHOD_ANNULAR_PEAK, + ) + self.trace_method_combo.addItem( + "Sector-integrated radial peak · I(q)", + _TRACE_METHOD_RADIAL_SECTOR, + ) + self.trace_method_combo.addItem( + "Curvature local candidates · advanced", + _TRACE_METHOD_CURVATURE, + ) + self.trace_method_combo.setAccessibleName("Butterfly tracing method") + self.trace_method_combo.setToolTip( + "For annular mode, each q ring is integrated over angle to find up to four lobe peaks and track them across q; " + "radial sectors and curvature remain compatibility modes." + ) + trace_form.addRow("Identification method", self.trace_method_combo) + self.annular_radial_bins = QtWidgets.QSpinBox(trace_group) + self.annular_radial_bins.setObjectName("butterflyAnnularRadialBins") + self.annular_radial_bins.setRange(4, 192) + self.annular_radial_bins.setSingleStep(1) + self.annular_radial_bins.setValue(40) + self.annular_radial_bins.setToolTip( + "Number of q annuli used to build I(χ) profiles and link lobe trajectories." + ) + trace_form.addRow("q-ring bins", self.annular_radial_bins) + self.annular_angle_bins = QtWidgets.QSpinBox(trace_group) + self.annular_angle_bins.setObjectName("butterflyAnnularAngleBins") + self.annular_angle_bins.setRange(16, 720) + self.annular_angle_bins.setSingleStep(1) + self.annular_angle_bins.setValue(72) + self.annular_angle_bins.setToolTip( + "Number of angular bins in each I(χ) profile; no missing angular support is synthesized." + ) + trace_form.addRow("angular bins", self.annular_angle_bins) + self.sector_width_spin = QtWidgets.QDoubleSpinBox(trace_group) + self.sector_width_spin.setObjectName("butterflySectorWidth") + self.sector_width_spin.setRange(0.5, 180.0) + self.sector_width_spin.setDecimals(1) + self.sector_width_spin.setSingleStep(0.5) + self.sector_width_spin.setValue(10.0) + self.sector_width_spin.setSuffix("°") + self.sector_width_spin.setToolTip( + "Azimuth width integrated into each radial I(q) profile." + ) + trace_form.addRow("Sector width", self.sector_width_spin) + self.sector_step_spin = QtWidgets.QDoubleSpinBox(trace_group) + self.sector_step_spin.setObjectName("butterflySectorStep") + self.sector_step_spin.setRange(0.5, 180.0) + self.sector_step_spin.setDecimals(1) + self.sector_step_spin.setSingleStep(0.5) + self.sector_step_spin.setValue(5.0) + self.sector_step_spin.setSuffix("°") + self.sector_step_spin.setToolTip( + "Azimuth step between adjacent sector centers." + ) + trace_form.addRow("Sector step", self.sector_step_spin) + self.trace_method_combo.currentIndexChanged.connect( + self._on_trace_settings_changed + ) + self.sector_width_spin.valueChanged.connect(self._on_trace_settings_changed) + self.sector_step_spin.valueChanged.connect(self._on_trace_settings_changed) + self.annular_radial_bins.valueChanged.connect(self._on_trace_settings_changed) + self.annular_angle_bins.valueChanged.connect(self._on_trace_settings_changed) + right_layout.addWidget(trace_group) + evaluation_group = QtWidgets.QGroupBox("Evaluation", right_panel) evaluation_group.setObjectName("butterflyEvaluationControls") evaluation_layout = QtWidgets.QFormLayout(evaluation_group) @@ -936,18 +1138,30 @@ def __init__(self, parent: Any = None, *, language: str = "zh_CN") -> None: supported_peaks=True, ) self._update_edit_buttons() + self._sync_trace_method_controls() self.set_language(self._language) self._sync_action_state() @property def butterfly_settings(self) -> dict[str, Any]: result = deepcopy(self._settings) + result["trace_method"] = self._trace_method() + result["sector_width_deg"] = float(self.sector_width_spin.value()) + result["sector_step_deg"] = float(self.sector_step_spin.value()) + result["annular_radial_bins"] = int(self.annular_radial_bins.value()) + result["annular_angle_bins"] = int(self.annular_angle_bins.value()) result["edits"] = deepcopy(self._edits) return result @property def analysis_settings(self) -> dict[str, Any]: - return {"ridge_method": "butterfly_curvature", "butterfly": self.butterfly_settings} + return { + # Keep the butterfly workflow family seam stable. The + # selected observable is carried by butterfly.trace_method; + # generic azimuthal_peak is a separate observables workflow. + "ridge_method": "butterfly_curvature", + "butterfly": self.butterfly_settings, + } @property def edits(self) -> list[dict[str, Any]]: @@ -1011,6 +1225,305 @@ def _tr(self, key: str, **values: Any) -> str: except (KeyError, ValueError): return key + def _trace_method(self) -> str: + return _canonical_trace_method( + self._settings.get("trace_method"), + default=_TRACE_METHOD_ANNULAR_PEAK, + ) + + @staticmethod + def _result_trace_method(result: Mapping[str, Any]) -> str: + """Infer the method that produced a payload before rendering it.""" + + settings = result.get("settings") + settings = settings if isinstance(settings, Mapping) else {} + for source in (settings, result): + explicit = source.get("trace_method") + if explicit not in (None, ""): + return _canonical_trace_method( + explicit, default=_TRACE_METHOD_CURVATURE + ) + # ``ridge_method=butterfly_curvature`` is the stable family seam + # for this page. The payload-specific annular/sector blocks are + # therefore checked before that historical family label. + if isinstance(result.get("annular_peaks"), Mapping): + return _TRACE_METHOD_ANNULAR_PEAK + if isinstance(result.get("sector_peaks"), Mapping): + return _TRACE_METHOD_RADIAL_SECTOR + for source in (settings, result): + ridge_method = source.get("ridge_method") + if ridge_method not in (None, ""): + return _canonical_trace_method( + ridge_method, default=_TRACE_METHOD_CURVATURE + ) + method_version = str(result.get("method_version", "") or "").lower() + if method_version.startswith("butterfly-annular-"): + return _TRACE_METHOD_ANNULAR_PEAK + if method_version.startswith("butterfly-radial-sector-"): + return _TRACE_METHOD_RADIAL_SECTOR + points = result.get("points") + if isinstance(points, Sequence) and not isinstance(points, (str, bytes)): + if any( + isinstance(point, Mapping) + and str(point.get("source_method", "") or "").lower() + in {_TRACE_METHOD_ANNULAR_PEAK, "annular_trajectory"} + for point in points + ): + return _TRACE_METHOD_ANNULAR_PEAK + if any( + isinstance(point, Mapping) + and str(point.get("source_method", "") or "").lower() + == _TRACE_METHOD_RADIAL_SECTOR + for point in points + ): + return _TRACE_METHOD_RADIAL_SECTOR + # A payload without explicit method metadata is a legacy curvature + # result. Do not let the new-session annular default reinterpret + # its landmarks. + return _TRACE_METHOD_CURVATURE + + def _adopt_result_trace_method(self, result: Mapping[str, Any]) -> None: + """Synchronize controls with a loaded result without invalidating it.""" + + method = self._result_trace_method(result) + self._settings["trace_method"] = method + result_settings = result.get("settings") + result_settings = result_settings if isinstance(result_settings, Mapping) else {} + annular_bundle = result.get("annular_peaks") + annular_settings = ( + annular_bundle.get("settings") + if isinstance(annular_bundle, Mapping) + else None + ) + annular_settings = annular_settings if isinstance(annular_settings, Mapping) else {} + for key in ("annular_radial_bins", "annular_angle_bins"): + if key in result_settings: + self._settings[key] = result_settings[key] + elif key in annular_settings: + self._settings[key] = annular_settings[key] + for key in ("sector_width_deg", "sector_step_deg"): + if key in result_settings: + self._settings[key] = result_settings[key] + self._sync_trace_method_controls() + self._render_trace_method_label() + self._apply_trace_method_landmark_visibility() + self._apply_trace_method_diagnostic_visibility() + + def _render_trace_method_label(self) -> None: + english = self._language.lower().startswith("en") + method = self._trace_method() + if method == _TRACE_METHOD_ANNULAR_PEAK: + text = "Annular I(χ) four-lobe tracks" if english else "环积分 I(χ) 四瓣轨迹" + tooltip = ( + "Each q annulus is integrated over angle; up to four supported lobe peaks are linked across q." + if english + else "对每个 q 环沿方位积分得到 I(χ),每环最多保留四个有支撑峰并沿 q 连成轨迹。" + ) + elif method == _TRACE_METHOD_RADIAL_SECTOR: + text = "Radial sector I(q) peak" if english else "扇区积分 I(q) 主峰" + tooltip = ( + "One radial I(q) profile is integrated per azimuth sector; q* is its selected peak." + if english + else "沿每个方位扇区积分得到 I(q),每个扇区只定位一个主峰 q*。" + ) + else: + text = "Curvature local candidates · advanced" if english else "曲率局部候选(高级)" + tooltip = ( + "Advanced pixel-curvature candidates; use only when the sector profile is insufficient." + if english + else "高级像素曲率候选;仅在扇区积分剖面不足时使用。" + ) + self.method_label.setText(text) + self.method_label.setToolTip(tooltip) + self.method_label.setAccessibleName(text) + + def _render_trace_method_controls(self) -> None: + english = self._language.lower().startswith("en") + group = self.findChild(QtWidgets.QGroupBox, "butterflyTraceLocalization") + if group is None: + return + group.setTitle("Trace localization" if english else "峰位识别方式") + form = group.layout() + if isinstance(form, QtWidgets.QFormLayout): + form.labelForField(self.trace_method_combo).setText( + "Identification method" if english else "识别方式" + ) + form.labelForField(self.annular_radial_bins).setText( + "q-ring bins" if english else "q 环数量" + ) + form.labelForField(self.annular_angle_bins).setText( + "Angular bins" if english else "方位角分箱" + ) + form.labelForField(self.sector_width_spin).setText( + "Sector width" if english else "扇区宽度" + ) + form.labelForField(self.sector_step_spin).setText( + "Sector step" if english else "扇区步长" + ) + labels = ( + ( + "Annular q-ring peaks · I(χ)" + if english + else "q 环方位峰 · I(χ)", + _TRACE_METHOD_ANNULAR_PEAK, + ), + ( + "Sector-integrated radial peak · I(q)" + if english + else "扇区积分径向主峰 · I(q)", + _TRACE_METHOD_RADIAL_SECTOR, + ), + ( + "Curvature local candidates · advanced" + if english + else "曲率局部候选 · 高级", + _TRACE_METHOD_CURVATURE, + ), + ) + for text, value in labels: + index = self.trace_method_combo.findData(value) + if index >= 0: + self.trace_method_combo.setItemText(index, text) + self.trace_method_combo.setToolTip( + "Annular mode integrates I(χ) on each q ring and links up to four lobe peaks; radial sectors and curvature are compatibility modes." + if english + else "环积分模式在每个 q 环上得到 I(χ),沿 q 连接最多四个瓣峰;扇区积分和曲率保留为兼容模式。" + ) + self.annular_radial_bins.setToolTip( + "Number of q annuli used for I(χ) profiles and trajectory linking." + if english + else "用于生成 I(χ) 和连接轨迹的 q 环数量。" + ) + self.annular_angle_bins.setToolTip( + "Angular bins per annulus; masked angular gaps remain unsupported." + if english + else "每个 q 环的方位角分箱数;掩膜造成的方位缺口不补点。" + ) + if hasattr(self, "identify_button"): + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + identify_tip = ( + "Build I(χ) on each q annulus and link up to four lobe tracks." + if english + else "在每个 q 环构建 I(χ),并沿 q 连接最多四条瓣轨迹。" + ) + else: + identify_tip = ( + "Trace observed arcs using the selected butterfly method" + if english + else "使用当前蝴蝶识别方式提取观测轨迹" + ) + self.identify_button.setToolTip(identify_tip) + self.sector_width_spin.setToolTip( + "Azimuth width integrated into each radial I(q) profile." + if english + else "每个径向 I(q) 剖面所积分的方位角宽度。" + ) + self.sector_step_spin.setToolTip( + "Azimuth step between adjacent sector centers." + if english + else "相邻扇区中心之间的方位角步长。" + ) + + def _sync_trace_method_controls(self) -> None: + method = self._trace_method() + width = _finite_positive(self._settings.get("sector_width_deg"), 10.0) + step = _finite_positive(self._settings.get("sector_step_deg"), 5.0) + radial_bins = _bounded_int( + self._settings.get("annular_radial_bins"), + 40, + minimum=4, + maximum=192, + ) + angle_bins = _bounded_int( + self._settings.get("annular_angle_bins"), + 72, + minimum=16, + maximum=720, + ) + self.trace_method_combo.blockSignals(True) + self.sector_width_spin.blockSignals(True) + self.sector_step_spin.blockSignals(True) + self.annular_radial_bins.blockSignals(True) + self.annular_angle_bins.blockSignals(True) + try: + index = self.trace_method_combo.findData(method) + self.trace_method_combo.setCurrentIndex(max(0, index)) + self.sector_width_spin.setValue(min(180.0, max(0.5, width))) + self.sector_step_spin.setValue(min(180.0, max(0.5, step))) + self.annular_radial_bins.setValue(radial_bins) + self.annular_angle_bins.setValue(angle_bins) + finally: + self.trace_method_combo.blockSignals(False) + self.sector_width_spin.blockSignals(False) + self.sector_step_spin.blockSignals(False) + self.annular_radial_bins.blockSignals(False) + self.annular_angle_bins.blockSignals(False) + self._settings["trace_method"] = method + self._settings["sector_width_deg"] = float(self.sector_width_spin.value()) + self._settings["sector_step_deg"] = float(self.sector_step_spin.value()) + self._settings["annular_radial_bins"] = int(self.annular_radial_bins.value()) + self._settings["annular_angle_bins"] = int(self.annular_angle_bins.value()) + annular = method == _TRACE_METHOD_ANNULAR_PEAK + for widget in (self.annular_radial_bins, self.annular_angle_bins): + widget.setVisible(annular) + for widget in (self.sector_width_spin, self.sector_step_spin): + widget.setVisible(method == _TRACE_METHOD_RADIAL_SECTOR) + if isinstance(getattr(self, "_trace_form", None), QtWidgets.QFormLayout): + for widget, visible in ( + (self.annular_radial_bins, annular), + (self.annular_angle_bins, annular), + (self.sector_width_spin, method == _TRACE_METHOD_RADIAL_SECTOR), + (self.sector_step_spin, method == _TRACE_METHOD_RADIAL_SECTOR), + ): + label = self._trace_form.labelForField(widget) + if label is not None: + label.setVisible(visible) + + def _on_trace_settings_changed(self, *_: Any) -> None: + method = _canonical_trace_method( + self.trace_method_combo.currentData(), + default=_TRACE_METHOD_CURVATURE, + ) + width = float(self.sector_width_spin.value()) + step = float(self.sector_step_spin.value()) + radial_bins = int(self.annular_radial_bins.value()) + angle_bins = int(self.annular_angle_bins.value()) + changed = ( + method != self._trace_method() + or width != _finite_positive(self._settings.get("sector_width_deg"), 10.0) + or step != _finite_positive(self._settings.get("sector_step_deg"), 5.0) + or radial_bins != _bounded_int( + self._settings.get("annular_radial_bins"), + 40, + minimum=4, + maximum=192, + ) + or angle_bins != _bounded_int( + self._settings.get("annular_angle_bins"), + 72, + minimum=16, + maximum=720, + ) + ) + self._settings.update( + { + "trace_method": method, + "sector_width_deg": width, + "sector_step_deg": step, + "annular_radial_bins": radial_bins, + "annular_angle_bins": angle_bins, + } + ) + self._sync_trace_method_controls() + self._render_trace_method_label() + self._apply_trace_method_landmark_visibility() + self._apply_trace_method_diagnostic_visibility() + if not changed: + return + self.clear_result() + self.analysisChanged.emit({"butterfly": self.butterfly_settings}) + self._sync_action_state() + def _refresh_fit_layers(self) -> None: if not self._result_fresh or not self._result: self._fit_layers = {} @@ -1292,11 +1805,68 @@ def _render_peak_table(self) -> None: self.peak_table.blockSignals(False) def _on_landmark_visibility_changed(self, *_: Any) -> None: + if not self._suppress_landmark_visibility_tracking: + # A manual toggle takes ownership of the pixel-diagnostic + # visibility. Switching methods or loading a result must not + # undo the user's explicit choice. + self._landmark_visibility_user_override = True + self._auto_hidden_radial_landmarks = False self.qspace.set_landmark_visibility( global_raw_max=self.global_max_check.isChecked(), supported_peaks=self.supported_peaks_check.isChecked(), ) + def _apply_trace_method_landmark_visibility(self) -> None: + """Keep pixel extrema secondary to radial sector profiles.""" + + if self._trace_method() in { + _TRACE_METHOD_RADIAL_SECTOR, + _TRACE_METHOD_ANNULAR_PEAK, + }: + self._radial_sector_seen = True + if self._landmark_visibility_user_override: + return + self._auto_hidden_radial_landmarks = True + self._suppress_landmark_visibility_tracking = True + try: + self.global_max_check.setChecked(False) + self.supported_peaks_check.setChecked(False) + finally: + self._suppress_landmark_visibility_tracking = False + self.qspace.set_landmark_visibility( + global_raw_max=False, + supported_peaks=False, + ) + return + if ( + not self._auto_hidden_radial_landmarks + or self._landmark_visibility_user_override + ): + return + self._auto_hidden_radial_landmarks = False + self._suppress_landmark_visibility_tracking = True + try: + self.global_max_check.setChecked(True) + self.supported_peaks_check.setChecked(True) + finally: + self._suppress_landmark_visibility_tracking = False + # Keep the automatic state alive so returning to radial mode + # hides the two diagnostic layers again. A real user toggle above + # clears this state and takes precedence. + self._auto_hidden_radial_landmarks = True + self.qspace.set_landmark_visibility( + global_raw_max=True, + supported_peaks=True, + ) + + def _apply_trace_method_diagnostic_visibility(self) -> None: + """Give the radial I(q) profile the diagnostic height it needs.""" + + self.ellipse_diagnostic.setVisible( + self._trace_method() + not in {_TRACE_METHOD_RADIAL_SECTOR, _TRACE_METHOD_ANNULAR_PEAK} + ) + def _on_overlay_mode_changed(self, *_: Any) -> None: self._render_fit_source_state() @@ -1515,6 +2085,134 @@ def _result_is_failed(result: Mapping[str, Any]) -> bool: return True return False + def _result_points(self) -> list[Any]: + points = _read(self._result, ("points",), None) + if isinstance(points, Sequence) and not isinstance(points, (str, bytes)): + return list(points) + sector_bundle = _read(self._result, ("sector_peaks",), {}) + points = _read(sector_bundle, ("points",), []) + return list(points) if isinstance(points, Sequence) and not isinstance(points, (str, bytes)) else [] + + def _is_sector_result(self) -> bool: + if self._trace_method() != _TRACE_METHOD_RADIAL_SECTOR: + return False + if isinstance(_read(self._result, ("sector_peaks",), None), Mapping): + return True + for point in self._result_points(): + if isinstance(point, Mapping) and str( + _read(point, ("source_method",), "") or "" + ).strip().lower() == _TRACE_METHOD_RADIAL_SECTOR: + return True + return any( + isinstance(profile, Mapping) + and str(_read(profile, ("profile_axis",), "") or "").lower() == "radial" + for profile in self._profiles.values() + ) + + def _is_annular_result(self) -> bool: + if self._trace_method() != _TRACE_METHOD_ANNULAR_PEAK: + return False + if isinstance(_read(self._result, ("annular_peaks",), None), Mapping): + return True + return any( + isinstance(profile, Mapping) + and str(_read(profile, ("profile_axis",), "") or "").lower() + in {"azimuthal", "angular", "chi"} + for profile in self._profiles.values() + ) + + def _point_list_points(self) -> list[Any]: + """Return selectable rows, including profile-only sectors.""" + + if self._trace_method() == _TRACE_METHOD_RADIAL_SECTOR: + sector_bundle = _read(self._result, ("sector_peaks",), {}) + sectors = _read(sector_bundle, ("sectors",), []) + if ( + isinstance(sectors, Sequence) + and not isinstance(sectors, (str, bytes)) + and sectors + ): + return list(sectors) + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + annular_bundle = _read(self._result, ("annular_peaks",), {}) + annuli = _read(annular_bundle, ("annuli",), []) + if ( + isinstance(annuli, Sequence) + and not isinstance(annuli, (str, bytes)) + and annuli + ): + return list(annuli) + return self._result_points() + + def _render_quality_summary(self) -> None: + """Keep the compact evidence summary synchronized with page state.""" + + trace_method = self._settings.get("trace_method", _TRACE_METHOD_ANNULAR_PEAK) + # The page defaults to the new method for a new session, while a + # loaded legacy result may carry no method metadata at all. Do + # not reinterpret that old candidate's arc radius as a sector + # median merely because the current controls have a new default. + if ( + trace_method == _TRACE_METHOD_RADIAL_SECTOR + and self._result + and not self._is_sector_result() + ): + trace_method = _TRACE_METHOD_CURVATURE + if ( + trace_method == _TRACE_METHOD_ANNULAR_PEAK + and self._result + and not self._is_annular_result() + ): + trace_method = _TRACE_METHOD_CURVATURE + self.quality_summary.set_state( + self._result if self._result_fresh else {}, + stage=str(self._settings.get("stage", "trace")), + page_state=self._page_status_state, + result_fresh=self._result_fresh, + data_ready=self._data_ready(), + busy=self._busy, + q_unit=self._frame_data.get("q_unit"), + poor_match=self._poor_geometry_fit(), + error=self._page_status_error, + trace_method=trace_method, + ) + + def _render_excluded_count(self) -> None: + english = self._language.lower().startswith("en") + self.excluded_count_label.setText( + f"{self._excluded_count} excluded" + if english + else f"已排除 {self._excluded_count} 个" + ) + self.excluded_count_label.setToolTip( + "Points excluded from the active butterfly result" + if english + else "当前蝴蝶结果中未接受或无效的点数" + ) + + def _render_batch_feedback(self) -> None: + if self._batch_success_count is None: + self.batch_feedback_label.clear() + return + english = self._language.lower().startswith("en") + success_count = int(self._batch_success_count) + failures = list(self._batch_failure_items) + if failures: + details = "; ".join(str(item) for item in failures[:4]) + if len(failures) > 4: + details += f" (+{len(failures) - 4})" + self.batch_feedback_label.setText( + f"Batch applied: {success_count} ready; failures: {details}" + if english + else f"批处理已应用:{success_count} 帧可用;失败:{details}" + ) + else: + self.batch_feedback_label.setText( + f"Batch applied: {success_count} frame(s) ready" + if english + else f"批处理已应用:{success_count} 帧可用" + ) + def _render_page_status(self) -> None: """Render the retained readiness/job/result state in the active language.""" @@ -1550,7 +2248,7 @@ def _render_page_status(self) -> None: suffix = f": {visible_detail}" if visible_detail else "" text = f"Failed · {kind_label}{suffix}" if english else f"失败 · {kind_label}{suffix}" elif state == "result": - count = len(self._result.get("points", []) or []) + count = len(self._result_points()) stage = self._settings.get("stage", "trace") text = ( f"Result · {stage} · {count} points" @@ -1558,7 +2256,7 @@ def _render_page_status(self) -> None: else f"结果 · {'评估' if stage == 'evaluate' else '追踪'} · {count} 个点" ) elif state == "completed": - count = len(self._result.get("points", []) or []) + count = len(self._result_points()) text = ( f"Completed · {kind_label} · {count} points" if english @@ -1578,6 +2276,7 @@ def _render_page_status(self) -> None: str(self._page_status_error or "") if state == "failed" else "" ) self._render_workflow_hint() + self._render_quality_summary() def _render_workflow_hint(self) -> None: """Show the next useful operation without implying scientific acceptance.""" @@ -1612,10 +2311,10 @@ def set_language(self, language: str) -> None: self._language = str(language) english = self._language.lower().startswith("en") self.qspace.set_language(self._language) + self.quality_summary.set_language(self._language) self.title_label.setText("Butterfly analysis" if english else "蝴蝶分析 / Butterfly analysis") - self.method_label.setText( - "Curvature ridge" if english else "论文曲率脊线" - ) + self._render_trace_method_controls() + self._render_trace_method_label() self.frame_title_label.setText("Frames" if english else "帧 / Frames") self.point_title_label.setText("Points" if english else "点 / Points") self.frame_list.setAccessibleName("Frames" if english else "帧列表") @@ -1684,6 +2383,18 @@ def set_language(self, language: str) -> None: ) self.global_max_check.setText(self._tr("check.raw_global_max")) self.supported_peaks_check.setText(self._tr("check.supported_peaks")) + self.global_max_check.setToolTip( + "Pixel raw-maximum diagnostic layer; it does not select the sector q*." + if english + else "像素原始最大值诊断图层;它不用于选择扇区 q*。" + ) + self.supported_peaks_check.setToolTip( + "Pixel-supported-peak diagnostic layer; it does not replace sector-integrated I(q)." + if english + else "像素支持峰诊断图层;它不替代扇区积分 I(q)。" + ) + self._apply_trace_method_landmark_visibility() + self._apply_trace_method_diagnostic_visibility() self.reset_peak_zoom_button.setText(self._tr("button.reset_peak_zoom")) previous_landmark = self._selected_landmark_id self._render_peak_table() @@ -1695,6 +2406,17 @@ def set_language(self, language: str) -> None: self.ellipse_diagnostic.set_language(english=english) self.peak_angular_profile.set_language(english=english) self.peak_radial_profile.set_language(english=english) + if self._selected_profile_point_id: + selected_point = None + for row in range(self.point_list.count()): + item = self.point_list.item(row) + candidate = item.data(QtCore.Qt.ItemDataRole.UserRole) if item else None + if isinstance(candidate, Mapping) and str( + candidate.get("point_id", "") + ) == self._selected_profile_point_id: + selected_point = candidate + break + self._render_profile(self._selected_profile_point_id, selected_point) self.diagnostics_tabs.setTabText( 0, "Point diagnostics" if english else "测量点诊断" ) @@ -1744,6 +2466,10 @@ def set_language(self, language: str) -> None: if english else "候选值为缓存且未验证,仅供诊断,不等同于定量值。" ) + if self._result: + self._render_quantities( + _read(self._result, ("quantitative_parameters",), {}) or {} + ) self.findChild(QtWidgets.QGroupBox, "butterflyCorrections").setTitle( "Corrections" if english else "校正 / 编辑" ) @@ -1799,6 +2525,8 @@ def set_language(self, language: str) -> None: "Select a measured point" if english else "请选择测量点" ) self._render_magnification_label() + self._render_excluded_count() + self._render_batch_feedback() self._render_page_status() self._sync_action_state() @@ -2151,6 +2879,11 @@ def set_analysis_settings( self._render_q_range_feedback() recipe_keys = { "stage", + "trace_method", + "sector_width_deg", + "sector_step_deg", + "annular_radial_bins", + "annular_angle_bins", "resamples", "evaluation_resamples", "seed", @@ -2177,6 +2910,21 @@ def set_analysis_settings( return recipe_source = {} if replace else deepcopy(self._settings) recipe_source.update(deepcopy(dict(nested))) + explicit_method = nested.get("trace_method") + if explicit_method in (None, ""): + explicit_method = settings.get("trace_method") + if explicit_method in (None, ""): + explicit_method = settings.get("ridge_method") + if explicit_method not in (None, ""): + recipe_source["trace_method"] = _canonical_trace_method( + explicit_method, + default=_TRACE_METHOD_CURVATURE, + ) + elif replace and "trace_method" not in nested: + # A replaced recipe without the new field is an old project + # recipe. Keep its curvature semantics instead of silently + # upgrading it to the new annular-trajectory default. + recipe_source["trace_method"] = _TRACE_METHOD_CURVATURE if "evaluation_resamples" in nested: recipe_source["evaluation_resamples"] = nested["evaluation_resamples"] elif recipe_source.get("stage") == "evaluate" and "resamples" in nested: @@ -2198,7 +2946,55 @@ def set_analysis_settings( "butterfly evaluation_resamples must be a non-negative integer" ) from exc normalized = normalize_butterfly_settings(recipe_source) + normalized["trace_method"] = _canonical_trace_method( + normalized.get("trace_method"), + default=_TRACE_METHOD_CURVATURE if replace else self._trace_method(), + ) + normalized["sector_width_deg"] = min( + 180.0, + max(0.5, _finite_positive(normalized.get("sector_width_deg"), 10.0)), + ) + normalized["sector_step_deg"] = min( + 180.0, + max(0.5, _finite_positive(normalized.get("sector_step_deg"), 5.0)), + ) + normalized["annular_radial_bins"] = _bounded_int( + normalized.get("annular_radial_bins"), + 40, + minimum=4, + maximum=192, + ) + normalized["annular_angle_bins"] = _bounded_int( + normalized.get("annular_angle_bins"), + 72, + minimum=16, + maximum=720, + ) old_settings = normalize_butterfly_settings(self._settings) + old_settings["trace_method"] = _canonical_trace_method( + old_settings.get("trace_method"), + default=_TRACE_METHOD_ANNULAR_PEAK, + ) + old_settings["sector_width_deg"] = min( + 180.0, + max(0.5, _finite_positive(old_settings.get("sector_width_deg"), 10.0)), + ) + old_settings["sector_step_deg"] = min( + 180.0, + max(0.5, _finite_positive(old_settings.get("sector_step_deg"), 5.0)), + ) + old_settings["annular_radial_bins"] = _bounded_int( + old_settings.get("annular_radial_bins"), + 40, + minimum=4, + maximum=192, + ) + old_settings["annular_angle_bins"] = _bounded_int( + old_settings.get("annular_angle_bins"), + 72, + minimum=16, + maximum=720, + ) self._settings = normalized edits = normalized.get("edits", []) self._edits = [dict(edit) for edit in edits if isinstance(edit, Mapping)] @@ -2207,6 +3003,10 @@ def set_analysis_settings( self.qspace.set_edits(self._edits) self._update_edit_buttons() self._sync_evaluation_controls() + self._sync_trace_method_controls() + self._render_trace_method_label() + self._apply_trace_method_landmark_visibility() + self._apply_trace_method_diagnostic_visibility() if normalized != old_settings: self.clear_result( message=( @@ -2223,7 +3023,9 @@ def set_analysis_settings( def reset_analysis_settings(self) -> None: """Reset the page recipe when a legacy project has no recipe.""" - self.set_analysis_settings(DEFAULT_BUTTERFLY_SETTINGS, replace=True) + legacy_recipe = deepcopy(DEFAULT_BUTTERFLY_SETTINGS) + legacy_recipe["trace_method"] = _TRACE_METHOD_CURVATURE + self.set_analysis_settings(legacy_recipe, replace=True) def set_legacy_method(self, method: Any) -> None: value = str(method or "radial_peak") @@ -2235,10 +3037,87 @@ def set_legacy_method(self, method: Any) -> None: ) self.legacy_banner.setVisible(bool(self._legacy_method)) + def _point_list_label( + self, + point: Mapping[str, Any], + index: int, + *, + marker: str | None = None, + ) -> str: + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + selected = _read(point, ("selected_peaks", "peaks"), ()) + has_selected = bool( + isinstance(selected, Sequence) + and not isinstance(selected, (str, bytes)) + and len(selected) + ) + accepted = bool( + _read( + point, + ("accepted",), + _read(point, ("valid",), has_selected), + ) + ) + else: + accepted = bool(_read(point, ("accepted",), _read(point, ("valid",), True))) + marker_text = marker or ("✓" if accepted else "×") + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + q_center = _read(point, ("q_center", "q"), None) + q_min = _read(point, ("q_min",), None) + q_max = _read(point, ("q_max",), None) + selected = _read(point, ("selected_peaks", "peaks"), ()) + selected_count = ( + len(selected) + if isinstance(selected, Sequence) and not isinstance(selected, (str, bytes)) + else 0 + ) + reason = _read(point, ("reason", "status", "failure_reason"), None) + if q_min not in (None, "") and q_max not in (None, ""): + q_label = f"q=[{_fmt(q_min)}, {_fmt(q_max)}]" + elif q_center not in (None, ""): + q_label = f"q={_fmt(q_center)}" + else: + q_label = f"ring {index + 1}" + peak_label = ( + f"{selected_count} peaks" + if self._language.lower().startswith("en") + else f"{selected_count} 个峰" + ) + if not selected_count and reason not in (None, ""): + peak_label = str(reason) + return f"{marker_text} {q_label} · {peak_label}" + if self._trace_method() == _TRACE_METHOD_RADIAL_SECTOR: + center = _read( + point, + ("sector_center_deg", "chi_deg", "angular_peak_deg"), + None, + ) + q_star = _read( + point, + ("q_star", "selected_peak_q", "q"), + None, + ) + if center in (None, ""): + center = f"sector {index + 1}" + else: + center = f"χ={_fmt(center)}°" + if q_star in (None, ""): + reason = _read(point, ("failure_reason", "reason", "status"), None) + q_label = str(reason or ("unlocated" if self._language.lower().startswith("en") else "未定位")) + else: + q_label = f"q*={_fmt(q_star)}" + return f"{marker_text} {center} · {q_label}" + point_id = str(_read(point, ("point_id",), "") or "") + qx = _fmt(_read(point, ("qx",), None)) + qy = _fmt(_read(point, ("qy",), None)) + return f"{marker_text} {point_id} ({qx}, {qy})" + def set_result(self, result: Any = None) -> None: butterfly = result if isinstance(result, Mapping) else {} self._result = deepcopy(dict(butterfly)) self._result_fresh = bool(self._result) + if self._result: + self._adopt_result_trace_method(self._result) self._model_parameters = None self._model_reference_axis_deg = None self._model_status = None @@ -2251,12 +3130,28 @@ def set_result(self, result: Any = None) -> None: "review_notes": "", "result_revision": self._result_revision if self._result_fresh else None, } - self.qspace.set_butterfly(self._result) + points = self._result_points() + list_points = self._point_list_points() + profiles = _read(self._result, ("profiles",), {}) + profiles = dict(profiles) if isinstance(profiles, Mapping) else {} + sector_bundle = _read(self._result, ("sector_peaks",), {}) + nested_profiles = _read(sector_bundle, ("profiles",), {}) + if isinstance(nested_profiles, Mapping): + profiles.update(dict(nested_profiles)) + annular_bundle = _read(self._result, ("annular_peaks",), {}) + nested_profiles = _read(annular_bundle, ("profiles",), {}) + if isinstance(nested_profiles, Mapping): + profiles.update(dict(nested_profiles)) + display_result = dict(self._result) + display_result["points"] = points + display_result["profiles"] = profiles + self.qspace.set_butterfly(display_result) self._peak_landmarks = dict( _read(self._result, ("peak_landmarks",), {}) or {} ) self._selected_landmark = {} self._selected_landmark_id = None + self._selected_profile_point_id = None self._reset_landmark_zoom() self.qspace.set_peak_landmarks(self._peak_landmarks) self._render_peak_table() @@ -2265,29 +3160,50 @@ def set_result(self, result: Any = None) -> None: self._refresh_fit_layers() self.point_list.blockSignals(True) self.point_list.clear() - for point in self._result.get("points", []) or []: + for index, point in enumerate(list_points): if not isinstance(point, Mapping): continue - point_id = str(_read(point, ("point_id",), "") or "") - qx = _fmt(_read(point, ("qx",), None)) - qy = _fmt(_read(point, ("qy",), None)) - accepted = bool(_read(point, ("accepted",), True)) + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + selected = _read(point, ("selected_peaks", "peaks"), ()) + has_selected = bool( + isinstance(selected, Sequence) + and not isinstance(selected, (str, bytes)) + and len(selected) + ) + accepted = bool( + _read( + point, + ("accepted",), + _read(point, ("valid",), has_selected), + ) + ) + else: + accepted = bool(_read(point, ("accepted",), _read(point, ("valid",), True))) marker = "✓" if accepted else "×" - item = QtWidgets.QListWidgetItem(f"{marker} {point_id} ({qx}, {qy})") + item = QtWidgets.QListWidgetItem( + self._point_list_label(point, index, marker=marker) + ) + source_reason = _read( + point, + ("failure_reason", "reason", "status"), + None, + ) + if source_reason not in (None, ""): + item.setToolTip(str(source_reason)) item.setData(QtCore.Qt.ItemDataRole.UserRole, dict(point)) self.point_list.addItem(item) self.point_list.blockSignals(False) - excluded_count = sum( + self._excluded_count = sum( 1 - for point in (self._result.get("points", []) or []) + for point in points if isinstance(point, Mapping) and ( not bool(_read(point, ("valid",), True)) or not bool(_read(point, ("accepted",), True)) ) ) - self.excluded_count_label.setText(f"{excluded_count} excluded") - self._profiles = dict(_read(self._result, ("profiles",), {}) or {}) + self._render_excluded_count() + self._profiles = dict(profiles or {}) if isinstance(profiles, Mapping) else {} self._ellipse_local = dict(_read(self._result, ("ellipse_local",), {}) or {}) self._render_quantities(_read(self._result, ("quantitative_parameters",), {}) or {}) diagnostics = _read(self._result, ("diagnostics",), {}) or {} @@ -2336,10 +3252,12 @@ def clear_result(self, *, message: str | None = None, state: str | None = None) self.point_list.clear() self.point_list.blockSignals(False) self._selected_ellipse_point = {} - self.excluded_count_label.setText("0 excluded") + self._excluded_count = 0 + self._render_excluded_count() self.quantity_table.setRowCount(0) self.normal_profile.clear() self.ellipse_diagnostic.clear() + self._selected_profile_point_id = None del message # The structured state is rendered afresh on language changes. self._page_status_state = str(state or ("ready" if self._data_ready() else "empty")) self._page_status_kind = "" @@ -2347,6 +3265,14 @@ def clear_result(self, *, message: str | None = None, state: str | None = None) self._render_page_status() self._sync_export_state() + def _set_quantity_cell(self, row: int, column: int, text: Any) -> None: + """Keep the complete value available when a narrow cell elides it.""" + + value = str(text) + item = QtWidgets.QTableWidgetItem(value) + item.setToolTip(value) + self.quantity_table.setItem(row, column, item) + def _render_quantities(self, quantities: Mapping[str, Any]) -> None: self.quantity_table.setRowCount(0) for name, payload in quantities.items(): @@ -2399,7 +3325,7 @@ def _render_quantities(self, quantities: Mapping[str, Any]) -> None: for column, text in enumerate( (display_name, _fmt(value), status_text, _fmt(candidate_value), interval_text, reason_text) ): - self.quantity_table.setItem(row, column, QtWidgets.QTableWidgetItem(text)) + self._set_quantity_cell(row, column, text) self._render_review_observables() def _render_review_observables(self) -> None: @@ -2444,7 +3370,98 @@ def _render_review_observables(self) -> None: reading = "fail" if english else "失败" else: reading = "ellipse" if english else "椭圆" - review_rows = ( + sector_result = self._is_sector_result() + annular_result = self._is_annular_result() + q_star_label = ( + "q* sector median (unassigned order)" + if english + else "主峰 q*中位数(未定级)" + ) if sector_result else ("q* (first-order)" if english else "一阶 q*") + ring_length_label = ( + "2π/q* (apparent)" + if english + else "2π/q*(表观)" + ) if sector_result else ("L ring (nm)" if english else "环 L(nm)") + sector_summary = result.get("measurement_summary") + sector_summary = sector_summary if isinstance(sector_summary, Mapping) else {} + if sector_result: + # The selected-sector statistic is the authoritative radial + # readout for this method. Never populate it from the legacy + # arc-radius aliases, which describe a different observable. + sector_q_star = sector_summary.get( + "q_star_sector_median", result.get("q_star_sector_median") + ) + sector_q_source = sector_summary.get( + "aggregation", "median of selected sector-profile peaks" + ) + sector_period = sector_summary.get( + "apparent_period_from_sector_median_nm", + result.get("apparent_period_from_sector_median_nm"), + ) + sector_q_unit = sector_summary.get( + "q_star_sector_median_unit", result.get("q_unit", "unknown") + ) + if canonical_q_unit(sector_q_unit) not in {"nm⁻¹", "Å⁻¹"}: + sector_period = None + else: + sector_q_star = None + sector_q_source = None + sector_period = None + annular_points = [ + point + for point in (result.get("points") or ()) + if isinstance(point, Mapping) + and bool(point.get("accepted", point.get("valid", False))) + and bool(point.get("valid", True)) + ] if annular_result else [] + trajectory_ids = { + str(point.get("trajectory_id")) + for point in annular_points + if point.get("trajectory_id") not in (None, "") + } + annular_bundle = result.get("annular_peaks") + annuli = annular_bundle.get("annuli", ()) if isinstance(annular_bundle, Mapping) else () + annular_rows = ( + ( + "reading" if english else "判读", + "annular I(χ) tracks" if english else "q 环 I(χ) 四瓣轨迹", + quality.get("status"), + None, + None, + "angular maxima are linked across q; no q* median or spacing is inferred" + if english + else "沿 q 连接每个环的方位峰;不由此推导 q* 中位数或周期", + ), + ( + "quality" if english else "质量", + quality.get("status"), + quality.get("status"), + None, + None, + ", ".join(str(item) for item in (quality.get("flags") or ()) if item), + ), + ( + "track support" if english else "轨迹支持", + f"{len(trajectory_ids)} tracks" if trajectory_ids else "—", + "pending evaluation" if str(self._settings.get("stage", "trace")) == "trace" else None, + None, + None, + "multiple q rings; up to four peaks per ring" + if english + else "多个 q 环;每环最多四个方位峰", + ), + ( + "annuli" if english else "q 环", + len(annuli) if isinstance(annuli, Sequence) and not isinstance(annuli, (str, bytes)) else "—", + None, + None, + None, + "profiles include raw counts and coverage" + if english + else "剖面保留原始像素数和覆盖率", + ), + ) + review_rows = annular_rows if annular_result else ( ( "reading" if english else "判读", reading, @@ -2470,18 +3487,23 @@ def _render_review_observables(self) -> None: None, ), ( - "q* (first-order)" if english else "一阶 q*", - candidate.get("q_star_from_arcs", result.get("q_star_from_arcs")), - candidate.get("q_star_source", result.get("q_star_source")), + q_star_label, + sector_q_star + if sector_result + else candidate.get("q_star_from_arcs", result.get("q_star_from_arcs")), + sector_q_source + if sector_result + else candidate.get("q_star_source", result.get("q_star_source")), None, None, None, ), ( - "L ring (nm)" if english else "环 L(nm)", - candidate.get( - "L_from_observed_radius_nm", - result.get("L_from_observed_radius_nm"), + ring_length_label, + sector_period + if sector_result + else candidate.get( + "L_from_observed_radius_nm", result.get("L_from_observed_radius_nm") ), None, None, @@ -2489,7 +3511,7 @@ def _render_review_observables(self) -> None: None, ), ) - if not unpublished_shape: + if not unpublished_shape and not annular_result: extra = [] ln = candidate.get("Ln_from_minor_axis_nm", candidate.get("L_N")) lz = candidate.get("Lz_from_draw_axis_nm", candidate.get("L_z")) @@ -2551,17 +3573,17 @@ def _render_review_observables(self) -> None: "" if reason is None else str(reason), ) ): - self.quantity_table.setItem(row, column, QtWidgets.QTableWidgetItem(text)) + self._set_quantity_cell(row, column, text) def _on_point_selected(self, point: Any) -> None: if not isinstance(point, Mapping): return - point_id = str(_read(point, ("point_id",), "") or "") + point_id = self._profile_id_for_entry(point) self.point_list.blockSignals(True) for row in range(self.point_list.count()): item = self.point_list.item(row) data = item.data(QtCore.Qt.ItemDataRole.UserRole) - if isinstance(data, Mapping) and str(data.get("point_id", "")) == point_id: + if isinstance(data, Mapping) and self._profile_id_for_entry(data) == point_id: self.point_list.setCurrentRow(row) break self.point_list.blockSignals(False) @@ -2575,12 +3597,30 @@ def _on_point_list_changed(self, row: int) -> None: point = item.data(QtCore.Qt.ItemDataRole.UserRole) if item is not None else None if not isinstance(point, Mapping): return - self.qspace.set_selected_point(_read(point, ("point_id",), None)) + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + # An annulus row is an angular profile, not one editable + # q-space point. Its selected peaks are shown in the profile. + self.qspace.set_selected_point(None) + elif bool(_read(point, ("profile_only",), False)): + self.qspace.set_selected_point(None) + else: + self.qspace.set_selected_point(_read(point, ("point_id",), None)) self._on_point_selected(point) + @staticmethod + def _profile_id_for_entry(entry: Mapping[str, Any] | None) -> str: + if not isinstance(entry, Mapping): + return "" + value = _read(entry, ("profile_id", "point_id", "annulus_id"), "") + return str(value or "") + def _exclude_selected_point(self) -> None: item = self.point_list.currentItem() point = item.data(QtCore.Qt.ItemDataRole.UserRole) if item is not None else None + if self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK: + return + if isinstance(point, Mapping) and bool(_read(point, ("profile_only",), False)): + return point_id = _read(point, ("point_id",), None) if point_id not in (None, ""): self._on_edit_requested({"type": "exclude_point", "point_id": str(point_id)}) @@ -2594,14 +3634,387 @@ def _series(profile: Mapping[str, Any], x_names: tuple[str, ...], y_names: tuple if isinstance(y, Mapping): y = _read(y, ("values", "data"), []) try: - return list(x or []), list(y or []) + return ([] if x is None else list(x)), ([] if y is None else list(y)) except TypeError: return [], [] + def _sector_failure_text( + self, + point: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None = None, + ) -> str: + source = point if isinstance(point, Mapping) else {} + profile_map = profile if isinstance(profile, Mapping) else {} + reason = _read( + source, + ("failure_reason", "reason", "status"), + _read(profile_map, ("failure_reason", "reason", "status"), None), + ) + raw_reason = str(reason or "").strip().lower() + translated = { + "selected": ("located" if self._language.lower().startswith("en") else "已定位"), + "no_peak": ("no peak located" if self._language.lower().startswith("en") else "未找到主峰"), + "ambiguous": ("ambiguous peaks" if self._language.lower().startswith("en") else "峰不唯一"), + "ambiguous_multiple_peaks": ( + "ambiguous peaks" if self._language.lower().startswith("en") else "峰不唯一" + ), + "low_coverage": ( + "insufficient coverage" if self._language.lower().startswith("en") else "覆盖不足" + ), + "insufficient_coverage": ( + "insufficient coverage" if self._language.lower().startswith("en") else "覆盖不足" + ), + "excluded_point_edit": ( + "manually excluded" if self._language.lower().startswith("en") else "手动排除" + ), + } + if raw_reason: + return translated.get(raw_reason, str(reason)) + if not bool(_read(source, ("accepted", "valid"), True)): + return "rejected" if self._language.lower().startswith("en") else "未通过" + return "supported" if self._language.lower().startswith("en") else "可用" + + @staticmethod + def _sector_failure_code( + point: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None = None, + ) -> str: + source = point if isinstance(point, Mapping) else {} + profile_map = profile if isinstance(profile, Mapping) else {} + reason = _read( + source, + ("failure_reason", "reason", "status"), + _read(profile_map, ("failure_reason", "reason", "status"), None), + ) + if reason not in (None, ""): + return str(reason) + if not bool(_read(source, ("accepted", "valid"), True)): + return "rejected" + return "supported" + + def _radial_profile_title( + self, + point_id: str, + point: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None, + ) -> str: + source = point if isinstance(point, Mapping) else {} + profile_map = profile if isinstance(profile, Mapping) else {} + center = _read( + source, + ("sector_center_deg", "chi_deg", "angular_peak_deg"), + _read(profile_map, ("sector_center_deg", "chi_deg"), None), + ) + width = _read( + source, + ("sector_width_deg",), + _read(profile_map, ("sector_width_deg",), self._settings.get("sector_width_deg")), + ) + selected_q = _read( + profile_map, + ("selected_peak_q",), + _read(source, ("q_star", "selected_peak_q", "q"), None), + ) + reason = self._sector_failure_text(source, profile_map) + if self._language.lower().startswith("en"): + return ( + f"Radial sector I(q) · χ={_fmt(center)}° · " + f"width={_fmt(width)}° · q*={_fmt(selected_q)} · {reason}" + ) + return ( + f"扇区积分 I(q) · χ={_fmt(center)}° · " + f"宽度={_fmt(width)}° · q*={_fmt(selected_q)} · {reason}" + ) + + def _radial_profile_tooltip( + self, + point_id: str, + point: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None, + title: str, + ) -> str: + source = point if isinstance(point, Mapping) else {} + profile_map = profile if isinstance(profile, Mapping) else {} + source_method = _read( + source, + ("source_method",), + _read(profile_map, ("source_method",), _TRACE_METHOD_RADIAL_SECTOR), + ) + reason = self._sector_failure_code(source, profile_map) + return ( + f"{title}\npoint_id={point_id}\nsource_method={source_method}\n" + f"reason={reason}" + ) + + def _annular_profile_title( + self, + profile_id: str, + annulus: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None, + ) -> str: + source = annulus if isinstance(annulus, Mapping) else {} + profile_map = profile if isinstance(profile, Mapping) else {} + q_center = _read(source, ("q_center",), _read(profile_map, ("q_center",), None)) + q_min = _read(source, ("q_min",), _read(profile_map, ("q_min",), None)) + q_max = _read(source, ("q_max",), _read(profile_map, ("q_max",), None)) + peaks = _read(source, ("selected_peaks",), _read(profile_map, ("peak_angles_deg",), ())) + peak_count = ( + len(peaks) + if isinstance(peaks, Sequence) and not isinstance(peaks, (str, bytes)) + else 0 + ) + reason = self._sector_failure_text(source, profile_map) + q_unit = str(_read(profile_map, ("q_unit",), _read(source, ("q_unit",), "q")) or "q") + if q_min not in (None, "") and q_max not in (None, ""): + q_text = f"q=[{_fmt(q_min)}, {_fmt(q_max)}]" + else: + q_text = f"q={_fmt(q_center)}" + if self._language.lower().startswith("en"): + return f"Annular I(χ) · {q_text} {q_unit} · {peak_count} peaks · {reason}" + readable_reason = { + "four_observed_lobes": "四瓣均有支撑", + "partial_or_missing_lobe_support": "部分花瓣未形成连续轨迹", + }.get(reason, reason) + return f"q 环积分 I(χ) · {q_text} {q_unit} · {peak_count} 个峰 · {readable_reason}" + + def _annular_profile_tooltip( + self, + profile_id: str, + annulus: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None, + title: str, + ) -> str: + source = annulus if isinstance(annulus, Mapping) else {} + profile_map = profile if isinstance(profile, Mapping) else {} + q_center = _read(source, ("q_center",), _read(profile_map, ("q_center",), None)) + q_min = _read(source, ("q_min",), _read(profile_map, ("q_min",), None)) + q_max = _read(source, ("q_max",), _read(profile_map, ("q_max",), None)) + status = _read(source, ("status", "reason"), _read(profile_map, ("status", "reason"), "")) + return ( + f"{title}\nprofile_id={profile_id}\n" + f"q_center={_fmt(q_center)}\nq_range=[{_fmt(q_min)}, {_fmt(q_max)}]\n" + f"status={status or '—'}\n" + "Each ring is an angular I(χ) profile; selected peaks are observed candidates." + ) + + def _render_annular_profile( + self, + profile_id: str, + annulus: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None, + ) -> None: + title = self._annular_profile_title(profile_id, annulus, profile) + self.normal_profile._title = "Annular I(χ)" + self.normal_profile.title_label.setText(title) + self.normal_profile.title_label.setToolTip( + self._annular_profile_tooltip(profile_id, annulus, profile, title) + ) + self._selected_ellipse_point = {} + if not isinstance(profile, Mapping): + self.normal_profile.clear( + message=( + "No angular annulus profile; see the ring reason." + if self._language.lower().startswith("en") + else "暂无 q 环方位剖面;请查看该环的状态原因。" + ) + ) + self.ellipse_diagnostic.clear( + "No ellipse-local diagnostic in annular mode" + if self._language.lower().startswith("en") + else "q 环轨迹模式不提供椭圆局部诊断" + ) + return + x, raw = self._series(profile, ("angle_deg", "chi_deg", "chi", "x"), ("raw_intensity", "raw", "intensity")) + _, smoothed = self._series(profile, ("angle_deg", "chi_deg", "chi", "x"), ("smoothed_intensity", "smoothed")) + _, counts = self._series(profile, ("angle_deg", "chi_deg", "chi", "x"), ("counts", "valid_counts")) + _, coverage = self._series(profile, ("angle_deg", "chi_deg", "chi", "x"), ("coverage",)) + series = { + name: values + for name, values in ( + ("raw", raw), + ("smoothed", smoothed), + ("counts", counts), + ("coverage", coverage), + ) + if values + } + marker_values: list[float] = [] + peak_angles = _read(profile, ("peak_angles_deg",), None) + if isinstance(peak_angles, Sequence) and not isinstance(peak_angles, (str, bytes)): + marker_values.extend( + value for value in (_finite(item) for item in peak_angles) if value is not None + ) + if not marker_values and isinstance(annulus, Mapping): + selected = _read(annulus, ("selected_peaks",), ()) + if isinstance(selected, Sequence) and not isinstance(selected, (str, bytes)): + marker_values.extend( + value + for value in ( + _finite(_read(item, ("chi_deg", "angle_deg", "chi"), None)) + if isinstance(item, Mapping) + else _finite(item) + for item in selected + ) + if value is not None + ) + markers = [(value, f"χ={value:g}°") for value in marker_values] + if x and series: + self.normal_profile.set_series( + x, + series, + x_label="χ (deg)" if self._language.lower().startswith("en") else "χ(deg)", + y_label="I(χ)" if self._language.lower().startswith("en") else "I(χ) 强度", + markers=markers, + plot_names=("raw", "smoothed"), + ) + self.normal_profile.title_label.setText(title) + self.normal_profile.title_label.setToolTip( + self._annular_profile_tooltip(profile_id, annulus, profile, title) + ) + if self.normal_profile.plot is not None: + self.normal_profile.plot.getAxis("bottom").enableAutoSIPrefix(False) + self.normal_profile.plot.setAccessibleDescription( + "Angular I(χ) profile; raw and locator-only smoothed intensity; vertical lines mark selected lobe peaks" + if self._language.lower().startswith("en") + else "方位 I(χ) 剖面;显示原始强度和仅用于定位的平滑强度;竖线标记已选瓣峰" + ) + else: + self.normal_profile.clear( + message=( + "Angular annulus profile is empty; see the ring reason." + if self._language.lower().startswith("en") + else "q 环方位剖面为空;请查看该环的状态原因。" + ) + ) + self.normal_profile.title_label.setText(title) + self.normal_profile.title_label.setToolTip( + self._annular_profile_tooltip(profile_id, annulus, profile, title) + ) + self.ellipse_diagnostic.clear( + "No ellipse-local diagnostic in annular mode" + if self._language.lower().startswith("en") + else "q 环轨迹模式不提供椭圆局部诊断" + ) + + def _render_radial_profile( + self, + point_id: str, + point: Mapping[str, Any] | None, + profile: Mapping[str, Any] | None, + ) -> None: + title = self._radial_profile_title(point_id, point, profile) + self.normal_profile._title = "Radial sector I(q)" + self.normal_profile.title_label.setText(title) + self.normal_profile.title_label.setToolTip( + self._radial_profile_tooltip(point_id, point, profile, title) + ) + self._selected_ellipse_point = dict(point or {}) + if not isinstance(profile, Mapping): + self.normal_profile.clear( + message=( + "No radial sector profile; see the point reason." + if self._language.lower().startswith("en") + else "暂无扇区径向剖面;请查看该点的失败原因。" + ) + ) + self.ellipse_diagnostic.clear( + "No ellipse-local diagnostic in radial sector mode" + if self._language.lower().startswith("en") + else "扇区积分模式不提供椭圆局部诊断" + ) + return + x, raw = self._series(profile, ("q",), ("raw_intensity",)) + _, smoothed = self._series(profile, ("q",), ("smoothed_intensity",)) + _, counts = self._series(profile, ("q",), ("counts", "valid_counts")) + _, coverage = self._series(profile, ("q",), ("coverage",)) + series = { + name: values + for name, values in ( + ("raw", raw), + ("smoothed", smoothed), + ("counts", counts), + ("coverage", coverage), + ) + if values + } + selected_q = _finite(_read(profile, ("selected_peak_q",), None)) + if selected_q is None: + selected_q = _finite(_read(point, ("q_star", "selected_peak_q"), None)) + q_unit = str(_read(profile, ("q_unit",), None) or self._frame_data.get("q_unit") or "q") + if x and series: + self.normal_profile.set_series( + x, + series, + x_label=(f"q ({q_unit})" if self._language.lower().startswith("en") else f"q({q_unit})"), + y_label="I(q)" if self._language.lower().startswith("en") else "I(q) 强度", + markers=[] if selected_q is None else [(selected_q, "q*")], + plot_names=("raw", "smoothed"), + ) + self.normal_profile.title_label.setText(title) + self.normal_profile.title_label.setToolTip( + self._radial_profile_tooltip(point_id, point, profile, title) + ) + if self.normal_profile.plot is not None: + self.normal_profile.plot.getAxis("bottom").enableAutoSIPrefix(False) + self.normal_profile.plot.setAccessibleDescription( + "Radial sector I(q); smoothed curve is only used for peak localization" + if self._language.lower().startswith("en") + else "扇区径向 I(q);平滑曲线仅用于定位主峰" + ) + else: + self.normal_profile.clear( + message=( + "Radial sector profile is empty; see the point reason." + if self._language.lower().startswith("en") + else "扇区径向剖面为空;请查看该点的失败原因。" + ) + ) + self.normal_profile.title_label.setText(title) + self.normal_profile.title_label.setToolTip( + self._radial_profile_tooltip(point_id, point, profile, title) + ) + self.ellipse_diagnostic.clear( + "No ellipse-local diagnostic in radial sector mode" + if self._language.lower().startswith("en") + else "扇区积分模式不提供椭圆局部诊断" + ) + def _render_profile(self, point_id: str, point: Mapping[str, Any] | None = None) -> None: + self._selected_profile_point_id = str(point_id) profile = self._profiles.get(point_id) if profile is None: profile = self._profiles.get(str(point_id)) + profile_axis = str(_read(profile, ("profile_axis",), "") or "").strip().lower() + is_annular = profile_axis in {"azimuthal", "angular", "chi"} or ( + self._trace_method() == _TRACE_METHOD_ANNULAR_PEAK + and ( + profile is None + or ( + isinstance(profile, Mapping) + and any( + key in profile + for key in ("angle_deg", "chi_deg", "peak_angles_deg") + ) + ) + ) + ) + if is_annular: + self._render_annular_profile(point_id, point, profile) + return + is_radial = profile_axis == "radial" or ( + self._trace_method() == _TRACE_METHOD_RADIAL_SECTOR + and ( + profile is None + or ( + isinstance(profile, Mapping) + and "q" in profile + and "raw_intensity" in profile + ) + ) + ) + if is_radial: + self._render_radial_profile(point_id, point, profile) + return if not isinstance(profile, Mapping): self.normal_profile.clear() self.ellipse_diagnostic.clear() @@ -2810,15 +4223,9 @@ def apply_to_batch(self) -> None: self.applyToBatchRequested.emit({"analysis": self.analysis_settings, "edits": self.edits}) def set_batch_feedback(self, successes: Sequence[Any] = (), failures: Sequence[Any] = ()) -> None: - success_count = len(list(successes)) - failure_items = list(failures) - if failure_items: - details = "; ".join(str(item) for item in failure_items[:4]) - if len(failure_items) > 4: - details += f" (+{len(failure_items) - 4})" - self.batch_feedback_label.setText(f"Batch applied: {success_count} ok; failures: {details}") - else: - self.batch_feedback_label.setText(f"Batch applied: {success_count} frame(s) ready") + self._batch_success_count = len(list(successes)) + self._batch_failure_items = list(failures) + self._render_batch_feedback() def set_busy(self, busy: bool) -> None: self._busy = bool(busy) @@ -2840,22 +4247,78 @@ def set_job_status( """Route asynchronous worker outcomes into the page-local status.""" state = str(state or "ready").lower() + lifecycle_state = state label = str(kind or "analysis") retain_error = state in {"error", "failed"} or result_ok is False if state == "canceled": state = "cancelled" if result_ok is False: state = "failed" - if state in {"cancelled", "ignored", "stale"} and label in { - "preview", - "optimize", - "measure_geometry", - "refine_geometry", - "trace", - "evaluate", - }: - self._result_fresh = False - self._clear_diagnostic_layers() + # A completed worker may have submitted a fresh diagnostic + # payload whose engineering quality is FAIL. Preserve that + # payload; only an exception/error lifecycle invalidates the old + # measurement. + diagnostic_failure = bool( + result_ok is False + and lifecycle_state in {"completed", "complete", "result"} + ) + detached_job = label in _DETACHED_JOB_KINDS or ( + state in {"cancelled", "canceled"} + and self._detached_status_restore is not None + ) + analysis_job = ( + label in _ANALYSIS_JOB_KINDS + or ( + state in {"cancelled", "ignored", "stale"} + and label in {"cancelled", "canceled", "ignored", "stale"} + ) + ) and not detached_job + if detached_job and state in {"running", "cancelling"} and self._result_fresh: + # MainWindow first sends a generic ``running`` update from + # set_busy(), then the detached export kind. Reconstruct the + # measurement state here so an export error can restore it. + self._detached_status_restore = ( + "result", + str(self._settings.get("stage", "trace")), + self._page_status_error, + ) + elif detached_job and state in { + "cancelled", + "failed", + "error", + "completed", + "complete", + "ready", + } and self._result_fresh and self._detached_status_restore is None: + # A detached exporter may report an error without a preceding + # page-local running callback (for example in a direct test or + # a fast worker failure). Preserve the measurement in that + # case as well. + self._detached_status_restore = ( + "result", + str(self._settings.get("stage", "trace")), + self._page_status_error, + ) + restoring_detached_page = bool( + self._detached_status_restore + and not detached_job + and label == self._detached_status_restore[1] + and state + in {"cancelled", "failed", "error", "completed", "complete", "result", "ready"} + ) + terminal_analysis = analysis_job and state in { + "cancelled", + "ignored", + "stale", + "failed", + "error", + } and not restoring_detached_page and not diagnostic_failure + if terminal_analysis: + # A failed/cancelled analysis invalidates the displayed + # measurement itself. Do not leave q*/L or export controls + # backed by a result that no longer belongs to the current job. + self.clear_result(state=state) + self._sync_action_state() if state in {"running", "cancelling"}: if elapsed_s is not None: self._job_elapsed_s = max(0.0, float(elapsed_s)) @@ -2867,12 +4330,28 @@ def set_job_status( self._job_elapsed_s = None self._job_progress_percent = None self._job_progress_phase = "" - self._page_status_state = { - "error": "failed", - "complete": "completed", - }.get(state, state) - self._page_status_kind = label - self._page_status_error = error if retain_error else None + restored_detached = ( + detached_job + and state in {"cancelled", "failed", "error", "completed", "complete", "ready"} + and self._detached_status_restore + ) + if restored_detached and self._result_fresh: + self._page_status_state, self._page_status_kind, restored_error = ( + self._detached_status_restore + ) + self._page_status_error = restored_error + self._detached_status_restore = None + else: + self._page_status_state = { + "error": "failed", + "complete": "completed", + }.get(state, state) + self._page_status_kind = label + self._page_status_error = error if retain_error else None + if detached_job and state not in {"running", "cancelling"}: + self._detached_status_restore = None + if restoring_detached_page: + self._detached_status_restore = None self._render_page_status() try: QtGui.QAccessible.updateAccessibility( diff --git a/src/butterfly_saxs/ui/figure_export_dialog.py b/src/butterfly_saxs/ui/figure_export_dialog.py new file mode 100644 index 0000000..81537a2 --- /dev/null +++ b/src/butterfly_saxs/ui/figure_export_dialog.py @@ -0,0 +1,811 @@ +"""Settings dialog for the detached butterfly measurement-figure export. + +The dialog deliberately contains no rendering code. It gives the user one +place to choose the paper width, raster resolution and a parent directory, +then emits a small configuration mapping to :class:`MainWindow`. The actual +figure export remains on the worker pool and keeps the frozen-snapshot and +generation guards owned by the main window. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .butterfly_figure_export import new_figure_export_target +from .qt_compat import QT_AVAILABLE, QtCore, QtGui, QtWidgets, require_qt + + +_WIDTHS_MM = (89.0, 183.0) +_DPIS = (300, 600, 1200) + + +def _figure_height_mm(width_mm: float) -> float: + """Return the fixed physical height used by the measurement exporter.""" + + return 150.0 if float(width_mm) == 89.0 else 94.0 + + +def _pixel_size(width_mm: float, dpi: int) -> tuple[int, int]: + """Return the exporter-compatible raster dimensions.""" + + height_mm = _figure_height_mm(width_mm) + return ( + int(float(width_mm) / 25.4 * int(dpi)), + int(height_mm / 25.4 * int(dpi)), + ) + + +if QT_AVAILABLE: + + class _PaperRatioPreview(QtWidgets.QWidget): + """Small static paper-ratio cue; this is not a rendered figure preview.""" + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self._width_mm = 183.0 + self._height_mm = _figure_height_mm(self._width_mm) + self.setMinimumHeight(76) + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.setAccessibleName("Paper aspect ratio preview") + self.setAccessibleDescription( + "Static paper ratio cue; it is not a rendered measurement figure." + ) + + def set_width_mm(self, width_mm: float) -> None: + self._width_mm = float(width_mm) + self._height_mm = _figure_height_mm(self._width_mm) + self.update() + + def paintEvent(self, event: Any) -> None: # noqa: N802 + del event + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.fillRect(self.rect(), QtGui.QColor("#f6f8fa")) + ratio = self._width_mm / max(self._height_mm, 1.0) + margin = 10.0 + available_width = max(1.0, float(self.width()) - 2.0 * margin) + available_height = max(1.0, float(self.height()) - 2.0 * margin) + width = min(available_width, available_height * ratio) + height = width / ratio + x = (float(self.width()) - width) / 2.0 + y = (float(self.height()) - height) / 2.0 + paper = QtCore.QRectF(x, y, width, height) + painter.setPen(QtGui.QPen(QtGui.QColor("#667784"), 1.0)) + painter.setBrush(QtGui.QBrush(QtGui.QColor("#ffffff"))) + painter.drawRect(paper) + painter.setPen(QtGui.QColor("#536572")) + painter.drawText( + paper, + int(QtCore.Qt.AlignmentFlag.AlignCenter), + f"{self._width_mm:g} × {self._height_mm:g} mm", + ) + painter.end() + + + class FigureExportDialog(QtWidgets.QDialog): + """Single-step settings and status window for a figure export. + + ``startRequested`` is emitted with ``parent``, ``width_mm`` and + ``dpi``. The main window owns the worker lifecycle and calls + :meth:`set_exported_paths`, :meth:`set_export_error` or + :meth:`set_export_cancelled` when that lifecycle completes. + """ + + startRequested = QtCore.Signal(object) + cancelRequested = QtCore.Signal() + openRequested = QtCore.Signal(object) + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + *, + language: str = "en", + q_unit: str = "unknown", + stage: str = "trace", + quality_status: str = "unknown", + measurement_status: str = "unknown", + scientific_status: str | None = None, + scientific_acceptance: str = "not_assessed", + has_qx: bool = True, + has_qy: bool = True, + default_parent: str | Path | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("figureExportDialog") + self.setWindowModality(QtCore.Qt.WindowModality.NonModal) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, False) + self.setSizeGripEnabled(True) + self._language = str(language or "en") + self._q_unit = str(q_unit or "unknown") + self._stage = str(stage or "trace") + self._quality_status = str(quality_status or "unknown") + self._measurement_status = str(measurement_status or "unknown") + self._scientific_status = str( + scientific_status or scientific_acceptance or "not_assessed" + ) + self._scientific_acceptance = str( + scientific_acceptance or "not_assessed" + ) + self._has_qx = bool(has_qx) + self._has_qy = bool(has_qy) + self._default_parent = ( + Path(default_parent).expanduser().resolve() + if default_parent + else None + ) + self._running = False + self._exported_paths: dict[str, Path] = {} + self._last_error: str | None = None + self._status_key = "idle" + self._status_detail = "" + self._status_error = False + self._export_stale = False + self._export_source_context: dict[str, Any] = {} + self._build_ui() + self._retranslate_ui() + if self._default_parent is not None: + self.parent_dir_edit.setText(str(self._default_parent)) + else: + self._update_target_preview() + self._resize_for_screen() + + # ---- public state ------------------------------------------------- + + @property + def language(self) -> str: + return self._language + + @property + def is_running(self) -> bool: + return self._running + + @property + def exported_paths(self) -> dict[str, Path]: + return dict(self._exported_paths) + + @property + def has_exported_result(self) -> bool: + return bool(self._exported_paths) + + @property + def export_is_stale(self) -> bool: + return bool(self._export_stale) + + def settings(self) -> dict[str, Any]: + """Return validated user settings without creating an output dir.""" + + parent_text = self.parent_dir_edit.text().strip() + parent = Path(parent_text).expanduser().resolve() + if not parent_text: + raise ValueError(self._text("Choose an output parent folder.", "请选择输出父目录。")) + if not parent.is_dir(): + raise ValueError( + self._text( + "The output parent folder does not exist.", + "输出父目录不存在。", + ) + ) + width_mm = float(self.width_combo.currentData()) + dpi = int(self.dpi_combo.currentData()) + return { + "parent": parent, + "width_mm": width_mm, + "dpi": dpi, + } + + def set_language(self, language: str) -> None: + self._language = str(language or "en") + self._retranslate_ui() + + def set_measurement_context(self, context: Mapping[str, Any]) -> None: + """Refresh display-only source state after the page changed.""" + + values = dict(context) + self._q_unit = str(values.get("q_unit", self._q_unit) or "unknown") + self._stage = str(values.get("stage", self._stage) or "trace") + self._quality_status = str( + values.get("quality_status", self._quality_status) or "unknown" + ) + self._measurement_status = str( + values.get("measurement_status", self._measurement_status) or "unknown" + ) + self._scientific_status = str( + values.get("scientific_status", self._scientific_status) + or "not_assessed" + ) + self._has_qx = bool(values.get("has_qx", self._has_qx)) + self._has_qy = bool(values.get("has_qy", self._has_qy)) + self._retranslate_ui() + + def set_context_changed(self) -> None: + """Stop a just-requested start when the source changed meanwhile.""" + + self._running = False + self.set_export_running(False) + self._set_status(key="context_changed", error=True) + + def set_export_running(self, running: bool = True) -> None: + self._running = bool(running) + for widget in ( + self.parent_dir_edit, + self.browse_button, + self.width_combo, + self.dpi_combo, + self.start_button, + ): + widget.setEnabled(not self._running) + self.cancel_button.setEnabled(True) + if self._running: + self.open_button.setEnabled(False) + self._set_status(key="running") + self.cancel_button.setText(self._text("Cancel export", "取消导出")) + else: + self.cancel_button.setText(self._text("Close", "关闭")) + + def set_cancelling(self) -> None: + self._running = True + self.start_button.setEnabled(False) + self.cancel_button.setEnabled(False) + self._set_status(key="cancelling") + + def set_exported_paths( + self, + paths: Mapping[str, str | Path], + *, + source_context: Mapping[str, Any] | None = None, + stale: bool = False, + ) -> None: + self._running = False + self._exported_paths = { + str(name): Path(path).expanduser().resolve() + for name, path in paths.items() + } + self._export_stale = bool(stale) + self._export_source_context = dict(source_context or {}) + self.set_export_running(False) + index = self._exported_paths.get("index") + if index is None: + manifest = self._exported_paths.get("manifest") + index = manifest.parent / "index.html" if manifest is not None else None + has_index = bool(index is not None and index.is_file()) + self.open_button.setEnabled(has_index) + self.open_button.setProperty("packagePath", str(index) if index else "") + self.open_button.setProperty("snapshotStale", self._export_stale) + self._render_snapshot_context() + self._set_status(key="stale_complete" if self._export_stale else "complete") + + def set_export_error(self, error: Any) -> None: + self._last_error = str(error) + self._running = False + self.set_export_running(False) + self._update_target_preview() + self._set_status(key="error", detail=self._last_error, error=True) + + def set_export_cancelled(self) -> None: + self._running = False + self.set_export_running(False) + self._set_status(key="cancelled") + + def mark_export_stale(self, current_context: Mapping[str, Any] | None = None) -> None: + """Mark a completed bundle as belonging to an older page snapshot.""" + + if not self._exported_paths: + return + self._export_stale = True + self.open_button.setProperty("snapshotStale", True) + self._render_snapshot_context(current_context=current_context) + self._set_status(key="stale_complete") + + # ---- construction ------------------------------------------------- + + def _build_ui(self) -> None: + self.setStyleSheet( + "#figureExportDialog QPushButton, #figureExportDialog QComboBox, " + "#figureExportDialog QLineEdit { min-height: 28px; }" + ) + outer = QtWidgets.QVBoxLayout(self) + outer.setContentsMargins(12, 10, 12, 10) + outer.setSpacing(8) + + self.title_label = QtWidgets.QLabel() + self.title_label.setObjectName("figureExportTitle") + self.title_label.setStyleSheet("font-size: 17px; font-weight: 600;") + outer.addWidget(self.title_label) + self.subtitle_label = QtWidgets.QLabel() + self.subtitle_label.setObjectName("figureExportSubtitle") + self.subtitle_label.setWordWrap(True) + outer.addWidget(self.subtitle_label) + + scroll = QtWidgets.QScrollArea(self) + scroll.setObjectName("figureExportSettingsScroll") + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + content = QtWidgets.QWidget(scroll) + content.setObjectName("figureExportSettingsContent") + content_layout = QtWidgets.QVBoxLayout(content) + content_layout.setContentsMargins(2, 2, 2, 2) + content_layout.setSpacing(8) + + self.form = QtWidgets.QFormLayout() + self.form.setFieldGrowthPolicy( + QtWidgets.QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + self.form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + content_layout.addLayout(self.form) + + self.parent_dir_edit = QtWidgets.QLineEdit(content) + self.parent_dir_edit.setObjectName("figureExportParentDirectory") + self.parent_dir_edit.setClearButtonEnabled(True) + self.parent_dir_edit.textChanged.connect(self._update_target_preview) + self.browse_button = QtWidgets.QPushButton(content) + self.browse_button.setObjectName("figureExportBrowseButton") + self.browse_button.clicked.connect(self._browse_parent) + parent_row = QtWidgets.QWidget(content) + parent_layout = QtWidgets.QHBoxLayout(parent_row) + parent_layout.setContentsMargins(0, 0, 0, 0) + parent_layout.addWidget(self.parent_dir_edit, 1) + parent_layout.addWidget(self.browse_button) + self._parent_label = QtWidgets.QLabel(parent_row) + self._parent_label.setBuddy(self.parent_dir_edit) + self.form.addRow(self._parent_label, parent_row) + + self.target_preview_label = QtWidgets.QLabel(content) + self.target_preview_label.setObjectName("figureExportTargetPreview") + self.target_preview_label.setWordWrap(True) + self.target_preview_label.setTextInteractionFlags( + QtCore.Qt.TextInteractionFlag.TextSelectableByMouse + ) + self._target_label = QtWidgets.QLabel(content) + self._target_label.setBuddy(self.parent_dir_edit) + self.form.addRow(self._target_label, self.target_preview_label) + + self.width_combo = QtWidgets.QComboBox(content) + self.width_combo.setObjectName("figureExportWidthCombo") + self.width_combo.addItem("", 89.0) + self.width_combo.addItem("", 183.0) + self.width_combo.setCurrentIndex(1) + self.width_combo.currentIndexChanged.connect(self._controls_changed) + self._width_label = QtWidgets.QLabel(content) + self._width_label.setBuddy(self.width_combo) + self.form.addRow(self._width_label, self.width_combo) + # Compatibility alias for callers that call this a preset control. + self.width_preset = self.width_combo + + self.dpi_combo = QtWidgets.QComboBox(content) + self.dpi_combo.setObjectName("figureExportDpiCombo") + for dpi in _DPIS: + self.dpi_combo.addItem(f"{dpi} dpi", dpi) + self.dpi_combo.setCurrentIndex(1) + self.dpi_combo.currentIndexChanged.connect(self._controls_changed) + self._dpi_label = QtWidgets.QLabel(content) + self._dpi_label.setBuddy(self.dpi_combo) + self.form.addRow(self._dpi_label, self.dpi_combo) + + self.size_label = QtWidgets.QLabel(content) + self.size_label.setObjectName("figureExportSizeLabel") + self.size_label.setWordWrap(True) + self.form.addRow(QtWidgets.QLabel(content), self.size_label) + self._size_label = self.form.labelForField(self.size_label) + + content_layout.addWidget(self._make_preview_group(content)) + + self.state_group = QtWidgets.QGroupBox(content) + self.state_group.setObjectName("figureExportStateGroup") + state_layout = QtWidgets.QFormLayout(self.state_group) + self.q_unit_label = QtWidgets.QLabel() + self.stage_label = QtWidgets.QLabel() + self.quality_label = QtWidgets.QLabel() + self.scientific_label = QtWidgets.QLabel() + self.coordinates_label = QtWidgets.QLabel() + for value in ( + self.q_unit_label, + self.stage_label, + self.quality_label, + self.scientific_label, + self.coordinates_label, + ): + value.setWordWrap(True) + self._q_unit_caption = QtWidgets.QLabel(self.state_group) + self._stage_caption = QtWidgets.QLabel(self.state_group) + self._quality_caption = QtWidgets.QLabel(self.state_group) + self._scientific_caption = QtWidgets.QLabel(self.state_group) + self._coordinates_caption = QtWidgets.QLabel(self.state_group) + self._snapshot_caption = QtWidgets.QLabel(self.state_group) + self.snapshot_label = QtWidgets.QLabel() + state_layout.addRow(self._q_unit_caption, self.q_unit_label) + state_layout.addRow(self._stage_caption, self.stage_label) + state_layout.addRow(self._quality_caption, self.quality_label) + state_layout.addRow(self._scientific_caption, self.scientific_label) + state_layout.addRow(self._coordinates_caption, self.coordinates_label) + state_layout.addRow(self._snapshot_caption, self.snapshot_label) + self.snapshot_label.setObjectName("figureExportSnapshotLabel") + self.snapshot_label.setWordWrap(True) + content_layout.addWidget(self.state_group) + + self.notes_label = QtWidgets.QLabel(content) + self.notes_label.setObjectName("figureExportNotes") + self.notes_label.setWordWrap(True) + self.notes_label.setTextInteractionFlags( + QtCore.Qt.TextInteractionFlag.TextSelectableByMouse + ) + content_layout.addWidget(self.notes_label) + content_layout.addStretch(1) + scroll.setWidget(content) + outer.addWidget(scroll, 1) + + self.status_label = QtWidgets.QLabel() + self.status_label.setObjectName("figureExportStatus") + self.status_label.setWordWrap(True) + outer.addWidget(self.status_label) + buttons = QtWidgets.QHBoxLayout() + buttons.addStretch(1) + self.open_button = QtWidgets.QPushButton() + self.open_button.setObjectName("openFigureExportButton") + self.open_button.setEnabled(False) + self.open_button.clicked.connect(self._open_exported_package) + buttons.addWidget(self.open_button) + self.cancel_button = QtWidgets.QPushButton() + self.cancel_button.setObjectName("cancelFigureExportButton") + self.cancel_button.clicked.connect(self._cancel_or_close) + buttons.addWidget(self.cancel_button) + self.start_button = QtWidgets.QPushButton() + self.start_button.setObjectName("startFigureExportButton") + self.start_button.setDefault(True) + self.start_button.clicked.connect(self._request_start) + buttons.addWidget(self.start_button) + outer.addLayout(buttons) + + def _make_preview_group(self, parent: QtWidgets.QWidget) -> QtWidgets.QGroupBox: + group = QtWidgets.QGroupBox(parent) + group.setObjectName("figureExportPaperGroup") + layout = QtWidgets.QVBoxLayout(group) + self.paper_preview = _PaperRatioPreview(group) + layout.addWidget(self.paper_preview) + self._preview_caption = QtWidgets.QLabel(group) + self._preview_caption.setWordWrap(True) + layout.addWidget(self._preview_caption) + return group + + # ---- interaction -------------------------------------------------- + + def _text(self, english: str, chinese: str) -> str: + return english if self._language.lower().startswith("en") else chinese + + def _browse_parent(self) -> None: + chosen = QtWidgets.QFileDialog.getExistingDirectory( + self, + self._text("Choose output parent folder", "选择输出父目录"), + self.parent_dir_edit.text().strip(), + ) + if chosen: + self.parent_dir_edit.setText(chosen) + + def _controls_changed(self, *_: Any) -> None: + width_mm = float(self.width_combo.currentData()) + dpi = int(self.dpi_combo.currentData()) + self.paper_preview.set_width_mm(width_mm) + pixels = _pixel_size(width_mm, dpi) + self.size_label.setText( + self._text( + f"Physical size: {width_mm:g} × {_figure_height_mm(width_mm):g} mm\n" + f"Raster size: {pixels[0]:,} × {pixels[1]:,} px at {dpi} dpi", + f"物理尺寸:{width_mm:g} × {_figure_height_mm(width_mm):g} mm\n" + f"栅格尺寸:{pixels[0]:,} × {pixels[1]:,} px({dpi} dpi)", + ) + ) + self._update_target_preview() + + def _update_target_preview(self, *_: Any) -> None: + parent_text = self.parent_dir_edit.text().strip() + if not parent_text: + self.target_preview_label.setText( + self._text( + "A new subfolder will be created after you choose a parent folder.", + "选择父目录后将创建新的子目录。", + ) + ) + return + parent = Path(parent_text).expanduser() + try: + target = new_figure_export_target(parent) + except (OSError, RuntimeError, ValueError): + target = parent / "butterfly-figure" + self.target_preview_label.setText( + self._text( + f"New non-overwriting bundle: {target}", + f"新建且不覆盖已有内容的图包目录:{target}", + ) + ) + + def _request_start(self) -> None: + try: + values = self.settings() + except (OSError, TypeError, ValueError) as exc: + self._set_status(key="error", detail=str(exc), error=True) + self.parent_dir_edit.setFocus() + return + self.set_export_running(True) + self.startRequested.emit(values) + + def _cancel_or_close(self) -> None: + if self._running: + self.set_cancelling() + self.cancelRequested.emit() + return + self.reject() + + def _open_exported_package(self) -> None: + path = self.open_button.property("packagePath") + if not path: + return + self.openRequested.emit(Path(str(path))) + + def closeEvent(self, event: Any) -> None: # noqa: N802 + if self._running: + self.set_cancelling() + self.cancelRequested.emit() + event.ignore() + return + event.accept() + + def _set_status( + self, + text: str | None = None, + *, + key: str | None = None, + detail: Any = "", + error: bool = False, + ) -> None: + """Store a language-neutral status and render it in the active locale.""" + + if key is not None: + self._status_key = str(key) + if text is not None: + self._status_detail = str(text) + elif detail not in (None, ""): + self._status_detail = str(detail) + elif key in {"running", "cancelling", "complete", "stale_complete", "cancelled"}: + self._status_detail = "" + self._status_error = bool(error) + self._render_status() + + def _render_status(self) -> None: + key = self._status_key + detail = self._status_detail + if key == "running": + text = self._text("Export is running…", "正在后台导出…") + elif key == "cancelling": + text = self._text( + "Cancelling; waiting for the worker…", + "正在取消,等待后台任务结束…", + ) + elif key == "complete": + text = self._text( + "Export complete. Scientific status is recorded only; acceptance is not inferred.", + "导出完成;科学状态仅作记录,不据此认定科学接纳。", + ) + elif key == "stale_complete": + text = self._text( + "Export complete for a previous frame/settings snapshot; the bundle is not tagged as the current measurement.", + "导出完成,但图包属于此前的帧/设置快照;不会标记为当前测量结果。", + ) + elif key == "cancelled": + text = self._text("Export cancelled.", "导出已取消。") + elif key == "context_changed": + text = self._text( + "The measurement changed; settings were refreshed. Review them and start again.", + "测量数据已变化;设置已刷新。请检查后再次开始导出。", + ) + elif key == "error": + text = self._text("Export failed: ", "导出失败:") + detail + else: + text = detail + self.status_label.setText(str(text)) + self.status_label.setStyleSheet( + "color: #9b1c1c;" if self._status_error else "color: #3b4b57;" + ) + self.status_label.setToolTip(str(text)) + + def _render_snapshot_context( + self, *, current_context: Mapping[str, Any] | None = None + ) -> None: + context = dict(self._export_source_context) + source = context.get("source") or context.get("frame") or "unknown" + frame = context.get("frame") + dataset = context.get("dataset") + parts = [f"source={source}"] + if frame not in (None, "") and str(frame) != str(source): + parts.append(f"frame={frame}") + if dataset not in (None, ""): + parts.append(f"dataset={dataset}") + if context.get("q_unit") not in (None, ""): + parts.append(f"q_unit={context['q_unit']}") + analysis = context.get("analysis") + if isinstance(analysis, Mapping): + butterfly = analysis.get("butterfly") + stage = ( + butterfly.get("stage") + if isinstance(butterfly, Mapping) + else analysis.get("stage") + ) + if stage not in (None, ""): + parts.append(f"stage={stage}") + if context.get("figure_width_mm") not in (None, ""): + parts.append(f"width={context['figure_width_mm']} mm") + if context.get("figure_dpi") not in (None, ""): + parts.append(f"dpi={context['figure_dpi']}") + snapshot = "; ".join(str(item) for item in parts) + if self._export_stale: + prefix = self._text("Previous export snapshot: ", "此前导出快照:") + current = "" + if current_context: + current_source = current_context.get("source") or current_context.get("frame") + if current_source not in (None, ""): + current = self._text( + f"; current page source={current_source}", + f";当前页面来源={current_source}", + ) + text = prefix + snapshot + current + else: + text = self._text("Export snapshot: ", "导出快照:") + snapshot + self.snapshot_label.setText(text) + self.snapshot_label.setToolTip(text) + + def _resize_for_screen(self) -> None: + screen = QtGui.QGuiApplication.primaryScreen() + available = screen.availableGeometry() if screen else QtCore.QRect(0, 0, 1280, 800) + # The settings body is scrollable, so the dialog follows a small + # screen instead of imposing a minimum that would be clipped. + width = max(1, min(680, available.width() - 24)) + height = max(1, min(760, available.height() - 32)) + self.resize(width, height) + + # ---- language ----------------------------------------------------- + + def _retranslate_ui(self) -> None: + english = self._language.lower().startswith("en") + self.setWindowTitle( + "Export butterfly measurement figure" if english else "导出蝴蝶测量图" + ) + self.title_label.setText( + "Butterfly measurement figure" if english else "蝴蝶花样测量图" + ) + self.subtitle_label.setText( + "One export setup · rendering runs in the background" + if english + else "一次设置完成导出 · 高分辨率绘图在后台运行" + ) + self.browse_button.setText("Browse…" if english else "浏览…") + self._parent_label.setText("Output parent" if english else "输出父目录") + self._target_label.setText("New bundle" if english else "新图包目录") + self._width_label.setText("Figure width" if english else "图稿宽度") + self._dpi_label.setText("Raster resolution" if english else "栅格分辨率") + self._size_label.setText("Size" if english else "尺寸") + self.width_combo.setItemText( + 0, "Single column · 89 mm" if english else "单栏 · 89 mm" + ) + self.width_combo.setItemText( + 1, "Double column · 183 mm" if english else "双栏 · 183 mm" + ) + for index, dpi in enumerate(_DPIS): + self.dpi_combo.setItemText(index, f"{dpi} dpi") + self._preview_caption.setText( + "Static paper-ratio cue only; this is not a rendered preview." + if english + else "这里只显示纸面比例示意,不是真实绘图预览。" + ) + self.state_group.setTitle( + "Current measurement state" if english else "当前测量状态" + ) + self._q_unit_caption.setText("q unit" if english else "q 单位") + self._stage_caption.setText("workflow" if english else "流程阶段") + self._quality_caption.setText("engineering quality" if english else "工程质量") + self._scientific_caption.setText( + "scientific status" if english else "科学状态" + ) + self._coordinates_caption.setText("coordinates" if english else "坐标") + self._snapshot_caption.setText( + "export source snapshot" if english else "导出来源快照" + ) + self.q_unit_label.setText(self._q_unit_display()) + self.stage_label.setText(self._stage_display()) + self.quality_label.setText( + f"{self._measurement_status} / {self._quality_status}" + ) + scientific_status = self._scientific_status.strip() or "not_assessed" + if scientific_status.lower() in {"not_assessed", "unknown", "none"}: + self.scientific_label.setText( + "Not assessed; export does not mean accepted" + if english + else "未评估;导出不等于科学接纳" + ) + else: + self.scientific_label.setText( + f"{scientific_status} · acceptance is never inferred" + if english + else f"{scientific_status} · 不会据此推断科学接纳" + ) + self.coordinates_label.setText(self._coordinates_display()) + self.notes_label.setText( + "Outputs include editable-text SVG and PDF, raster PNG/TIFF, source_data.npz, and provenance files. " + "Figure colors use the exporter-wide 0.5th–99.5th percentile of valid pixels; the main-view percentile control is not copied into this figure." + if english + else "输出包含可编辑文字的 SVG/PDF、PNG/TIFF 栅格图、source_data.npz 源数据和来源记录。" + "图稿统一使用有效像素第 0.5–99.5 百分位颜色范围;主图分位数控件不会完整沿用。" + ) + self.open_button.setText( + ( + "Open previous-snapshot bundle" + if self._export_stale and english + else "打开此前快照图包" + if self._export_stale + else "Open exported figure bundle" + if english + else "打开已导出图包" + ) + ) + if not self._running: + self.cancel_button.setText("Close" if english else "关闭") + self.start_button.setText( + "Start background export" if english else "开始后台导出" + ) + self._controls_changed() + self._render_snapshot_context() + self._render_status() + + def _q_unit_display(self) -> str: + unit = self._q_unit.strip() or "unknown" + if unit.lower() in {"unknown", "pixel-q", "pixel_q", "pixel", "px"}: + return self._text( + f"{unit} · no physical q calibration (diagnostic export allowed)", + f"{unit} · 无物理 q 标定(仍允许导出诊断图)", + ) + return unit + + def _stage_display(self) -> str: + normalized = self._stage.strip().lower() + label = "Evaluate" if normalized == "evaluate" else "Trace" + return label if self._language.lower().startswith("en") else ( + "评估" if normalized == "evaluate" else "追踪" + ) + + def _coordinates_display(self) -> str: + missing = [] + if not self._has_qx: + missing.append("qx") + if not self._has_qy: + missing.append("qy") + if not missing: + return self._text("qx/qy available", "qx/qy 已提供") + values = ", ".join(missing) + return self._text( + f"Missing {values}; the worker will report a readable export error.", + f"缺少 {values};后台导出会返回可读的错误提示。", + ) + + +else: + + class FigureExportDialog: # pragma: no cover - exercised without Qt only + """Import-safe placeholder when the optional Qt UI is unavailable.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + require_qt() + + +__all__ = [ + "FigureExportDialog", + "_DPIS", + "_WIDTHS_MM", + "_figure_height_mm", + "_pixel_size", +] diff --git a/src/butterfly_saxs/ui/main_window.py b/src/butterfly_saxs/ui/main_window.py index 6d46901..6ecbbef 100644 --- a/src/butterfly_saxs/ui/main_window.py +++ b/src/butterfly_saxs/ui/main_window.py @@ -29,6 +29,7 @@ from .qt_compat import QT_AVAILABLE, QtCore, QtGui, QtWidgets, require_qt from .butterfly_workbench import ButterflyWorkbench from .butterfly_figure_export import new_figure_export_target, run_butterfly_figure_export +from .figure_export_dialog import FigureExportDialog from .qspace import overlay_ring_radius, overlay_uses_first_order_ring from .views import PLOT_AVAILABLE, ViewGrid, _disable_auto_si_prefix from .workers import AnalysisWorker, GenerationGuard @@ -635,6 +636,8 @@ def first_order_ring_overlay(payload: Mapping[str, Any] | None) -> list[dict[str def _result_has_failure(result: Any) -> bool: """Return whether a result carries an explicit failure condition.""" + if _read(result, ("cancelled",), False) is True: + return True if _is_butterfly_trace_result(result): # Trace deliberately has no fitted ellipse/uncertainty candidate yet; # its ``not_fitted``/quality fields are NOT_EVALUATED state, not a job @@ -687,6 +690,22 @@ def _result_has_failure(result: Any) -> bool: ) +_BATCH_SUCCESS_STATUSES = frozenset({"ok", "success", "completed"}) + + +def _batch_records_have_failure(records: Any) -> bool: + """Return whether any batch record is explicitly unsuccessful.""" + + for record in _sequence(records): + if isinstance(record, Mapping): + status = str(record.get("status", "ok") or "ok").casefold() + if status not in _BATCH_SUCCESS_STATUSES: + return True + if _result_has_failure(record): + return True + return False + + def _new_fit_session() -> dict[str, Any]: """Return the small, JSON-friendly manual-review session state.""" @@ -902,6 +921,16 @@ def __init__( self._last_evidence_paths: dict[str, Path] = {} self._last_butterfly_figure_paths: dict[str, Path] = {} self._figure_export_result_revisions: dict[int, int] = {} + self._figure_export_result_guards: dict[int, tuple[Any, ...]] = {} + self._figure_export_result_contexts: dict[int, dict[str, Any]] = {} + self._figure_export_page_status: dict[int, tuple[str, str, Any]] = {} + self._figure_export_dialog: FigureExportDialog | None = None + self._figure_export_dialog_guard: tuple[Any, ...] | None = None + self._figure_export_dialog_stale_timer = QtCore.QTimer(self) + self._figure_export_dialog_stale_timer.setInterval(250) + self._figure_export_dialog_stale_timer.timeout.connect( + self._check_figure_export_dialog_staleness + ) self._last_error: str | None = None self._fit_ridge_points: Any = [] self._rejected_ridge_points: list[Any] = [] @@ -950,6 +979,9 @@ def __init__( self._build_evolution_page() self._build_status_bar() initial_analysis = dict(analysis_settings or self._analysis_settings) + if analysis_settings is not None and isinstance(initial_analysis.get("butterfly"), Mapping): + initial_analysis["butterfly"] = dict(initial_analysis["butterfly"]) + initial_analysis["butterfly"].setdefault("trace_method", "curvature") self.set_analysis_settings(initial_analysis, trigger_preview=False) self._debounce_timer = QtCore.QTimer(self) @@ -1245,55 +1277,173 @@ def _on_butterfly_figure_export_requested(self) -> None: if not page.result_fresh: self._set_status("status.evidence_stale", flags="butterfly_figure_stale") return - parent = QtWidgets.QFileDialog.getExistingDirectory( - self, - self._tr("dialog.export_butterfly_figure"), - "", - ) - if not parent: + dialog = self._figure_export_dialog + if dialog is not None and dialog.isVisible(): + dialog.raise_() + dialog.activateWindow() return - choices = [ - self._tr("figure.single_column"), - self._tr("figure.double_column"), - ] - choice, accepted = QtWidgets.QInputDialog.getItem( + context = self._butterfly_figure_dialog_context() + dialog = FigureExportDialog( self, - self._tr("dialog.export_butterfly_figure"), - self._tr("dialog.figure_export_width"), - choices, - 1, - False, + language=self._language, + **context, ) - if not accepted: + self._figure_export_dialog = dialog + self._figure_export_dialog_guard = self._butterfly_figure_export_guard() + dialog.startRequested.connect(self._on_figure_export_dialog_start) + dialog.cancelRequested.connect(self._on_figure_export_dialog_cancel) + dialog.openRequested.connect(self._open_butterfly_figure_package) + dialog.finished.connect( + lambda _result: self._figure_export_dialog_stale_timer.stop() + ) + dialog.show() + dialog.raise_() + dialog.activateWindow() + self._figure_export_dialog_stale_timer.start() + + def _butterfly_figure_dialog_context(self) -> dict[str, Any]: + """Return display-only state for the single figure-export dialog.""" + + page = self.butterfly_workbench + # This dialog only reads a handful of scalar statuses. Avoid the + # page property's defensive deep copy here; a result may contain + # large detector arrays and opening a settings window must stay + # responsive. + result = getattr(page, "_result", {}) if page.result_fresh else {} + if not isinstance(result, Mapping): + result = {} + quality = result.get("quality") + if not isinstance(quality, Mapping): + quality = {} + metrics = quality.get("metrics") + if not isinstance(metrics, Mapping): + metrics = {} + settings = page.butterfly_settings + if not isinstance(settings, Mapping): + settings = {} + stage = settings.get("stage") or result.get("stage") or "trace" + quality_status = ( + quality.get("status") + or result.get("quality_status") + or result.get("engineering_status") + or metrics.get("status") + or "unknown" + ) + scientific_status = ( + quality.get("scientific_status") + or quality.get("scientific_acceptance") + or result.get("scientific_status") + or result.get("scientific_acceptance") + or "not_assessed" + ) + measurement_status = ( + result.get("measurement_status") + or result.get("status") + or "unknown" + ) + frame_data = getattr(page, "_frame_data", {}) + if not isinstance(frame_data, Mapping): + frame_data = {} + return { + "q_unit": self._active_q_unit(result), + "stage": str(stage), + "quality_status": str(quality_status), + "measurement_status": str(measurement_status), + # Never infer scientific acceptance from engineering status. + "scientific_status": str(scientific_status), + "has_qx": frame_data.get("qx") is not None, + "has_qy": frame_data.get("qy") is not None, + } + + def _butterfly_figure_export_guard(self) -> tuple[Any, ...]: + """Identify the current result and settings for the modeless dialog.""" + + page = self.butterfly_workbench + try: + fit_signature: Any = self._fit_state_signature() + except (TypeError, ValueError, RuntimeError): + fit_signature = None + result = getattr(page, "_result", {}) if page.result_fresh else {} + return (page.result_revision, fit_signature, self._active_q_unit(result)) + + def _check_figure_export_dialog_staleness(self) -> None: + """Mark a completed bundle when the modeless page moves on.""" + + dialog = self._figure_export_dialog + if ( + dialog is None + or not dialog.isVisible() + or dialog.is_running + or not dialog.has_exported_result + ): return - width_mm = 89.0 if choice == choices[0] else 183.0 - dpi, accepted = QtWidgets.QInputDialog.getInt( - self, - self._tr("dialog.export_butterfly_figure"), - self._tr("dialog.figure_export_dpi"), - 600, - 72, - 2400, - 50, - ) - if not accepted: + current_guard = self._butterfly_figure_export_guard() + if self._figure_export_dialog_guard == current_guard: + return + current_context = self._butterfly_figure_dialog_context() + # Refresh the displayed measurement state first. The completed + # bundle keeps its original source_context inside the dialog, so + # the stale marker can still distinguish old export from current + # page state while a subsequent Start uses the new snapshot. + dialog.set_measurement_context(current_context) + dialog.mark_export_stale(current_context) + self._figure_export_dialog_guard = current_guard + + def _on_figure_export_dialog_start(self, values: Any) -> None: + """Freeze current inputs and queue the dialog's one-click export.""" + + dialog = self._figure_export_dialog + if dialog is None: return - target = new_figure_export_target(parent) + if not isinstance(values, Mapping): + dialog.set_export_error("invalid figure export settings") + return + current_guard = self._butterfly_figure_export_guard() + if self._figure_export_dialog_guard != current_guard: + dialog.set_measurement_context(self._butterfly_figure_dialog_context()) + self._figure_export_dialog_guard = current_guard + dialog.set_context_changed() + return + page = self.butterfly_workbench try: - snapshot = page.figure_export_snapshot(context=self._butterfly_export_context()) + parent = Path(values["parent"]).expanduser().resolve() + target = new_figure_export_target(parent) + snapshot = page.figure_export_snapshot( + context=self._butterfly_export_context() + ) self._start_butterfly_figure_export( target, snapshot, - width_mm=width_mm, - dpi=dpi, + width_mm=float(values["width_mm"]), + dpi=int(values["dpi"]), ) except (FileExistsError, OSError, RuntimeError, TypeError, ValueError) as exc: + dialog.set_export_error(exc) self._set_status( "status.butterfly_figure_export_failed", flags="butterfly_figure_export_error", error=exc, ) + def _on_figure_export_dialog_cancel(self) -> None: + """Route the dialog's cancel action through the shared worker gate.""" + + if self._workers: + self.cancel_jobs() + elif self._figure_export_dialog is not None: + self._figure_export_dialog.set_export_cancelled() + + def _open_butterfly_figure_package(self, path: Any) -> None: + """Open a completed local index only after an explicit user click.""" + + try: + chosen = Path(path).expanduser().resolve() + except (TypeError, ValueError, OSError): + return + if not chosen.is_file(): + return + QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(chosen))) + def _start_butterfly_figure_export( self, target: str | Path, @@ -1310,6 +1460,15 @@ def _start_butterfly_figure_export( result_revision = int(snapshot.get("result_revision", -1)) if not page.result_fresh or page.result_revision != result_revision: raise ValueError("the butterfly result changed before figure export started") + page_status_snapshot = ( + str(getattr(page, "_page_status_state", "result")), + str(getattr(page, "_page_status_kind", "")), + getattr(page, "_page_status_error", None), + ) + export_guard = self._butterfly_figure_export_guard() + export_context = deepcopy(dict(snapshot.get("context") or {})) + export_context["figure_width_mm"] = float(width_mm) + export_context["figure_dpi"] = int(dpi) generation = self._generation.next() cancel_event = threading.Event() payload = { @@ -1381,6 +1540,9 @@ def _start_butterfly_figure_export( self._workers[generation] = worker self._cancel_events[generation] = cancel_event self._figure_export_result_revisions[generation] = result_revision + self._figure_export_result_guards[generation] = export_guard + self._figure_export_result_contexts[generation] = export_context + self._figure_export_page_status[generation] = page_status_snapshot self._last_butterfly_figure_paths = {} self._set_busy(True, "butterfly_figure_export") self._thread_pool.start(worker) @@ -3291,6 +3453,8 @@ def _retranslate_ui(self) -> None: _refresh_workflow_guide(self) except Exception: pass + if self._figure_export_dialog is not None: + self._figure_export_dialog.set_language(self._language) self._sync_fit_session_controls(preserve_edits=True) self._apply_tooltips() self._render_status() @@ -5433,14 +5597,23 @@ def _on_worker_finished(self, generation: int, kind: str, result: Any) -> None: if not self._workers: state = "cancelled" if self._cancel_pending else "ignored" self._cancel_pending = False - self._set_busy(False, state) + self._set_busy( + False, + state, + page_status_kind=kind if state == "cancelled" else None, + ) elif self._cancel_pending: active_kind = next(iter(self._workers.values())).kind self._set_busy(True, active_kind) return self._last_error = None + result_ok = not _result_has_failure(result) + cancelled_batch = kind == "batch" and _read(result, ("cancelled",), False) is True if kind == "batch": - records = _result_value(result, ("records", "results", "evolution"), []) + records = _sequence( + _result_value(result, ("records", "results", "evolution"), []) + ) + result_ok = bool(records) and result_ok and not _batch_records_have_failure(records) if records: self.plot_evolution(records) self._update_batch_rows(records) @@ -5523,8 +5696,10 @@ def _on_worker_finished(self, generation: int, kind: str, result: Any) -> None: if self._workers: active_kind = next(iter(self._workers.values())).kind self._set_busy(True, active_kind) + elif cancelled_batch: + self._set_busy(False, "cancelled", page_status_kind="batch") else: - self._set_busy(False, kind, result_ok=not _result_has_failure(result)) + self._set_busy(False, kind, result_ok=result_ok) def _on_worker_error(self, generation: int, kind: str, error: Exception) -> None: self._workers.pop(generation, None) @@ -5542,7 +5717,11 @@ def _on_worker_error(self, generation: int, kind: str, error: Exception) -> None if not self._workers: state = "cancelled" if self._cancel_pending else "ignored" self._cancel_pending = False - self._set_busy(False, state) + self._set_busy( + False, + state, + page_status_kind=kind if state == "cancelled" else None, + ) elif self._cancel_pending: active_kind = next(iter(self._workers.values())).kind self._set_busy(True, active_kind) @@ -5574,6 +5753,26 @@ def _on_worker_error(self, generation: int, kind: str, error: Exception) -> None error=error, ) + @staticmethod + def _restore_figure_export_page_status( + page: Any, + *, + page_result_is_current: bool, + snapshot: tuple[str, str, Any] | None, + ) -> None: + """Return the page to its pre-export status without invalidating data.""" + + if not page_result_is_current: + page.finish_detached_job_if_active("butterfly_figure_export") + return + if snapshot is None: + page.set_job_status("result" if page.result_fresh else "ready") + return + state, kind, error = snapshot + if state in {"running", "cancelling"}: + state = "result" if page.result_fresh else "ready" + page.set_job_status(state, kind, error=error) + def _finish_butterfly_figure_export( self, generation: int, @@ -5586,6 +5785,9 @@ def _finish_butterfly_figure_export( page = self.butterfly_workbench result_revision = self._figure_export_result_revisions.pop(generation, None) + result_guard = self._figure_export_result_guards.pop(generation, None) + result_context = self._figure_export_result_contexts.pop(generation, None) + page_status_snapshot = self._figure_export_page_status.pop(generation, None) page_result_is_current = bool( result_revision is not None and page.result_revision == result_revision @@ -5621,11 +5823,16 @@ def _finish_butterfly_figure_export( if cancelled: self._set_busy( False, - "cancelled", - update_butterfly_status=page_result_is_current, + "butterfly_figure_export", + update_butterfly_status=False, + ) + self._restore_figure_export_page_status( + page, + page_result_is_current=page_result_is_current, + snapshot=page_status_snapshot, ) - if not page_result_is_current: - page.finish_detached_job_if_active("butterfly_figure_export") + if self._figure_export_dialog is not None: + self._figure_export_dialog.set_export_cancelled() self._set_status("status.cancelled") return @@ -5634,10 +5841,20 @@ def _finish_butterfly_figure_export( False, "butterfly_figure_export", result_ok=True, - update_butterfly_status=page_result_is_current, + update_butterfly_status=False, ) - if not page_result_is_current: - page.finish_detached_job_if_active("butterfly_figure_export") + self._restore_figure_export_page_status( + page, + page_result_is_current=page_result_is_current, + snapshot=page_status_snapshot, + ) + if self._figure_export_dialog is not None: + self._figure_export_dialog.set_exported_paths( + self._last_butterfly_figure_paths, + source_context=result_context, + stale=not page_result_is_current, + ) + self._figure_export_dialog_guard = result_guard manifest = self._last_butterfly_figure_paths.get("manifest") output_dir = manifest.parent if manifest is not None else next( iter(self._last_butterfly_figure_paths.values()) @@ -5653,10 +5870,15 @@ def _finish_butterfly_figure_export( False, "butterfly_figure_export", result_ok=False, - update_butterfly_status=page_result_is_current, + update_butterfly_status=False, ) - if not page_result_is_current: - page.finish_detached_job_if_active("butterfly_figure_export") + self._restore_figure_export_page_status( + page, + page_result_is_current=page_result_is_current, + snapshot=page_status_snapshot, + ) + if self._figure_export_dialog is not None: + self._figure_export_dialog.set_export_error(error) self._set_status( "status.butterfly_figure_export_failed", flags="butterfly_figure_export_error", @@ -7155,9 +7377,37 @@ def set_batch_frames(self, frames: Iterable[Any]) -> None: current=self._source_path, ) + def _reject_batch_request(self, error: str, *, flags: str) -> int: + """Reject an invalid batch request without starting a worker.""" + + generation = self._generation.next() + self._batch_cancel_event = None + self.batch_progress.setVisible(False) + self.batch_progress_label.setVisible(False) + self._last_error = str(error) + self._set_status( + "status.job_error", + flags=flags, + kind_key="job.batch", + error=error, + ) + return generation + def run_batch(self, frames: Iterable[Any] | bool | None = None) -> int: if frames is not None and not isinstance(frames, bool): self.set_batch_frames(frames) + stream = bool(self.batch_stream_check.isChecked()) + output = self.batch_output_edit.text().strip() or None + if not self.batch_frames: + return self._reject_batch_request( + "no batch frames selected", + flags="no_batch_frames", + ) + if stream and output is None: + return self._reject_batch_request( + "streaming requires an output directory", + flags="stream_output_required", + ) payload = { "frames": list(self.batch_frames), "parameters": self.parameter_model.parameter_dict(), @@ -7168,7 +7418,7 @@ def run_batch(self, frames: Iterable[Any] | bool | None = None) -> int: "mode": self.batch_mode_combo.currentData(), "stage": self.batch_stage_combo.currentData(), "full2d": self.batch_stage_combo.currentData() == "full2d", - "stream": bool(self.batch_stream_check.isChecked()), + "stream": stream, "manifest": self.batch_manifest_edit.text() or None, "checkpoint": self.batch_checkpoint_edit.text() or None, "resume": self.batch_resume_check.isChecked(), @@ -7177,7 +7427,7 @@ def run_batch(self, frames: Iterable[Any] | bool | None = None) -> int: "stop": None if self.batch_stop_spin.value() < 0 else self.batch_stop_spin.value(), "stride": self.batch_stride_spin.value(), "frame_range": self.batch_range_edit.text().strip() or None, - "output": self.batch_output_edit.text() or None, + "output": output, "source": self._source_path, "poni": self._poni_path, } @@ -7303,6 +7553,7 @@ def _set_busy( *, result_ok: bool | None = None, update_butterfly_status: bool = True, + page_status_kind: str | None = None, ) -> None: self.preview_button.setEnabled(not busy) self.optimize_button.setEnabled(not busy) @@ -7355,10 +7606,17 @@ def _set_busy( if not self._workers: self._cancel_pending = False if hasattr(self, "butterfly_workbench") and update_butterfly_status: + status_kind = str(page_status_kind or kind) if kind in {"cancelled", "canceled"}: - self.butterfly_workbench.set_job_status("cancelled", kind) + self.butterfly_workbench.set_job_status("cancelled", status_kind) elif result_ok is False: - self.butterfly_workbench.set_job_status("error", kind) + # A worker can complete with a finite diagnostic + # result whose solver quality is FAIL. Keep that + # result fresh for diagnosis/export; only the true + # worker exception path below is an error lifecycle. + self.butterfly_workbench.set_job_status( + "completed", status_kind, result_ok=False + ) elif kind == "ignored": self.butterfly_workbench.set_job_status( "result" if self.butterfly_workbench.result_fresh else "ready" diff --git a/tests/test_annular_peak_figures.py b/tests/test_annular_peak_figures.py new file mode 100644 index 0000000..f96e6d3 --- /dev/null +++ b/tests/test_annular_peak_figures.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import csv +import hashlib +import json +from pathlib import Path + +import numpy as np +import pytest + +from butterfly_saxs.annular_peak_figures import ( + export_annular_peak_figures, + render_annular_peak_figures, +) + + +def _result() -> dict: + angles = np.asarray([-180.0, -90.0, 0.0, 90.0], dtype=float) + q_edges = np.asarray([0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70], dtype=float) + annuli = [] + for index, q_center in enumerate((0.15, 0.25, 0.35, 0.45, 0.55, 0.65)): + raw = np.asarray([1.0, 4.0, 2.0, 3.0], dtype=float) + index + selected = [ + { + "point_id": f"ring-{index}-a", + "trajectory_id": "lobe-a", + "chi_deg": -90.0, + "qx": 0.0, + "qy": -q_center, + "accepted": True, + "raw_intensity": float(raw[1]), + "intensity": float(raw[1]), + }, + { + "point_id": f"ring-{index}-b", + "trajectory_id": "lobe-b", + "chi_deg": 90.0, + "qx": 0.0, + "qy": q_center, + "accepted": True, + "raw_intensity": float(raw[3]), + "intensity": float(raw[3]), + }, + ] + annuli.append( + { + "annulus_index": index, + "q_center": q_center, + "q_min": q_edges[index], + "q_max": q_edges[index + 1], + "profile_id": f"profile-{index}", + "raw_mean": raw, + "raw_sum": raw * 10.0, + "counts": np.asarray([10, 11, 12, 13]), + "geometry_counts": np.asarray([12, 12, 12, 14]), + "coverage": np.asarray([0.8, 0.9, 1.0, 0.7]), + "smoothed_intensity": raw + 0.25, + "candidates": [ + {"point_id": f"candidate-{index}", "chi_deg": -180.0, "prominence": 2.5}, + ], + "selected_peaks": selected, + "status": "selected", + "reason": "", + } + ) + return { + "q_unit": "nm^-1", + "q_edges": q_edges, + "angle_centers_deg": angles, + "settings": {"n_annuli": 6, "n_angle_bins": 4}, + "method_version": "annular-test-v1", + "annuli": annuli, + } + + +def _assert_text_inside(figure) -> None: + figure.canvas.draw() + renderer = figure.canvas.get_renderer() + container = figure.bbox + artists = list(figure.texts) + for axis in figure.axes: + artists.extend((axis.title, axis.xaxis.label, axis.yaxis.label)) + artists.extend(axis.texts) + for legend in figure.legends: + artists.extend(legend.get_texts()) + for artist in artists: + if not artist.get_visible() or not artist.get_text().strip(): + continue + bbox = artist.get_window_extent(renderer) + assert bbox.x0 >= container.x0 - 1.0 + assert bbox.y0 >= container.y0 - 1.0 + assert bbox.x1 <= container.x1 + 1.0 + assert bbox.y1 <= container.y1 + 1.0 + + +def test_annular_figures_keep_supplied_gaps_and_mark_all_selected_peaks() -> None: + result = _result() + result["annuli"][1]["raw_mean"][1] = np.nan + result["annuli"][1]["smoothed_intensity"][1] = np.nan + # Refined angular maxima need not equal the centre of an angular bin. + result["annuli"][1]["selected_peaks"][0]["chi_deg"] = -89.5 + for width in (89.0, 183.0): + figures = render_annular_peak_figures( + result, + data={"context": {"metadata": {"header": {"IntensityUnit": "cm^-1"}}}}, + width_mm=width, + dpi=120, + ) + assert figures["annular_qchi"].get_size_inches()[0] * 25.4 == pytest.approx(width) + assert figures["annular_profiles"].get_size_inches()[0] * 25.4 == pytest.approx(width) + _assert_text_inside(figures["annular_qchi"]) + _assert_text_inside(figures["annular_profiles"]) + assert any(collection.get_offsets().shape[0] == 1 for collection in figures["annular_qchi"].axes[0].collections) + raw_lines = [line for line in figures["annular_profiles"].axes[0].lines if line.get_label() == "raw annular mean"] + assert raw_lines + assert len(raw_lines[0].get_ydata()) == len(result["angle_centers_deg"]) + assert np.isnan(raw_lines[0].get_ydata()).any() + for figure in figures.values(): + figure.clear() + + +def test_annular_narrow_layout_stays_contained_at_export_dpi() -> None: + figures = render_annular_peak_figures(_result(), data={}, width_mm=89, dpi=600) + _assert_text_inside(figures["annular_qchi"]) + _assert_text_inside(figures["annular_profiles"]) + for figure in figures.values(): + figure.clear() + + +def test_annular_export_roundtrips_raw_arrays_and_selected_candidates(tmp_path: Path) -> None: + result = _result() + original_raw = np.asarray([row["raw_mean"] for row in result["annuli"]], dtype=float) + original_angles = np.asarray(result["angle_centers_deg"], dtype=float).copy() + output, metadata = export_annular_peak_figures( + tmp_path, + data={ + "context": {"metadata": {"header": {"IntensityUnit": "cm^-1"}}}, + "width_mm": 183, + "dpi": 120, + }, + result=result, + ) + assert metadata["q_coordinate_is_annulus_center"] is True + assert metadata["radial_q_star_not_used"] is True + assert metadata["selected_peak_count"] == 12 + with np.load(output["annular_profiles_npz"], allow_pickle=False) as arrays: + np.testing.assert_array_equal(arrays["raw_mean"], original_raw) + np.testing.assert_array_equal(arrays["q_edges"], result["q_edges"]) + assert arrays["q_unit"].item() == "nm^-1" + np.testing.assert_array_equal(result["angle_centers_deg"], original_angles) + np.testing.assert_array_equal(result["annuli"][0]["raw_mean"], original_raw[0]) + with output["annular_profiles_csv"].open(encoding="utf-8-sig", newline="") as stream: + profile_rows = list(csv.DictReader(stream)) + assert len(profile_rows) == 6 * 4 + assert any(row["selected"] == "True" for row in profile_rows) + # The raw evidence table retains source order; the figure view is + # canonicalized separately to 0..360. + first_annulus = [row for row in profile_rows if row["annulus_index"] == "0"] + assert [row["chi_deg"] for row in first_annulus] == ["-180", "-90", "0", "90"] + with output["annular_peaks_csv"].open(encoding="utf-8-sig", newline="") as stream: + peak_rows = list(csv.DictReader(stream)) + assert sum(row["record_type"] == "selected" for row in peak_rows) == 12 + assert sum(row["record_type"] == "candidate" for row in peak_rows) == 6 + manifest = json.loads(output["annular_manifest"].read_text(encoding="utf-8")) + for name, digest in manifest["sha256"].items(): + assert hashlib.sha256((tmp_path / name).read_bytes()).hexdigest() == digest + caption = output["annular_caption"].read_text(encoding="utf-8") + assert "annulus coordinate" in caption + assert "2π/q" in caption + assert "ellipse fit" in caption + + +def test_annular_export_preserves_candidate_text_and_does_not_reselect(tmp_path: Path) -> None: + result = _result() + result["annuli"][0]["status"] = "=STATUS()" + result["annuli"][0]["reason"] = "=SUM(A1:A2)" + result["annuli"][0]["selected_peaks"] = [result["annuli"][0]["selected_peaks"][0]] + result["annuli"][0]["candidates"] = [ + {"point_id": "candidate", "reason": "=DROP_ME()", "chi_deg": 0.0} + ] + output, _ = export_annular_peak_figures( + tmp_path, + data={"width_mm": 89, "dpi": 120}, + result={"annular_peaks": result}, + ) + with output["annular_peaks_csv"].open(encoding="utf-8-sig", newline="") as stream: + rows = list(csv.DictReader(stream)) + first = next(row for row in rows if row["annulus_index"] == "0" and row["record_type"] == "candidate") + assert first["candidate_json"].startswith("{") + with output["annular_profiles_csv"].open(encoding="utf-8-sig", newline="") as stream: + profiles = list(csv.DictReader(stream)) + assert profiles[0]["status"] == "'=STATUS()" + assert profiles[0]["reason"] == "'=SUM(A1:A2)" + assert sum(row["record_type"] == "selected" for row in rows) == 11 + with pytest.raises(FileExistsError): + export_annular_peak_figures(tmp_path, data={"width_mm": 89, "dpi": 120}, result=result) + + +def test_annular_export_refuses_more_than_four_supplied_peaks(tmp_path: Path) -> None: + result = _result() + result["annuli"][0]["selected_peaks"] = result["annuli"][0]["selected_peaks"] * 3 + with pytest.raises(ValueError, match="more than four"): + export_annular_peak_figures(tmp_path, data={"width_mm": 89, "dpi": 120}, result=result) diff --git a/tests/test_annular_trace.py b/tests/test_annular_trace.py new file mode 100644 index 0000000..ce3be00 --- /dev/null +++ b/tests/test_annular_trace.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from butterfly_saxs.annular_trace import trace_butterfly_annuli +from butterfly_saxs.butterfly import analyze_butterfly + + +def _curved_petals(): + axis = np.linspace(-.85, .85, 241) + qx, qy = np.meshgrid(axis, axis) + q = np.hypot(qx, qy) + chi = np.degrees(np.arctan2(qy, qx)) % 360 + # Four observed lobes following outward-moving angular maxima. + centre = 62 - 36 * q + image = np.full(q.shape, 2.) + for angle in (centre, 180 - centre, 180 + centre, 360 - centre): + delta = (chi - angle + 180) % 360 - 180 + image += 30 * np.exp(-.5 * (delta / 9) ** 2) + image += np.random.default_rng(19).normal(0, .04, q.shape) + return image, {"qx": qx, "qy": qy, "q": q, "q_unit": "nm^-1"}, chi + + +def test_annuli_trace_four_extended_petals_instead_of_radial_peak_positions(): + image, qmap, _ = _curved_petals() + result = trace_butterfly_annuli(image, qmap, (.25, .75), options={"annular_radial_bins": 20}) + accepted = [p for p in result["points"] if p["accepted"]] + assert len(accepted) >= 72 + assert len(result["arcs"]) == 4 + assert len({p["trajectory_id"] for p in accepted}) == 4 + assert all(len(row["selected_peaks"]) == 4 for row in result["annular_peaks"]["annuli"]) + for point in accepted: + q, angle = point["q_annulus"], point["chi_deg"] + expected = [62 - 36 * q, 180 - (62 - 36 * q), 180 + (62 - 36 * q), 360 - (62 - 36 * q)] + assert min(abs((angle - x + 180) % 360 - 180) for x in expected) < 2. + assert "q_star" not in point + assert np.ptp([p["q_annulus"] for p in accepted]) > .45 + assert all(profile["profile_axis"] == "azimuthal" for profile in result["profiles"].values()) + + +def test_missing_lobe_and_masked_ring_are_not_synthesized_or_bridged(): + image, qmap, chi = _curved_petals() + q = qmap["q"] + mask = ((chi > 0) & (chi < 90)) | ((q > .45) & (q < .55)) + result = trace_butterfly_annuli(image, qmap, (.25, .75), mask=mask, + options={"annular_radial_bins": 20}) + accepted = [p for p in result["points"] if p["accepted"]] + assert accepted + assert not any(0 < p["chi_deg"] < 90 or .45 < p["q_annulus"] < .55 for p in accepted) + for track in {p["trajectory_id"] for p in accepted}: + positions = [p["q_annulus"] for p in accepted if p["trajectory_id"] == track] + assert not (min(positions) < .45 and max(positions) > .55) + for point in accepted: + assert not mask[int(point["pixel_y"]), int(point["pixel_x"])] + + +def test_annular_recipe_dispatch_and_sampling_coordinates_do_not_become_periods(): + image, qmap, _ = _curved_petals() + result = analyze_butterfly(image, qmap, (.25, .75), options={ + "trace_method": "annular_peak", "stage": "evaluate", "resamples": 0, + "sensitivity": False, "annular_radial_bins": 20, + }, multistart=1) + assert result["method_version"] == "butterfly-annular-trajectory-v1.0" + assert result["candidate_fit"]["q_star_from_arcs"] is None + assert result["candidate_fit"]["L_from_observed_radius_nm"] is None + assert result["candidate_fit"]["q_star_source"] == "unavailable_prescribed_annuli" + assert result["quality"]["scientific_status"] == "NOT_ACCEPTED" + assert "annular_peaks" in result and "sector_peaks" not in result + with pytest.raises(ValueError, match="annular_radial_bins"): + analyze_butterfly(image, qmap, (.25, .75), options={"trace_method":"annular_peak", "annular_radial_bins": True}) diff --git a/tests/test_annular_workbench.py b/tests/test_annular_workbench.py new file mode 100644 index 0000000..ad9de9f --- /dev/null +++ b/tests/test_annular_workbench.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from butterfly_saxs.ui.butterfly_workbench import ButterflyWorkbench +from butterfly_saxs.ui.butterfly_summary import build_butterfly_quality_summary + + +def _annular_result() -> dict: + angles = [0.0, 90.0, 180.0, 270.0] + profiles = {} + annuli = [] + points = [] + for index, q_center in enumerate((0.20, 0.30)): + profile_id = f"annulus-{index:03d}" + profiles[profile_id] = { + "profile_axis": "azimuthal", + "angle_deg": list(range(0, 360, 90)), + "raw_intensity": [1.0 + index, 4.0, 1.2 + index, 3.5], + "smoothed_intensity": [1.1 + index, 3.8, 1.3 + index, 3.2], + "counts": [80, 81, 79, 82], + "coverage": [0.9, 0.91, 0.88, 0.92], + "peak_angles_deg": angles, + "q_center": q_center, + "q_min": q_center - 0.025, + "q_max": q_center + 0.025, + "q_unit": "nm^-1", + "reason": "selected", + } + selected = [ + { + "point_id": f"track-{branch}-{index}", + "chi_deg": angle, + "qx": q_center, + "qy": 0.0, + "trajectory_id": f"trajectory-{branch}", + "branch_id": branch // 2, + "side": "upper" if branch % 2 == 0 else "lower", + "accepted": True, + "valid": True, + } + for branch, angle in enumerate(angles) + ] + annuli.append( + { + "annulus_index": index, + "q_center": q_center, + "q_min": q_center - 0.025, + "q_max": q_center + 0.025, + "profile_id": profile_id, + "selected_peaks": selected, + "status": "selected", + "reason": "selected", + } + ) + if index == 0: + points.extend(selected) + return { + "method_version": "butterfly-annular-v1", + "trace_method": "annular_peak", + "q_unit": "nm^-1", + "points": points, + "profiles": profiles, + "annular_peaks": { + "q_unit": "nm^-1", + "q_edges": [0.175, 0.225, 0.275, 0.325], + "angle_centers_deg": [0.0, 90.0, 180.0, 270.0], + "settings": {"annular_radial_bins": 40, "annular_angle_bins": 72}, + "annuli": annuli, + }, + "quality": { + "status": "WARN", + "engineering_status": "WARN", + "flags": ["ellipse_not_evaluated"], + }, + } + + +def test_fresh_workbench_defaults_to_annular_controls(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + page.show() + qtbot.wait(10) + + assert page.trace_method_combo.currentData() == "annular_peak" + assert page.analysis_settings["ridge_method"] == "butterfly_curvature" + assert page.annular_radial_bins.value() == 40 + assert page.annular_angle_bins.value() == 72 + assert page.annular_radial_bins.isVisible() + assert page.annular_angle_bins.isVisible() + assert not page.sector_width_spin.isVisible() + assert not page.sector_step_spin.isVisible() + assert not page.global_max_check.isChecked() + assert not page.supported_peaks_check.isChecked() + assert not page.ellipse_diagnostic.isVisible() + + +def test_annular_result_lists_every_ring_and_renders_full_angular_profile(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + page.set_result(_annular_result()) + + assert page.trace_method_combo.currentData() == "annular_peak" + assert page.point_list.count() == 2 + assert "q=[0.175, 0.225]" in page.point_list.item(0).text() + assert "4 个峰" in page.point_list.item(0).text() + assert "q=[0.275, 0.325]" in page.point_list.item(1).text() + + page.point_list.setCurrentRow(0) + assert page.normal_profile._x_label == "χ(deg)" + assert page.normal_profile._series_names == ("raw", "smoothed", "counts", "coverage") + assert page.normal_profile.table.rowCount() == 4 + headers = [ + page.normal_profile.table.horizontalHeaderItem(column).text() + for column in range(page.normal_profile.table.columnCount()) + ] + assert "有效像素数(像素)" in headers + assert "覆盖率(无量纲)" in headers + assert "平滑(仅用于定位)" in headers + assert len(page.normal_profile.plot.listDataItems()) == 2 + assert len(page.normal_profile.plot.items()) >= 4 + assert not page.ellipse_diagnostic.isVisible() + assert getattr(page.qspace, "_selected_point_id", None) is None + + +def test_annular_mode_roundtrip_invalidation_and_compatibility_modes(qtbot): + page = ButterflyWorkbench(language="en") + qtbot.addWidget(page) + page.show() + qtbot.wait(10) + page.set_result(_annular_result()) + assert page.result_fresh + + events: list[dict] = [] + page.analysisChanged.connect(events.append) + page.annular_angle_bins.setValue(96) + assert not page.result_fresh + assert events[-1]["butterfly"]["trace_method"] == "annular_peak" + assert events[-1]["butterfly"]["annular_angle_bins"] == 96 + + page.set_analysis_settings( + { + "stage": "trace", + "trace_method": "annular_peak", + "annular_radial_bins": 44, + "annular_angle_bins": 96, + }, + replace=True, + ) + assert page.butterfly_settings["annular_radial_bins"] == 44 + assert page.butterfly_settings["annular_angle_bins"] == 96 + + page.trace_method_combo.setCurrentIndex(page.trace_method_combo.findData("radial_sector")) + assert page.trace_method_combo.currentData() == "radial_sector" + assert page.sector_width_spin.isVisible() + assert not page.annular_angle_bins.isVisible() + assert page.ellipse_diagnostic.isVisible() is False + + page.trace_method_combo.setCurrentIndex(page.trace_method_combo.findData("curvature")) + assert page.trace_method_combo.currentData() == "curvature" + assert page.global_max_check.isChecked() + assert page.supported_peaks_check.isChecked() + assert page.ellipse_diagnostic.isVisible() + + +def test_legacy_payload_without_trace_method_remains_curvature(qtbot): + page = ButterflyWorkbench(language="en") + qtbot.addWidget(page) + legacy = { + "method_version": "butterfly-curvature-arcs-v2.1", + "points": [{"point_id": "legacy-0", "qx": 0.1, "qy": 0.0}], + "profiles": {}, + } + page.set_result(legacy) + + assert page.trace_method_combo.currentData() == "curvature" + assert page.global_max_check.isChecked() + assert page.supported_peaks_check.isChecked() + + +def test_annular_summary_reports_tracks_without_q_median_or_spacing(): + state = build_butterfly_quality_summary( + _annular_result(), + trace_method="annular_peak", + result_fresh=True, + data_ready=True, + ) + assert state.trace_method == "annular_peak" + assert state.q_star is None + assert state.l_ring is None + assert state.next_step_key in {"evaluate", "ellipse_review", "ring_review"} diff --git a/tests/test_arc_geometry.py b/tests/test_arc_geometry.py index a38e276..b0aaed0 100644 --- a/tests/test_arc_geometry.py +++ b/tests/test_arc_geometry.py @@ -85,6 +85,23 @@ def test_arc_fit_is_invariant_to_global_branch_id_swap() -> None: assert swapped["branch_swap_applied"] is True +def test_sampling_boundary_is_not_assumed_to_be_a_major_axis_tip(): + parameters = { + "cx": {"value": 0., "vary": False}, "cy": {"value": 0., "vary": False}, + "a": {"value": 1.2}, "axis_ratio": {"value": .08, "min": .005, "max": .35}, + "theta": {"value": .35, "min": 0., "max": np.pi / 2}, + } + result = fit_arc_ellipses(_arc_points(), parameters=parameters, multistart=1, + observed_tip_constraint=False) + assert result["fit"].values["a"] == pytest.approx(1.5, rel=1e-5) + assert result["fit"].values["axis_ratio"] == pytest.approx(.1, rel=1e-5) + assert result["fit"].values["theta"] == pytest.approx(.35, abs=1e-5) + parameters["a"]["max"] = 1.3 + constrained = fit_arc_ellipses(_arc_points(), parameters=parameters, multistart=1, + observed_tip_constraint=False) + assert constrained["fit"].values["a"] <= 1.3 + + @pytest.mark.parametrize("axis_ratio", (0.005, 0.02)) def test_free_flat_arc_fit_selects_low_cost_nonconverged_candidate_honestly(axis_ratio: float) -> None: points: list[dict[str, object]] = [] diff --git a/tests/test_arc_projection_vectorized.py b/tests/test_arc_projection_vectorized.py new file mode 100644 index 0000000..9499d53 --- /dev/null +++ b/tests/test_arc_projection_vectorized.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import math + +import numpy as np + +from butterfly_saxs.arc_geometry import ( + _project_ellipse_arc, + _project_point_to_support, + _symmetric_arc_projection, + project_arc_points, +) +from butterfly_saxs.ellipse import EllipseGeometry, ellipse_implicit + + +_PROJECTION_FIELDS = ( + "t", + "distance", + "global_distance", + "at_endpoint", + "endpoint_clipped", + "support_endpoint_clipped", + "manual_bound_clipped", + "projection_valid", + "support_infeasible", + "support_violation_q", + "objective_distance", + "support_component_id", + "support_interval_index", +) + + +def _rectangle_at(point: np.ndarray, *, component_id: int, segment_index: int, half_width: float = 0.02) -> dict[str, object]: + return { + "frame_origin_q": point.tolist(), + "tangent_q": [1.0, 0.0], + "normal_q": [0.0, 1.0], + "tangent_bounds": [-half_width, half_width], + "normal_bounds": [-half_width, half_width], + "component_id": component_id, + "segment_index": segment_index, + } + + +def _projection_fixture() -> tuple[ + np.ndarray, + np.ndarray, + np.ndarray, + list[dict[str, object]], + dict[str, float], + float, +]: + values = { + "cx": 0.12, + "cy": -0.08, + "a": 1.4, + "axis_ratio": 0.04, + "theta": 0.37, + } + reference_axis = 0.13 + branch_geometry = { + branch: EllipseGeometry( + values["cx"], + values["cy"], + values["a"], + values["axis_ratio"], + reference_axis + (1.0 if branch == 0 else -1.0) * values["theta"], + ) + for branch in (0, 1) + } + rows = [ + (0, "upper", 0.48, [(0.10, 0.24), (0.40, 0.60)], ()), + (0, "lower", 3.62, None, ()), + (0, "upper", 0.78, None, ()), + (1, "lower", 3.28, [(3.30, 3.42), (3.55, 3.72)], ()), + (1, "upper", 0.62, None, ()), + (1, "lower", 5.70, None, ()), + (0, "upper", 0.15, [(0.30, 0.50)], ()), + ] + points: list[list[float]] = [] + branch_ids: list[int] = [] + sides: list[str] = [] + interval_specs: list[dict[str, object]] = [] + records: list[dict[str, object]] = [] + for point_id, (branch, side, t, manual_intervals, _unused) in enumerate(rows): + geometry = branch_geometry[branch] + point = np.asarray(geometry.point(t), dtype=float).reshape(2) + rectangles: tuple[dict[str, object], ...] + if point_id == 1: + rectangles = ( + _rectangle_at(point + np.asarray([3.0, 3.0]), component_id=7, segment_index=0), + _rectangle_at(point, component_id=8, segment_index=4), + ) + elif point_id == 2: + rectangles = (_rectangle_at(np.asarray([3.0, 3.0]), component_id=9, segment_index=2),) + elif point_id == 4: + rectangles = (_rectangle_at(point, component_id=10, segment_index=5),) + elif point_id == 5: + rectangles = (_rectangle_at(np.asarray([-3.0, 2.0]), component_id=11, segment_index=1),) + else: + rectangles = () + points.append(point.tolist()) + branch_ids.append(branch) + sides.append(side) + interval_specs.append({"manual_intervals": manual_intervals, "rectangles": rectangles}) + record = { + "point_id": point_id, + "qx": float(point[0]), + "qy": float(point[1]), + "branch_id": branch, + "side": side, + "arc_id": point_id, + "accepted": True, + "arc_t_intervals": manual_intervals, + } + records.append(record) + return ( + np.asarray(points, dtype=float), + np.asarray(branch_ids, dtype=int), + np.asarray(sides, dtype=object), + interval_specs, + values, + reference_axis, + ) + + +def _scalar_oracle( + points: np.ndarray, + values: dict[str, float], + branch_ids: np.ndarray, + sides: np.ndarray, + interval_specs: list[dict[str, object]], + reference_axis: float, +) -> dict[str, object]: + geometry = EllipseGeometry.from_values(values) + result: dict[str, object] = { + "t": np.full(points.shape[0], np.nan, dtype=float), + "distance": np.full(points.shape[0], np.nan, dtype=float), + "global_distance": np.full(points.shape[0], np.nan, dtype=float), + "at_endpoint": np.zeros(points.shape[0], dtype=bool), + "endpoint_clipped": np.zeros(points.shape[0], dtype=bool), + "support_endpoint_clipped": np.zeros(points.shape[0], dtype=bool), + "manual_bound_clipped": np.zeros(points.shape[0], dtype=bool), + "projection_valid": np.zeros(points.shape[0], dtype=bool), + "support_infeasible": np.zeros(points.shape[0], dtype=bool), + "support_violation_q": np.zeros(points.shape[0], dtype=float), + "objective_distance": np.full(points.shape[0], np.nan, dtype=float), + "support_component_id": np.full(points.shape[0], None, dtype=object), + "support_interval_index": np.full(points.shape[0], -1, dtype=int), + "implicit": np.full(points.shape[0], np.nan, dtype=float), + } + for branch in (0, 1): + mask = branch_ids == branch + branch_geometry = EllipseGeometry( + geometry.cx, + geometry.cy, + geometry.a, + geometry.axis_ratio, + reference_axis + (1.0 if branch == 0 else -1.0) * geometry.theta, + ) + for row in np.flatnonzero(mask): + spec = interval_specs[int(row)] + projection = _project_point_to_support( + points[row], + branch_geometry, + str(sides[row]), + spec.get("manual_intervals"), + spec.get("rectangles", ()), + include_global_oracle=True, + ) + for name in _PROJECTION_FIELDS: + result[name][row] = projection[name] + result["implicit"][mask] = ellipse_implicit(points[mask], branch_geometry) + signed_distance = np.copysign(result["objective_distance"], result["implicit"]) + result["signed_distance"] = np.nan_to_num( + signed_distance, nan=1.0e12, posinf=1.0e12, neginf=-1.0e12 + ) + result["objective_distance"] = np.nan_to_num( + result["objective_distance"], nan=1.0e12, posinf=1.0e12, neginf=1.0e12 + ) + return result + + +def test_batched_projection_matches_scalar_oracle_for_support_and_manual_intervals() -> None: + points, branch_ids, sides, interval_specs, values, reference_axis = _projection_fixture() + + batched = _symmetric_arc_projection( + points, + values, + branch_ids, + sides, + interval_specs, + reference_axis, + include_global_oracle=True, + ) + scalar = _scalar_oracle(points, values, branch_ids, sides, interval_specs, reference_axis) + + for name in _PROJECTION_FIELDS + ("implicit", "signed_distance"): + actual = np.asarray(batched[name]) + expected = np.asarray(scalar[name]) + if actual.dtype == object: + assert np.array_equal(actual, expected), name + elif actual.dtype == bool: + assert np.array_equal(actual, expected), name + else: + np.testing.assert_allclose(actual, expected, rtol=0.0, atol=3.0e-13, equal_nan=True) + + assert bool(batched["projection_valid"][1]) + assert int(batched["support_component_id"][1]) == 8 + assert not bool(batched["projection_valid"][2]) + assert bool(batched["support_infeasible"][2]) + assert bool(batched["manual_bound_clipped"][6]) + + +def test_vectorized_flat_arc_projection_preserves_endpoint_and_global_distance() -> None: + geometry = EllipseGeometry(0.11, -0.07, 1.6, 0.005, 0.41) + t = np.asarray([0.20, 0.2000000001, 0.80, 3.30, 3.31], dtype=float) + points = np.asarray(geometry.point(t), dtype=float).reshape(-1, 2) + local_u, local_v = ( + points[:, 0] - geometry.cx, + points[:, 1] - geometry.cy, + ) + cosine = math.cos(geometry.theta) + sine = math.sin(geometry.theta) + local_u, local_v = cosine * local_u + sine * local_v, -sine * local_u + cosine * local_v + vectorized = _project_ellipse_arc( + local_u, + local_v, + geometry.a, + geometry.b, + np.asarray([0.25, 0.25, 0.25, 3.25, 3.25]), + np.asarray([0.75, 0.75, 0.75, 3.35, 3.35]), + include_global_oracle=True, + ) + scalar = [ + _project_ellipse_arc( + np.asarray([u]), + np.asarray([v]), + geometry.a, + geometry.b, + np.asarray([lo]), + np.asarray([hi]), + include_global_oracle=True, + ) + for u, v, lo, hi in zip( + local_u, + local_v, + np.asarray([0.25, 0.25, 0.25, 3.25, 3.25]), + np.asarray([0.75, 0.75, 0.75, 3.35, 3.35]), + ) + ] + for name in ("t", "distance", "global_distance"): + expected = np.asarray([item[name][0] for item in scalar]) + np.testing.assert_allclose(vectorized[name], expected, rtol=0.0, atol=3.0e-13, equal_nan=True) + assert np.array_equal(vectorized["at_endpoint"], np.asarray([item["at_endpoint"][0] for item in scalar])) + assert np.array_equal(vectorized["endpoint_clipped"], np.asarray([item["endpoint_clipped"][0] for item in scalar])) + + +def test_public_projection_branch_swap_reuses_same_bounded_geometry() -> None: + points, branch_ids, _sides, interval_specs, values, _reference_axis = _projection_fixture() + records: list[dict[str, object]] = [] + for index, point in enumerate(points): + spec = interval_specs[index] + if spec["manual_intervals"] is None or spec["rectangles"]: + continue + records.append( + { + "point_id": index, + "qx": float(point[0]), + "qy": float(point[1]), + "branch_id": int(1 - branch_ids[index]), + "side": "upper" if index in (0, 2, 6) else "lower", + "arc_id": index, + "accepted": True, + "arc_t_intervals": spec["manual_intervals"], + } + ) + swapped = project_arc_points(records, values, branch_swap_applied=True) + direct_records = [dict(record, branch_id=1 - int(record["branch_id"])) for record in records] + direct = project_arc_points(direct_records, values, branch_swap_applied=False) + swapped_rows = swapped["point_diagnostics"] + direct_rows = direct["point_diagnostics"] + assert len(swapped_rows) == len(direct_rows) + for actual, expected in zip(swapped_rows, direct_rows): + assert actual["projection_valid"] == expected["projection_valid"] + assert actual["projection_t"] == expected["projection_t"] + assert actual["distance_q"] == expected["distance_q"] + assert actual["projected_qx"] == expected["projected_qx"] + assert actual["projected_qy"] == expected["projected_qy"] diff --git a/tests/test_azimuthal_ridge.py b/tests/test_azimuthal_ridge.py index 6211394..5ec3c9b 100644 --- a/tests/test_azimuthal_ridge.py +++ b/tests/test_azimuthal_ridge.py @@ -4,6 +4,7 @@ from butterfly_saxs.models import ImageFrame, QMap from butterfly_saxs.observables import ( + _azimuthal_peak_ridges, fit_symmetric_double_ellipse, measure_observables, measure_radial_ridges, @@ -59,6 +60,22 @@ def test_azimuthal_peak_returns_direct_observed_points_with_branch_ids() -> None assert all(0.22 <= point.q <= 0.78 for point in ridge.points) +def test_annular_geometry_coverage_retains_nonfinite_intensity_pixels(): + from butterfly_saxs.observables import _azimuthal_peak_ridges + + frame, qmap = _butterfly_frame() + image = frame.data.copy() + image[(qmap.qx > 0) & (qmap.qy > 0)] = np.nan + diagnostics = {} + _azimuthal_peak_ridges(image, qmap, (.22, .78), n_annuli=20, n_angle_bins=72, + diagnostics=diagnostics) + assert np.sum(diagnostics["geometry_counts"]) > np.sum(diagnostics["counts"]) + missing = (diagnostics["geometry_counts"] > 0) & (diagnostics["counts"] == 0) + assert np.any(missing) + assert np.all(diagnostics["coverage"][missing] == 0) + assert np.all(np.isnan(diagnostics["raw_mean"][missing])) + + def test_azimuthal_peak_does_not_mirror_a_single_observed_wing() -> None: frame, qmap = _butterfly_frame(angle_centres=(0.42,), amplitudes=(8.0,)) ridge = measure_radial_ridges( @@ -192,3 +209,97 @@ def test_measure_observables_exposes_ellipse_controls_and_keeps_method_label() - (not point.valid) and (not np.isfinite(point.q_star)) for point in strict.lobe_radial_peaks ) + + +def test_azimuthal_diagnostics_use_local_noise_and_retain_four_peaks_per_ring() -> None: + frame, qmap = _butterfly_frame(noise=0.05) + diagnostics: dict[str, object] = {} + points, *_ = _azimuthal_peak_ridges( + frame, + qmap, + (0.22, 0.78), + n_annuli=24, + n_angle_bins=144, + snr_threshold=3.0, + min_peak_fraction=0.25, + diagnostics=diagnostics, + ) + assert len(points) >= 4 * 20 + assert { + "q_edges", + "q_centers", + "angle_edges_deg", + "angle_centers_deg", + "raw_mean", + "raw_sum", + "counts", + "geometry_counts", + "coverage", + "smoothed", + "annuli", + "candidates", + "selected_points", + "settings", + } <= set(diagnostics) + assert np.asarray(diagnostics["raw_mean"]).shape == (24, 144) + assert np.asarray(diagnostics["smoothed"]).shape == (24, 144) + assert diagnostics["settings"]["noise_definition"].startswith("robust_adjacent") + annuli = diagnostics["annuli"] + evaluated = [row for row in annuli if row["status"] == "profile_evaluated"] + assert evaluated + assert max(float(row["noise"]) for row in evaluated) < 0.2 + assert all("prominence" in candidate and "reason" in candidate for candidate in diagnostics["candidates"]) + assert all( + point.metadata["representative_pixel_role"] == "median_of_valid_support_pixels" + and "raw_intensity" in point.metadata + and "raw_bin_chi_deg" in point.metadata + and "refinement" in point.metadata + for point in points + ) + + +def test_azimuthal_constant_noise_and_single_hot_pixel_do_not_create_ridges() -> None: + axis = np.linspace(-1.0, 1.0, 241) + qx, qy = np.meshgrid(axis, axis) + q = np.hypot(qx, qy) + rng = np.random.default_rng(20260922) + image = 1.0 + rng.normal(0.0, 0.03, q.shape) + image[121, 158] = 1.0e6 + frame = ImageFrame(image) + diagnostics: dict[str, object] = {} + points, *_ = _azimuthal_peak_ridges( + frame, + QMap(qx, qy, q_unit="nm^-1"), + (0.22, 0.78), + n_annuli=24, + n_angle_bins=144, + snr_threshold=5.0, + min_bin_count=2, + diagnostics=diagnostics, + ) + assert points == [] + reasons = {candidate["reason"] for candidate in diagnostics["candidates"]} + assert reasons & {"insufficient_bin_support", "hot_pixel_dominated"} + + +def test_azimuthal_smoothing_keeps_masked_angular_gap_empty() -> None: + axis = np.linspace(-1.0, 1.0, 241) + qx, qy = np.meshgrid(axis, axis) + angle = np.arctan2(qy, qx) + mask_gap = np.abs(np.angle(np.exp(1j * (angle - 0.42)))) < 0.09 + frame, qmap = _butterfly_frame(mask=mask_gap) + diagnostics: dict[str, object] = {} + _azimuthal_peak_ridges( + frame, + qmap, + (0.22, 0.78), + n_annuli=24, + n_angle_bins=144, + snr_threshold=3.0, + diagnostics=diagnostics, + ) + counts = np.asarray(diagnostics["counts"]) + smoothed = np.asarray(diagnostics["smoothed"]) + missing = counts == 0 + assert np.any(missing) + assert np.all(np.isnan(smoothed[missing])) diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py index cac3495..351fa37 100644 --- a/tests/test_batch_export.py +++ b/tests/test_batch_export.py @@ -9,7 +9,7 @@ import pytest from butterfly_saxs.batch import FrameFitResult, FrameRef, build_frame_refs, run_batch -from butterfly_saxs.export import _contains_omitted_array, export_batch +from butterfly_saxs.export import _contains_omitted_array, _parameters, export_batch def _touch_frames(root: Path, names: list[str]) -> list[Path]: @@ -233,6 +233,83 @@ def test_batch_rejects_top_level_fail_status(tmp_path: Path) -> None: assert "status=FAIL" in (run[0].error or "") +@pytest.mark.parametrize( + ("result", "expected"), + [ + ({"quality_status": "FAIL"}, "quality_status=FAIL"), + ({"quality": {"status": "FAIL"}}, "quality.status=FAIL"), + ], +) +def test_batch_rejects_root_quality_failure_status( + tmp_path: Path, result: dict[str, object], expected: str +) -> None: + path = _touch_frames(tmp_path, ["frame1.tif"])[0] + + run = run_batch([path], lambda _frame: result) + + assert run[0].status == "failed" + assert expected in (run[0].error or "") + + +def test_checkpoint_requires_input_content_sha256(tmp_path: Path) -> None: + missing = tmp_path / "missing.tif" + with pytest.raises(ValueError, match="input content SHA-256 is unavailable"): + run_batch( + [missing], + lambda _frame: {"parameters": {"value": 1.0}}, + checkpoint=tmp_path / "missing-checkpoint.json", + ) + + source = _touch_frames(tmp_path, ["frame1.tif"])[0] + checkpoint = tmp_path / "checkpoint.json" + run_batch( + [source], + lambda _frame: {"parameters": {"value": 1.0}}, + checkpoint=checkpoint, + ) + source.unlink() + source.mkdir() + + with pytest.raises(ValueError, match="input content SHA-256 is unavailable"): + run_batch( + [source], + lambda _frame: {"parameters": {"value": 1.0}}, + checkpoint=checkpoint, + resume=True, + ) + + +def test_checkpoint_requires_config_file_content_sha256( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import butterfly_saxs.batch as batch_module + + source = _touch_frames(tmp_path, ["frame1.tif"])[0] + config_file = tmp_path / "mask.npy" + config_file.write_bytes(b"mask") + + checkpoint = tmp_path / "checkpoint.json" + run_batch( + [source], + lambda _frame: {"parameters": {"value": 1.0}}, + config={"mask": config_file}, + checkpoint=checkpoint, + ) + monkeypatch.setattr( + batch_module, + "_file_content_fingerprint", + lambda _value: {"path": str(config_file), "exists": True, "sha256": None}, + ) + with pytest.raises(ValueError, match="configured analysis file SHA-256 is unavailable"): + run_batch( + [source], + lambda _frame: {"parameters": {"value": 1.0}}, + config={"mask": config_file}, + checkpoint=checkpoint, + resume=True, + ) + + def test_frame_ref_key_uses_canonical_path_frame_and_dataset_identity(tmp_path: Path) -> None: left = tmp_path / "left" / "frame.npy" right = tmp_path / "right" / "frame.npy" @@ -731,6 +808,43 @@ class TopLevelResult: assert json.loads(numpy_scalar["flags"]) == ["empirical_model_only"] +def test_unaccepted_ellipse_periods_are_candidate_only_in_batch_parameters() -> None: + result = { + "parameters": { + "L_N": 109.6, + "Ln_from_minor_axis_nm": 109.6, + "L_z": 78.9, + "Lz_from_draw_axis_nm": 78.9, + "L_from_major_axis_nm": 0.55, + "L_from_observed_radius_nm": 15.0, + }, + "ellipse_fit": { + "quantitative_parameters": { + name: {"status": "undetermined", "value": None, "candidate_value": 1.0} + for name in ("a", "b", "axis_ratio", "theta_deg") + } + }, + } + + rows = {row["parameter"]: row for row in _parameters(result)} + for name in ( + "L_N", "Ln_from_minor_axis_nm", "L_z", "Lz_from_draw_axis_nm", + "L_from_major_axis_nm", + ): + assert rows[name]["value"] == "" + assert rows[name]["candidate_value"] != "" + assert rows[name]["identifiability_status"] == "undetermined" + assert rows["L_from_observed_radius_nm"]["value"] == 15.0 + + missing_evidence = {row["parameter"]: row for row in _parameters({ + "parameters": {"L_N": 109.6, "L_z": 78.9}, + "ellipse_fit": {}, + })} + assert missing_evidence["L_N"]["value"] == "" + assert missing_evidence["L_N"]["candidate_value"] == 109.6 + assert missing_evidence["L_z"]["value"] == "" + + def test_evolution_plot_uses_separate_panels_for_different_units(tmp_path: Path) -> None: frame = FrameFitResult( frame=FrameRef(tmp_path / "frame1.tif"), diff --git a/tests/test_butterfly_figure.py b/tests/test_butterfly_figure.py index 4968ad6..ec41d6e 100644 --- a/tests/test_butterfly_figure.py +++ b/tests/test_butterfly_figure.py @@ -138,6 +138,9 @@ def test_export_bundle_preserves_source_and_writes_editable_vector_and_hashes( "result", "settings", "caption", + "radial_profile", + "figure_qa", + "index", "manifest", "comparison_svg", "comparison_pdf", diff --git a/tests/test_butterfly_input_domain.py b/tests/test_butterfly_input_domain.py new file mode 100644 index 0000000..fa9ff0e --- /dev/null +++ b/tests/test_butterfly_input_domain.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from butterfly_saxs.io import LoadedImage + + +def _input_case() -> tuple[dict[str, object], LoadedImage, dict[str, object], np.ndarray, np.ndarray]: + shape = (17, 17) + qy, qx = np.mgrid[-8.0:9.0, -8.0:9.0] + q = np.hypot(qx, qy) + valid = np.ones(shape, dtype=bool) + valid[:, 7:10] = False + data = np.ones(shape, dtype=float) + frame_mapping = {"data": data, "valid_mask": valid} + frame_object = LoadedImage(data.copy(), valid_mask=valid.copy()) + qmap = {"qx": qx, "qy": qy, "q": q, "metadata": {"q_unit": "nm^-1"}} + return frame_mapping, frame_object, qmap, data, valid + + +def _expected_mask_fraction(valid: np.ndarray, qmap: dict[str, object], explicit: np.ndarray | None = None) -> float: + q = np.asarray(qmap["q"], dtype=float) + in_window = (q >= 1.0) & (q <= 9.0) + invalid = ~valid + if explicit is not None: + invalid |= explicit + return float(np.count_nonzero(invalid & in_window) / np.count_nonzero(in_window)) + + +@pytest.mark.parametrize("frame_kind", ["mapping", "object"]) +@pytest.mark.parametrize("stage", ["trace", "evaluate"]) +def test_analyze_butterfly_preserves_frame_valid_mask_for_trace_and_evaluate( + frame_kind: str, stage: str +) -> None: + from butterfly_saxs.butterfly import analyze_butterfly + + frame_mapping, frame_object, qmap, _data, valid = _input_case() + frame = frame_mapping if frame_kind == "mapping" else frame_object + result = analyze_butterfly( + frame, + qmap, + (1.0, 9.0), + options={ + "stage": stage, + "resamples": 0, + "sensitivity": False, + "smoothing_scales": (1.0,), + "run_wang_check": False, + }, + ) + + assert result["diagnostics"]["mask_fraction_in_q_window"] == pytest.approx( + _expected_mask_fraction(valid, qmap) + ) + + +def test_explicit_mask_is_unioned_with_frame_mask_and_include_edit_cannot_restore_it() -> None: + from butterfly_saxs.butterfly import analyze_butterfly + + frame_mapping, _frame_object, qmap, _data, _valid = _input_case() + data_before = np.asarray(frame_mapping["data"]).copy() + valid_before = np.asarray(frame_mapping["valid_mask"]).copy() + explicit = np.zeros_like(valid_before) + explicit[8, 6] = True + qmap_before = {name: np.asarray(value).copy() for name, value in qmap.items() if name in {"qx", "qy", "q"}} + result = analyze_butterfly( + frame_mapping, + qmap, + (1.0, 9.0), + mask=explicit, + options={ + "stage": "trace", + "smoothing_scales": (1.0,), + "run_wang_check": False, + "edits": [ + { + "type": "include_polygon", + "points": [[-2.5, -8.0], [2.5, -8.0], [2.5, 8.0], [-2.5, 8.0]], + } + ], + }, + ) + + assert result["diagnostics"]["mask_fraction_in_q_window"] == pytest.approx( + _expected_mask_fraction(valid_before, qmap, explicit) + ) + np.testing.assert_array_equal(frame_mapping["data"], data_before) + np.testing.assert_array_equal(frame_mapping["valid_mask"], valid_before) + for name, original in qmap_before.items(): + np.testing.assert_array_equal(qmap[name], original) + + +def test_qmap_valid_mask_is_unioned_with_frame_domain() -> None: + from butterfly_saxs.butterfly import analyze_butterfly + + frame_mapping, _frame_object, qmap, _data, valid = _input_case() + qmap_valid = np.ones_like(valid) + qmap_valid[8, 5] = False + qmap_with_mask = {**qmap, "valid_mask": qmap_valid} + result = analyze_butterfly( + frame_mapping, + qmap_with_mask, + (1.0, 9.0), + options={ + "stage": "trace", + "smoothing_scales": (1.0,), + "run_wang_check": False, + }, + ) + + expected_valid = valid & qmap_valid + assert result["diagnostics"]["mask_fraction_in_q_window"] == pytest.approx( + _expected_mask_fraction(expected_valid, qmap) + ) + + +def test_companions_receive_the_same_masked_and_edited_analysis_domain(monkeypatch: pytest.MonkeyPatch) -> None: + import butterfly_saxs.observables as observables + from butterfly_saxs.butterfly import measure_butterfly_observables + from butterfly_saxs.butterfly_ridge import _apply_edits + from butterfly_saxs.ridge_inputs import canonical_inputs + + frame_mapping, _frame_object, qmap, _data, _valid = _input_case() + explicit = np.zeros_like(frame_mapping["valid_mask"]) + explicit[8, 2] = True + edits = [{"type": "exclude_polygon", "points": [[-7.5, -0.5], [-3.5, -0.5], [-3.5, 0.5], [-7.5, 0.5]]}] + captured: dict[str, np.ndarray] = {} + original_angular = observables.measure_angular_spectrum + + def capture_angular(*args: object, **kwargs: object): + captured["angular"] = np.asarray(kwargs["mask"], dtype=bool).copy() + return original_angular(*args, **kwargs) + + def capture_radial(*args: object, **kwargs: object): + captured["radial"] = np.asarray(kwargs["mask"], dtype=bool).copy() + return [], [] + + monkeypatch.setattr(observables, "measure_angular_spectrum", capture_angular) + monkeypatch.setattr(observables, "_measure_lobe_radial_observables", capture_radial) + measure_butterfly_observables( + frame_mapping, + qmap, + (1.0, 9.0), + mask=explicit, + fit_ellipse=False, + options={ + "stage": "trace", + "smoothing_scales": (1.0,), + "run_wang_check": False, + "edits": edits, + }, + ) + + image, qx, qy, q, invalid = canonical_inputs(frame_mapping, qmap, mask=explicit) + expected_valid = ( + ~invalid + & np.isfinite(image) + & np.isfinite(qx) + & np.isfinite(qy) + & np.isfinite(q) + & (q >= 1.0) + & (q <= 9.0) + ) + expected_valid, _, _ = _apply_edits(expected_valid, qx, qy, edits) + np.testing.assert_array_equal(captured["angular"], ~expected_valid) + np.testing.assert_array_equal(captured["radial"], ~expected_valid) + + +def test_metadata_only_q_unit_reaches_observable_bundle() -> None: + from butterfly_saxs.butterfly import measure_butterfly_observables + + frame_mapping, _frame_object, qmap, _data, _valid = _input_case() + result = measure_butterfly_observables( + frame_mapping, + qmap, + (1.0, 9.0), + fit_ellipse=False, + options={ + "stage": "trace", + "companion_observables": False, + "smoothing_scales": (1.0,), + "run_wang_check": False, + }, + ) + + assert result.q_unit == "nm^-1" + assert result.ridge["q_unit"] == "nm^-1" + assert result.butterfly["candidate_fit"]["q_unit"] == "nm^-1" + assert result.butterfly["peak_landmarks"]["q_unit"] == "nm^-1" + + +def _patch_minimal_evaluate(monkeypatch: pytest.MonkeyPatch, captured: dict[str, object]) -> None: + import butterfly_saxs.butterfly as butterfly + import butterfly_saxs.butterfly_ridge as butterfly_ridge + + def fake_trace(*args: object, **kwargs: object) -> dict[str, object]: + captured.setdefault("trace_masks", []).append(np.asarray(kwargs["mask"], dtype=bool).copy()) + return {"points": [], "arcs": [], "profiles": {}, "diagnostics": {}, "method_version": "test"} + + monkeypatch.setattr(butterfly_ridge, "trace_butterfly_ridges", fake_trace) + monkeypatch.setattr( + butterfly, + "_fit_trace", + lambda *_args, **_kwargs: {"success": True, "q_unit": "nm^-1"}, + ) + monkeypatch.setattr( + butterfly, + "evaluate_arc_evidence", + lambda *_args, **_kwargs: {"quality": {}, "quantitative_parameters": {}}, + ) + + +def test_resample_mask_override_cannot_restore_frame_invalid_pixels( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import butterfly_saxs.butterfly as butterfly + + frame_mapping, _frame_object, qmap, _data, valid = _input_case() + captured: dict[str, object] = {} + _patch_minimal_evaluate(monkeypatch, captured) + + def fake_resample(image: np.ndarray, qmap: object, *, mask: np.ndarray, q_window: object, + refit: object, options: dict[str, object], cancel_event: object) -> dict[str, object]: + del qmap, q_window, options, cancel_event + captured["resample_mask"] = np.asarray(mask, dtype=bool).copy() + refit(np.asarray(image, dtype=float).copy(), {"mask": np.zeros_like(mask, dtype=bool)}) + return {"intervals": {}, "coverage_calibrated": False, "status": "mock"} + + import butterfly_saxs.butterfly_uncertainty as uncertainty + + monkeypatch.setattr(uncertainty, "resample_butterfly", fake_resample) + butterfly.analyze_butterfly( + frame_mapping, + qmap, + (1.0, 9.0), + options={ + "stage": "evaluate", + "resamples": 1, + "sensitivity": False, + }, + ) + + expected_invalid = ~valid + np.testing.assert_array_equal(captured["resample_mask"], expected_invalid) + assert len(captured["trace_masks"]) == 2 + for trace_mask in captured["trace_masks"]: + np.testing.assert_array_equal(trace_mask, expected_invalid) + + +def test_sensitivity_receives_canonical_base_invalid_mask(monkeypatch: pytest.MonkeyPatch) -> None: + import butterfly_saxs.butterfly as butterfly + + frame_mapping, _frame_object, qmap, _data, valid = _input_case() + captured: dict[str, object] = {} + _patch_minimal_evaluate(monkeypatch, captured) + + def fake_sensitivity(*args: object, **kwargs: object) -> dict[str, object]: + del args + captured["sensitivity_mask"] = np.asarray(kwargs["mask"], dtype=bool).copy() + return {"completed": True, "records": [], "held_out_arcs": []} + + monkeypatch.setattr(butterfly, "_sensitivity", fake_sensitivity) + butterfly.analyze_butterfly( + frame_mapping, + qmap, + (1.0, 9.0), + options={ + "stage": "evaluate", + "resamples": 0, + "sensitivity": True, + }, + ) + + np.testing.assert_array_equal(captured["sensitivity_mask"], ~valid) diff --git a/tests/test_butterfly_quality_summary.py b/tests/test_butterfly_quality_summary.py new file mode 100644 index 0000000..43462e3 --- /dev/null +++ b/tests/test_butterfly_quality_summary.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from butterfly_saxs.ui.butterfly_summary import build_butterfly_quality_summary +from butterfly_saxs.ui import MainWindow +from butterfly_saxs.ui.butterfly_workbench import ButterflyWorkbench + + +def _points(*, excluded: int = 0) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for index, (branch, side) in enumerate( + ((0, "upper"), (0, "lower"), (1, "upper"), (1, "lower")) + ): + rows.append( + { + "point_id": f"p{index}", + "qx": 0.1 + index * 0.01, + "qy": 0.1, + "branch_id": branch, + "side": side, + "accepted": index >= excluded, + "valid": True, + } + ) + return rows + + +def _result( + *, + quality_status: str = "WARN", + engineering_status: str = "WARN", + flags: list[str] | None = None, + q_unit: str = "nm^-1", + stage: str = "evaluate", +) -> dict[str, object]: + return { + "q_unit": q_unit, + "points": _points(), + "settings": {"stage": stage}, + "quality": { + "status": quality_status, + "engineering_status": engineering_status, + "flags": list(flags or []), + "metrics": { + "side_counts": { + "0:upper": 3, + "0:lower": 3, + "1:upper": 3, + "1:lower": 3, + } + }, + }, + "candidate_fit": { + "axis_ratio": 0.35, + "q_star_from_arcs": 0.092, + "L_from_observed_radius_nm": 68.3, + "q_unit": q_unit, + }, + "quantitative_parameters": { + "a": {"value": 0.2, "status": "available"}, + }, + } + + +def _page(qtbot, *, language: str = "zh_CN") -> ButterflyWorkbench: + page = ButterflyWorkbench(language=language) + qtbot.addWidget(page) + return page + + +def test_summary_trace_and_evaluate_are_distinct(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "trace", "resamples": 0}) + page.set_result(_result(stage="trace", quality_status="NOT_EVALUATED", engineering_status="NOT_EVALUATED")) + assert page.quality_summary.state.status_key == "pending_evaluation" + assert "评估" in page.quality_summary.status_label.text() + + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result(_result()) + assert page.quality_summary.state.status_key == "ellipse_candidate" + assert page.quality_summary.state.side_support == "4/4" + assert page.quality_summary.state.q_star == pytest.approx(0.092) + assert page.quality_summary.state.l_ring == pytest.approx(68.3) + page.close() + + +def test_summary_ring_only_and_poor_match_reasons_are_visible(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result( + _result(flags=["analysis_choice_sensitivity_unassessed", "axis_ratio_at_bound"]) + ) + # The bound flag is a ring-only classification even when a candidate ratio exists. + assert page.quality_summary.state.status_key == "ring_only" + assert "axis_ratio_at_bound" in page.quality_summary.state.reasons + assert "#fff5d6" in page.quality_summary.styleSheet() + + page.set_result( + _result(flags=["analysis_choice_sensitivity_unassessed", "insufficient_occupied_sides", "poor_match"]) + ) + assert page.quality_summary.state.status_key == "ellipse_candidate" + assert page.quality_summary.state.reasons[0] == "poor_match" + assert "insufficient_occupied_sides" in page.quality_summary.state.reasons + assert "#fff0ee" in page.quality_summary.styleSheet() + page.set_result( + _result( + flags=[ + "arc_endpoint_or_manual_bound_dependent", + "disconnected_observed_support", + "observed_support_infeasible", + ] + ) + ) + assert "依赖弧端点或手动边界" in page.quality_summary.reason_label.text() + assert "观测弧支持不连续" in page.quality_summary.reason_label.text() + assert "部分拟合投影超出观测支持" in page.quality_summary.reason_label.text() + page.close() + + +def test_pixel_q_never_displays_physical_ring_period(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "evaluate", "resamples": 0}) + page.set_result(_result(q_unit="pixel-q")) + state = page.quality_summary.state + assert state.q_star == pytest.approx(0.092) + assert state.q_star_unit == "pixel-q" + assert state.l_ring is None + assert state.calibrated is False + assert "uncalibrated_pixel_q" in state.reasons + assert "未标定" in page.quality_summary.unit_label.text() + page.close() + + +def test_explicit_nm_inverse_q_field_keeps_its_declared_unit() -> None: + result = _result(q_unit="Å^-1") + candidate = result["candidate_fit"] + assert isinstance(candidate, dict) + candidate.pop("q_star_from_arcs") + candidate["q_star_nm_inv"] = 0.92 + state = build_butterfly_quality_summary(result, stage="evaluate") + assert state.q_star == pytest.approx(0.92) + assert state.q_star_unit == "nm^-1" + + +def test_cancel_invalidates_summary_and_language_refreshes_labels(qtbot) -> None: + page = _page(qtbot, language="zh_CN") + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result(_result()) + assert page.quality_summary.state.q_star is not None + + page.set_job_status("cancelled", "evaluate") + assert page.quality_summary.state.q_star is None + assert page.quality_summary.state.l_ring is None + assert page.quality_summary.state.status_key == "empty" + + page.set_result(_result()) + page.set_language("en") + assert "Ellipse" in page.quality_summary.status_label.text() + assert "engineering" in page.quality_summary.engineering_label.text() + assert "Scientific acceptance" in page.quality_summary.scientific_label.text() + page.set_language("zh_CN") + assert "科学接受" in page.quality_summary.scientific_label.text() + page.close() + + +def test_old_failed_payload_does_not_survive_retry_running_state(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result(_result(quality_status="FAIL", engineering_status="FAIL")) + assert page.quality_summary.state.status_key == "failed" + + page.set_job_status("running", "evaluate") + assert page.quality_summary.state.status_key == "running" + assert page.quality_summary.state.q_star is None + assert page.quality_summary.state.l_ring is None + + page.set_job_status("cancelled", "evaluate") + assert page.quality_summary.state.status_key == "empty" + page.close() + + +def test_analysis_error_and_cancel_clear_old_measurement_and_export_state(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result(_result()) + assert page.result_fresh + assert page.quality_summary.state.q_star is not None + assert page.export_button.isEnabled() + + page.set_job_status("error", "measure_geometry", error="worker failed") + assert not page.result_fresh + assert page.quality_summary.state.q_star is None + assert page.quality_summary.state.l_ring is None + assert page.quantity_table.rowCount() == 0 + assert not page.export_button.isEnabled() + assert page.quality_summary.state.status_key == "failed" + + page.set_result(_result()) + page.set_job_status("cancelled", "evaluate") + assert not page.result_fresh + assert page.quality_summary.state.q_star is None + assert page.quality_summary.state.l_ring is None + assert page.quantity_table.rowCount() == 0 + assert not page.export_button.isEnabled() + page.close() + + +def test_completed_quality_failure_keeps_new_diagnostic_payload(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + failed = _result(quality_status="FAIL", engineering_status="FAIL", flags=["poor_match"]) + page.set_result(failed) + q_star = page.quality_summary.state.q_star + l_ring = page.quality_summary.state.l_ring + + page.set_job_status("completed", "evaluate", result_ok=False) + assert page.result_fresh + assert page.quality_summary.state.status_key == "failed" + assert page.quality_summary.state.q_star == q_star + assert page.quality_summary.state.l_ring == l_ring + assert page.quantity_table.rowCount() > 0 + assert page.export_button.isEnabled() + page.close() + + +def test_detached_figure_export_error_preserves_fresh_measurement(qtbot) -> None: + page = _page(qtbot) + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result(_result()) + q_star = page.quality_summary.state.q_star + l_ring = page.quality_summary.state.l_ring + + # Cover the normal MainWindow sequence and a direct terminal error without + # depending on the export worker or filesystem dialog. + page.set_busy(True) + page.set_job_status("running", "butterfly_figure_export") + page.set_busy(False) + page.set_job_status("error", "butterfly_figure_export", error="export failed") + assert page.result_fresh + assert page.quality_summary.state.q_star == q_star + assert page.quality_summary.state.l_ring == l_ring + assert page.quality_summary.state.status_key == "ellipse_candidate" + assert page.export_button.isEnabled() + + page.set_result(_result()) + page.set_busy(True) + page.set_job_status("running", "butterfly_figure_export") + page.set_busy(False) + page.set_job_status("cancelled", "cancelled") + assert page.result_fresh + assert page.quality_summary.state.q_star == q_star + assert page.quality_summary.state.l_ring == l_ring + + failed = _result(quality_status="FAIL", engineering_status="FAIL", flags=["poor_match"]) + page.set_result(failed) + failed_q_star = page.quality_summary.state.q_star + page.set_busy(True) + page.set_job_status("running", "butterfly_figure_export") + page.set_busy(False) + # MainWindow restores the pre-export diagnostic state after a detached + # export error; a fresh FAIL payload must remain available for review. + page.set_job_status("failed", "evaluate") + assert page.result_fresh + assert page.quality_summary.state.status_key == "failed" + assert page.quality_summary.state.q_star == failed_q_star + page.close() + + +def test_excluded_count_batch_feedback_and_cell_tooltips_follow_language(qtbot) -> None: + page = _page(qtbot, language="zh_CN") + result = _result() + result["points"] = _points(excluded=1) + result["quantitative_parameters"] = { + "a": { + "value": 0.2, + "status": "undetermined", + "reason": "a long diagnostic reason that should remain available in a tooltip", + } + } + page.set_result(result) + assert "1" in page.excluded_count_label.text() + cell = page.quantity_table.item(0, 5) + assert cell is not None + assert cell.toolTip() == cell.text() + + page.set_batch_feedback(["frame-1"], ["frame-2 failed"]) + assert "批处理" in page.batch_feedback_label.text() + page.set_language("en") + assert "excluded" in page.excluded_count_label.text() + assert "Batch applied" in page.batch_feedback_label.text() + refreshed = page.quantity_table.item(0, 5) + assert refreshed is not None + assert refreshed.toolTip() == refreshed.text() + page.close() + + +def test_summary_long_observables_keep_full_bbox_at_980_and_1600(qtbot, tmp_path) -> None: + window = MainWindow(engine=object(), auto_preview=False, language="en") + qtbot.addWidget(window) + page = window.butterfly_workbench + result = _result() + candidate = result["candidate_fit"] + assert isinstance(candidate, dict) + candidate["q_star_from_arcs"] = 0.330123456 + candidate["L_from_observed_radius_nm"] = 18.9123456 + result["quality"]["flags"] = ["poor_match"] + page.set_analysis_settings({"stage": "evaluate", "resamples": 32}) + page.set_result(result) + window.show() + qtbot.waitForWindowShown(window) + for width, name in ((980, "980"), (1600, "1600")): + window.resize(width, 680 if width == 980 else 1050) + qtbot.waitUntil(lambda: window.width() == width, timeout=1_000) + assert page.quality_summary.width() <= width + for label in ( + page.quality_summary.status_label, + page.quality_summary.engineering_label, + page.quality_summary.support_label, + page.quality_summary.q_star_label, + page.quality_summary.l_ring_label, + page.quality_summary.unit_label, + ): + required = label.fontMetrics().horizontalAdvance(label.text()) + assert label.geometry().width() >= required, (name, label.objectName(), label.text()) + assert label.geometry().height() >= label.fontMetrics().height() + assert "#fff0ee" in page.quality_summary.styleSheet() + assert page.quality_summary.state.engineering_status == "WARN" + assert page.save_screenshot(tmp_path / f"quality-summary-{name}.png").exists() + window.close() diff --git a/tests/test_butterfly_workflow_upgrade.py b/tests/test_butterfly_workflow_upgrade.py index a75514d..f4245ce 100644 --- a/tests/test_butterfly_workflow_upgrade.py +++ b/tests/test_butterfly_workflow_upgrade.py @@ -280,26 +280,6 @@ def test_figure_button_uses_selected_nature_width_600dpi_and_frozen_measurement_ captured = [] started = threading.Event() release = threading.Event() - dialog_state = {} - - monkeypatch.setattr( - QtWidgets.QFileDialog, - "getExistingDirectory", - lambda *args, **kwargs: str(tmp_path), - ) - - def choose_item(parent, title, label, items, current, editable): - dialog_state["items"] = list(items) - dialog_state["default_index"] = current - dialog_state["editable"] = editable - return items[choice_index], True - - def choose_dpi(parent, title, label, value, minimum, maximum, step): - dialog_state["dpi_default"] = value - return value, True - - monkeypatch.setattr(QtWidgets.QInputDialog, "getItem", choose_item) - monkeypatch.setattr(QtWidgets.QInputDialog, "getInt", choose_dpi) def fake_export(target, **kwargs): captured.append((target, kwargs)) @@ -324,13 +304,25 @@ def fake_export(target, **kwargs): assert page.figure_export_button.text() == "Export figure" assert page.figure_export_button.isEnabled() page.figure_export_button.click() + dialog = window._figure_export_dialog + assert dialog is not None and dialog.isVisible() + dialog.parent_dir_edit.setText(str(tmp_path)) + dialog.width_combo.setCurrentIndex(choice_index) + dialog.dpi_combo.setCurrentIndex(dialog.dpi_combo.findData(600)) + assert [dialog.width_combo.itemData(i) for i in range(dialog.width_combo.count())] == [ + 89.0, + 183.0, + ] + assert [dialog.dpi_combo.itemData(i) for i in range(dialog.dpi_combo.count())] == [ + 300, + 600, + 1200, + ] + dialog.start_button.click() qtbot.waitUntil(started.is_set, timeout=2_000) qtbot.waitUntil(lambda: "47%" in page.status_label.text(), timeout=2_000) assert "running" in page.status_label.text().lower() assert "s" in page.status_label.text().lower() - assert dialog_state["items"] == ["Single column · 89 mm", "Double column · 183 mm"] - assert dialog_state["default_index"] == 1 - assert dialog_state["dpi_default"] == 600 target, kwargs = captured[0] assert kwargs["width_mm"] == expected_width assert kwargs["dpi"] == 600 @@ -378,26 +370,15 @@ def fake_export(target, **kwargs): return {"manifest": Path(target) / "manifest.json"} monkeypatch.setattr(figure_module, "export_butterfly_figure", fake_export) - monkeypatch.setattr( - QtWidgets.QFileDialog, - "getExistingDirectory", - lambda *args, **kwargs: str(tmp_path), - ) - monkeypatch.setattr( - QtWidgets.QInputDialog, - "getItem", - lambda parent, title, label, items, current, editable: (items[0], True), - ) - monkeypatch.setattr( - QtWidgets.QInputDialog, - "getInt", - lambda parent, title, label, value, minimum, maximum, step: (value, True), - ) page.set_result({"status": "failed", "points": [], "quantitative_parameters": {}}) assert page.result_fresh assert page.figure_export_button.isEnabled() assert "failed" in page.status_label.text().lower() page.figure_export_button.click() + dialog = window._figure_export_dialog + assert dialog is not None and dialog.isVisible() + dialog.parent_dir_edit.setText(str(tmp_path)) + dialog.start_button.click() qtbot.waitUntil(lambda: bool(calls), timeout=2_000) qtbot.waitUntil(lambda: not window._workers, timeout=3_000) assert calls[0]["result"]["status"] == "failed" @@ -454,7 +435,9 @@ def fail_export(*args, **kwargs): assert page.result_fresh assert page.current_result["points"] == result["points"] assert page.figure_export_button.isEnabled() - assert "failed" in page.status_label.text().lower() + # A figure-render error belongs to the export job; it must not turn the + # current butterfly measurement into a failed measurement status. + assert "result" in page.status_label.text().lower() assert window._status_key == "status.butterfly_figure_export_failed" window.close() @@ -491,7 +474,8 @@ def wait_then_cancel(target, **kwargs): release.set() qtbot.waitUntil(lambda: not window._workers, timeout=3_000) assert not page.cancel_button.isEnabled() - assert "cancelled" in page.status_label.text().lower() + assert window._status_key == "status.cancelled" + assert "result" in page.status_label.text().lower() assert page.result_fresh assert page.figure_export_button.isEnabled() window.close() @@ -540,3 +524,57 @@ def delayed_export(target, **kwargs): assert page.figure_export_button.isEnabled() is False assert window._last_butterfly_figure_paths["manifest"].parent == tmp_path / "old-frame-figure" window.close() + + +def test_completed_modeless_export_marks_previous_snapshot_and_refreshes_before_restart( + qtbot, tmp_path, monkeypatch +): + import butterfly_saxs.butterfly_figure as figure_module + + window, _, observed, *_ = _window(qtbot) + page = window.butterfly_workbench + _set_page_result(window) + window._frame = "old-frame" + calls = [] + + def fake_export(target, **kwargs): + calls.append(kwargs) + target.mkdir(parents=True, exist_ok=False) + manifest = target / "manifest.json" + index = target / "index.html" + manifest.write_text("{}", encoding="utf-8") + index.write_text("", encoding="utf-8") + return {"manifest": manifest, "index": index} + + monkeypatch.setattr(figure_module, "export_butterfly_figure", fake_export) + page.figure_export_button.click() + dialog = window._figure_export_dialog + assert dialog is not None and dialog.isVisible() + dialog.parent_dir_edit.setText(str(tmp_path)) + dialog.start_button.click() + qtbot.waitUntil(lambda: not window._workers, timeout=3_000) + assert calls[0]["q_unit"] == "1/nm" + assert not dialog.export_is_stale + + replacement = np.full_like(observed, 5.0) + yy, xx = np.indices(replacement.shape, dtype=float) + window._frame = "new-frame" + window.set_observed_data( + replacement, + qmap={ + "qx": (xx - 3.5) / 5.0, + "qy": (yy - 3.5) / 5.0, + "q_unit": "1/A", + }, + ) + page.set_result({"points": [], "quantitative_parameters": {}}) + qtbot.waitUntil(lambda: dialog.export_is_stale, timeout=2_000) + assert "1/A" in dialog.q_unit_label.text() + assert "old-frame" in dialog.snapshot_label.text() + assert "previous frame" in dialog.status_label.text() + + dialog.start_button.click() + qtbot.waitUntil(lambda: not window._workers, timeout=3_000) + assert calls[-1]["q_unit"] == "1/A" + assert not dialog.export_is_stale + window.close() diff --git a/tests/test_figure_bundle_review.py b/tests/test_figure_bundle_review.py new file mode 100644 index 0000000..8322522 --- /dev/null +++ b/tests/test_figure_bundle_review.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import csv +import hashlib +import json +import re +from pathlib import Path + +import numpy as np +import pytest +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure + +from butterfly_saxs.butterfly_figure import ( + NATURE_FIGURE_GUIDE_URL, + _caption, + _draw_radial_diagnostic, + _prepare_inputs, + _settings, + export_butterfly_figure, +) + + +def _fixture() -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, object]]: + rows, cols = np.indices((9, 9), dtype=np.float64) + qx = (cols - 4.0) * 0.01 + qy = (rows - 4.0) * 0.01 + observed = 2.0 + np.exp(-((qx / 0.018) ** 2 + (qy / 0.027) ** 2)) + result: dict[str, object] = { + "measurement_status": "traced", + "scientific_status": "NOT_ACCEPTED", + "points": [], + "arcs": [], + "candidate_fit": {"status": "ring_only"}, + "diagnostics": {"q_window": [0.0, 0.06]}, + } + return observed, qx, qy, result + + +def _radial_axes( + means: np.ndarray, linthresh: float, *, width_mm: float = 89.0 +) -> tuple[Figure, object]: + figure = Figure(figsize=(width_mm / 25.4, 150.0 / 25.4), dpi=120) + FigureCanvasAgg(figure) + profile_ax = figure.add_axes([0.14, 0.17, 0.76, 0.18]) + count_ax = figure.add_axes([0.14, 0.39, 0.76, 0.045], sharex=profile_ax) + edges = np.linspace(0.1, 0.5, len(means) + 1) + _draw_radial_diagnostic( + profile_ax, + count_ax, + { + "radial_centers": 0.5 * (edges[:-1] + edges[1:]), + "radial_edges": edges, + "radial_mean": means, + "radial_counts": np.arange(1, len(means) + 1), + "radial_linthresh": linthresh, + "q_unit": "nm^-1", + }, + counts_bottom=False, + ) + figure.canvas.draw() + return figure, profile_ax + + +@pytest.mark.parametrize( + ("means", "linthresh"), + ( + (np.array([3.2, 3.4, 3.6, 3.8, 4.0, 4.2]), 2.8), + (np.array([-4.2, -4.0, -3.8, -3.6, -3.4, -3.2]), 2.8), + (np.array([-0.004, -0.002, 0.0, 0.001, 0.003, 0.005]), 0.001), + ), +) +def test_radial_symlog_keeps_visible_labels_for_narrow_ranges( + means: np.ndarray, linthresh: float +) -> None: + figure, profile_ax = _radial_axes(means, linthresh) + try: + labels = [ + label + for label in profile_ax.get_yticklabels() + if label.get_visible() and label.get_text().strip() + ] + assert profile_ax.get_yscale() == "symlog" + assert len(labels) >= 2 + finally: + figure.clear() + + +def test_radial_fallback_labels_fit_89mm_profile_panel() -> None: + figure, profile_ax = _radial_axes( + np.array([3.2, 3.4, 3.6, 3.8, 4.0, 4.2]), 2.8 + ) + try: + renderer = figure.canvas.get_renderer() + canvas = figure.bbox + boxes = [ + label.get_window_extent(renderer) + for label in profile_ax.get_yticklabels() + if label.get_visible() and label.get_text().strip() + ] + assert len(boxes) >= 2 + assert all( + box.x0 >= canvas.x0 - 1.0 + and box.y0 >= canvas.y0 - 1.0 + and box.x1 <= canvas.x1 + 1.0 + and box.y1 <= canvas.y1 + 1.0 + for box in boxes + ) + assert all(not first.overlaps(second) for index, first in enumerate(boxes) for second in boxes[index + 1 :]) + finally: + figure.clear() + + +@pytest.mark.parametrize("display_scale", ("linear", "log1p", "asinh")) +def test_display_metadata_records_signed_input_preservation( + display_scale: str, +) -> None: + observed = np.array([[-3.0, -1.0], [0.0, 2.0]], dtype=np.float64) + qx = np.array([[-0.02, 0.02], [-0.02, 0.02]], dtype=np.float64) + qy = np.array([[-0.02, -0.02], [0.02, 0.02]], dtype=np.float64) + data = _prepare_inputs( + observed=observed, + qx=qx, + qy=qy, + valid_mask=None, + result={}, + q_unit="nm^-1", + context=None, + display_scale=display_scale, + width_mm=89.0, + dpi=120, + ) + settings = _settings(data) + assert settings["intensity_display"]["negative_values_preserved"] is True + assert settings["intensity_display"]["negative_values_preserved_by_signed_transform"] is ( + display_scale in {"log1p", "asinh"} + ) + caption = _caption(data) + if display_scale == "linear": + assert "linear, untransformed" in caption + elif display_scale == "asinh": + assert "asinh(I)" in caption + else: + assert "signed log1p" in caption + + +def test_figure_bundle_has_offline_browser_raw_radial_profile_and_qa( + tmp_path: Path, +) -> None: + observed, qx, qy, result = _fixture() + valid = np.ones(observed.shape, dtype=bool) + valid[0, :] = False + valid[:, 0] = False + + output = export_butterfly_figure( + tmp_path / "bundle", + observed=observed, + qx=qx, + qy=qy, + valid_mask=valid, + result=result, + q_unit="nm^-1", + display_scale="asinh", + width_mm=89.0, + dpi=120, + ) + + for key in ("index", "radial_profile", "figure_qa", "manifest"): + assert output[key].is_file() + + with np.load(output["source_data"], allow_pickle=False) as source: + edges = source["radial_q_edges"] + centers = 0.5 * (edges[:-1] + edges[1:]) + raw_sum = source["radial_intensity_sum_raw"] + raw_mean = source["radial_intensity_mean_raw"] + counts = source["radial_valid_pixel_counts"] + display_pixel_count = int(np.count_nonzero(source["display_valid_mask"])) + + with output["radial_profile"].open(encoding="utf-8-sig", newline="") as stream: + rows = list(csv.DictReader(stream)) + assert list(rows[0]) == [ + "bin_index", + "q_unit", + "edge_left", + "edge_right", + "center", + "raw_sum", + "raw_mean", + "count", + ] + assert len(rows) == len(centers) + for index, row in enumerate(rows): + assert int(row["bin_index"]) == index + assert row["q_unit"] == "nm^-1" + assert float(row["edge_left"]) == np.float64(edges[index]) + assert float(row["edge_right"]) == np.float64(edges[index + 1]) + assert float(row["center"]) == np.float64(centers[index]) + assert int(row["count"]) == int(counts[index]) + if counts[index] == 0: + assert row["raw_sum"] == "" + assert row["raw_mean"] == "" + else: + assert float(row["raw_sum"]) == np.float64(raw_sum[index]) + assert float(row["raw_mean"]) == np.float64(raw_mean[index]) + assert sum(int(row["count"]) for row in rows) == display_pixel_count + + qa = json.loads(output["figure_qa"].read_text(encoding="utf-8")) + assert qa["status"] == "WARN" + assert qa["dpi"] == 120 + assert qa["checks"]["dpi"]["status"] == "WARN" + assert qa["checks"]["physical_size"]["status"] == "configured" + assert qa["checks"]["editable_text"]["status"] == "configured" + assert qa["q_unit"] == "nm^-1" + assert qa["scientific_acceptance"] == "not_assessed" + assert qa["no_fabricated_points"] is True + assert qa["nature_reference"] == NATURE_FIGURE_GUIDE_URL + + browser = output["index"].read_text(encoding="utf-8") + for text in ( + "Measured", + "Candidate", + "Model", + "Scientific status", + "NOT_ACCEPTED", + "radial_profile.csv", + "source_data.npz", + "caption.txt", + "manifest.json", + "peak_map.png", + ): + assert text in browser + assert "http://" not in browser + assert "https://" not in browser + assert "file:///" not in browser + assert "E:\\" not in browser + for relative in re.findall(r'href="([^"]+)"|src="([^"]+)"', browser): + target = relative[0] or relative[1] + assert not target.startswith(("http:", "https:", "file:")) + assert (output["index"].parent / target).is_file(), target + + caption = output["caption"].read_text(encoding="utf-8") + assert "asinh(I)" in caption + assert "signed log1p" not in caption + + manifest = json.loads(output["manifest"].read_text(encoding="utf-8")) + for name in ("index.html", "radial_profile.csv", "figure_qa.json"): + assert name in manifest["sha256"] + assert hashlib.sha256((output["index"].parent / name).read_bytes()).hexdigest() == manifest["sha256"][name] diff --git a/tests/test_figure_export_dialog.py b/tests/test_figure_export_dialog.py new file mode 100644 index 0000000..2c4979d --- /dev/null +++ b/tests/test_figure_export_dialog.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from butterfly_saxs.ui.figure_export_dialog import FigureExportDialog, _pixel_size + + +def test_dialog_preset_dimensions_are_explicit_and_static_preview_is_not_claimed_as_rendered(qtbot): + dialog = FigureExportDialog(language="en", q_unit="1/nm") + qtbot.addWidget(dialog) + + assert [dialog.width_combo.itemData(i) for i in range(dialog.width_combo.count())] == [ + 89.0, + 183.0, + ] + assert dialog.width_combo.itemText(0) == "Single column · 89 mm" + assert dialog.width_combo.itemText(1) == "Double column · 183 mm" + assert [dialog.dpi_combo.itemData(i) for i in range(dialog.dpi_combo.count())] == [ + 300, + 600, + 1200, + ] + assert _pixel_size(89.0, 300) == (1051, 1771) + assert _pixel_size(183.0, 1200) == (8645, 4440) + assert "not a rendered" in dialog._preview_caption.text().lower() + assert dialog.start_button.isDefault() + + +def test_dialog_uses_non_overwriting_subfolder_and_surfaces_uncalibrated_q(tmp_path, qtbot): + (tmp_path / "butterfly-figure").mkdir() + dialog = FigureExportDialog( + language="zh_CN", + q_unit="pixel-q", + stage="evaluate", + quality_status="WARN", + scientific_status="NOT_ACCEPTED", + has_qx=False, + has_qy=True, + ) + qtbot.addWidget(dialog) + dialog.parent_dir_edit.setText(str(tmp_path)) + + assert "butterfly-figure-2" in dialog.target_preview_label.text() + assert "无物理 q 标定" in dialog.q_unit_label.text() + assert "诊断图" in dialog.q_unit_label.text() + assert "NOT_ACCEPTED" in dialog.scientific_label.text() + assert "qx" in dialog.coordinates_label.text() + assert dialog._parent_label.buddy() is dialog.parent_dir_edit + assert dialog._width_label.buddy() is dialog.width_combo + + +def test_dialog_start_is_single_signal_and_open_button_requires_explicit_click(tmp_path, qtbot): + dialog = FigureExportDialog(language="en") + qtbot.addWidget(dialog) + dialog.parent_dir_edit.setText(str(tmp_path)) + emitted = [] + opened = [] + dialog.startRequested.connect(emitted.append) + dialog.openRequested.connect(opened.append) + + dialog.start_button.click() + qtbot.wait(10) + assert len(emitted) == 1 + assert emitted[0]["parent"] == tmp_path.resolve() + assert emitted[0]["width_mm"] == 183.0 + assert emitted[0]["dpi"] == 600 + assert dialog.is_running + assert not dialog.start_button.isEnabled() + + index = tmp_path / "bundle" / "index.html" + index.parent.mkdir() + index.write_text("", encoding="utf-8") + dialog.set_exported_paths({"index": index}) + assert dialog.open_button.isEnabled() + dialog.open_button.click() + assert opened == [index.resolve()] + + +def test_dialog_retranslates_while_open_and_refreshes_context(qtbot): + dialog = FigureExportDialog(language="en", q_unit="1/nm", stage="trace") + qtbot.addWidget(dialog) + dialog.set_measurement_context( + { + "q_unit": "unknown", + "stage": "evaluate", + "quality_status": "FAIL", + "scientific_status": "not_assessed", + "has_qx": True, + "has_qy": False, + } + ) + dialog.set_language("zh_CN") + assert "评估" in dialog.stage_label.text() + assert "无物理 q 标定" in dialog.q_unit_label.text() + assert "qy" in dialog.coordinates_label.text() + assert "失败" not in dialog.quality_label.text() + assert "FAIL" in dialog.quality_label.text() + + +def test_dialog_cancel_emits_worker_cancel_when_running(qtbot): + dialog = FigureExportDialog(language="en") + qtbot.addWidget(dialog) + cancelled = [] + dialog.cancelRequested.connect(lambda: cancelled.append(True)) + dialog.set_export_running(True) + dialog.cancel_button.click() + assert cancelled == [True] + assert not dialog.cancel_button.isEnabled() + + +def test_dialog_retranslates_running_error_and_stale_statuses(tmp_path, qtbot): + dialog = FigureExportDialog(language="en") + qtbot.addWidget(dialog) + dialog.set_export_running(True) + dialog.set_language("zh_CN") + assert "后台导出" in dialog.status_label.text() + dialog.set_export_error("render failed") + dialog.set_language("en") + assert "Export failed" in dialog.status_label.text() + index = tmp_path / "bundle" / "index.html" + index.parent.mkdir() + index.write_text("", encoding="utf-8") + dialog.set_exported_paths( + {"index": index}, + source_context={"source": "frame-A", "frame": 4}, + stale=True, + ) + dialog.set_language("zh_CN") + assert "此前" in dialog.status_label.text() + assert "frame-A" in dialog.snapshot_label.text() diff --git a/tests/test_radial_hint_cache.py b/tests/test_radial_hint_cache.py index 45641c8..41c85a1 100644 --- a/tests/test_radial_hint_cache.py +++ b/tests/test_radial_hint_cache.py @@ -164,7 +164,9 @@ def resample_stub(image, _qmap, *, refit, **_kwargs): refit( image, { - "qmap": {"q_unit": "A^-1"}, + # Keep the callback fixture within the public q-map input + # contract; only the declared perturbation varies per case. + "qmap": _qmap, "mask": None, "qmap_perturbation": perturbation, }, diff --git a/tests/test_review_contracts.py b/tests/test_review_contracts.py index 0e5d455..f7d500a 100644 --- a/tests/test_review_contracts.py +++ b/tests/test_review_contracts.py @@ -130,6 +130,7 @@ def fake_measure(*_args, **_kwargs): ) assert result.analysis["butterfly"] == { "stage": "trace", + "trace_method": "curvature", "resamples": 3, "seed": 20260906, "edits": [], diff --git a/tests/test_sector_peak_figures.py b/tests/test_sector_peak_figures.py new file mode 100644 index 0000000..3ea9a48 --- /dev/null +++ b/tests/test_sector_peak_figures.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import csv +import hashlib +import json +from pathlib import Path + +import numpy as np + +from butterfly_saxs.sector_peak_figures import ( + export_sector_peak_figures, + render_sector_peak_figures, +) +from butterfly_saxs.sector_peaks import measure_sector_peaks +from butterfly_saxs.butterfly_figure import export_butterfly_figure + + +def _sector_result() -> dict: + axis = np.arange(81, dtype=float) - 40.0 + qy, qx = np.meshgrid(axis * 0.006, axis * 0.006, indexing="ij") + q = np.hypot(qx, qy) + image = 0.1 + np.exp(-0.5 * ((q - 0.18) / 0.014) ** 2) + return measure_sector_peaks( + image, + {"qx": qx, "qy": qy, "q": q, "metadata": {"q_unit": "nm^-1"}}, + (0.08, 0.28), + options={"requested_radial_bins": 28}, + ) + + +def _assert_visible_text_bboxes_are_inside_figure(figure) -> list[str]: + figure.canvas.draw() + renderer = figure.canvas.get_renderer() + container = figure.bbox + artists = list(figure.texts) + for axis in figure.axes: + artists.extend((axis.title, axis.xaxis.label, axis.yaxis.label)) + artists.extend(axis.texts) + for legend in figure.legends: + artists.extend(legend.get_texts()) + visible_text = [] + for artist in artists: + if not artist.get_visible() or not artist.get_text().strip(): + continue + bbox = artist.get_window_extent(renderer) + assert bbox.x0 >= container.x0 - 1.0 + assert bbox.y0 >= container.y0 - 1.0 + assert bbox.x1 <= container.x1 + 1.0 + assert bbox.y1 <= container.y1 + 1.0 + visible_text.append(artist.get_text()) + return visible_text + + +def test_sector_figures_keep_missing_bins_blank_and_selected_markers_are_points() -> None: + result = _sector_result() + result["sectors"][0]["raw_mean"][5:8] = np.nan + result["sectors"][0]["smoothed_intensity"][5:8] = np.nan + figures = render_sector_peak_figures( + result, + data={"q_unit": "nm^-1", "context": {"metadata": {"header": {"IntensityUnit": "cm^-1"}}}}, + width_mm=89, + dpi=120, + ) + + qchi = figures["sector_qchi"] + assert qchi.get_size_inches()[0] * 25.4 == 89.0 + assert any(collection.get_offsets().shape[0] >= 0 for collection in qchi.axes[0].collections) + profiles = figures["sector_profiles"] + labels = [line.get_label() for line in profiles.axes[0].lines] + assert any("raw sector mean" in label for label in labels) + assert any("locator-only smooth" in label for label in labels) + assert all("fit" not in label.lower() for label in labels) + for figure in figures.values(): + figure.clear() + + +def test_sector_figure_text_layout_is_contained_at_both_publication_widths() -> None: + result = _sector_result() + for width in (89.0, 183.0): + figures = render_sector_peak_figures( + result, + data={"q_unit": "nm^-1", "context": {"metadata": {"header": {"IntensityUnit": "cm^-1"}}}}, + width_mm=width, + dpi=120, + ) + qchi_text = _assert_visible_text_bboxes_are_inside_figure(figures["sector_qchi"]) + profile_text = _assert_visible_text_bboxes_are_inside_figure(figures["sector_profiles"]) + qchi_lower = [text.lower() for text in qchi_text] + profile_lower = [text.lower() for text in profile_text] + assert any("raw sector-integrated" in text for text in qchi_lower) + assert any("raw sector mean" in text for text in profile_lower) + assert any("locator-only smooth" in text for text in profile_lower) + assert any("coverage" in text for text in profile_lower) + for figure in figures.values(): + figure.clear() + + +def test_sector_export_writes_auditable_profiles_peaks_npz_and_hashes(tmp_path: Path) -> None: + result = _sector_result() + result["sectors"][0]["selected_peak"] = None + result["sectors"][0]["status"] = "no_peak" + result["sectors"][0]["reason"] = "insufficient_two_sided_support" + output, metadata = export_sector_peak_figures( + tmp_path, + data={ + "q_unit": "nm^-1", + "context": {"metadata": {"header": {"IntensityUnit": "cm^-1"}}}, + "width_mm": 183, + "dpi": 120, + }, + result=result, + ) + + expected = { + "sector_qchi_svg", "sector_qchi_pdf", "sector_qchi_png", "sector_qchi_tiff", + "sector_profiles_svg", "sector_profiles_pdf", "sector_profiles_png", "sector_profiles_tiff", + "sector_profiles_csv", "sector_peaks_csv", "sector_profiles_npz", "sector_caption", "sector_manifest", + } + assert set(output) == expected + assert metadata["intensity_unit"] == "cm^-1" + assert metadata["smooth_is_locator_only"] is True + assert metadata["fwhm_is_not_confidence_interval"] is True + with output["sector_profiles_csv"].open(encoding="utf-8-sig", newline="") as stream: + rows = list(csv.DictReader(stream)) + assert rows + assert {"raw_mean", "raw_count", "geometric_count", "coverage", "sector_reason"} <= set(rows[0]) + assert any(row["sector_reason"] == "insufficient_two_sided_support" for row in rows) + with np.load(output["sector_profiles_npz"], allow_pickle=False) as arrays: + assert arrays["raw_mean"].ndim == 2 + assert arrays["supported_bin_mask"].dtype == np.dtype(bool) + assert arrays["intensity_unit"].item() == "cm^-1" + np.testing.assert_allclose(arrays["q_window"], result["q_window"]) + caption = output["sector_caption"].read_text(encoding="utf-8") + assert "locator-only" in caption + assert "not a confidence interval" in caption + assert "correlated" in caption + assert "Finite raw means remain visible" in caption + assert "unsupported bins remain blank" not in caption + manifest = json.loads(output["sector_manifest"].read_text(encoding="utf-8")) + assert manifest["manifest_excluded_from_own_sha256"] is True + for name, digest in manifest["sha256"].items(): + assert hashlib.sha256((tmp_path / name).read_bytes()).hexdigest() == digest + + +def test_intensity_unit_does_not_come_from_a_filename(tmp_path: Path) -> None: + result = _sector_result() + result["q_unit"] = "-0.25" + result["sectors"][0]["status"] = "=STATUS()" + result["sectors"][0]["reason"] = "=SUM(A1:A2)" + result["sectors"][0]["candidates"] = [ + { + "status": "rejected", + "reason": "flat_top_unresolved_peak", + "height_snr": 7.25, + "n_eff": 13.5, + "flat_top_ratio": 0.42, + "settings": {"min_prominence_sigma": 3.0}, + } + ] + stage = tmp_path / "looks_like_cm-1_data" + stage.mkdir() + output, metadata = export_sector_peak_figures( + stage, + data={"q_unit": "nm^-1", "context": {}, "width_mm": 89, "dpi": 120}, + result=result, + ) + assert metadata["intensity_unit"] == "input intensity units" + with output["sector_profiles_csv"].open(encoding="utf-8-sig", newline="") as stream: + profile_rows = list(csv.DictReader(stream)) + assert profile_rows[0]["q_unit"] == "-0.25" + assert profile_rows[0]["sector_status"] == "'=STATUS()" + assert profile_rows[0]["sector_reason"] == "'=SUM(A1:A2)" + with output["sector_peaks_csv"].open(encoding="utf-8-sig", newline="") as stream: + peak_rows = list(csv.DictReader(stream)) + candidate_row = next(row for row in peak_rows if row["record_type"] == "candidate") + candidate = json.loads(candidate_row["candidate_json"]) + assert candidate["height_snr"] == 7.25 + assert candidate["n_eff"] == 13.5 + assert candidate["flat_top_ratio"] == 0.42 + assert candidate["settings"]["min_prominence_sigma"] == 3.0 + assert "no unit inferred" in output["sector_caption"].read_text(encoding="utf-8") + + +def test_butterfly_bundle_adds_sector_assets_only_when_result_contains_measurement( + tmp_path: Path, +) -> None: + axis = np.arange(12, dtype=float) - 5.5 + qy, qx = np.meshgrid(axis * 0.01, axis * 0.01, indexing="ij") + q = np.hypot(qx, qy) + observed = 0.1 + np.exp(-0.5 * ((q - 0.035) / 0.007) ** 2) + sector_result = measure_sector_peaks( + observed, + {"qx": qx, "qy": qy, "q": q, "metadata": {"q_unit": "nm^-1"}}, + (0.02, 0.08), + options={"requested_radial_bins": 10}, + ) + result = {"points": [], "arcs": [], "sector_peaks": sector_result} + output = export_butterfly_figure( + tmp_path / "bundle", + observed=observed, + qx=qx, + qy=qy, + result=result, + q_unit="nm^-1", + context={"metadata": {"header": {"IntensityUnit": "cm^-1"}}}, + dpi=72, + ) + assert output["sector_qchi_png"].is_file() + assert output["sector_profiles_csv"].is_file() + main_manifest = json.loads(output["manifest"].read_text(encoding="utf-8")) + assert main_manifest["sector_manifest_file"] == "sector_manifest.json" + assert "sector_qchi.png" in main_manifest["files"] + html = output["index"].read_text(encoding="utf-8") + assert "Sector-integrated primary peaks" in html + assert "Pixel-brightness ancillary diagnostics" in html diff --git a/tests/test_sector_peaks.py b/tests/test_sector_peaks.py new file mode 100644 index 0000000..e6e76b5 --- /dev/null +++ b/tests/test_sector_peaks.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from butterfly_saxs.sector_peaks import measure_sector_peaks + + +def _qmap(size: int = 181, step: float = 0.005, *, unit: str = "nm^-1") -> tuple[np.ndarray, dict[str, np.ndarray | str]]: + coordinates = np.arange(size, dtype=float) - 0.5 * (size - 1) + qy, qx = np.meshgrid(coordinates * step, coordinates * step, indexing="ij") + q = np.hypot(qx, qy) + return q, {"qx": qx, "qy": qy, "q": q, "metadata": {"q_unit": unit}} + + +def _ring_image(q: np.ndarray, peak: float = 0.30, width: float = 0.012) -> np.ndarray: + return 0.08 + np.exp(-0.5 * ((q - peak) / width) ** 2) + + +def _sector(result: dict, angle: float) -> dict: + sectors = result["sectors"] + return min(sectors, key=lambda item: abs(((item["angle_deg"] - angle + 180.0) % 360.0) - 180.0)) + + +def test_ring_and_four_lobe_amplitude_measure_one_observed_peak_per_sector() -> None: + q, qmap = _qmap() + angles = np.mod(np.degrees(np.arctan2(qmap["qy"], qmap["qx"])), 360.0) + intensity = _ring_image(q) + amplitude = 0.25 + 0.75 * np.square(np.cos(np.radians(2.0 * angles))) + image = intensity * amplitude + result = measure_sector_peaks(image, qmap, (0.18, 0.42)) + + assert result["method_version"].startswith("sector-peaks-v1") + assert result["q_unit"] == "nm^-1" + assert result["sector_overlap"]["overlapping"] is True + for sector in result["sectors"]: + assert sector["raw_mean"].shape == sector["raw_sum"].shape == sector["raw_count"].shape + assert sector["geometry_count"].shape == sector["coverage"].shape + strong = _sector(result, 0.0) + assert strong["selected_peak"] is not None + assert strong["selected_peak"]["q_star"] == pytest.approx(0.30, abs=0.018) + assert strong["selected_peak"]["pixel_x"] is not None + assert strong["selected_peak"]["pixel_y"] is not None + assert strong["selected_peak"]["source_pixel_count"] > 0 + assert strong["selected_peak"]["sampling_sigma_basis"].endswith("sampling_resolution_not_CI") + assert strong["sector_coverage"] == pytest.approx(1.0) + + +def test_low_q_monotonic_tail_does_not_win_against_supported_outer_peak() -> None: + q, qmap = _qmap() + image = 1.8 / np.maximum(q, 0.02) + 1.5 * np.exp(-0.5 * ((q - 0.34) / 0.014) ** 2) + result = measure_sector_peaks(image, qmap, (0.10, 0.48)) + sector = _sector(result, 0.0) + + assert sector["selected_peak"] is not None + assert sector["selected_peak"]["q_star"] == pytest.approx(0.34, abs=0.025) + assert sector["selected_peak"]["peak_bin_index"] > 2 + + +def test_monotonic_and_pure_noise_profiles_are_not_promoted_to_peaks() -> None: + q, qmap = _qmap() + monotonic = 2.0 / np.maximum(q, 0.02) + monotonic_result = measure_sector_peaks(monotonic, qmap, (0.10, 0.48)) + monotonic_sector = _sector(monotonic_result, 0.0) + assert monotonic_sector["selected_peak"] is None + assert "boundary" in monotonic_sector["reason"] or "monotonic" in monotonic_sector["reason"] + + rng = np.random.default_rng(1234) + noisy = 1.0 + 0.02 * rng.normal(size=q.shape) + noise_result = measure_sector_peaks(noisy, qmap, (0.10, 0.48)) + assert all(sector["selected_peak"] is None for sector in noise_result["sectors"]) + + +def test_two_similar_supported_peaks_remain_ambiguous() -> None: + q, qmap = _qmap() + image = ( + 0.05 + + 1.0 * np.exp(-0.5 * ((q - 0.25) / 0.012) ** 2) + + 0.98 * np.exp(-0.5 * ((q - 0.36) / 0.012) ** 2) + ) + result = measure_sector_peaks( + image, + qmap, + (0.12, 0.46), + options={"selection_prominence_ratio": 1.25}, + ) + sector = _sector(result, 0.0) + + assert sector["selected_peak"] is None + assert sector["reason"] == "ambiguous_multiple_peaks" + usable = [candidate for candidate in sector["candidates"] if candidate["status"] == "ambiguous"] + assert len(usable) >= 2 + assert {round(candidate["q_star"], 2) for candidate in usable} >= {0.25, 0.36} + + +def test_noisy_single_hot_pixel_is_rejected_by_effective_source_support() -> None: + axis = np.linspace(-0.8, 0.8, 161) + qy, qx = np.meshgrid(axis, axis, indexing="ij") + q = np.hypot(qx, qy) + rng = np.random.default_rng(11) + image = 3.0 + rng.normal(0.0, 0.05, q.shape) + image[80, 120] = 10_000.0 + result = measure_sector_peaks(image, {"qx": qx, "qy": qy, "q": q}, (0.10, 0.70)) + + for angle in (0.0, 5.0, 355.0): + sector = _sector(result, angle) + assert sector["selected_peak"] is None + assert sector["reason"] == "single_pixel_dominated_support" + assert sector["candidates"] + candidate = sector["candidates"][0] + assert candidate["n_eff"] < 2.0 + assert candidate["max_contribution_fraction"] > 0.75 + + +def test_two_negative_hot_pixels_do_not_make_a_positive_between_valleys() -> None: + axis = np.linspace(-0.8, 0.8, 161) + qy, qx = np.meshgrid(axis, axis, indexing="ij") + q = np.hypot(qx, qy) + rng = np.random.default_rng(11) + image = 3.0 + rng.normal(0.0, 0.05, q.shape) + image[80, 110] = -10_000.0 + image[80, 130] = -10_000.0 + result = measure_sector_peaks(image, {"qx": qx, "qy": qy, "q": q}, (0.10, 0.70)) + + for angle in (0.0, 5.0, 355.0): + sector = _sector(result, angle) + assert sector["selected_peak"] is None + assert sector["reason"] == "height_below_baseline_noise" + candidate = sector["candidates"][0] + assert candidate["prominence_snr"] > 100.0 + assert candidate["height_snr"] < 4.0 + assert candidate["raw_baseline"] > 2.8 + assert np.nanmin(sector["raw_mean"]) < -100.0 + + +def test_mask_gap_is_visible_and_never_smoothed_into_a_peak() -> None: + q, qmap = _qmap() + image = _ring_image(q, peak=0.30, width=0.010) + mask = (q > 0.285) & (q < 0.315) + result = measure_sector_peaks(image, qmap, (0.18, 0.42), mask=mask) + sector = _sector(result, 0.0) + gap = (sector["raw_count"] == 0) & (sector["geometry_count"] > 0) + + assert np.any(gap) + assert np.all(np.isnan(sector["smoothed_intensity"][gap])) + assert sector["selected_peak"] is None or not (0.285 < sector["selected_peak"]["q_star"] < 0.315) + assert np.min(sector["coverage"][gap]) == 0.0 + + +def test_unrelated_masked_quadrant_does_not_remove_a_well_supported_peak() -> None: + q, qmap = _qmap() + image = _ring_image(q) + mask = (qmap["qx"] < 0.0) & (qmap["qy"] > 0.0) + result = measure_sector_peaks(image, qmap, (0.18, 0.42), mask=mask) + sector = _sector(result, 0.0) + + assert sector["selected_peak"] is not None + assert sector["selected_peak"]["n_eff"] >= 2.0 + assert sector["selected_peak"]["max_contribution_fraction"] <= 0.75 + + +def test_boundary_maximum_is_not_reported_without_two_sided_support() -> None: + q, qmap = _qmap() + image = np.asarray(q, dtype=float) # increasing tail: maximum at q-window edge + result = measure_sector_peaks(image, qmap, (0.10, 0.32)) + sector = _sector(result, 0.0) + + assert sector["selected_peak"] is None + assert "boundary" in sector["reason"] or "monotonic" in sector["reason"] + + +def test_wide_plateau_is_retained_as_unresolved_instead_of_getting_a_fake_q() -> None: + q, qmap = _qmap() + image = np.where((q > 0.24) & (q < 0.41), 1.0, 0.05) + result = measure_sector_peaks(image, qmap, (0.10, 0.50)) + sector = _sector(result, 45.0) + + assert sector["selected_peak"] is None + assert sector["reason"] == "flat_top_unresolved_peak" + assert any(candidate["reason"] == "flat_top_unresolved_peak" for candidate in sector["candidates"]) + + +def test_wide_gaussian_and_lorentzian_profiles_are_valid_when_both_tails_return() -> None: + q, qmap = _qmap(size=401) + qmap["q_unit"] = "nm^-1" + profiles = ( + 0.1 + np.exp(-0.5 * ((q - 0.40) / 0.12) ** 2), + 0.1 + 1.0 / (1.0 + ((q - 0.40) / 0.12) ** 2), + ) + + for image in profiles: + result = measure_sector_peaks(image, qmap, (0.10, 0.90)) + sector = _sector(result, 0.0) + assert sector["selected_peak"] is not None + assert sector["selected_peak"]["q_star"] == pytest.approx(0.40, abs=0.02) + assert sector["selected_peak"]["radial_fwhm"] > 0.15 + assert sector["selected_peak"]["flat_top_ratio"] < 0.45 + + +def test_positive_peak_on_negative_raw_baseline_is_kept_and_raw_values_remain_signed() -> None: + q, qmap = _qmap(size=401) + image = -2.0 + 5.0 * np.exp(-0.5 * ((q - 0.40) / 0.025) ** 2) + result = measure_sector_peaks(image, qmap, (0.10, 0.90)) + sector = _sector(result, 0.0) + peak = sector["selected_peak"] + + assert peak is not None + assert peak["q_star"] == pytest.approx(0.40, abs=0.012) + assert peak["raw_baseline"] < -1.5 + assert peak["height"] > peak["height_threshold"] + assert np.nanmin(sector["raw_mean"]) < -1.5 + + +def test_non_affine_qmap_units_and_explicit_bin_cap_are_recorded_without_mutation() -> None: + q, qmap = _qmap(size=121, step=0.007, unit="A^-1") + qx_before = qmap["qx"].copy() + qy_before = qmap["qy"].copy() + image = _ring_image(q, peak=0.28, width=0.018) + image_before = image.copy() + # A smooth non-affine radial coordinate is supplied explicitly. The + # sector measurement must use it as data and retain its declared unit. + qmap["q"] = q * (1.0 + 0.025 * np.sin(qmap["qx"] * 2.0) * np.cos(qmap["qy"] * 3.0)) + qmap_before = qmap["q"].copy() + result = measure_sector_peaks( + image, + qmap, + (0.12, 0.42), + options={"radial_bins": 512, "max_radial_bins": 192}, + ) + + assert result["q_unit"] == "A^-1" + assert result["settings"]["effective_radial_bins"] <= 192 + assert result["settings"]["effective_radial_bins"] <= result["settings"]["q_resolution_limited_bins"] + assert result["sampling"]["representative_q_step"] > 0.0 + np.testing.assert_array_equal(qmap["qx"], qx_before) + np.testing.assert_array_equal(qmap["qy"], qy_before) + np.testing.assert_array_equal(qmap["q"], qmap_before) + np.testing.assert_array_equal(image, image_before) + + +def test_heterogeneous_local_q_steps_use_conservative_neighbor_upper_bound() -> None: + size = 161 + coordinate = np.arange(size, dtype=float) - 0.5 * (size - 1) + qx_profile = np.where(coordinate < 0.0, coordinate * 0.02, coordinate * 0.24) + qy_profile = coordinate * 0.02 + qx = np.broadcast_to(qx_profile[None, :], (size, size)) + qy = np.broadcast_to(qy_profile[:, None], (size, size)) + q = np.hypot(qx, qy) + image = np.ones_like(q) + result = measure_sector_peaks( + image, + {"qx": qx, "qy": qy, "q": q, "q_unit": "nm^-1"}, + (0.10, 0.70), + ) + + details = result["sampling"]["q_step_details"] + assert result["settings"]["representative_q_step"] == pytest.approx(0.24) + assert details["vector_step_max"] == pytest.approx(0.24) + assert details["radial_step_max"] == pytest.approx(0.24) + assert result["sampling"]["effective_radial_bin_width"] >= 0.24 + assert "conservative maximum" in result["settings"]["q_step_source"] + + +def test_mask_true_excludes_observed_statistics_but_remains_in_geometry_denominator() -> None: + q, qmap = _qmap(size=101) + image = _ring_image(q) + mask = np.zeros_like(q, dtype=bool) + mask[:, q.shape[1] // 2 :] = True + result = measure_sector_peaks(image, qmap, (0.18, 0.42), mask=mask) + right = _sector(result, 0.0) + + assert right["measured_total_count"] < right["geometry_total_count"] + assert np.nanmin(right["coverage"]) == 0.0 + assert result["input"]["mask_true_is_excluded"] is True + + +def test_numeric_options_reject_bool_values_instead_of_coercing_them() -> None: + q, qmap = _qmap() + image = _ring_image(q) + + with pytest.raises(ValueError, match="sector_width_deg"): + measure_sector_peaks(image, qmap, (0.18, 0.42), options={"sector_width_deg": True}) + with pytest.raises(ValueError, match="smoothing_sigma_bins"): + measure_sector_peaks(image, qmap, (0.18, 0.42), options={"smoothing_sigma_bins": np.bool_(False)}) diff --git a/tests/test_sector_trace.py b/tests/test_sector_trace.py new file mode 100644 index 0000000..913e6c9 --- /dev/null +++ b/tests/test_sector_trace.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from butterfly_saxs.butterfly import analyze_butterfly, measure_butterfly_observables +from butterfly_saxs.butterfly_settings import normalize_butterfly_settings +from butterfly_saxs.sector_trace import trace_butterfly_sector_peaks + + +def _ring(): + axis = np.linspace(-0.85, 0.85, 161) + qx, qy = np.meshgrid(axis, axis) + q = np.hypot(qx, qy) + image = 3.0 + 50.0 * np.exp(-0.5 * ((q - 0.42) / 0.04) ** 2) + return image, {"qx": qx, "qy": qy, "q": q, "q_unit": "nm^-1"} + + +def test_sector_trace_is_profile_first_and_keeps_missing_sectors(): + image, qmap = _ring() + chi = np.mod(np.degrees(np.arctan2(qmap["qy"], qmap["qx"])), 360) + mask = (chi >= 50) & (chi <= 110) + original = image.copy() + result = trace_butterfly_sector_peaks( + image, qmap, (.15, .7), mask=mask, + options={"sector_width_deg": 10, "sector_step_deg": 10}, + ) + measurement = result["sector_peaks"] + assert len(measurement["sectors"]) == 36 + assert 8 < len(result["points"]) <= 36 + assert len({p["sector_index"] for p in result["points"]}) == len(result["points"]) + assert all(abs(p["q_star"] - .42) < .025 for p in result["points"]) + assert not any(60 <= p["sector_center_deg"] <= 100 for p in result["points"]) + missing = next(s for s in measurement["sectors"] if s["angle_deg"] == 80) + assert missing["selected_peak"] is None + profile = result["profiles"][missing["point_id"]] + assert profile["profile_axis"] == "radial" + assert profile["fit_intensity"] == [] + assert np.count_nonzero(profile["counts"]) == 0 + for point in result["points"]: + assert not mask[int(point["pixel_y"]), int(point["pixel_x"])] + assert np.isnan(point["normal_fwhm_q"]) + assert np.isnan(point["localization_sigma_q"]) + assert not point["scale_stable"] + np.testing.assert_array_equal(image, original) + + +def test_sector_pipeline_trace_is_separately_versioned_and_does_not_fit(): + image, qmap = _ring() + result = analyze_butterfly(image, qmap, (.15, .7), options={ + "stage": "trace", "trace_method": "radial_sector", "sector_step_deg": 10, + }) + assert result["method_version"].startswith("butterfly-radial-sector-") + assert result["quality"]["status"] == "NOT_EVALUATED" + assert result["candidate_fit"]["success"] is False + assert result["diagnostics"]["first_order_q_hint"]["q_star"] is None + assert "sector_peaks" in result + + +def test_sector_median_summary_is_explicit_for_trace_and_evaluate(): + image, qmap = _ring() + for stage in ("trace", "evaluate"): + result = analyze_butterfly(image, qmap, (.15, .7), options={ + "stage": stage, + "trace_method": "radial_sector", + "sector_step_deg": 10, + "resamples": 0, + "sensitivity": False, + }) + summary = result["measurement_summary"] + assert result["q_star_sector_median"] == pytest.approx( + summary["q_star_sector_median"] + ) + assert summary["q_star_sector_median"] == pytest.approx(.42, abs=.025) + assert summary["apparent_period_from_sector_median_nm"] == pytest.approx( + 2 * np.pi / summary["q_star_sector_median"] + ) + assert summary["q_star_source"] == "selected_sector_peak_median" + assert summary["peak_order"] == "unassigned" + assert "overlapping sectors are correlated" in summary["interpretation"] + assert result["candidate_fit"].get("q_star_source") != "unindexed_sector_peak_median" + + observables = measure_butterfly_observables( + image, + qmap, + (.15, .7), + options={ + "stage": "trace", + "trace_method": "radial_sector", + "sector_step_deg": 10, + "companion_observables": False, + }, + ) + assert observables.ridge["flags"] == ["butterfly-radial-sector-v1.0"] + + +def test_sector_median_does_not_report_nm_for_pixel_q(): + image, qmap = _ring() + qmap["q_unit"] = "pixel-q" + result = analyze_butterfly(image, qmap, (.15, .7), options={ + "stage": "trace", "trace_method": "radial_sector", "sector_step_deg": 10, + }) + summary = result["measurement_summary"] + assert summary["q_star_sector_median"] is not None + assert summary["q_star_sector_median_unit"] == "pixel-q" + assert summary["apparent_period_from_sector_median_nm"] is None + + +def test_excluding_one_sector_does_not_create_or_relocate_peaks(): + image, qmap = _ring() + options = {"sector_step_deg": 10} + baseline = trace_butterfly_sector_peaks(image, qmap, (.15, .7), options=options) + chosen = next(p for p in baseline["points"] if p["accepted"]) + edited = trace_butterfly_sector_peaks(image, qmap, (.15, .7), options=options, + edits=[{"type": "exclude_point", "point_id": chosen["point_id"]}]) + assert [(p["point_id"], p["qx"], p["qy"]) for p in baseline["points"]] == [ + (p["point_id"], p["qx"], p["qy"]) for p in edited["points"]] + point = next(p for p in edited["points"] if p["point_id"] == chosen["point_id"]) + assert not point["accepted"] and not point["valid"] + + +def test_existing_recipe_keeps_curvature_and_invalid_method_is_rejected(): + assert normalize_butterfly_settings({})["trace_method"] == "curvature" + with pytest.raises(ValueError, match="trace_method"): + normalize_butterfly_settings({"trace_method": "brightest_pixel"}) + for value in (0, float("nan"), True): + with pytest.raises(ValueError, match="sector_width_deg"): + normalize_butterfly_settings({"sector_width_deg": value}) + + +def test_cli_explicit_sector_method_selects_shared_workflow_and_rejects_conflict(): + from butterfly_saxs.cli import _analysis_overrides, build_parser + + parser = build_parser() + args = parser.parse_args(["analyze", "frame.edf", "--butterfly-trace-method", "radial_sector", + "--sector-width", "10", "--sector-step", "5"]) + analysis = _analysis_overrides(args) + assert analysis["ridge_method"] == "butterfly_curvature" + assert analysis["butterfly"]["trace_method"] == "radial_sector" + assert analysis["butterfly"]["sector_width_deg"] == 10 + args = parser.parse_args(["analyze", "frame.edf", "--butterfly-trace-method", "radial_sector", + "--ridge-method", "radial_peak"]) + with pytest.raises(ValueError, match="butterfly workflow"): + _analysis_overrides(args) diff --git a/tests/test_sector_workbench.py b/tests/test_sector_workbench.py new file mode 100644 index 0000000..4a72888 --- /dev/null +++ b/tests/test_sector_workbench.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from butterfly_saxs.ui.butterfly_workbench import ButterflyWorkbench + + +def _sector_result() -> dict: + profiles = { + "sector-000": { + "profile_axis": "radial", + "q_unit": "nm^-1", + "q": [0.12, 0.16, 0.20, 0.24], + "raw_intensity": [1.0, 3.0, 2.0, 1.2], + "smoothed_intensity": [1.1, 2.7, 2.1, 1.3], + "counts": [80, 82, 81, 79], + "coverage": [0.9, 0.92, 0.91, 0.88], + "selected_peak_q": 0.16, + }, + "sector-001": { + "profile_axis": "radial", + "q_unit": "nm^-1", + "q": [0.12, 0.16, 0.20, 0.24], + "raw_intensity": [0.8, 0.9, 0.85, 0.7], + "smoothed_intensity": [0.8, 0.88, 0.84, 0.72], + "counts": [20, 18, 19, 17], + "coverage": [0.22, 0.20, 0.21, 0.19], + "selected_peak_q": None, + "failure_reason": "low_coverage", + }, + } + sectors = [ + { + "point_id": "sector-000", + "source_method": "radial_sector", + "sector_center_deg": 35.0, + "sector_width_deg": 10.0, + "q_star": 0.16, + "accepted": True, + "valid": True, + }, + { + "point_id": "sector-001", + "source_method": "radial_sector", + "sector_center_deg": 40.0, + "sector_width_deg": 10.0, + "q_star": None, + "failure_reason": "low_coverage", + "accepted": False, + "valid": False, + "profile_only": True, + }, + ] + return { + "points": [ + { + "point_id": "sector-000", + "qx": 0.16, + "qy": 0.0, + "source_method": "radial_sector", + "sector_center_deg": 35.0, + "sector_width_deg": 10.0, + "q_star": 0.16, + "accepted": True, + "valid": True, + } + ], + "profiles": profiles, + "sector_peaks": {"sectors": sectors}, + "peak_landmarks": { + "raw_global_max": {"q": 0.3, "qx": 0.3, "qy": 0.0, "raw_intensity": 9}, + "peaks": [{"peak_id": "P1", "q": 0.16, "qx": 0.16, "qy": 0.0}], + }, + } + + +def test_sector_controls_default_roundtrip_and_invalidate(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + page.show() + qtbot.wait(10) + + assert page.trace_method_combo.currentData() == "annular_peak" + page.trace_method_combo.setCurrentIndex(page.trace_method_combo.findData("radial_sector")) + assert page.sector_width_spin.value() == pytest.approx(10.0) + assert page.sector_step_spin.value() == pytest.approx(5.0) + assert not page.global_max_check.isChecked() + assert not page.supported_peaks_check.isChecked() + assert not page.ellipse_diagnostic.isVisible() + + page.trace_method_combo.setCurrentIndex(page.trace_method_combo.findData("curvature")) + qtbot.wait(10) + assert page.ellipse_diagnostic.isVisible() + page.trace_method_combo.setCurrentIndex(page.trace_method_combo.findData("radial_sector")) + qtbot.wait(10) + assert not page.ellipse_diagnostic.isVisible() + + page.set_result(_sector_result()) + assert page.result_fresh + events: list[dict] = [] + page.analysisChanged.connect(events.append) + page.sector_width_spin.setValue(12.0) + + assert not page.result_fresh + assert events[-1]["butterfly"]["trace_method"] == "radial_sector" + assert events[-1]["butterfly"]["sector_width_deg"] == pytest.approx(12.0) + assert events[-1]["butterfly"]["sector_step_deg"] == pytest.approx(5.0) + + +def test_loading_legacy_curvature_result_restores_landmarks_without_overriding_user(qtbot): + legacy = _sector_result() + legacy.pop("sector_peaks") + legacy["method_version"] = "butterfly-curvature-arcs-v2.1" + legacy["points"][0]["source_method"] = "butterfly_curvature" + + page = ButterflyWorkbench(language="en") + qtbot.addWidget(page) + assert not page.global_max_check.isChecked() + assert not page.supported_peaks_check.isChecked() + page.set_result(legacy) + assert page.butterfly_settings["trace_method"] == "curvature" + assert page.global_max_check.isChecked() + assert page.supported_peaks_check.isChecked() + + manual = ButterflyWorkbench(language="en") + qtbot.addWidget(manual) + manual.global_max_check.setChecked(True) + manual.set_result(legacy) + assert manual.butterfly_settings["trace_method"] == "curvature" + assert manual.global_max_check.isChecked() + assert not manual.supported_peaks_check.isChecked() + + +def test_replaced_legacy_recipe_without_trace_method_stays_curvature(qtbot): + page = ButterflyWorkbench(language="en") + qtbot.addWidget(page) + + page.set_analysis_settings({"stage": "trace", "edits": []}, replace=True) + assert page.butterfly_settings["trace_method"] == "curvature" + assert page.trace_method_combo.currentData() == "curvature" + assert page.global_max_check.isChecked() + assert page.supported_peaks_check.isChecked() + + page.set_analysis_settings( + { + "stage": "trace", + "trace_method": "radial_sector", + "sector_width_deg": 14, + "sector_step_deg": 7, + }, + replace=True, + ) + assert page.butterfly_settings["trace_method"] == "radial_sector" + assert page.sector_width_spin.value() == pytest.approx(14.0) + assert page.sector_step_spin.value() == pytest.approx(7.0) + + +def test_sector_list_includes_profile_only_rows_without_fake_qspace_points(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + page.set_result(_sector_result()) + + assert page.point_list.count() == 2 + assert "χ=35" in page.point_list.item(0).text() + assert "q*=0.16" in page.point_list.item(0).text() + assert "low_coverage" in page.point_list.item(1).text() + assert "sector-001" not in page.point_list.item(1).text() + + page.point_list.setCurrentRow(1) + assert getattr(page.qspace, "_selected_point_id", None) is None + page._exclude_selected_point() + assert page.edits == [] + + +def test_radial_profile_uses_absolute_q_and_keeps_smoothed_as_locator_only(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + page.set_result(_sector_result()) + page.point_list.setCurrentRow(0) + + assert page.normal_profile._x_label == "q(nm^-1)" + assert page.normal_profile._series_names == ("raw", "smoothed", "counts", "coverage") + assert "sector-000" not in page.normal_profile.title_label.text() + assert "source=" not in page.normal_profile.title_label.text() + assert "point_id=sector-000" in page.normal_profile.title_label.toolTip() + headers = [ + page.normal_profile.table.horizontalHeaderItem(column).text() + for column in range(page.normal_profile.table.columnCount()) + ] + assert "有效像素数(像素)" in headers + assert "覆盖率(无量纲)" in headers + assert "平滑(仅用于定位)" in headers + assert "拟合" not in page.normal_profile.title_label.text() + if page.normal_profile.plot is not None: + assert "仅用于定位" in page.normal_profile.plot.accessibleDescription() + assert len(page.normal_profile.plot.listDataItems()) == 2 + assert not page.normal_profile.plot.getAxis("bottom").autoSIPrefix + + page.point_list.setCurrentRow(1) + assert "覆盖不足" in page.normal_profile.title_label.text() + assert "reason=low_coverage" in page.normal_profile.title_label.toolTip() + assert page.normal_profile._x_label == "q(nm^-1)" + assert page.normal_profile.table.rowCount() == 4 + + +def test_sector_profile_and_method_label_follow_language(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + page.set_result(_sector_result()) + page.point_list.setCurrentRow(0) + assert "扇区积分 I(q)" in page.method_label.text() + assert "扇区积分 I(q)" in page.normal_profile.title_label.text() + + page.set_language("en") + assert "Radial sector" in page.method_label.text() + assert "Radial sector I(q)" in page.normal_profile.title_label.text() + assert "Sector-integrated" in page.trace_method_combo.itemText(page.trace_method_combo.findData("radial_sector")) + + +def test_profile_only_sector_without_profile_keeps_failure_context(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + result = _sector_result() + result["profiles"].pop("sector-001") + page.set_result(result) + page.point_list.setCurrentRow(1) + + assert "覆盖不足" in page.normal_profile.title_label.text() + assert "reason=low_coverage" in page.normal_profile.title_label.toolTip() + assert "暂无扇区径向剖面" in page.normal_profile.empty_label.text() + assert getattr(page.qspace, "_selected_point_id", None) is None + + +def test_sector_review_uses_unassigned_peak_definitions_without_recomputing_values(qtbot): + page = ButterflyWorkbench(language="zh_CN") + qtbot.addWidget(page) + result = _sector_result() + result["candidate_fit"] = { + "q_star_from_arcs": 0.16, + "L_from_observed_radius_nm": 39.27, + } + result["measurement_summary"] = { + "q_star_sector_median": 0.17, + "q_star_sector_median_unit": "nm^-1", + "apparent_period_from_sector_median_nm": 36.96, + "aggregation": "median of selected finite sector-profile peaks after manual exclusion", + } + result["quality"] = {"status": "WARN", "flags": []} + page.set_result(result) + labels = [ + page.quantity_table.item(row, 0).text() + for row in range(page.quantity_table.rowCount()) + ] + + assert "主峰 q*中位数(未定级)" in labels + assert "2π/q*(表观)" in labels + assert page.quantity_table.item(labels.index("主峰 q*中位数(未定级)"), 1).text() == "0.17" + assert page.quantity_table.item(labels.index("2π/q*(表观)"), 1).text() == "36.96" diff --git a/tests/test_sparse_ring_performance_contract.py b/tests/test_sparse_ring_performance_contract.py new file mode 100644 index 0000000..d04224e --- /dev/null +++ b/tests/test_sparse_ring_performance_contract.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import numpy as np + +from butterfly_saxs import butterfly_ridge as ridge +from butterfly_saxs.butterfly_ridge import ( + _fill_sparse_first_order_ring, + _normalise_options, + _prepare_sparse_first_order_samples, + _sector_first_order_peak, +) + + +_SECTORS = tuple( + 30.0 + quadrant + offset + for quadrant in (0.0, 90.0, 180.0, 270.0) + for offset in (30.0, 45.0, 60.0, 75.0) +) + + +def _legacy_sector_first_order_peak( + qx: np.ndarray, + qy: np.ndarray, + q: np.ndarray, + intensity: np.ndarray, + valid: np.ndarray, + *, + sector_deg: float, + halfwidth_deg: float, + hint: float, +) -> dict[str, float] | None: + """Reference implementation of the pre-optimization sector scan.""" + + if not (np.isfinite(hint) and hint > 0.0): + return None + ang = np.degrees(np.arctan2(qy, qx)) + delta = np.abs(((ang - float(sector_deg) + 180.0) % 360.0) - 180.0) + q_lo, q_hi = 0.70 * hint, 1.45 * hint + selected = ( + np.asarray(valid, dtype=bool) + & np.isfinite(q) + & np.isfinite(intensity) + & (delta <= float(halfwidth_deg)) + & (q >= q_lo) + & (q <= q_hi) + ) + if int(np.count_nonzero(selected)) < 12: + return None + radii = np.asarray(q[selected], dtype=float) + values = np.asarray(intensity[selected], dtype=float) + edges = np.linspace(q_lo, q_hi, 9) + profile = np.full(edges.size - 1, np.nan, dtype=float) + counts = np.zeros(edges.size - 1, dtype=int) + idx = np.digitize(radii, edges) - 1 + for bin_i in range(edges.size - 1): + in_bin = idx == bin_i + counts[bin_i] = int(np.count_nonzero(in_bin)) + if counts[bin_i] >= 2: + profile[bin_i] = float(np.nanmedian(values[in_bin])) + usable = np.isfinite(profile) + if int(np.count_nonzero(usable)) < 3: + return None + peak_i = int(np.nanargmax(np.where(usable, profile, -np.inf))) + peak = float(profile[peak_i]) + baseline = float(np.nanmedian(profile[usable])) + if not (np.isfinite(peak) and np.isfinite(baseline) and baseline > 0 and peak >= 1.30 * baseline): + return None + q_star = float(0.5 * (edges[peak_i] + edges[peak_i + 1])) + in_bin = selected & (q >= edges[peak_i]) & (q < edges[peak_i + 1]) + if int(np.count_nonzero(in_bin)) < 3: + in_bin = selected + median_qx = float(np.nanmedian(qx[in_bin])) + median_qy = float(np.nanmedian(qy[in_bin])) + if not (np.isfinite(median_qx) and np.isfinite(median_qy)): + return None + rows, cols = np.nonzero(in_bin) + nearest = int(np.argmin((qx[in_bin] - median_qx) ** 2 + (qy[in_bin] - median_qy) ** 2)) + return { + "qx": median_qx, + "qy": median_qy, + "q_star": q_star, + "intensity": peak, + "contrast": peak / baseline, + "pixel_x": float(cols[nearest]), + "pixel_y": float(rows[nearest]), + "sector_deg": float(sector_deg), + } + + +def _ring_case(size: int = 192) -> tuple[np.ndarray, ...]: + axis = np.linspace(-1.0, 1.0, size) + qx, qy = np.meshgrid(axis, axis) + q = np.hypot(qx, qy) + image = 0.2 + 4.0 * np.exp(-0.5 * ((q - 0.50) / 0.03) ** 2) + image += 0.02 * np.sin(5.0 * qx) * np.cos(3.0 * qy) + valid = np.isfinite(image) + valid[::17, 3::19] = False + image[5::23, 7::29] = np.nan + q[11::31, 13::27] = np.nan + return qx, qy, q, image, valid + + +def _sector_result(result: dict[str, float] | None) -> tuple[tuple[str, float], ...] | None: + if result is None: + return None + return tuple(sorted((key, float(value)) for key, value in result.items())) + + +def test_prepared_sector_scan_is_numerically_equivalent_to_legacy_scan() -> None: + qx, qy, q, image, valid = _ring_case() + prepared = _prepare_sparse_first_order_samples(qx, qy, q, image, valid, hint=0.50) + for sector in _SECTORS: + legacy = _legacy_sector_first_order_peak( + qx, qy, q, image, valid, sector_deg=sector, halfwidth_deg=7.5, hint=0.50 + ) + optimized = _sector_first_order_peak( + qx, + qy, + q, + image, + valid, + sector_deg=sector, + halfwidth_deg=7.5, + hint=0.50, + prepared=prepared, + ) + assert _sector_result(optimized) == _sector_result(legacy) + direct = _sector_first_order_peak( + qx, + qy, + q, + image, + valid, + sector_deg=sector, + halfwidth_deg=7.5, + hint=0.50, + ) + assert _sector_result(direct) == _sector_result(legacy) + + +def test_prepared_scan_excludes_masked_and_nonfinite_pixels_without_source_mutation() -> None: + qx, qy, q, image, valid = _ring_case() + originals = tuple(array.copy() for array in (qx, qy, q, image, valid)) + prepared = _prepare_sparse_first_order_samples(qx, qy, q, image, valid, hint=0.50) + + assert np.all(np.isfinite(prepared["q"])) + assert np.all(np.isfinite(prepared["intensity"])) + assert prepared["q"].size < np.count_nonzero(np.isfinite(q)) + for current, original in zip((qx, qy, q, image, valid), originals): + assert np.array_equal(current, original, equal_nan=True) + + +def test_highest_q_edge_is_kept_in_last_profile_bin() -> None: + hint = 0.50 + q_lo, q_hi = 0.70 * hint, 1.45 * hint + edges = np.linspace(q_lo, q_hi, 9) + radial_values = np.concatenate( + [ + np.full(3, edges[0] + 0.005), + np.full(3, edges[1] + 0.005), + np.full(3, edges[2] + 0.005), + np.full(12, q_hi), + ] + ) + angle = np.deg2rad(45.0) + qx = (radial_values * np.cos(angle))[:, None] + qy = (radial_values * np.sin(angle))[:, None] + q = radial_values[:, None] + image = np.concatenate([np.ones(9), np.full(12, 10.0)])[:, None] + valid = np.ones_like(q, dtype=bool) + + result = _sector_first_order_peak( + qx, + qy, + q, + image, + valid, + sector_deg=45.0, + halfwidth_deg=7.5, + hint=hint, + ) + + assert result is not None + assert result["intensity"] == 10.0 + assert result["q_star"] == (edges[-2] + edges[-1]) / 2.0 + assert result["qx"] == q_hi * np.cos(angle) + assert result["qy"] == q_hi * np.sin(angle) + + +def test_fill_path_keeps_original_arrays_unchanged() -> None: + qx, qy, q, image, valid = _ring_case() + originals = tuple(array.copy() for array in (qx, qy, q, image, valid)) + points = [ + {"qx": 0.50, "qy": 0.02, "accepted": True, "branch_id": 0, "side": "upper"}, + {"qx": -0.50, "qy": -0.02, "accepted": True, "branch_id": 1, "side": "lower"}, + ] + _fill_sparse_first_order_ring( + points, + qx=qx, + qy=qy, + q=q, + intensity=image, + valid=valid, + hint=0.50, + options=_normalise_options({"reference_axis_deg": 0.0, "run_wang_check": False}), + q_step=2.0 / q.shape[0], + signature="no-source-mutation", + ) + for current, original in zip((qx, qy, q, image, valid), originals): + assert np.array_equal(current, original, equal_nan=True) + + +def test_fill_path_prepares_sparse_samples_once(monkeypatch) -> None: + qx, qy, q, image, valid = _ring_case() + original_prepare = ridge._prepare_sparse_first_order_samples + calls = 0 + + def counted_prepare(*args, **kwargs): + nonlocal calls + calls += 1 + return original_prepare(*args, **kwargs) + + monkeypatch.setattr(ridge, "_prepare_sparse_first_order_samples", counted_prepare) + points = [ + {"qx": 0.50, "qy": 0.02, "accepted": True, "branch_id": 0, "side": "upper"}, + {"qx": -0.50, "qy": -0.02, "accepted": True, "branch_id": 1, "side": "lower"}, + ] + _fill_sparse_first_order_ring( + points, + qx=qx, + qy=qy, + q=q, + intensity=image, + valid=valid, + hint=0.50, + options=_normalise_options({"reference_axis_deg": 0.0, "run_wang_check": False}), + q_step=2.0 / q.shape[0], + signature="one-preparation", + ) + + assert calls == 1 diff --git a/tests/test_ui_workflow.py b/tests/test_ui_workflow.py index ac683dd..58334b4 100644 --- a/tests/test_ui_workflow.py +++ b/tests/test_ui_workflow.py @@ -33,6 +33,31 @@ def batch(self, *, parameters, payload): } +class _PartialFailureBatchEngine: + def __init__(self) -> None: + self.payloads: list[dict] = [] + + def batch(self, *, parameters, payload): + del parameters + self.payloads.append(payload) + return { + "records": [ + {"frame": "good-frame.tif", "status": "ok"}, + { + "frame": "bad-frame.tif", + "status": "failed", + "error": "read failed", + }, + ] + } + + +class _CancelledBatchEngine: + def batch(self, *, parameters, payload): + del parameters, payload + return {"records": [{"frame": "first.tif", "status": "ok"}], "cancelled": True} + + class _StateEngine: def __init__(self) -> None: self.parameters = { @@ -112,6 +137,66 @@ def test_pattern_views_force_row_major_for_rectangular_detector_arrays(qtbot) -> grid.close() +def test_run_batch_rejects_empty_frame_selection(qtbot) -> None: + engine = _BatchEngine() + window = MainWindow(engine=engine, auto_preview=False) + qtbot.addWidget(window) + + generation = window.run_batch() + + assert generation > 0 + assert engine.payloads == [] + assert not window._workers + assert window._status_key == "status.job_error" + assert window._status_values["error"] == "no batch frames selected" + window.close() + + +def test_run_batch_stream_requires_output_directory(qtbot) -> None: + engine = _BatchEngine() + window = MainWindow(engine=engine, auto_preview=False) + qtbot.addWidget(window) + window.set_batch_frames(["frame.tif"]) + window.batch_stream_check.setChecked(True) + + generation = window.run_batch() + + assert generation > 0 + assert engine.payloads == [] + assert not window._workers + assert window._status_key == "status.job_error" + assert window._status_values["error"] == "streaming requires an output directory" + window.close() + + +def test_batch_failed_record_sets_failed_completion_status(qtbot) -> None: + engine = _PartialFailureBatchEngine() + window = MainWindow(engine=engine, auto_preview=False) + qtbot.addWidget(window) + window.set_batch_frames(["good-frame.tif", "bad-frame.tif"]) + + window.run_batch() + qtbot.waitUntil(lambda: window._status_key == "status.job_failed", timeout=2_000) + + assert engine.payloads + assert "batch failed" in window.status_message.text().lower() + assert "bad-frame.tif" in window.butterfly_workbench.batch_feedback_label.text() + window.close() + + +def test_partially_cancelled_batch_shows_cancelled_status(qtbot) -> None: + window = MainWindow(engine=_CancelledBatchEngine(), auto_preview=False) + qtbot.addWidget(window) + window.set_batch_frames(["first.tif", "second.tif"]) + + window.run_batch() + qtbot.waitUntil(lambda: window._status_key == "status.cancelled", timeout=2_000) + + assert "cancelled" in window.status_message.text().lower() + assert window.butterfly_workbench.batch_feedback_label.text() + window.close() + + def test_parameter_edit_invalidates_an_inflight_worker_result(qtbot) -> None: engine = _StateEngine() window = MainWindow(engine=engine, auto_preview=False) diff --git a/tests/test_unattended_batch.py b/tests/test_unattended_batch.py new file mode 100644 index 0000000..669870b --- /dev/null +++ b/tests/test_unattended_batch.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from butterfly_saxs import cli +from butterfly_saxs import batch as batch_module +from butterfly_saxs import export as export_module +from butterfly_saxs.service import ButterflyAnalysisService + + +def _inputs(tmp_path: Path) -> tuple[Path, Path, Path]: + package = tmp_path / "package" + package.mkdir() + np.save(package / "frame1.npy", np.ones((8, 8))) + np.save(package / "frame2.npy", np.ones((8, 8)) * 2) + poni = package / "geometry.poni" + poni.write_text("test calibration", encoding="utf-8") + mask = package / "mask.npy" + np.save(mask, np.zeros((8, 8), dtype=bool)) + return package, poni, mask + + +def test_unattended_batch_preflights_selected_frames_and_resumes( + tmp_path: Path, monkeypatch, capsys +) -> None: + package, poni, mask = _inputs(tmp_path) + output = tmp_path / "results" + preflight_calls = [] + analyzed = [] + recipes = [] + + def preflight(self, source, **kwargs): + preflight_calls.append((source, kwargs)) + return {"status": {"status_color": "yellow", "scientific_status": "WARN", "exit_code": 1}} + + def analyze(source, **kwargs): + analyzed.append(Path(source).name) + recipes.append(kwargs["config"].analysis) + return {"success": True, "status": "ok", "parameters": {"a": 1.0}} + + monkeypatch.setattr(ButterflyAnalysisService, "preflight", preflight) + monkeypatch.setattr(cli, "analyze_frame", analyze) + command = [ + "batch", str(package / "frame*.npy"), "--poni", str(poni), + "--mask", str(mask), "--unattended", str(package), "-o", str(output), + ] + + assert cli.main(command) == 1 + report = json.loads(capsys.readouterr().out) + assert report["n_frames"] == 2 + assert report["n_failed"] == 0 + assert report["preflight"]["status_color"] == "yellow" + assert report["agent"]["scientific_acceptance"] is False + assert all("result" not in frame for frame in report["frames"]) + assert analyzed == ["frame1.npy", "frame2.npy"] + assert all(recipe["ridge_method"] == "butterfly_curvature" for recipe in recipes) + assert all(recipe["butterfly"]["trace_method"] == "annular_peak" for recipe in recipes) + assert all(recipe["butterfly"]["stage"] == "evaluate" for recipe in recipes) + assert (output / "checkpoint.json").exists() + assert (output / "results.npz").exists() + assert preflight_calls[0][1]["mask"] == str(mask) + assert [Path(row["path"]).name for row in preflight_calls[0][1]["manifest"]] == analyzed + + assert cli.main([*command, "--resume"]) == 1 + resumed = json.loads(capsys.readouterr().out) + assert resumed["n_frames"] == 2 + assert all(frame["resumed"] for frame in resumed["frames"]) + assert analyzed == ["frame1.npy", "frame2.npy"] + + +def test_unattended_batch_stops_on_red_preflight( + tmp_path: Path, monkeypatch, capsys +) -> None: + package, poni, _ = _inputs(tmp_path) + output = tmp_path / "results" + + def preflight(self, source, **kwargs): + return {"status": {"status_color": "red", "scientific_status": "FAIL", "exit_code": 2}} + + def analyze(*args, **kwargs): + raise AssertionError("red preflight must stop before fitting") + + monkeypatch.setattr(ButterflyAnalysisService, "preflight", preflight) + monkeypatch.setattr(cli, "analyze_frame", analyze) + assert cli.main([ + "batch", str(package / "frame*.npy"), "--poni", str(poni), + "--unattended", str(package), "-o", str(output), + ]) == 2 + report = json.loads(capsys.readouterr().out) + assert report["blocked_stage"] == "preflight" + assert report["preflight"]["status_color"] == "red" + assert any("fitting did not start" in step for step in report["agent"]["next"]) + assert not (output / "results.npz").exists() + + +def test_unattended_series_selection_and_failed_frame_are_explicit( + tmp_path: Path, monkeypatch, capsys +) -> None: + package, poni, _ = _inputs(tmp_path) + manifest = package / "manifest.json" + manifest.write_text(json.dumps([ + {"path": "frame1.npy", "source": "reference", "order": 0}, + {"path": "frame2.npy", "source": "hold", "order": 1}, + ]), encoding="utf-8") + seen = [] + + def preflight(self, source, **kwargs): + seen.extend(kwargs["manifest"]) + return {"status": {"status_color": "green", "scientific_status": "PASS", "exit_code": 0}} + + def analyze(source, **kwargs): + raise RuntimeError("detector read failed") + + monkeypatch.setattr(ButterflyAnalysisService, "preflight", preflight) + monkeypatch.setattr(cli, "analyze_frame", analyze) + output = tmp_path / "results" + assert cli.main([ + "batch", "--manifest", str(manifest), "--series", "hold", + "--poni", str(poni), "--unattended", str(package), "-o", str(output), + ]) == 1 + report = json.loads(capsys.readouterr().out) + assert [Path(row["path"]).name for row in seen] == ["frame2.npy"] + assert report["n_frames"] == report["n_failed"] == 1 + assert report["frames"][0]["status"] == "failed" + assert "detector read failed" in report["frames"][0]["error"] + with np.load(output / "results.npz", allow_pickle=False) as arrays: + metadata = json.loads(str(arrays["__metadata__"].item())) + assert metadata["complete"] is False + assert metadata["quality_complete"] is False + + +def test_unattended_relative_paths_and_frame_selector_keep_recipe( + tmp_path: Path, monkeypatch, capsys +) -> None: + package, poni, mask = _inputs(tmp_path) + monkeypatch.chdir(tmp_path) + preflight_values = [] + analyzed_configs = [] + analyzed_masks = [] + + def preflight(self, source, **kwargs): + preflight_values.append(kwargs) + return {"status": {"status_color": "green", "scientific_status": "PASS", "exit_code": 0}} + + def analyze(source, **kwargs): + analyzed_configs.append(kwargs["config"]) + analyzed_masks.append(kwargs["mask"]) + return {"success": True, "status": "ok"} + + monkeypatch.setattr(ButterflyAnalysisService, "preflight", preflight) + monkeypatch.setattr(cli, "analyze_frame", analyze) + assert cli.main([ + "batch", "package/frame*.npy", "--unattended", "package", + "--poni", "package/geometry.poni", "--mask", "mask.npy", + "--frame", "0", "--dataset", "data", "-o", "results", + ]) == 0 + report = json.loads(capsys.readouterr().out) + assert report["n_frames"] == 2 + assert preflight_values[0]["poni"] == str(poni) + assert preflight_values[0]["mask"] == str(mask) + assert all(Path(row["path"]).is_absolute() for row in preflight_values[0]["manifest"]) + assert all(row["frame"] == 0 and row["dataset"] == "data" for row in preflight_values[0]["manifest"]) + assert all(config.poni_path == str(poni) for config in analyzed_configs) + assert analyzed_masks == [str(mask), str(mask)] + assert all(config.analysis["ridge_method"] == "butterfly_curvature" for config in analyzed_configs) + assert all(config.analysis["butterfly"]["trace_method"] == "annular_peak" for config in analyzed_configs) + + +def test_unattended_relative_paths_reach_real_preflight( + tmp_path: Path, monkeypatch, capsys +) -> None: + from pyFAI.azimuthalIntegrator import AzimuthalIntegrator + + package, poni, _ = _inputs(tmp_path) + AzimuthalIntegrator( + dist=0.12, poni1=0.0003, poni2=0.00035, + pixel1=0.0001, pixel2=0.0001, wavelength=1.0e-10, + ).save(str(poni)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cli, "analyze_frame", + lambda source, **kwargs: {"success": True, "status": "ok"}, + ) + + assert cli.main([ + "batch", "package/frame*.npy", "--unattended", "package", + "--poni", "package/geometry.poni", "--mask", "mask.npy", + "-o", "results", + ]) == 1 + report = json.loads(capsys.readouterr().out) + assert report["n_frames"] == 2 + assert report["preflight"]["status_color"] == "yellow" + evidence = json.loads((tmp_path / "results" / "preflight" / "preflight.json").read_text(encoding="utf-8")) + assert evidence["geometry"]["q_unit"] == "nm^-1" + assert len(evidence["input"]["images"]) == 2 + + +def test_unattended_batch_requires_separate_output_and_calibration( + tmp_path: Path, capsys +) -> None: + package, poni, _ = _inputs(tmp_path) + base = ["batch", str(package / "frame*.npy"), "--unattended", str(package)] + assert cli.main([*base, "-o", str(tmp_path / "results")]) == 2 + assert "PONI" in json.loads(capsys.readouterr().out)["error"]["message"] + assert cli.main([*base, "--poni", str(poni), "-o", str(package / "results")]) == 2 + assert "outside" in json.loads(capsys.readouterr().out)["error"]["message"] + assert cli.main([ + *base, "--poni", str(poni), "-o", str(tmp_path / "results"), + "--checkpoint", str(package / "checkpoint.json"), + ]) == 2 + assert "checkpoint" in json.loads(capsys.readouterr().out)["error"]["message"] + + +def test_cancelled_batch_and_project_never_report_success( + tmp_path: Path, monkeypatch, capsys +) -> None: + package, _, _ = _inputs(tmp_path) + cancelled = batch_module.BatchRunResult([], cancelled=True, total_count=2) + monkeypatch.setattr(batch_module, "run_batch", lambda *args, **kwargs: cancelled) + monkeypatch.setattr(export_module, "export_batch", lambda *args, **kwargs: {}) + assert cli.main([ + "batch", str(package / "frame*.npy"), "-o", str(tmp_path / "results"), + ]) == 1 + report = json.loads(capsys.readouterr().out) + assert report["cancelled"] is True + assert report["agent"]["exit_code"] == 1 + + monkeypatch.setattr(cli, "run_project", lambda *args, **kwargs: cancelled) + assert cli.main(["project", str(tmp_path / "project.toml")]) == 1 + project_report = json.loads(capsys.readouterr().out) + assert project_report["cancelled"] is True + assert project_report["agent"]["exit_code"] == 1 diff --git a/tests/test_workflow_refinement.py b/tests/test_workflow_refinement.py index 2e85404..d82327c 100644 --- a/tests/test_workflow_refinement.py +++ b/tests/test_workflow_refinement.py @@ -585,6 +585,72 @@ def test_selector_range_and_series_are_explicit() -> None: assert [ref.id for ref in selected] == ["f2"] +def test_pipeline_series_selection_filters_mixed_manifest_before_batch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + paths = [tmp_path / "a.dat", tmp_path / "b.dat"] + for path in paths: + path.write_bytes(b"frame") + manifest = [ + {"path": paths[0], "source": "A", "frame_id": "a"}, + {"path": paths[1], "source": "B", "frame_id": "b"}, + ] + seen: list[Path] = [] + + def fake_analyze(source, **kwargs): + del kwargs + seen.append(Path(source)) + return PipelineResult( + image=np.ones((2, 2), dtype=float), + qmap={}, + observables={}, + ridges=[], + ellipse_fit={}, + ) + + monkeypatch.setattr(pipeline, "analyze_frame", fake_analyze) + results = batch_analyze(paths, manifest=manifest, series="A") + + assert seen == [paths[0]] + assert len(results) == 1 + + +def test_service_batch_records_preserve_frame_execution_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "frame.dat" + frame = FrameRef(source) + item = FrameFitResult( + frame=frame, + result={"metrics": {}}, + status="failed", + error="read failed", + traceback="Traceback (most recent call last): ...", + warm_start_from="previous-frame", + elapsed_s=0.25, + resumed=True, + ) + run = BatchRunResult( + frame_results=[item], + mode="warm_start", + elapsed_s=0.5, + processed_count=1, + total_count=1, + ) + monkeypatch.setattr(service_module, "run_batch", lambda *args, **kwargs: run) + + result = ButterflyAnalysisService().batch( + payload={"frames": [source], "mode": "warm_start"} + ) + + record = result["records"][0] + assert record["error"] == "read failed" + assert record["traceback"].startswith("Traceback") + assert record["warm_start_from"] == "previous-frame" + assert record["elapsed_s"] == pytest.approx(0.25) + assert record["resumed"] is True + + @pytest.mark.parametrize("manifest", ([], {"frames": []})) def test_empty_explicit_manifest_fails_closed(manifest) -> None: with pytest.raises(ValueError, match="manifest contains no frame entries"): @@ -595,6 +661,8 @@ def test_batch_cancel_reports_progress_and_checkpoint_state(tmp_path: Path) -> N event = __import__("threading").Event() progress: list[dict[str, object]] = [] paths = [tmp_path / f"frame_{index}.npy" for index in range(3)] + for path in paths: + path.write_bytes(b"pending") def analyzer(frame, initial=None): del initial @@ -766,6 +834,30 @@ def analyzer(frame): assert "frame_0001__image" in bundle.files +def test_stream_npz_complete_requires_quality_success(tmp_path: Path) -> None: + source = tmp_path / "frame.dat" + source.write_bytes(b"frame") + output = tmp_path / "stream" + writer = StreamingBatchExporter(output) + run = run_batch( + [source], + lambda _frame: { + "image": np.ones((2, 2), dtype=float), + "quality_status": "FAIL", + }, + result_sink=writer.write, + retain_results=False, + ) + writer.finalize(run) + + with np.load(output / "results.npz", allow_pickle=False) as bundle: + metadata = json.loads(str(bundle["__metadata__"].item())) + assert metadata["complete"] is False + assert metadata["artifact_complete"] is True + assert metadata["quality_complete"] is False + assert metadata["quality_failed_frames"] == [0] + + def test_direct_batch_mapping_resolves_base_dir_before_file_validation(tmp_path: Path) -> None: source = tmp_path / "frame.dat" weights = tmp_path / "weights.npy"