From 15dac5a6656914b8cf6992e85842531458a5c0c3 Mon Sep 17 00:00:00 2001 From: Delun Gong Date: Sun, 23 Aug 2026 18:14:19 +0800 Subject: [PATCH 1/2] fix: harden workbench units and usability --- SASAbs.py | 899 +++++++++++++++++++++------- src/saxsabs/cli.py | 211 ++++++- src/saxsabs/core/intensity_state.py | 30 +- src/saxsabs/io/parsers.py | 282 ++++++++- tests/test_cli.py | 226 ++++++- tests/test_intensity_state.py | 7 + tests/test_io_formats.py | 35 ++ tests/test_parsers.py | 212 +++++++ tests/test_workbench_scientific.py | 627 +++++++++++++++++++ 9 files changed, 2310 insertions(+), 219 deletions(-) diff --git a/SASAbs.py b/SASAbs.py index c01c200..f9beb91 100644 --- a/SASAbs.py +++ b/SASAbs.py @@ -24,6 +24,7 @@ import datetime from io import StringIO import re +import unicodedata import json import concurrent.futures import threading @@ -53,6 +54,8 @@ def _read_package_version() -> str: MAX_OUTPUT_STEM_LENGTH = 120 DEFAULT_LEGACY_RESUME_ENABLED = False WORKBENCH_MIN_SIZE = (900, 600) +OUTPUT_FORMAT_TOKENS = ("tsv", "csv", "cansas_xml", "nxcansas_h5") +EXTERNAL_1D_FILE_PATTERN = "*.dat *.txt *.chi *.csv *.xml *.h5 *.hdf5" logger = logging.getLogger(__name__) SUPPORTED_LANGUAGES = ("en", "zh") @@ -75,7 +78,7 @@ def _read_package_version() -> str: "t1_run_btn": "\u25b6 Run K Calibration", "t1_hist_btn": "K History", "t1_report_title": "Analysis Report", - "t1_plot_tip": "Plot: dashed=net signal; blue=K-corrected; orange=NIST/reference", + "t1_plot_tip": "Plot: dashed=net signal; blue=K-corrected; orange=reference markers", "plot_preset_label": "Figure preset:", "plot_format_label": "Format:", "plot_export_btn": "Export Figure", @@ -86,7 +89,7 @@ def _read_package_version() -> str: "tip_plot_format": "PNG/TIFF are high-DPI raster formats; PDF/SVG/EPS are vector formats.", "tip_plot_export": "Save the current plot with the selected publication preset and tight bounding box.", "t2_guide_title": "Batch Workflow", - "t2_guide_text": "① Ensure K, BG/Dark, and poni are ready\n② Select thickness logic\n③ Select one or more integration modes\n④ Add sample files and run dry-check\n⑤ Start batch and review batch_report.csv", + "t2_guide_text": "① Ensure K, BG/Dark, and poni are ready\n② Enter the fixed thickness required for formal output\n③ Select one or more integration modes\n④ Add sample files and run dry-check\n⑤ Start batch and review batch_report.csv", "t2_mid_title": "Sample Queue", "t2_add_btn": "Add Files", "t2_add_folder_btn": "Add Folder", @@ -132,7 +135,7 @@ def _read_package_version() -> str: "session_error_body": "Failed to read session:\n{err}", "session_loaded_title": "Session Loaded", # --- Tab1 labels --- - "lbl_t1_std_file": "Standard (GC):", + "lbl_t1_std_file": "Standard image:", "lbl_t1_bg_file": "Background:", "lbl_t1_dark_file": "Dark image:", "lbl_t1_poni_file": "Geometry (.poni):", @@ -140,10 +143,10 @@ def _read_package_version() -> str: "cb_solid_angle": "SolidAngle correction", # --- Tab1 hints --- "hint_t1_files": "Standard recommended: Glassy Carbon (GC); BG/Dark/poni must share the same geometry and energy.", - "hint_t1_phys": "Time(s)=exposure; I0=incident monitor; T=transmission(0–1). Normalisation follows selected I0 mode.", + "hint_t1_phys": "Time(s)=exposure; I0=incident monitor; T=transmission (0 < T ≤ 1). Normalisation follows selected I0 mode.", # --- Tab1 tooltips --- "tip_t1_guide": "Follow steps 1–5 to avoid missing key parameters.", - "tip_t1_std_entry": "Standard sample 2D image for absolute calibration (GC recommended).", + "tip_t1_std_entry": "Standard sample 2D image for absolute calibration (GC is recommended, but other validated standards are supported).", "tip_t1_std_btn": "Browse to select standard file.", "tip_t1_bg_entry": "Empty-cell / air / background 2D image for subtraction.", "tip_t1_bg_btn": "Browse to select background image.", @@ -154,18 +157,18 @@ def _read_package_version() -> str: "tip_t1_poni_btn": "Browse to select .poni file.", "tip_t1_std_exp": "Standard exposure time (s).", "tip_t1_std_i0": "Standard I0 (monitor reading).", - "tip_t1_std_t": "Standard transmission; should be in 0–1.", + "tip_t1_std_t": "Standard transmission T; must satisfy 0 < T ≤ 1.", "tip_t1_std_thk": "Standard thickness (mm); for volume normalisation.", "tip_t1_bg_exp": "Background exposure time (s).", "tip_t1_bg_i0": "Background I0 (monitor reading).", - "tip_t1_bg_t": "Background transmission.", + "tip_t1_bg_t": "Background transmission T; must satisfy 0 < T ≤ 1.", "tip_t1_norm_mode": "rate: I0 is count rate; integrated: I0 is integrated counts.", "tip_t1_norm_hint": "Choose according to beamline output. Wrong choice adds exposure-related systematic error.", "tip_t1_solid_angle": "Shared by Tab1 calibration & Tab2 batch. Must be consistent or K is invalid.", - "tip_t1_calibrate": "Run 2D BG subtraction + 1D integration + NIST matching; writes K factor.", + "tip_t1_calibrate": "Run 2D background subtraction, 1D integration, and reference matching; writes the K factor.", "tip_t1_history": "View historical K factor trend to monitor instrument drift.", "tip_t1_report": "Displays calibration key metrics: K, valid points, Q overlap range and dispersion.", - "tip_t1_plot": "If the blue line tracks the red dots, K calibration quality is good.", + "tip_t1_plot": "If the blue line tracks the orange reference markers, K calibration quality is good.", # --- Tab2 labels --- "lf_t2_global": "1. Global Settings", "lbl_t2_k_factor": "K factor:", @@ -267,11 +270,11 @@ def _read_package_version() -> str: "tip_t2_export_cal2d": "Write detector-space absolute-calibrated 2D EDF plus PONI, mask and metadata for pyFAI/pydidas reintegration.", "tip_t2_cal2d_flat": "If enabled, flat correction is baked into the exported 2D image. Do not pass flat again to pyFAI.", "tip_t2_cal2d_dtype": "Float32 is compact; float64 preserves more numerical precision.", - "tip_t2_add": "Multi-select TIFF files.", + "tip_t2_add": "Multi-select 2D image files (.tif/.tiff/.edf/.cbf).", "tip_t2_add_folder": "Recursively add 2D image files from a folder (.tif/.tiff/.edf/.cbf).", "tip_t2_clear": "Clear queue; does not delete files on disk.", - "tip_t2_check": "Batch-check each file's exp/mon/T and thickness availability.", - "tip_t2_group": "Auto-detect files from the same experimental run (机时) using timestamps. Creates logical groups for output organization and smarter BG/Dark matching.", + "tip_t2_check": "Check each file's exp/mon/T, fixed thickness, instrument identity, and enabled fluorescence settings before processing.", + "tip_t2_group": "Group queued files by acquisition time, update queue information, and record group count/IDs in the batch manifest; it does not change output routing or BG/Dark matching.", "tip_t2_listbox": "Current sample queue.", "tip_t2_run": "Run batch. Single-file failure does not abort the batch.", "tip_t2_progress": "Batch processing progress.", @@ -291,7 +294,7 @@ def _read_package_version() -> str: "lf_t3_execution": "2. Execution Strategy", "cb_t3_resume": "Resume (skip existing output)", "cb_t3_overwrite": "Force overwrite", - "lbl_t3_formats": "Supported: .dat .txt .chi .csv (need X & I columns; Error optional)", + "lbl_t3_formats": "Supported: .dat .txt .chi .csv .xml .h5 .hdf5 (need X & I columns; Error optional)", "lf_t3_raw_params": "3. Raw 1D Correction Params (raw pipeline)", "btn_t3_meta_from_batch": "Generate metadata from Tab2 report", "cb_t3_meta_thk": "Prefer thk_mm from metadata", @@ -301,7 +304,7 @@ def _read_package_version() -> str: "lbl_t3_outdir": "Output dir:", # --- Tab3 hints --- "hint_t3_global": "K from Tab1. Choose pipeline, then formula. Raw pipeline uses exp/I0/T and BG1D/Dark1D.", - "hint_t3_execution": "Recommend dry-check first. Resume to avoid redundant overwrites.", + "hint_t3_execution": "Run Dry Check first. Exists-only resume is disabled for formal output.", "hint_t3_raw": "Only active when pipeline = Raw 1D. Can use Tab2's batch_report.csv or metadata.csv directly.", "hint_t3_queue": "Click 'Dry Check' to verify column parsing for each file.", # --- Tab3 tooltips --- @@ -321,7 +324,7 @@ def _read_package_version() -> str: "tip_t3_meta_from_batch": "One-click: generate Tab3 metadata.csv from Tab2 batch_report.csv; auto-fill path.", "tip_t3_meta_thk": "If enabled and sample's metadata has thk_mm, overrides fixed thickness.", "tip_t3_sync_bg": "When enabled, Tab3 BG params auto-update from Tab1/global, avoiding stale values.", - "tip_t3_add": "Multi-select external integration result files.", + "tip_t3_add": "Multi-select external relative 1D integration result files.", "tip_t3_clear": "Clear queue only; does not delete files on disk.", "tip_t3_check": "Check column recognition, point count, and X-axis type inference.", "tip_t3_listbox": "Current external 1D file list for conversion.", @@ -416,6 +419,33 @@ def _read_package_version() -> str: # --- File row labels (Tab3) --- "lbl_t3_bg1d_file": "BG 1D file:", "lbl_t3_dark1d_file": "Dark 1D file:", + "lbl_t3_meta_csv": "Metadata CSV:", + "status_ready": "Ready", + "tip_browse_file": "Browse for a file.", + "tip_browse_dir": "Browse for a directory.", + "tip_output_format": "Choose the exported file format. The internal format token is kept stable for reproducible reports; canSAS XML and NXcanSAS HDF5 require a Q (Å⁻¹) axis.", + "lbl_t2_fluo_method": "Method:", + "lbl_t2_fluo_f0": "F0 (cm⁻¹):", + "lbl_t2_fluo_f0_uncertainty": "u(F0):", + "lbl_t2_fluo_beta": "β:", + "lbl_t2_fluo_beta_uncertainty": "u(β):", + "lbl_t2_fluo_qmin": "qmin (Å⁻¹):", + "lbl_t2_fluo_qmax": "qmax (Å⁻¹):", + "lbl_t2_fluo_file": "Measured F(q) file:", + "lbl_t3_fluo_beta_uncertainty": "u(β):", + "lbl_t3_fluo_qmin": "qmin (Å⁻¹):", + "lbl_t3_fluo_qmax": "qmax (Å⁻¹):", + "tip_t2_fluo_method": "Fluorescence model: constant, high-q estimate, or measured absolute F(q).", + "tip_t2_fluo_f0": "Constant fluorescence F0 in cm⁻¹; required for the constant method.", + "tip_t2_fluo_f0_uncertainty": "Standard uncertainty u(F0) in cm⁻¹; leave blank when unknown.", + "tip_t2_fluo_beta": "Scale factor β applied to the fluorescence term; must be > 0.", + "tip_t2_fluo_beta_uncertainty": "Standard uncertainty u(β); leave blank when unknown.", + "tip_t2_fluo_qmin": "Lower bound of the high-q estimation window in Å⁻¹.", + "tip_t2_fluo_qmax": "Upper bound of the high-q estimation window in Å⁻¹; must exceed qmin.", + "tip_t2_fluo_file": "Measured fluorescence F(q) must be an absolute Q profile in cm⁻¹.", + "tip_t3_fluo_beta_uncertainty": "Standard uncertainty u(β); leave blank when unknown.", + "tip_t3_fluo_qmin": "Lower bound of the high-q estimation window in Å⁻¹.", + "tip_t3_fluo_qmax": "Upper bound of the high-q estimation window in Å⁻¹; must exceed qmin.", # --- Report messages --- "rpt_start_calib": "Start calibration (robust mode)...", "rpt_i0_norm_mode": "I0 normalisation mode: {mode} (norm={formula})", @@ -478,7 +508,7 @@ def _read_package_version() -> str: "t1_run_btn": "\u25b6 运行 K 因子标定", "t1_hist_btn": "K 历史", "t1_report_title": "分析报告(建议重点看 Std Dev)", - "t1_plot_tip": "图示说明:虚线=净信号;蓝线=K 校正后;橙色=NIST/参考点", + "t1_plot_tip": "图示说明:虚线=净信号;蓝线=K 校正后;橙色=参考点", "plot_preset_label": "图像预设:", "plot_format_label": "格式:", "plot_export_btn": "导出图像", @@ -489,7 +519,7 @@ def _read_package_version() -> str: "tip_plot_format": "PNG/TIFF 是高分辨率位图;PDF/SVG/EPS 是矢量格式。", "tip_plot_export": "按当前预设保存图像,并自动使用 tight bounding box 避免标签裁切。", "t2_guide_title": "批处理工作流(推荐顺序)", - "t2_guide_text": "① 先确认 K 因子和 BG/暗场/poni 已就绪\n② 选择厚度逻辑(自动/固定)\n③ 选择一个或多个积分模式(可同时勾选)\n④ 添加样品文件并点击预检查\n⑤ 启动批处理并查看 batch_report.csv", + "t2_guide_text": "① 先确认 K 因子和 BG/暗场/poni 已就绪\n② 输入正式输出所需的固定厚度\n③ 选择一个或多个积分模式(可同时勾选)\n④ 添加样品文件并点击预检查\n⑤ 启动批处理并查看 batch_report.csv", "t2_mid_title": "样品队列", "t2_add_btn": "添加文件", "t2_add_folder_btn": "添加文件夹", @@ -535,7 +565,7 @@ def _read_package_version() -> str: "session_error_body": "读取会话失败:\n{err}", "session_loaded_title": "会话已加载", # --- Tab1 labels --- - "lbl_t1_std_file": "标准样 (GC):", + "lbl_t1_std_file": "标准样图像:", "lbl_t1_bg_file": "背景图像:", "lbl_t1_dark_file": "暗场图像:", "lbl_t1_poni_file": "几何文件 (.poni):", @@ -543,10 +573,10 @@ def _read_package_version() -> str: "cb_solid_angle": "SolidAngle修正", # --- Tab1 hints --- "hint_t1_files": "标准样建议用玻璃碳(GC);背景/暗场/poni 应与样品保持同一实验几何与能量。", - "hint_t1_phys": "Time(s)=曝光时间;I0=入射强度监测值;T=透过率(0~1)。归一化按下方 I0 语义选择公式。", + "hint_t1_phys": "Time(s)=曝光时间;I0=入射强度监测值;T=透过率(0 < T ≤ 1)。归一化按下方 I0 语义选择公式。", # --- Tab1 tooltips --- "tip_t1_guide": "按 1~5 步执行,基本不会漏关键参数。", - "tip_t1_std_entry": "用于绝对强度标定的标准样二维图像(推荐 GC)。", + "tip_t1_std_entry": "用于绝对强度标定的标准样二维图像(推荐 GC,也支持其他已验证标准样)。", "tip_t1_std_btn": "点击选择标准样文件。", "tip_t1_bg_entry": "空样品/空气或本底散射图像,用于 2D 本底扣除。", "tip_t1_bg_btn": "点击选择背景图像。", @@ -557,18 +587,18 @@ def _read_package_version() -> str: "tip_t1_poni_btn": "点击选择 .poni 文件。", "tip_t1_std_exp": "标准样曝光时间(秒)。", "tip_t1_std_i0": "标准样 I0(监测器读数)。", - "tip_t1_std_t": "标准样透过率,建议在 0~1 之间。", + "tip_t1_std_t": "标准样透过率 T,必须满足 0 < T ≤ 1。", "tip_t1_std_thk": "标准样厚度(mm),用于体积归一化。", "tip_t1_bg_exp": "背景图曝光时间(秒)。", "tip_t1_bg_i0": "背景图 I0(监测器读数)。", - "tip_t1_bg_t": "背景图透过率。", + "tip_t1_bg_t": "背景图透过率 T,必须满足 0 < T ≤ 1。", "tip_t1_norm_mode": "rate: I0 是每秒计数率;integrated: I0 是曝光积分计数。", "tip_t1_norm_hint": "请按线站实际输出选择。选错会引入曝光时间相关系统误差。", "tip_t1_solid_angle": "Tab1标定与Tab2批处理共用此设置。两者必须一致,否则 K 因子无效。", - "tip_t1_calibrate": "执行 2D 扣背景 + 1D 积分 + NIST 匹配,自动写入 K 因子。", + "tip_t1_calibrate": "执行 2D 扣背景、1D 积分和参考曲线匹配,自动写入 K 因子。", "tip_t1_history": "查看历史 K 因子趋势,监控仪器漂移。", "tip_t1_report": "会显示标定关键指标:K、有效点数、Q 重叠区间和离散度。", - "tip_t1_plot": "若蓝线与红点趋势一致,通常说明 K 标定质量较好。", + "tip_t1_plot": "若蓝线与橙色参考点趋势一致,通常说明 K 标定质量较好。", # --- Tab2 labels --- "lf_t2_global": "1. 全局配置", "lbl_t2_k_factor": "K 因子:", @@ -662,11 +692,11 @@ def _read_package_version() -> str: "tip_t2_export_cal2d": "导出 detector-space 绝对强度2D图、PONI、mask 和 metadata,供 pyFAI/pydidas 后续重新积分。", "tip_t2_cal2d_flat": "开启后 flat 会烧入导出2D图;后续 pyFAI 不应再次传入 flat。", "tip_t2_cal2d_dtype": "float32 文件更小;float64 保留更多数值精度。", - "tip_t2_add": "支持多选 TIFF 文件。", + "tip_t2_add": "支持多选二维图像(.tif/.tiff/.edf/.cbf)。", "tip_t2_add_folder": "递归添加文件夹中的二维图像(.tif/.tiff/.edf/.cbf)。", "tip_t2_clear": "清空队列,不会删除磁盘文件。", - "tip_t2_check": "批量检查每个文件的 exp/mon/T 和厚度可用性。", - "tip_t2_group": "根据时间戳自动识别同一次机时(实验轮次)的文件。可用于按组输出子目录和优先匹配同组BG/Dark。", + "tip_t2_check": "批量检查每个文件的 exp/mon/T、固定厚度、仪器身份和已启用的荧光设置。", + "tip_t2_group": "按采集时间对队列文件分组,更新队列信息,并在 batch manifest 中记录分组数量/ID;不会改变输出路由或 BG/Dark 匹配。", "tip_t2_listbox": "显示当前待处理样品列表。", "tip_t2_run": "执行批处理。单文件失败不会中断整批。", "tip_t2_progress": "显示批处理进度。", @@ -686,7 +716,7 @@ def _read_package_version() -> str: "lf_t3_execution": "2. 执行策略", "cb_t3_resume": "断点续跑(跳过已存在输出)", "cb_t3_overwrite": "强制覆盖输出", - "lbl_t3_formats": "支持格式: .dat .txt .chi .csv(列至少包含 X 与 I;Error 可选)", + "lbl_t3_formats": "支持格式: .dat .txt .chi .csv .xml .h5 .hdf5(列至少包含 X 与 I;Error 可选)", "lf_t3_raw_params": "3. 原始1D校正参数(raw流程)", "btn_t3_meta_from_batch": "由 Tab2 报告生成 metadata", "cb_t3_meta_thk": "优先使用 metadata 中的 thk_mm", @@ -696,7 +726,7 @@ def _read_package_version() -> str: "lbl_t3_outdir": "输出根目录:", # --- Tab3 hints --- "hint_t3_global": "K 来自 Tab1。先选流程,再选公式。原始1D流程会用到 exp/I0/T 与 BG1D/Dark1D。", - "hint_t3_execution": "建议先预检查。可断点续跑,避免重复覆盖。", + "hint_t3_execution": "正式运行前先做预检查。正式输出不启用仅按存在性跳过的续跑。", "hint_t3_raw": "仅当流程=原始1D完整校正时生效。可直接使用 Tab2 的 batch_report.csv 或 metadata.csv。", "hint_t3_queue": '建议先点"预检查"确认每个文件的列解析情况。', # --- Tab3 tooltips --- @@ -716,7 +746,7 @@ def _read_package_version() -> str: "tip_t3_meta_from_batch": "从 Tab2 的 batch_report.csv 一键生成 Tab3 可用 metadata.csv,并自动回填路径。", "tip_t3_meta_thk": "开启后,若某样品 metadata 含 thk_mm,则覆盖固定厚度。", "tip_t3_sync_bg": "开启后 Tab3 的 BG 参数会随 Tab1/全局变化自动更新,避免陈旧值。", - "tip_t3_add": "支持多选外部积分结果文件。", + "tip_t3_add": "支持多选外部 relative 1D 积分结果文件。", "tip_t3_clear": "仅清空队列,不删除磁盘文件。", "tip_t3_check": "检查列识别、点数和坐标类型推断。", "tip_t3_listbox": "当前待转换的外部1D文件列表。", @@ -811,6 +841,33 @@ def _read_package_version() -> str: # --- File row labels (Tab3) --- "lbl_t3_bg1d_file": "BG 1D 文件:", "lbl_t3_dark1d_file": "Dark 1D 文件:", + "lbl_t3_meta_csv": "Metadata CSV:", + "status_ready": "就绪", + "tip_browse_file": "点击选择文件。", + "tip_browse_dir": "点击选择文件夹。", + "tip_output_format": "选择导出文件格式;内部格式 token 保持稳定,便于复现和读取报告。canSAS XML 和 NXcanSAS HDF5 要求 Q (Å⁻¹) 轴。", + "lbl_t2_fluo_method": "方法:", + "lbl_t2_fluo_f0": "F0 (cm⁻¹):", + "lbl_t2_fluo_f0_uncertainty": "u(F0):", + "lbl_t2_fluo_beta": "β:", + "lbl_t2_fluo_beta_uncertainty": "u(β):", + "lbl_t2_fluo_qmin": "qmin (Å⁻¹):", + "lbl_t2_fluo_qmax": "qmax (Å⁻¹):", + "lbl_t2_fluo_file": "实测 F(q) 文件:", + "lbl_t3_fluo_beta_uncertainty": "u(β):", + "lbl_t3_fluo_qmin": "qmin (Å⁻¹):", + "lbl_t3_fluo_qmax": "qmax (Å⁻¹):", + "tip_t2_fluo_method": "荧光模型:常数、高 q 估计或实测绝对 F(q)。", + "tip_t2_fluo_f0": "常数荧光 F0,单位 cm⁻¹;constant 方法必须填写。", + "tip_t2_fluo_f0_uncertainty": "F0 的标准不确定度 u(F0),单位 cm⁻¹;未知时留空。", + "tip_t2_fluo_beta": "荧光项缩放因子 β,必须 > 0。", + "tip_t2_fluo_beta_uncertainty": "β 的标准不确定度 u(β),未知时留空。", + "tip_t2_fluo_qmin": "高 q 估计窗口下限,单位 Å⁻¹。", + "tip_t2_fluo_qmax": "高 q 估计窗口上限,单位 Å⁻¹;必须大于 qmin。", + "tip_t2_fluo_file": "实测荧光 F(q) 必须是单位 cm⁻¹ 的绝对 Q 曲线。", + "tip_t3_fluo_beta_uncertainty": "β 的标准不确定度 u(β),未知时留空。", + "tip_t3_fluo_qmin": "高 q 估计窗口下限,单位 Å⁻¹。", + "tip_t3_fluo_qmax": "高 q 估计窗口上限,单位 Å⁻¹;必须大于 qmin。", # --- Report messages --- "rpt_start_calib": "开始标定(稳健模式)...", "rpt_i0_norm_mode": "I0 归一化模式: {mode} (norm={formula})", @@ -1316,11 +1373,13 @@ def save_figure(fig, path, **_kwargs): try: from saxsabs.core.fluorescence_subtraction import ( combine_sequential_standard_uncertainties, + parse_fluorescence_method, subtract_fluorescence, ) except Exception: subtract_fluorescence = None combine_sequential_standard_uncertainties = None + parse_fluorescence_method = None try: from saxsabs.core.execution_policy import ( @@ -1391,6 +1450,15 @@ def should_skip_all_existing(existing_flags, policy): _core_profile_uncertainty = None _core_read_external_1d_profile = None +try: + from saxsabs.io.parsers import ( + canonicalize_q_unit as _core_canonicalize_q_unit, + q_axis_kind as _core_q_axis_kind, + ) +except Exception: + _core_canonicalize_q_unit = None + _core_q_axis_kind = None + def _profile_intensity(profile): if _core_profile_intensity is not None: @@ -1775,6 +1843,7 @@ def __init__(self, root, language="en"): if self.language not in SUPPORTED_LANGUAGES: self.language = "en" self.root.title(self.tr("app_title")) + geometry = None if choose_initial_window_geometry is not None: geometry = choose_initial_window_geometry( self.root.winfo_screenwidth(), @@ -1785,13 +1854,14 @@ def __init__(self, root, language="en"): self.root.geometry(geometry.tk_geometry) else: self.root.geometry("960x620") - self.root.minsize(*WORKBENCH_MIN_SIZE) + self.root.minsize(*self._window_min_size_for_geometry(geometry)) # Apply shared scientific plot defaults globally. saxs_mpl_style.apply_nature_style("raw_inspection") self.set_style() self._tooltips = [] + self._output_format_combos = [] # Top bar for theme toggle top_bar = ttk.Frame(self.root) @@ -1857,7 +1927,7 @@ def __init__(self, root, language="en"): # --- Status bar --- status_sep = ttk.Separator(self.root, orient="horizontal") status_sep.pack(fill="x", side="bottom") - self._status_var = tk.StringVar(value="Ready") + self._status_var = tk.StringVar(value=self.tr("status_ready")) self._status_bar = ttk.Label( self.root, textvariable=self._status_var, style="Status.TLabel", anchor="w" ) @@ -1876,6 +1946,15 @@ def tr(self, key): def _lang_button_text(self): return self.tr("lang_toggle_to_zh") if self.language == "en" else self.tr("lang_toggle_to_en") + @staticmethod + def _window_min_size_for_geometry(geometry): + if geometry is None: + return WORKBENCH_MIN_SIZE + return ( + min(WORKBENCH_MIN_SIZE[0], int(geometry.width)), + min(WORKBENCH_MIN_SIZE[1], int(geometry.height)), + ) + def toggle_language(self): self.language = "zh" if self.language == "en" else "en" self.refresh_ui_language() @@ -1911,6 +1990,40 @@ def refresh_ui_language(self): lbl.configure(text=f"{self.tr('hint_prefix')}: {self.tr(key)}") except Exception: pass + if hasattr(self, "t1_std_combo") and hasattr(self, "t1_std_type"): + standard_options = ( + "opt_std_srm3600", + "opt_std_water", + "opt_std_lupolen", + "opt_std_custom", + ) + values = [self.tr(key) for key in standard_options] + self.t1_std_combo.configure(values=values) + self._t1_std_option_map = { + self.tr("opt_std_srm3600"): "SRM3600", + self.tr("opt_std_water"): "Water_20C", + self.tr("opt_std_lupolen"): "Lupolen", + self.tr("opt_std_custom"): "Custom", + } + standard_key = str(self.t1_std_type.get() or "SRM3600") + display_by_key = { + "SRM3600": self.tr("opt_std_srm3600"), + "Water_20C": self.tr("opt_std_water"), + "Lupolen": self.tr("opt_std_lupolen"), + "Custom": self.tr("opt_std_custom"), + } + self.t1_std_combo.set(display_by_key.get(standard_key, values[0])) + self._refresh_output_format_combos() + if hasattr(self, "_status_var"): + try: + current_status = str(self._status_var.get()) + if current_status in { + I18N["en"].get("status_ready"), + I18N["zh"].get("status_ready"), + }: + self._status_var.set(self.tr("status_ready")) + except Exception: + pass self.refresh_help_text() self.refresh_queue_status() self.refresh_external_1d_status() @@ -1920,6 +2033,52 @@ def _register_i18n_widget(self, widget, key): self._i18n_widgets = [] self._i18n_widgets.append((widget, key)) + def _output_format_labels(self): + return [self.tr(f"opt_fmt_{token}") for token in OUTPUT_FORMAT_TOKENS] + + def _output_format_label(self, token): + token = str(token or "tsv").strip().lower() + if token not in OUTPUT_FORMAT_TOKENS: + token = "tsv" + return self.tr(f"opt_fmt_{token}") + + def _configure_output_format_combo(self, combo, token_var): + """Show localized format labels while retaining stable internal tokens.""" + display_var = tk.StringVar(value=self._output_format_label(token_var.get())) + combo.configure(textvariable=display_var, values=self._output_format_labels()) + + def _on_format_selected(_event=None): + selected = str(display_var.get()) + for token in OUTPUT_FORMAT_TOKENS: + if selected == self.tr(f"opt_fmt_{token}"): + token_var.set(token) + break + else: + # A stale label should never leak into the processing contract. + token_var.set("tsv") + display_var.set(self._output_format_label("tsv")) + + combo.bind("<>", _on_format_selected, add="+") + if not hasattr(self, "_output_format_combos"): + self._output_format_combos = [] + self._output_format_combos.append((combo, token_var, display_var)) + return display_var + + def _refresh_output_format_combos(self): + alive = [] + for combo, token_var, display_var in getattr(self, "_output_format_combos", []): + try: + combo.configure(values=self._output_format_labels()) + token = str(token_var.get() or "tsv").strip().lower() + if token not in OUTPUT_FORMAT_TOKENS: + token = "tsv" + token_var.set(token) + display_var.set(self._output_format_label(token)) + alive.append((combo, token_var, display_var)) + except Exception: + pass + self._output_format_combos = alive + def _fmt_queue_info(self, total, uniq): if total == uniq: return f"{self.tr('queue_files')}: {uniq}" @@ -2928,8 +3087,8 @@ def _normalize_transmission(self, trans, raw=None, key=None): # 透过率归一化策略: # 1) 明确百分号/percent/pct -> 按百分数处理 - # 2) 1.0~2.0 视为轻微漂移,夹紧到 1.0(避免把 1.25 误判成 1.25%) - # 3) 2.0~100 视作百分数字面量(如 85 -> 0.85) + # 2) Unhinted 1 0.85). has_pct_hint = ( "%" in raw_s or "percent" in raw_s @@ -3229,10 +3388,11 @@ def value_with_unit_to_si(self, raw, target): def extract_instrument_signature(self, filepath, header_dict=None, shape=None): meta = self.normalize_header_dict(header_dict) - if not meta: + if not meta or shape is None: try: loaded = _workbench_load_detector_image(filepath, dtype=None) - meta = self.normalize_header_dict(loaded.header) + if not meta: + meta = self.normalize_header_dict(loaded.header) if shape is None: shape = tuple(loaded.data.shape) except Exception: @@ -3256,10 +3416,30 @@ def extract_instrument_signature(self, filepath, header_dict=None, shape=None): if en_kev is None and wl_a and wl_a > 0: en_kev = HC_KEV_A / wl_a + detector = None + if det_raw is not None: + detector_text = str(det_raw).strip() + detector = detector_text or None + image_shape = None + if shape is not None: + try: + image_shape = tuple(int(value) for value in shape) + except (TypeError, ValueError): + image_shape = tuple(shape) if isinstance(shape, (tuple, list)) else None + return { "distance_m": dist_m, "pixel1_m": px1_m, "pixel2_m": px2_m, + "wavelength_a": wl_a, + "energy_kev": en_kev, + "detector": detector, + "shape": image_shape, + "path": str(filepath), + "observed_fields": { + "wavelength_a": wl_a is not None and np.isfinite(wl_a) and wl_raw is not None, + "energy_kev": en_kev is not None and np.isfinite(en_kev) and en_raw is not None, + }, } def relative_diff(self, a, b): @@ -3386,7 +3566,7 @@ def merge_integrate1d_results(self, results): sum_w = np.zeros_like(r0, dtype=np.float64) sum_iw = np.zeros_like(r0, dtype=np.float64) sum_sw2 = np.zeros_like(r0, dtype=np.float64) - has_sigma = False + sigma_unknown = np.zeros_like(r0, dtype=bool) for res in results: rr = np.asarray(res.radial, dtype=np.float64) @@ -3394,37 +3574,38 @@ def merge_integrate1d_results(self, results): raise ValueError("分段扇区积分的 q 网格不一致,无法合并。") i = np.asarray(res.intensity, dtype=np.float64) + if i.shape != r0.shape: + raise ValueError("分段扇区积分的强度数组形状不一致,无法合并。") w = getattr(res, "count", None) if w is None: - w = np.where(np.isfinite(i), 1.0, 0.0) + w = np.ones_like(i, dtype=np.float64) else: w = np.asarray(w, dtype=np.float64) if w.shape != r0.shape: - w = np.where(np.isfinite(i), 1.0, 0.0) - w = np.nan_to_num(w, nan=0.0, posinf=0.0, neginf=0.0) - w = np.maximum(w, 0.0) + w = np.zeros_like(i, dtype=np.float64) + valid = np.isfinite(i) & np.isfinite(w) & (w > 0.0) + w_eff = np.where(valid, w, 0.0) - i_num = np.nan_to_num(i, nan=0.0, posinf=0.0, neginf=0.0) - sum_iw += i_num * w - sum_w += w + sum_iw += np.where(valid, i, 0.0) * w_eff + sum_w += w_eff sigma = getattr(res, "sigma", None) - if sigma is not None: + if sigma is None: + sigma_unknown |= valid + else: s = np.asarray(sigma, dtype=np.float64) - if s.shape == r0.shape: - term = np.nan_to_num(s, nan=0.0, posinf=0.0, neginf=0.0) * w + if s.shape != r0.shape: + sigma_unknown |= valid + else: + sigma_valid = np.isfinite(s) & (s >= 0.0) + sigma_unknown |= valid & ~sigma_valid + term = np.where(valid & sigma_valid, s * w_eff, 0.0) sum_sw2 += term * term - has_sigma = True i_merge = np.divide(sum_iw, sum_w, out=np.full_like(sum_iw, np.nan), where=sum_w > 0) - sigma_merge = None - if has_sigma: - sigma_merge = np.divide( - np.sqrt(sum_sw2), - sum_w, - out=np.full_like(sum_w, np.nan), - where=sum_w > 0, - ) + sigma_merge = np.full_like(sum_w, np.nan) + sigma_valid = (sum_w > 0) & ~sigma_unknown + sigma_merge[sigma_valid] = np.sqrt(sum_sw2[sigma_valid]) / sum_w[sigma_valid] return SimpleNamespace( radial=r0, @@ -3479,22 +3660,56 @@ def check_instrument_consistency(self, file_paths, poni_path=None, tol_pct=0.5): ref = sigs[0] fallback = self.session_geometry_fallback if isinstance(self.session_geometry_fallback, dict) else {} if fallback: - for key in ("wavelength_a", "distance_m", "pixel1_m", "pixel2_m", "energy_kev"): - if ref.get(key) is None and fallback.get(key) is not None: - ref[key] = fallback.get(key) + for signature in sigs: + for key in ("wavelength_a", "distance_m", "pixel1_m", "pixel2_m", "energy_kev"): + if signature.get(key) is None and fallback.get(key) is not None: + signature[key] = fallback.get(key) issues = [] + for signature in sigs: + if "error" in signature: + path_name = Path(signature.get("path", "")).name + issues.append( + f"{path_name}: 无法读取文件头 ({signature['error']})" + ) + if "error" in ref: + return issues + + for signature in sigs: + observed = signature.get("observed_fields", {}) + if not ( + observed.get("wavelength_a") and observed.get("energy_kev") + ): + continue + wavelength_a = signature.get("wavelength_a") + energy_kev = signature.get("energy_kev") + if wavelength_a is None or energy_kev is None: + continue + product = float(wavelength_a) * float(energy_kev) + relative_error = abs(product - HC_KEV_A) / HC_KEV_A + if relative_error > tol: + p = Path(signature.get("path", "")).name + issues.append( + f"{p}: 波长与能量内部不一致,E·λ={product:.6g} keV·A " + f"(期望 {HC_KEV_A:.6g})" + ) + for s in sigs[1:]: p = Path(s.get("path", "")).name if "error" in s: - issues.append(f"{p}: 无法读取文件头 ({s['error']})") continue - if ref.get("shape") and s.get("shape") and ref["shape"] != s["shape"]: - issues.append(f"{p}: 图像尺寸不一致 {s['shape']} != {ref['shape']}") + ref_shape = ref.get("shape") + sample_shape = s.get("shape") + if ref_shape != sample_shape and (ref_shape is not None or sample_shape is not None): + issues.append(f"{p}: 图像尺寸不一致 {sample_shape} != {ref_shape}") - if ref.get("detector") and s.get("detector") and ref["detector"] != s["detector"]: - issues.append(f"{p}: 探测器型号不一致 {s['detector']} != {ref['detector']}") + ref_detector = ref.get("detector") + sample_detector = s.get("detector") + if ref_detector != sample_detector and ( + ref_detector is not None or sample_detector is not None + ): + issues.append(f"{p}: 探测器型号不一致 {sample_detector} != {ref_detector}") for key, label in [ ("energy_kev", "能量(keV)"), @@ -3503,7 +3718,13 @@ def check_instrument_consistency(self, file_paths, poni_path=None, tol_pct=0.5): ("pixel1_m", "pixel1(m)"), ("pixel2_m", "pixel2(m)"), ]: - rd = self.relative_diff(s.get(key), ref.get(key)) + sample_value = s.get(key) + ref_value = ref.get(key) + if sample_value is None or ref_value is None: + if sample_value != ref_value: + issues.append(f"{p}: {label} 信息缺失或不一致") + continue + rd = self.relative_diff(sample_value, ref_value) if rd is not None and rd > tol: issues.append( f"{p}: {label} 偏差 {rd*100:.3f}% 超过阈值 {tol*100:.3f}%" @@ -3512,12 +3733,41 @@ def check_instrument_consistency(self, file_paths, poni_path=None, tol_pct=0.5): if poni_path: try: ai = pyFAI.load(poni_path) - ai_wl_a = ai.wavelength * 1e10 if getattr(ai, "wavelength", None) else None - if ai_wl_a and ref.get("wavelength_a"): - rd = self.relative_diff(ai_wl_a, ref["wavelength_a"]) + def finite_positive(value): + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if np.isfinite(number) and number > 0 else None + + poni_values = { + "wavelength_a": ( + finite_positive(getattr(ai, "wavelength", None)) * 1e10 + if finite_positive(getattr(ai, "wavelength", None)) is not None + else None + ), + "distance_m": finite_positive(getattr(ai, "dist", None)), + } + detector = getattr(ai, "detector", None) + poni_values["pixel1_m"] = finite_positive( + getattr(detector, "pixel1", None) + ) + poni_values["pixel2_m"] = finite_positive( + getattr(detector, "pixel2", None) + ) + for key, label, unit in ( + ("wavelength_a", "波长", "A"), + ("distance_m", "样探距", "m"), + ("pixel1_m", "pixel1", "m"), + ("pixel2_m", "pixel2", "m"), + ): + poni_value = poni_values[key] + header_value = ref.get(key) + rd = self.relative_diff(poni_value, header_value) if rd is not None and rd > tol: issues.append( - f"poni 波长与样品头信息不一致: {ai_wl_a:.6g} A vs {ref['wavelength_a']:.6g} A" + f"poni {label}与样品头信息不一致: " + f"{poni_value:.6g} {unit} vs {header_value:.6g} {unit}" ) except Exception as e: issues.append(f"无法读取 poni 做一致性检查: {e}") @@ -3571,6 +3821,19 @@ def mode_output_path(self, save_dirs, mode, out_stem): ext = ".chi" if mode == "radial_chi" else ".dat" return save_dirs[mode] / f"{out_stem}{ext}" + @staticmethod + def validate_output_format_for_axis(output_format, x_label): + """Fail early when a structured format cannot represent the profile axis.""" + fmt = str(output_format or "tsv").strip().lower() + if fmt not in OUTPUT_FORMAT_TOKENS: + raise ValueError(f"Unsupported output format: {output_format}") + if fmt in {"cansas_xml", "nxcansas_h5"} and str(x_label) != "Q_A^-1": + raise ValueError( + f"输出格式 {fmt} 要求 Q_A^-1 轴数据,当前为 {x_label};" + "请改用 TSV/CSV 或选择 Q 轴输入。" + ) + return fmt + def resolve_profile_output_path(self, out_path, output_format="tsv"): out_path = Path(out_path) fmt = str(output_format or "tsv").strip().lower() @@ -3706,6 +3969,7 @@ def save_profile_table( ): # Origin-friendly text table: first row is column names, tab-separated. out_path = Path(out_path) + output_format = self.validate_output_format_for_axis(output_format, x_label) final_path = self.resolve_profile_output_path(out_path, output_format) if run_policy is not None: if resolve_output_path_for_write is not None: @@ -3733,11 +3997,6 @@ def save_profile_table( profile_metadata.update(dict(uncertainty_metadata)) compatibility_error = e_arr if combined_arr is None else combined_arr - if output_format in ("cansas_xml", "nxcansas_h5") and str(x_label) != "Q_A^-1": - raise ValueError( - f"输出格式 {output_format} 仅支持 Q_A^-1 轴数据,当前为 {x_label}。" - ) - if output_format == "cansas_xml": if write_cansas1d_xml is None: raise RuntimeError("canSAS XML writer is unavailable.") @@ -4065,36 +4324,38 @@ def init_tab1_k_calc(self): # Not packed initially — shown when Water is selected # Reference curve file row (hidden by default) - self.t1_ref_row = self.add_file_row(f_files, self.tr("lbl_t1_std_ref_file"), self.t1_std_ref_path, "*.dat *.txt *.csv *.xml") + self.t1_ref_row = self.add_file_row(f_files, "lbl_t1_std_ref_file", self.t1_std_ref_path, "*.dat *.txt *.csv *.xml") self.t1_ref_row["frame"].pack_forget() # hidden by default - row_std = self.add_file_row(f_files, self.tr("lbl_t1_std_file"), self.t1_files["std"], "*.tif", self.on_load_std_t1) + row_std = self.add_file_row( + f_files, + "lbl_t1_std_file", + self.t1_files["std"], + "*.tif *.tiff *.edf *.cbf", + self.on_load_std_t1, + ) self.add_tooltip(row_std["entry"], "tip_t1_std_entry") - self.add_tooltip(row_std["button"], "tip_t1_std_btn") - row_bg = self.add_file_row(f_files, self.tr("lbl_t1_bg_file"), self.t1_files["bg"], "*.tif", self.on_load_bg_t1) + row_bg = self.add_file_row(f_files, "lbl_t1_bg_file", self.t1_files["bg"], "*.tif *.tiff *.edf *.cbf", self.on_load_bg_t1) self.add_tooltip(row_bg["entry"], "tip_t1_bg_entry") - self.add_tooltip(row_bg["button"], "tip_t1_bg_btn") btn_bg_multi = ttk.Button(row_bg["frame"], text="+", width=3, command=self.select_multi_bg_t1) btn_bg_multi.pack(side="left", padx=(2, 0)) self.add_tooltip(btn_bg_multi, "tip_t1_bg_multi") - row_dark = self.add_file_row(f_files, self.tr("lbl_t1_dark_file"), self.t1_files["dark"], "*.tif") + row_dark = self.add_file_row(f_files, "lbl_t1_dark_file", self.t1_files["dark"], "*.tif *.tiff *.edf *.cbf") self.add_tooltip(row_dark["entry"], "tip_t1_dark_entry") - self.add_tooltip(row_dark["button"], "tip_t1_dark_btn") - row_poni = self.add_file_row(f_files, self.tr("lbl_t1_poni_file"), self.t1_files["poni"], "*.poni") + row_poni = self.add_file_row(f_files, "lbl_t1_poni_file", self.t1_files["poni"], "*.poni") self.add_tooltip(row_poni["entry"], "tip_t1_poni_entry") - self.add_tooltip(row_poni["button"], "tip_t1_poni_btn") row_mask_t1 = self.add_file_row( f_files, - self.tr("lbl_t2_mask"), + "lbl_t2_mask", self.global_vars["mask_path"], "*.tif *.tiff *.edf *.npy", ) row_flat_t1 = self.add_file_row( f_files, - self.tr("lbl_t2_flat"), + "lbl_t2_flat", self.global_vars["flat_path"], "*.tif *.tiff *.edf *.npy", ) @@ -4446,7 +4707,7 @@ def init_tab2_batch(self): e_sec_max = ttk.Entry(f_sec, textvariable=self.t2_sec_max, width=4) e_sec_max.pack(side="left") ttk.Label(f_sec, text="] deg").pack(side="left") - btn_sec_preview = ttk.Button(f_sec, text=self.tr("btn_t2_iq_preview"), width=8, command=self.preview_iq_window_t2) + btn_sec_preview = ttk.Button(f_sec, text=self.tr("btn_t2_iq_preview"), command=self.preview_iq_window_t2) btn_sec_preview.pack(side="left", padx=(4, 0)) self._register_i18n_widget(btn_sec_preview, "btn_t2_iq_preview") @@ -4479,7 +4740,7 @@ def init_tab2_batch(self): e_qmax = ttk.Entry(f_tex, textvariable=self.t2_rad_qmax, width=4) e_qmax.pack(side="left") ttk.Label(f_tex, text="] A⁻¹").pack(side="left") - btn_chi_preview = ttk.Button(f_tex, text=self.tr("btn_t2_chi_preview"), width=10, command=self.preview_ichi_window_t2) + btn_chi_preview = ttk.Button(f_tex, text=self.tr("btn_t2_chi_preview"), command=self.preview_ichi_window_t2) btn_chi_preview.pack(side="left", padx=(4, 0)) self._register_i18n_widget(btn_chi_preview, "btn_t2_chi_preview") @@ -4529,8 +4790,8 @@ def _sync_pol_entry_state(): cb_pol.configure(command=_sync_pol_entry_state) _sync_pol_entry_state() - row_mask = self.add_file_row(c4, self.tr("lbl_t2_mask"), self.t2_mask_path, "*.tif *.tiff *.edf *.npy") - row_flat = self.add_file_row(c4, self.tr("lbl_t2_flat"), self.t2_flat_path, "*.tif *.tiff *.edf *.npy") + row_mask = self.add_file_row(c4, "lbl_t2_mask", self.t2_mask_path, "*.tif *.tiff *.edf *.npy") + row_flat = self.add_file_row(c4, "lbl_t2_flat", self.t2_flat_path, "*.tif *.tiff *.edf *.npy") self.add_tooltip(cb_solid, "tip_t2_solid_angle") self.add_tooltip(cb_err, "tip_t2_error_model") @@ -4647,28 +4908,74 @@ def _sync_pol_entry_state(): ) cb_fluo2.pack(anchor="w") self._register_i18n_widget(cb_fluo2, "cb_t2_fluo_enable") - row_fluo2 = ttk.Frame(fluo2) - row_fluo2.pack(fill="x") - ttk.Combobox( - row_fluo2, + row_fluo2_m = ttk.Frame(fluo2) + row_fluo2_m.pack(fill="x") + lbl_fluo2_m = ttk.Label(row_fluo2_m, text=self.tr("lbl_t2_fluo_method")) + lbl_fluo2_m.pack(side="left", padx=(3, 2)) + self._register_i18n_widget(lbl_fluo2_m, "lbl_t2_fluo_method") + cb_fluo2_method = ttk.Combobox( + row_fluo2_m, textvariable=self.t2_fluo_method, values=["constant", "high_q_mean", "high_q_median", "measured"], width=16, state="readonly", - ).pack(side="left", padx=3) - ttk.Entry(row_fluo2, textvariable=self.t2_fluo_f0, width=8).pack(side="left", padx=3) - ttk.Entry(row_fluo2, textvariable=self.t2_fluo_f0_uncertainty, width=8).pack( - side="left", padx=3 ) - ttk.Entry(row_fluo2, textvariable=self.t2_fluo_beta, width=6).pack(side="left", padx=3) - ttk.Entry(row_fluo2, textvariable=self.t2_fluo_beta_uncertainty, width=6).pack( - side="left", padx=3 + cb_fluo2_method.pack(side="left", padx=(0, 6)) + lbl_fluo2_f0 = ttk.Label(row_fluo2_m, text=self.tr("lbl_t2_fluo_f0")) + lbl_fluo2_f0.pack(side="left", padx=(2, 2)) + self._register_i18n_widget(lbl_fluo2_f0, "lbl_t2_fluo_f0") + e_fluo2_f0 = ttk.Entry(row_fluo2_m, textvariable=self.t2_fluo_f0, width=8) + e_fluo2_f0.pack(side="left", padx=(0, 5)) + lbl_fluo2_f0u = ttk.Label( + row_fluo2_m, text=self.tr("lbl_t2_fluo_f0_uncertainty") ) - ttk.Entry(row_fluo2, textvariable=self.t2_fluo_qmin, width=6).pack(side="left", padx=2) - ttk.Entry(row_fluo2, textvariable=self.t2_fluo_qmax, width=6).pack(side="left", padx=2) - self.add_file_row( - fluo2, self.tr("lbl_t3_fluo_file"), self.t2_fluo_path, "*.dat *.txt *.csv *.xml" + lbl_fluo2_f0u.pack(side="left", padx=(2, 2)) + self._register_i18n_widget(lbl_fluo2_f0u, "lbl_t2_fluo_f0_uncertainty") + e_fluo2_f0u = ttk.Entry( + row_fluo2_m, textvariable=self.t2_fluo_f0_uncertainty, width=8 ) + e_fluo2_f0u.pack(side="left", padx=(0, 3)) + + row_fluo2_b = ttk.Frame(fluo2) + row_fluo2_b.pack(fill="x") + lbl_fluo2_b = ttk.Label(row_fluo2_b, text=self.tr("lbl_t2_fluo_beta")) + lbl_fluo2_b.pack(side="left", padx=(3, 2)) + self._register_i18n_widget(lbl_fluo2_b, "lbl_t2_fluo_beta") + e_fluo2_b = ttk.Entry(row_fluo2_b, textvariable=self.t2_fluo_beta, width=8) + e_fluo2_b.pack(side="left", padx=(0, 8)) + lbl_fluo2_bu = ttk.Label( + row_fluo2_b, text=self.tr("lbl_t2_fluo_beta_uncertainty") + ) + lbl_fluo2_bu.pack(side="left", padx=(2, 2)) + self._register_i18n_widget(lbl_fluo2_bu, "lbl_t2_fluo_beta_uncertainty") + e_fluo2_bu = ttk.Entry( + row_fluo2_b, textvariable=self.t2_fluo_beta_uncertainty, width=8 + ) + e_fluo2_bu.pack(side="left", padx=(0, 3)) + + row_fluo2_w = ttk.Frame(fluo2) + row_fluo2_w.pack(fill="x") + lbl_fluo2_qmin = ttk.Label(row_fluo2_w, text=self.tr("lbl_t2_fluo_qmin")) + lbl_fluo2_qmin.pack(side="left", padx=(3, 2)) + self._register_i18n_widget(lbl_fluo2_qmin, "lbl_t2_fluo_qmin") + e_fluo2_qmin = ttk.Entry(row_fluo2_w, textvariable=self.t2_fluo_qmin, width=8) + e_fluo2_qmin.pack(side="left", padx=(0, 8)) + lbl_fluo2_qmax = ttk.Label(row_fluo2_w, text=self.tr("lbl_t2_fluo_qmax")) + lbl_fluo2_qmax.pack(side="left", padx=(2, 2)) + self._register_i18n_widget(lbl_fluo2_qmax, "lbl_t2_fluo_qmax") + e_fluo2_qmax = ttk.Entry(row_fluo2_w, textvariable=self.t2_fluo_qmax, width=8) + e_fluo2_qmax.pack(side="left", padx=(0, 3)) + self.add_tooltip(cb_fluo2_method, "tip_t2_fluo_method") + self.add_tooltip(e_fluo2_f0, "tip_t2_fluo_f0") + self.add_tooltip(e_fluo2_f0u, "tip_t2_fluo_f0_uncertainty") + self.add_tooltip(e_fluo2_b, "tip_t2_fluo_beta") + self.add_tooltip(e_fluo2_bu, "tip_t2_fluo_beta_uncertainty") + self.add_tooltip(e_fluo2_qmin, "tip_t2_fluo_qmin") + self.add_tooltip(e_fluo2_qmax, "tip_t2_fluo_qmax") + row_fluo2_file = self.add_file_row( + fluo2, "lbl_t2_fluo_file", self.t2_fluo_path, EXTERNAL_1D_FILE_PATTERN + ) + self.add_tooltip(row_fluo2_file["entry"], "tip_t2_fluo_file") ttk.Label(fluo2, textvariable=self.t2_fluo_status, style="Hint.TLabel").pack(anchor="w") row_fmt2 = ttk.Frame(c5) @@ -4678,13 +4985,12 @@ def _sync_pol_entry_state(): self._register_i18n_widget(lbl_ofmt2, "lbl_output_format") self.t2_fmt_combo = ttk.Combobox( row_fmt2, - textvariable=self.t2_output_format, - values=["tsv", "csv", "cansas_xml", "nxcansas_h5"], width=18, state="readonly", ) - self.t2_fmt_combo.current(0) self.t2_fmt_combo.pack(side="left", padx=5) + self._configure_output_format_combo(self.t2_fmt_combo, self.t2_output_format) + self.add_tooltip(self.t2_fmt_combo, "tip_output_format") row_cal2d = ttk.Frame(c5) row_cal2d.pack(fill="x", pady=(2, 0)) @@ -4774,7 +5080,7 @@ def _sync_pol_entry_state(): btn_run.pack(fill="x", ipady=8) # strongest visual weight — this is the main action for most users self.prog_bar = ttk.Progressbar(bot_frame, mode="determinate") self.prog_bar.pack(fill="x", pady=5) - row_out_dir = self.add_dir_row(bot_frame, self.tr("lbl_t2_outdir"), self.t2_output_root) + row_out_dir = self.add_dir_row(bot_frame, "lbl_t2_outdir", self.t2_output_root) self.add_tooltip(btn_run, "tip_t2_run") self.add_tooltip(self.prog_bar, "tip_t2_progress") self.add_tooltip(row_out_dir["entry"], "tip_t2_outdir") @@ -4864,6 +5170,7 @@ def init_tab3_external_1d(self): self.t3_overwrite = tk.BooleanVar(value=False) self.t3_queue_info = tk.StringVar(value=self._fmt_queue_info(0, 0)) self.t3_out_hint = tk.StringVar(value=f"{self.tr('out_auto_prefix')}: processed_external_1d_abs") + self.t3_output_format = tk.StringVar(value="tsv") f_guide = ttk.LabelFrame(p, text=self.tr("t3_guide_title"), style="Group.TLabelframe") self._register_i18n_widget(f_guide, "t3_guide_title") @@ -4979,6 +5286,15 @@ def init_tab3_external_1d(self): ) lbl_fmt.pack(anchor="w") self._register_i18n_widget(lbl_fmt, "lbl_t3_formats") + fmt_row = ttk.Frame(c2) + fmt_row.pack(fill="x", pady=(4, 2)) + lbl_ofmt = ttk.Label(fmt_row, text=self.tr("lbl_output_format"), anchor="e") + lbl_ofmt.pack(side="left") + self._register_i18n_widget(lbl_ofmt, "lbl_output_format") + self.t3_fmt_combo = ttk.Combobox(fmt_row, width=18, state="readonly") + self.t3_fmt_combo.pack(side="left", padx=5) + self._configure_output_format_combo(self.t3_fmt_combo, self.t3_output_format) + self.add_tooltip(self.t3_fmt_combo, "tip_output_format") self.add_tooltip(cb_resume, "tip_t3_resume") self.add_tooltip(cb_overwrite, "tip_t3_overwrite") @@ -4987,9 +5303,9 @@ def init_tab3_external_1d(self): self._register_i18n_widget(c3, "lf_t3_raw_params") self.add_hint(c3, "hint_t3_raw", wraplength=420) - row_meta = self.add_file_row(c3, "Metadata CSV:", self.t3_meta_csv_path, "*.csv") - row_bg = self.add_file_row(c3, self.tr("lbl_t3_bg1d_file"), self.t3_bg1d_path, "*.dat *.txt *.chi *.csv") - row_dark = self.add_file_row(c3, self.tr("lbl_t3_dark1d_file"), self.t3_dark1d_path, "*.dat *.txt *.chi *.csv") + row_meta = self.add_file_row(c3, "lbl_t3_meta_csv", self.t3_meta_csv_path, "*.csv") + row_bg = self.add_file_row(c3, "lbl_t3_bg1d_file", self.t3_bg1d_path, EXTERNAL_1D_FILE_PATTERN) + row_dark = self.add_file_row(c3, "lbl_t3_dark1d_file", self.t3_dark1d_path, EXTERNAL_1D_FILE_PATTERN) row_meta_ops = ttk.Frame(c3) row_meta_ops.pack(fill="x", pady=(1, 1)) @@ -5060,7 +5376,7 @@ def init_tab3_external_1d(self): cb_buf = ttk.Checkbutton(buf_frame, text=self.tr("cb_t3_buffer_enable"), variable=self.t3_buffer_enabled) cb_buf.pack(anchor="w", padx=3, pady=2) self._register_i18n_widget(cb_buf, "cb_t3_buffer_enable") - self.add_file_row(buf_frame, self.tr("lbl_t3_buffer_file"), self.t3_buffer_path, "*.dat *.txt *.csv *.xml") + self.add_file_row(buf_frame, "lbl_t3_buffer_file", self.t3_buffer_path, EXTERNAL_1D_FILE_PATTERN) row_alpha = ttk.Frame(buf_frame) row_alpha.pack(fill="x", pady=1) lbl_alpha = ttk.Label(row_alpha, text=self.tr("lbl_t3_alpha"), anchor="e") @@ -5114,55 +5430,66 @@ def init_tab3_external_1d(self): state="readonly", ) self.t3_fluo_method_combo.pack(side="left", padx=5) + self.add_tooltip(self.t3_fluo_method_combo, "tip_t2_fluo_method") lbl_fluo_f0 = ttk.Label(row_fluo_m, text=self.tr("lbl_t3_fluo_f0")) lbl_fluo_f0.pack(side="left", padx=(8, 0)) self._register_i18n_widget(lbl_fluo_f0, "lbl_t3_fluo_f0") - ttk.Entry(row_fluo_m, textvariable=self.t3_fluo_f0, width=8).pack(side="left", padx=5) + e_fluo_f0 = ttk.Entry(row_fluo_m, textvariable=self.t3_fluo_f0, width=8) + e_fluo_f0.pack(side="left", padx=5) + self.add_tooltip(e_fluo_f0, "tip_t2_fluo_f0") lbl_fluo_f0u = ttk.Label(row_fluo_m, text=self.tr("lbl_t3_fluo_f0_uncertainty")) lbl_fluo_f0u.pack(side="left") self._register_i18n_widget(lbl_fluo_f0u, "lbl_t3_fluo_f0_uncertainty") - ttk.Entry(row_fluo_m, textvariable=self.t3_fluo_f0_uncertainty, width=8).pack( - side="left", padx=5 + e_fluo_f0u = ttk.Entry( + row_fluo_m, textvariable=self.t3_fluo_f0_uncertainty, width=8 ) + e_fluo_f0u.pack(side="left", padx=5) + self.add_tooltip(e_fluo_f0u, "tip_t2_fluo_f0_uncertainty") row_fluo_b = ttk.Frame(fluo_frame) row_fluo_b.pack(fill="x", pady=1) lbl_fluo_b = ttk.Label(row_fluo_b, text=self.tr("lbl_t3_fluo_beta")) lbl_fluo_b.pack(side="left") self._register_i18n_widget(lbl_fluo_b, "lbl_t3_fluo_beta") - ttk.Entry(row_fluo_b, textvariable=self.t3_fluo_beta, width=8).pack(side="left", padx=5) - ttk.Entry(row_fluo_b, textvariable=self.t3_fluo_beta_uncertainty, width=8).pack( - side="left", padx=5 + e_fluo_b = ttk.Entry(row_fluo_b, textvariable=self.t3_fluo_beta, width=8) + e_fluo_b.pack(side="left", padx=5) + self.add_tooltip(e_fluo_b, "tip_t2_fluo_beta") + lbl_fluo_bu = ttk.Label( + row_fluo_b, text=self.tr("lbl_t3_fluo_beta_uncertainty") + ) + lbl_fluo_bu.pack(side="left", padx=(4, 0)) + self._register_i18n_widget(lbl_fluo_bu, "lbl_t3_fluo_beta_uncertainty") + e_fluo_bu = ttk.Entry( + row_fluo_b, textvariable=self.t3_fluo_beta_uncertainty, width=8 ) - lbl_fluo_w = ttk.Label(row_fluo_b, text=self.tr("lbl_t3_fluo_window")) - lbl_fluo_w.pack(side="left", padx=(8, 0)) + e_fluo_bu.pack(side="left", padx=5) + self.add_tooltip(e_fluo_bu, "tip_t3_fluo_beta_uncertainty") + + row_fluo_w = ttk.Frame(fluo_frame) + row_fluo_w.pack(fill="x", pady=1) + lbl_fluo_w = ttk.Label(row_fluo_w, text=self.tr("lbl_t3_fluo_window")) + lbl_fluo_w.pack(side="left") self._register_i18n_widget(lbl_fluo_w, "lbl_t3_fluo_window") - ttk.Entry(row_fluo_b, textvariable=self.t3_fluo_qmin, width=8).pack(side="left", padx=2) - ttk.Entry(row_fluo_b, textvariable=self.t3_fluo_qmax, width=8).pack(side="left", padx=2) - self.add_file_row( - fluo_frame, self.tr("lbl_t3_fluo_file"), self.t3_fluo_path, "*.dat *.txt *.csv *.xml" + lbl_fluo_qmin = ttk.Label(row_fluo_w, text=self.tr("lbl_t3_fluo_qmin")) + lbl_fluo_qmin.pack(side="left", padx=(4, 2)) + self._register_i18n_widget(lbl_fluo_qmin, "lbl_t3_fluo_qmin") + e_fluo_qmin = ttk.Entry(row_fluo_w, textvariable=self.t3_fluo_qmin, width=8) + e_fluo_qmin.pack(side="left", padx=(0, 6)) + self.add_tooltip(e_fluo_qmin, "tip_t3_fluo_qmin") + lbl_fluo_qmax = ttk.Label(row_fluo_w, text=self.tr("lbl_t3_fluo_qmax")) + lbl_fluo_qmax.pack(side="left", padx=(2, 2)) + self._register_i18n_widget(lbl_fluo_qmax, "lbl_t3_fluo_qmax") + e_fluo_qmax = ttk.Entry(row_fluo_w, textvariable=self.t3_fluo_qmax, width=8) + e_fluo_qmax.pack(side="left") + self.add_tooltip(e_fluo_qmax, "tip_t3_fluo_qmax") + row_fluo_file = self.add_file_row( + fluo_frame, "lbl_t3_fluo_file", self.t3_fluo_path, EXTERNAL_1D_FILE_PATTERN ) + self.add_tooltip(row_fluo_file["entry"], "tip_t2_fluo_file") self.add_hint(fluo_frame, "hint_t3_fluo", wraplength=720) ttk.Label(fluo_frame, textvariable=self.t3_fluo_status, style="Hint.TLabel").pack( anchor="w", padx=3 ) - # ---- Output format selector ---- - self.t3_output_format = tk.StringVar(value="tsv") - fmt_row = ttk.Frame(buf_frame) - fmt_row.pack(fill="x", pady=(4, 2)) - lbl_ofmt = ttk.Label(fmt_row, text=self.tr("lbl_output_format"), anchor="e") - lbl_ofmt.pack(side="left") - self._register_i18n_widget(lbl_ofmt, "lbl_output_format") - self.t3_fmt_combo = ttk.Combobox( - fmt_row, - textvariable=self.t3_output_format, - values=["tsv", "csv", "cansas_xml", "nxcansas_h5"], - width=18, - state="readonly", - ) - self.t3_fmt_combo.current(0) - self.t3_fmt_combo.pack(side="left", padx=5) - mid = ttk.LabelFrame(p, text=self.tr("t3_mid_title"), style="Group.TLabelframe") self._register_i18n_widget(mid, "t3_mid_title") mid.pack(fill="both", expand=True, padx=10, pady=5) @@ -5208,7 +5535,7 @@ def init_tab3_external_1d(self): btn_run.pack(fill="x", ipady=7) self.t3_prog_bar = ttk.Progressbar(bot, mode="determinate") self.t3_prog_bar.pack(fill="x", pady=5) - row_out_dir = self.add_dir_row(bot, self.tr("lbl_t3_outdir"), self.t3_output_root) + row_out_dir = self.add_dir_row(bot, "lbl_t3_outdir", self.t3_output_root) ttk.Label(bot, textvariable=self.t3_out_hint, style="Hint.TLabel").pack(anchor="w") self.add_tooltip(btn_run, "tip_t3_run") self.add_tooltip(self.t3_prog_bar, "tip_t3_progress") @@ -5279,7 +5606,7 @@ def disable_raw_controls(parent): def add_external_1d_files(self): fs = filedialog.askopenfilenames( - filetypes=[("1D Files", "*.dat *.txt *.chi *.csv"), ("All Files", "*.*")] + filetypes=[("1D Files", EXTERNAL_1D_FILE_PATTERN), ("All Files", "*.*")] ) for f in fs: if f not in self.t3_files: @@ -6031,11 +6358,21 @@ def set_status(value): "raw 流程下禁止荧光扣除;荧光必须作用在绝对强度标度上。" ) method = str(getattr(self, f"{source}_fluo_method").get()).strip().lower() + if parse_fluorescence_method is not None: + parse_fluorescence_method(method) + elif method not in {"constant", "high_q_mean", "high_q_median", "measured"}: + raise ValueError( + "Fluorescence method must be constant, high_q_mean, high_q_median, or measured" + ) beta = float(getattr(self, f"{source}_fluo_beta").get()) if not np.isfinite(beta) or beta <= 0: raise ValueError("Fluorescence beta must be finite and > 0") f0_text = str(getattr(self, f"{source}_fluo_f0").get()).strip() f0 = float(f0_text) if f0_text else None + if method == "constant" and ( + f0 is None or not np.isfinite(f0) or f0 < 0 + ): + raise ValueError("constant fluorescence method requires finite F0 >= 0") f0_uncertainty = self.parse_optional_nonnegative_uncertainty( getattr(self, f"{source}_fluo_f0_uncertainty").get(), label="Fluorescence F0 uncertainty", @@ -6048,6 +6385,19 @@ def set_status(value): getattr(self, f"{source}_fluo_qmin").get(), getattr(self, f"{source}_fluo_qmax").get(), ) + if method in {"high_q_mean", "high_q_median"}: + if f0 is not None: + raise ValueError("high_q fluorescence methods estimate F0; leave F0 blank") + if high_q_window is None: + raise ValueError("high_q fluorescence methods require qmin and qmax") + elif method == "constant" and high_q_window is not None: + raise ValueError("constant fluorescence method does not accept a high-q window") + elif method == "measured" and ( + f0 is not None or f0_uncertainty is not None or high_q_window is not None + ): + raise ValueError( + "measured fluorescence uses the supplied absolute F(q); leave F0 and high-q fields blank" + ) path_text = str(getattr(self, f"{source}_fluo_path").get()).strip() profile = None digest = None @@ -6064,8 +6414,15 @@ def set_status(value): raise RuntimeError("fluorescence-state validation is unavailable") fluo_path = Path(path_text).expanduser().resolve() profile = self.prepare_external_profile_axis( - fluo_path, self.read_external_1d_profile(fluo_path) + fluo_path, + self.read_external_1d_profile(fluo_path), + mode="auto" if source == "t2" else None, + wavelength_a="" if source == "t2" else None, ) + if profile.get("x_label") != "Q_A^-1": + raise ValueError( + "Measured fluorescence requires an explicit Q axis in A^-1; Chi is not allowed." + ) require_absolute_input_for_fluorescence_subtraction( profile, profile_name="Fluorescence" ) @@ -6273,7 +6630,11 @@ def resolve_external_x_axis(self, path, profile, *, mode=None, wavelength_a=None selected = ( str(mode).strip().lower() if mode is not None - else str(self.t3_x_mode.get()).strip().lower() + else str( + getattr(self, "t3_x_mode", None).get() + if getattr(self, "t3_x_mode", None) is not None + else "auto" + ).strip().lower() ) if selected not in {"auto", "q_a^-1", "two_theta_deg", "chi_deg"}: raise ValueError(f"未知外部 X轴模式: {selected}") @@ -6281,34 +6642,94 @@ def resolve_external_x_axis(self, path, profile, *, mode=None, wavelength_a=None x = np.asarray(profile.get("x"), dtype=np.float64) if x.ndim != 1 or x.size < 1 or not np.all(np.isfinite(x)): raise ValueError("外部 X轴必须是一维有限数值。") - raw_name = str(profile.get("x_col", "")).strip().lower() - raw_name = raw_name.replace("å", "angstrom").replace("Å", "angstrom") - name = re.sub(r"[^a-z0-9]+", "", raw_name) + + def canonical_q_unit(value): + if _core_canonicalize_q_unit is not None: + canonical = _core_canonicalize_q_unit(value) + if canonical == "A^-1": + return "a^-1" + if canonical == "nm^-1": + return "nm^-1" + return None + + text = unicodedata.normalize("NFKC", str(value or "").strip().lower()) + text = ( + text.replace("å", "angstrom") + .replace("Å", "angstrom") + .replace("⁻", "-") + .replace("−", "-") + .replace("–", "-") + ) + text = re.sub(r"^\s*q(?:\s*[_:\-]?\s*)?", "", text, count=1) + has_inverse = bool( + re.search(r"(? 0;BG/Dark/poni 路径正确。 -2) 选择厚度策略: - - 自动厚度:d = -ln(T)/mu - - 固定厚度:所有样品同一厚度 +2) 输入正式输出所需的固定厚度;逐帧 Beer-Lambert 厚度仅保留为诊断概念,不参与正式输出。 3) 选择积分模式(可多选): - I-Q 全环 - I-Q 扇区(支持多扇区:如 -25~25;45~65) @@ -7693,13 +8130,8 @@ def init_tab_help(self): 8) 点击“开始稳健批处理”。 Step 3. 如果你已在外部软件完成积分(可选) -1) 进入 Tab3,导入外部 1D 文件(.dat/.txt/.chi/.csv)。 -2) 选择流程: - - 仅比例缩放:外部1D已完成本底/归一化 - - 原始1D完整校正:外部1D是原始积分结果,需要提供 BG1D/Dark1D 和 exp/I0/T - - metadata 来源优先级:metadata.csv > 文件注释头 > Tab3 固定参数 - - BG固定参数默认跟随 Tab1 全局;可取消“BG参数跟随”后手动覆盖 - - metadata.csv 可以直接用 Tab2 的 batch_report.csv,或点“由 Tab2 报告生成 metadata” +1) 进入 Tab3,导入外部 relative 1D 文件(.dat/.txt/.chi/.csv/.xml/.h5/.hdf5)。 +2) Tab3 当前正式流程仅做已完成归一化的 relative 曲线比例缩放;raw 1D 完整校正入口保持禁用。 3) 选择公式: - K/d:外部 1D 还未除厚度 - K:外部 1D 已除厚度 @@ -7717,11 +8149,11 @@ def init_tab_help(self): 3) Trans(T) 透过率,推荐范围 (0, 1]。 - 程序会对 1~2 的值做保护处理(视为漂移并夹到 1.0), - 仅对明确百分号或明显百分数字面量(>2)才按百分数换算。 + 带百分号或明显百分数字面量(2≤T≤100,例如 85)的值会按百分数换算; + 没有百分号且位于 1Tab3 直连 metadata 流程) @@ -7858,12 +8290,13 @@ def init_tab_help(self): 1) Run Tab1 calibration with Std/BG/Dark/poni. 2) Verify Time/I0/T and monitor mode (rate or integrated). 3) Run robust K calibration and check Points Used, Std Dev, and Q overlap. -4) Go to Tab2 for batch processing; run dry-check before full run. -5) Use Tab3 only when external 1D conversion is needed. +4) Go to Tab2, enter fixed thickness, and run dry-check before full output. +5) Use Tab3 only to scale an already reduced relative 1D profile. [3] Critical checks before batch runs - K factor is valid and recent. - BG/Dark/poni are from compatible conditions. +- Transmission T must satisfy 0 < T <= 1. - Dry-check reports no critical warnings. - Monitor mode matches beamline data semantics. @@ -7890,7 +8323,7 @@ def copy_help(): btn_copy = ttk.Button(bar, text=self.tr("help_copy_btn"), command=copy_help) self._register_i18n_widget(btn_copy, "help_copy_btn") btn_copy.pack(side="right") - self.add_tooltip(btn_copy, self.tr("help_copy_tooltip")) + self.add_tooltip(btn_copy, "help_copy_tooltip") def refresh_help_text(self): txt = getattr(self, "help_text_widget", None) @@ -7912,12 +8345,13 @@ def refresh_help_text(self): 1) Run Tab1 calibration with Std/BG/Dark/poni. 2) Verify Time/I0/T and monitor mode (rate or integrated). 3) Run robust K calibration and check Points Used, Std Dev, and Q overlap. -4) Go to Tab2 for batch processing; run dry-check before full run. -5) Use Tab3 only when external 1D conversion is needed. +4) Go to Tab2, enter fixed thickness, and run dry-check before full output. +5) Use Tab3 only to scale an already reduced relative 1D profile. [3] Critical checks before batch runs - K factor is valid and recent. - BG/Dark/poni are from compatible conditions. +- Transmission T must satisfy 0 < T <= 1. - Dry-check reports no critical warnings. - Monitor mode matches beamline data semantics. @@ -9600,6 +10034,15 @@ def run_batch(self): self.refresh_queue_status() selected_modes = self.get_selected_modes() + output_format = str( + getattr(self, "t2_output_format", None).get() + if getattr(self, "t2_output_format", None) is not None + else "tsv" + ).strip().lower() + self.validate_output_format_for_axis( + output_format, + "Chi_deg" if "radial_chi" in selected_modes else "Q_A^-1", + ) export_cal2d = bool(self.t2_export_cal2d.get()) if hasattr(self, "t2_export_cal2d") else False if not selected_modes and not export_cal2d: raise ValueError("未选择输出:请至少勾选一种积分模式,或启用校正后2D数据包导出。") @@ -9826,7 +10269,7 @@ def run_batch(self): k_factor=float(self.global_vars["k_factor"].get()), require_scaled_pipeline=False, ), - "output_format": self.t2_output_format.get() if hasattr(self, "t2_output_format") else "tsv", + "output_format": output_format, "export_cal2d": export_cal2d, "cal2d_root": cal2d_root, "cal2d_dtype": cal2d_dtype, @@ -9975,7 +10418,7 @@ def run_batch(self): ), "fixed_thk_cm": fixed_thk_cm if self.t2_calc_mode.get() == "fixed" else None, "alpha": float(self.t2_alpha.get()) if self.t2_alpha_enabled.get() else 1.0, - "output_format": self.t2_output_format.get() if hasattr(self, "t2_output_format") else "tsv", + "output_format": output_format, "export_calibrated_2d": export_cal2d, "calibrated_2d_dtype": cal2d_dtype if export_cal2d else None, "calibrated_2d_apply_flat": bool(self.t2_cal2d_apply_flat.get()) if export_cal2d else None, @@ -10451,6 +10894,7 @@ def dry_run(self): selected_modes = self.get_selected_modes() warnings = [] calibration_gate_error = None + active_calibration_context = None formal_config_errors = [] if str(mode).strip().lower() != "fixed": formal_config_errors.append( @@ -10462,11 +10906,23 @@ def dry_run(self): formal_config_errors.append( "Legacy exists-only resume is disabled for formal Tab2 output." ) + output_format = str( + getattr(self, "t2_output_format", None).get() + if getattr(self, "t2_output_format", None) is not None + else "tsv" + ).strip().lower() + try: + self.validate_output_format_for_axis( + output_format, + "Chi_deg" if "radial_chi" in selected_modes else "Q_A^-1", + ) + except ValueError as exc: + formal_config_errors.append(str(exc)) warnings.extend(formal_config_errors) try: k_factor = float(self.global_vars["k_factor"].get()) polarization_applied, polarization_factor = self.resolve_t2_polarization() - self.require_calibration_context_for_batch( + active_calibration_context = self.require_calibration_context_for_batch( k_factor=k_factor, monitor_mode=monitor_mode, poni_path=self.global_vars["poni_path"].get(), @@ -10480,6 +10936,22 @@ def dry_run(self): except (OSError, RuntimeError, TypeError, ValueError) as exc: calibration_gate_error = str(exc) warnings.append(calibration_gate_error) + try: + fluorescence_k = ( + float(self.global_vars["k_factor"].get()) + if "k_factor" in getattr(self, "global_vars", {}) + else None + ) + self.prepare_workbench_fluorescence( + source="t2", + pipeline_mode="scaled", + calibration_context=active_calibration_context, + k_factor=fluorescence_k, + require_scaled_pipeline=False, + ) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + formal_config_errors.append(f"Fluorescence configuration invalid: {exc}") + warnings.append(f"Fluorescence configuration invalid: {exc}") worker_gate_error = None try: self.validate_batch_workers(self.t2_workers.get()) @@ -10511,18 +10983,30 @@ def dry_run(self): monitor_mode, ) - if not selected_modes: + export_cal2d = bool( + getattr(self, "t2_export_cal2d", None).get() + if getattr(self, "t2_export_cal2d", None) is not None + else False + ) + if not selected_modes and not export_cal2d: + formal_config_errors.append(self.tr("warn_no_integ_mode")) warnings.append(self.tr("warn_no_integ_mode")) sector_specs = [] if "1d_sector" in selected_modes: try: sector_specs = self.get_t2_sector_specs() if not self.t2_sector_save_each.get() and not self.t2_sector_save_combined.get(): - warnings.append(self.tr("warn_sector_no_output")) + sector_output_error = self.tr("warn_sector_no_output") + formal_config_errors.append(sector_output_error) + warnings.append(sector_output_error) except Exception as e: - warnings.append(self.tr("warn_sector_angle_invalid").format(e=e)) + sector_parse_error = self.tr("warn_sector_angle_invalid").format(e=e) + formal_config_errors.append(sector_parse_error) + warnings.append(sector_parse_error) if "radial_chi" in selected_modes and self.t2_rad_qmin.get() >= self.t2_rad_qmax.get(): - warnings.append(self.tr("warn_texture_q_invalid")) + radial_q_error = self.tr("warn_texture_q_invalid") + formal_config_errors.append(radial_q_error) + warnings.append(radial_q_error) if self.t2_ref_mode.get() == "auto": if not self.t2_bg_candidates: warnings.append(self.tr("warn_auto_bg_empty")) @@ -10536,6 +11020,11 @@ def dry_run(self): ) if inst_issues: warnings.append(self.tr("warn_inst_issues").format(n=len(inst_issues))) + # Strict instrument mismatches are deterministic run blockers; + # keep the summary in the warning panel and details below it. + formal_config_errors.append( + self.tr("warn_inst_issues").format(n=len(inst_issues)) + ) bg_build_rejected = [] dark_build_rejected = [] @@ -11477,11 +11966,15 @@ def _remove_poni_trace(event): top.bind("", _remove_poni_trace, add="+") - def add_file_row(self, p, label_text, v, pat, cmd=None): + def add_file_row(self, p, label_text, v, pat, cmd=None, label_key=None): + key = label_key or (label_text if label_text in I18N.get("en", {}) else None) + display_text = self.tr(key) if key else label_text f = ttk.Frame(p) f.pack(fill="x", pady=3) - lbl = ttk.Label(f, text=label_text, width=18, anchor="e", justify="right", wraplength=150) + lbl = ttk.Label(f, text=display_text, width=18, anchor="e", justify="right", wraplength=150) lbl.pack(side="left", padx=(0, 6)) + if key: + self._register_i18n_widget(lbl, key) ent = ttk.Entry(f, textvariable=v) ent.pack(side="left", fill="x", expand=True, padx=(0, 4)) def b(): @@ -11492,13 +11985,18 @@ def b(): cmd(fp) btn = ttk.Button(f, text="...", width=3, command=b) btn.pack(side="left") + self.add_tooltip(btn, "tip_browse_file") return {"frame": f, "label": lbl, "entry": ent, "button": btn} - def add_dir_row(self, p, label_text, v): + def add_dir_row(self, p, label_text, v, label_key=None): + key = label_key or (label_text if label_text in I18N.get("en", {}) else None) + display_text = self.tr(key) if key else label_text f = ttk.Frame(p) f.pack(fill="x", pady=3) - lbl = ttk.Label(f, text=label_text, width=18, anchor="e", justify="right", wraplength=150) + lbl = ttk.Label(f, text=display_text, width=18, anchor="e", justify="right", wraplength=150) lbl.pack(side="left", padx=(0, 6)) + if key: + self._register_i18n_widget(lbl, key) ent = ttk.Entry(f, textvariable=v) ent.pack(side="left", fill="x", expand=True, padx=(0, 4)) def b(): @@ -11507,6 +12005,7 @@ def b(): v.set(dp) btn = ttk.Button(f, text="...", width=3, command=b) btn.pack(side="left") + self.add_tooltip(btn, "tip_browse_dir") return {"frame": f, "label": lbl, "entry": ent, "button": btn} def add_grid_entry(self, p, v, r, c): diff --git a/src/saxsabs/cli.py b/src/saxsabs/cli.py index 11b1e4a..749a45d 100644 --- a/src/saxsabs/cli.py +++ b/src/saxsabs/cli.py @@ -10,8 +10,11 @@ import json import math from pathlib import Path +import re import sys +import unicodedata +import numpy as np import pandas as pd from . import __version__ @@ -25,6 +28,10 @@ profile_intensity, profile_uncertainty, read_external_1d_profile, + canonicalize_q_unit, + infer_q_unit_from_column, + q_column_unit_hint, + q_axis_kind, ) @@ -112,6 +119,62 @@ def _resolve_column( ) +def _has_explicit_q_unit_hint(raw_hint: object, x_col: object) -> bool: + """Recognize unit-like hints while allowing semantic names such as ``q_ref``.""" + + hint = str(raw_hint or "").strip().lower() + column = unicodedata.normalize("NFKC", str(x_col or "")).lower() + text = f"{hint} {column}" + if any(marker in text for marker in ("^", "/", "⁻", "−", "inverse")): + return True + if any(char in column for char in "()[]{}"): + return True + return any( + re.search(rf"(? bool: + """Reject ``Q (nm)``/``Q (angstrom)`` before they become reciprocal Q.""" + + text = unicodedata.normalize("NFKC", str(x_col or "")).strip().lower() + text = text.replace("å", "angstrom").replace("Å", "angstrom") + if not text.startswith("q"): + return False + suffix = re.sub(r"[^a-z0-9]+", "", text[1:]) + return suffix in {"a", "angstrom", "nm", "mm", "cm", "m", "um", "pm"} + + +def _unitless_column_prefix(name: object) -> str: + text = str(name or "").strip() + prefix = re.split(r"[\s(\[{_:]", text, maxsplit=1)[0] + return _clean_column_name(prefix) + + +def _q_selector_matches_unitful_profile(requested: object, resolved: object) -> bool: + return ( + q_axis_kind(resolved) == "q" + and _clean_column_name(requested) == _unitless_column_prefix(resolved) + and _clean_column_name(requested) != _clean_column_name(resolved) + ) + + +def _q_column_matches_profile_values( + dataframe: pd.DataFrame, + column: object, + profile: dict[str, object], +) -> bool: + try: + source = pd.to_numeric(dataframe[column], errors="coerce").to_numpy(dtype=float) + parsed = np.asarray(profile["x"], dtype=float) + except (KeyError, TypeError, ValueError): + return False + source = np.sort(source[np.isfinite(source)]) + parsed = np.sort(parsed[np.isfinite(parsed)]) + return source.shape == parsed.shape and np.allclose(source, parsed, rtol=0.0, atol=1e-12) + + def _read_profile_for_estimate( path: Path, *, @@ -122,18 +185,72 @@ def _read_profile_for_estimate( if q_col is None and i_col is None: return read_external_1d_profile(path) + parsed_profile = read_external_1d_profile(path) + requested_q_matches = q_col is None or ( + _clean_column_name(q_col) == _clean_column_name(parsed_profile.get("x_col", "")) + ) + requested_i_matches = i_col is None or ( + _clean_column_name(i_col) == _clean_column_name(parsed_profile.get("i_col", "")) + ) + if requested_q_matches and requested_i_matches: + reused = dict(parsed_profile) + reused["x_axis_override"] = q_col is not None + if q_col is not None and not reused.get("x_unit"): + resolved_name = str(reused.get("x_col", "")) + reused["x_unit"] = infer_q_unit_from_column(resolved_name) + raw_q_hint = q_column_unit_hint(resolved_name) + reused["x_unit_raw"] = ( + raw_q_hint + if reused["x_unit"] is None + and _has_explicit_q_unit_hint(raw_q_hint, resolved_name) + else "" + ) + return reused + df = _read_tabular_dataframe(path) columns = list(df.columns) + if ( + q_col is not None + and requested_i_matches + and _q_selector_matches_unitful_profile( + q_col, parsed_profile.get("x_col", "") + ) + ): + try: + raw_q_col = _resolve_column(columns, q_col, "q", profile_label) + except ValueError as exc: + raise ValueError( + f"{profile_label} q selector {q_col!r} is ambiguous; " + "select the complete unit-bearing Q column" + ) from exc + if not _q_column_matches_profile_values(df, raw_q_col, parsed_profile): + raise ValueError( + f"{profile_label} q selector {q_col!r} does not match the parsed Q column; " + "refusing to discard its unit metadata" + ) + reused = dict(parsed_profile) + reused["x_axis_override"] = True + return reused + resolved_q_col = _resolve_column(columns, q_col, "q", profile_label) resolved_i_col = _resolve_column(columns, i_col, "intensity", profile_label) q = pd.to_numeric(df[resolved_q_col], errors="coerce").to_numpy(dtype=float) intensity = pd.to_numeric(df[resolved_i_col], errors="coerce").to_numpy(dtype=float) - profile = read_external_1d_profile(path) + profile = parsed_profile profile["x"] = q profile["intensity"] = intensity profile["i_col"] = str(resolved_i_col) profile["x_col"] = str(resolved_q_col) + profile["x_unit"] = infer_q_unit_from_column(resolved_q_col) + raw_q_hint = q_column_unit_hint(resolved_q_col) + profile["x_unit_raw"] = ( + raw_q_hint + if profile["x_unit"] is None + and _has_explicit_q_unit_hint(raw_q_hint, resolved_q_col) + else "" + ) + profile["x_axis_override"] = q_col is not None return profile @@ -151,6 +268,78 @@ def _apply_declared_intensity_state( return updated +def _normalize_q_profile( + profile: dict[str, object], + *, + profile_label: str = "profile", +) -> dict[str, object]: + """Return a shallow profile copy whose Q values are in Å⁻¹. + + Parsers preserve source values and report ``x_unit``. The numerical + kernels used by these CLI commands have a single Å⁻¹ contract, so this is + the only conversion boundary. Calling it repeatedly is harmless because + the returned profile is explicitly marked ``A^-1``. + """ + + x_col = str(profile.get("x_col", "")).strip() + axis_kind = q_axis_kind(x_col) + if axis_kind == "chi": + raise ValueError(f"{profile_label}: chi is an angle axis, not Q") + if axis_kind == "two_theta": + raise ValueError(f"{profile_label}: 2theta requires an explicit wavelength conversion") + if axis_kind == "unknown" and profile.get("x_axis_override"): + axis_kind = "q" + if axis_kind != "q": + raise ValueError( + f"{profile_label}: unsupported or non-Q axis {x_col!r}; expected a Q column" + ) + if _is_bare_q_length_header(x_col): + raise ValueError( + f"{profile_label}: Q unit must explicitly state a reciprocal length " + "(for example nm^-1 or 1/angstrom)" + ) + + raw_unit = profile.get("x_unit") + declared_unit = canonicalize_q_unit(raw_unit) if raw_unit not in (None, "") else None + if raw_unit not in (None, "") and declared_unit is None: + raise ValueError(f"{profile_label}: unsupported Q unit {raw_unit!r}") + raw_unit_hint = str(profile.get("x_unit_raw", "") or "").strip() + if raw_unit_hint and declared_unit is None: + raise ValueError(f"{profile_label}: unsupported Q unit {raw_unit_hint!r}") + inferred_unit = infer_q_unit_from_column(x_col) + if declared_unit is not None and inferred_unit is not None and declared_unit != inferred_unit: + raise ValueError( + f"{profile_label}: Q unit metadata conflicts with column {x_col!r}" + ) + source_unit = declared_unit or inferred_unit + if source_unit is None: + # A bare q column is the historical CLI contract and is interpreted + # as Å⁻¹; no conversion is applied. + source_unit = "A^-1" + + try: + x = np.asarray(profile["x"], dtype=np.float64) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"{profile_label}: Q values must be numeric") from exc + if x.ndim != 1 or not np.all(np.isfinite(x)): + raise ValueError(f"{profile_label}: Q values must be a finite 1-D array") + + conversion = "none" + if source_unit == "nm^-1": + x = x / 10.0 + conversion = "nm^-1_to_A^-1" + + updated = dict(profile) + updated["x"] = x.copy() + updated["x_unit"] = "A^-1" + updated["x_col"] = "Q_A^-1" + provenance = dict(profile.get("operator_provenance") or {}) + provenance["q_unit_original"] = str(profile.get("x_unit") or source_unit) + provenance["q_unit_conversion"] = conversion + updated["operator_provenance"] = provenance + return updated + + def _add_bl19b2_arguments( parser: argparse.ArgumentParser, *, @@ -457,6 +646,7 @@ def main() -> None: { "points": int(result["x"].size), "x_col": result["x_col"], + "x_unit": result.get("x_unit"), "i_col": result["i_col"], "err_col": result["err_col"], "intensity_state": result.get("intensity_state"), @@ -477,6 +667,7 @@ def main() -> None: ), args.intensity_state, ) + measured = _normalize_q_profile(measured, profile_label="measured") assessment = require_relative_input_for_absolute_scaling( measured, profile_name=str(args.meas) ) @@ -506,6 +697,7 @@ def main() -> None: i_col=args.ref_i_col, profile_label="reference", ) + reference = _normalize_q_profile(reference, profile_label="reference") out = estimate_k_factor_robust( q_meas=measured["x"], i_meas_per_cm=i_meas, @@ -539,8 +731,12 @@ def main() -> None: if args.command == "subtract-buffer": try: - sample = read_external_1d_profile(args.sample) - buffer_profile = read_external_1d_profile(args.buffer) + sample = _normalize_q_profile( + read_external_1d_profile(args.sample), profile_label="sample" + ) + buffer_profile = _normalize_q_profile( + read_external_1d_profile(args.buffer), profile_label="buffer" + ) result = subtract_buffer( sample["x"], profile_intensity(sample), @@ -571,7 +767,9 @@ def main() -> None: if args.command == "subtract-fluorescence": try: - sample = read_external_1d_profile(args.sample) + sample = _normalize_q_profile( + read_external_1d_profile(args.sample), profile_label="sample" + ) high_q_window = None if args.qmin is not None or args.qmax is not None: if args.qmin is None or args.qmax is None: @@ -580,7 +778,10 @@ def main() -> None: q_fluo = i_fluo = err_fluo = None fluo_profile = None if args.fluorescence is not None: - fluo_profile = read_external_1d_profile(args.fluorescence) + fluo_profile = _normalize_q_profile( + read_external_1d_profile(args.fluorescence), + profile_label="fluorescence", + ) q_fluo = fluo_profile["x"] i_fluo = profile_intensity(fluo_profile) err_fluo = profile_uncertainty(fluo_profile) diff --git a/src/saxsabs/core/intensity_state.py b/src/saxsabs/core/intensity_state.py index b068fc4..5fcbb5e 100644 --- a/src/saxsabs/core/intensity_state.py +++ b/src/saxsabs/core/intensity_state.py @@ -72,7 +72,27 @@ def protected_corrections(self) -> tuple[str, ...]: def _normalized_token(value: object) -> str: - return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower()) + text = str(value or "").strip().lower().translate( + str.maketrans( + { + "⁰": "0", + "¹": "1", + "²": "2", + "³": "3", + "⁴": "4", + "⁵": "5", + "⁶": "6", + "⁷": "7", + "⁸": "8", + "⁹": "9", + "⁻": "-", + "−": "-", + "–": "-", + "—": "-", + } + ) + ) + return re.sub(r"[^a-z0-9]+", "", text) def _canonical_correction(value: object) -> str: @@ -217,12 +237,12 @@ def assess_intensity_state(profile: Mapping[str, object]) -> IntensityStateAsses semantic_states.add(IntensityState.RAW_COUNTS) evidence.append(f"column:{profile.get('i_col')}") - unit = _normalized_token( - profile.get("intensity_unit", provenance.get("intensity_unit", "")) + raw_intensity_unit = profile.get( + "intensity_unit", provenance.get("intensity_unit", "") ) - if is_cm_inv_intensity_unit(unit): + if is_cm_inv_intensity_unit(raw_intensity_unit): semantic_states.add(IntensityState.ABSOLUTE_CM_INV) - evidence.append(f"unit:{profile.get('intensity_unit', provenance.get('intensity_unit'))}") + evidence.append(f"unit:{raw_intensity_unit}") # ``corrections_applied`` is evidence about the physical state. The # ``do_not_repeat`` ledger is an execution guard and may be stricter than diff --git a/src/saxsabs/io/parsers.py b/src/saxsabs/io/parsers.py index a434f65..20b4f0f 100644 --- a/src/saxsabs/io/parsers.py +++ b/src/saxsabs/io/parsers.py @@ -20,6 +20,7 @@ from io import StringIO import re +import unicodedata import xml.etree.ElementTree as ET from pathlib import Path from typing import Any @@ -34,6 +35,26 @@ COMMA_THOUSANDS_PATTERN = re.compile(r"(? dict[str, str]: provenance[key] = match.group(2).strip() return provenance + +def _normalise_unit_text(value: object) -> str: + """Return a conservative ASCII token for a unit/header fragment.""" + + text = unicodedata.normalize("NFKC", str(value or "").strip().lower()) + text = text.translate(_SUPERSCRIPT_TRANSLATION) + text = text.replace("å", "angstrom").replace("Å", "angstrom") + return text + + +def _unit_token(value: object) -> str: + return re.sub(r"[^a-z0-9]+", "", _normalise_unit_text(value)) + + +def canonicalize_q_unit(value: object) -> str | None: + """Canonicalize common reciprocal-length units used for a Q axis. + + Numeric Q values are intentionally left untouched by the parser. Unknown + or unsupported units return ``None`` instead of being guessed. A bare + length (``nm``/``angstrom``) is not reciprocal; the source must include + ``1/``, an explicit ``-1`` exponent, or ``inverse``/``inv``. + """ + + if value is None: + return None + text = _normalise_unit_text(value) + if not text: + return None + if not _unit_delimiters_are_balanced(text): + return None + # A column label may include a leading Q (for example ``Q_A^-1``), but + # this does not itself establish reciprocal units. + text = re.sub(r"^\s*q(?:\s*[_:\-]?\s*)?", "", text, count=1) + has_reciprocal_slash = bool(re.search(r"(? bool: + """Reject mismatched or unclosed unit delimiters before token inference.""" + + matching = {"(": ")", "[": "]", "{": "}"} + closing = set(matching.values()) + stack: list[str] = [] + for char in text: + if char in matching: + stack.append(matching[char]) + elif char in closing: + if not stack or stack.pop() != char: + return False + return not stack + + +def q_axis_kind(name: object) -> str: + """Classify a source X-column as Q, chi, two-theta, or unknown.""" + + token = _unit_token(name) + if "2theta" in token or "twotheta" in token: + return "two_theta" + if "chi" in token: + return "chi" + if token.startswith("q"): + return "q" + return "unknown" + + +def infer_q_unit_from_column(name: object) -> str | None: + """Infer a canonical Q unit from a column header, without guessing plain Q.""" + + if q_axis_kind(name) != "q": + return None + text = _normalise_unit_text(name) + match = re.match(r"^q(?:\s*[_:\-]?\s*)(.*)$", text, flags=re.IGNORECASE) + suffix = match.group(1) if match is not None else "" + if not suffix.strip(" _-:()[]{}"): + return None + return canonicalize_q_unit("q" + suffix) + + +def q_column_unit_hint(name: object) -> str | None: + """Return an explicit, but possibly unsupported, Q-header unit hint.""" + + if q_axis_kind(name) != "q": + return None + text = _normalise_unit_text(name) + match = re.match(r"^q(?:\s*[_:\-]?\s*)(.*)$", text, flags=re.IGNORECASE) + suffix = match.group(1) if match is not None else "" + suffix = suffix.strip(" _-:()[]{}") + return suffix or None + + def _clean_column_name(name: Any) -> str: - return re.sub(r"[^a-z0-9]+", "", str(name).strip().lower()) + text = _normalise_unit_text(name).replace("σ", "sigma") + return re.sub(r"[^a-z0-9]+", "", text) def _error_column_preference(name: Any) -> int: @@ -207,7 +332,7 @@ def _comment_header_score(tokens: list[str]) -> int: score += _match_column_score( name, exact={"i", "intensity", "irel", "iabs", "signal", "count", "counts", "y"}, - prefixes=("intensity", "signal", "count", "irel", "iabs"), + prefixes=("intensity", "signal", "count", "irel", "iabs", "i"), suffixes=("intensity",), ) score += _match_column_score( @@ -219,6 +344,80 @@ def _comment_header_score(tokens: list[str]) -> int: return score +def _merge_parenthesized_header_tokens(tokens: list[str]) -> list[str]: + """Rejoin ``Q (nm^-1)``/``Q [nm^-1]`` names split by whitespace.""" + + opening_to_closing = {"(": ")", "[": "]", "{": "}"} + merged: list[str] = [] + idx = 0 + while idx < len(tokens): + token = tokens[idx] + next_token = tokens[idx + 1] if idx + 1 < len(tokens) else "" + opener = next_token[:1] + closer = opening_to_closing.get(opener) + if closer is not None: + end = idx + 1 + while end < len(tokens) and not tokens[end].endswith(closer): + end += 1 + if end < len(tokens) and _unit_delimiters_are_balanced( + " ".join(tokens[idx : end + 1]) + ): + merged.append(" ".join(tokens[idx : end + 1])) + idx = end + 1 + continue + merged.append(token) + idx += 1 + return merged + + +def _has_malformed_unit_header_tokens(tokens: list[str]) -> bool: + """Detect an unclosed or mismatched delimiter after a named Q column.""" + + opening_to_closing = {"(": ")", "[": "]", "{": "}"} + closing = set(opening_to_closing.values()) + for index, token in enumerate(tokens[:-1]): + if q_axis_kind(token) != "q": + continue + next_token = tokens[index + 1] + opener = next_token[:1] + expected = opening_to_closing.get(opener) + if expected is None: + continue + for candidate in tokens[index + 1 :]: + if candidate.endswith(expected): + break + if any(char in candidate for char in closing): + return True + else: + return True + return False + + +def _normalise_inferred_header_columns(df: pd.DataFrame) -> pd.DataFrame: + """Rejoin parenthesized units in a header parsed by pandas. + + A whitespace-delimited header such as ``Q (nm^-1) I`` is read as three + column names while its data still have three numeric columns. The unit + token is part of the Q label, not a data column; any remaining numeric + column is retained as an unnamed column so it cannot be mistaken for I. + """ + + columns = [str(column) for column in df.columns] + merged = _merge_parenthesized_header_tokens(columns) + if merged == columns: + return df + if len(merged) < len(columns): + merged.extend( + f"__unnamed_{index}" + for index in range(len(merged), len(columns)) + ) + else: + merged = merged[: len(columns)] + out = df.copy() + out.columns = merged + return out + + def _read_comment_header_dataframe(path: str | Path) -> pd.DataFrame | None: try: lines = Path(path).read_text(encoding="utf-8-sig", errors="ignore").splitlines() @@ -235,6 +434,9 @@ def _read_comment_header_dataframe(path: str | Path) -> pd.DataFrame | None: if not candidate or ":" in candidate or "=" in candidate: continue tokens = [token for token in re.split(r"[,\s;]+", candidate) if token] + if _has_malformed_unit_header_tokens(tokens): + raise ValueError("malformed Q-unit delimiter in comment header") + tokens = _merge_parenthesized_header_tokens(tokens) if len(tokens) >= 2 and any(FLOAT_PATTERN.fullmatch(token) is None for token in tokens): header_candidates.append((idx, tokens, _comment_header_score(tokens))) continue @@ -256,6 +458,21 @@ def _read_comment_header_dataframe(path: str | Path) -> pd.DataFrame | None: return None try: + raw_data = pd.read_csv( + StringIO(text), + sep=r"[,\s;]+", + engine="python", + comment="#", + header=None, + ) + data_columns = int(raw_data.shape[1]) + if data_columns < len(header_tokens): + header_tokens = header_tokens[:data_columns] + elif data_columns > len(header_tokens): + header_tokens.extend( + f"__unnamed_{index}" + for index in range(len(header_tokens), data_columns) + ) df = pd.read_csv( StringIO(text), sep=r"[,\s;]+", @@ -422,15 +639,23 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: {"sep": r"[,\s;]+", "engine": "python", "comment": "#"}, {"sep": r"[,\s;]+", "engine": "python", "comment": "#", "header": None}, ] + malformed_header_detected = False for kw in read_trials: try: df = pd.read_csv(path, encoding="utf-8-sig", **kw) + if df is not None and "header" not in kw: + if _has_malformed_unit_header_tokens([str(column) for column in df.columns]): + malformed_header_detected = True + continue + df = _normalise_inferred_header_columns(df) if df is not None and not df.empty and df.shape[1] >= 2: dfs.append(df) except Exception as exc: errs.append(str(exc)) + if malformed_header_detected: + raise ValueError(f"Malformed Q-unit delimiter in {p.name}") if not dfs: raise ValueError(f"Cannot parse file: {Path(path).name} ({'; '.join(errs[:2])})") @@ -465,7 +690,7 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: cols, {x_col}, exact={"i", "intensity", "irel", "iabs", "signal", "count", "counts", "y"}, - prefixes=("intensity", "signal", "count", "irel", "iabs"), + prefixes=("intensity", "signal", "count", "irel", "iabs", "i"), suffixes=("intensity",), ) if i_col is None: @@ -515,6 +740,12 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: "intensity": intensity, "uncertainty": err, "x_col": str(x_col), + "x_unit": infer_q_unit_from_column(x_col), + "x_unit_raw": ( + q_column_unit_hint(x_col) + if infer_q_unit_from_column(x_col) is None + else "" + ), "i_col": str(i_col), "err_col": str(err_col) if err_named and err_col is not None else "", } @@ -554,6 +785,7 @@ def read_cansas1d_xml(path: str | Path) -> dict[str, Any]: i_vals: list[float] = [] e_vals: list[float] = [] intensity_unit = "" + q_unit_records: list[tuple[str, str | None]] = [] for idata in root.iter(f"{ns}Idata"): q_el = idata.find(f"{ns}Q") @@ -561,12 +793,16 @@ def read_cansas1d_xml(path: str | Path) -> dict[str, Any]: if q_el is None or i_el is None: continue try: - q_vals.append(float(q_el.text)) - i_vals.append(float(i_el.text)) - if not intensity_unit: - intensity_unit = str(i_el.attrib.get("unit", "") or "").strip() + q_value = float(q_el.text) + i_value = float(i_el.text) except (TypeError, ValueError): continue + q_vals.append(q_value) + i_vals.append(i_value) + raw_q_unit = str(q_el.attrib.get("unit", "") or "").strip() + q_unit_records.append((raw_q_unit, canonicalize_q_unit(raw_q_unit))) + if not intensity_unit: + intensity_unit = str(i_el.attrib.get("unit", "") or "").strip() e_el = idata.find(f"{ns}Idev") if e_el is not None and e_el.text: try: @@ -579,6 +815,23 @@ def read_cansas1d_xml(path: str | Path) -> dict[str, Any]: if len(q_vals) < 2: raise ValueError(f"canSAS XML contains too few data points: {p.name}") + canonical_by_point = [canonical for _, canonical in q_unit_records] + known_q_units = {unit for unit in canonical_by_point if unit is not None} + raw_q_unit_tokens = {_unit_token(raw) for raw, _ in q_unit_records} + # Canonically equivalent spellings (1/A and 1/angstrom) are consistent; + # a mixture of canonical, unknown, or conflicting units is not safe. + if len(set(canonical_by_point)) > 1 or ( + len(known_q_units) == 1 and any(unit is None for unit in canonical_by_point) + ): + raise ValueError(f"canSAS XML contains inconsistent Q units: {p.name}") + if not known_q_units and len(raw_q_unit_tokens) > 1: + raise ValueError(f"canSAS XML contains inconsistent Q units: {p.name}") + q_unit = next(iter(known_q_units), None) + q_unit_raw = next( + (raw for raw, canonical in q_unit_records if raw and canonical is None), + "", + ) + x = np.asarray(q_vals, dtype=np.float64) intensity = np.asarray(i_vals, dtype=np.float64) err = np.asarray(e_vals, dtype=np.float64) if e_vals else np.full_like(x, np.nan) @@ -594,6 +847,8 @@ def read_cansas1d_xml(path: str | Path) -> dict[str, Any]: { "x": x[order], "x_col": "Q", + "x_unit": q_unit, + "x_unit_raw": q_unit_raw, "i_col": "I", "err_col": "Idev", "intensity_unit": intensity_unit, @@ -630,9 +885,11 @@ def read_nxcansas_h5(path: str | Path) -> dict[str, Any]: i_ds = None e_ds = None intensity_unit = "" + q_unit: str | None = None + q_unit_raw = "" def _find_sasdata(group: Any) -> bool: - nonlocal q_ds, i_ds, e_ds, intensity_unit + nonlocal q_ds, i_ds, e_ds, intensity_unit, q_unit, q_unit_raw cls = group.attrs.get("canSAS_class", "") if isinstance(cls, bytes): cls = cls.decode() @@ -640,6 +897,13 @@ def _find_sasdata(group: Any) -> bool: if "Q" in group and "I" in group: q_ds = group["Q"][()] i_ds = group["I"][()] + raw_q_unit = group["Q"].attrs.get( + "units", group["Q"].attrs.get("unit", "") + ) + if isinstance(raw_q_unit, bytes): + raw_q_unit = raw_q_unit.decode("utf-8", errors="replace") + q_unit = canonicalize_q_unit(raw_q_unit) + q_unit_raw = str(raw_q_unit or "") if q_unit is None else "" raw_unit = group["I"].attrs.get("units", "") if isinstance(raw_unit, bytes): raw_unit = raw_unit.decode("utf-8", errors="replace") @@ -706,6 +970,8 @@ def _collect_operator_provenance(_name: str, item: Any) -> None: { "x": x[order], "x_col": "Q", + "x_unit": q_unit, + "x_unit_raw": q_unit_raw, "i_col": "I", "err_col": "Idev" if e_ds is not None else "", "intensity_unit": intensity_unit, diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ced143..c52d689 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,9 +2,10 @@ import sys from pathlib import Path +import numpy as np import pytest -from saxsabs.cli import main +from saxsabs.cli import _normalize_q_profile, main from saxsabs.workflows import bl19b2_abs2d @@ -153,6 +154,229 @@ def test_cli_estimate_k_accepts_scattering_column_names_with_units( assert out["k_factor"] == pytest.approx(2.0, rel=1e-6) +def test_cli_estimate_k_converts_nm_inverse_q_to_angstrom_inverse( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + meas = tmp_path / "meas_nm.csv" + ref = tmp_path / "ref_nm.csv" + meas.write_text( + "Q (nm⁻¹),I_rel\n0.10,17.1\n0.20,15.4\n0.50,13.4\n1.00,11.8\n", + encoding="utf-8", + ) + ref.write_text( + "Q (nm⁻¹),I_rel\n0.10,34.2\n0.20,30.8\n0.50,26.8\n1.00,23.6\n", + encoding="utf-8", + ) + + monkeypatch.setattr( + sys, + "argv", + [ + "saxsabs", + "estimate-k", + "--meas", + str(meas), + "--ref", + str(ref), + "--intensity-state", + "relative", + "--qmin", + "0.01", + "--qmax", + "0.11", + ], + ) + + main() + + out = json.loads(capsys.readouterr().out) + assert out["k_factor"] == pytest.approx(2.0, rel=1e-6) + assert out["points_total"] == 4 + + +def test_cli_estimate_k_reuses_normalized_whitespace_header_columns( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + meas = tmp_path / "meas_plain_nm.dat" + ref = tmp_path / "ref_plain_nm.dat" + meas.write_text( + "Q (nm^-1) I\n0.10 17.1 0.1\n0.20 15.4 0.1\n0.50 13.4 0.1\n1.00 11.8 0.1\n", + encoding="utf-8", + ) + ref.write_text( + "Q (nm^-1) I\n0.10 34.2 0.1\n0.20 30.8 0.1\n0.50 26.8 0.1\n1.00 23.6 0.1\n", + encoding="utf-8", + ) + monkeypatch.setattr( + sys, + "argv", + [ + "saxsabs", + "estimate-k", + "--meas", + str(meas), + "--ref", + str(ref), + "--q-col", + "Q (nm^-1)", + "--i-col", + "I", + "--ref-q-col", + "Q (nm^-1)", + "--ref-i-col", + "I", + "--intensity-state", + "relative", + "--qmin", + "0.01", + "--qmax", + "0.11", + ], + ) + + main() + + full_header_out = json.loads(capsys.readouterr().out) + + monkeypatch.setattr( + sys, + "argv", + [ + "saxsabs", + "estimate-k", + "--meas", + str(meas), + "--ref", + str(ref), + "--q-col", + "Q", + "--i-col", + "I", + "--ref-q-col", + "Q", + "--ref-i-col", + "I", + "--intensity-state", + "relative", + "--qmin", + "0.01", + "--qmax", + "0.11", + ], + ) + main() + + prefix_out = json.loads(capsys.readouterr().out) + assert full_header_out["k_factor"] == pytest.approx(2.0, rel=1e-6) + assert prefix_out["k_factor"] == pytest.approx(full_header_out["k_factor"], rel=1e-12) + assert prefix_out["points_total"] == full_header_out["points_total"] == 4 + + +def test_cli_parse_external1d_reports_q_unit( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + profile = tmp_path / "profile_nm.csv" + profile.write_text( + "Q (nm⁻¹),I\n0.1,10\n0.2,9\n0.3,8\n", + encoding="utf-8", + ) + monkeypatch.setattr(sys, "argv", ["saxsabs", "parse-external1d", "--input", str(profile)]) + + main() + + out = json.loads(capsys.readouterr().out) + assert out["x_unit"] == "nm^-1" + + +def test_cli_estimate_k_rejects_chi_axis( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + meas = tmp_path / "meas_chi.csv" + ref = tmp_path / "ref.csv" + meas.write_text("chi_deg,I_rel\n1,17.1\n2,15.4\n3,13.4\n", encoding="utf-8") + ref.write_text("q,I_rel\n0.01,34.2\n0.02,30.8\n0.03,26.8\n", encoding="utf-8") + monkeypatch.setattr( + sys, + "argv", + [ + "saxsabs", + "estimate-k", + "--meas", + str(meas), + "--ref", + str(ref), + "--intensity-state", + "relative", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + assert "chi" in capsys.readouterr().err.lower() + + +def test_cli_q_normalization_is_shallow_and_idempotent(): + profile = { + "x": [1.0, 2.0], + "x_col": "Q", + "x_unit": "nm^-1", + "operator_provenance": {}, + } + + converted = _normalize_q_profile(profile, profile_label="sample") + repeated = _normalize_q_profile(converted, profile_label="sample") + + assert profile["x"] == [1.0, 2.0] + np.testing.assert_allclose(converted["x"], [0.1, 0.2]) + np.testing.assert_allclose(repeated["x"], converted["x"]) + assert converted["x_unit"] == repeated["x_unit"] == "A^-1" + + +def test_cli_q_normalization_rejects_unknown_unit_with_explicit_override(): + profile = { + "x": [1.0, 2.0], + "x_col": "Q (mm^-1)", + "x_unit": None, + "x_unit_raw": "mm^-1", + "x_axis_override": True, + } + + with pytest.raises(ValueError, match="unsupported Q unit"): + _normalize_q_profile(profile, profile_label="sample") + + +@pytest.mark.parametrize("header", ["Q (nm)", "Q (angstrom)", "Q (A)", "Q (Å)"]) +def test_cli_q_normalization_rejects_bare_length_unit_headers(header): + profile = { + "x": [1.0, 2.0], + "x_col": header, + "x_unit": "nm^-1" if "nm" in header else "A^-1", + "x_unit_raw": "", + } + + with pytest.raises(ValueError, match="reciprocal length"): + _normalize_q_profile(profile, profile_label="sample") + + +@pytest.mark.parametrize("header", ["Q (nm^-1)", "Q (Å⁻¹)", "Q (1/nm)"]) +def test_cli_q_normalization_accepts_explicit_reciprocal_unit_headers(header): + profile = {"x": [1.0, 2.0], "x_col": header, "x_unit": None, "x_unit_raw": ""} + + converted = _normalize_q_profile(profile, profile_label="sample") + + assert converted["x_unit"] == "A^-1" + + def test_cli_estimate_k_accepts_explicit_column_overrides( tmp_path: Path, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_intensity_state.py b/tests/test_intensity_state.py index 9ebcca9..e0b4d24 100644 --- a/tests/test_intensity_state.py +++ b/tests/test_intensity_state.py @@ -13,6 +13,13 @@ ) +@pytest.mark.parametrize("unit", ["cm⁻¹", "cm−¹", "cm^-1", "1/cm"]) +def test_unicode_cm_inverse_unit_is_absolute(unit): + assessment = assess_intensity_state({"i_col": "I", "intensity_unit": unit}) + + assert assessment.state is IntensityState.ABSOLUTE_CM_INV + + def test_absolute_column_is_rejected_before_k_or_thickness_is_reapplied(): profile = {"i_col": "I_abs_cm^-1", "operator_provenance": {}} diff --git a/tests/test_io_formats.py b/tests/test_io_formats.py index 17f4e46..c10fc84 100644 --- a/tests/test_io_formats.py +++ b/tests/test_io_formats.py @@ -39,6 +39,7 @@ def test_write_read_roundtrip(self, tmp_path): assert "i_abs" in result assert "i_rel" not in result assert result["intensity_state"] == "absolute_cm^-1" + assert result["x_unit"] == "A^-1" np.testing.assert_allclose(result["x"], q, rtol=1e-6) np.testing.assert_allclose(result["i_abs"], i_abs, rtol=1e-6) np.testing.assert_allclose(result["err_abs"], err, rtol=1e-6) @@ -50,6 +51,22 @@ def test_write_no_error(self, tmp_path): result = read_cansas1d_xml(xml_path) np.testing.assert_allclose(result["x"], q, rtol=1e-6) + def test_reader_rejects_inconsistent_q_units(self, tmp_path): + q, i_abs, err = self._make_data(4) + xml_path = tmp_path / "inconsistent-q-units.xml" + write_cansas1d_xml(xml_path, q, i_abs, err, metadata=ABS_META) + + tree = ET.parse(xml_path) + namespace = "{urn:cansas1d:1.1}" + q_elements = tree.getroot().iter(f"{namespace}Q") + next(q_elements) + second = next(q_elements) + second.set("unit", "1/nm") + tree.write(xml_path, encoding="utf-8", xml_declaration=True) + + with pytest.raises(ValueError, match="inconsistent Q units"): + read_cansas1d_xml(xml_path) + def test_auto_detect_xml_extension(self, tmp_path): """read_external_1d_profile should auto-detect .xml files.""" q, i_abs, err = self._make_data() @@ -179,6 +196,7 @@ def test_write_read_roundtrip(self, tmp_path): result = read_nxcansas_h5(h5_path) assert "i_abs" in result assert "i_rel" not in result + assert result["x_unit"] == "A^-1" np.testing.assert_allclose(result["x"], q, rtol=1e-10) np.testing.assert_allclose(result["i_abs"], i_abs, rtol=1e-10) np.testing.assert_allclose(result["err_abs"], err, rtol=1e-10) @@ -237,6 +255,23 @@ def test_no_error_dataset(self, tmp_path): np.testing.assert_allclose(result["x"], q, rtol=1e-10) assert np.all(np.isnan(result["err_abs"])) + def test_reader_preserves_nm_inverse_q_units_without_conversion(self, tmp_path): + h5_path = tmp_path / "nm-q.h5" + q = np.array([0.1, 0.2, 0.3]) + intensity = np.array([10.0, 9.0, 8.0]) + with h5py.File(h5_path, "w") as f: + entry = f.create_group("sasentry01") + data = entry.create_group("sasdata01") + data.attrs["canSAS_class"] = "SASdata" + q_ds = data.create_dataset("Q", data=q) + q_ds.attrs["units"] = "1/nm" + i_ds = data.create_dataset("I", data=intensity) + i_ds.attrs["units"] = "1/cm" + + result = read_nxcansas_h5(h5_path) + assert result["x_unit"] == "nm^-1" + np.testing.assert_allclose(result["x"], q) + def test_write_shape_mismatch_raises(self, tmp_path): h5_path = tmp_path / "bad.h5" try: diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 54fc566..1429568 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -1,15 +1,60 @@ from pathlib import Path import numpy as np +import pytest from saxsabs.io.parsers import ( + canonicalize_q_unit, extract_float, + infer_q_unit_from_column, normalize_transmission, parse_header_values, read_external_1d_profile, ) +@pytest.mark.parametrize( + "raw_unit", + [ + "nm", + "angstrom", + "A", + "nm1", + "a1", + "angstrom1", + "Q (nm)", + "Q (angstrom)", + "Q [nm^-1)", + ], +) +def test_q_unit_canonicalization_rejects_bare_or_signless_lengths(raw_unit): + assert canonicalize_q_unit(raw_unit) is None + + +@pytest.mark.parametrize( + ("raw_unit", "expected"), + [ + ("1/A", "A^-1"), + ("1/angstrom", "A^-1"), + ("A^-1", "A^-1"), + ("Å⁻¹", "A^-1"), + ("nm^-1", "nm^-1"), + ("1/nm", "nm^-1"), + ("inverse angstrom", "A^-1"), + ("inv nm", "nm^-1"), + ("invangstrom", "A^-1"), + ("invnm", "nm^-1"), + ], +) +def test_q_unit_canonicalization_requires_and_accepts_reciprocal_marker(raw_unit, expected): + assert canonicalize_q_unit(raw_unit) == expected + + +def test_infer_q_unit_from_column_leaves_bare_q_length_unknown(): + assert infer_q_unit_from_column("Q (nm)") is None + assert infer_q_unit_from_column("Q (angstrom)") is None + + def test_extract_float_accepts_thousands_and_decimal_commas(): assert np.isclose(extract_float("1,200,000"), 1200000.0) assert np.isclose(extract_float("0,85"), 0.85) @@ -178,3 +223,170 @@ def test_read_external_1d_profile_prefers_combined_over_earlier_statistical_colu assert out["err_col"] == "Error_CombinedStandard_cm^-1" np.testing.assert_allclose(out["uncertainty"], [1.5, 2.5, 3.5]) + + +def test_read_external_1d_profile_recognizes_unicode_q_intensity_and_sigma_units( + tmp_path: Path, +): + f = tmp_path / "unicode_units.csv" + f.write_text( + "Q (Å⁻¹),I (cm⁻¹),σ (cm⁻¹)\n" + "0.10,100,5\n" + "0.20,90,4\n" + "0.30,80,4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "A^-1" + assert out["i_col"] == "I (cm⁻¹)" + assert out["err_col"] == "σ (cm⁻¹)" + np.testing.assert_allclose(out["x"], [0.10, 0.20, 0.30]) + np.testing.assert_allclose(out["uncertainty"], [5.0, 4.0, 4.0]) + + +def test_read_external_1d_profile_recognizes_unicode_nm_q_unit(tmp_path: Path): + f = tmp_path / "unicode_nm.csv" + f.write_text( + "Q (nm⁻¹),I\n" + "0.10,100\n" + "0.20,90\n" + "0.30,80\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "nm^-1" + np.testing.assert_allclose(out["x"], [0.10, 0.20, 0.30]) + + +def test_read_external_1d_profile_recognizes_angstrom_sign_variant(tmp_path: Path): + f = tmp_path / "angstrom_sign.csv" + f.write_text( + "Q (Å⁻¹),I\n" + "0.10,100\n" + "0.20,90\n" + "0.30,80\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "A^-1" + + +@pytest.mark.parametrize("q_header", ["Q (nm)", "Q (angstrom)"]) +def test_read_external_1d_profile_keeps_bare_q_length_unknown( + tmp_path: Path, q_header: str +): + f = tmp_path / "bare_length.csv" + f.write_text( + f"{q_header},I\n" + "0.10,100\n" + "0.20,90\n" + "0.30,80\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] is None + assert out["x_unit_raw"] in {"nm", "angstrom"} + + +def test_read_external_1d_profile_rejoins_parenthesized_comment_header_units( + tmp_path: Path, +): + f = tmp_path / "comment_units.dat" + f.write_text( + "# Q (nm^-1) I (cm^-1) sigma (cm^-1)\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_col"] == "Q (nm^-1)" + assert out["x_unit"] == "nm^-1" + assert out["i_col"] == "I (cm^-1)" + assert out["err_col"] == "sigma (cm^-1)" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + np.testing.assert_allclose(out["uncertainty"], [5.0, 4.0, 4.0]) + + +@pytest.mark.parametrize( + ("header", "expected_unit"), + [("Q (nm^-1) I", "nm^-1"), ("Q (Å⁻¹) I", "A^-1")], +) +def test_read_external_1d_profile_rejoins_plain_parenthesized_header_units( + tmp_path: Path, header: str, expected_unit: str +): + f = tmp_path / "plain_header_units.dat" + f.write_text( + f"{header}\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_col"] == header.rsplit(" I", 1)[0] + assert out["x_unit"] == expected_unit + assert out["i_col"] == "I" + assert out["err_col"] == "" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + assert np.all(np.isnan(out["uncertainty"])) + + +@pytest.mark.parametrize( + ("header", "expected_unit"), + [ + ("# Q [nm^-1] I", "nm^-1"), + ("Q [nm^-1] I", "nm^-1"), + ("# Q {Å⁻¹} I", "A^-1"), + ("Q {Å⁻¹} I", "A^-1"), + ], +) +def test_read_external_1d_profile_rejoins_square_and_braced_unit_headers( + tmp_path: Path, header: str, expected_unit: str +): + f = tmp_path / "delimited_header_units.dat" + f.write_text( + f"{header}\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + header_without_comment = header.removeprefix("# ") + + assert out["x_col"] == header_without_comment.rsplit(" I", 1)[0] + assert out["x_unit"] == expected_unit + assert out["i_col"] == "I" + assert out["err_col"] == "" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +@pytest.mark.parametrize("header", ["# Q [nm^-1) I", "Q {Å⁻¹] I"]) +def test_read_external_1d_profile_rejects_mismatched_unit_delimiters( + tmp_path: Path, header: str +): + f = tmp_path / "malformed_delimiter_header.dat" + f.write_text( + f"{header}\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Q-unit delimiter"): + read_external_1d_profile(f) diff --git a/tests/test_workbench_scientific.py b/tests/test_workbench_scientific.py index 4ec86f5..50b1812 100644 --- a/tests/test_workbench_scientific.py +++ b/tests/test_workbench_scientific.py @@ -198,6 +198,43 @@ def test_tab3_q_nm_inverse_converts_to_q_angstrom_inverse(): assert conversion == "q_nm^-1_to_q_a^-1" +@pytest.mark.parametrize("x_col", ["Q_nm1", "Q_A1", "Q (nm)", "Q (mm^-1)"]) +def test_tab3_rejects_sign_stripped_or_unsupported_q_unit_headers(x_col): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + profile = {"x": np.array([0.01, 0.02, 0.03]), "x_col": x_col} + + with pytest.raises(ValueError, match="单位|unit|歧义|reciprocal|倒数"): + app.resolve_external_x_axis("profile.dat", profile, mode="auto", wavelength_a="") + + +def test_tab3_preserves_explicit_x_unit_and_rejects_unsupported_raw_hint(): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + profile = { + "x": np.array([1.0, 2.0, 3.0]), + "x_col": "Q_nm1", + "x_unit": "nm^-1", + "x_unit_raw": "nm1", + } + q, label, conversion = app.resolve_external_x_axis( + "profile.dat", profile, mode="auto", wavelength_a="" + ) + np.testing.assert_allclose(q, [0.1, 0.2, 0.3]) + assert label == "Q_A^-1" + assert conversion == "q_nm^-1_to_q_a^-1" + + unsupported = { + "x": np.array([0.01, 0.02, 0.03]), + "x_col": "Q", + "x_unit_raw": "nm", + } + with pytest.raises(ValueError, match="单位|unit|歧义|reciprocal|倒数"): + app.resolve_external_x_axis( + "profile.dat", unsupported, mode="auto", wavelength_a="" + ) + + @pytest.mark.parametrize("mode", ["auto", "q_a^-1"]) def test_tab3_ambiguous_named_q_unit_fails_closed(mode): module = _load_workbench_module() @@ -208,6 +245,204 @@ def test_tab3_ambiguous_named_q_unit_fails_closed(mode): app.resolve_external_x_axis("profile.dat", profile, mode=mode, wavelength_a="") +def test_tab3_profile_x_unit_contract_has_priority_and_supports_unicode_headers(): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + + profile = { + "x": np.array([1.0, 2.0, 3.0]), + "x_col": "Q (misleading Chi label)", + "x_unit": "nm^-1", + } + q, label, conversion = app.resolve_external_x_axis( + "profile.dat", profile, mode="auto", wavelength_a="" + ) + np.testing.assert_allclose(q, [0.1, 0.2, 0.3]) + assert label == "Q_A^-1" + assert conversion == "q_nm^-1_to_q_a^-1" + + unicode_header = { + "x": np.array([0.01, 0.02, 0.03]), + "x_col": "Q (Å⁻¹)", + } + q, label, conversion = app.resolve_external_x_axis( + "profile.dat", unicode_header, mode="auto", wavelength_a="" + ) + np.testing.assert_array_equal(q, unicode_header["x"]) + assert label == "Q_A^-1" + assert conversion == "none" + + +def test_workbench_merge_ignores_nan_intensity_and_keeps_unknown_sigma(): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + radial = np.array([0.1, 0.2, 0.3]) + merged = app.merge_integrate1d_results( + [ + SimpleNamespace( + radial=radial, + intensity=np.array([10.0, np.nan, np.nan]), + count=np.array([2.0, 2.0, 1.0]), + sigma=np.array([1.0, 2.0, 3.0]), + ), + SimpleNamespace( + radial=radial, + intensity=np.array([20.0, 30.0, 40.0]), + count=np.array([1.0, 1.0, 0.0]), + sigma=np.array([np.nan, 4.0, 5.0]), + ), + ] + ) + + np.testing.assert_allclose(merged.count, [3.0, 1.0, 0.0]) + np.testing.assert_allclose(merged.intensity[:2], [40.0 / 3.0, 30.0]) + assert np.isnan(merged.intensity[2]) + assert np.isnan(merged.sigma[0]) + assert merged.sigma[1] == pytest.approx(4.0) + assert np.isnan(merged.sigma[2]) + + +def test_instrument_signature_exposes_only_observed_fields_and_strict_mismatch( + monkeypatch, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + header = { + "Wavelength": "0.1 nm", + "Energy": "12.398 keV", + "Distance": "1000 mm", + "Pixel1": "100 um", + "Pixel2": "200 um", + "Detector": "Pilatus 1M", + } + signature = app.extract_instrument_signature( + "first.tif", header_dict=header, shape=(10, 12) + ) + assert signature["path"] == "first.tif" + assert signature["shape"] == (10, 12) + assert signature["detector"] == "Pilatus 1M" + assert signature["distance_m"] == pytest.approx(1.0) + assert signature["pixel1_m"] == pytest.approx(1.0e-4) + assert signature["pixel2_m"] == pytest.approx(2.0e-4) + assert signature["wavelength_a"] == pytest.approx(1.0) + assert signature["energy_kev"] == pytest.approx(12.398) + + app.session_geometry_fallback = {} + + def fake_loader(path, *, dtype=None): + del dtype + if str(path) == "first.tif": + return SimpleNamespace(data=np.zeros((10, 12)), header=header) + return SimpleNamespace( + data=np.zeros((12, 12)), + header={ + "Wavelength": "0.2 nm", + "Energy": "6.2 keV", + "Distance": "1000 mm", + "Pixel1": "100 um", + "Pixel2": "200 um", + "Detector": "Eiger", + }, + ) + + monkeypatch.setattr(module, "_workbench_load_detector_image", fake_loader) + issues = app.check_instrument_consistency(["first.tif", "second.tif"]) + assert any("图像尺寸不一致" in issue for issue in issues) + assert any("探测器型号不一致" in issue for issue in issues) + assert any("波长" in issue for issue in issues) + assert any("能量" in issue for issue in issues) + + +def test_instrument_consistency_applies_session_geometry_fallback_to_each_file( + monkeypatch, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.session_geometry_fallback = { + "wavelength_a": 1.0, + "energy_kev": module.HC_KEV_A, + "distance_m": 0.2, + "pixel1_m": 1.0e-4, + "pixel2_m": 1.0e-4, + } + + monkeypatch.setattr( + module, + "_workbench_load_detector_image", + lambda _path, dtype=None: SimpleNamespace( + data=np.zeros((10, 10)), header={} + ), + ) + + assert app.check_instrument_consistency(["first.tif", "second.tif"]) == [] + + +def test_instrument_consistency_reports_unreadable_reference_file(monkeypatch): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.session_geometry_fallback = {} + + def fail_loader(_path, dtype=None): + raise OSError("cannot read detector image") + + monkeypatch.setattr(module, "_workbench_load_detector_image", fail_loader) + issues = app.check_instrument_consistency(["first.tif"]) + + assert len(issues) == 1 + assert "first.tif" in issues[0] + assert "cannot read detector image" in issues[0] + + +def test_instrument_consistency_checks_reliable_poni_distance_and_pixels(monkeypatch): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.session_geometry_fallback = {} + headers = { + "Wavelength": "1 A", + "Distance": "200 mm", + "Pixel1": "100 um", + "Pixel2": "100 um", + } + monkeypatch.setattr( + module, + "_workbench_load_detector_image", + lambda _path, dtype=None: SimpleNamespace( + data=np.zeros((10, 10)), header=headers + ), + ) + fake_ai = SimpleNamespace( + wavelength=0.1e-10, + dist=0.3, + detector=SimpleNamespace(pixel1=2.0e-4, pixel2=1.0e-4), + ) + monkeypatch.setattr(module.pyFAI, "load", lambda _path: fake_ai) + + issues = app.check_instrument_consistency( + ["sample.tif"], poni_path="geometry.poni", tol_pct=0.5 + ) + assert any("poni 样探距" in issue for issue in issues) + assert any("poni pixel1" in issue for issue in issues) + + +def test_instrument_signature_rejects_explicit_wavelength_energy_pair_mismatch( + monkeypatch, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.session_geometry_fallback = {} + bad_header = {"Wavelength": "1 A", "Energy": "6 keV"} + monkeypatch.setattr( + module, + "_workbench_load_detector_image", + lambda _path, dtype=None: SimpleNamespace( + data=np.zeros((10, 10)), header=bad_header + ), + ) + + issues = app.check_instrument_consistency(["sample.tif"], tol_pct=0.5) + assert any("波长与能量内部不一致" in issue for issue in issues) + + def test_tab3_profile_parser_preserves_operator_fingerprint_header(tmp_path): module = _load_workbench_module() app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) @@ -448,6 +683,326 @@ def capture_gate(**kwargs): assert captured["warnings_count"] >= 1 +def test_tab2_dry_run_reuses_fluorescence_validation_and_blocks_missing_f0(): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.language = "en" + app.t2_files = ["sample.tif"] + app.global_vars = { + "k_factor": _Var(2.5), + "poni_path": _Var("geometry.poni"), + "bg_exp": _Var(1.0), + "bg_i0": _Var(10.0), + "bg_t": _Var(1.0), + } + app.t2_calc_mode = _Var("fixed") + app.t2_mu = _Var("") + app.t2_fixed_thk = _Var(1.0) + app.t2_mask_path = _Var("") + app.t2_flat_path = _Var("") + app.t2_apply_solid_angle = _Var(True) + app.t2_workers = _Var(1) + app.t2_ref_mode = _Var("fixed") + app.t2_strict_instrument = _Var(False) + app.t2_resume_enabled = _Var(False) + app.t2_fluo_enabled = _Var(True) + app.t2_fluo_method = _Var("constant") + app.t2_fluo_f0 = _Var("") + app.t2_fluo_f0_uncertainty = _Var("") + app.t2_fluo_beta = _Var(1.0) + app.t2_fluo_beta_uncertainty = _Var("") + app.t2_fluo_qmin = _Var("") + app.t2_fluo_qmax = _Var("") + app.t2_fluo_path = _Var("") + app.t2_fluo_status = _Var("") + app.get_monitor_mode = lambda: "rate" + app.get_selected_modes = lambda: ["1d_full"] + app.resolve_t2_polarization = lambda: (False, None) + app.resolve_sample_thickness_config = lambda **_kwargs: { + "mode": "fixed", + "mu_cm_inv": None, + "fixed_thickness_cm": 0.1, + } + app.compute_norm_factor = lambda *_args: 1.0 + app.parse_header = lambda _path: (1.0, 10.0, 0.5) + app.require_calibration_context_for_batch = lambda **_kwargs: object() + + captured = {} + + class StopAfterGate(Exception): + pass + + def capture_gate(**kwargs): + captured.update(kwargs) + raise StopAfterGate + + app._evaluate_preflight_gate = capture_gate + with pytest.raises(StopAfterGate): + app.dry_run() + + assert captured["failed_files"] == 1 + assert captured["warnings_count"] >= 1 + + +def test_tab2_dry_run_blocks_structured_format_for_radial_chi(): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.language = "en" + app.t2_files = ["sample.tif"] + app.global_vars = { + "k_factor": _Var(2.5), + "poni_path": _Var("geometry.poni"), + "bg_exp": _Var(1.0), + "bg_i0": _Var(10.0), + "bg_t": _Var(1.0), + } + app.t2_calc_mode = _Var("fixed") + app.t2_mu = _Var("") + app.t2_fixed_thk = _Var(1.0) + app.t2_mask_path = _Var("") + app.t2_flat_path = _Var("") + app.t2_apply_solid_angle = _Var(True) + app.t2_workers = _Var(1) + app.t2_ref_mode = _Var("fixed") + app.t2_strict_instrument = _Var(False) + app.t2_output_format = _Var("cansas_xml") + app.t2_rad_qmin = _Var(0.01) + app.t2_rad_qmax = _Var(0.03) + app.get_monitor_mode = lambda: "rate" + app.get_selected_modes = lambda: ["radial_chi"] + app.resolve_t2_polarization = lambda: (False, None) + app.resolve_sample_thickness_config = lambda **_kwargs: { + "mode": "fixed", + "mu_cm_inv": None, + "fixed_thickness_cm": 0.1, + } + app.compute_norm_factor = lambda *_args: 1.0 + app.parse_header = lambda _path: (1.0, 10.0, 0.5) + app.require_calibration_context_for_batch = lambda **_kwargs: object() + captured = {} + + class StopAfterGate(Exception): + pass + + def capture_gate(**kwargs): + captured.update(kwargs) + raise StopAfterGate + + app._evaluate_preflight_gate = capture_gate + with pytest.raises(StopAfterGate): + app.dry_run() + + assert captured["failed_files"] == 1 + assert captured["warnings_count"] >= 1 + + +@pytest.mark.parametrize( + ("export_cal2d", "expected_failed_files"), + [(False, 1), (True, 0)], +) +def test_tab2_dry_run_empty_modes_blocks_unless_cal2d_export_is_enabled( + export_cal2d, expected_failed_files +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.language = "en" + app.t2_files = ["sample.tif"] + app.global_vars = { + "k_factor": _Var(2.5), + "poni_path": _Var("geometry.poni"), + "bg_exp": _Var(1.0), + "bg_i0": _Var(10.0), + "bg_t": _Var(1.0), + } + app.t2_calc_mode = _Var("fixed") + app.t2_mu = _Var("") + app.t2_fixed_thk = _Var(1.0) + app.t2_mask_path = _Var("") + app.t2_flat_path = _Var("") + app.t2_apply_solid_angle = _Var(True) + app.t2_workers = _Var(1) + app.t2_ref_mode = _Var("fixed") + app.t2_strict_instrument = _Var(False) + app.t2_output_format = _Var("tsv") + app.t2_export_cal2d = _Var(export_cal2d) + app.get_monitor_mode = lambda: "rate" + app.get_selected_modes = lambda: [] + app.resolve_t2_polarization = lambda: (False, None) + app.resolve_sample_thickness_config = lambda **_kwargs: { + "mode": "fixed", + "mu_cm_inv": None, + "fixed_thickness_cm": 0.1, + } + app.compute_norm_factor = lambda *_args: 1.0 + app.parse_header = lambda _path: (1.0, 10.0, 0.5) + app.require_calibration_context_for_batch = lambda **_kwargs: object() + captured = {} + + class StopAfterGate(Exception): + pass + + def capture_gate(**kwargs): + captured.update(kwargs) + raise StopAfterGate + + app._evaluate_preflight_gate = capture_gate + with pytest.raises(StopAfterGate): + app.dry_run() + + assert captured["failed_files"] == expected_failed_files + + +def test_tab2_dry_run_strict_instrument_mismatch_is_blocking(): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.language = "en" + app.t2_files = ["sample.tif"] + app.global_vars = { + "k_factor": _Var(2.5), + "poni_path": _Var("geometry.poni"), + "bg_exp": _Var(1.0), + "bg_i0": _Var(10.0), + "bg_t": _Var(1.0), + } + app.t2_calc_mode = _Var("fixed") + app.t2_mu = _Var("") + app.t2_fixed_thk = _Var(1.0) + app.t2_mask_path = _Var("") + app.t2_flat_path = _Var("") + app.t2_apply_solid_angle = _Var(True) + app.t2_workers = _Var(1) + app.t2_ref_mode = _Var("fixed") + app.t2_strict_instrument = _Var(True) + app.t2_instr_tol_pct = _Var(0.5) + app.t2_output_format = _Var("tsv") + app.get_monitor_mode = lambda: "rate" + app.get_selected_modes = lambda: ["1d_full"] + app.resolve_t2_polarization = lambda: (False, None) + app.resolve_sample_thickness_config = lambda **_kwargs: { + "mode": "fixed", + "mu_cm_inv": None, + "fixed_thickness_cm": 0.1, + } + app.compute_norm_factor = lambda *_args: 1.0 + app.parse_header = lambda _path: (1.0, 10.0, 0.5) + app.require_calibration_context_for_batch = lambda **_kwargs: object() + app.check_instrument_consistency = lambda *_args, **_kwargs: [ + "sample.tif: wavelength mismatch" + ] + captured = {} + + class StopAfterGate(Exception): + pass + + def capture_gate(**kwargs): + captured.update(kwargs) + raise StopAfterGate + + app._evaluate_preflight_gate = capture_gate + with pytest.raises(StopAfterGate): + app.dry_run() + + assert captured["failed_files"] == 1 + assert captured["warnings_count"] >= 1 + + +def test_tab3_dry_run_blocks_structured_format_for_chi_profile(tmp_path): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.language = "en" + fingerprint = "d" * 64 + sample = tmp_path / "chi_profile.dat" + sample.write_text( + f"# calibration_context_fingerprint: {fingerprint}\n" + "# intensity_state: relative\n" + "# corrections_applied: []\n" + "# chi I_rel Error\n" + "0 10 0.1\n10 9 0.1\n20 8 0.1\n", + encoding="utf-8", + ) + app.t3_files = [str(sample)] + app.t3_pipeline_mode = _Var("scaled") + app.t3_corr_mode = _Var("k_only") + app.t3_fixed_thk = _Var(1.0) + app.t3_resume_enabled = _Var(False) + app.t3_output_format = _Var("cansas_xml") + app.t3_x_mode = _Var("auto") + app.t3_wavelength_a = _Var("") + app.t3_buffer_enabled = _Var(False) + app.t3_buffer_status = _Var("") + app.t3_fluo_enabled = _Var(False) + app.global_vars = {"k_factor": _Var(2.5)} + app.get_monitor_mode = lambda: "rate" + context = SimpleNamespace(fingerprint=lambda: fingerprint) + app.require_trusted_k_for_external = lambda *_args, **_kwargs: context + app.require_external_profile_operator_provenance = lambda *_args, **_kwargs: fingerprint + captured = {} + + class StopAfterGate(Exception): + pass + + def capture_gate(**kwargs): + captured.update(kwargs) + raise StopAfterGate + + app._evaluate_preflight_gate = capture_gate + with pytest.raises(StopAfterGate): + app.dry_run_external_1d() + + assert captured["failed_files"] == 1 + assert captured["warnings_count"] >= 0 + + +def test_t2_measured_fluorescence_uses_auto_q_without_tab3_axis_state(monkeypatch): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.t2_fluo_enabled = _Var(True) + app.t2_fluo_method = _Var("measured") + app.t2_fluo_f0 = _Var("") + app.t2_fluo_f0_uncertainty = _Var("") + app.t2_fluo_beta = _Var(1.0) + app.t2_fluo_beta_uncertainty = _Var("0") + app.t2_fluo_qmin = _Var("") + app.t2_fluo_qmax = _Var("") + app.t2_fluo_path = _Var("measured.dat") + app.t2_fluo_status = _Var("") + calls = {} + + def capture_axis(path, profile, *, mode=None, wavelength_a=None): + calls.update(mode=mode, wavelength_a=wavelength_a) + return { + **profile, + "x_label": "Q_A^-1", + "x_conversion": "q_nm^-1_to_q_a^-1", + } + + app.read_external_1d_profile = lambda _path: { + "x": np.array([0.1, 0.2, 0.3]), + "x_col": "Q", + "x_unit": "nm^-1", + } + app.prepare_external_profile_axis = capture_axis + app.require_external_profile_operator_provenance = lambda *_args, **_kwargs: "f" * 64 + monkeypatch.setattr( + module, + "require_absolute_input_for_fluorescence_subtraction", + lambda *_args, **_kwargs: None, + ) + app._optional_file_sha256 = lambda _path: "a" * 64 + context = SimpleNamespace() + + payload = app.prepare_workbench_fluorescence( + source="t2", + pipeline_mode="scaled", + calibration_context=context, + k_factor=2.5, + require_scaled_pipeline=False, + ) + + assert payload["enabled"] is True + assert calls == {"mode": "auto", "wavelength_a": ""} + + @pytest.mark.parametrize( ("thickness_mm", "resume_enabled"), [(0.0, False), (1.0, True)], @@ -592,6 +1147,78 @@ def test_workbench_safety_copy_and_legacy_resume_default_are_explicit(): assert "\u539f\u4f4d" in module.I18N["zh"]["hint_t2_thickness"] +def test_workbench_ui_contract_keeps_labels_tooltips_and_stable_format_tokens(): + module = _load_workbench_module() + + assert module.OUTPUT_FORMAT_TOKENS == ("tsv", "csv", "cansas_xml", "nxcansas_h5") + for language in ("en", "zh"): + pack = module.I18N[language] + for key in ( + "lbl_t2_fluo_method", + "lbl_t2_fluo_f0", + "lbl_t2_fluo_f0_uncertainty", + "lbl_t2_fluo_beta", + "lbl_t2_fluo_beta_uncertainty", + "lbl_t2_fluo_qmin", + "lbl_t2_fluo_qmax", + "lbl_t3_fluo_beta_uncertainty", + "lbl_t3_fluo_qmin", + "lbl_t3_fluo_qmax", + "tip_output_format", + "tip_browse_file", + "tip_browse_dir", + ): + assert pack[key] + assert module.I18N["en"]["lbl_t1_std_file"] == "Standard image:" + assert "NIST matching" not in module.I18N["en"]["tip_t1_calibrate"] + assert "orange reference markers" in module.I18N["en"]["tip_t1_plot"] + assert "橙色参考点" in module.I18N["zh"]["tip_t1_plot"] + assert "0 < T ≤ 1" in module.I18N["en"]["hint_t1_phys"] + assert "0 < T ≤ 1" in module.I18N["zh"]["hint_t1_phys"] + assert set(module.EXTERNAL_1D_FILE_PATTERN.split()) >= { + "*.dat", + "*.txt", + "*.chi", + "*.csv", + "*.xml", + "*.h5", + "*.hdf5", + } + assert ".h5 .hdf5" in module.I18N["en"]["lbl_t3_formats"] + assert "canSAS XML" in module.I18N["en"]["tip_output_format"] + assert "Q (Å⁻¹)" in module.I18N["en"]["tip_output_format"] + assert "does not change output routing" in module.I18N["en"]["tip_t2_group"] + assert "不会改变输出路由" in module.I18N["zh"]["tip_t2_group"] + + +def test_workbench_minsize_preserves_normal_minimum_and_caps_small_geometry(): + module = _load_workbench_module() + geometry = SimpleNamespace(width=736, height=520) + + assert module.SAXSAbsWorkbenchApp._window_min_size_for_geometry(geometry) == ( + 736, + 520, + ) + assert module.SAXSAbsWorkbenchApp._window_min_size_for_geometry( + SimpleNamespace(width=960, height=620) + ) == module.WORKBENCH_MIN_SIZE + assert module.SAXSAbsWorkbenchApp._window_min_size_for_geometry(None) == ( + 900, + 600, + ) + + +@pytest.mark.parametrize("output_format", ["cansas_xml", "nxcansas_h5"]) +def test_structured_output_formats_fail_closed_for_chi_and_accept_q(output_format): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + + with pytest.raises(ValueError, match=r"Q_A\^-1|Q.*轴"): + app.validate_output_format_for_axis(output_format, "Chi_deg") + assert app.validate_output_format_for_axis(output_format, "Q_A^-1") == output_format + assert app.validate_output_format_for_axis("tsv", "Chi_deg") == "tsv" + + def test_fixed_tab2_preflight_excludes_diagnostic_mu_payload(): module = _load_workbench_module() app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) From cac6efc2ad65a329221d0b32941d89629d635303 Mon Sep 17 00:00:00 2001 From: Delun Gong Date: Sun, 23 Aug 2026 22:05:02 +0800 Subject: [PATCH 2/2] fix: close PR review gaps --- SASAbs.py | 95 ++++-- src/saxsabs/cli.py | 209 +++++++++++-- src/saxsabs/io/parsers.py | 426 +++++++++++++++++++++----- tests/test_cli.py | 335 +++++++++++++++++++- tests/test_parsers.py | 477 +++++++++++++++++++++++++++++ tests/test_workbench_scientific.py | 197 +++++++++++- 6 files changed, 1605 insertions(+), 134 deletions(-) diff --git a/SASAbs.py b/SASAbs.py index f9beb91..9c3a74d 100644 --- a/SASAbs.py +++ b/SASAbs.py @@ -55,7 +55,7 @@ def _read_package_version() -> str: DEFAULT_LEGACY_RESUME_ENABLED = False WORKBENCH_MIN_SIZE = (900, 600) OUTPUT_FORMAT_TOKENS = ("tsv", "csv", "cansas_xml", "nxcansas_h5") -EXTERNAL_1D_FILE_PATTERN = "*.dat *.txt *.chi *.csv *.xml *.h5 *.hdf5" +EXTERNAL_1D_FILE_PATTERN = "*.dat *.txt *.chi *.csv *.xml *.h5 *.hdf5 *.hdf *.nxs" logger = logging.getLogger(__name__) SUPPORTED_LANGUAGES = ("en", "zh") @@ -294,7 +294,7 @@ def _read_package_version() -> str: "lf_t3_execution": "2. Execution Strategy", "cb_t3_resume": "Resume (skip existing output)", "cb_t3_overwrite": "Force overwrite", - "lbl_t3_formats": "Supported: .dat .txt .chi .csv .xml .h5 .hdf5 (need X & I columns; Error optional)", + "lbl_t3_formats": "Supported: .dat .txt .chi .csv .xml .h5 .hdf5 .hdf .nxs (need X & I columns; Error optional)", "lf_t3_raw_params": "3. Raw 1D Correction Params (raw pipeline)", "btn_t3_meta_from_batch": "Generate metadata from Tab2 report", "cb_t3_meta_thk": "Prefer thk_mm from metadata", @@ -716,7 +716,7 @@ def _read_package_version() -> str: "lf_t3_execution": "2. 执行策略", "cb_t3_resume": "断点续跑(跳过已存在输出)", "cb_t3_overwrite": "强制覆盖输出", - "lbl_t3_formats": "支持格式: .dat .txt .chi .csv .xml .h5 .hdf5(列至少包含 X 与 I;Error 可选)", + "lbl_t3_formats": "支持格式: .dat .txt .chi .csv .xml .h5 .hdf5 .hdf .nxs(列至少包含 X 与 I;Error 可选)", "lf_t3_raw_params": "3. 原始1D校正参数(raw流程)", "btn_t3_meta_from_batch": "由 Tab2 报告生成 metadata", "cb_t3_meta_thk": "优先使用 metadata 中的 thk_mm", @@ -3582,7 +3582,7 @@ def merge_integrate1d_results(self, results): else: w = np.asarray(w, dtype=np.float64) if w.shape != r0.shape: - w = np.zeros_like(i, dtype=np.float64) + w = np.ones_like(i, dtype=np.float64) valid = np.isfinite(i) & np.isfinite(w) & (w > 0.0) w_eff = np.where(valid, w, 0.0) @@ -3642,9 +3642,15 @@ def integrate1d_sector(self, ai, img, npt, sec_min, sec_max, **kwargs): return res, s1, s2, wrap def check_instrument_consistency(self, file_paths, poni_path=None, tol_pct=0.5): + try: + tolerance_pct = float(tol_pct) + except (TypeError, ValueError): + return ["仪器一致性阈值必须是有限正数(单位:%)"] + if not np.isfinite(tolerance_pct) or tolerance_pct <= 0: + return ["仪器一致性阈值必须是有限正数(单位:%)"] if not file_paths: return [] - tol = max(float(tol_pct), 0.01) / 100.0 + tol = max(tolerance_pct, 0.01) / 100.0 sigs = [] for fp in file_paths: try: @@ -3827,6 +3833,19 @@ def validate_output_format_for_axis(output_format, x_label): fmt = str(output_format or "tsv").strip().lower() if fmt not in OUTPUT_FORMAT_TOKENS: raise ValueError(f"Unsupported output format: {output_format}") + if fmt == "nxcansas_h5": + if write_nxcansas_h5 is None: + raise ValueError( + "NXcanSAS HDF5 output is unavailable; install it with: " + "pip install saxsabs[hdf5]" + ) + try: + import h5py # noqa: F401 + except ImportError as exc: + raise ValueError( + "NXcanSAS HDF5 output requires h5py; install it with: " + "pip install saxsabs[hdf5]" + ) from exc if fmt in {"cansas_xml", "nxcansas_h5"} and str(x_label) != "Q_A^-1": raise ValueError( f"输出格式 {fmt} 要求 Q_A^-1 轴数据,当前为 {x_label};" @@ -6653,28 +6672,52 @@ def canonical_q_unit(value): return None text = unicodedata.normalize("NFKC", str(value or "").strip().lower()) + text = text.replace("å", "angstrom").replace("Å", "angstrom") + text = re.sub(r"(?<=[a-z])(?:−|–|—)1", "^-1", text) text = ( - text.replace("å", "angstrom") - .replace("Å", "angstrom") + text + .replace("⁰", "0") + .replace("¹", "1") + .replace("²", "2") + .replace("³", "3") + .replace("⁴", "4") + .replace("⁵", "5") + .replace("⁶", "6") + .replace("⁷", "7") + .replace("⁸", "8") + .replace("⁹", "9") .replace("⁻", "-") .replace("−", "-") .replace("–", "-") - ) - text = re.sub(r"^\s*q(?:\s*[_:\-]?\s*)?", "", text, count=1) - has_inverse = bool( - re.search(r"(? str: def _read_tabular_dataframe(path: Path) -> pd.DataFrame: + comment_header_df = _read_comment_header_dataframe(path) + if comment_header_df is not None: + return comment_header_df + errors: list[str] = [] + header_tokens = _read_plain_header_tokens(path) + data_width = _physical_data_width(path) read_trials = [ {"sep": None, "engine": "python", "comment": "#"}, {"sep": r"[,\s;]+", "engine": "python", "comment": "#"}, @@ -80,6 +92,11 @@ def _read_tabular_dataframe(path: Path) -> pd.DataFrame: errors.append(str(exc)) continue if df is not None and not df.empty and df.shape[1] >= 2: + df = _normalise_inferred_header_columns( + df, + header_tokens=header_tokens, + data_width=data_width, + ) return df detail = f" ({'; '.join(errors[:2])})" if errors else "" @@ -97,6 +114,15 @@ def _resolve_column( return requested requested_clean = _clean_column_name(requested) matches = [col for col in columns if _clean_column_name(col) == requested_clean] + if role == "intensity" and requested_clean == "i": + matches.extend( + col + for col in columns + if re.fullmatch( + r"i\s*(?:[([{][^()\[\]{}]*[)\]}])?", str(col).strip(), re.IGNORECASE + ) + and col not in matches + ) if len(matches) == 1: return matches[0] raise ValueError( @@ -153,11 +179,58 @@ def _unitless_column_prefix(name: object) -> str: def _q_selector_matches_unitful_profile(requested: object, resolved: object) -> bool: - return ( - q_axis_kind(resolved) == "q" - and _clean_column_name(requested) == _unitless_column_prefix(resolved) + prefix_match = ( + _clean_column_name(requested) == _unitless_column_prefix(resolved) and _clean_column_name(requested) != _clean_column_name(resolved) ) + if not prefix_match: + return False + if q_axis_kind(resolved) == "q": + return True + unit, raw_hint = _explicit_q_unit_from_header(resolved) + return unit is not None or raw_hint is not None + + +def _explicit_q_unit_from_header(name: object) -> tuple[str | None, str | None]: + """Extract a complete explicit reciprocal-unit suffix from any header.""" + + inferred = infer_q_unit_from_column(name) + if inferred is not None: + return inferred, None + + text = unicodedata.normalize("NFKC", str(name or "").strip().lower()) + matching = {"(": ")", "[": "]", "{": "}"} + delimiter_chars = set(matching) | set(matching.values()) + if any(char in text for char in delimiter_chars): + bracketed = re.fullmatch( + r"([a-z][a-z0-9_]*)\s*([([{])(.*)([)\]}])", + text, + ) + if bracketed is None: + return None, text + opener = bracketed.group(2) + if ( + matching[opener] != bracketed.group(4) + or not _unit_delimiters_are_balanced(text) + ): + return None, text + candidate = bracketed.group(3).strip() + if not candidate or any(char in candidate for char in delimiter_chars): + return None, text + return canonicalize_q_unit(candidate), candidate + + candidates: list[str] = [] + for separator in ("_", ":", "-", " "): + marker = text.find(separator) + if marker > 0: + candidates.append(text[marker + 1 :].strip()) + for candidate in candidates: + if not candidate: + continue + canonical = canonicalize_q_unit(candidate) + if canonical is not None or _has_explicit_q_unit_hint(candidate, text): + return canonical, candidate + return None, None def _q_column_matches_profile_values( @@ -185,7 +258,10 @@ def _read_profile_for_estimate( if q_col is None and i_col is None: return read_external_1d_profile(path) - parsed_profile = read_external_1d_profile(path) + parsed_profile = read_external_1d_profile( + path, + allow_unidentified_intensity=i_col is not None, + ) requested_q_matches = q_col is None or ( _clean_column_name(q_col) == _clean_column_name(parsed_profile.get("x_col", "")) ) @@ -197,59 +273,114 @@ def _read_profile_for_estimate( reused["x_axis_override"] = q_col is not None if q_col is not None and not reused.get("x_unit"): resolved_name = str(reused.get("x_col", "")) - reused["x_unit"] = infer_q_unit_from_column(resolved_name) + selected_unit, selected_raw_hint = _explicit_q_unit_from_header(resolved_name) + reused["x_unit"] = selected_unit or infer_q_unit_from_column(resolved_name) raw_q_hint = q_column_unit_hint(resolved_name) reused["x_unit_raw"] = ( raw_q_hint if reused["x_unit"] is None + and raw_q_hint and _has_explicit_q_unit_hint(raw_q_hint, resolved_name) - else "" + else (selected_raw_hint or "") if reused["x_unit"] is None else "" ) return reused df = _read_tabular_dataframe(path) columns = list(df.columns) - if ( - q_col is not None - and requested_i_matches - and _q_selector_matches_unitful_profile( - q_col, parsed_profile.get("x_col", "") - ) + preserve_parsed_q_metadata = False + if q_col is not None and _q_selector_matches_unitful_profile( + q_col, parsed_profile.get("x_col", "") ): try: raw_q_col = _resolve_column(columns, q_col, "q", profile_label) - except ValueError as exc: - raise ValueError( - f"{profile_label} q selector {q_col!r} is ambiguous; " - "select the complete unit-bearing Q column" - ) from exc + except ValueError: + # Whitespace-delimited headers are normalized to the complete + # parenthesized Q name. A semantic ``Q`` selector may therefore + # resolve through the parser-selected unit-bearing column. + parsed_q_name = str(parsed_profile.get("x_col", "")) + raw_q_col = next( + ( + column + for column in columns + if _clean_column_name(column) == _clean_column_name(parsed_q_name) + ), + None, + ) + if raw_q_col is None: + raise ValueError( + f"{profile_label} q selector {q_col!r} is ambiguous; " + "select the complete unit-bearing Q column" + ) from None if not _q_column_matches_profile_values(df, raw_q_col, parsed_profile): raise ValueError( f"{profile_label} q selector {q_col!r} does not match the parsed Q column; " "refusing to discard its unit metadata" ) - reused = dict(parsed_profile) - reused["x_axis_override"] = True - return reused - - resolved_q_col = _resolve_column(columns, q_col, "q", profile_label) + resolved_q_col = raw_q_col + preserve_parsed_q_metadata = True + else: + resolved_q_col = _resolve_column(columns, q_col, "q", profile_label) resolved_i_col = _resolve_column(columns, i_col, "intensity", profile_label) q = pd.to_numeric(df[resolved_q_col], errors="coerce").to_numpy(dtype=float) intensity = pd.to_numeric(df[resolved_i_col], errors="coerce").to_numpy(dtype=float) - profile = parsed_profile + mask = np.isfinite(q) & np.isfinite(intensity) + if int(mask.sum()) < 3: + raise ValueError( + f"{profile_label}: selected Q/intensity columns contain fewer than 3 finite rows" + ) + order = np.argsort(q[mask]) + q = q[mask][order] + intensity = intensity[mask][order] + profile = dict(parsed_profile) profile["x"] = q profile["intensity"] = intensity profile["i_col"] = str(resolved_i_col) - profile["x_col"] = str(resolved_q_col) - profile["x_unit"] = infer_q_unit_from_column(resolved_q_col) - raw_q_hint = q_column_unit_hint(resolved_q_col) - profile["x_unit_raw"] = ( - raw_q_hint - if profile["x_unit"] is None - and _has_explicit_q_unit_hint(raw_q_hint, resolved_q_col) - else "" + if preserve_parsed_q_metadata: + profile["x_col"] = str(parsed_profile.get("x_col", resolved_q_col)) + selected_unit, selected_raw_hint = _explicit_q_unit_from_header(resolved_q_col) + profile["x_unit"] = selected_unit + profile["x_unit_raw"] = "" if selected_unit is not None else (selected_raw_hint or "") + else: + profile["x_col"] = str(resolved_q_col) + selected_unit, selected_raw_hint = _explicit_q_unit_from_header(resolved_q_col) + profile["x_unit"] = selected_unit or infer_q_unit_from_column(resolved_q_col) + raw_q_hint = q_column_unit_hint(resolved_q_col) + profile["x_unit_raw"] = ( + raw_q_hint + if profile["x_unit"] is None + and raw_q_hint + and _has_explicit_q_unit_hint(raw_q_hint, resolved_q_col) + else (selected_raw_hint or "") if profile["x_unit"] is None else "" + ) + parsed_error_col = str(parsed_profile.get("err_col", "") or "").strip() + error_source_col = next( + ( + column + for column in columns + if parsed_error_col + and _clean_column_name(column) == _clean_column_name(parsed_error_col) + ), + None, ) + if error_source_col is None: + uncertainty = np.full(q.shape, np.nan, dtype=float) + else: + raw_uncertainty = pd.to_numeric( + df[error_source_col], errors="coerce" + ).to_numpy(dtype=float) + if raw_uncertainty.shape == mask.shape: + uncertainty = raw_uncertainty[mask][order] + uncertainty = np.where(np.isfinite(uncertainty), uncertainty, np.nan) + else: + uncertainty = np.full(q.shape, np.nan, dtype=float) + profile["uncertainty"] = uncertainty + profile.pop("i_abs", None) + profile.pop("i_rel", None) + profile.pop("err_abs", None) + profile.pop("err_rel", None) + profile.pop("intensity_state", None) + profile = _attach_intensity_arrays(profile, intensity, uncertainty) profile["x_axis_override"] = q_col is not None return profile @@ -305,7 +436,12 @@ def _normalize_q_profile( raise ValueError(f"{profile_label}: unsupported Q unit {raw_unit!r}") raw_unit_hint = str(profile.get("x_unit_raw", "") or "").strip() if raw_unit_hint and declared_unit is None: - raise ValueError(f"{profile_label}: unsupported Q unit {raw_unit_hint!r}") + if _has_explicit_q_unit_hint(raw_unit_hint, x_col): + raise ValueError(f"{profile_label}: unsupported Q unit {raw_unit_hint!r}") + # Semantic suffixes such as ``q_ref`` are historical aliases for a + # bare q column, not unit declarations. Keep rejecting unit-like + # unknown hints while preserving that CLI compatibility policy. + raw_unit_hint = "" inferred_unit = infer_q_unit_from_column(x_col) if declared_unit is not None and inferred_unit is not None and declared_unit != inferred_unit: raise ValueError( @@ -334,8 +470,13 @@ def _normalize_q_profile( updated["x_unit"] = "A^-1" updated["x_col"] = "Q_A^-1" provenance = dict(profile.get("operator_provenance") or {}) - provenance["q_unit_original"] = str(profile.get("x_unit") or source_unit) - provenance["q_unit_conversion"] = conversion + normalized_input = x_col == "Q_A^-1" and source_unit == "A^-1" + if normalized_input: + provenance.setdefault("q_unit_original", str(profile.get("x_unit") or source_unit)) + provenance.setdefault("q_unit_conversion", conversion) + else: + provenance["q_unit_original"] = str(profile.get("x_unit") or source_unit) + provenance["q_unit_conversion"] = conversion updated["operator_provenance"] = provenance return updated diff --git a/src/saxsabs/io/parsers.py b/src/saxsabs/io/parsers.py index 20b4f0f..64ce365 100644 --- a/src/saxsabs/io/parsers.py +++ b/src/saxsabs/io/parsers.py @@ -18,8 +18,10 @@ from __future__ import annotations +import csv from io import StringIO import re +import shlex import unicodedata import xml.etree.ElementTree as ET from pathlib import Path @@ -162,8 +164,9 @@ def _normalise_unit_text(value: object) -> str: """Return a conservative ASCII token for a unit/header fragment.""" text = unicodedata.normalize("NFKC", str(value or "").strip().lower()) - text = text.translate(_SUPERSCRIPT_TRANSLATION) text = text.replace("å", "angstrom").replace("Å", "angstrom") + text = re.sub(r"(?<=[a-z])(?:−|–|—)1", "^-1", text) + text = text.translate(_SUPERSCRIPT_TRANSLATION) return text @@ -183,28 +186,40 @@ def canonicalize_q_unit(value: object) -> str | None: if value is None: return None text = _normalise_unit_text(value) - if not text: - return None - if not _unit_delimiters_are_balanced(text): + if not text or not _unit_delimiters_are_balanced(text): return None + # A column label may include a leading Q (for example ``Q_A^-1``), but - # this does not itself establish reciprocal units. - text = re.sub(r"^\s*q(?:\s*[_:\-]?\s*)?", "", text, count=1) - has_reciprocal_slash = bool(re.search(r"(? bool: @@ -245,7 +260,7 @@ def infer_q_unit_from_column(name: object) -> str | None: suffix = match.group(1) if match is not None else "" if not suffix.strip(" _-:()[]{}"): return None - return canonicalize_q_unit("q" + suffix) + return canonicalize_q_unit("q_" + suffix) def q_column_unit_hint(name: object) -> str | None: @@ -256,8 +271,47 @@ def q_column_unit_hint(name: object) -> str | None: text = _normalise_unit_text(name) match = re.match(r"^q(?:\s*[_:\-]?\s*)(.*)$", text, flags=re.IGNORECASE) suffix = match.group(1) if match is not None else "" - suffix = suffix.strip(" _-:()[]{}") - return suffix or None + suffix = suffix.strip() + if suffix[:1] in "_:-": + suffix = suffix[1:].lstrip() + if not suffix: + return None + if suffix[:1] in "([{": + matching = {"(": ")", "[": "]", "{": "}"} + opener = suffix[0] + if ( + _unit_delimiters_are_balanced(suffix) + and suffix.endswith(matching[opener]) + ): + inner = suffix[1:-1].strip() + return inner or suffix + return suffix + + +def _q_header_has_unsupported_unit_syntax(name: object) -> bool: + """Identify explicit Q-unit syntax that failed full-header parsing.""" + + if q_axis_kind(name) != "q" or canonicalize_q_unit(name) is not None: + return False + text = _normalise_unit_text(name) + hint = q_column_unit_hint(name) + if not hint: + return False + if re.search(r"[\^/]|⁻|[−–—]|\b(?:inverse|inv)\b", text): + return True + if any(char in text for char in "()[]{}"): + known_bare_units = {"a", "angstrom", "nm", "mm", "cm", "m", "um", "pm"} + return hint not in known_bare_units or text.count("(") + text.count("[") + text.count("{") != 1 + return bool( + re.search( + r"(? str: @@ -294,6 +348,21 @@ def _match_column_score( return 0 +def _intensity_column_score(name: Any) -> int: + """Score intensity headers without treating every ``i...`` name as I.""" + + score = _match_column_score( + _clean_column_name(name), + exact={"i", "intensity", "irel", "iabs", "signal", "count", "counts", "y"}, + prefixes=("intensity", "signal", "count", "irel", "iabs"), + suffixes=("intensity",), + ) + text = _normalise_unit_text(name) + if re.match(r"^i(?=$|[\s_:/\-([{])", text): + score = max(score, 200) + return score + + def _pick_named_column( cols: list[Any], used: set[Any], @@ -307,12 +376,15 @@ def _pick_named_column( for col in cols: if col in used: continue - score = _match_column_score( - _clean_column_name(col), - exact=exact, - prefixes=prefixes, - suffixes=suffixes, - ) + if exact == {"i", "intensity", "irel", "iabs", "signal", "count", "counts", "y"}: + score = _intensity_column_score(col) + else: + score = _match_column_score( + _clean_column_name(col), + exact=exact, + prefixes=prefixes, + suffixes=suffixes, + ) if score > best_score: best = col best_score = score @@ -329,12 +401,7 @@ def _comment_header_score(tokens: list[str]) -> int: prefixes=("q", "chi", "radial", "twotheta"), suffixes=("q",), ) - score += _match_column_score( - name, - exact={"i", "intensity", "irel", "iabs", "signal", "count", "counts", "y"}, - prefixes=("intensity", "signal", "count", "irel", "iabs", "i"), - suffixes=("intensity",), - ) + score += _intensity_column_score(token) score += _match_column_score( name, exact={"err", "error", "errors", "sigma", "std", "stdev", "unc", "uncertainty", "idev"}, @@ -344,26 +411,107 @@ def _comment_header_score(tokens: list[str]) -> int: return score -def _merge_parenthesized_header_tokens(tokens: list[str]) -> list[str]: - """Rejoin ``Q (nm^-1)``/``Q [nm^-1]`` names split by whitespace.""" +def _merge_parenthesized_header_tokens( + tokens: list[str], +) -> list[str]: + """Rejoin complete Q-unit syntax without absorbing an I/error column.""" opening_to_closing = {"(": ")", "[": "]", "{": "}"} + + def consume_bracket_group(start: int) -> int | None: + opener = tokens[start][:1] + closer = opening_to_closing.get(opener) + if closer is None: + return None + end = start + while end < len(tokens): + candidate = " ".join(tokens[start : end + 1]) + if candidate.endswith(closer) and _unit_delimiters_are_balanced(candidate): + return end + 1 + end += 1 + return None + + def is_q_unit_token(token: str) -> bool: + normalized = _normalise_unit_text(token).strip() + if not normalized: + return False + if normalized in { + "1", + "/", + "^", + "-", + "-1", + "inverse", + "inv", + "a", + "angstrom", + "nm", + "mm", + "cm", + "m", + "um", + "pm", + "q", + }: + return True + if re.fullmatch( + r"(?:a|angstrom|nm|mm|cm|m|um|pm)(?:\^?-?\d+)?", normalized + ): + return True + if re.fullmatch( + r"(?:inv|inverse)(?:a|angstrom|nm|mm|cm|m|um|pm)\d*", normalized + ): + return True + if canonicalize_q_unit(normalized) is not None: + return True + return any(marker in normalized for marker in ("/", "^", "⁻", "−")) + merged: list[str] = [] idx = 0 while idx < len(tokens): token = tokens[idx] + if q_axis_kind(token) == "q": + parts = [token] + end = idx + 1 + consumed = False + while end < len(tokens): + next_token = tokens[end] + if _intensity_column_score(next_token) > 0 or _error_column_preference(next_token) < 2: + break + if next_token[:1] in opening_to_closing: + bracket_end = consume_bracket_group(end) + if bracket_end is None: + break + parts.extend(tokens[end:bracket_end]) + end = bracket_end + consumed = True + continue + if is_q_unit_token(next_token): + parts.append(next_token) + end += 1 + consumed = True + if canonicalize_q_unit(" ".join(parts)) is not None: + next_is_bracket = ( + end < len(tokens) + and tokens[end][:1] in opening_to_closing + ) + if not next_is_bracket: + break + continue + break + if consumed: + merged.append(" ".join(parts)) + idx = end + continue + next_token = tokens[idx + 1] if idx + 1 < len(tokens) else "" opener = next_token[:1] closer = opening_to_closing.get(opener) if closer is not None: - end = idx + 1 - while end < len(tokens) and not tokens[end].endswith(closer): - end += 1 - if end < len(tokens) and _unit_delimiters_are_balanced( - " ".join(tokens[idx : end + 1]) - ): - merged.append(" ".join(tokens[idx : end + 1])) - idx = end + 1 + bracket_end = consume_bracket_group(idx + 1) + if bracket_end is not None: + merged.append(" ".join(tokens[idx:bracket_end])) + idx = bracket_end continue merged.append(token) idx += 1 @@ -385,6 +533,8 @@ def _has_malformed_unit_header_tokens(tokens: list[str]) -> bool: continue for candidate in tokens[index + 1 :]: if candidate.endswith(expected): + if not _unit_delimiters_are_balanced(candidate): + return True break if any(char in candidate for char in closing): return True @@ -393,7 +543,100 @@ def _has_malformed_unit_header_tokens(tokens: list[str]) -> bool: return False -def _normalise_inferred_header_columns(df: pd.DataFrame) -> pd.DataFrame: +def _strip_inline_comment(line: str) -> str: + in_quotes = False + index = 0 + while index < len(line): + char = line[index] + if char == '"': + if in_quotes and index + 1 < len(line) and line[index + 1] == '"': + index += 2 + continue + in_quotes = not in_quotes + elif char == "#" and not in_quotes: + return line[:index] + index += 1 + return line + + +def _physical_width_from_data_lines(lines: list[str]) -> int: + widths: list[int] = [] + for line in lines: + stripped = _strip_inline_comment(line).strip() + if not stripped or stripped.startswith("#"): + continue + widths.append(len(_tokenize_header_line(stripped))) + if not widths: + return 0 + if len(set(widths)) != 1: + raise ValueError("data rows have inconsistent physical field widths") + return widths[0] + + +def _physical_data_width(path: str | Path) -> int: + lines = Path(path).read_text(encoding="utf-8-sig", errors="ignore").splitlines() + first_data_index: int | None = None + first_tokens: list[str] = [] + for index, line in enumerate(lines): + stripped = _strip_inline_comment(line).strip() + if not stripped or stripped.startswith("#"): + continue + first_data_index = index + first_tokens = _tokenize_header_line(stripped) + break + if first_data_index is None: + return 0 + numeric_markers = { + "nan", "+nan", "-nan", "inf", "+inf", "-inf", + "infinity", "+infinity", "-infinity", + } + first_is_numeric = all( + FLOAT_PATTERN.fullmatch(token) is not None + or token.strip().lower() in numeric_markers + for token in first_tokens + ) + data_lines = lines[first_data_index:] if first_is_numeric else lines[first_data_index + 1 :] + return _physical_width_from_data_lines(data_lines) + + +def _read_plain_header_tokens(path: str | Path) -> list[str] | None: + try: + lines = Path(path).read_text(encoding="utf-8-sig", errors="ignore").splitlines() + except ValueError: + raise + except Exception: + return None + for line in lines: + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#"): + continue + tokens = _tokenize_header_line(stripped) + if len(tokens) >= 2 and any(FLOAT_PATTERN.fullmatch(token) is None for token in tokens): + return tokens + return None + return None + + +def _tokenize_header_line(line: str) -> list[str]: + """Tokenize a header without splitting quoted fields or unit spaces.""" + + text = str(line).strip() + if not text: + return [] + delimiter = ";" if ";" in text and "," not in text else "," if "," in text else None + if delimiter is not None: + return [field.strip() for field in next(csv.reader([text], delimiter=delimiter))] + return [field.strip() for field in shlex.split(text, posix=True)] + + +def _normalise_inferred_header_columns( + df: pd.DataFrame, + *, + header_tokens: list[str] | None = None, + data_width: int | None = None, +) -> pd.DataFrame: """Rejoin parenthesized units in a header parsed by pandas. A whitespace-delimited header such as ``Q (nm^-1) I`` is read as three @@ -402,17 +645,22 @@ def _normalise_inferred_header_columns(df: pd.DataFrame) -> pd.DataFrame: column is retained as an unnamed column so it cannot be mistaken for I. """ - columns = [str(column) for column in df.columns] + columns = [str(column) for column in (header_tokens or list(df.columns))] + data_width = int(data_width if data_width is not None else df.shape[1]) merged = _merge_parenthesized_header_tokens(columns) - if merged == columns: + if merged != columns and len(merged) > data_width: + raise ValueError( + "header has more logical columns than the observed data width" + ) + if merged == columns and header_tokens is None: return df - if len(merged) < len(columns): + if len(merged) < df.shape[1]: merged.extend( f"__unnamed_{index}" - for index in range(len(merged), len(columns)) + for index in range(len(merged), df.shape[1]) ) else: - merged = merged[: len(columns)] + merged = merged[: df.shape[1]] out = df.copy() out.columns = merged return out @@ -433,12 +681,16 @@ def _read_comment_header_dataframe(path: str | Path) -> pd.DataFrame | None: candidate = stripped.lstrip("#").strip() if not candidate or ":" in candidate or "=" in candidate: continue - tokens = [token for token in re.split(r"[,\s;]+", candidate) if token] + tokens = _tokenize_header_line(candidate) if _has_malformed_unit_header_tokens(tokens): raise ValueError("malformed Q-unit delimiter in comment header") - tokens = _merge_parenthesized_header_tokens(tokens) - if len(tokens) >= 2 and any(FLOAT_PATTERN.fullmatch(token) is None for token in tokens): - header_candidates.append((idx, tokens, _comment_header_score(tokens))) + score = _comment_header_score(tokens) + if ( + len(tokens) >= 2 + and score > 0 + and any(FLOAT_PATTERN.fullmatch(token) is None for token in tokens) + ): + header_candidates.append((idx, tokens, score)) continue break @@ -465,22 +717,13 @@ def _read_comment_header_dataframe(path: str | Path) -> pd.DataFrame | None: comment="#", header=None, ) - data_columns = int(raw_data.shape[1]) - if data_columns < len(header_tokens): - header_tokens = header_tokens[:data_columns] - elif data_columns > len(header_tokens): - header_tokens.extend( - f"__unnamed_{index}" - for index in range(len(header_tokens), data_columns) - ) - df = pd.read_csv( - StringIO(text), - sep=r"[,\s;]+", - engine="python", - comment="#", - header=None, - names=header_tokens, + df = _normalise_inferred_header_columns( + raw_data, + header_tokens=header_tokens, + data_width=_physical_width_from_data_lines(data_lines), ) + except ValueError: + raise except Exception: return None @@ -614,7 +857,11 @@ def get_val(keys: list[str], exact_only: set[str] | None = None) -> tuple[str | return exp, mon, trans -def read_external_1d_profile(path: str | Path) -> dict[str, Any]: +def read_external_1d_profile( + path: str | Path, + *, + allow_unidentified_intensity: bool = False, +) -> dict[str, Any]: p = Path(path) ext = p.suffix.lower() @@ -631,6 +878,9 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: errs: list[str] = [] comment_header_df = _read_comment_header_dataframe(p) + has_comment_header = comment_header_df is not None + plain_header_tokens = _read_plain_header_tokens(p) + physical_data_width = _physical_data_width(p) if comment_header_df is not None: dfs.append(comment_header_df) @@ -644,13 +894,38 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: for kw in read_trials: try: df = pd.read_csv(path, encoding="utf-8-sig", **kw) + if kw.get("header") is None and df is not None and not df.empty: + position_columns = all( + isinstance(column, (int, np.integer)) for column in df.columns + ) + if position_columns: + if has_comment_header: + continue + first_row = df.iloc[0] + non_numeric_header_tokens = int( + pd.to_numeric(first_row, errors="coerce").isna().sum() + ) + if non_numeric_header_tokens >= 1: + continue if df is not None and "header" not in kw: - if _has_malformed_unit_header_tokens([str(column) for column in df.columns]): + header_for_malformed_check = ( + plain_header_tokens + or [str(column) for column in df.columns] + ) + if _has_malformed_unit_header_tokens(header_for_malformed_check): malformed_header_detected = True continue - df = _normalise_inferred_header_columns(df) + df = _normalise_inferred_header_columns( + df, + header_tokens=plain_header_tokens, + data_width=physical_data_width, + ) if df is not None and not df.empty and df.shape[1] >= 2: dfs.append(df) + except ValueError as exc: + if str(exc).startswith("header has more logical columns"): + malformed_header_detected = True + errs.append(str(exc)) except Exception as exc: errs.append(str(exc)) @@ -694,6 +969,11 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: suffixes=("intensity",), ) if i_col is None: + position_columns = all( + isinstance(column, (int, np.integer)) for column in df.columns + ) + if not position_columns and not allow_unidentified_intensity: + continue i_col = next((c for c in cols if c != x_col), None) if i_col is None: continue @@ -752,6 +1032,8 @@ def read_external_1d_profile(path: str | Path) -> dict[str, Any]: if best is None: raise ValueError(f"Cannot identify valid numeric columns in {Path(path).name}") + if _q_header_has_unsupported_unit_syntax(best["x_col"]): + raise ValueError(f"Unsupported or malformed Q-unit header in {Path(path).name}") best["operator_provenance"] = _read_text_operator_provenance(p) intensity = np.asarray(best.pop("intensity"), dtype=np.float64) uncertainty = np.asarray(best.pop("uncertainty"), dtype=np.float64) diff --git a/tests/test_cli.py b/tests/test_cli.py index c52d689..f598d0c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from saxsabs.cli import _normalize_q_profile, main +from saxsabs.cli import _normalize_q_profile, _read_profile_for_estimate, main from saxsabs.workflows import bl19b2_abs2d @@ -340,6 +340,10 @@ def test_cli_q_normalization_is_shallow_and_idempotent(): np.testing.assert_allclose(converted["x"], [0.1, 0.2]) np.testing.assert_allclose(repeated["x"], converted["x"]) assert converted["x_unit"] == repeated["x_unit"] == "A^-1" + assert converted["operator_provenance"]["q_unit_original"] == "nm^-1" + assert repeated["operator_provenance"]["q_unit_original"] == "nm^-1" + assert converted["operator_provenance"]["q_unit_conversion"] == "nm^-1_to_A^-1" + assert repeated["operator_provenance"]["q_unit_conversion"] == "nm^-1_to_A^-1" def test_cli_q_normalization_rejects_unknown_unit_with_explicit_override(): @@ -420,6 +424,335 @@ def test_cli_estimate_k_accepts_explicit_column_overrides( assert out["k_factor"] == pytest.approx(2.0, rel=1e-6) +def test_cli_estimate_k_default_and_semantic_q_ref_override_have_same_policy( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + meas = tmp_path / "meas.csv" + ref = tmp_path / "ref.csv" + meas.write_text( + "q_ref,I_ref\n0.01,17.1\n0.02,15.4\n0.05,13.4\n0.10,11.8\n", + encoding="utf-8", + ) + ref.write_text( + "q_ref,I_ref\n0.01,34.2\n0.02,30.8\n0.05,26.8\n0.10,23.6\n", + encoding="utf-8", + ) + base_argv = [ + "saxsabs", + "estimate-k", + "--meas", + str(meas), + "--ref", + str(ref), + "--intensity-state", + "relative", + ] + + monkeypatch.setattr(sys, "argv", base_argv) + main() + default_out = json.loads(capsys.readouterr().out) + + monkeypatch.setattr( + sys, + "argv", + base_argv + + [ + "--q-col", + "q_ref", + "--i-col", + "I_ref", + "--ref-q-col", + "q_ref", + "--ref-i-col", + "I_ref", + ], + ) + main() + override_out = json.loads(capsys.readouterr().out) + + assert default_out["k_factor"] == pytest.approx(2.0, rel=1e-12) + assert override_out["k_factor"] == pytest.approx(default_out["k_factor"], rel=1e-12) + assert override_out["points_total"] == default_out["points_total"] == 4 + + +def test_cli_estimate_k_requires_explicit_selector_for_unsupported_intensity_header( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + meas = tmp_path / "meas_bad_intensity.csv" + ref = tmp_path / "ref_bad_intensity.csv" + meas.write_text( + "Q,id,foo\n0.01,101,10\n0.02,102,9\n0.05,103,8\n0.10,104,7\n", + encoding="utf-8", + ) + ref.write_text( + "Q,id,foo\n0.01,201,30\n0.02,202,27\n0.05,203,24\n0.10,204,21\n", + encoding="utf-8", + ) + base_argv = [ + "saxsabs", + "estimate-k", + "--meas", + str(meas), + "--ref", + str(ref), + "--intensity-state", + "relative", + ] + + monkeypatch.setattr(sys, "argv", base_argv) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "numeric columns" in capsys.readouterr().err + + monkeypatch.setattr( + sys, + "argv", + base_argv + + [ + "--q-col", + "Q", + "--i-col", + "foo", + "--ref-q-col", + "Q", + "--ref-i-col", + "foo", + ], + ) + main() + explicit_out = json.loads(capsys.readouterr().out) + assert explicit_out["k_factor"] == pytest.approx(3.0, rel=1e-12) + + +@pytest.mark.parametrize("comment_header", [False, True]) +def test_cli_normalizes_space_separated_q_unit_header( + tmp_path: Path, + comment_header: bool, +): + profile_path = tmp_path / "space_q_unit.dat" + prefix = "# " if comment_header else "" + profile_path.write_text( + f"{prefix}Q 1/nm I\n0.10 10\n0.20 9\n0.30 8\n", + encoding="utf-8", + ) + + profile = _read_profile_for_estimate( + profile_path, + q_col=None, + i_col=None, + profile_label="sample", + ) + normalized = _normalize_q_profile(profile, profile_label="sample") + + np.testing.assert_allclose(normalized["x"], [0.01, 0.02, 0.03]) + + +@pytest.mark.parametrize("comment_header", [False, True]) +def test_cli_normalizes_quoted_q_unit_header( + tmp_path: Path, + comment_header: bool, +): + profile_path = tmp_path / "quoted_q_unit.csv" + prefix = "# " if comment_header else "" + profile_path.write_text( + f'{prefix}"Q 1/nm",I\n1.0,100\n2.0,90\n3.0,80\n', + encoding="utf-8", + ) + + profile = _read_profile_for_estimate( + profile_path, + q_col=None, + i_col=None, + profile_label="sample", + ) + normalized = _normalize_q_profile(profile, profile_label="sample") + + np.testing.assert_allclose(normalized["x"], [0.1, 0.2, 0.3]) + + +def test_cli_explicit_selector_preserves_nan_error_column_width(tmp_path: Path): + profile_path = tmp_path / "explicit_nan_selector.csv" + profile_path.write_text( + '"Q 1/nm","I/cm","Error_CombinedStandard"\n' + "1.0,100,NaN\n2.0,90,NaN\n3.0,80,NaN\n", + encoding="utf-8", + ) + + profile = _read_profile_for_estimate( + profile_path, + q_col="Q 1/nm", + i_col="I/cm", + profile_label="sample", + ) + normalized = _normalize_q_profile(profile, profile_label="sample") + + np.testing.assert_allclose(normalized["x"], [0.1, 0.2, 0.3]) + np.testing.assert_allclose(profile["intensity"], [100.0, 90.0, 80.0]) + assert profile["err_col"] == "Error_CombinedStandard" + assert np.all(np.isnan(profile["uncertainty"])) + + +@pytest.mark.parametrize("header", ["Q (nm^-1) [A^-1] I", "Q nm^-10 I"]) +def test_cli_rejects_invalid_space_separated_q_unit_header( + tmp_path: Path, + header: str, +): + profile_path = tmp_path / "invalid_space_q_unit.dat" + profile_path.write_text( + f"{header}\n0.10 10\n0.20 9\n0.30 8\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Unsupported or malformed Q-unit header"): + _read_profile_for_estimate( + profile_path, + q_col=None, + i_col=None, + profile_label="sample", + ) + + +@pytest.mark.parametrize("q_selector", [None, "Q", "Q (nm^-1)"]) +def test_cli_whitespace_parenthesized_q_and_alternate_intensity_selector( + tmp_path: Path, + q_selector: str | None, +): + profile_path = tmp_path / "profile.dat" + profile_path.write_text( + "Q (nm^-1) I1 I2\n" + "0.10 10 20\n" + "0.20 9 18\n" + "0.30 8 16\n", + encoding="utf-8", + ) + + profile = _read_profile_for_estimate( + profile_path, + q_col=q_selector, + i_col="I2", + profile_label="sample", + ) + normalized = _normalize_q_profile(profile, profile_label="sample") + + np.testing.assert_allclose(profile["intensity"], [20.0, 18.0, 16.0]) + np.testing.assert_allclose(normalized["x"], [0.01, 0.02, 0.03]) + assert normalized["x_unit"] == "A^-1" + + +def test_cli_alternate_intensity_reassesses_state_and_keeps_unsorted_uncertainty_aligned( + tmp_path: Path, +): + profile_path = tmp_path / "unsorted.csv" + profile_path.write_text( + "q,I_abs (cm^-1),I_rel,Error\n" + "0.30,300,30,3\n" + "0.10,100,10,1\n" + "0.20,200,20,2\n", + encoding="utf-8", + ) + + profile = _read_profile_for_estimate( + profile_path, + q_col=None, + i_col="I_rel", + profile_label="sample", + ) + + np.testing.assert_allclose(profile["x"], [0.10, 0.20, 0.30]) + np.testing.assert_allclose(profile["intensity"], [10.0, 20.0, 30.0]) + np.testing.assert_allclose(profile["uncertainty"], [1.0, 2.0, 3.0]) + assert profile["i_col"] == "I_rel" + assert profile["intensity_state"] == "relative" + assert "i_rel" in profile + assert "i_abs" not in profile + assert "err_rel" in profile + assert "err_abs" not in profile + + +def test_cli_q_normalization_overwrites_stale_first_call_provenance(): + profile = { + "x": [1.0, 2.0], + "x_col": "Q", + "x_unit": "nm^-1", + "operator_provenance": { + "q_unit_original": "A^-1", + "q_unit_conversion": "none", + }, + } + + normalized = _normalize_q_profile(profile, profile_label="sample") + + assert normalized["operator_provenance"]["q_unit_original"] == "nm^-1" + assert normalized["operator_provenance"]["q_unit_conversion"] == "nm^-1_to_A^-1" + + +@pytest.mark.parametrize( + ("header", "selector", "expected_q"), + [ + ("S (nm^-1)", "S (nm^-1)", [0.01, 0.02, 0.03]), + ("X (1/A)", "X (1/A)", [0.1, 0.2, 0.3]), + ("angle", "angle", [0.1, 0.2, 0.3]), + ], +) +def test_cli_explicit_arbitrary_q_selector_preserves_complete_unit_suffix( + tmp_path: Path, + header: str, + selector: str, + expected_q: list[float], +): + profile_path = tmp_path / "arbitrary_q.csv" + profile_path.write_text( + f"{header} I\n0.1 10\n0.2 9\n0.3 8\n", + encoding="utf-8", + ) + + profile = _read_profile_for_estimate( + profile_path, + q_col=selector, + i_col="I", + profile_label="sample", + ) + normalized = _normalize_q_profile(profile, profile_label="sample") + + np.testing.assert_allclose(normalized["x"], expected_q) + + +@pytest.mark.parametrize( + "header", + [ + "S (mm^-1)", + "S (nm^-10)", + "S (nm^-1) [A^-1]", + "S_nm^-1_", + "S_nm^-1-", + "S 1/nm_", + ], +) +def test_cli_explicit_arbitrary_q_selector_rejects_unknown_unit_suffix( + tmp_path: Path, + header: str, +): + profile_path = tmp_path / "arbitrary_q_bad.csv" + profile_path.write_text( + f"{header},I\n0.1,10\n0.2,9\n0.3,8\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError): + profile = _read_profile_for_estimate( + profile_path, + q_col=header, + i_col="I", + profile_label="sample", + ) + _normalize_q_profile(profile, profile_label="sample") + + def test_cli_estimate_k_invalid_override_lists_available_columns( tmp_path: Path, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 1429568..10282b8 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -37,8 +37,12 @@ def test_q_unit_canonicalization_rejects_bare_or_signless_lengths(raw_unit): ("1/A", "A^-1"), ("1/angstrom", "A^-1"), ("A^-1", "A^-1"), + ("A-1", "A^-1"), + ("angstrom-1", "A^-1"), ("Å⁻¹", "A^-1"), ("nm^-1", "nm^-1"), + ("nm-1", "nm^-1"), + ("nm - 1", "nm^-1"), ("1/nm", "nm^-1"), ("inverse angstrom", "A^-1"), ("inv nm", "nm^-1"), @@ -50,6 +54,44 @@ def test_q_unit_canonicalization_requires_and_accepts_reciprocal_marker(raw_unit assert canonicalize_q_unit(raw_unit) == expected +@pytest.mark.parametrize( + "raw_unit", + [ + "nm^-10", + "nm^-12", + "A^-10", + "1/nm2", + "nm-10", + "q_nm^-10", + "inverse nm2", + "invnm2", + "1/nm/s", + "nm^-1foo", + "Q_nonsense_nm^-1", + "1/nm A^-1", + "nm^-1 A^-1", + "1/nm/angstrom", + "Q (nm^-1) [A^-1]", + ], +) +def test_q_unit_canonicalization_rejects_conflicting_or_partial_tokens(raw_unit): + assert canonicalize_q_unit(raw_unit) is None + + +@pytest.mark.parametrize( + ("raw_unit", "expected"), + [ + ("Q_A^-1", "A^-1"), + ("q_nm^-1", "nm^-1"), + ("Q (nm^-1)", "nm^-1"), + ("Q [nm^-1]", "nm^-1"), + ("Q {Å⁻¹}", "A^-1"), + ], +) +def test_q_unit_canonicalization_accepts_leading_q_and_bracket_forms(raw_unit, expected): + assert canonicalize_q_unit(raw_unit) == expected + + def test_infer_q_unit_from_column_leaves_bare_q_length_unknown(): assert infer_q_unit_from_column("Q (nm)") is None assert infer_q_unit_from_column("Q (angstrom)") is None @@ -192,6 +234,441 @@ def test_read_external_1d_profile_prefers_intensity_over_id_column(tmp_path: Pat assert "i_rel" not in out +def test_read_external_1d_profile_ignores_index_before_unit_bearing_intensity(tmp_path: Path): + f = tmp_path / "profile_with_index_and_unit_intensity.csv" + f.write_text( + "index,Q (nm^-1),I_abs (cm^-1)\n" + "0,0.10,100\n" + "1,0.20,90\n" + "2,0.30,80\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_col"] == "Q (nm^-1)" + assert out["i_col"] == "I_abs (cm^-1)" + assert out["intensity_state"] == "absolute_cm^-1" + np.testing.assert_allclose(out["x"], [0.10, 0.20, 0.30]) + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +def test_read_external_1d_profile_ignores_id_before_standalone_unit_bearing_i( + tmp_path: Path, +): + f = tmp_path / "profile_with_id_and_unit_intensity.csv" + f.write_text( + "Q,id,I (cm^-1),sigma\n" + "0.10,101,100,5\n" + "0.20,102,90,4\n" + "0.30,103,80,4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_col"] == "Q" + assert out["i_col"] == "I (cm^-1)" + assert out["intensity_state"] == "absolute_cm^-1" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + np.testing.assert_allclose(out["uncertainty"], [5.0, 4.0, 4.0]) + + +def test_read_external_1d_profile_accepts_underscore_unit_intensity_after_id( + tmp_path: Path, +): + f = tmp_path / "profile_with_id_and_underscore_unit_intensity.csv" + f.write_text( + "Q,id,I_cm^-1,sigma\n" + "0.10,101,100,5\n" + "0.20,102,90,4\n" + "0.30,103,80,4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["i_col"] == "I_cm^-1" + assert out["intensity_state"] == "absolute_cm^-1" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +def test_read_external_1d_profile_selects_slash_unit_intensity_over_id( + tmp_path: Path, +): + f = tmp_path / "profile_with_id_and_slash_unit_intensity.csv" + f.write_text( + "Q,id,I/cm,sigma\n" + "0.10,101,100,5\n" + "0.20,102,90,4\n" + "0.30,103,80,4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["i_col"] == "I/cm" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +def test_read_external_1d_profile_rejects_text_header_without_supported_intensity( + tmp_path: Path, +): + f = tmp_path / "profile_without_supported_intensity.csv" + f.write_text( + "Q,id,foo\n" + "0.01,101,10\n" + "0.02,102,9\n" + "0.03,103,8\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="numeric columns"): + read_external_1d_profile(f) + + +@pytest.mark.parametrize( + ("header", "expected_unit"), + [ + ("Q 1/nm I", "nm^-1"), + ("# Q 1/nm I", "nm^-1"), + ("Q inverse nm I", "nm^-1"), + ("Q nm -1 I", "nm^-1"), + ], +) +def test_read_external_1d_profile_rejoins_space_separated_q_unit_header( + tmp_path: Path, + header: str, + expected_unit: str, +): + f = tmp_path / "space_q_unit.dat" + f.write_text( + f"{header}\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_col"] in { + "Q 1/nm", + "Q inverse nm", + "Q nm -1", + } + assert out["x_unit"] == expected_unit + np.testing.assert_allclose(out["x"], [0.10, 0.20, 0.30]) + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +@pytest.mark.parametrize("unit_phrase", ["nm - 1", "A - 1", "angstrom - 1"]) +@pytest.mark.parametrize("comment_header", [False, True]) +def test_read_external_1d_profile_rejoins_q_unit_and_keeps_slash_intensity( + tmp_path: Path, + unit_phrase: str, + comment_header: bool, +): + f = tmp_path / "space_q_unit_intensity.dat" + prefix = "# " if comment_header else "" + f.write_text( + f"{prefix}Q {unit_phrase} I/cm sigma\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == ("nm^-1" if unit_phrase.startswith("nm") else "A^-1") + assert out["i_col"] == "I/cm" + assert out["err_col"] == "sigma" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +@pytest.mark.parametrize( + ("header", "separator", "comment_header"), + [ + ('"Q 1/nm",I', ",", False), + ('"Q 1/nm",I', ",", True), + ('"Q 1/nm";"I/cm";sigma', ";", False), + ('"Q 1/nm" "I/cm" sigma', " ", False), + ], +) +def test_read_external_1d_profile_tokenizes_quoted_headers_without_losing_units( + tmp_path: Path, + header: str, + separator: str, + comment_header: bool, +): + f = tmp_path / "quoted_header.dat" + prefix = "# " if comment_header else "" + values = ( + ["1.0", "100"] + if header.endswith(",I") + else ["1.0", "100", "5"] + ) + if len(values) == 2: + rows = [ + separator.join(["1.0", "100"]), + separator.join(["2.0", "90"]), + separator.join(["3.0", "80"]), + ] + else: + rows = [ + separator.join(["1.0", "100", "5"]), + separator.join(["2.0", "90", "4"]), + separator.join(["3.0", "80", "4"]), + ] + f.write_text(f"{prefix}{header}\n" + "\n".join(rows) + "\n", encoding="utf-8") + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "nm^-1" + assert out["i_col"] in {"I", "I/cm"} + np.testing.assert_allclose(out["x"], [1.0, 2.0, 3.0]) + + +def test_read_external_1d_profile_tokenizes_quoted_parenthesized_columns( + tmp_path: Path, +): + f = tmp_path / "quoted_parenthesized_header.csv" + f.write_text( + '"Q (nm^-1)","I/cm",sigma\n' + "1.0,100,5\n2.0,90,4\n3.0,80,4\n", + encoding="utf-8", + ) + out = read_external_1d_profile(f) + assert out["x_unit"] == "nm^-1" + assert out["i_col"] == "I/cm" + assert out["err_col"] == "sigma" + np.testing.assert_allclose(out["uncertainty"], [5.0, 4.0, 4.0]) + + +@pytest.mark.parametrize( + ("header", "separator", "comment_header"), + [ + ("Q 1/nm I Error_CombinedStandard", " ", False), + ("Q 1/nm I Error_CombinedStandard", " ", True), + ("Q 1/nm,I,Error_CombinedStandard", ",", False), + ('"Q 1/nm";"I/cm";Error_CombinedStandard', ";", False), + ('"Q 1/nm" "I/cm" Error_CombinedStandard', " ", False), + ], +) +def test_read_external_1d_profile_counts_explicit_nan_physical_fields( + tmp_path: Path, + header: str, + separator: str, + comment_header: bool, +): + f = tmp_path / "explicit_nan_uncertainty.dat" + prefix = "# " if comment_header else "" + f.write_text( + f"{prefix}{header}\n" + f"1.0{separator}100{separator}NaN\n" + f"2.0{separator}90{separator}NaN\n" + f"3.0{separator}80{separator}NaN\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "nm^-1" + assert out["i_col"] in {"I", "I/cm"} + assert out["err_col"] == "Error_CombinedStandard" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + assert np.all(np.isnan(out["uncertainty"])) + + +def test_read_external_1d_profile_keeps_mixed_nan_uncertainty_aligned(tmp_path: Path): + f = tmp_path / "mixed_nan_uncertainty.csv" + f.write_text( + "Q 1/nm,I,Error_CombinedStandard\n" + "1.0,100,5\n2.0,90,NaN\n3.0,80,4\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + np.testing.assert_allclose(out["uncertainty"][::2], [5.0, 4.0]) + assert np.isnan(out["uncertainty"][1]) + + +@pytest.mark.parametrize( + ("header", "separator", "comment_header"), + [ + ("Q 1/nm I", " ", False), + ("Q 1/nm I", " ", True), + ('"Q 1/nm",I', ",", False), + ('"Q 1/nm";I', ";", False), + ], +) +def test_read_external_1d_profile_ignores_unquoted_inline_comments_in_width_scan( + tmp_path: Path, + header: str, + separator: str, + comment_header: bool, +): + f = tmp_path / "inline_comment_width.dat" + prefix = "# " if comment_header else "" + f.write_text( + f"{prefix}{header}\n" + f"0.1{separator}100 # frame one\n" + f"0.2{separator}90\n" + f"0.3{separator}80 # frame three\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "nm^-1" + np.testing.assert_allclose(out["x"], [0.1, 0.2, 0.3]) + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +def test_read_external_1d_profile_keeps_hash_inside_quoted_data_field( + tmp_path: Path, +): + f = tmp_path / "quoted_hash_width.dat" + f.write_text( + 'Q 1/nm I tag\n' + '0.1 100 "frame # one"\n' + '0.2 90 "frame # two"\n' + '0.3 80 "frame # three"\n', + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + assert out["x_unit"] == "nm^-1" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +def test_read_external_1d_profile_rejects_quoted_conflicting_q_header(tmp_path: Path): + f = tmp_path / "quoted_conflicting_q_header.csv" + f.write_text( + '"Q (nm^-1) [A^-1]",I\n' + "1.0,100\n2.0,90\n3.0,80\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError): + read_external_1d_profile(f) + + +def test_read_external_1d_profile_uses_data_width_to_keep_custom_column( + tmp_path: Path, +): + valid = tmp_path / "custom_column_valid.dat" + valid.write_text( + "Q 1/nm A I/cm sigma\n" + "0.10 1 100 5\n" + "0.20 2 90 4\n" + "0.30 3 80 4\n", + encoding="utf-8", + ) + out = read_external_1d_profile(valid) + assert out["x_col"] == "Q 1/nm" + assert out["i_col"] == "I/cm" + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + ambiguous = tmp_path / "custom_column_ambiguous.dat" + ambiguous.write_text( + "Q 1/nm A I/cm sigma\n" + "0.10 1 100\n" + "0.20 2 90\n" + "0.30 3 80\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="Malformed Q-unit delimiter"): + read_external_1d_profile(ambiguous) + + +@pytest.mark.parametrize( + "header", + [ + "Q nm-10 I Error", + "Q nm1 I Error", + "Q Q (nm^-1) I Error", + "Q invnm2 I Error", + "# Q nm-10 I Error", + "# Q nm1 I Error", + "# Q Q (nm^-1) I Error", + "# Q invnm2 I Error", + ], +) +def test_read_external_1d_profile_rejects_malformed_q_adjacency( + tmp_path: Path, + header: str, +): + f = tmp_path / "malformed_q_adjacency.dat" + f.write_text( + f"{header}\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError): + read_external_1d_profile(f) + + +@pytest.mark.parametrize("header", ["Q (nm^-1) [A^-1] I", "Q nm^-10 I"]) +def test_read_external_1d_profile_preserves_invalid_q_unit_header( + tmp_path: Path, + header: str, +): + f = tmp_path / "invalid_q_unit_header.dat" + f.write_text( + f"{header}\n" + "0.10 100 5\n" + "0.20 90 4\n" + "0.30 80 4\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Unsupported or malformed Q-unit header"): + read_external_1d_profile(f) + + +@pytest.mark.parametrize("n_columns", [2, 3]) +def test_read_external_1d_profile_keeps_numeric_data_after_description_comment( + tmp_path: Path, + n_columns: int, +): + f = tmp_path / "description_without_header.dat" + rows = [ + "0.10 100" + (" 5" if n_columns == 3 else ""), + "0.20 90" + (" 4" if n_columns == 3 else ""), + "0.30 80" + (" 4" if n_columns == 3 else ""), + ] + f.write_text("# Integrated SAXS profile\n" + "\n".join(rows) + "\n", encoding="utf-8") + + out = read_external_1d_profile(f) + + np.testing.assert_allclose(out["x"], [0.10, 0.20, 0.30]) + np.testing.assert_allclose(out["intensity"], [100.0, 90.0, 80.0]) + + +def test_read_external_1d_profile_keeps_position_fallback_for_numeric_file( + tmp_path: Path, +): + f = tmp_path / "headerless_numeric.dat" + f.write_text( + "0.01 10 1\n" + "0.02 9 1\n" + "0.03 8 1\n", + encoding="utf-8", + ) + + out = read_external_1d_profile(f) + + np.testing.assert_allclose(out["x"], [0.01, 0.02, 0.03]) + np.testing.assert_allclose(out["intensity"], [10.0, 9.0, 8.0]) + + def test_read_external_1d_profile_unnamed_third_column_not_treated_as_error(tmp_path: Path): f = tmp_path / "profile_three_cols.dat" f.write_text( diff --git a/tests/test_workbench_scientific.py b/tests/test_workbench_scientific.py index 50b1812..a390855 100644 --- a/tests/test_workbench_scientific.py +++ b/tests/test_workbench_scientific.py @@ -198,7 +198,18 @@ def test_tab3_q_nm_inverse_converts_to_q_angstrom_inverse(): assert conversion == "q_nm^-1_to_q_a^-1" -@pytest.mark.parametrize("x_col", ["Q_nm1", "Q_A1", "Q (nm)", "Q (mm^-1)"]) +@pytest.mark.parametrize( + "x_col", + [ + "Q_nm1", + "Q_A1", + "Q (nm)", + "Q (mm^-1)", + "Q_nm^-1_", + "Q_nm^-1-", + "Q (nm^-1))", + ], +) def test_tab3_rejects_sign_stripped_or_unsupported_q_unit_headers(x_col): module = _load_workbench_module() app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) @@ -208,6 +219,93 @@ def test_tab3_rejects_sign_stripped_or_unsupported_q_unit_headers(x_col): app.resolve_external_x_axis("profile.dat", profile, mode="auto", wavelength_a="") +@pytest.mark.parametrize("header", ["Q_[(nm^-1)]", "Q_Q (nm^-1)"]) +def test_tab3_parser_to_workbench_rejects_lossy_q_unit_hints( + tmp_path: Path, + header: str, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + profile_path = tmp_path / "malformed_q_header.dat" + profile_path.write_text( + f"{header} I\n0.1 10\n0.2 9\n0.3 8\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Unsupported or malformed Q-unit header"): + app.read_external_1d_profile(profile_path) + + +@pytest.mark.parametrize("comment_header", [False, True]) +def test_tab3_parser_to_workbench_converts_space_separated_q_unit_header( + tmp_path: Path, + comment_header: bool, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + profile_path = tmp_path / "space_q_unit.dat" + prefix = "# " if comment_header else "" + profile_path.write_text( + f"{prefix}Q 1/nm I\n0.10 10\n0.20 9\n0.30 8\n", + encoding="utf-8", + ) + + profile = app.read_external_1d_profile(profile_path) + q, label, conversion = app.resolve_external_x_axis( + profile_path, + profile, + mode="auto", + wavelength_a="", + ) + + np.testing.assert_allclose(q, [0.01, 0.02, 0.03]) + assert label == "Q_A^-1" + assert conversion == "q_nm^-1_to_q_a^-1" + + +@pytest.mark.parametrize("comment_header", [False, True]) +def test_tab3_parser_to_workbench_converts_quoted_q_unit_header( + tmp_path: Path, + comment_header: bool, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + profile_path = tmp_path / "quoted_q_unit.csv" + prefix = "# " if comment_header else "" + profile_path.write_text( + f'{prefix}"Q 1/nm",I\n1.0,100\n2.0,90\n3.0,80\n', + encoding="utf-8", + ) + + profile = app.read_external_1d_profile(profile_path) + q, label, conversion = app.resolve_external_x_axis( + profile_path, + profile, + mode="auto", + wavelength_a="", + ) + + np.testing.assert_allclose(q, [0.1, 0.2, 0.3]) + assert label == "Q_A^-1" + assert conversion == "q_nm^-1_to_q_a^-1" + + +@pytest.mark.parametrize("header", ["Q (nm^-1) [A^-1] I", "Q nm^-10 I"]) +def test_tab3_parser_to_workbench_rejects_invalid_space_q_unit_header( + tmp_path: Path, + header: str, +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + profile_path = tmp_path / "invalid_space_q_unit.dat" + profile_path.write_text( + f"{header}\n0.10 10\n0.20 9\n0.30 8\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="Unsupported or malformed Q-unit header"): + app.read_external_1d_profile(profile_path) + + def test_tab3_preserves_explicit_x_unit_and_rejects_unsupported_raw_hint(): module = _load_workbench_module() app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) @@ -273,6 +371,40 @@ def test_tab3_profile_x_unit_contract_has_priority_and_supports_unicode_headers( assert conversion == "none" +def test_tab3_legacy_q_unit_fallback_uses_strict_full_token_grammar(monkeypatch): + module = _load_workbench_module() + monkeypatch.setattr(module, "_core_canonicalize_q_unit", None) + monkeypatch.setattr(module, "_core_q_axis_kind", None) + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + + for raw_unit in ( + "Q_A^-1", + "q_nm^-1", + "Q (nm^-1)", + "Q {Å⁻¹}", + "nm-1", + "A-1", + "angstrom-1", + ): + q, label, _conversion = app.resolve_external_x_axis( + "profile.dat", + {"x": np.array([1.0]), "x_col": "Q", "x_unit": raw_unit}, + mode="auto", + wavelength_a="", + ) + assert label == "Q_A^-1" + assert np.isfinite(q).all() + + for raw_unit in ("nm^-10", "Q_nonsense_nm^-1", "1/nm A^-1"): + with pytest.raises(ValueError): + app.resolve_external_x_axis( + "profile.dat", + {"x": np.array([1.0]), "x_col": "Q", "x_unit": raw_unit}, + mode="auto", + wavelength_a="", + ) + + def test_workbench_merge_ignores_nan_intensity_and_keeps_unknown_sigma(): module = _load_workbench_module() app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) @@ -302,6 +434,34 @@ def test_workbench_merge_ignores_nan_intensity_and_keeps_unknown_sigma(): assert np.isnan(merged.sigma[2]) +@pytest.mark.parametrize( + "count", + [2.0, np.array([2.0, 2.0])], +) +def test_workbench_merge_uses_unit_weights_for_wrong_shaped_count(count): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + result = app.merge_integrate1d_results( + [ + SimpleNamespace( + radial=np.array([0.01, 0.02, 0.03]), + intensity=np.array([10.0, 20.0, 30.0]), + count=count, + sigma=None, + ), + SimpleNamespace( + radial=np.array([0.01, 0.02, 0.03]), + intensity=np.array([20.0, 40.0, 60.0]), + count=np.ones(3), + sigma=None, + ), + ] + ) + + np.testing.assert_allclose(result.count, [2.0, 2.0, 2.0]) + np.testing.assert_allclose(result.intensity, [15.0, 30.0, 45.0]) + + def test_instrument_signature_exposes_only_observed_fields_and_strict_mismatch( monkeypatch, ): @@ -424,6 +584,27 @@ def test_instrument_consistency_checks_reliable_poni_distance_and_pixels(monkeyp assert any("poni pixel1" in issue for issue in issues) +@pytest.mark.parametrize("tol_pct", [0.0, -0.1, np.nan, np.inf, -np.inf]) +def test_instrument_consistency_rejects_nonfinite_or_nonpositive_tolerance( + monkeypatch, tol_pct +): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + app.session_geometry_fallback = {} + monkeypatch.setattr( + module, + "_workbench_load_detector_image", + lambda *_args, **_kwargs: pytest.fail( + "invalid tolerance must fail before loading files" + ), + ) + + issues = app.check_instrument_consistency(["sample.tif"], tol_pct=tol_pct) + + assert issues + assert "有限正数" in issues[0] + + def test_instrument_signature_rejects_explicit_wavelength_energy_pair_mismatch( monkeypatch, ): @@ -1183,8 +1364,11 @@ def test_workbench_ui_contract_keeps_labels_tooltips_and_stable_format_tokens(): "*.xml", "*.h5", "*.hdf5", + "*.hdf", + "*.nxs", } - assert ".h5 .hdf5" in module.I18N["en"]["lbl_t3_formats"] + assert ".h5 .hdf5 .hdf .nxs" in module.I18N["en"]["lbl_t3_formats"] + assert ".h5 .hdf5 .hdf .nxs" in module.I18N["zh"]["lbl_t3_formats"] assert "canSAS XML" in module.I18N["en"]["tip_output_format"] assert "Q (Å⁻¹)" in module.I18N["en"]["tip_output_format"] assert "does not change output routing" in module.I18N["en"]["tip_t2_group"] @@ -1219,6 +1403,15 @@ def test_structured_output_formats_fail_closed_for_chi_and_accept_q(output_forma assert app.validate_output_format_for_axis("tsv", "Chi_deg") == "tsv" +def test_nxcansas_validation_reports_missing_h5py(monkeypatch): + module = _load_workbench_module() + app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp) + monkeypatch.setitem(sys.modules, "h5py", None) + + with pytest.raises(ValueError, match=r"h5py.*pip install saxsabs\[hdf5\]"): + app.validate_output_format_for_axis("nxcansas_h5", "Q_A^-1") + + def test_fixed_tab2_preflight_excludes_diagnostic_mu_payload(): module = _load_workbench_module() app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)