diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41b13e3..1841c44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
## [2.0.0] - Unreleased
+### Release-readiness hardening (2026-08-27)
+
+- Harden the Workbench preflight boundary with content-aware input identities,
+ language-only display refreshes, localized empty-queue/completion messages,
+ and stale-progress reset on failed Tab 2/3 runs. Update the release-readiness
+ snapshot and operator documentation; this remains an unreleased source
+ candidate.
+- Harden structured canSAS/NXcanSAS Q-axis handling to fail closed on missing
+ or unknown units, and canonicalize `1/m` to Å⁻¹. Detect NaN/Inf in headerless
+ profiles, publish canSAS XML atomically, handle Pydidas numeric tokens and
+ inline comments, and reject empty arrays in the scientific core. These are
+ engineering safeguards, not measured scientific acceptance.
+
### Fluorescence subtraction (1D)
- Add an opt-in absolute-scale fluorescence kernel
diff --git a/README.md b/README.md
index 04c2f56..4cf48de 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,15 @@
reusable data writers, and provenance checks. The result and the processing
record remain reviewable together.
-Reviewers should use the unreleased 2.0.0 tree on
-[`main`](https://github.com/D-sudoasd/SASAbs). GitHub Release
-[v1.1.1](https://github.com/D-sudoasd/SASAbs/releases/tag/v1.1.1) is an earlier
-archive and is not this candidate. Do not treat the Zenodo concept DOI as a
-version DOI for 2.0.0.
+Version status:
+
+- Current source candidate: branch [`main`](https://github.com/D-sudoasd/SASAbs),
+ version `2.0.0`, unreleased.
+- Stable archive: GitHub Release
+ [`v1.1.1`](https://github.com/D-sudoasd/SASAbs/releases/tag/v1.1.1) release assets.
+- The `2.0.0` candidate is source-only: no PyPI installation is documented, and
+ no version tag, GitHub Release, or Zenodo version archive has been created.
+ The DOI above is the project concept DOI.
Quick start ·
@@ -55,8 +59,11 @@ python -m pip install -e ".[gui]"
saxsabs-workbench --lang en
```
-The core package requires Python 3.10+, NumPy, pandas, and xraydb. The project
-does not currently document a PyPI installation.
+On Windows, `py -m pip install -e ".[gui]"` and `py saxsabs_workbench.py --lang en`
+are equivalent Python-launcher forms.
+
+The core package requires Python 3.10+, NumPy, pandas, and xraydb. The commands
+above install from the source tree.
Optional dependency groups
@@ -189,9 +196,10 @@ python scripts/check_submission_readiness.py \
```
Run the strict command from the exact branch and commit that will be submitted.
-PR #1 is already on `main`. A PASS recorded on an earlier revision does not
-cover a later commit; update `submitted_branch` and `submitted_commit` and rerun
-the gate on the revision sent to JOSS.
+The submitted branch and 40-character SHA must identify the same revision as the
+public README and paper blobs and the successful CI run. A PASS recorded for an
+earlier revision does not cover a later commit; update `submitted_branch` and
+`submitted_commit` and rerun both gates.
After the strict local gate passes, verify the same commit, branch, visible
README and paper, repository identity, and successful CI run against GitHub:
diff --git a/SASAbs.py b/SASAbs.py
index 490070d..cabe74a 100644
--- a/SASAbs.py
+++ b/SASAbs.py
@@ -331,7 +331,7 @@ def _read_package_version() -> str:
"tip_t3_kd": "For external integrated result still in relative intensity (not divided by thickness).",
"tip_t3_thk": "Only used in K/d mode. Unit: mm.",
"tip_t3_k_only": "For external integrated result already divided by thickness.",
- "tip_t3_x_mode": "'auto' requires explicit Q units (Å⁻¹ or nm⁻¹) or Chi. q_nm⁻¹ is converted to Å⁻¹; 2theta requires wavelength; unknown axes are blocked.",
+ "tip_t3_x_mode": "'auto' requires explicit Q units (Å⁻¹, nm⁻¹, or m⁻¹) or Chi. q_nm⁻¹ and q_m⁻¹ are converted to Å⁻¹; 2theta requires wavelength; unknown axes are blocked.",
"tip_t3_resume": "Skip if output exists; for resuming large batches.",
"tip_t3_overwrite": "Ignore existing results and recalculate.",
"tip_t3_meta": "Optional. Supports metadata.csv or Tab2's batch_report.csv.",
@@ -399,6 +399,22 @@ def _read_package_version() -> str:
# --- Messagebox bodies ---
"msg_meta_gen_title": "Metadata Generated",
"msg_batch_done_title": "Batch Completed",
+ "msg_batch_done_body": (
+ "Robust batch processing completed.\n"
+ "Samples succeeded: {sample_success}\n"
+ "Samples partially successful: {sample_partial}\n"
+ "Samples skipped: {sample_skip}\n"
+ "Samples failed: {sample_fail}\n"
+ "Mode summary:\n{mode_summary}\n"
+ "Output directories:\n{dir_summary}\n"
+ "Report: {report}\n"
+ "Cal2D manifest: {cal2d_manifest}\n"
+ "Tab3 metadata: {tab3_metadata}\n"
+ "Metadata: {meta}"
+ ),
+ "msg_batch_no_1d": "No 1D, sector, or texture integration was run.",
+ "msg_not_enabled": "not enabled",
+ "msg_export_failed": "export failed",
"msg_k_history_empty": "No K history yet; run calibration first.",
"msg_k_history_file_empty": "History file is empty.",
"msg_k_history_read_error": "Failed to read history: {e}",
@@ -494,6 +510,7 @@ def _read_package_version() -> str:
"reason_thk_invalid": "Thickness invalid (fixed thickness or metadata thk_mm)",
# --- Ext 1D messagebox ---
"msg_t3_queue_empty": "Queue is empty; please add external 1D files first.",
+ "msg_t2_queue_empty": "Queue is empty; please add sample files before Dry Check.",
# --- Preview info labels ---
"info_iq_sector": "Sector mode({n}): {desc}",
"info_iq_full": "Full ring (valid pixels)",
@@ -506,6 +523,9 @@ def _read_package_version() -> str:
# --- Mu tool messagebox ---
"msg_mu_wt_warn": "Total wt% = {w_tot}",
"msg_mu_fail": "μ estimation failed: {e}",
+ "status_batch_running": "Batch is running...",
+ "status_batch_completed": "Batch completed.",
+ "status_batch_failed": "Batch failed; review the error details.",
},
"zh": {
"app_title": f"{APP_NAME} v{APP_VERSION}",
@@ -754,7 +774,7 @@ def _read_package_version() -> str:
"tip_t3_kd": "适用于外部积分结果仍是相对强度(尚未除厚度)。",
"tip_t3_thk": "仅在 K/d 模式下使用。单位 mm。",
"tip_t3_k_only": "适用于外部积分结果已经做了厚度归一化。",
- "tip_t3_x_mode": "auto 要求明确的 Q 单位(Å⁻¹或nm⁻¹)或 Chi;q_nm⁻¹会换算为Å⁻¹,2theta必须提供波长,未知轴阻止输出。",
+ "tip_t3_x_mode": "auto 要求明确的 Q 单位(Å⁻¹、nm⁻¹或m⁻¹)或 Chi;q_nm⁻¹和q_m⁻¹会换算为Å⁻¹,2theta必须提供波长,未知轴阻止输出。",
"tip_t3_resume": "输出存在时跳过,适合大批量中断后继续。",
"tip_t3_overwrite": "忽略已存在结果并重算。",
"tip_t3_meta": "可选。支持 metadata.csv,或直接选择 Tab2 的 batch_report.csv。",
@@ -822,6 +842,22 @@ def _read_package_version() -> str:
# --- Messagebox bodies ---
"msg_meta_gen_title": "metadata 已生成",
"msg_batch_done_title": "批处理完成",
+ "msg_batch_done_body": (
+ "稳健批处理完成。\n"
+ "样品成功: {sample_success}\n"
+ "样品部分成功: {sample_partial}\n"
+ "样品已跳过: {sample_skip}\n"
+ "样品失败: {sample_fail}\n"
+ "模式统计:\n{mode_summary}\n"
+ "输出目录:\n{dir_summary}\n"
+ "报告: {report}\n"
+ "Cal2D manifest: {cal2d_manifest}\n"
+ "Tab3 metadata: {tab3_metadata}\n"
+ "元数据: {meta}"
+ ),
+ "msg_batch_no_1d": "未运行 1D、扇区或织构积分。",
+ "msg_not_enabled": "未启用",
+ "msg_export_failed": "导出失败",
"msg_k_history_empty": "尚无 K 历史记录,请先运行一次标定。",
"msg_k_history_file_empty": "历史文件为空。",
"msg_k_history_read_error": "读取历史失败: {e}",
@@ -917,6 +953,7 @@ def _read_package_version() -> str:
"reason_thk_invalid": "厚度无效(固定厚度或metadata thk_mm)",
# --- Ext 1D messagebox ---
"msg_t3_queue_empty": "队列为空,请先添加外部1D文件。",
+ "msg_t2_queue_empty": "队列为空,请先添加样品文件再做预检查。",
# --- Preview info labels ---
"info_iq_sector": "扇区模式({n}): {desc}",
"info_iq_full": "全环 (有效像素)",
@@ -929,6 +966,9 @@ def _read_package_version() -> str:
# --- Mu tool messagebox ---
"msg_mu_wt_warn": "总 wt% = {w_tot}",
"msg_mu_fail": "μ 估算失败: {e}",
+ "status_batch_running": "批处理运行中……",
+ "status_batch_completed": "批处理完成。",
+ "status_batch_failed": "批处理失败,请查看错误详情。",
},
}
@@ -2134,9 +2174,13 @@ def refresh_ui_language(self):
self._status_var.set(self.tr("status_ready"))
except Exception:
pass
+ self._refresh_workbench_job_status_text()
self.refresh_help_text()
- self.refresh_queue_status()
- self.refresh_external_1d_status()
+ # Language changes only redraw labels and derived display text. They
+ # do not alter the scientific configuration that the last Dry Check
+ # approved, so retain both in-memory approvals here.
+ self.refresh_queue_status(invalidate=False)
+ self.refresh_external_1d_status(invalidate=False)
def _register_i18n_widget(self, widget, key):
if not hasattr(self, "_i18n_widgets"):
@@ -5765,8 +5809,9 @@ def clear_external_1d_files(self):
self.lb_ext1d.delete(0, tk.END)
self.refresh_external_1d_status()
- def refresh_external_1d_status(self):
- self._invalidate_workbench_preflight("t3")
+ def refresh_external_1d_status(self, invalidate=True):
+ if invalidate:
+ self._invalidate_workbench_preflight("t3")
if hasattr(self, "t3_queue_info"):
total = len(getattr(self, "t3_files", []))
uniq = len(dict.fromkeys(getattr(self, "t3_files", [])))
@@ -6794,6 +6839,8 @@ def canonical_q_unit(value):
return "a^-1"
if canonical == "nm^-1":
return "nm^-1"
+ if canonical == "m^-1":
+ return "m^-1"
return None
text = unicodedata.normalize("NFKC", str(value or "").strip().lower())
@@ -6834,7 +6881,7 @@ def canonical_q_unit(value):
if not text.endswith(closer):
return None
text = text[1:-1].strip()
- unit = r"(?:a|angstrom|nm)"
+ unit = r"(?:a|angstrom|nm|m)"
matched = re.fullmatch(rf"1\s*/\s*({unit})", text)
if matched is None:
matched = re.fullmatch(rf"({unit})\s*(?:\^\s*)?-\s*1", text)
@@ -6842,7 +6889,13 @@ def canonical_q_unit(value):
matched = re.fullmatch(rf"(?:inverse|inv)\s*({unit})", text)
if matched is None:
return None
- return "nm^-1" if matched.group(1) == "nm" else "a^-1"
+ return (
+ "nm^-1"
+ if matched.group(1) == "nm"
+ else "m^-1"
+ if matched.group(1) == "m"
+ else "a^-1"
+ )
raw_name = str(profile.get("x_col", "")).strip()
normalized_name = unicodedata.normalize("NFKC", raw_name).lower()
@@ -6876,7 +6929,7 @@ def canonical_q_unit(value):
if q_unit is None:
raise ValueError(
f"外部 Q 轴单位不受支持: {profile_unit!r};"
- "仅支持明确的 A^-1 或 nm^-1。"
+ "仅支持明确的 A^-1、nm^-1 或 m^-1。"
)
# The parser's x_unit contract is stronger than a misleading column
# name or filename suffix: it explicitly identifies a Q axis.
@@ -6893,7 +6946,7 @@ def canonical_q_unit(value):
if q_unit is None:
raise ValueError(
f"Q轴单位未知或歧义: {raw_name!r};"
- "必须明确为 A^-1/nm^-1 的倒数单位(^-1、1/unit 或 inverse/inv)。"
+ "必须明确为 A^-1/nm^-1/m^-1 的倒数单位(^-1、1/unit 或 inverse/inv)。"
)
named_axis = "q_a^-1"
elif axis_kind == "two_theta":
@@ -6922,11 +6975,11 @@ def canonical_q_unit(value):
return x, "Chi_deg", "none"
if selected == "q_a^-1":
if q_unit is None:
- raise ValueError(
- "Q轴单位未知或歧义;必须明确为 A^-1 或 nm^-1。"
- )
+ raise ValueError("Q轴单位未知或歧义;必须明确为 A^-1、nm^-1 或 m^-1。")
if q_unit == "nm^-1":
return x / 10.0, "Q_A^-1", "q_nm^-1_to_q_a^-1"
+ if q_unit == "m^-1":
+ return x * 1.0e-10, "Q_A^-1", "q_m^-1_to_q_a^-1"
return x, "Q_A^-1", "none"
raw_wavelength = wavelength_a
@@ -6964,7 +7017,12 @@ def assert_external_profile_axis_compatible(self, sample_profile, reference_prof
sample_conversion = str(sample_profile.get("x_conversion", "")).strip()
reference_conversion = str(reference_profile.get("x_conversion", "")).strip()
allowed = {
- "Q_A^-1": {"none", "two_theta_deg_to_q_a^-1", "q_nm^-1_to_q_a^-1"},
+ "Q_A^-1": {
+ "none",
+ "two_theta_deg_to_q_a^-1",
+ "q_nm^-1_to_q_a^-1",
+ "q_m^-1_to_q_a^-1",
+ },
"Chi_deg": {"none"},
}
if sample_label not in allowed or sample_conversion not in allowed[sample_label]:
@@ -7702,7 +7760,9 @@ def dry_run_external_1d(self):
txt.insert(tk.END, pd.DataFrame(rows).to_string(index=False))
def run_external_1d_batch(self):
+ self._reset_workbench_job_state("t3")
try:
+ self._set_workbench_job_state("t3", "running")
files, _queue_changed = self.normalize_t3_queue()
if bool(self.t3_resume_enabled.get()):
raise ValueError(
@@ -8205,8 +8265,10 @@ def run_external_1d_batch(self):
out_dir=out_dir, report=report_path.name, meta=meta_path.name,
),
)
+ self._set_workbench_job_state("t3", "completed")
except Exception as e:
+ self._mark_workbench_job_failed("t3")
self.show_error("msg_ext_error_title", f"{e}\n{traceback.format_exc()}")
def init_tab_help(self):
@@ -10280,7 +10342,9 @@ def prepare_batch_references(self, *, ref_mode, bg_path, dark_path, monitor_mode
"dark_library": [],
}
def run_batch(self):
+ self._reset_workbench_job_state("t2")
try:
+ self._set_workbench_job_state("t2", "running")
original_queue_count = len(getattr(self, "t2_files", []) or [])
files, queue_changed = self.normalize_t2_queue()
if queue_changed:
@@ -10850,11 +10914,24 @@ def run_batch(self):
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
- mode_summary = "\n".join(
- [f"{m}: 成功{mode_ok_count[m]} / 跳过{mode_skip_count[m]} / 失败{mode_fail_count[m]}" for m in selected_modes]
- )
+ if self.language == "en":
+ mode_summary = "\n".join(
+ [
+ f"{m}: success {mode_ok_count[m]} / skipped {mode_skip_count[m]} / "
+ f"failed {mode_fail_count[m]}"
+ for m in selected_modes
+ ]
+ )
+ else:
+ mode_summary = "\n".join(
+ [
+ f"{m}: 成功{mode_ok_count[m]} / 跳过{mode_skip_count[m]} / "
+ f"失败{mode_fail_count[m]}"
+ for m in selected_modes
+ ]
+ )
if not mode_summary:
- mode_summary = "未运行 1D/扇区/织构积分"
+ mode_summary = self.tr("msg_batch_no_1d")
dir_lines = []
if export_cal2d:
dir_lines.append(f"calibrated_2d -> {cal2d_root}")
@@ -10871,29 +10948,67 @@ def run_batch(self):
dir_lines.append(f"1d_sector_sum -> {sector_combined_dir}")
dir_summary = "\n".join(dir_lines)
- messagebox.showinfo(
- "批处理完成",
- (
- "稳健批处理完成。\n"
- f"样品成功: {sample_success}\n"
- f"样品部分成功: {sample_partial}\n"
- f"样品已跳过: {sample_skip}\n"
- f"样品失败: {sample_fail}\n"
- f"模式统计:\n{mode_summary}\n"
- f"输出目录:\n{dir_summary}\n"
- f"报告: {report_path.name}\n"
- f"Cal2D manifest: {cal2d_manifest_path.name if cal2d_manifest_path else '未启用'}\n"
- f"Tab3 metadata: {tab3_meta_stamp.name if tab3_meta_stamp else '导出失败'}\n"
- f"元数据: {meta_path.name}"
+ self._show_batch_completion(
+ sample_success=sample_success,
+ sample_partial=sample_partial,
+ sample_skip=sample_skip,
+ sample_fail=sample_fail,
+ mode_summary=mode_summary,
+ dir_summary=dir_summary,
+ report=report_path.name,
+ cal2d_manifest=(
+ cal2d_manifest_path.name if cal2d_manifest_path else None
+ ),
+ tab3_metadata=(
+ tab3_meta_stamp.name if tab3_meta_stamp else None
),
+ meta=meta_path.name,
)
+ self._set_workbench_job_state("t2", "completed")
except Exception as e:
+ self._mark_workbench_job_failed("t2")
self.show_error("msg_batch_error_title", f"{e}\n{traceback.format_exc()}")
# --- Helpers ---
- def refresh_queue_status(self):
- self._invalidate_workbench_preflight("t2")
+ def _format_batch_completion_message(
+ self,
+ *,
+ sample_success,
+ sample_partial,
+ sample_skip,
+ sample_fail,
+ mode_summary,
+ dir_summary,
+ report,
+ cal2d_manifest=None,
+ tab3_metadata=None,
+ meta,
+ ):
+ """Format the localized Tab 2 completion body from run results."""
+ return self.tr("msg_batch_done_body").format(
+ sample_success=sample_success,
+ sample_partial=sample_partial,
+ sample_skip=sample_skip,
+ sample_fail=sample_fail,
+ mode_summary=mode_summary,
+ dir_summary=dir_summary,
+ report=report,
+ cal2d_manifest=cal2d_manifest or self.tr("msg_not_enabled"),
+ tab3_metadata=tab3_metadata or self.tr("msg_export_failed"),
+ meta=meta,
+ )
+
+ def _show_batch_completion(self, **summary):
+ """Show the Tab 2 completion dialog through the I18N message key."""
+ self.show_info(
+ "msg_batch_done_title",
+ self._format_batch_completion_message(**summary),
+ )
+
+ def refresh_queue_status(self, invalidate=True):
+ if invalidate:
+ self._invalidate_workbench_preflight("t2")
if hasattr(self, "t2_queue_info"):
total = len(getattr(self, "t2_files", []))
uniq = len(dict.fromkeys(getattr(self, "t2_files", [])))
@@ -10954,21 +11069,96 @@ def _preflight_var_value(value, default=None):
return str(value)
@staticmethod
- def _preflight_file_identity(path):
+ def _preflight_var_bool(value):
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @staticmethod
+ def _preflight_file_identity(path, *, active=True):
text = str(path or "").strip()
+ if not active:
+ return {
+ "path": "",
+ "raw_path": text,
+ "exists": False,
+ "size": None,
+ "mtime_ns": None,
+ "sha256": None,
+ "identity_active": False,
+ "identity_valid": None,
+ "identity_error": "input not enabled",
+ }
if not text:
- return {"path": "", "exists": False}
+ # An active input slot with no path is a missing identity. Optional
+ # slots call this helper with active=False when they are disabled.
+ return {
+ "path": "",
+ "raw_path": "",
+ "exists": False,
+ "size": None,
+ "mtime_ns": None,
+ "sha256": None,
+ "identity_active": True,
+ "identity_valid": False,
+ "identity_error": "path not supplied",
+ }
+
+ resolved = None
+
+ def failed(*, exists, error, stat_result=None):
+ return {
+ "path": str(resolved) if resolved is not None else text,
+ "raw_path": text,
+ "exists": bool(exists),
+ "size": int(stat_result.st_size) if stat_result is not None else None,
+ "mtime_ns": int(stat_result.st_mtime_ns) if stat_result is not None else None,
+ "sha256": None,
+ "identity_active": True,
+ "identity_valid": False,
+ "identity_error": str(error),
+ }
+
try:
resolved = Path(text).expanduser().resolve()
- stat = resolved.stat()
+ before = resolved.stat()
+ if not resolved.is_file():
+ return failed(exists=True, error="path is not a regular file", stat_result=before)
+
+ digest = hashlib.sha256()
+ bytes_read = 0
+ with resolved.open("rb") as stream:
+ while True:
+ chunk = stream.read(1024 * 1024)
+ if not chunk:
+ break
+ digest.update(chunk)
+ bytes_read += len(chunk)
+
+ after = resolved.stat()
+ if (
+ bytes_read != int(before.st_size)
+ or int(before.st_size) != int(after.st_size)
+ or int(before.st_mtime_ns) != int(after.st_mtime_ns)
+ ):
+ return failed(
+ exists=True,
+ error="file changed while hashing",
+ stat_result=after,
+ )
return {
"path": str(resolved),
+ "raw_path": text,
"exists": True,
- "size": int(stat.st_size),
- "mtime_ns": int(stat.st_mtime_ns),
+ "size": int(after.st_size),
+ "mtime_ns": int(after.st_mtime_ns),
+ "sha256": digest.hexdigest(),
+ "identity_active": True,
+ "identity_valid": True,
+ "identity_error": None,
}
- except OSError:
- return {"path": text, "exists": False}
+ except (OSError, RuntimeError) as exc:
+ return failed(exists=False, error=f"file identity unavailable: {exc}")
def _preflight_calibration_identity(self):
context = getattr(self, "calibration_context", None)
@@ -10978,9 +11168,14 @@ def _preflight_calibration_identity(self):
fingerprint = str(context.fingerprint())
except (AttributeError, TypeError, ValueError):
fingerprint = None
+ record_path = str(getattr(self, "calibration_record_path", None) or "")
return {
"context_fingerprint": fingerprint,
- "record_path": str(getattr(self, "calibration_record_path", None) or ""),
+ "record_path": record_path,
+ "record_path_identity": self._preflight_file_identity(
+ record_path,
+ active=bool(record_path.strip()),
+ ),
"provenance_complete": self._preflight_var_value(
getattr(self, "calibration_record_provenance_complete", None)
),
@@ -10992,20 +11187,37 @@ def _preflight_calibration_identity(self):
),
}
- def _preflight_global_config(self):
+ def _preflight_global_config(self, *, tab=None, reference_mode=None):
global_vars = getattr(self, "global_vars", {}) or {}
values = {
key: self._preflight_var_value(value)
for key, value in sorted(global_vars.items())
}
+
+ if reference_mode is None:
+ reference_mode = self._preflight_var_value(
+ getattr(self, "t2_ref_mode", None), default=""
+ )
+ reference_mode = str(reference_mode or "").strip().lower()
for key in ("poni_path", "bg_path", "dark_path", "mask_path", "flat_path"):
if key in values:
+ raw_value = values[key]
+ if key == "poni_path":
+ active = tab == "t2" and bool(str(raw_value or "").strip())
+ elif key in {"bg_path", "dark_path"}:
+ active = tab == "t2" and reference_mode == "fixed"
+ else:
+ active = tab == "t2" and bool(str(raw_value or "").strip())
+
if key in {"bg_path", "dark_path"}:
- paths = str(values[key] or "").split(";")
+ paths = self.split_path_list(raw_value)
+ if not paths:
+ paths = [raw_value]
else:
- paths = [values[key]]
+ paths = [raw_value]
values[f"{key}_identity"] = [
- self._preflight_file_identity(path) for path in paths
+ self._preflight_file_identity(path, active=active)
+ for path in paths
]
return values
@@ -11030,8 +11242,22 @@ def _t2_preflight_config(self):
for name in names
}
calc_mode = str(config.get("t2_calc_mode") or "fixed").strip().lower()
+ reference_mode = str(config.get("t2_ref_mode") or "").strip().lower()
if calc_mode != "auto":
config["t2_mu"] = None
+ for name in ("t2_mask_path", "t2_flat_path"):
+ config[f"{name}_identity"] = self._preflight_file_identity(
+ config[name], active=bool(str(config[name] or "").strip())
+ )
+ fluo_method = str(config.get("t2_fluo_method") or "").strip().lower()
+ fluo_enabled = self._preflight_var_bool(config.get("t2_fluo_enabled"))
+ config["t2_fluo_path_identity"] = self._preflight_file_identity(
+ config["t2_fluo_path"],
+ active=(
+ fluo_enabled
+ and fluo_method == "measured"
+ ),
+ )
files = list(dict.fromkeys(str(item) for item in getattr(self, "t2_files", [])))
bg_files = list(
dict.fromkeys(str(item) for item in getattr(self, "t2_bg_candidates", []))
@@ -11047,11 +11273,17 @@ def _t2_preflight_config(self):
else None
),
"files": [self._preflight_file_identity(path) for path in files],
- "bg_candidates": [self._preflight_file_identity(path) for path in bg_files],
+ "bg_candidates": [
+ self._preflight_file_identity(path, active=reference_mode == "auto")
+ for path in bg_files
+ ],
"dark_candidates": [
- self._preflight_file_identity(path) for path in dark_files
+ self._preflight_file_identity(path, active=reference_mode == "auto")
+ for path in dark_files
],
- "global": self._preflight_global_config(),
+ "global": self._preflight_global_config(
+ tab="t2", reference_mode=config.get("t2_ref_mode")
+ ),
"calibration": self._preflight_calibration_identity(),
})
return config
@@ -11073,19 +11305,31 @@ def _t3_preflight_config(self):
name: self._preflight_var_value(getattr(self, name, None))
for name in names
}
+ pipeline_mode = str(config.get("t3_pipeline_mode") or "").strip().lower()
+ buffer_enabled = self._preflight_var_bool(config.get("t3_buffer_enabled"))
+ fluo_method = str(config.get("t3_fluo_method") or "").strip().lower()
+ fluo_enabled = self._preflight_var_bool(config.get("t3_fluo_enabled"))
files = list(dict.fromkeys(str(item) for item in getattr(self, "t3_files", [])))
- for name in (
- "t3_meta_csv_path",
- "t3_bg1d_path",
- "t3_dark1d_path",
- "t3_buffer_path",
- "t3_fluo_path",
- ):
- config[f"{name}_identity"] = self._preflight_file_identity(config[name])
+ active_paths = {
+ "t3_meta_csv_path": pipeline_mode == "raw"
+ and bool(str(config["t3_meta_csv_path"] or "").strip()),
+ "t3_bg1d_path": pipeline_mode == "raw",
+ "t3_dark1d_path": pipeline_mode == "raw"
+ and bool(str(config["t3_dark1d_path"] or "").strip()),
+ "t3_buffer_path": buffer_enabled,
+ "t3_fluo_path": (
+ fluo_enabled
+ and fluo_method == "measured"
+ ),
+ }
+ for name, active in active_paths.items():
+ config[f"{name}_identity"] = self._preflight_file_identity(
+ config[name], active=active
+ )
config.update({
"schema": "saxsabs-workbench-tab3-preflight-v1",
"files": [self._preflight_file_identity(path) for path in files],
- "global": self._preflight_global_config(),
+ "global": self._preflight_global_config(tab="t3"),
"calibration": self._preflight_calibration_identity(),
})
return config
@@ -11101,6 +11345,68 @@ def _invalidate_workbench_preflight(self, tab):
except (AttributeError, tk.TclError):
pass
+ def _set_workbench_job_state(self, tab, state):
+ """Record a small, testable lifecycle state for a Tab 2/3 job."""
+ normalized_state = str(state)
+ setattr(self, f"{tab}_job_status", normalized_state)
+ if normalized_state in {"running", "completed", "failed"}:
+ self._workbench_last_job_tab = tab
+ status_keys = {
+ "running": "status_batch_running",
+ "completed": "status_batch_completed",
+ "failed": "status_batch_failed",
+ }
+ key = status_keys.get(normalized_state)
+ if key is None or not hasattr(self, "_status_var"):
+ return
+ try:
+ self._status_var.set(self.tr(key))
+ except (AttributeError, tk.TclError, TypeError, ValueError):
+ pass
+
+ def _refresh_workbench_job_status_text(self):
+ """Re-localize the visible status bar without changing job state."""
+ if not hasattr(self, "_status_var"):
+ return
+ status_keys = {
+ "running": "status_batch_running",
+ "completed": "status_batch_completed",
+ "failed": "status_batch_failed",
+ }
+ preferred = getattr(self, "_workbench_last_job_tab", None)
+ tabs = [preferred] if preferred in {"t2", "t3"} else []
+ tabs.extend(tab for tab in ("t2", "t3") if tab not in tabs)
+ for tab in tabs:
+ state = getattr(self, f"{tab}_job_status", None)
+ key = status_keys.get(str(state))
+ if key is None:
+ continue
+ try:
+ self._status_var.set(self.tr(key))
+ except (AttributeError, tk.TclError, TypeError, ValueError):
+ pass
+ return
+
+ def _reset_workbench_job_state(self, tab):
+ """Clear stale progress before any new preflight or execution work."""
+ bar = getattr(self, "prog_bar" if tab == "t2" else "t3_prog_bar", None)
+ if bar is not None:
+ try:
+ bar["value"] = 0
+ except (AttributeError, KeyError, tk.TclError, TypeError, ValueError):
+ pass
+ self._set_workbench_job_state(tab, "idle")
+
+ def _mark_workbench_job_failed(self, tab):
+ """Never leave a failed job displaying the previous run's 100 percent."""
+ bar = getattr(self, "prog_bar" if tab == "t2" else "t3_prog_bar", None)
+ if bar is not None:
+ try:
+ bar["value"] = 0
+ except (AttributeError, KeyError, tk.TclError, TypeError, ValueError):
+ pass
+ self._set_workbench_job_state(tab, "failed")
+
def _bind_preflight_invalidation(self, tab, variables):
for variable in variables:
if variable is None or not hasattr(variable, "trace_add"):
@@ -11129,6 +11435,28 @@ def _require_current_workbench_preflight(self, tab):
if require_current_preflight is None:
raise RuntimeError("Run blocked: workbench preflight safety helper is unavailable.")
config = self._t2_preflight_config() if tab == "t2" else self._t3_preflight_config()
+ invalid_paths = []
+
+ def collect(value):
+ if isinstance(value, dict):
+ if "identity_valid" in value and "path" in value:
+ if value.get("identity_valid") is False:
+ invalid_paths.append(
+ str(value.get("path") or "")
+ )
+ for nested in value.values():
+ collect(nested)
+ elif isinstance(value, (list, tuple)):
+ for nested in value:
+ collect(nested)
+
+ collect(config)
+ if invalid_paths:
+ preview = ", ".join(dict.fromkeys(invalid_paths))
+ raise RuntimeError(
+ "Run blocked: input file identity is missing, unreadable, or changed "
+ f"({preview}); run Dry Check again."
+ )
approval = getattr(self, f"{tab}_preflight_approval", None)
return require_current_preflight(approval, config)
@@ -11172,6 +11500,7 @@ def _preflight_label_text(self, gate):
def dry_run(self):
if not self.t2_files:
+ self.show_info("msg_preview_title", self.tr("msg_t2_queue_empty"))
return
original_queue_count = len(self.t2_files)
files, queue_changed = self.normalize_t2_queue()
@@ -12405,9 +12734,13 @@ def _get_std_reference_data(self):
)
if prepared.get("x_label") != "Q_A^-1" or prepared.get(
"x_conversion"
- ) not in {"none", "q_nm^-1_to_q_a^-1"}:
+ ) not in {
+ "none",
+ "q_nm^-1_to_q_a^-1",
+ "q_m^-1_to_q_a^-1",
+ }:
raise ValueError(
- "标准参考曲线必须明确标记为 Q 轴(A^-1 或 nm^-1);"
+ "标准参考曲线必须明确标记为 Q 轴(A^-1、nm^-1 或 m^-1);"
"缺失、chi 或 2theta 轴语义均不允许用于 K 标定。"
)
q_user = prepared["x"]
diff --git a/SUBMISSION_READINESS.md b/SUBMISSION_READINESS.md
index 9a48fb1..6248112 100644
--- a/SUBMISSION_READINESS.md
+++ b/SUBMISSION_READINESS.md
@@ -1,29 +1,32 @@
# Submission readiness snapshot
-Updated: 16 August 2026 (Asia/Shanghai)
+Updated: 27 August 2026 (Asia/Shanghai)
-Review the unreleased 2.0.0 tree on `main`, not GitHub Release v1.1.1. Do not
-create `v2.0.0`, a GitHub Release, or a Zenodo version archive during review.
+Review the current unreleased 2.0.0 source candidate on `main`; the stable
+archive is GitHub Release v1.1.1 and its release assets. `v2.0.0` remains
+unreleased: do not create its tag, GitHub Release, or Zenodo version archive
+during this review.
## Locally verified
-- Full source suite: PASS in a fully provisioned Python 3.13 environment; exact
- count and duration are retained in the dated external validation record.
-- Ruff: root modules, package, tests, paper scripts, and submission gate pass.
+- Full source suite: PASS under Python 3.11 and 3.12, with
+ `py -3.11 -B -m pytest -q -p no:cacheprovider --tb=short -W error` and the
+ equivalent Python 3.12 command each reporting `1155 passed`.
+- Full repository Ruff check: PASS.
+- Current `git diff --check`: PASS.
+- Distribution smoke from a clean temporary clone outside the checkout: sdist
+ and wheel builds, fresh-venv installation of `wheel[gui,hdf5]`, CLI/import/
+ `pip check`, and the `minimal_2d` synthetic smoke all PASS. The temporary
+ clone path is intentionally omitted because it is not durable evidence.
+- Python 3.10 and 3.13 remain pending the remote CI matrix; the local full-suite
+ evidence above covers only Python 3.11 and 3.12.
- README: 5 local images and all local links resolve; SVG/image audit passes.
- Minimal 2D example: 9×9 homemade radial average (not pyFAI) recovers planted
K and sample maximum relative errors of `0.001933697...`; CSV, TSV, XML, and
- HDF5 outputs are written with unknown uncertainty.
-- Fresh-copy distribution build: wheel and sdist PASS from a source tree
- outside every Git checkout. The exact archive inventory is retained in the
- dated external validation record; the sdist includes README assets,
- workflows, docs, examples, tests, and paper sources.
-- Installed-wheel smoke: CLI reports `saxsabs 2.0.0`; `SASAbs`,
- `saxs_mpl_style`, and `saxsabs` import from the temporary environment; the
- copied minimal example passes outside the checkout. A fresh Python 3.13
- environment resolves the declared GUI/HDF5 extras with no broken
- requirements.
-- Paper: 1100-word body by the documented Pandoc method; 16 references; current
+ HDF5 outputs are written with unknown uncertainty. This synthetic smoke is
+ an engineering/reproducibility check, not BL19B2 measured scientific
+ acceptance.
+- Paper: 1228-word body by the documented Pandoc method; 16 references; current
Inara TeX and well-formed JATS resolve both figures.
- Review PDF: the official CI paper job produces a five-page draft whose pages,
bounds, figures, citations, and embedded fonts have been visually checked.
@@ -56,6 +59,9 @@ create `v2.0.0`, a GitHub Release, or a Zenodo version archive during review.
6. Before submission, verify that the public GitHub description, homepage
concept DOI, visible README, submitted branch, and green CI all identify the
exact candidate revision.
+7. Complete measured beamline/scientific acceptance with archived raw inputs,
+ repeatability, and an independent comparison; synthetic validation and
+ engineering tests do not satisfy this gate.
Run the strict decision gate with Pandoc available:
```bash
@@ -64,10 +70,10 @@ python scripts/check_submission_readiness.py \
--manual-confirmations path/to/submission-confirmations.json
```
-The gate must run on the exact branch and commit submitted to JOSS. PR #1 is
-already on `main`; rerun the gate on the clean `main` commit that will be
-submitted and record `submitted_branch` and `submitted_commit` accordingly.
-Evidence from an earlier revision is not evidence for a later commit.
+The gate must run on the exact branch and commit submitted to JOSS. Record the
+submitted branch and 40-character SHA, and require that the local/public
+README, paper blobs, and successful CI run all resolve to that same revision.
+Evidence from an earlier commit does not cover a later commit.
After that local PASS, run:
@@ -83,7 +89,8 @@ editorialbot branch command when the paper is not on `main`.
The current strict result is intentionally **FAIL** because the paper still has
four author-input placeholders, no confirmed corresponding author, and no paper
-email. The mechanical preflight passes when
+email. No research-use evidence or measured scientific acceptance is recorded
+as complete. The mechanical preflight passes when
`--allow-author-placeholders --as-of 2026-08-26` is used; this override is not a
submission authorization.
diff --git a/codemeta.json b/codemeta.json
index 8931b08..e82e530 100644
--- a/codemeta.json
+++ b/codemeta.json
@@ -40,7 +40,7 @@
"SRM 3600"
],
"dateCreated": "2026-02-25",
- "dateModified": "2026-08-16",
+ "dateModified": "2026-08-27",
"developmentStatus": "active",
"softwareRequirements": [
"numpy >= 1.24",
diff --git a/docs/architecture.md b/docs/architecture.md
index 157810e..6c08edf 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -68,11 +68,18 @@
control and both Tab 2/Tab 3 existence-only resume controls are UI-disabled;
forced values make Dry Check BLOCKED and are rejected again at Run. K and μ
are read-only in Tab 2, and K is read-only in Tab 3.
-- Workbench file identities currently bind resolved path, size, and mtime, not a
- content SHA-256 for every selected source. `CAUTION` currently permits Run
- without a separately persisted acknowledgement; these are deliberate open
- boundaries, not properties of the strict runners. BG/Dark reference-library
- mutations explicitly invalidate the in-memory Tab 2 approval.
+- Workbench identities bind resolved path, size, mtime, and a streaming SHA-256
+ for selected queue files and currently active configured inputs. A disabled
+ optional field may retain a stale raw path, but its identity is normalized as
+ disabled: it is not hashed and does not block Run. When that input is enabled,
+ a missing, unreadable, or changing file is unverified and blocks Run; replacing
+ content at the same path with the same size/mtime changes the preflight
+ fingerprint. Changing a switch or configuration still invalidates approval;
+ language refresh only redraws display text and retains current Tab 2/Tab 3
+ approvals. `CAUTION` currently permits Run without a separately persisted
+ acknowledgement; these are deliberate open boundaries, not properties of the
+ strict runners. BG/Dark reference-library mutations explicitly invalidate the
+ in-memory Tab 2 approval.
- Tab 3 raw correction is disabled. Formal K/Kd accepts only an explicitly
reduced `relative` profile; `raw_counts`, `absolute_cm^-1`, and `ambiguous`
states fail closed. K/d requires `d > 0`; K-only applies K without repeating
@@ -149,7 +156,8 @@
- **Implemented**: normalization, header parsing, external 1D parsing, robust K
estimation, NIST 30 keV material core, Elam diagnostic calculator, 1D
intensity ledger, signed-in-memory Workbench preflight, fixed-thickness
- enforcement, disabled legacy/resume controls, exact K-only/Kd/buffer gates,
+ enforcement, content-aware source identities, disabled legacy/resume controls,
+ exact K-only/Kd/buffer gates,
absolute-buffer validation, optional absolute 1D fluorescence subtraction,
provenance-aware scrollable μ UI, disabled Tab 3 raw mode, screen-aware
startup, strict BL19B2 workflows, standard writers, bilingual GUI, CLI, CI,
@@ -164,7 +172,9 @@
is disabled rather than treated as safe.
- **Desktop operation**: long GUI jobs run on the Tk event thread and are not
cancellable. Users should prefer headless workflows for unattended or large
- campaigns. `CAUTION` remains visible but is not separately persisted as an
+ campaigns. Tab 2/Tab 3 progress resets at job entry and is marked failed on
+ an outer run error, but whole-job atomic publication is not implemented.
+ `CAUTION` remains visible but is not separately persisted as an
acknowledgement.
- **Input resources**: Workbench and headless detector readers share
`saxsabs.io.detector_images`. Reviewers should still use the documented
diff --git a/docs/bl19b2_abs2d_batch_runbook.md b/docs/bl19b2_abs2d_batch_runbook.md
index d7bd376..dcb0a15 100644
--- a/docs/bl19b2_abs2d_batch_runbook.md
+++ b/docs/bl19b2_abs2d_batch_runbook.md
@@ -211,10 +211,32 @@ scientific outputs silently.
## Reuse Command
-For a new beamtime, copy `examples/bl19b2_abs2d_template/processing_config.example.yml`,
-edit the paths, run a dry scan first, then run the full export.
+For a new beamtime, copy `examples/bl19b2_abs2d_template/processing_config.example.yml`
+and edit the paths. Always use the following two-step sequence: inspect the
+dry-run result first, then repeat the same command without `--dry-run` only
+after the inputs and planned output root have been confirmed.
-Template command:
+Step 1 — preflight only (no formal output publication):
+
+```powershell
+$env:PYTHONPATH='src'
+python -m saxsabs.cli bl19b2-abs2d `
+ --input-root '\datXXX' `
+ --pydidas-cali-yaml '\datXXX\reference_saxs\Cali.yaml' `
+ --output-root '\datXXX_absolute_corrected_2D' `
+ --monitor-mode rate `
+ --mu 20.2 `
+ --standard-key SRM3600 `
+ --correct-solid-angle-for-k `
+ --no-polarization-correction `
+ --dry-run
+```
+
+Review the returned status, discovered sample inventory, geometry/mask
+selection, thickness and uncertainty gates, and output paths. Correct any
+failure or unexpected classification before continuing.
+
+Step 2 — formal run after explicit confirmation:
```powershell
$env:PYTHONPATH='src'
diff --git a/docs/joss-submission-checklist.md b/docs/joss-submission-checklist.md
index f7fa960..973203b 100644
--- a/docs/joss-submission-checklist.md
+++ b/docs/joss-submission-checklist.md
@@ -1,7 +1,9 @@
# JOSS submission checklist
This checklist follows the current JOSS author and reviewer documentation,
-accessed 16 August 2026:
+accessed 27 August 2026. It is a release-readiness snapshot for the unreleased
+2.0.0 source candidate on `main`; the stable archived assets remain Release
+v1.1.1.
- [Submission requirements](https://joss.readthedocs.io/en/latest/submitting.html)
- [Paper format](https://joss.readthedocs.io/en/latest/paper.html)
@@ -11,9 +13,10 @@ accessed 16 August 2026:
## Pre-review screening gates
- [ ] **More than six months of public development.** GitHub reports that this
- repository was created on 25 February 2026. The date gate is therefore not
- satisfied on 16 August 2026; 26 August 2026 is the first conservative
- submission date, provided public development remains active.
+ repository was created on 25 February 2026. The first conservative
+ eligibility date is 26 August 2026; the author must still recheck the
+ public history on the actual submission date and keep this gate open until
+ that evidence is confirmed.
- [ ] **Demonstrated research use.** The repository contains a concrete BL19B2
workflow and reproducible synthetic validation material, but the author
must supply evidence that the software has been used in research. Claims
@@ -43,6 +46,8 @@ accessed 16 August 2026:
- [ ] Immediately before submission, record green push and Draft-PR runs for
the exact submitted HEAD in the dated external validation record. Do not
embed a self-referential commit hash in this tracked checklist.
+- [ ] Confirm that the submitted branch and 40-character SHA identify the same
+ revision as the visible public README, paper blobs, and successful CI run.
- [ ] Verify that the public repository description, homepage concept DOI,
visible README, and submitted branch identify the same candidate.
- [ ] Run `scripts/check_public_candidate.py` against the completed confirmation
@@ -75,6 +80,9 @@ accessed 16 August 2026:
acknowledgements, funding, conflicts of interest, and contribution roles.
- [ ] The author confirms the complete AI disclosure and human review statement.
- [ ] The author supplies research-use evidence suitable for the impact section.
+- [ ] Measured beamline/scientific acceptance is archived with raw inputs,
+ repeatability, and an independent comparison; synthetic validation and
+ engineering tests do not satisfy this gate.
- [x] The current official Inara workflow converts the paper to TeX and
well-formed JATS with citations and figures resolved.
- [x] The current candidate PDF was built from Inara-generated TeX with
diff --git a/examples/manual-verification.md b/examples/manual-verification.md
index d0a5709..1b9dc30 100644
--- a/examples/manual-verification.md
+++ b/examples/manual-verification.md
@@ -14,7 +14,7 @@ cannot be fully public.
1. Install package:
```bash
- pip install -e .[dev,hdf5]
+ pip install -e ".[dev,hdf5]"
```
2. Run tests:
@@ -104,12 +104,23 @@ front end to the strict BL19B2 campaign runner.
one tracked scientific parameter or source path and confirm Run immediately
disables. Add BG/Dark files, add either library recursively, and clear the
libraries; every mutation must immediately invalidate Tab 2 approval. Run
- must also reject a stale approval if a file's recorded size or modification
- time changes after Dry Check.
+ must also reject a stale approval if a selected queue file or currently active
+ configured input's content, size, or modification time changes after Dry
+ Check. Replace a fixture with different bytes while preserving its size and
+ mtime to verify the streaming SHA-256 catches the replacement. Missing,
+ unreadable, or changing-while-read active files must fail closed rather than
+ become valid identities. Leave a stale missing path in a disabled optional
+ buffer/fluorescence field and confirm it does not block; then enable that
+ option without changing the path and confirm the active identity blocks Run.
+ Changing a switch or configuration must invalidate approval. Toggle the UI
+ language and confirm the approval remains current; this display-only action
+ must not invalidate Tab 2/Tab 3 preflight.
5. Repeat with BLOCKED fixtures and confirm Run remains disabled. Include Tab 3
K/d with blank, non-finite, zero, and negative thickness. Record that a
CAUTION result currently permits Run without a separately persisted
- acknowledgement; this remains an open release gate.
+ acknowledgement; this remains an open release gate. Set a prior progress bar
+ to 100%, trigger a preflight failure at each Run entry, and confirm the bar
+ returns to 0 with a visible failed-job status.
6. In the material calculator, select the NIST 30 keV source and verify:
- Ti-24Nb-4Zr-8Sn: `74.550355 cm^-1`
@@ -202,13 +213,18 @@ These capabilities are outside the current Workbench support contract. They are
not claimed by the README or paper; use the strict headless workflow where
applicable:
-- formal multi-folder/per-sample fixed-thickness campaigns have a Workbench
- owner equivalent to the strict CLI/batch campaign;
-- Workbench and strict BL19B2 runner use one shared scientific kernel;
-- Workbench output root has an owner manifest, atomic campaign publication, and
- content-signature resume (existence-only resume must remain disabled);
-- Workbench preflight binds critical file content hashes and persists explicit
- CAUTION acceptance;
+- formal multi-folder/per-sample fixed-thickness campaigns do not have a
+ Workbench owner equivalent to the strict CLI/batch campaign;
+- Workbench and strict BL19B2 runner do not use one shared campaign-level
+ scientific kernel;
+- Workbench output root does not have an owner manifest, atomic campaign
+ publication, or content-signature resume (existence-only resume must remain
+ disabled);
+- Workbench preflight binds SHA-256 content identities for selected queue files
+ and currently active configured inputs. Disabled optional fields may retain a
+ stale path without hashing or blocking; enabling them makes missing,
+ unreadable, or changing inputs fail closed. Configuration changes still
+ invalidate approval, and explicit CAUTION acceptance is not persisted;
- all FabIO readers pass OS-level handle audits on every Windows workstation
(unit tests now cover the shared copy-and-close helper; a full desktop
handle audit remains a local check);
diff --git a/examples/minimal_2d/README.md b/examples/minimal_2d/README.md
index c08d92d..a8c936d 100644
--- a/examples/minimal_2d/README.md
+++ b/examples/minimal_2d/README.md
@@ -24,7 +24,7 @@ Expected key result:
- `summary.json` with `k_relative_error < 0.005` and
`sample_max_relative_error < 0.01`
- `absolute_profile.csv`, `absolute_profile.tsv`, `absolute_profile.xml`
-- `absolute_profile.h5` if `h5py` is installed (`pip install -e .[hdf5]`)
+- `absolute_profile.h5` if `h5py` is installed (`pip install -e ".[hdf5]"`)
The example recovers a planted synthetic $K$ and sample curve on a 9×9 array
using a homemade integer-bin radial average (not pyFAI), writes labeled
diff --git a/src/saxsabs/cli.py b/src/saxsabs/cli.py
index 2b5ea80..8d590b6 100644
--- a/src/saxsabs/cli.py
+++ b/src/saxsabs/cli.py
@@ -471,6 +471,9 @@ def _normalize_q_profile(
if source_unit == "nm^-1":
x = x / 10.0
conversion = "nm^-1_to_A^-1"
+ elif source_unit == "m^-1":
+ x = x * 1.0e-10
+ conversion = "m^-1_to_A^-1"
updated = dict(profile)
updated["x"] = x.copy()
diff --git a/src/saxsabs/core/buffer_subtraction.py b/src/saxsabs/core/buffer_subtraction.py
index abfdd5b..c0c0719 100644
--- a/src/saxsabs/core/buffer_subtraction.py
+++ b/src/saxsabs/core/buffer_subtraction.py
@@ -66,6 +66,8 @@ def _as_1d_float_array(name: str, values: np.ndarray | None, *, require_finite:
arr = np.asarray(values, dtype=np.float64)
if arr.ndim != 1:
raise ValueError(f"{name} must be a 1-D array")
+ if arr.size == 0:
+ raise ValueError(f"{name} must not be empty")
if require_finite and not np.all(np.isfinite(arr)):
raise ValueError(f"{name} contains non-finite values")
return arr
diff --git a/src/saxsabs/core/detector_reduction.py b/src/saxsabs/core/detector_reduction.py
index 5185040..aa375e6 100644
--- a/src/saxsabs/core/detector_reduction.py
+++ b/src/saxsabs/core/detector_reduction.py
@@ -82,6 +82,8 @@ def normalize_detector_frame(
dark_arr = np.asarray(dark, dtype=np.float64)
if image_arr.shape != dark_arr.shape:
raise ValueError(f"dark shape mismatch: {dark_arr.shape} vs {image_arr.shape}")
+ if image_arr.size == 0:
+ raise ValueError("detector image and dark must not be empty")
if not np.all(np.isfinite(image_arr)):
raise ValueError("detector image contains non-finite values")
if not np.all(np.isfinite(dark_arr)):
diff --git a/src/saxsabs/core/fluorescence_subtraction.py b/src/saxsabs/core/fluorescence_subtraction.py
index 49cbb42..36ed588 100644
--- a/src/saxsabs/core/fluorescence_subtraction.py
+++ b/src/saxsabs/core/fluorescence_subtraction.py
@@ -87,6 +87,8 @@ def _as_1d_float_array(
arr = np.asarray(values, dtype=np.float64)
if arr.ndim != 1:
raise ValueError(f"{name} must be a 1-D array")
+ if arr.size == 0:
+ raise ValueError(f"{name} must not be empty")
if require_finite and not np.all(np.isfinite(arr)):
raise ValueError(f"{name} contains non-finite values")
return arr
diff --git a/src/saxsabs/core/uncertainty.py b/src/saxsabs/core/uncertainty.py
index 1afab6a..e878d1a 100644
--- a/src/saxsabs/core/uncertainty.py
+++ b/src/saxsabs/core/uncertainty.py
@@ -77,6 +77,8 @@ def propagate_absolute_uncertainty(
intensity_arr = np.asarray(intensity, dtype=np.float64)
if intensity_arr.ndim == 0:
intensity_arr = intensity_arr.reshape(1)
+ if intensity_arr.size == 0:
+ raise ValueError("intensity must not be empty")
if not np.all(np.isfinite(intensity_arr)):
raise ValueError("intensity must contain only finite values")
shape = intensity_arr.shape
diff --git a/src/saxsabs/io/parsers.py b/src/saxsabs/io/parsers.py
index 5c32ceb..5e4e4d8 100644
--- a/src/saxsabs/io/parsers.py
+++ b/src/saxsabs/io/parsers.py
@@ -39,6 +39,31 @@
FLOAT_PATTERN = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
COMMA_THOUSANDS_PATTERN = re.compile(r"(? bool:
+ """Return whether a text token is numeric syntax for header detection.
+
+ Explicit non-finite spellings are accepted here only to distinguish a
+ headerless numeric first row from a textual header. Downstream numeric
+ parsing still applies its finite-value and uncertainty rules.
+ """
+
+ token = str(value).strip().lower()
+ return FLOAT_PATTERN.fullmatch(token) is not None or token in _NONFINITE_NUMERIC_MARKERS
_SUPERSCRIPT_TRANSLATION = str.maketrans(
@@ -210,7 +235,7 @@ def canonicalize_q_unit(value: object) -> str | None:
return None
text = text[1:-1].strip()
- unit = r"(?:a|angstrom|nm)"
+ unit = r"(?:a|angstrom|nm|m)"
if re.fullmatch(rf"1\s*/\s*({unit})", text):
matched_unit = re.fullmatch(rf"1\s*/\s*({unit})", text)
else:
@@ -223,7 +248,12 @@ def canonicalize_q_unit(value: object) -> str | None:
if matched_unit is None:
return None
- return "nm^-1" if matched_unit.group(1) == "nm" else "A^-1"
+ matched_name = matched_unit.group(1)
+ if matched_name == "nm":
+ return "nm^-1"
+ if matched_name == "m":
+ return "m^-1"
+ return "A^-1"
def _unit_delimiters_are_balanced(text: str) -> bool:
@@ -260,7 +290,7 @@ def q_axis_kind(name: object) -> str:
if re.fullmatch(r"q", text, flags=re.IGNORECASE):
return "q"
if re.fullmatch(
- r"q(?:a|angstrom|nm)(?:\s*\^?\s*-\s*1)",
+ r"q(?:a|angstrom|nm|m)(?:\s*\^?\s*-\s*1)",
text,
flags=re.IGNORECASE,
):
@@ -664,15 +694,7 @@ def _physical_data_width(path: str | Path) -> int:
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
- )
+ first_is_numeric = all(_is_numeric_syntax_token(token) 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)
@@ -691,7 +713,9 @@ def _read_plain_header_tokens(path: str | Path) -> list[str] | None:
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):
+ if len(tokens) >= 2 and any(
+ not _is_numeric_syntax_token(token) for token in tokens
+ ):
return tokens
return None
return None
@@ -1128,13 +1152,7 @@ def read_external_1d_profile(
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:
+ if has_comment_header or plain_header_tokens is not None:
continue
if df is not None and "header" not in kw:
header_for_malformed_check = (
@@ -1433,6 +1451,10 @@ def read_cansas1d_xml(path: str | Path) -> dict[str, Any]:
)
canonical_by_point = [canonical for _, canonical in q_unit_records]
+ if any(not raw for raw, _ in q_unit_records):
+ raise ValueError(f"canSAS XML Q unit is required: {p.name}")
+ if any(canonical is None for _, canonical in q_unit_records):
+ raise ValueError(f"canSAS XML contains unsupported Q unit: {p.name}")
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;
@@ -1501,9 +1523,11 @@ def read_nxcansas_h5(path: str | Path) -> dict[str, Any]:
i_dev_unit = ""
q_unit: str | None = None
q_unit_raw = ""
+ q_unit_declared = False
def _find_sasdata(group: Any) -> bool:
- nonlocal q_ds, i_ds, e_ds, intensity_unit, i_dev_unit, q_unit, q_unit_raw
+ nonlocal q_ds, i_ds, e_ds, intensity_unit, i_dev_unit
+ nonlocal q_unit, q_unit_raw, q_unit_declared
cls = group.attrs.get("canSAS_class", "")
if isinstance(cls, bytes):
cls = cls.decode()
@@ -1516,6 +1540,7 @@ def _find_sasdata(group: Any) -> bool:
)
if isinstance(raw_q_unit, bytes):
raw_q_unit = raw_q_unit.decode("utf-8", errors="replace")
+ q_unit_declared = bool(str(raw_q_unit or "").strip())
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", "")
@@ -1563,6 +1588,10 @@ def _collect_operator_provenance(_name: str, item: Any) -> None:
operator_provenance[key] = value.strip()
f.visititems(_collect_operator_provenance)
+ if not q_unit_declared:
+ raise ValueError(f"NXcanSAS Q unit is required: {p.name}")
+ if q_unit_raw:
+ raise ValueError(f"NXcanSAS contains unsupported Q unit: {p.name}")
if q_ds is None or i_ds is None:
raise ValueError(f"Cannot find SASdata/Q,I datasets in {p.name}")
diff --git a/src/saxsabs/io/writers.py b/src/saxsabs/io/writers.py
index 236b8f8..33a2ea1 100644
--- a/src/saxsabs/io/writers.py
+++ b/src/saxsabs/io/writers.py
@@ -233,7 +233,22 @@ def write_cansas1d_xml(
tree = ET.ElementTree(root)
ET.indent(tree, space=" ")
out.parent.mkdir(parents=True, exist_ok=True)
- tree.write(str(out), xml_declaration=True, encoding="utf-8")
+ fd, temporary_name = tempfile.mkstemp(
+ prefix=f".{out.name}.", suffix=".tmp", dir=str(out.parent)
+ )
+ os.close(fd)
+ temporary = Path(temporary_name)
+ committed = False
+ try:
+ tree.write(str(temporary), xml_declaration=True, encoding="utf-8")
+ os.replace(str(temporary), str(out))
+ committed = True
+ finally:
+ if not committed:
+ try:
+ temporary.unlink()
+ except FileNotFoundError:
+ pass
return out
diff --git a/src/saxsabs/workflows/bl19b2_abs2d.py b/src/saxsabs/workflows/bl19b2_abs2d.py
index 0ef195c..10edeef 100644
--- a/src/saxsabs/workflows/bl19b2_abs2d.py
+++ b/src/saxsabs/workflows/bl19b2_abs2d.py
@@ -870,8 +870,28 @@ def _parse_float(raw: Any) -> float | None:
return value
+_STRICT_FLOAT_PATTERN = re.compile(
+ r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
+)
+
+
+def _parse_strict_float(raw: Any) -> float | None:
+ """Parse one complete finite numeric token without accepting unit text."""
+
+ if raw is None:
+ return None
+ text = str(raw).strip()
+ if not text or _STRICT_FLOAT_PATTERN.fullmatch(text) is None:
+ return None
+ try:
+ value = float(text)
+ except (TypeError, ValueError):
+ return None
+ return value if math.isfinite(value) else None
+
+
def _parse_required_float(fields: dict[str, str], key: str, path: Path) -> float:
- value = _parse_float(fields.get(key))
+ value = _parse_strict_float(fields.get(key))
if value is None:
raise ValueError(f"{path} missing numeric {key}")
return value
@@ -884,10 +904,50 @@ def _parse_required_positive_float(fields: dict[str, str], key: str, path: Path)
return value
+def _parse_optional_strict_float(
+ fields: dict[str, str], key: str, path: Path
+) -> float | None:
+ raw = fields.get(key)
+ if raw is None or not str(raw).strip():
+ return None
+ value = _parse_strict_float(raw)
+ if value is None:
+ raise ValueError(f"{path} invalid numeric {key}")
+ return value
+
+
def _norm_key(key: str) -> str:
return re.sub(r"[^A-Z0-9]+", "", str(key).upper())
+def _strip_yaml_inline_comment(value: str) -> str:
+ """Strip an unquoted YAML comment while preserving ``#`` in quotes."""
+
+ quote: str | None = None
+ index = 0
+ while index < len(value):
+ char = value[index]
+ if quote is None:
+ if char in {"'", '"'}:
+ quote = char
+ elif char == "#" and (index == 0 or value[index - 1].isspace()):
+ return value[:index].rstrip()
+ elif quote == "'":
+ if char == "'":
+ if index + 1 < len(value) and value[index + 1] == "'":
+ index += 2
+ continue
+ quote = None
+ else:
+ if char == "\\":
+ index += 2
+ continue
+ if char == '"':
+ quote = None
+ index += 1
+ return value.strip()
+
+
def _read_flat_yaml(path: str | Path) -> dict[str, str]:
yaml_path = Path(path)
fields: dict[str, str] = {}
@@ -896,7 +956,8 @@ def _read_flat_yaml(path: str | Path) -> dict[str, str]:
if not line or line.startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
- fields[key.strip()] = value.strip().strip("'\"")
+ clean_value = _strip_yaml_inline_comment(value.strip())
+ fields[key.strip()] = clean_value.strip().strip("'\"")
return fields
@@ -920,6 +981,9 @@ def parse_pydidas_cali_yaml(path: str | Path) -> PydidasCalibration:
pixel_x_um = _parse_required_positive_float(fields, "detector_pxsizex", yaml_path)
pixel_y_um = _parse_required_positive_float(fields, "detector_pxsizey", yaml_path)
wavelength_angstrom = _parse_required_positive_float(fields, "xray_wavelength", yaml_path)
+ rot1 = _parse_optional_strict_float(fields, "detector_rot1", yaml_path)
+ rot2 = _parse_optional_strict_float(fields, "detector_rot2", yaml_path)
+ rot3 = _parse_optional_strict_float(fields, "detector_rot3", yaml_path)
return PydidasCalibration(
source_path=yaml_path,
detector_name=fields.get("detector_name", "Pilatus 2M"),
@@ -928,9 +992,9 @@ def parse_pydidas_cali_yaml(path: str | Path) -> PydidasCalibration:
poni2_m=_parse_required_float(fields, "detector_poni2", yaml_path),
pixel1_m=round(pixel_y_um * 1e-6, 12),
pixel2_m=round(pixel_x_um * 1e-6, 12),
- rot1=_parse_float(fields.get("detector_rot1")) or 0.0,
- rot2=_parse_float(fields.get("detector_rot2")) or 0.0,
- rot3=_parse_float(fields.get("detector_rot3")) or 0.0,
+ rot1=0.0 if rot1 is None else rot1,
+ rot2=0.0 if rot2 is None else rot2,
+ rot3=0.0 if rot3 is None else rot3,
wavelength_m=round(wavelength_angstrom * 1e-10, 23),
mask_path=_resolve_yaml_path(fields.get("detector_mask_file"), yaml_path),
)
diff --git a/tests/test_bl19b2_abs2d.py b/tests/test_bl19b2_abs2d.py
index de2846e..cfdb8c9 100644
--- a/tests/test_bl19b2_abs2d.py
+++ b/tests/test_bl19b2_abs2d.py
@@ -494,6 +494,43 @@ def test_parse_pydidas_cali_yaml_rejects_nonpositive_required_geometry_values(
parse_pydidas_cali_yaml(cali)
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("detector_dist", "3.048m"),
+ ("xray_wavelength", "0.413junk"),
+ ("detector_poni1", "0.1 0.2"),
+ ("detector_rot1", "0.1 0.2"),
+ ],
+)
+def test_parse_pydidas_cali_yaml_rejects_non_numeric_geometry_tokens(
+ tmp_path: Path,
+ field: str,
+ value: str,
+):
+ cali = tmp_path / "Cali.yaml"
+ _write_pydidas_cali(cali, **{field: value})
+
+ with pytest.raises(ValueError, match=field):
+ parse_pydidas_cali_yaml(cali)
+
+
+def test_parse_pydidas_cali_yaml_accepts_numeric_inline_comments(tmp_path: Path):
+ cali = tmp_path / "Cali.yaml"
+ _write_pydidas_cali(
+ cali,
+ detector_dist="3.048 # m",
+ detector_name='"Pilatus #2M"',
+ detector_rot1="-0.01 # rad",
+ )
+
+ geometry = parse_pydidas_cali_yaml(cali)
+
+ assert geometry.distance_m == pytest.approx(3.048)
+ assert geometry.detector_name == "Pilatus #2M"
+ assert geometry.rot1 == pytest.approx(-0.01)
+
+
def test_write_pydidas_poni_uses_pyfai_units(tmp_path: Path):
cali = tmp_path / "Cali.yaml"
cali.write_text(
diff --git a/tests/test_buffer_subtraction.py b/tests/test_buffer_subtraction.py
index 418ad85..ebde883 100644
--- a/tests/test_buffer_subtraction.py
+++ b/tests/test_buffer_subtraction.py
@@ -319,3 +319,15 @@ def test_subtract_buffer_refuses_unlabeled_profiles():
q = np.array([0.01, 0.02, 0.03])
with pytest.raises(ValueError, match="sample_profile and buffer_profile"):
subtract_buffer(q, np.ones(3), np.ones(3), q, np.ones(3), np.ones(3))
+
+
+def test_subtract_buffer_rejects_empty_public_arrays():
+ with pytest.raises(ValueError, match="empty"):
+ _sub(
+ np.array([]),
+ np.array([]),
+ np.array([]),
+ np.array([]),
+ np.array([]),
+ np.array([]),
+ )
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 655c0cf..0e18ec1 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -430,6 +430,42 @@ def test_cli_q_normalization_is_shallow_and_idempotent():
assert repeated["operator_provenance"]["q_unit_conversion"] == "nm^-1_to_A^-1"
+def test_cli_q_normalization_converts_reciprocal_metre_and_records_provenance():
+ profile = {
+ "x": [1.0e8, 2.0e8],
+ "x_col": "Q",
+ "x_unit": "1/m",
+ "operator_provenance": {},
+ }
+
+ converted = _normalize_q_profile(profile, profile_label="sample")
+
+ np.testing.assert_allclose(converted["x"], [0.01, 0.02])
+ assert converted["x_unit"] == "A^-1"
+ assert converted["operator_provenance"]["q_unit_original"] == "1/m"
+ assert converted["operator_provenance"]["q_unit_conversion"] == "m^-1_to_A^-1"
+
+
+def test_cli_q_normalization_converts_reciprocal_metre_from_text_profile(tmp_path: Path):
+ profile_path = tmp_path / "metre-q.csv"
+ profile_path.write_text(
+ "Q (1/m),I\n1.0e8,10\n2.0e8,9\n3.0e8,8\n",
+ encoding="utf-8",
+ )
+
+ profile = _read_profile_for_estimate(
+ profile_path,
+ q_col=None,
+ i_col=None,
+ profile_label="sample",
+ )
+ converted = _normalize_q_profile(profile, profile_label="sample")
+
+ np.testing.assert_allclose(converted["x"], [0.01, 0.02, 0.03])
+ assert converted["operator_provenance"]["q_unit_original"] == "m^-1"
+ assert converted["operator_provenance"]["q_unit_conversion"] == "m^-1_to_A^-1"
+
+
def test_cli_q_normalization_rejects_unknown_unit_with_explicit_override():
profile = {
"x": [1.0, 2.0],
diff --git a/tests/test_detector_reduction.py b/tests/test_detector_reduction.py
index e3caf5c..d201fff 100644
--- a/tests/test_detector_reduction.py
+++ b/tests/test_detector_reduction.py
@@ -27,6 +27,19 @@ def test_normalize_detector_frame_scales_integrated_dark_by_exposure():
np.testing.assert_allclose(result.image, [[10.0]])
+def test_normalize_detector_frame_rejects_empty_arrays():
+ with pytest.raises(ValueError, match="empty"):
+ normalize_detector_frame(
+ np.array([]),
+ np.array([]),
+ image_exposure_s=1.0,
+ dark_exposure_s=1.0,
+ monitor=1.0,
+ transmission=1.0,
+ monitor_mode="integrated",
+ )
+
+
def test_normalize_detector_frame_rejects_nonfinite_dark_scale():
with pytest.raises(ValueError, match="dark scale"):
normalize_detector_frame(
diff --git a/tests/test_fluorescence_subtraction.py b/tests/test_fluorescence_subtraction.py
index ed111c1..552d7a4 100644
--- a/tests/test_fluorescence_subtraction.py
+++ b/tests/test_fluorescence_subtraction.py
@@ -201,6 +201,17 @@ def test_refuses_unlabeled_and_negative_f0():
_sub(q, np.ones(3) * 5, np.ones(3) * 0.1, method="constant", f0=-0.1)
+def test_subtract_fluorescence_rejects_empty_public_arrays():
+ with pytest.raises(ValueError, match="empty"):
+ _sub(
+ np.array([]),
+ np.array([]),
+ np.array([]),
+ method="constant",
+ f0=1.0,
+ )
+
+
def test_already_fluorescence_subtracted_is_refused():
q = np.array([0.01, 0.02, 0.03])
profile = {
diff --git a/tests/test_io_formats.py b/tests/test_io_formats.py
index 78952d5..6cfc4e5 100644
--- a/tests/test_io_formats.py
+++ b/tests/test_io_formats.py
@@ -67,6 +67,69 @@ def test_reader_rejects_inconsistent_q_units(self, tmp_path):
with pytest.raises(ValueError, match="inconsistent Q units"):
read_cansas1d_xml(xml_path)
+ def test_reader_rejects_unknown_nonempty_q_unit(self, tmp_path):
+ q, i_abs, err = self._make_data(4)
+ xml_path = tmp_path / "unknown-q-unit.xml"
+ write_cansas1d_xml(xml_path, q, i_abs, err, metadata=ABS_META)
+
+ tree = ET.parse(xml_path)
+ namespace = "{urn:cansas1d:1.1}"
+ for q_element in tree.getroot().iter(f"{namespace}Q"):
+ q_element.set("unit", "furlong")
+ tree.write(xml_path, encoding="utf-8", xml_declaration=True)
+
+ with pytest.raises(ValueError, match="unsupported Q unit"):
+ read_cansas1d_xml(xml_path)
+
+ def test_reader_rejects_missing_q_unit(self, tmp_path):
+ q, i_abs, err = self._make_data(4)
+ xml_path = tmp_path / "missing-q-unit.xml"
+ write_cansas1d_xml(xml_path, q, i_abs, err, metadata=ABS_META)
+
+ tree = ET.parse(xml_path)
+ namespace = "{urn:cansas1d:1.1}"
+ for q_element in tree.getroot().iter(f"{namespace}Q"):
+ q_element.attrib.pop("unit", None)
+ tree.write(xml_path, encoding="utf-8", xml_declaration=True)
+
+ with pytest.raises(ValueError, match="Q unit"):
+ read_cansas1d_xml(xml_path)
+
+ def test_reader_accepts_reciprocal_metre_q_unit(self, tmp_path):
+ q, i_abs, err = self._make_data(4)
+ xml_path = tmp_path / "metre-q-unit.xml"
+ write_cansas1d_xml(xml_path, q, i_abs, err, metadata=ABS_META)
+
+ tree = ET.parse(xml_path)
+ namespace = "{urn:cansas1d:1.1}"
+ for q_element in tree.getroot().iter(f"{namespace}Q"):
+ q_element.set("unit", "1/m")
+ tree.write(xml_path, encoding="utf-8", xml_declaration=True)
+
+ result = read_cansas1d_xml(xml_path)
+
+ assert result["x_unit"] == "m^-1"
+ np.testing.assert_allclose(result["x"], q, rtol=1e-6)
+
+ def test_writer_is_atomic_when_xml_write_fails(self, tmp_path, monkeypatch):
+ q, i_abs, err = self._make_data(4)
+ target = tmp_path / "atomic.xml"
+ write_cansas1d_xml(target, q, i_abs, err, metadata=ABS_META)
+ original = target.read_bytes()
+
+ def broken_write(_tree, file_or_filename, **_kwargs):
+ from pathlib import Path
+
+ Path(file_or_filename).write_text("partial", encoding="utf-8")
+ raise RuntimeError("simulated XML write failure")
+
+ monkeypatch.setattr(ET.ElementTree, "write", broken_write)
+ with pytest.raises(RuntimeError, match="simulated XML write failure"):
+ write_cansas1d_xml(target, q, i_abs, err, metadata=ABS_META)
+
+ assert target.read_bytes() == original
+ assert not list(tmp_path.glob(f".{target.name}.*.tmp"))
+
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()
@@ -358,6 +421,49 @@ def test_reader_preserves_nm_inverse_q_units_without_conversion(self, tmp_path):
assert result["x_unit"] == "nm^-1"
np.testing.assert_allclose(result["x"], q)
+ def test_reader_rejects_unknown_nonempty_q_unit(self, tmp_path):
+ h5_path = tmp_path / "unknown-q-unit.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:
+ data = f.create_group("sasdata01")
+ data.attrs["canSAS_class"] = "SASdata"
+ q_ds = data.create_dataset("Q", data=q)
+ q_ds.attrs["units"] = "furlong"
+ data.create_dataset("I", data=intensity).attrs["units"] = "1/cm"
+
+ with pytest.raises(ValueError, match="unsupported Q unit"):
+ read_nxcansas_h5(h5_path)
+
+ def test_reader_rejects_missing_q_unit(self, tmp_path):
+ h5_path = tmp_path / "missing-q-unit.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:
+ data = f.create_group("sasdata01")
+ data.attrs["canSAS_class"] = "SASdata"
+ data.create_dataset("Q", data=q)
+ data.create_dataset("I", data=intensity).attrs["units"] = "1/cm"
+
+ with pytest.raises(ValueError, match="Q unit"):
+ read_nxcansas_h5(h5_path)
+
+ def test_reader_accepts_reciprocal_metre_q_unit(self, tmp_path):
+ h5_path = tmp_path / "metre-q-unit.h5"
+ q = np.array([1.0e8, 2.0e8, 3.0e8])
+ intensity = np.array([10.0, 9.0, 8.0])
+ with h5py.File(h5_path, "w") as f:
+ data = f.create_group("sasdata01")
+ data.attrs["canSAS_class"] = "SASdata"
+ q_ds = data.create_dataset("Q", data=q)
+ q_ds.attrs["units"] = "1/m"
+ data.create_dataset("I", data=intensity).attrs["units"] = "1/cm"
+
+ result = read_nxcansas_h5(h5_path)
+
+ assert result["x_unit"] == "m^-1"
+ np.testing.assert_allclose(result["x"], q)
+
def test_write_shape_mismatch_raises(self, tmp_path):
h5_path = tmp_path / "bad.h5"
try:
@@ -383,8 +489,10 @@ def test_reader_rejects_malformed_dataset_lengths(self, tmp_path):
entry = f.create_group("sasentry01")
data = entry.create_group("sasdata01")
data.attrs["canSAS_class"] = "SASdata"
- data.create_dataset("Q", data=np.array([0.1, 0.2, 0.3]))
- data.create_dataset("I", data=np.array([10.0, 9.0]))
+ q_ds = data.create_dataset("Q", data=np.array([0.1, 0.2, 0.3]))
+ q_ds.attrs["units"] = "1/A"
+ i_ds = data.create_dataset("I", data=np.array([10.0, 9.0]))
+ i_ds.attrs["units"] = "1/cm"
try:
read_nxcansas_h5(h5_path)
@@ -430,8 +538,10 @@ def test_reader_rejects_nonfinite_hdf_q_or_i(self, tmp_path, bad_value):
with h5py.File(path, "w") as f:
data = f.create_group("sasdata01")
data.attrs["canSAS_class"] = "SASdata"
- data.create_dataset("Q", data=np.array([0.1, bad_value, 0.3]))
- data.create_dataset("I", data=np.array([10.0, 9.0, 8.0]))
+ q_ds = data.create_dataset("Q", data=np.array([0.1, bad_value, 0.3]))
+ q_ds.attrs["units"] = "1/A"
+ i_ds = data.create_dataset("I", data=np.array([10.0, 9.0, 8.0]))
+ i_ds.attrs["units"] = "1/cm"
with pytest.raises(ValueError, match="finite"):
read_nxcansas_h5(path)
@@ -440,8 +550,10 @@ def test_reader_rejects_non_1d_hdf_datasets(self, tmp_path):
with h5py.File(path, "w") as f:
data = f.create_group("sasdata01")
data.attrs["canSAS_class"] = "SASdata"
- data.create_dataset("Q", data=np.ones((2, 2)))
- data.create_dataset("I", data=np.ones((2, 2)))
+ q_ds = data.create_dataset("Q", data=np.ones((2, 2)))
+ q_ds.attrs["units"] = "1/A"
+ i_ds = data.create_dataset("I", data=np.ones((2, 2)))
+ i_ds.attrs["units"] = "1/cm"
with pytest.raises(ValueError, match="1-D"):
read_nxcansas_h5(path)
@@ -451,8 +563,11 @@ def test_reader_rejects_invalid_hdf_i_dev(self, tmp_path, bad_value):
with h5py.File(path, "w") as f:
data = f.create_group("sasdata01")
data.attrs["canSAS_class"] = "SASdata"
- data.create_dataset("Q", data=np.array([0.1, 0.2, 0.3]))
- data.create_dataset("I", data=np.array([10.0, 9.0, 8.0]))
- data.create_dataset("Idev", data=np.array([0.1, bad_value, 0.1]))
+ q_ds = data.create_dataset("Q", data=np.array([0.1, 0.2, 0.3]))
+ q_ds.attrs["units"] = "1/A"
+ i_ds = data.create_dataset("I", data=np.array([10.0, 9.0, 8.0]))
+ i_ds.attrs["units"] = "1/cm"
+ e_ds = data.create_dataset("Idev", data=np.array([0.1, bad_value, 0.1]))
+ e_ds.attrs["units"] = "1/cm"
with pytest.raises(ValueError, match="Idev"):
read_nxcansas_h5(path)
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
index e4462cd..29b5641 100644
--- a/tests/test_parsers.py
+++ b/tests/test_parsers.py
@@ -46,6 +46,10 @@ def test_q_unit_canonicalization_rejects_bare_or_signless_lengths(raw_unit):
("nm-1", "nm^-1"),
("nm - 1", "nm^-1"),
("1/nm", "nm^-1"),
+ ("1/m", "m^-1"),
+ ("m^-1", "m^-1"),
+ ("m - 1", "m^-1"),
+ ("inverse m", "m^-1"),
("inverse angstrom", "A^-1"),
("inv nm", "nm^-1"),
("invangstrom", "A^-1"),
@@ -836,6 +840,27 @@ def test_read_external_1d_profile_keeps_position_fallback_for_numeric_file(
np.testing.assert_allclose(out["intensity"], [10.0, 9.0, 8.0])
+@pytest.mark.parametrize("nonfinite_token", ["NaN", "Inf"])
+def test_read_external_1d_profile_accepts_nonfinite_token_in_headerless_first_row(
+ tmp_path: Path,
+ nonfinite_token: str,
+):
+ f = tmp_path / "headerless_nonfinite_third_column.dat"
+ f.write_text(
+ f"0.10 10 {nonfinite_token}\n"
+ "0.20 20 200\n"
+ "0.30 30 300\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"], [10.0, 20.0, 30.0])
+ assert out["err_col"] == ""
+ assert np.all(np.isnan(out["uncertainty"]))
+
+
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_uncertainty.py b/tests/test_uncertainty.py
index c501541..d65994c 100644
--- a/tests/test_uncertainty.py
+++ b/tests/test_uncertainty.py
@@ -45,6 +45,11 @@ def test_complete_budget_keeps_components_and_combines_independent_variances():
assert budget.unknown_components == ()
+def test_propagate_absolute_uncertainty_rejects_empty_intensity():
+ with pytest.raises(ValueError, match="empty"):
+ propagate_absolute_uncertainty(np.array([]))
+
+
def test_missing_component_stays_unknown_and_prevents_optimistic_combination():
budget = propagate_absolute_uncertainty(
intensity=np.array([10.0, 20.0]),
diff --git a/tests/test_workbench_scientific.py b/tests/test_workbench_scientific.py
index 0d14c1f..9b772d0 100644
--- a/tests/test_workbench_scientific.py
+++ b/tests/test_workbench_scientific.py
@@ -1,5 +1,6 @@
import importlib.util
import json
+import os
from pathlib import Path
import sys
from types import SimpleNamespace
@@ -1661,6 +1662,444 @@ def test_workbench_preflight_detects_input_file_change(tmp_path):
with pytest.raises(RuntimeError, match="configuration changed"):
app._require_current_workbench_preflight("t2")
+
+def test_workbench_language_refresh_keeps_preflight_approvals_and_explicitly_skips_invalidation():
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.language = "en"
+ app.root = SimpleNamespace(title=Mock())
+ app.refresh_help_text = Mock()
+ app._refresh_output_format_combos = Mock()
+ app.t2_preflight_approval = object()
+ app.t3_preflight_approval = object()
+ app.refresh_queue_status = Mock()
+ app.refresh_external_1d_status = Mock()
+
+ app.refresh_ui_language()
+
+ app.refresh_queue_status.assert_called_once_with(invalidate=False)
+ app.refresh_external_1d_status.assert_called_once_with(invalidate=False)
+ assert app.t2_preflight_approval is not None
+ assert app.t3_preflight_approval is not None
+
+
+def test_workbench_language_refresh_relocalizes_existing_job_status():
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.language = "en"
+ app.root = SimpleNamespace(title=Mock())
+ app._status_var = _Var(module.I18N["en"]["status_batch_failed"])
+ app.t2_job_status = "failed"
+ app._workbench_last_job_tab = "t2"
+ app.refresh_help_text = Mock()
+ app.refresh_queue_status = Mock()
+ app.refresh_external_1d_status = Mock()
+
+ app.language = "zh"
+ app.refresh_ui_language()
+
+ assert app._status_var.get() == module.I18N["zh"]["status_batch_failed"]
+ assert app.t2_job_status == "failed"
+
+
+def test_workbench_status_refresh_defaults_to_preflight_invalidation():
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app._invalidate_workbench_preflight = Mock()
+
+ app.refresh_queue_status()
+ app.refresh_external_1d_status()
+
+ assert [item.args for item in app._invalidate_workbench_preflight.call_args_list] == [
+ ("t2",),
+ ("t3",),
+ ]
+
+
+def test_workbench_preflight_file_identity_hash_detects_same_stat_content_replacement(
+ tmp_path,
+):
+ module = _load_workbench_module()
+ sample = tmp_path / "sample.tif"
+ sample.write_bytes(b"first!")
+ first = module.SAXSAbsWorkbenchApp._preflight_file_identity(sample)
+ original_stat = sample.stat()
+
+ sample.write_bytes(b"second")
+ os.utime(sample, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
+ second = module.SAXSAbsWorkbenchApp._preflight_file_identity(sample)
+
+ assert first["identity_valid"] is True
+ assert second["identity_valid"] is True
+ assert first["size"] == second["size"] == 6
+ assert first["mtime_ns"] == second["mtime_ns"]
+ assert first["sha256"] != second["sha256"]
+
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.global_vars = {}
+ app.t2_files = [str(sample)]
+ # Approve the original content, then replace it with the same-size content
+ # and restore the original timestamp to exercise the hash-only boundary.
+ sample.write_bytes(b"first!")
+ os.utime(sample, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
+ app.t2_preflight_approval = module.approve_preflight(
+ app._t2_preflight_config(), "READY"
+ )
+ sample.write_bytes(b"second")
+ os.utime(sample, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
+
+ with pytest.raises(RuntimeError, match="configuration changed"):
+ app._require_current_workbench_preflight("t2")
+
+
+def test_workbench_preflight_file_identity_fails_closed_for_unreadable_source(
+ tmp_path,
+ monkeypatch,
+):
+ module = _load_workbench_module()
+ sample = tmp_path / "sample.dat"
+ sample.write_bytes(b"profile")
+
+ def refuse_open(_self, *_args, **_kwargs):
+ raise PermissionError("permission denied")
+
+ monkeypatch.setattr(module.Path, "open", refuse_open)
+ identity = module.SAXSAbsWorkbenchApp._preflight_file_identity(sample)
+
+ assert identity["exists"] is False
+ assert identity["identity_valid"] is False
+ assert identity["sha256"] is None
+ assert "permission denied" in identity["identity_error"]
+
+
+def test_workbench_preflight_does_not_accept_missing_file_identity(tmp_path):
+ module = _load_workbench_module()
+ missing = tmp_path / "missing.dat"
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.global_vars = {}
+ app.t2_files = [str(missing)]
+ app.t2_preflight_approval = module.approve_preflight(
+ app._t2_preflight_config(), "READY"
+ )
+
+ with pytest.raises(RuntimeError, match="identity.*missing|missing.*identity"):
+ app._require_current_workbench_preflight("t2")
+
+
+def test_tab2_empty_dry_check_reports_localized_prompt():
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.language = "en"
+ app.t2_files = []
+ app.show_info = Mock()
+
+ app.dry_run()
+
+ app.show_info.assert_called_once_with(
+ "msg_preview_title", module.I18N["en"]["msg_t2_queue_empty"]
+ )
+
+
+def test_tab2_completion_message_is_localized_for_english_and_chinese():
+ module = _load_workbench_module()
+
+ assert "批处理完成" not in module.I18N["en"]["msg_batch_done_title"]
+ assert "稳健批处理完成" not in module.I18N["en"]["msg_batch_done_body"]
+ assert "Batch Completed" == module.I18N["en"]["msg_batch_done_title"]
+ assert "批处理完成" == module.I18N["zh"]["msg_batch_done_title"]
+
+
+def test_tab2_completion_call_uses_real_localized_formatter_path():
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.language = "en"
+ app.show_info = Mock()
+
+ app._show_batch_completion(
+ sample_success=1,
+ sample_partial=0,
+ sample_skip=0,
+ sample_fail=0,
+ mode_summary="1d_full: success 1 / skipped 0 / failed 0",
+ dir_summary="1d_full -> processed_robust_1d_full",
+ report="batch_report.csv",
+ cal2d_manifest=None,
+ tab3_metadata=None,
+ meta="run_meta.json",
+ )
+
+ app.show_info.assert_called_once()
+ title_key, message = app.show_info.call_args.args
+ assert title_key == "msg_batch_done_title"
+ assert "Robust batch processing completed." in message
+ assert "batch_report.csv" in message
+ assert "not enabled" in message
+ assert "export failed" in message
+ assert "批处理" not in message
+
+
+def test_tab2_and_tab3_disabled_stale_optional_paths_are_not_hashed_as_active(
+ tmp_path,
+):
+ module = _load_workbench_module()
+ poni = tmp_path / "geometry.poni"
+ poni.write_text("poni", encoding="utf-8")
+ missing = tmp_path / "stale-disabled.dat"
+
+ t2 = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ t2.global_vars = {
+ "poni_path": _Var(str(poni)),
+ "bg_path": _Var(str(missing)),
+ "dark_path": _Var(str(missing)),
+ "mask_path": _Var(""),
+ "flat_path": _Var(""),
+ }
+ t2.t2_files = []
+ t2.t2_ref_mode = _Var("auto")
+ t2.t2_mask_path = _Var("")
+ t2.t2_flat_path = _Var("")
+ t2.t2_fluo_enabled = _Var(False)
+ t2.t2_fluo_method = _Var("measured")
+ t2.t2_fluo_path = _Var(str(missing))
+ t2_config = t2._t2_preflight_config()
+
+ assert t2_config["t2_fluo_path_identity"]["identity_active"] is False
+ assert t2_config["global"]["bg_path_identity"][0]["identity_active"] is False
+ assert t2_config["global"]["dark_path_identity"][0]["identity_active"] is False
+ t2.t2_preflight_approval = module.approve_preflight(t2_config, "READY")
+ t2._require_current_workbench_preflight("t2")
+
+ t3 = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ t3.global_vars = {
+ "poni_path": _Var(str(poni)),
+ "bg_path": _Var(str(missing)),
+ "dark_path": _Var(str(missing)),
+ "mask_path": _Var(str(missing)),
+ "flat_path": _Var(str(missing)),
+ }
+ t3.t3_files = []
+ t3.t3_pipeline_mode = _Var("scaled")
+ t3.t3_meta_csv_path = _Var(str(missing))
+ t3.t3_bg1d_path = _Var(str(missing))
+ t3.t3_dark1d_path = _Var(str(missing))
+ t3.t3_buffer_enabled = _Var(False)
+ t3.t3_buffer_path = _Var(str(missing))
+ t3.t3_fluo_enabled = _Var(False)
+ t3.t3_fluo_method = _Var("measured")
+ t3.t3_fluo_path = _Var(str(missing))
+ t3_config = t3._t3_preflight_config()
+
+ for key in (
+ "t3_meta_csv_path_identity",
+ "t3_bg1d_path_identity",
+ "t3_dark1d_path_identity",
+ "t3_buffer_path_identity",
+ "t3_fluo_path_identity",
+ ):
+ assert t3_config[key]["identity_active"] is False
+ assert all(
+ identity["identity_active"] is False
+ for key, identity in (
+ ("bg_path_identity", t3_config["global"].get("bg_path_identity", [{}])[0]),
+ ("dark_path_identity", t3_config["global"].get("dark_path_identity", [{}])[0]),
+ )
+ )
+ t3.t3_preflight_approval = module.approve_preflight(t3_config, "READY")
+ t3._require_current_workbench_preflight("t3")
+
+ # The raw path remains part of the canonical configuration, so changing a
+ # disabled value still invalidates an earlier approval without hashing it.
+ t3.t3_buffer_path.set(str(tmp_path / "another-disabled.dat"))
+ with pytest.raises(RuntimeError, match="configuration changed"):
+ t3._require_current_workbench_preflight("t3")
+
+
+def test_workbench_active_optional_path_missing_still_blocks_run(tmp_path):
+ module = _load_workbench_module()
+ missing = tmp_path / "missing-buffer.dat"
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.global_vars = {}
+ app.t3_files = []
+ app.t3_pipeline_mode = _Var("scaled")
+ app.t3_buffer_enabled = _Var(True)
+ app.t3_buffer_path = _Var(str(missing))
+ config = app._t3_preflight_config()
+ assert config["t3_buffer_path_identity"]["identity_active"] is True
+ assert config["t3_buffer_path_identity"]["identity_valid"] is False
+ app.t3_preflight_approval = module.approve_preflight(config, "READY")
+
+ with pytest.raises(RuntimeError, match="identity"):
+ app._require_current_workbench_preflight("t3")
+
+
+@pytest.mark.parametrize(
+ ("path_name", "extra_setup"),
+ [
+ ("t2_mask_path", lambda app, path: setattr(app, "t2_mask_path", _Var(str(path)))),
+ ("t2_flat_path", lambda app, path: setattr(app, "t2_flat_path", _Var(str(path)))),
+ (
+ "t2_fluo_path",
+ lambda app, path: (
+ setattr(app, "t2_fluo_enabled", _Var(True)),
+ setattr(app, "t2_fluo_method", _Var("measured")),
+ setattr(app, "t2_fluo_path", _Var(str(path))),
+ ),
+ ),
+ ],
+)
+def test_tab2_active_missing_optional_path_blocks_run(tmp_path, path_name, extra_setup):
+ module = _load_workbench_module()
+ missing = tmp_path / f"{path_name}.dat"
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.global_vars = {}
+ app.t2_files = []
+ app.t2_fluo_enabled = _Var(False)
+ app.t2_fluo_method = _Var("constant")
+ app.t2_fluo_path = _Var("")
+ extra_setup(app, missing)
+ config = app._t2_preflight_config()
+
+ assert config[f"{path_name}_identity"]["identity_active"] is True
+ assert config[f"{path_name}_identity"]["identity_valid"] is False
+ app.t2_preflight_approval = module.approve_preflight(config, "READY")
+
+ with pytest.raises(RuntimeError, match="identity"):
+ app._require_current_workbench_preflight("t2")
+
+
+def test_tab3_raw_active_missing_metadata_path_blocks_run(tmp_path):
+ module = _load_workbench_module()
+ missing = tmp_path / "missing-metadata.csv"
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.global_vars = {}
+ app.t3_files = []
+ app.t3_pipeline_mode = _Var("raw")
+ app.t3_meta_csv_path = _Var(str(missing))
+ config = app._t3_preflight_config()
+
+ assert config["t3_meta_csv_path_identity"]["identity_active"] is True
+ assert config["t3_meta_csv_path_identity"]["identity_valid"] is False
+ app.t3_preflight_approval = module.approve_preflight(config, "READY")
+
+ with pytest.raises(RuntimeError, match="identity"):
+ app._require_current_workbench_preflight("t3")
+
+
+@pytest.mark.parametrize(
+ ("tab", "enabled_name", "path_name", "method_setup"),
+ [
+ ("t2", "t2_fluo_enabled", "t2_fluo_path", lambda app: setattr(app, "t2_fluo_method", _Var("measured"))),
+ ("t3", "t3_buffer_enabled", "t3_buffer_path", lambda _app: None),
+ ],
+)
+def test_workbench_enabled_required_file_slot_with_blank_path_blocks_run(
+ tab,
+ enabled_name,
+ path_name,
+ method_setup,
+):
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.global_vars = {}
+ app.t2_files = []
+ app.t3_files = []
+ setattr(app, enabled_name, _Var(True))
+ setattr(app, path_name, _Var(""))
+ method_setup(app)
+ config = app._t2_preflight_config() if tab == "t2" else app._t3_preflight_config()
+
+ assert config[f"{path_name}_identity"]["identity_active"] is True
+ assert config[f"{path_name}_identity"]["identity_valid"] is False
+ setattr(app, f"{tab}_preflight_approval", module.approve_preflight(config, "READY"))
+
+ with pytest.raises(RuntimeError, match="identity"):
+ app._require_current_workbench_preflight(tab)
+
+
+@pytest.mark.parametrize(
+ ("axis_header", "x_values", "expected_conversion", "expected_q"),
+ [
+ ("q_m^-1", [1.0e8, 2.0e8], "q_m^-1_to_q_a^-1", [0.01, 0.02]),
+ ("1/m", [1.0e8, 2.0e8], "q_m^-1_to_q_a^-1", [0.01, 0.02]),
+ ],
+)
+def test_tab3_workbench_converts_m_inverse_q_to_angstrom_inverse(
+ axis_header,
+ x_values,
+ expected_conversion,
+ expected_q,
+):
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ profile = {
+ "x": np.asarray(x_values),
+ "x_col": "q",
+ "x_unit": axis_header,
+ "x_unit_raw": axis_header,
+ }
+
+ q, label, conversion = app.resolve_external_x_axis(
+ "profile.dat", profile, mode="auto"
+ )
+
+ np.testing.assert_allclose(q, expected_q)
+ assert label == "Q_A^-1"
+ assert conversion == expected_conversion
+
+
+def test_tab3_m_inverse_conversion_is_allowed_for_profile_alignment():
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ profile = {
+ "x": np.array([1.0e8, 2.0e8]),
+ "x_col": "q",
+ "x_unit": "m^-1",
+ "x_unit_raw": "1/m",
+ }
+ prepared = app.prepare_external_profile_axis("profile.dat", profile, mode="auto")
+ app.assert_external_profile_axis_compatible(prepared, prepared, "reference")
+class _FakeProgressBar:
+ def __init__(self, value=0):
+ self.values = {"value": value}
+
+ def __getitem__(self, key):
+ return self.values[key]
+
+ def __setitem__(self, key, value):
+ self.values[key] = value
+
+
+@pytest.mark.parametrize(
+ ("runner", "bar_name", "files_name", "resume_name"),
+ [
+ ("run_batch", "prog_bar", "t2_files", "t2_resume_enabled"),
+ ("run_external_1d_batch", "t3_prog_bar", "t3_files", "t3_resume_enabled"),
+ ],
+)
+def test_workbench_failed_run_clears_stale_progress_and_marks_failure(
+ runner,
+ bar_name,
+ files_name,
+ resume_name,
+):
+ module = _load_workbench_module()
+ app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)
+ app.language = "en"
+ app.root = SimpleNamespace()
+ app._status_var = _Var("old status")
+ setattr(app, bar_name, _FakeProgressBar(value=100))
+ setattr(app, files_name, [])
+ setattr(app, resume_name, _Var(False))
+ app.normalize_t2_queue = lambda: ([], False)
+ app.normalize_t3_queue = lambda: ([], False)
+ app.show_error = Mock()
+
+ getattr(app, runner)()
+
+ assert getattr(app, bar_name)["value"] == 0
+ assert getattr(app, "t2_job_status", getattr(app, "t3_job_status", None)) == "failed"
+ app.show_error.assert_called_once()
+
def test_workbench_requires_explicit_thickness_after_selecting_non_srm_standard():
module = _load_workbench_module()
app = module.SAXSAbsWorkbenchApp.__new__(module.SAXSAbsWorkbenchApp)