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'
'
+ )
+
+ 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")}
+{annular_files_text}
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")}
{measured_files_text}
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_files_text}
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_files_text}
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")}
+{sector_files_text}
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")}
{links(tuple(name for name in ("peak_map.png", "peak_diagnostics.png", "peak_zooms.png") if name in available))}
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 · 源数据与图注
+{provenance_links}
+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"