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)