From 356baf283ef9b3e21cf88a0ba631288ecac21e22 Mon Sep 17 00:00:00 2001
From: Delun Gong
Date: Wed, 12 Aug 2026 19:44:13 +0800
Subject: [PATCH] feat: restore CIF2Peaks product surface with lab exports and
bilingual GUI
Bring d-range, profile models, Cu-Ka 2theta, hkil labels, Chinese Excel sheets,
quick-export, publication figures, and Windows launchers while keeping the Gemmi
engine and verifiable result bundles. Verified with pytest (98 passed).
---
CHANGELOG.md | 10 +
MANIFEST.in | 1 +
README.md | 24 +
README.zh-CN.md | 26 +-
docs/ENGINE_PARITY.md | 99 ++
docs/GUI.md | 33 +
docs/SCHEMA_ALIASES.md | 149 +++
docs/SOURCE_LINEAGE.md | 14 +
pyproject.toml | 10 +
quick_export_diffractscout.bat | 49 +
scripts/package_windows_portable.py | 142 +++
src/diffractscout/cli.py | 57 +
src/diffractscout/diffraction.py | 187 +++-
src/diffractscout/elasticity_input.py | 87 ++
src/diffractscout/export_views.py | 113 ++
src/diffractscout/exporters.py | 225 +++-
src/diffractscout/gui.py | 742 +++++++++++--
src/diffractscout/gui_i18n.py | 331 ++++++
src/diffractscout/hkl.py | 104 ++
src/diffractscout/models.py | 28 +
src/diffractscout/pipeline.py | 55 +-
src/diffractscout/plotting.py | 1080 +++++++++++++++++++
src/diffractscout/quick_export.py | 216 ++++
src/diffractscout/structure.py | 58 +
tests/test_cli.py | 50 +-
tests/test_elasticity_input.py | 52 +
tests/test_export_views.py | 120 +++
tests/test_gui.py | 61 ++
tests/test_hkl.py | 26 +
tests/test_parity_features.py | 119 ++
tests/test_plotting.py | 100 ++
tests/test_quick_export.py | 56 +
"\345\220\257\345\212\250DiffractScout.bat" | 26 +
33 files changed, 4320 insertions(+), 130 deletions(-)
create mode 100644 docs/ENGINE_PARITY.md
create mode 100644 docs/SCHEMA_ALIASES.md
create mode 100644 quick_export_diffractscout.bat
create mode 100644 scripts/package_windows_portable.py
create mode 100644 src/diffractscout/elasticity_input.py
create mode 100644 src/diffractscout/export_views.py
create mode 100644 src/diffractscout/gui_i18n.py
create mode 100644 src/diffractscout/hkl.py
create mode 100644 src/diffractscout/plotting.py
create mode 100644 src/diffractscout/quick_export.py
create mode 100644 tests/test_elasticity_input.py
create mode 100644 tests/test_export_views.py
create mode 100644 tests/test_hkl.py
create mode 100644 tests/test_parity_features.py
create mode 100644 tests/test_plotting.py
create mode 100644 tests/test_quick_export.py
create mode 100644 "\345\220\257\345\212\250DiffractScout.bat"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1790487..74f6207 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes are recorded here. The project follows semantic versioning a
## [Unreleased]
+### Added
+
+- Windows launchers: `启动DiffractScout.bat` (GUI) and `quick_export_diffractscout.bat` (drag-and-drop quick-export with Excel next to the first input).
+- Packaging stub `scripts/package_windows_portable.py` documenting a future PyInstaller portable layout (`--help` / `--print-recipe`; no freeze yet).
+- Optional dependencies `figures` (matplotlib) and `gui-dnd` (tkinterdnd2); `paper` remains as a matplotlib alias.
+- Console entry point `diffractscout-quick-export` and GUI entry point `diffractscout-gui` documented alongside `diffractscout`.
+- Documentation for CIF2Peaks parity features (lab Excel views, *d*-range filters, bilingual lab sheets, quick-export, figures) in README / README.zh-CN, GUI controls in `docs/GUI.md`, and module mapping in `docs/SOURCE_LINEAGE.md`.
+
+### Notes
+
- Awaiting the first public GitHub release, archived software DOI, and external validation cases.
## [0.3.0] - 2026-08-12
diff --git a/MANIFEST.in b/MANIFEST.in
index 12a59f3..0036ddc 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,7 @@
include pyproject.toml MANIFEST.in
include README.md README.zh-CN.md LICENSE NOTICE.md AUTHORS.md CITATION.cff CHANGELOG.md
include CONTRIBUTING.md CODE_OF_CONDUCT.md SECURITY.md GOVERNANCE.md SUPPORT.md ROADMAP.md
+include 启动DiffractScout.bat quick_export_diffractscout.bat
recursive-include docs *.md *.svg *.png *.json
recursive-include paper *.md *.bib *.svg *.png *.sh *.py
recursive-include examples *.cif *.json *.md
diff --git a/README.md b/README.md
index 9507ec0..421b8ed 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,22 @@ diffractscout-gui
The desktop interface exposes the scientific controls used by the Python API: radiation definition, angular window, profile spacing, pseudo-Voigt parameters, elastic-tensor pairing, candidate limits, reciprocal-space resource guards, overwrite authorization, progress, structured diagnostics, and result-folder access. The API key remains in memory and is not written to project files. See [docs/GUI.md](docs/GUI.md).
+On Windows, double-click `启动DiffractScout.bat` after an editable install, or drag CIF files onto `quick_export_diffractscout.bat` for a one-shot lab export.
+
+## CIF2Peaks parity features
+
+DiffractScout reimplements the CIF2Peaks desktop workflow inside a provenance-first package (Gemmi engine; not bit-identical intensities). Practical parity includes:
+
+| Capability | Where |
+|---|---|
+| Laboratory Excel views (Chinese beginner peak table + usage guide sheets) | `export_lab_views` / CLI `--no-lab-views` to disable |
+| Optional *d*-spacing window (intersects the 2θ search) | CLI/API `--d-min` / `--d-max` |
+| Bilingual lab-facing tables with English canonical CSV/XLSX | Excel `推荐峰表` / `使用说明` plus English `Peaks` |
+| One-shot quick export (Cu Kα lab defaults, optional `.xlsx` shortcut) | `diffractscout-quick-export`, `diffractscout quick-export`, Windows drag-drop bat |
+| Optional figure generation request | CLI `--figures` / `.[figures]` (matplotlib) |
+
+Column-name mapping and intensity-channel aliases: [docs/SCHEMA_ALIASES.md](docs/SCHEMA_ALIASES.md). Engine semantics vs CIF2Peaks/pymatgen: [docs/ENGINE_PARITY.md](docs/ENGINE_PARITY.md).
+
## Installation
### Local CIF analysis
@@ -58,6 +74,14 @@ python -m pip install -e ".[mp]"
export MP_API_KEY="your-key" # PowerShell: $env:MP_API_KEY = "your-key"
```
+### Optional extras
+
+```bash
+python -m pip install -e ".[figures]" # matplotlib for figure request / paper figures
+python -m pip install -e ".[gui-dnd]" # optional Tk drag-and-drop helper (future UX)
+python -m pip install -e ".[mp]" # Materials Project
+```
+
### Development environment
```bash
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 154ae53..03ee871 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -30,7 +30,23 @@ diffractscout-gui
-界面提供:CIF 文件与文件夹批量选择、递归扫描、光源/能量/波长、`2θ` 范围、步长、FWHM、伪 Voigt 混合参数、弹性配对、候选相数量上限、倒易空间资源限制、覆盖授权、运行状态、结构化日志和结果目录入口。API 密钥只保存在当前进程内存中,不写入项目文件。
+界面提供:CIF 文件与文件夹批量选择、递归扫描、光源/能量/波长、`2θ` 范围、步长、FWHM、伪 Voigt 混合参数、弹性配对、候选相数量上限、倒易空间资源限制、覆盖授权、运行状态、结构化日志和结果目录入口。API 密钥只保存在当前进程内存中,不写入项目文件。详见 [docs/GUI.md](docs/GUI.md)。
+
+Windows 下可在可编辑安装后双击 `启动DiffractScout.bat` 启动界面;或将 CIF 拖到 `quick_export_diffractscout.bat` 进行一次实验室默认导出。
+
+## 已吸收 CIF2Peaks 桌面能力
+
+DiffractScout 在可追溯结果包中重实现了 CIF2Peaks 的主要桌面工作流(离线引擎为 Gemmi,强度为语义对齐而非逐字节一致):
+
+| 能力 | 入口 |
+|---|---|
+| 实验室 Excel 视图(中文推荐峰表 + 使用说明) | `export_lab_views`;CLI `--no-lab-views` 可关闭 |
+| *d* 间距过滤窗口(与 2θ 搜索求交) | CLI/API `--d-min` / `--d-max` |
+| 中英双语:中文实验室表 + 英文规范列名 CSV/XLSX | 工作簿 `推荐峰表` / `使用说明` 与 `Peaks` |
+| 一键快速导出(Cu Kα 实验室默认,可选 `.xlsx` 快捷路径) | `diffractscout-quick-export`、`diffractscout quick-export`、Windows 拖放 bat |
+| 可选图件生成请求 | CLI `--figures` / 可选依赖 `.[figures]` |
+
+列名与强度通道别名见 [docs/SCHEMA_ALIASES.md](docs/SCHEMA_ALIASES.md);与 CIF2Peaks/pymatgen 引擎差异见 [docs/ENGINE_PARITY.md](docs/ENGINE_PARITY.md)。
## 安装
@@ -48,6 +64,14 @@ Materials Project 支持:
python -m pip install -e ".[mp]"
```
+可选依赖:
+
+```bash
+python -m pip install -e ".[figures]" # matplotlib(图件请求 / 论文图)
+python -m pip install -e ".[gui-dnd]" # 可选 Tk 拖放辅助(后续 UX)
+python -m pip install -e ".[mp]" # Materials Project
+```
+
开发与测试:
```bash
diff --git a/docs/ENGINE_PARITY.md b/docs/ENGINE_PARITY.md
new file mode 100644
index 0000000..c8707d5
--- /dev/null
+++ b/docs/ENGINE_PARITY.md
@@ -0,0 +1,99 @@
+# Diffraction engine parity: Gemmi vs CIF2Peaks (pymatgen)
+
+DiffractScout’s offline powder engine is built on **Gemmi**. CIF2Peaks used
+**pymatgen** `XRDCalculator` for theoretical powder lines. The two implementations
+are designed for **workflow parity**, not bit-identical intensities.
+
+## What “workflow parity” means
+
+Both tools aim to produce, from a structure-bearing CIF and an X-ray wavelength:
+
+1. indexed reflections with Miller indices, \(d\), \(\theta\), \(2\theta\), \(q\), \(g\);
+2. structure-factor-related intensities with and without a laboratory-style
+ Lorentz–polarization (LP) factor;
+3. phase-internal relative intensities (strongest line scaled to 100);
+4. volume-normalized intensity channels
+ \(J = I / V_{\mathrm{cell}}^2\) (legacy names still contain `R_hkl`);
+5. optional plane-normal Young’s modulus when a valid \(C_{ij}\) is paired;
+6. a continuous pseudo-Voigt display profile for plotting.
+
+Users can move the same scientific questions—candidate peaks, LP vs no-LP
+channels, elasticity on `hkl` normals—between the two codebases with the column
+aliases in [`SCHEMA_ALIASES.md`](SCHEMA_ALIASES.md).
+
+## What is **not** guaranteed
+
+| Quantity | Expectation |
+|---|---|
+| Absolute \(I\) or \(J\) values | May differ between Gemmi and pymatgen |
+| Peak-by-peak intensity ordering near ties | May swap when values are close |
+| Multiplicity of a given representative | Same physical idea; counting of symmetry/Friedel mates can differ in edge cases |
+| Representative `hkl` of a family | Both pick a deterministic member; the choice rule may differ |
+| Multi-family coincidence at one \(2\theta\) | CIF2Peaks could merge pymatgen families into one peak row; DiffractScout emits one row per unique family |
+| Floating-point \(2\theta\), \(d\), \(q\) | Agree to crystallographic precision for clean cells; not bit-identical |
+
+Do **not** use bit-identical intensity regression between CIF2Peaks exports and
+DiffractScout as a release gate. Prefer analytic structure-factor checks (e.g.
+monoatomic FCC \(\lvert F_{111}\rvert^2\)), space-group absences, and internal
+invariants (\(J = I / V^2\), LP ratio consistency).
+
+## Architectural differences
+
+| Topic | CIF2Peaks (pymatgen) | DiffractScout (Gemmi) |
+|---|---|---|
+| Structure I/O | pymatgen structure from CIF | Gemmi small structure; dedicated occupancy conversion for SF |
+| Powder intensities | `XRDCalculator.get_pattern(scaled=False)` | Enumerate Miller candidates, absences, \(\lvert F\rvert^2\), multiplicity, LP |
+| Atomic form factors / SF | pymatgen calculator defaults | Gemmi `StructureFactorCalculatorX` |
+| Debye–Waller | Assumed 1 when absent | Same practical boundary; missing \(B\) not invented |
+| LP factor | Same laboratory-style form \((1+\cos^2 2\theta)/(\sin^2\theta\cos\theta)\) | Same formula in `diffraction.py` |
+| Volume-normalized \(J\) | `I_unscaled / V^2` and `(I_unscaled/LP)/V^2` | `I_with_LP / V^2` and `I_no_LP / V^2` |
+| Offline base install | Required pymatgen for local XRD | Core analysis uses Gemmi; pymatgen optional via mp-api |
+| Provenance | Export notes | SHA-256 inputs, manifest, scientific boundary string |
+
+## Intensity channel correspondence
+
+Conceptually:
+
+```text
+I_with_LP ≈ theoretical_intensity_unscaled (CIF2Peaks)
+I_no_LP ≈ multiplicity_structure_factor_sq (CIF2Peaks)
+J_with_LP = I_with_LP / V_cell^2 ↔ material_scattering_factor_R_hkl
+J_no_LP = I_no_LP / V_cell^2 ↔ material_scattering_factor_R_hkl_no_lp
+```
+
+The **definitions** of the \(J\) channels match. The **numerators** come from
+different structure-factor stacks, so \(J\) values are workflow-comparable, not
+byte-equal.
+
+## Systematic absences and indexing
+
+Both engines respect crystallographic absences for the resolved space group.
+DiffractScout records space-group resolution order and optional spglib
+cross-checks in diagnostics. A mismatch between declared and detected symmetry
+is a user-review item in both ecosystems; it can change which lines appear.
+
+## Hexagonal / trigonal labels
+
+CIF2Peaks often retained four-index Miller–Bravais labels when pymatgen supplied
+them. DiffractScout stores three-index \(h,k,l\) on `ReflectionRecord` and can
+format four-index **display** labels with `label_hkl_for_crystal_system` in
+`hkl.py` when the crystal system string indicates hexagonal or trigonal families.
+Plane-normal elasticity always uses the three-index plane
+`plane_hkl_for_normal` (requiring \(i = -(h+k)\) for four-index input).
+
+## Validation guidance
+
+1. **Contract tests**: \(J = I / V^2\), ranks consistent with channels, LP ratio
+ \(I_{\mathrm{with\,LP}} / I_{\mathrm{no\,LP}}\).
+2. **Analytic fixtures**: known monoatomic cells and expected \(\lvert F\rvert^2\).
+3. **Cross-engine comparison**: compare \(d\) and \(2\theta\) to a tight tolerance;
+ compare intensity **ratios** or top-\(N\) peak sets, not raw floats.
+4. **Never** treat legacy `R_hkl` columns as Rietveld residuals (see
+ `SCHEMA_ALIASES.md` and `SCIENTIFIC_CONTRACTS.md`).
+
+## Summary
+
+DiffractScout preserves the CIF2Peaks **scientific workflow** (indexed peaks, LP
+split, volume-normalized channels, optional \(E(n_{hkl})\), plottable profile)
+while moving crystallographic computation to Gemmi for an offline-first,
+provenance-oriented package. Intensity parity is **semantic**, not bitwise.
diff --git a/docs/GUI.md b/docs/GUI.md
index 8a89ea0..cd79c17 100644
--- a/docs/GUI.md
+++ b/docs/GUI.md
@@ -8,10 +8,15 @@ DiffractScout provides a Tk desktop interface for researchers who prefer to conf
diffractscout-gui
# equivalent
diffractscout gui
+# or: python -m diffractscout gui
```
+On Windows, after an editable or environment install, double-click `启动DiffractScout.bat` in the repository root (it `cd`s to the script directory and tries `py -3 -m diffractscout gui`, then `diffractscout-gui`).
+
A normal Python installation with Tk support is required. On Linux, the operating-system package is commonly named `python3-tk` or `tk`.
+Optional extra `.[gui-dnd]` installs `tkinterdnd2` for future drag-and-drop enhancements; the current GUI does not require it.
+
## Local CIF analysis

@@ -63,9 +68,37 @@ Energy and wavelength inputs must be finite and positive. The CLI also makes exp
- `Profile points` rejects a requested grid above the configured count before allocation.
- `Reciprocal candidates` rejects a conservative Miller-candidate estimate, and then the actual candidate list, above the configured limit.
- Elasticity pairing calculates a directional modulus only for a valid 6×6 stiffness tensor with an explicitly compatible coordinate frame.
+- **Pair numerical elasticity sidecars** / **Evaluate frame-compatible elasticity** and **Write Excel workbook** appear under Outputs on each tab.
The discrete indexed reflection table remains the primary scientific result. Profile parameters do not represent an inferred instrument function.
+## Parity and lab-oriented options (CLI / API)
+
+Several CIF2Peaks-parity settings are available on the shared analysis model. The desktop form currently exposes radiation, angular window, profile spacing, pseudo-Voigt η, resource guards, elasticity pairing, and Excel. The following are configured via CLI or Python `AnalysisSettings` (defaults apply when the GUI omits a control):
+
+| Control | Default in GUI path | CLI / settings |
+|---|---|---|
+| *d*-spacing filter | off (`d_min_A` / `d_max_A` = `None`) | `--d-min`, `--d-max` |
+| Profile lineshape | `pseudo_voigt` | `--profile-model` (`pseudo_voigt`, `gaussian`, `lorentzian`) |
+| Pattern axis label | `two_theta` | `--pattern-axis` (`two_theta`, `d_spacing`, `q`, `g`) |
+| Laboratory Excel views | on (`export_lab_views=True`) | `--no-lab-views` to disable Chinese `推荐峰表` / `使用说明` sheets |
+| Continuous pattern series | on | `--no-patterns` |
+| Figure generation request | off | `--figures`, `--figure-preset` (requires optional `.[figures]` when exporters draw plots) |
+
+Laboratory views add bilingual convenience sheets to `results.xlsx` without changing the English canonical CSV columns. See [SCHEMA_ALIASES.md](SCHEMA_ALIASES.md) and [ENGINE_PARITY.md](ENGINE_PARITY.md).
+
+## Quick export (no full form)
+
+For a Cu Kα, 5–120° lab-default one-shot export without opening the notebook UI:
+
+```bash
+diffractscout-quick-export path/to/sample.cif -o path/to/sample_out.xlsx
+# or
+diffractscout quick-export path/to/cifs -o path/to/bundle_dir
+```
+
+On Windows, drag CIF files or folders onto `quick_export_diffractscout.bat`. The script writes `_diffractscout.xlsx` next to the first input (bundle: `_diffractscout_bundle/`).
+
## Activity log and completion states
The Activity panel reports timestamps and separates informational, warning, and error diagnostics. A completed bundle can contain diagnostic errors for individual phases that failed while other phases succeeded. Completion messages therefore distinguish:
diff --git a/docs/SCHEMA_ALIASES.md b/docs/SCHEMA_ALIASES.md
new file mode 100644
index 0000000..69a18fa
--- /dev/null
+++ b/docs/SCHEMA_ALIASES.md
@@ -0,0 +1,149 @@
+# CIF2Peaks → DiffractScout schema aliases
+
+This document maps CIF2Peaks peak-table and intensity column names to DiffractScout
+canonical export names. Prefer the DiffractScout names in new code and papers. Legacy
+CIF2Peaks-compatible fields remain in exports where noted for interoperability.
+
+Machine-readable peak columns are defined by `PEAK_HEADERS` in
+`src/diffractscout/exporters.py`. Pattern columns are `PATTERN_HEADERS`.
+
+## Intensity channels (critical)
+
+| CIF2Peaks name | DiffractScout canonical name | Definition |
+|---|---|---|
+| `material_scattering_factor_R_hkl` | `volume_normalized_intensity_with_lp` | \(J_{hkl}^{\mathrm{with\,LP}} = I_{\mathrm{with\,LP}} / V_{\mathrm{cell}}^2\) |
+| `material_scattering_factor_R_hkl_no_lp` | `volume_normalized_intensity_no_lp` | \(J_{hkl}^{\mathrm{no\,LP}} = I_{\mathrm{no\,LP}} / V_{\mathrm{cell}}^2\) |
+
+### `R_hkl` is **not** a residual
+
+In both projects, the historical field prefix `R_hkl` is a **project-defined
+volume-normalized theoretical intensity** alias. It is **not**:
+
+- a crystallographic residual factor (Rietveld \(R\), \(R_{\mathrm{wp}}\), \(R_{\mathrm{Bragg}}\), etc.);
+- a standardized quantitative-phase-analysis scale factor or reference intensity ratio;
+- an experimentally calibrated material scattering factor.
+
+DiffractScout therefore:
+
+1. exports preferred names `volume_normalized_intensity_with_lp` and
+ `volume_normalized_intensity_no_lp`;
+2. still writes the legacy names `material_scattering_factor_R_hkl` and
+ `material_scattering_factor_R_hkl_no_lp` with **identical numeric values**;
+3. ranks derived from those channels use `rank_by_R_hkl` /
+ `rank_by_R_hkl_no_lp` as short legacy rank labels.
+
+## Peak geometry and identity
+
+| CIF2Peaks name | DiffractScout name | Notes |
+|---|---|---|
+| `phase_name` | `phase_name` | Same role |
+| `cif_name` | `cif_name` | Same role |
+| *(none / path only)* | `cif_sha256` | Always fingerprints the input CIF |
+| `formula` | `formula` | Same role |
+| `space_group` | `space_group` | Symbol string |
+| `h`, `k`, `l` | `h`, `k`, `l` | Miller indices |
+| `i` | `i` | Miller–Bravais basal index when used; blank for 3-index systems |
+| `hkl` | `hkl` | Formatted plane label, e.g. `(1 1 0)` or `(1 0 -1 0)` |
+| `family_label` | `family_label` | Symmetry-family display string |
+| `multiplicity` | `multiplicity` | Family multiplicity from the Gemmi engine |
+| `d_A` | `d_spacing_A` | \(d\)-spacing in Å |
+| `theta_deg` | `theta_deg` | Bragg angle \(\theta\) |
+| `two_theta_current_deg` / `two_theta_deg` | `two_theta_deg` | \(2\theta\) for the active wavelength |
+| `two_theta_cu_ka_deg` | `two_theta_cu_ka_deg` | Convenience \(2\theta\) at Cu Kα (\(\lambda=1.5406\) Å) |
+| `q_1_over_A` | `q_invA` | \(q = 2\pi / d\) |
+| `g_1_over_A` | `g_invA` | \(g = 1 / d\) |
+
+## Trig and form-factor helpers (exported)
+
+| CIF2Peaks name | DiffractScout name |
+|---|---|
+| `sin_theta` | `sin_theta` |
+| `cos_theta` | `cos_theta` |
+| `sin_theta_over_lambda_1_over_A` | `sin_theta_over_lambda` |
+| `sin2_theta_over_lambda2_1_over_A2` | `sin2_theta_over_lambda2` |
+| `mean_structure_factor_sq_per_multiplicity` | `mean_structure_factor_sq_per_multiplicity` |
+| `mean_structure_factor_abs_per_multiplicity` | `mean_structure_factor_abs_per_multiplicity` |
+| `coincident_hkl_family_count` | `coincident_hkl_family_count` |
+| `is_multi_family_peak` | `is_multi_family_peak` |
+
+Note: DiffractScout marks coincident families by shared \(2\theta\) bins; it does not
+merge multi-family peaks into a single intensity the way pymatgen sometimes does
+(see `ENGINE_PARITY.md`).
+
+## Raw and LP-separated intensities
+
+| CIF2Peaks name | DiffractScout name | Notes |
+|---|---|---|
+| `theoretical_intensity_unscaled` | `intensity_with_lp` | Unscaled powder line with LP; engines differ |
+| `multiplicity_structure_factor_sq` | `intensity_no_lp` | \(m_{hkl}\|F_{hkl}\|^2\) (no LP) |
+| `lp_factor` | `lp_factor` | Lorentz–polarization factor |
+| *(implicit)* | `structure_factor_sq` | \(\|F\|^2\) before multiplicity |
+| `relative_intensity` | `normalized_intensity` | Phase-internal scale; max line → 100 |
+
+## Volume-normalized helpers and ranks
+
+| CIF2Peaks name | DiffractScout name |
+|---|---|
+| `inverse_material_scattering_factor_1_over_R_hkl` | `inverse_R_hkl` |
+| `inverse_material_scattering_factor_1_over_R_hkl_no_lp` | `inverse_R_hkl_no_lp` |
+| `phase_relative_R_hkl_pct` | `phase_relative_R_hkl_pct` |
+| `phase_relative_R_hkl_no_lp_pct` | `phase_relative_R_hkl_no_lp_pct` |
+| `phase_peak_rank_by_relative_intensity` | `rank_by_intensity` |
+| `phase_peak_rank_by_R_hkl` | `rank_by_R_hkl` |
+| `phase_peak_rank_by_R_hkl_no_lp` | `rank_by_R_hkl_no_lp` |
+| `r_hkl_model_note` | `r_hkl_model_note` |
+
+## Phase mass / density (peak + phase tables)
+
+| CIF2Peaks name | DiffractScout name |
+|---|---|
+| `phase_density_g_cm3` | `density_g_cm3` |
+| `phase_formula_weight_g_mol` | `formula_weight_g_mol` |
+| `phase_cell_volume_A3` / `cell_volume_A3` | `cell_volume_A3` |
+
+## Elasticity
+
+| CIF2Peaks name | DiffractScout name | Notes |
+|---|---|---|
+| `young_modulus_hkl_normal_GPa` | `young_modulus_hkl_normal_GPa` | Plane-normal Young’s modulus from \(C_{ij}\) |
+| `elastic_status` | `elastic_status` | Status string |
+| `elastic_warning` / notes | `elastic_note` | Combined note channel |
+| `elastic_hkl_used` | *(via plane normal)* | Three-index plane normal via `plane_hkl_for_normal` |
+| `elastic_family_count` / `elastic_family_moduli_GPa` | partial | Coincident-family count is exported; multi-family modulus lists may differ |
+
+## Pattern profile
+
+| CIF2Peaks name | DiffractScout name |
+|---|---|
+| profile `two_theta_deg` | `two_theta_deg` |
+| profile `d` | `d_A` |
+| profile `q` / `g` | `q_invA` / `g_invA` |
+| axis mode | `x_axis_mode` |
+| selected abscissa | `x` |
+| `relative_intensity` | `relative_intensity` |
+
+When `AnalysisSettings.include_patterns` is false, `pattern_profiles.csv` and the
+Excel `Patterns` sheet are omitted.
+
+## Lab views (Excel only)
+
+When `export_lab_views` is true (default):
+
+| Sheet | Role |
+|---|---|
+| `推荐峰表` | Chinese beginner headers mapped from canonical peak rows |
+| `使用说明` | Bilingual-oriented guide; states \(R_{hkl}\) is not a residual |
+| `峰_` | Optional per-phase peak sheets (≤20 phases) |
+
+## Not re-exported / intentional differences
+
+| Topic | Status |
+|---|---|
+| Bit-identical intensities vs CIF2Peaks/pymatgen | **Not claimed** — Gemmi engine (see `ENGINE_PARITY.md`) |
+| Experimental pattern overlay sheet | Deferred (CIF2Peaks draft, not productized) |
+| Portable Windows EXE | Optional packaging path; not a schema column |
+
+## Chinese beginner sheet header map
+
+See `BEGINNER_PEAK_HEADERS_ZH` in `src/diffractscout/export_views.py` for the
+exact Chinese → canonical key mapping used by `推荐峰表`.
diff --git a/docs/SOURCE_LINEAGE.md b/docs/SOURCE_LINEAGE.md
index 6b5d5b4..850ba21 100644
--- a/docs/SOURCE_LINEAGE.md
+++ b/docs/SOURCE_LINEAGE.md
@@ -35,6 +35,20 @@ The source projects were connected by adjacent files and naming conventions. Dif
- Voigt engineering-shear convention for hkl-normal Young's modulus.
- CSV/Excel export intended for Origin, Excel and Python workflows.
+## CIF2Peaks UX → module mapping
+
+Desktop and lab-facing behaviour from CIF2Peaks was reimplemented (not vendored as Tk UI) into headless modules consumed by CLI, GUI, and `quick_export`:
+
+| DiffractScout module | Role relative to CIF2Peaks UX |
+|---|---|
+| `hkl.py` | Miller / Miller–Bravais labels and family helpers for peak tables |
+| `export_views.py` | Laboratory Excel views: Chinese beginner peak headers and usage-guide sheets |
+| `plotting` / figure flags | Optional figure request path (`include_figures`, `figure_preset`; exporters may no-op until matplotlib extras are used) |
+| `elasticity_input.py` | User-supplied Cij parsing helpers without Tk (cubic / matrix text) |
+| `quick_export.py` | One-shot local export with Cu Kα lab defaults and optional `.xlsx` path shortcut |
+
+Canonical intensity names and legacy `R_hkl` aliases are documented in `SCHEMA_ALIASES.md`; engine differences versus CIF2Peaks/pymatgen are in `ENGINE_PARITY.md`.
+
## Attribution
Both source repositories were licensed under MIT with copyright `2026 D-sudoasd`. DiffractScout is licensed under MIT and retains the source notice in `NOTICE.md`. Git history in the new repository records subsequent modifications; the source snapshot table provides the audit trail for the initial merge.
diff --git a/pyproject.toml b/pyproject.toml
index 6cb771b..ce73d45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,12 +54,22 @@ test = [
"PyYAML>=6.0",
"tomli>=2.0; python_version < '3.11'",
]
+# Optional display figures and JOSS paper figure regeneration.
+figures = [
+ "matplotlib>=3.7",
+]
+# Alias kept for existing paper/build docs.
paper = [
"matplotlib>=3.7",
]
+# Optional Tk drag-and-drop helpers for future desktop UX (not required by core GUI).
+"gui-dnd" = [
+ "tkinterdnd2>=0.3.0",
+]
[project.scripts]
diffractscout = "diffractscout.cli:main"
+diffractscout-quick-export = "diffractscout.quick_export:main"
[project.gui-scripts]
diffractscout-gui = "diffractscout.gui:main"
diff --git a/quick_export_diffractscout.bat b/quick_export_diffractscout.bat
new file mode 100644
index 0000000..e245f67
--- /dev/null
+++ b/quick_export_diffractscout.bat
@@ -0,0 +1,49 @@
+@echo off
+REM Drag-and-drop one-shot CIF export (Cu Ka lab defaults).
+REM Usage: drop CIF files/folders onto this script, or pass paths on the command line.
+setlocal EnableExtensions EnableDelayedExpansion
+cd /d "%~dp0"
+
+if "%~1"=="" (
+ echo Drag CIF files or folders onto this script, or run:
+ echo %~nx0 path\to\sample.cif [more paths...]
+ echo.
+ echo Output defaults to ^\^_diffractscout.xlsx
+ echo with a verifiable bundle at ^_diffractscout_bundle\
+ pause
+ exit /b 1
+)
+
+REM Default Excel path next to the first dropped input.
+set "OUT=%~dp1%~n1_diffractscout.xlsx"
+
+where py >nul 2>&1
+if %ERRORLEVEL%==0 (
+ py -3 -m diffractscout quick-export -o "!OUT!" %*
+ set "RC=!ERRORLEVEL!"
+) else (
+ where diffractscout-quick-export >nul 2>&1
+ if !ERRORLEVEL!==0 (
+ diffractscout-quick-export -o "!OUT!" %*
+ set "RC=!ERRORLEVEL!"
+ ) else (
+ echo ERROR: Neither "py -3" nor diffractscout-quick-export was found.
+ echo Install with: py -3 -m pip install -e .
+ pause
+ exit /b 1
+ )
+)
+
+if not "!RC!"=="0" (
+ echo.
+ echo quick-export finished with exit code !RC!
+ pause
+ exit /b !RC!
+)
+
+echo.
+echo Excel: !OUT!
+echo Done.
+pause
+endlocal
+exit /b 0
diff --git a/scripts/package_windows_portable.py b/scripts/package_windows_portable.py
new file mode 100644
index 0000000..d835179
--- /dev/null
+++ b/scripts/package_windows_portable.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""Document / sketch a Windows portable (PyInstaller) build for DiffractScout.
+
+Status: **stub / documentation only**. This script does not run a full freeze
+build. Prefer an editable install plus the root batch launchers for day-to-day
+Windows use:
+
+* ``启动DiffractScout.bat`` — desktop GUI
+* ``quick_export_diffractscout.bat`` — drag-and-drop quick-export
+
+When a portable single-folder or one-file build is required, the recommended
+approach is PyInstaller against the installed console/GUI entry points.
+
+Example outline (run from a clean venv after ``pip install -e ".[figures]"``)::
+
+ pyinstaller ^
+ --noconfirm --clean ^
+ --name DiffractScout ^
+ --collect-all gemmi ^
+ --collect-all spglib ^
+ --hidden-import diffractscout.gui ^
+ --hidden-import diffractscout.quick_export ^
+ -m diffractscout
+
+Or target the entry-point scripts generated by setuptools after install
+(``diffractscout.exe``, ``diffractscout-gui.exe``) with a custom ``.spec``.
+
+Notes for a real freeze:
+
+* Tk GUI needs the Tcl/Tk data files bundled (PyInstaller usually handles this).
+* Optional MP path needs ``.[mp]`` and network access at runtime, not freeze time.
+* Optional figures need matplotlib (``.[figures]``).
+* Ship ``启动DiffractScout.bat`` / ``quick_export_diffractscout.bat`` only when the
+ frozen layout still exposes ``py -3 -m diffractscout``; for pure frozen trees,
+ point the batch files at the frozen executables instead.
+* Do not embed user API keys or experimental datasets in the portable package.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from textwrap import dedent
+
+
+STATUS = "stub"
+SUMMARY = dedent(
+ """\
+ package_windows_portable.py — status: stub (documentation only)
+
+ This helper does not produce a portable build yet. Use:
+
+ py -3 -m pip install -e ".[figures,gui-dnd]"
+ 启动DiffractScout.bat
+ quick_export_diffractscout.bat
+
+ For a future PyInstaller freeze, see the module docstring
+ (python scripts/package_windows_portable.py --help) and the examples
+ printed by --print-recipe.
+ """
+)
+
+RECIPE = dedent(
+ """\
+ # Suggested future recipe (not executed by this stub)
+ python -m venv .venv-portable
+ .venv-portable\\Scripts\\activate
+ python -m pip install -U pip
+ python -m pip install -e ".[figures]"
+ python -m pip install pyinstaller
+ pyinstaller --noconfirm --clean --name DiffractScout ^
+ --collect-all gemmi --collect-all spglib ^
+ --hidden-import diffractscout.gui ^
+ --hidden-import diffractscout.quick_export ^
+ -m diffractscout
+ # Then copy batch launchers and edit them to call dist\\DiffractScout\\DiffractScout.exe
+ """
+)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="package_windows_portable",
+ description=(
+ "Windows portable packaging helper for DiffractScout. "
+ "Currently a documentation stub: it does not run PyInstaller."
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=dedent(
+ """\
+ status:
+ stub — no freeze build is performed.
+
+ related launchers (repo root):
+ 启动DiffractScout.bat
+ quick_export_diffractscout.bat
+ """
+ ),
+ )
+ parser.add_argument(
+ "--status",
+ action="store_true",
+ help="Print packaging status and exit 0.",
+ )
+ parser.add_argument(
+ "--print-recipe",
+ action="store_true",
+ help="Print a sample PyInstaller recipe (not executed).",
+ )
+ parser.add_argument(
+ "--build",
+ action="store_true",
+ help="Reserved for a future freeze implementation (currently errors).",
+ )
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ if args.build:
+ print(
+ "ERROR: portable freeze is not implemented in this stub.\n"
+ "Use --print-recipe for a manual PyInstaller outline, or install\n"
+ "the package and use the Windows batch launchers.",
+ file=sys.stderr,
+ )
+ return 2
+
+ if args.print_recipe:
+ print(RECIPE)
+ return 0
+
+ # Default and --status: explain current state.
+ print(SUMMARY)
+ print(f"status={STATUS}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/diffractscout/cli.py b/src/diffractscout/cli.py
index 0ff8f63..7e0105a 100644
--- a/src/diffractscout/cli.py
+++ b/src/diffractscout/cli.py
@@ -38,6 +38,14 @@ def _analysis_settings(args: argparse.Namespace) -> AnalysisSettings:
include_elasticity=not args.no_elasticity,
max_profile_points=args.max_profile_points,
max_reflection_estimate=args.max_reflection_estimate,
+ d_min_A=args.d_min,
+ d_max_A=args.d_max,
+ profile_model=args.profile_model,
+ pattern_axis=args.pattern_axis,
+ include_figures=bool(args.figures),
+ figure_preset=args.figure_preset,
+ export_lab_views=not args.no_lab_views,
+ include_patterns=not args.no_patterns,
)
@@ -108,6 +116,32 @@ def _add_analysis_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--no-elasticity", action="store_true", help="Do not discover, copy, or calculate paired elastic data.")
parser.add_argument("--max-profile-points", type=int, default=1_000_000, help="Safety limit for the generated display-profile grid.")
parser.add_argument("--max-reflection-estimate", type=int, default=2_000_000, help="Safety limit for reciprocal-lattice candidate generation.")
+ parser.add_argument("--d-min", type=float, default=None, dest="d_min", help="Minimum d-spacing filter in Å.")
+ parser.add_argument("--d-max", type=float, default=None, dest="d_max", help="Maximum d-spacing filter in Å.")
+ parser.add_argument(
+ "--profile-model",
+ choices=("pseudo_voigt", "gaussian", "lorentzian"),
+ default="pseudo_voigt",
+ help="Display-profile lineshape model.",
+ )
+ parser.add_argument(
+ "--pattern-axis",
+ choices=("two_theta", "d_spacing", "q", "g"),
+ default="two_theta",
+ help="Primary axis label for continuous pattern exports.",
+ )
+ parser.add_argument("--figures", action="store_true", help="Request figure generation when exporters support it.")
+ parser.add_argument("--figure-preset", default="publication", help="Named figure style preset.")
+ parser.add_argument(
+ "--no-lab-views",
+ action="store_true",
+ help="Skip laboratory convenience views in the result bundle.",
+ )
+ parser.add_argument(
+ "--no-patterns",
+ action="store_true",
+ help="Skip continuous powder-pattern series in exports.",
+ )
parser.add_argument("--no-excel", action="store_true", help="Skip results.xlsx; CSV and JSON remain enabled.")
parser.add_argument("--overwrite", action="store_true", help="Replace only an existing DiffractScout output bundle.")
parser.add_argument("--json", action="store_true", help="Print the final summary as JSON.")
@@ -186,6 +220,15 @@ def build_parser() -> argparse.ArgumentParser:
benchmark.add_argument("--overwrite", action="store_true")
benchmark.add_argument("--json", action="store_true")
+ quick = subparsers.add_parser(
+ "quick-export",
+ help="One-shot local CIF analysis with lab-friendly defaults (Excel + verifiable bundle).",
+ )
+ quick.add_argument("inputs", nargs="+", help="CIF files or directories.")
+ quick.add_argument("-o", "--output", required=True, help="Bundle directory or .xlsx path.")
+ quick.add_argument("--no-recursive", action="store_true")
+ _add_analysis_options(quick)
+
subparsers.add_parser("gui", help="Launch the optional Tk desktop interface.")
return parser
@@ -275,6 +318,20 @@ def main(argv: Sequence[str] | None = None) -> int:
print("PASS" if report["all_passed"] and verification["ok"] else "FAIL")
return 0 if report["all_passed"] and verification["ok"] else 2
+ if args.command == "quick-export":
+ from .quick_export import quick_export
+
+ result = quick_export(
+ args.inputs,
+ args.output,
+ settings=_analysis_settings(args),
+ recursive=not args.no_recursive,
+ include_excel=not args.no_excel,
+ overwrite=args.overwrite,
+ )
+ _print_result(result, as_json=args.json)
+ return _pipeline_exit_code(result)
+
if args.command == "gui":
from .gui import main as gui_main
diff --git a/src/diffractscout/diffraction.py b/src/diffractscout/diffraction.py
index 31c3df2..ddecca1 100644
--- a/src/diffractscout/diffraction.py
+++ b/src/diffractscout/diffraction.py
@@ -11,7 +11,9 @@
import numpy as np
from .elasticity import SUPPORTED_DIRECTIONAL_FRAMES, young_modulus_hkl_normal_GPa
+from .hkl import family_label_hkl, miller_bravais_i, uses_miller_bravais
from .models import AnalysisSettings, ElasticTensor, PhaseAnalysis, ReflectionRecord, StructureRecord
+from .structure import structure_mass_metadata
from .utils import package_versions, utc_now_iso
ENERGY_WAVELENGTH_KEV_A = 12.398419843320026
@@ -23,6 +25,9 @@
"Ag Ka": 0.5594,
"Custom": None,
}
+CU_KA_WAVELENGTH_A = float(X_RAY_SOURCES_A["Cu Ka"]) # type: ignore[arg-type]
+PROFILE_MODELS = frozenset({"pseudo_voigt", "gaussian", "lorentzian"})
+PATTERN_AXES = frozenset({"two_theta", "d_spacing", "q", "g"})
SCIENTIFIC_BOUNDARY = (
"The output is a kinematic theoretical powder reference. It is not phase identification, "
@@ -64,6 +69,57 @@ def resolve_wavelength(settings: AnalysisSettings) -> tuple[float, float | None,
return wavelength, ENERGY_WAVELENGTH_KEV_A / wavelength, f"source_preset:{settings.source_preset}"
+def two_theta_for_d(d_spacing_A: float, wavelength_A: float) -> float | None:
+ """Bragg 2θ (degrees) for spacing d and wavelength λ, or None if inaccessible."""
+
+ if not np.isfinite(d_spacing_A) or not np.isfinite(wavelength_A):
+ return None
+ if d_spacing_A <= 0 or wavelength_A <= 0:
+ return None
+ argument = wavelength_A / (2.0 * d_spacing_A)
+ if argument <= 0 or argument > 1.0:
+ return None
+ return float(np.rad2deg(2.0 * np.arcsin(argument)))
+
+
+def apply_d_range_to_settings(settings: AnalysisSettings) -> AnalysisSettings:
+ """Narrow the 2θ window by intersection with Bragg angles from d bounds.
+
+ Larger d maps to smaller 2θ. When ``d_min_A`` / ``d_max_A`` are set, the
+ search window becomes the intersection of the user 2θ range with the Bragg
+ interval implied by those d limits. Reflection-level d filtering is still
+ applied after geometry so peaks outside the d window are dropped even if
+ the angular intersection cannot fully express a one-sided bound.
+ """
+
+ if settings.d_min_A is None and settings.d_max_A is None:
+ return settings
+ wavelength, _, _ = resolve_wavelength(settings)
+ tmin = float(settings.two_theta_min_deg)
+ tmax = float(settings.two_theta_max_deg)
+ if settings.d_max_A is not None:
+ # d_max → lower 2θ bound
+ tt = two_theta_for_d(float(settings.d_max_A), wavelength)
+ if tt is not None:
+ tmin = max(tmin, tt)
+ if settings.d_min_A is not None:
+ # d_min → upper 2θ bound
+ tt = two_theta_for_d(float(settings.d_min_A), wavelength)
+ if tt is not None:
+ tmax = min(tmax, tt)
+ if not (0.0 <= tmin < tmax <= 180.0):
+ # Empty intersection: keep original angles; d filters will drop peaks.
+ return settings
+ return replace(settings, two_theta_min_deg=tmin, two_theta_max_deg=tmax)
+
+
+def _safe_inverse(value: float) -> float | None:
+ if not np.isfinite(value) or value == 0.0:
+ return None
+ inverse = 1.0 / float(value)
+ return float(inverse) if np.isfinite(inverse) else None
+
+
def _validate_settings(settings: AnalysisSettings) -> None:
values = (
settings.two_theta_min_deg,
@@ -80,6 +136,28 @@ def _validate_settings(settings: AnalysisSettings) -> None:
raise ValueError("step_deg and fwhm_deg must be positive.")
if not 0 <= settings.profile_eta <= 1:
raise ValueError("profile_eta must lie in [0, 1].")
+ if settings.profile_model not in PROFILE_MODELS:
+ raise ValueError(
+ f"Unknown profile_model {settings.profile_model!r}; "
+ f"choose one of: {', '.join(sorted(PROFILE_MODELS))}."
+ )
+ if settings.pattern_axis not in PATTERN_AXES:
+ raise ValueError(
+ f"Unknown pattern_axis {settings.pattern_axis!r}; "
+ f"choose one of: {', '.join(sorted(PATTERN_AXES))}."
+ )
+ for name in ("d_min_A", "d_max_A"):
+ value = getattr(settings, name)
+ if value is None:
+ continue
+ if not np.isfinite(value) or float(value) <= 0:
+ raise ValueError(f"{name} must be a finite positive number when set.")
+ if (
+ settings.d_min_A is not None
+ and settings.d_max_A is not None
+ and float(settings.d_min_A) > float(settings.d_max_A)
+ ):
+ raise ValueError("d_min_A must be <= d_max_A when both are set.")
for name in ("max_profile_points", "max_reflection_estimate"):
value = getattr(settings, name)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
@@ -166,6 +244,19 @@ def _pseudo_voigt(grid: np.ndarray, center: float, fwhm: float, eta: float) -> n
return eta * _lorentzian(grid, center, fwhm) + (1.0 - eta) * _gaussian(grid, center, fwhm)
+def _peak_profile(
+ grid: np.ndarray,
+ center: float,
+ fwhm: float,
+ settings: AnalysisSettings,
+) -> np.ndarray:
+ if settings.profile_model == "gaussian":
+ return _gaussian(grid, center, fwhm)
+ if settings.profile_model == "lorentzian":
+ return _lorentzian(grid, center, fwhm)
+ return _pseudo_voigt(grid, center, fwhm, settings.profile_eta)
+
+
def _rank_desc(values: list[float]) -> list[int]:
ordered = sorted(
enumerate(values),
@@ -218,6 +309,8 @@ def simulate_powder_pattern(
) -> PhaseAnalysis:
_validate_settings(settings)
wavelength, energy, wavelength_source = resolve_wavelength(settings)
+ # Narrow 2θ by Bragg intersection with optional d bounds, then filter by d.
+ settings = apply_d_range_to_settings(settings)
point_count = _profile_point_count(settings)
if point_count > settings.max_profile_points:
raise ValueError(
@@ -231,6 +324,8 @@ def simulate_powder_pattern(
float(np.nextafter(d_min, 0.0)),
float(d_min) * (1.0 - DMIN_SEARCH_RELATIVE_MARGIN),
)
+ # If user d_min is stricter (larger) than Bragg d_min, still search to Bragg
+ # d_min but filter reflections; if user d_min is smaller, Bragg already limits.
cell_volume = float(structure.small_structure.cell.volume)
reflection_estimate = _reflection_search_estimate(cell_volume, float(d_min_search))
if reflection_estimate > settings.max_reflection_estimate:
@@ -252,6 +347,7 @@ def simulate_powder_pattern(
f"max_reflection_estimate={settings.max_reflection_estimate:,}."
)
calculator = gemmi.StructureFactorCalculatorX(structure.small_structure.cell)
+ four_index = uses_miller_bravais(structure.space_group_object)
reflections: list[ReflectionRecord] = []
for raw_hkl in miller_array:
@@ -261,6 +357,10 @@ def simulate_powder_pattern(
d_spacing = float(structure.small_structure.cell.calculate_d(hkl))
if not np.isfinite(d_spacing) or d_spacing <= 0:
continue
+ if settings.d_min_A is not None and d_spacing < float(settings.d_min_A) - 1e-12:
+ continue
+ if settings.d_max_A is not None and d_spacing > float(settings.d_max_A) + 1e-12:
+ continue
argument = wavelength / (2.0 * d_spacing)
if argument <= 0 or argument > 1:
continue
@@ -286,12 +386,26 @@ def simulate_powder_pattern(
representative,
requested=settings.include_elasticity,
)
+ sin_theta = float(np.sin(theta_rad))
+ cos_theta = float(np.cos(theta_rad))
+ sin_over_lambda = float(sin_theta / wavelength) if wavelength > 0 else float("nan")
+ sin2_over_lambda2 = float(sin_over_lambda**2) if np.isfinite(sin_over_lambda) else float("nan")
+ mean_sf_sq = structure_factor_sq
+ mean_sf_abs = float(math.sqrt(mean_sf_sq)) if mean_sf_sq >= 0 and np.isfinite(mean_sf_sq) else float("nan")
+ index_i = miller_bravais_i(representative[0], representative[1]) if four_index else None
+ cu_ka_two_theta = two_theta_for_d(d_spacing, CU_KA_WAVELENGTH_A)
reflections.append(
ReflectionRecord(
h=representative[0],
k=representative[1],
l=representative[2],
- family_label="{" + " ".join(str(value) for value in representative) + "}",
+ family_label=family_label_hkl(
+ representative[0],
+ representative[1],
+ representative[2],
+ use_four_index=four_index,
+ i=index_i,
+ ),
multiplicity=multiplicity,
d_spacing_A=d_spacing,
theta_deg=float(np.rad2deg(theta_rad)),
@@ -308,15 +422,56 @@ def simulate_powder_pattern(
young_modulus_hkl_normal_GPa=modulus,
elastic_status=elastic_status,
elastic_note=elastic_note,
+ i=index_i,
+ two_theta_cu_ka_deg=float(cu_ka_two_theta) if cu_ka_two_theta is not None else 0.0,
+ inverse_R_hkl=_safe_inverse(r_with_lp),
+ inverse_R_hkl_no_lp=_safe_inverse(r_no_lp),
+ sin_theta=sin_theta,
+ cos_theta=cos_theta,
+ sin_theta_over_lambda=sin_over_lambda,
+ sin2_theta_over_lambda2=sin2_over_lambda2,
+ mean_structure_factor_sq_per_multiplicity=mean_sf_sq,
+ mean_structure_factor_abs_per_multiplicity=mean_sf_abs,
+ r_hkl_model_note="R_hkl := I / V_cell^2 (project-defined; not a residual factor)",
)
)
reflections.sort(key=lambda item: (item.two_theta_deg, item.h, item.k, item.l))
+
+ # Mark coincident families that share the same peak position within 1e-8 deg.
+ if reflections:
+ groups: dict[float, list[int]] = {}
+ for index, item in enumerate(reflections):
+ key = round(item.two_theta_deg, 8)
+ groups.setdefault(key, []).append(index)
+ for indices in groups.values():
+ count = len(indices)
+ if count <= 1:
+ continue
+ for index in indices:
+ reflections[index] = replace(
+ reflections[index],
+ is_multi_family_peak=True,
+ coincident_hkl_family_count=count,
+ )
+
finite_intensities = [item.intensity_with_lp for item in reflections if np.isfinite(item.intensity_with_lp)]
maximum = max(finite_intensities) if finite_intensities else 0.0
intensity_ranks = _rank_desc([item.intensity_with_lp for item in reflections])
r_ranks = _rank_desc([item.material_scattering_factor_R_hkl for item in reflections])
r_no_lp_ranks = _rank_desc([item.material_scattering_factor_R_hkl_no_lp for item in reflections])
+ finite_r = [
+ item.material_scattering_factor_R_hkl
+ for item in reflections
+ if np.isfinite(item.material_scattering_factor_R_hkl)
+ ]
+ finite_r_no_lp = [
+ item.material_scattering_factor_R_hkl_no_lp
+ for item in reflections
+ if np.isfinite(item.material_scattering_factor_R_hkl_no_lp)
+ ]
+ max_r = max(finite_r) if finite_r else 0.0
+ max_r_no_lp = max(finite_r_no_lp) if finite_r_no_lp else 0.0
reflections = [
replace(
item,
@@ -326,6 +481,16 @@ def simulate_powder_pattern(
rank_by_intensity=intensity_ranks[index],
rank_by_R_hkl=r_ranks[index],
rank_by_R_hkl_no_lp=r_no_lp_ranks[index],
+ phase_relative_R_hkl_pct=(
+ 100.0 * item.material_scattering_factor_R_hkl / max_r
+ if max_r > 0 and np.isfinite(item.material_scattering_factor_R_hkl)
+ else 0.0
+ ),
+ phase_relative_R_hkl_no_lp_pct=(
+ 100.0 * item.material_scattering_factor_R_hkl_no_lp / max_r_no_lp
+ if max_r_no_lp > 0 and np.isfinite(item.material_scattering_factor_R_hkl_no_lp)
+ else 0.0
+ ),
)
for index, item in enumerate(reflections)
]
@@ -335,11 +500,11 @@ def simulate_powder_pattern(
profile = np.zeros_like(grid)
for item in reflections:
if np.isfinite(item.intensity_with_lp):
- profile += item.intensity_with_lp * _pseudo_voigt(
+ profile += item.intensity_with_lp * _peak_profile(
grid,
item.two_theta_deg,
settings.fwhm_deg,
- settings.profile_eta,
+ settings,
)
if profile.size and float(np.max(profile)) > 0:
profile = profile / float(np.max(profile)) * 100.0
@@ -353,6 +518,7 @@ def simulate_powder_pattern(
if active_elastic_tensor is not None:
warnings.extend(item for item in active_elastic_tensor.warnings if item not in warnings)
+ mass_meta = structure_mass_metadata(structure)
metadata = {
"generated_at_utc": utc_now_iso(),
"cif_sha256": structure.cif_sha256,
@@ -362,13 +528,22 @@ def simulate_powder_pattern(
"two_theta_range_deg": [settings.two_theta_min_deg, settings.two_theta_max_deg],
"step_deg": settings.step_deg,
"fwhm_deg": settings.fwhm_deg,
- "profile_model": "pseudo_voigt",
+ "profile_model": settings.profile_model,
"profile_eta": settings.profile_eta,
+ "pattern_axis": settings.pattern_axis,
"profile_point_count": int(grid.size),
"max_profile_points": settings.max_profile_points,
+ # Geometric Bragg d-min from the (possibly narrowed) 2θ max — existing contract.
"d_min_A": float(d_min),
"d_min_search_A": float(d_min_search),
"d_min_search_relative_margin": DMIN_SEARCH_RELATIVE_MARGIN,
+ "filter_d_min_A": settings.d_min_A,
+ "filter_d_max_A": settings.d_max_A,
+ "d_max_A": settings.d_max_A,
+ "cell_volume_A3": mass_meta["cell_volume_A3"],
+ "formula_weight_g_mol": mass_meta["formula_weight_g_mol"],
+ "density_g_cm3": mass_meta["density_g_cm3"],
+ "cu_ka_wavelength_A": CU_KA_WAVELENGTH_A,
"reflection_search_estimate": reflection_estimate,
"miller_candidates_generated": len(miller_array),
"max_reflection_estimate": settings.max_reflection_estimate,
@@ -381,6 +556,10 @@ def simulate_powder_pattern(
),
"q_definition": "2*pi/d = 4*pi*sin(theta)/lambda",
"elasticity_requested": settings.include_elasticity,
+ "include_figures": settings.include_figures,
+ "figure_preset": settings.figure_preset,
+ "export_lab_views": settings.export_lab_views,
+ "include_patterns": settings.include_patterns,
"scientific_boundary": SCIENTIFIC_BOUNDARY,
"software_versions": package_versions(),
}
diff --git a/src/diffractscout/elasticity_input.py b/src/diffractscout/elasticity_input.py
new file mode 100644
index 0000000..383fc12
--- /dev/null
+++ b/src/diffractscout/elasticity_input.py
@@ -0,0 +1,87 @@
+"""Pure helpers for user-supplied elastic stiffness (Cij) tensors.
+
+Ported from CIF2Peaks GUI parsing helpers without any Tk dependency.
+All matrices are validated through :func:`validate_elastic_tensor`.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Iterable
+
+import numpy as np
+
+from .elasticity import validate_elastic_tensor
+from .models import ElasticTensor
+
+_SOURCE_PROVIDER = "user_input"
+_TOKEN_SPLIT = re.compile(r"[\s,;|]+")
+
+
+def parse_cubic_cij(
+ c11: float,
+ c12: float,
+ c44: float,
+ source: str = "",
+) -> ElasticTensor:
+ """Build a cubic Voigt stiffness matrix from C11, C12, C44 (GPa)."""
+
+ c11_f = float(c11)
+ c12_f = float(c12)
+ c44_f = float(c44)
+ matrix = [
+ [c11_f, c12_f, c12_f, 0.0, 0.0, 0.0],
+ [c12_f, c11_f, c12_f, 0.0, 0.0, 0.0],
+ [c12_f, c12_f, c11_f, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, c44_f, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, c44_f, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, c44_f],
+ ]
+ return validate_elastic_tensor(
+ matrix,
+ source_provider=_SOURCE_PROVIDER,
+ source_record_id=str(source or ""),
+ nature_of_data=str(source or "user_input"),
+ )
+
+
+def parse_cij_matrix_6x6(
+ values: Iterable[Iterable[object]] | np.ndarray,
+) -> ElasticTensor:
+ """Validate a full 6×6 Voigt stiffness matrix in GPa."""
+
+ return validate_elastic_tensor(
+ values,
+ source_provider=_SOURCE_PROVIDER,
+ nature_of_data="user_input",
+ )
+
+
+def parse_cij_paste_text(text: str) -> list[list[float]]:
+ """Parse a pasted 6×6 Cij block into a nested list of floats.
+
+ Accepts whitespace-, comma-, semicolon-, or pipe-separated tokens (36 values).
+ """
+
+ tokens = [token for token in _TOKEN_SPLIT.split(str(text).strip()) if token]
+ if len(tokens) != 36:
+ raise ValueError(
+ f"Expected 36 numeric values for a 6x6 Cij matrix, got {len(tokens)}."
+ )
+ try:
+ numbers = [float(token) for token in tokens]
+ except ValueError as exc:
+ raise ValueError("Cij paste text must contain only numeric values.") from exc
+ return [numbers[row * 6 : (row + 1) * 6] for row in range(6)]
+
+
+def format_cij_matrix(tensor: ElasticTensor) -> str:
+ """Format a validated stiffness matrix as a readable 6-line string."""
+
+ matrix = np.asarray(tensor.stiffness_GPa, dtype=float)
+ if matrix.shape != (6, 6):
+ raise ValueError("format_cij_matrix requires a 6x6 stiffness matrix.")
+ lines: list[str] = []
+ for row in matrix:
+ lines.append(" ".join(f"{float(value):.6g}" for value in row))
+ return "\n".join(lines)
diff --git a/src/diffractscout/export_views.py b/src/diffractscout/export_views.py
new file mode 100644
index 0000000..e2ac880
--- /dev/null
+++ b/src/diffractscout/export_views.py
@@ -0,0 +1,113 @@
+"""Lab-oriented Excel views: beginner Chinese peak table and usage guide.
+
+These sheets are additive presentation layers over the canonical English CSV/XLSX
+exports. Scientific definitions follow SCIENTIFIC_CONTRACTS.md: R_hkl aliases are
+project-defined volume-normalized theoretical intensities, not residuals or QPA.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+# Chinese display header -> canonical peak_rows / PEAK_HEADERS key.
+BEGINNER_PEAK_HEADERS_ZH: dict[str, str] = {
+ "物相名称": "phase_name",
+ "CIF文件": "cif_name",
+ "化学式": "formula",
+ "空间群": "space_group",
+ "h": "h",
+ "k": "k",
+ "i": "i",
+ "l": "l",
+ "晶面指标": "hkl",
+ "晶面族": "family_label",
+ "多重度": "multiplicity",
+ "d间距_Å": "d_spacing_A",
+ "θ_deg": "theta_deg",
+ "2θ_deg": "two_theta_deg",
+ "2θ_CuKa_deg": "two_theta_cu_ka_deg",
+ "q_1/Å": "q_invA",
+ "g_1/Å": "g_invA",
+ "相对强度": "normalized_intensity",
+ "强度_含LP": "intensity_with_lp",
+ "强度_无LP": "intensity_no_lp",
+ "体积归一强度_含LP_R_hkl": "volume_normalized_intensity_with_lp",
+ "体积归一强度_无LP": "volume_normalized_intensity_no_lp",
+ "相内相对R_hkl_%": "phase_relative_R_hkl_pct",
+ "强度排序": "rank_by_intensity",
+ "R_hkl排序": "rank_by_R_hkl",
+ "杨氏模量_hkl法向_GPa": "young_modulus_hkl_normal_GPa",
+ "弹性状态": "elastic_status",
+ "波长_Å": "wavelength_A",
+ "晶胞体积_Å3": "cell_volume_A3",
+ "式量_g_mol": "formula_weight_g_mol",
+ "密度_g_cm3": "density_g_cm3",
+ "R_hkl说明": "r_hkl_model_note",
+}
+
+
+def beginner_peak_rows_zh(peaks: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Map canonical peak row dicts onto Chinese beginner headers."""
+
+ rows: list[dict[str, Any]] = []
+ for peak in peaks:
+ rows.append({zh: peak.get(en) for zh, en in BEGINNER_PEAK_HEADERS_ZH.items()})
+ return rows
+
+
+def user_guide_rows() -> list[list[str]]:
+ """Two-column 使用说明 content for the Excel lab view."""
+
+ return [
+ ["项目", "说明"],
+ ["软件", "DiffractScout — 理论粉末 XRD 参考与物相候选 scout(非实验反演)"],
+ ["推荐峰表", "面向实验室阅读的中文精简峰表;完整英文字段见 Peaks 工作表与 peak_reference.csv"],
+ ["Peaks / peak_reference.csv", "完整索引峰表(规范英文列名,可复现分析)"],
+ ["Patterns / pattern_profiles.csv", "显示用峰形轮廓;不是仪器分辨率模型"],
+ ["d_spacing_A / d_A", "面间距 d(Å)"],
+ ["two_theta_deg", "当前波长下的 2θ(°)"],
+ ["two_theta_cu_ka_deg", "同一 d 在 Cu Kα(1.5406 Å)下的 2θ 便捷列"],
+ ["q_invA", "q = 2π/d = 4π sin(θ)/λ(1/Å)"],
+ ["g_invA", "g = 1/d(1/Å)"],
+ ["normalized_intensity / 相对强度", "相内将最强线标为 100 的显示归一;不可直接跨物相比对"],
+ ["intensity_with_lp", "多重度 × |F|² × Lorentz–polarization"],
+ ["intensity_no_lp", "多重度 × |F|²(不含 LP)"],
+ [
+ "volume_normalized_intensity_with_lp / R_hkl",
+ "I_with_LP / V_cell² — 项目定义的体积归一理论强度(历史别名 material_scattering_factor_R_hkl)",
+ ],
+ [
+ "volume_normalized_intensity_no_lp",
+ "I_no_LP / V_cell² — 同上通道但不含 LP(历史别名 material_scattering_factor_R_hkl_no_lp)",
+ ],
+ [
+ "重要:R_hkl 不是残差",
+ "R_hkl 不是 Rietveld R / Rwp / RBragg 等晶体学残差因子,也不是标准化 QPA 系数或实验标定散射因子",
+ ],
+ [
+ "本软件不做",
+ "物相鉴定、Rietveld/Le Bail/Pawley 精修、定量相分析(QPA)、绝对强度标定、择优取向/吸收/背底推断",
+ ],
+ ["杨氏模量列", "可选:沿 hkl 倒易法向的 E(n);依赖匹配的弹性张量与坐标框架"],
+ ["scientific_boundary", "完整科学边界见 provenance.json 与 SCIENTIFIC_CONTRACTS.md"],
+ ["复现", "使用同一 CIF SHA-256、波长设置与软件版本;manifest.json 提供文件 SHA-256 清单"],
+ ]
+
+
+def safe_excel_sheet_title(name: str, used: set[str] | None = None, max_len: int = 31) -> str:
+ """Excel sheet titles: max 31 chars; no : \\ / ? * [ ]."""
+
+ cleaned = re.sub(r"[:\\/?*\[\]]", "_", str(name)).strip()
+ cleaned = cleaned or "phase"
+ cleaned = cleaned[:max_len]
+ if used is None:
+ return cleaned
+ candidate = cleaned
+ suffix = 2
+ while candidate in used:
+ tail = f"_{suffix}"
+ candidate = (cleaned[: max_len - len(tail)] + tail) if len(cleaned) + len(tail) > max_len else cleaned + tail
+ suffix += 1
+ used.add(candidate)
+ return candidate
diff --git a/src/diffractscout/exporters.py b/src/diffractscout/exporters.py
index af1df53..4fb8bfe 100644
--- a/src/diffractscout/exporters.py
+++ b/src/diffractscout/exporters.py
@@ -14,6 +14,12 @@
from openpyxl.utils import get_column_letter
from .diffraction import SCIENTIFIC_BOUNDARY
+from .export_views import (
+ BEGINNER_PEAK_HEADERS_ZH,
+ beginner_peak_rows_zh,
+ safe_excel_sheet_title,
+ user_guide_rows,
+)
from .models import (
AnalysisSettings,
CandidateRecord,
@@ -62,6 +68,9 @@
"wavelength_A",
"energy_keV",
"wavelength_source",
+ "formula_weight_g_mol",
+ "density_g_cm3",
+ "cell_volume_A3",
"elastic_status",
"elastic_source_provider",
"elastic_source_record_id",
@@ -77,6 +86,7 @@
"space_group",
"h",
"k",
+ "i",
"l",
"hkl",
"family_label",
@@ -84,9 +94,16 @@
"d_spacing_A",
"theta_deg",
"two_theta_deg",
+ "two_theta_cu_ka_deg",
"q_invA",
"g_invA",
+ "sin_theta",
+ "cos_theta",
+ "sin_theta_over_lambda",
+ "sin2_theta_over_lambda2",
"structure_factor_sq",
+ "mean_structure_factor_sq_per_multiplicity",
+ "mean_structure_factor_abs_per_multiplicity",
"intensity_no_lp",
"lp_factor",
"intensity_with_lp",
@@ -95,14 +112,24 @@
"volume_normalized_intensity_no_lp",
"material_scattering_factor_R_hkl",
"material_scattering_factor_R_hkl_no_lp",
+ "inverse_R_hkl",
+ "inverse_R_hkl_no_lp",
+ "phase_relative_R_hkl_pct",
+ "phase_relative_R_hkl_no_lp_pct",
"rank_by_intensity",
"rank_by_R_hkl",
"rank_by_R_hkl_no_lp",
+ "is_multi_family_peak",
+ "coincident_hkl_family_count",
"young_modulus_hkl_normal_GPa",
"elastic_status",
"elastic_note",
"wavelength_A",
"energy_keV",
+ "formula_weight_g_mol",
+ "density_g_cm3",
+ "cell_volume_A3",
+ "r_hkl_model_note",
"scientific_boundary",
]
ELASTICITY_HEADERS = [
@@ -124,6 +151,11 @@
"phase_name",
"cif_name",
"two_theta_deg",
+ "d_A",
+ "q_invA",
+ "g_invA",
+ "x_axis_mode",
+ "x",
"relative_intensity",
"wavelength_A",
]
@@ -246,6 +278,7 @@ def phase_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
structure = analysis.structure
cell = structure.cell_parameters
tensor = analysis.elastic_tensor
+ meta = analysis.metadata
rows.append(
{
"phase_name": analysis.phase_name,
@@ -269,11 +302,14 @@ def phase_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
"wavelength_A": analysis.wavelength_A,
"energy_keV": analysis.energy_keV,
"wavelength_source": analysis.wavelength_source,
+ "formula_weight_g_mol": meta.get("formula_weight_g_mol"),
+ "density_g_cm3": meta.get("density_g_cm3"),
+ "cell_volume_A3": meta.get("cell_volume_A3"),
"elastic_status": (
tensor.status
if tensor
else "not_requested"
- if analysis.metadata.get("elasticity_requested") is False
+ if meta.get("elasticity_requested") is False
else "not_available"
),
"elastic_source_provider": tensor.source_provider if tensor else "",
@@ -290,7 +326,12 @@ def peak_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for analysis in analyses:
structure = analysis.structure
+ meta = analysis.metadata
for reflection in analysis.reflections:
+ if reflection.i is not None:
+ hkl_label = f"({reflection.h} {reflection.k} {reflection.i} {reflection.l})"
+ else:
+ hkl_label = f"({reflection.h} {reflection.k} {reflection.l})"
rows.append(
{
"phase_name": analysis.phase_name,
@@ -300,16 +341,28 @@ def peak_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
"space_group": structure.space_group_symbol,
"h": reflection.h,
"k": reflection.k,
+ "i": reflection.i,
"l": reflection.l,
- "hkl": f"({reflection.h} {reflection.k} {reflection.l})",
+ "hkl": hkl_label,
"family_label": reflection.family_label,
"multiplicity": reflection.multiplicity,
"d_spacing_A": reflection.d_spacing_A,
"theta_deg": reflection.theta_deg,
"two_theta_deg": reflection.two_theta_deg,
+ "two_theta_cu_ka_deg": reflection.two_theta_cu_ka_deg,
"q_invA": reflection.q_invA,
"g_invA": reflection.g_invA,
+ "sin_theta": reflection.sin_theta,
+ "cos_theta": reflection.cos_theta,
+ "sin_theta_over_lambda": reflection.sin_theta_over_lambda,
+ "sin2_theta_over_lambda2": reflection.sin2_theta_over_lambda2,
"structure_factor_sq": reflection.structure_factor_sq,
+ "mean_structure_factor_sq_per_multiplicity": (
+ reflection.mean_structure_factor_sq_per_multiplicity
+ ),
+ "mean_structure_factor_abs_per_multiplicity": (
+ reflection.mean_structure_factor_abs_per_multiplicity
+ ),
"intensity_no_lp": reflection.intensity_no_lp,
"lp_factor": reflection.lp_factor,
"intensity_with_lp": reflection.intensity_with_lp,
@@ -318,14 +371,24 @@ def peak_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
"volume_normalized_intensity_no_lp": reflection.material_scattering_factor_R_hkl_no_lp,
"material_scattering_factor_R_hkl": reflection.material_scattering_factor_R_hkl,
"material_scattering_factor_R_hkl_no_lp": reflection.material_scattering_factor_R_hkl_no_lp,
+ "inverse_R_hkl": reflection.inverse_R_hkl,
+ "inverse_R_hkl_no_lp": reflection.inverse_R_hkl_no_lp,
+ "phase_relative_R_hkl_pct": reflection.phase_relative_R_hkl_pct,
+ "phase_relative_R_hkl_no_lp_pct": reflection.phase_relative_R_hkl_no_lp_pct,
"rank_by_intensity": reflection.rank_by_intensity,
"rank_by_R_hkl": reflection.rank_by_R_hkl,
"rank_by_R_hkl_no_lp": reflection.rank_by_R_hkl_no_lp,
+ "is_multi_family_peak": reflection.is_multi_family_peak,
+ "coincident_hkl_family_count": reflection.coincident_hkl_family_count,
"young_modulus_hkl_normal_GPa": reflection.young_modulus_hkl_normal_GPa,
"elastic_status": reflection.elastic_status,
"elastic_note": reflection.elastic_note,
"wavelength_A": analysis.wavelength_A,
"energy_keV": analysis.energy_keV,
+ "formula_weight_g_mol": meta.get("formula_weight_g_mol"),
+ "density_g_cm3": meta.get("density_g_cm3"),
+ "cell_volume_A3": meta.get("cell_volume_A3"),
+ "r_hkl_model_note": reflection.r_hkl_model_note,
"scientific_boundary": SCIENTIFIC_BOUNDARY,
}
)
@@ -360,19 +423,67 @@ def elasticity_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
return rows
+def _pattern_axis_coordinates(
+ two_theta_deg: float,
+ wavelength_A: float,
+ x_axis_mode: str,
+) -> tuple[float | None, float | None, float | None, float | None]:
+ """Return d_A, q_invA, g_invA, and selected x for a profile sample."""
+
+ theta_rad = math.radians(float(two_theta_deg) / 2.0)
+ sin_theta = math.sin(theta_rad)
+ if not math.isfinite(sin_theta) or sin_theta <= 0 or not math.isfinite(wavelength_A) or wavelength_A <= 0:
+ d_A = None
+ else:
+ d_A = float(wavelength_A) / (2.0 * sin_theta)
+ if not math.isfinite(d_A) or d_A <= 0:
+ d_A = None
+ if d_A is None:
+ q_invA = None
+ g_invA = None
+ else:
+ q_invA = float(2.0 * math.pi / d_A)
+ g_invA = float(1.0 / d_A)
+ if not math.isfinite(q_invA):
+ q_invA = None
+ if not math.isfinite(g_invA):
+ g_invA = None
+ mode = str(x_axis_mode or "two_theta")
+ if mode == "d_spacing":
+ x_value = d_A
+ elif mode == "q":
+ x_value = q_invA
+ elif mode == "g":
+ x_value = g_invA
+ else:
+ x_value = float(two_theta_deg)
+ return d_A, q_invA, g_invA, x_value
+
+
def pattern_rows(analyses: list[PhaseAnalysis]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for analysis in analyses:
+ x_axis_mode = str(analysis.metadata.get("pattern_axis") or "two_theta")
+ wavelength = float(analysis.wavelength_A)
for angle, intensity in zip(
analysis.two_theta_grid,
analysis.intensity_profile,
strict=True,
):
+ two_theta = float(angle)
+ d_A, q_invA, g_invA, x_value = _pattern_axis_coordinates(
+ two_theta, wavelength, x_axis_mode
+ )
rows.append(
{
"phase_name": analysis.phase_name,
"cif_name": analysis.structure.cif_path.name,
- "two_theta_deg": float(angle),
+ "two_theta_deg": two_theta,
+ "d_A": d_A,
+ "q_invA": q_invA,
+ "g_invA": g_invA,
+ "x_axis_mode": x_axis_mode,
+ "x": x_value,
"relative_intensity": float(intensity),
"wavelength_A": analysis.wavelength_A,
}
@@ -417,6 +528,31 @@ def _add_sheet(
cell.alignment = Alignment(vertical="top", wrap_text=True)
+def _add_guide_sheet(workbook: Workbook, title: str, rows: list[list[str]]) -> None:
+ sheet = workbook.create_sheet(title=title)
+ if not rows:
+ sheet.append(["no rows", ""])
+ else:
+ for row in rows:
+ sheet.append([_cell_value(cell) for cell in row])
+ sheet.freeze_panes = "A2"
+ header_fill = PatternFill("solid", fgColor="16324F")
+ for cell in sheet[1]:
+ cell.font = Font(bold=True, color="FFFFFF")
+ cell.fill = header_fill
+ cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+ for column_index in range(1, 3):
+ sampled = [
+ str(_cell_value(row[column_index - 1]) if column_index - 1 < len(row) else "")
+ for row in rows[:200]
+ ] or [""]
+ width = min(max(max(len(value) for value in sampled) + 2, 12), 72)
+ sheet.column_dimensions[get_column_letter(column_index)].width = width
+ for row in sheet.iter_rows(min_row=2):
+ for cell in row:
+ cell.alignment = Alignment(vertical="top", wrap_text=True)
+
+
def write_excel_workbook(
path: Path,
*,
@@ -428,6 +564,9 @@ def write_excel_workbook(
downloads: list[dict[str, Any]],
diagnostics: list[dict[str, Any]],
patterns: list[dict[str, Any]],
+ analyses: list[PhaseAnalysis] | None = None,
+ export_lab_views: bool = False,
+ include_patterns: bool = True,
) -> Path:
workbook = Workbook()
workbook.remove(workbook.active)
@@ -438,7 +577,23 @@ def write_excel_workbook(
_add_sheet(workbook, "Candidates", candidates, CANDIDATE_HEADERS)
_add_sheet(workbook, "Downloads", downloads, DOWNLOAD_HEADERS)
_add_sheet(workbook, "Diagnostics", diagnostics, DIAGNOSTIC_HEADERS)
- _add_sheet(workbook, "Patterns", patterns, PATTERN_HEADERS)
+ if include_patterns:
+ _add_sheet(workbook, "Patterns", patterns, PATTERN_HEADERS)
+ if export_lab_views:
+ zh_headers = list(BEGINNER_PEAK_HEADERS_ZH.keys())
+ _add_sheet(workbook, "推荐峰表", beginner_peak_rows_zh(peaks), zh_headers)
+ _add_guide_sheet(workbook, "使用说明", user_guide_rows())
+ analysis_list = analyses or []
+ if 0 < len(analysis_list) <= 20:
+ used_titles: set[str] = set(workbook.sheetnames)
+ for analysis in analysis_list:
+ if not analysis.reflections:
+ continue
+ phase_peaks = [row for row in peaks if row.get("phase_name") == analysis.phase_name]
+ if not phase_peaks:
+ continue
+ title = safe_excel_sheet_title(f"峰_{analysis.phase_name}", used=used_titles)
+ _add_sheet(workbook, title, phase_peaks, PEAK_HEADERS)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
try:
@@ -465,6 +620,11 @@ def _summary_rows(
{"key": "input_mode", "value": settings.input_mode},
{"key": "source_preset", "value": settings.source_preset},
{"key": "two_theta_range_deg", "value": [settings.two_theta_min_deg, settings.two_theta_max_deg]},
+ {"key": "profile_model", "value": settings.profile_model},
+ {"key": "d_min_A", "value": settings.d_min_A},
+ {"key": "d_max_A", "value": settings.d_max_A},
+ {"key": "pattern_axis", "value": settings.pattern_axis},
+ {"key": "export_lab_views", "value": settings.export_lab_views},
{"key": "scientific_boundary", "value": SCIENTIFIC_BOUNDARY},
]
@@ -528,7 +688,7 @@ def export_result_bundle(
phases = phase_rows(analyses)
peaks = peak_rows(analyses)
elasticity = elasticity_rows(analyses)
- patterns = pattern_rows(analyses)
+ patterns = pattern_rows(analyses) if settings.include_patterns else []
if include_excel:
workbook_tables = {
"Phases": phases,
@@ -536,8 +696,9 @@ def export_result_bundle(
"Elasticity": elasticity,
"Candidates": candidates,
"Downloads": download_table,
- "Patterns": patterns,
}
+ if settings.include_patterns:
+ workbook_tables["Patterns"] = patterns
for title, rows in workbook_tables.items():
if len(rows) > EXCEL_DATA_ROW_LIMIT:
diagnostics.append(
@@ -555,7 +716,8 @@ def export_result_bundle(
_write_csv(output / "phase_summary.csv", phases, PHASE_HEADERS)
_write_csv(output / "peak_reference.csv", peaks, PEAK_HEADERS)
- _write_csv(output / "pattern_profiles.csv", patterns, PATTERN_HEADERS)
+ if settings.include_patterns:
+ _write_csv(output / "pattern_profiles.csv", patterns, PATTERN_HEADERS)
_write_csv(output / "elasticity.csv", elasticity, ELASTICITY_HEADERS)
_write_csv(output / "candidate_index.csv", candidates, CANDIDATE_HEADERS)
_write_csv(output / "download_index.csv", download_table, DOWNLOAD_HEADERS)
@@ -596,6 +758,16 @@ def export_result_bundle(
"they are not crystallographic residual factors or standardized QPA coefficients"
),
"elastic_modulus": "E(n) = 1 / (q(n)^T S q(n)) under engineering-shear Voigt convention",
+ "lab_views_schema": (
+ "When export_lab_views is true, results.xlsx adds 推荐峰表 (Chinese beginner headers "
+ "mapped by BEGINNER_PEAK_HEADERS_ZH), 使用说明 (two-column guide), and optional "
+ "per-phase peak sheets (≤20 phases). Canonical English sheets and CSV columns remain "
+ "the machine-readable schema; lab views are additive presentation only."
+ ),
+ "pattern_axis_columns": (
+ "pattern_profiles.csv always includes two_theta_deg, d_A, q_invA, g_invA, "
+ "x_axis_mode (settings.pattern_axis), x (selected axis value), and relative_intensity"
+ ),
},
"scientific_boundary": SCIENTIFIC_BOUNDARY,
"excel_data_row_limit": EXCEL_DATA_ROW_LIMIT,
@@ -615,8 +787,26 @@ def export_result_bundle(
downloads=download_table,
diagnostics=diagnostic_table,
patterns=patterns,
+ analyses=analyses,
+ export_lab_views=bool(settings.export_lab_views),
+ include_patterns=bool(settings.include_patterns),
)
+ if settings.include_figures and analyses:
+ from .plotting import export_phase_figures
+
+ figures_dir = output / "figures"
+ figure_preset = settings.figure_preset or "publication"
+ multi = len(analyses) > 1
+ for index, analysis in enumerate(analyses, start=1):
+ export_phase_figures(
+ analysis,
+ figures_dir,
+ preset=figure_preset,
+ formats=("svg", "png"),
+ index=index if multi else None,
+ )
+
files: list[dict[str, Any]] = []
for path in sorted(output.rglob("*")):
if path.is_symlink():
@@ -624,20 +814,23 @@ def export_result_bundle(
if not path.is_file() or path.name == "manifest.json":
continue
relative = path.relative_to(output).as_posix()
+ suffix = path.suffix.lower()
+ if relative.startswith("inputs/"):
+ role = "source_input"
+ elif relative.startswith("figures/") or suffix in {".svg", ".png", ".pdf", ".eps", ".tif", ".tiff"}:
+ role = "figure"
+ elif suffix in {".csv", ".xlsx"}:
+ role = "tabular_result"
+ elif suffix == ".json":
+ role = "provenance"
+ else:
+ role = "documentation"
files.append(
{
"path": relative,
"sha256": sha256_file(path),
"size_bytes": path.stat().st_size,
- "role": (
- "source_input"
- if relative.startswith("inputs/")
- else "tabular_result"
- if path.suffix.lower() in {".csv", ".xlsx"}
- else "provenance"
- if path.suffix.lower() == ".json"
- else "documentation"
- ),
+ "role": role,
}
)
manifest = {
diff --git a/src/diffractscout/gui.py b/src/diffractscout/gui.py
index b29aa61..137b948 100644
--- a/src/diffractscout/gui.py
+++ b/src/diffractscout/gui.py
@@ -4,6 +4,7 @@
import os
import queue
+import re
import subprocess
import sys
import threading
@@ -13,7 +14,9 @@
from typing import Any, Callable, Mapping
from . import __version__
-from .models import AnalysisSettings, DiscoverySettings, PipelineResult
+from .elasticity_input import parse_cij_matrix_6x6, parse_cij_paste_text, parse_cubic_cij
+from .gui_i18n import DEFAULT_LANG, t
+from .models import AnalysisSettings, DiscoverySettings, ElasticTensor, PipelineResult
from .pipeline import analyze_cifs, run_pipeline
from .providers.materials_project import MaterialsProjectProvider
@@ -24,6 +27,15 @@
tk = None # type: ignore[assignment]
filedialog = messagebox = ttk = None # type: ignore[assignment]
+try: # Optional drag-and-drop; soft-fail when tkinterdnd2 is absent.
+ from tkinterdnd2 import DND_FILES, TkinterDnD
+
+ _HAS_DND = True
+except ImportError: # pragma: no cover - optional dependency
+ DND_FILES = None # type: ignore[assignment]
+ TkinterDnD = None # type: ignore[assignment]
+ _HAS_DND = False
+
NAVY = "#102A43"
NAVY_DARK = "#0B1F33"
TEAL = "#00A6A6"
@@ -40,6 +52,14 @@
LOG_BG = "#0D1B2A"
LOG_TEXT = "#DCE7F1"
+_PROFILE_MODELS = ("pseudo_voigt", "gaussian", "lorentzian")
+_PATTERN_AXES = ("two_theta", "d_spacing", "q", "g")
+_SHORTCUT_CU = "Cu Kα"
+_SHORTCUT_30 = "30 keV"
+_SHORTCUT_83 = "83 keV"
+_SHORTCUT_CUSTOM = "Custom"
+_ENERGY_SHORTCUTS = (_SHORTCUT_CU, _SHORTCUT_30, _SHORTCUT_83, _SHORTCUT_CUSTOM)
+
def _required_float(value: object, field: str) -> float:
try:
@@ -68,27 +88,58 @@ def _optional_int(value: object, field: str) -> int | None:
return None if not text else _required_int(text, field)
+def _as_bool(value: object, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ text = str(value).strip().lower()
+ if text in {"1", "true", "yes", "on"}:
+ return True
+ if text in {"0", "false", "no", "off", ""}:
+ return False
+ return bool(value)
+
+
def analysis_settings_from_form(values: Mapping[str, object]) -> AnalysisSettings:
mode = str(values.get("input_mode", "source")).strip().lower()
if mode not in {"source", "wavelength", "energy"}:
raise ValueError("Radiation mode must be source, wavelength, or energy.")
radiation = _optional_float(values.get("radiation_value"), "Radiation value")
+ profile_model = str(values.get("profile_model", "pseudo_voigt")).strip()
+ if profile_model not in _PROFILE_MODELS:
+ raise ValueError(
+ f"Profile model must be one of: {', '.join(_PROFILE_MODELS)}."
+ )
+ pattern_axis = str(values.get("pattern_axis", "two_theta")).strip()
+ if pattern_axis not in _PATTERN_AXES:
+ raise ValueError(f"Pattern axis must be one of: {', '.join(_PATTERN_AXES)}.")
return AnalysisSettings(
input_mode=mode, # type: ignore[arg-type]
source_preset=str(values.get("source_preset", "Cu Ka")),
- wavelength_A=radiation if mode == "wavelength" or (mode == "source" and str(values.get("source_preset")) == "Custom") else None,
+ wavelength_A=radiation
+ if mode == "wavelength" or (mode == "source" and str(values.get("source_preset")) == "Custom")
+ else None,
energy_keV=radiation if mode == "energy" else None,
two_theta_min_deg=_required_float(values.get("two_theta_min", 5), "2θ minimum"),
two_theta_max_deg=_required_float(values.get("two_theta_max", 120), "2θ maximum"),
step_deg=_required_float(values.get("step", 0.02), "Profile step"),
fwhm_deg=_required_float(values.get("fwhm", 0.15), "FWHM"),
profile_eta=_required_float(values.get("eta", 0.5), "Pseudo-Voigt η"),
- include_elasticity=bool(values.get("include_elasticity", True)),
+ include_elasticity=_as_bool(values.get("include_elasticity", True), True),
max_profile_points=_required_int(values.get("max_profile_points", 1_000_000), "Maximum profile points"),
max_reflection_estimate=_required_int(
values.get("max_reflection_estimate", 2_000_000),
"Maximum reciprocal candidates",
),
+ d_min_A=_optional_float(values.get("d_min_A"), "d_min_A"),
+ d_max_A=_optional_float(values.get("d_max_A"), "d_max_A"),
+ profile_model=profile_model, # type: ignore[arg-type]
+ pattern_axis=pattern_axis, # type: ignore[arg-type]
+ include_figures=_as_bool(values.get("include_figures", False), False),
+ figure_preset=str(values.get("figure_preset", "publication") or "publication"),
+ export_lab_views=_as_bool(values.get("export_lab_views", True), True),
+ include_patterns=_as_bool(values.get("include_patterns", True), True),
)
@@ -107,7 +158,7 @@ def discovery_settings_from_form(values: Mapping[str, object]) -> DiscoverySetti
def open_path(path: str | Path) -> None:
- """Open a directory with the platform file manager without invoking a shell."""
+ """Open a file or directory with the platform handler without a shell."""
target = str(Path(path).expanduser().resolve())
if sys.platform.startswith("win"):
@@ -118,26 +169,46 @@ def open_path(path: str | Path) -> None:
subprocess.Popen(["xdg-open", target])
+def _parse_drop_paths(data: str) -> list[Path]:
+ """Parse tkinterdnd2 / Windows brace-wrapped drop payloads into paths."""
+
+ text = str(data or "").strip()
+ if not text:
+ return []
+ # {C:/path with spaces/a.cif} C:/other.cif
+ braced = re.findall(r"\{([^}]*)\}|(\S+)", text)
+ parts = [a or b for a, b in braced if (a or b)]
+ return [Path(item) for item in parts if str(item).strip()]
+
+
if tk is not None:
+ _TkBase = TkinterDnD.Tk if _HAS_DND and TkinterDnD is not None else tk.Tk
- class DiffractScoutApp(tk.Tk):
+ class DiffractScoutApp(_TkBase): # type: ignore[valid-type,misc]
"""Desktop controller; all scientific work is delegated to tested pipeline functions."""
def __init__(self) -> None:
super().__init__()
self.title(f"DiffractScout {__version__}")
- self.geometry("1180x900")
- self.minsize(980, 760)
+ self.geometry("1180x920")
+ self.minsize(980, 780)
self.configure(background=BG)
self.protocol("WM_DELETE_WINDOW", self._on_close)
+ self.lang = DEFAULT_LANG
self.events: queue.Queue[tuple[str, object]] = queue.Queue()
self.running = False
self.last_output: Path | None = None
self.local_inputs: list[Path] = []
+ self.elastic_overrides: dict[str, ElasticTensor] = {}
self._run_buttons: list[ttk.Button] = []
self._radiation_source_widgets: list[ttk.Combobox] = []
self._radiation_value_widgets: list[ttk.Entry] = []
+ self._i18n_targets: list[tuple[Any, str, str]] = []
+ self._title_pairs: list[tuple[Any, Any, str, str]] = []
+ self._labelframes: list[tuple[Any, str]] = []
+ self._notebook_tabs: list[tuple[int, str]] = []
+ self._syncing_shortcut = False
self._configure_style()
self._create_variables()
@@ -146,11 +217,19 @@ def __init__(self) -> None:
self._build_activity_panel()
self._build_body()
self._sync_radiation_controls()
+ self._refresh_cij_status()
+ self._apply_language()
self.after(120, self._poll)
- self._log(
- "Ready. Source CIFs and the PhaseScout/CIF2Peaks repositories are read-only inputs.",
- "info",
- )
+ self._log(self._t("log_ready"), "info")
+
+ def _t(self, key: str, **fmt: object) -> str:
+ return t(self.lang, key, **fmt)
+
+ def _register_text(self, widget: Any, key: str, attr: str = "text") -> Any:
+ self._i18n_targets.append((widget, key, attr))
+ if attr == "text":
+ widget.configure(text=self._t(key))
+ return widget
def _configure_style(self) -> None:
style = ttk.Style(self)
@@ -181,12 +260,17 @@ def _configure_style(self) -> None:
style.configure("Horizontal.TProgressbar", background=TEAL, troughcolor="#DCE6EE", borderwidth=0)
def _create_variables(self) -> None:
+ self.lang_var = tk.StringVar(value=self.lang)
self.local_output = tk.StringVar()
self.local_recursive = tk.BooleanVar(value=True)
self.include_excel = tk.BooleanVar(value=True)
self.include_elasticity = tk.BooleanVar(value=True)
+ self.export_lab_views = tk.BooleanVar(value=True)
+ self.include_patterns = tk.BooleanVar(value=True)
+ self.include_figures = tk.BooleanVar(value=False)
self.overwrite = tk.BooleanVar(value=False)
+ self.energy_shortcut = tk.StringVar(value=_SHORTCUT_CU)
self.input_mode = tk.StringVar(value="source")
self.source_preset = tk.StringVar(value="Cu Ka")
self.radiation_value = tk.StringVar(value="1.5406")
@@ -195,10 +279,20 @@ def _create_variables(self) -> None:
self.step = tk.StringVar(value="0.02")
self.fwhm = tk.StringVar(value="0.15")
self.eta = tk.StringVar(value="0.5")
+ self.d_min_A = tk.StringVar(value="")
+ self.d_max_A = tk.StringVar(value="")
+ self.profile_model = tk.StringVar(value="pseudo_voigt")
+ self.pattern_axis = tk.StringVar(value="two_theta")
self.max_profile_points = tk.StringVar(value="1000000")
self.max_reflection_estimate = tk.StringVar(value="2000000")
self.input_mode.trace_add("write", lambda *_args: self._sync_radiation_controls())
self.source_preset.trace_add("write", lambda *_args: self._sync_radiation_controls())
+ self.energy_shortcut.trace_add("write", lambda *_args: self._on_energy_shortcut())
+
+ self.cij_c11 = tk.StringVar(value="")
+ self.cij_c12 = tk.StringVar(value="")
+ self.cij_c44 = tk.StringVar(value="")
+ self.cij_status = tk.StringVar(value="")
self.mp_composition = tk.StringVar(value="Ti-6Al-4V")
self.mp_key = tk.StringVar(value=os.environ.get("MP_API_KEY", ""))
@@ -212,8 +306,8 @@ def _create_variables(self) -> None:
self.mp_include_deprecated = tk.BooleanVar(value=False)
self.mp_conventional = tk.BooleanVar(value=True)
- self.status_text = tk.StringVar(value="Ready")
- self.input_count_text = tk.StringVar(value="No CIF inputs selected")
+ self.status_text = tk.StringVar(value=self._t("status_ready"))
+ self.input_count_text = tk.StringVar(value=self._t("inputs_none"))
def _build_header(self) -> None:
header = tk.Frame(self, bg=NAVY_DARK, height=102)
@@ -229,12 +323,27 @@ def _build_header(self) -> None:
text = tk.Frame(header, bg=NAVY_DARK)
text.pack(side="left", fill="y", pady=15)
ttk.Label(text, text="DiffractScout", style="HeaderTitle.TLabel").pack(anchor="w")
- ttk.Label(
- text,
- text="Candidate phases → validated CIFs → indexed powder diffraction → optional hkl elasticity",
- style="HeaderSub.TLabel",
- ).pack(anchor="w", pady=(3, 0))
- ttk.Label(header, text=f"v{__version__}", style="Badge.TLabel").pack(side="right", padx=24)
+ self.header_sub = ttk.Label(text, text=self._t("app_subtitle"), style="HeaderSub.TLabel")
+ self.header_sub.pack(anchor="w", pady=(3, 0))
+ self._i18n_targets.append((self.header_sub, "app_subtitle", "text"))
+
+ right = tk.Frame(header, bg=NAVY_DARK)
+ right.pack(side="right", padx=24)
+ ttk.Label(right, text=f"v{__version__}", style="Badge.TLabel").pack(anchor="e", pady=(8, 6))
+ lang_row = tk.Frame(right, bg=NAVY_DARK)
+ lang_row.pack(anchor="e")
+ self.lang_label = ttk.Label(lang_row, text=self._t("lang_label"), style="HeaderSub.TLabel")
+ self.lang_label.pack(side="left", padx=(0, 6))
+ self._i18n_targets.append((self.lang_label, "lang_label", "text"))
+ lang_box = ttk.Combobox(
+ lang_row,
+ textvariable=self.lang_var,
+ values=("zh", "en"),
+ state="readonly",
+ width=6,
+ )
+ lang_box.pack(side="left")
+ lang_box.bind("<>", lambda _e: self._set_language(self.lang_var.get()))
def _build_body(self) -> None:
body = ttk.Frame(self, padding=(18, 14, 18, 4))
@@ -244,14 +353,25 @@ def _build_body(self) -> None:
notebook.pack(fill="both", expand=True)
local = ttk.Frame(notebook, style="Card.TFrame", padding=16)
mp = ttk.Frame(notebook, style="Card.TFrame", padding=16)
- notebook.add(local, text=" Local CIF analysis ")
- notebook.add(mp, text=" Materials Project pipeline ")
+ notebook.add(local, text=self._t("tab_local"))
+ notebook.add(mp, text=self._t("tab_mp"))
+ self._notebook_tabs = [(0, "tab_local"), (1, "tab_mp")]
self._build_local_tab(local)
self._build_mp_tab(mp)
- def _card_title(self, parent: Any, title: str, hint: str) -> None:
- ttk.Label(parent, text=title, style="Title.TLabel").pack(anchor="w")
- ttk.Label(parent, text=hint, style="Hint.TLabel", wraplength=480).pack(anchor="w", pady=(2, 10))
+ def _card_title(self, parent: Any, title_key: str, hint_key: str) -> None:
+ title = ttk.Label(parent, text=self._t(title_key), style="Title.TLabel")
+ title.pack(anchor="w")
+ hint = ttk.Label(parent, text=self._t(hint_key), style="Hint.TLabel", wraplength=480)
+ hint.pack(anchor="w", pady=(2, 10))
+ self._title_pairs.append((title, hint, title_key, hint_key))
+ self._i18n_targets.append((title, title_key, "text"))
+ self._i18n_targets.append((hint, hint_key, "text"))
+
+ def _labeled_frame(self, parent: Any, key: str, **kwargs: Any) -> ttk.LabelFrame:
+ frame = ttk.LabelFrame(parent, text=self._t(key), **kwargs)
+ self._labelframes.append((frame, key))
+ return frame
def _build_local_tab(self, frame: ttk.Frame) -> None:
frame.columnconfigure(0, weight=1)
@@ -262,7 +382,7 @@ def _build_local_tab(self, frame: ttk.Frame) -> None:
right = ttk.Frame(frame, style="Card.TFrame", padding=(12, 0, 0, 0))
left.grid(row=0, column=0, sticky="nsew")
right.grid(row=0, column=1, sticky="nsew")
- self._card_title(left, "1. Select structures", "Add individual CIF files or scan one or more folders. Duplicate paths are removed.")
+ self._card_title(left, "local_select_title", "local_select_hint")
list_frame = tk.Frame(left, bg=CARD, highlightbackground=BORDER, highlightthickness=1)
list_frame.pack(fill="both", expand=True)
@@ -281,29 +401,81 @@ def _build_local_tab(self, frame: ttk.Frame) -> None:
self.input_list.configure(yscrollcommand=scrollbar.set)
self.input_list.pack(side="left", fill="both", expand=True, padx=8, pady=8)
scrollbar.pack(side="right", fill="y")
+ self._enable_dnd(self.input_list)
ttk.Label(left, textvariable=self.input_count_text, style="Hint.TLabel").pack(anchor="w", pady=(5, 4))
buttons = ttk.Frame(left, style="Card.TFrame")
buttons.pack(fill="x", pady=(0, 12))
- ttk.Button(buttons, text="Add CIF files", style="Secondary.TButton", command=self._add_cif_files).pack(side="left", padx=(0, 6))
- ttk.Button(buttons, text="Add folder", style="Secondary.TButton", command=self._add_cif_folder).pack(side="left", padx=6)
- ttk.Button(buttons, text="Remove", style="Danger.TButton", command=self._remove_inputs).pack(side="left", padx=6)
- ttk.Button(buttons, text="Clear", style="Secondary.TButton", command=self._clear_inputs).pack(side="left", padx=6)
+ self.btn_add_cif = ttk.Button(
+ buttons, text=self._t("btn_add_cif"), style="Secondary.TButton", command=self._add_cif_files
+ )
+ self.btn_add_cif.pack(side="left", padx=(0, 6))
+ self._register_text(self.btn_add_cif, "btn_add_cif")
+ self.btn_add_folder = ttk.Button(
+ buttons, text=self._t("btn_add_folder"), style="Secondary.TButton", command=self._add_cif_folder
+ )
+ self.btn_add_folder.pack(side="left", padx=6)
+ self._register_text(self.btn_add_folder, "btn_add_folder")
+ self.btn_remove = ttk.Button(
+ buttons, text=self._t("btn_remove"), style="Danger.TButton", command=self._remove_inputs
+ )
+ self.btn_remove.pack(side="left", padx=6)
+ self._register_text(self.btn_remove, "btn_remove")
+ self.btn_clear = ttk.Button(
+ buttons, text=self._t("btn_clear"), style="Secondary.TButton", command=self._clear_inputs
+ )
+ self.btn_clear.pack(side="left", padx=6)
+ self._register_text(self.btn_clear, "btn_clear")
- output_box = ttk.LabelFrame(left, text="Result bundle", padding=10)
+ output_box = self._labeled_frame(left, "result_bundle", padding=10)
output_box.pack(fill="x")
self._path_entry(output_box, self.local_output, self._choose_local_output)
- ttk.Checkbutton(output_box, text="Scan selected folders recursively", variable=self.local_recursive).pack(anchor="w", pady=(8, 0))
- ttk.Checkbutton(output_box, text="Replace an existing verified DiffractScout bundle", variable=self.overwrite).pack(anchor="w", pady=(4, 0))
+ self.chk_recursive = ttk.Checkbutton(
+ output_box, text=self._t("scan_recursive"), variable=self.local_recursive
+ )
+ self.chk_recursive.pack(anchor="w", pady=(8, 0))
+ self._register_text(self.chk_recursive, "scan_recursive")
+ self.chk_overwrite_local = ttk.Checkbutton(
+ output_box, text=self._t("overwrite_bundle"), variable=self.overwrite
+ )
+ self.chk_overwrite_local.pack(anchor="w", pady=(4, 0))
+ self._register_text(self.chk_overwrite_local, "overwrite_bundle")
- self._card_title(right, "2. Scientific controls", "Theoretical kinematic powder reference. Limits prevent accidental memory-intensive grids.")
+ self._card_title(right, "scientific_title", "scientific_hint")
self._analysis_controls(right)
- options = ttk.LabelFrame(right, text="Outputs", padding=10)
+ self._build_cij_panel(right)
+ options = self._labeled_frame(right, "outputs", padding=10)
options.pack(fill="x", pady=(10, 0))
- ttk.Checkbutton(options, text="Pair numerical elasticity sidecars", variable=self.include_elasticity).pack(side="left", padx=(0, 14))
- ttk.Checkbutton(options, text="Write Excel workbook", variable=self.include_excel).pack(side="left")
- button = ttk.Button(right, text="Analyze selected CIFs", style="Primary.TButton", command=self._run_local)
+ self.chk_elasticity_local = ttk.Checkbutton(
+ options, text=self._t("pair_elasticity"), variable=self.include_elasticity
+ )
+ self.chk_elasticity_local.pack(anchor="w")
+ self._register_text(self.chk_elasticity_local, "pair_elasticity")
+ self.chk_excel_local = ttk.Checkbutton(
+ options, text=self._t("write_excel"), variable=self.include_excel
+ )
+ self.chk_excel_local.pack(anchor="w")
+ self._register_text(self.chk_excel_local, "write_excel")
+ self.chk_lab_views = ttk.Checkbutton(
+ options, text=self._t("export_lab_views"), variable=self.export_lab_views
+ )
+ self.chk_lab_views.pack(anchor="w")
+ self._register_text(self.chk_lab_views, "export_lab_views")
+ self.chk_patterns = ttk.Checkbutton(
+ options, text=self._t("include_patterns"), variable=self.include_patterns
+ )
+ self.chk_patterns.pack(anchor="w")
+ self._register_text(self.chk_patterns, "include_patterns")
+ self.chk_figures = ttk.Checkbutton(
+ options, text=self._t("include_figures"), variable=self.include_figures
+ )
+ self.chk_figures.pack(anchor="w")
+ self._register_text(self.chk_figures, "include_figures")
+ button = ttk.Button(
+ right, text=self._t("analyze_local"), style="Primary.TButton", command=self._run_local
+ )
button.pack(fill="x", pady=(14, 0))
+ self._register_text(button, "analyze_local")
self._run_buttons.append(button)
def _build_mp_tab(self, frame: ttk.Frame) -> None:
@@ -315,111 +487,282 @@ def _build_mp_tab(self, frame: ttk.Frame) -> None:
left.grid(row=0, column=0, sticky="nsew")
right.grid(row=0, column=1, sticky="nsew")
- self._card_title(left, "1. Discover candidate phases", "Enter an alloy grade, formula, chemical system, or explicit mp-IDs.")
- form = ttk.LabelFrame(left, text="Materials Project query", padding=12)
+ self._card_title(left, "mp_discover_title", "mp_discover_hint")
+ form = self._labeled_frame(left, "mp_query", padding=12)
form.pack(fill="x")
- ttk.Label(form, text="Composition", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=5)
- ttk.Entry(form, textvariable=self.mp_composition).grid(row=0, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=5)
- ttk.Label(form, text="API key", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=5)
+ self.lbl_composition = ttk.Label(form, text=self._t("composition"), style="Card.TLabel")
+ self.lbl_composition.grid(row=0, column=0, sticky="w", pady=5)
+ self._register_text(self.lbl_composition, "composition")
+ ttk.Entry(form, textvariable=self.mp_composition).grid(
+ row=0, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=5
+ )
+ self.lbl_api_key = ttk.Label(form, text=self._t("api_key"), style="Card.TLabel")
+ self.lbl_api_key.grid(row=1, column=0, sticky="w", pady=5)
+ self._register_text(self.lbl_api_key, "api_key")
self.mp_key_entry = ttk.Entry(form, textvariable=self.mp_key, show="" if self.mp_show_key.get() else "•")
self.mp_key_entry.grid(row=1, column=1, columnspan=2, sticky="ew", padx=(8, 8), pady=5)
- ttk.Checkbutton(form, text="Show", variable=self.mp_show_key, command=self._toggle_key).grid(row=1, column=3, sticky="w")
- ttk.Label(form, text="Mode", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=5)
+ self.chk_show_key = ttk.Checkbutton(
+ form, text=self._t("show_key"), variable=self.mp_show_key, command=self._toggle_key
+ )
+ self.chk_show_key.grid(row=1, column=3, sticky="w")
+ self._register_text(self.chk_show_key, "show_key")
+ self.lbl_mode = ttk.Label(form, text=self._t("mode"), style="Card.TLabel")
+ self.lbl_mode.grid(row=2, column=0, sticky="w", pady=5)
+ self._register_text(self.lbl_mode, "mode")
ttk.Combobox(
form,
textvariable=self.mp_mode,
values=("possible_phases", "near_stable", "single_chemsys", "mpids_only"),
state="readonly",
).grid(row=2, column=1, sticky="ew", padx=(8, 12), pady=5)
- ttk.Label(form, text="Eₕᵤₗₗ max", style="Card.TLabel").grid(row=2, column=2, sticky="w", pady=5)
+ self.lbl_ehull = ttk.Label(form, text=self._t("e_hull_max"), style="Card.TLabel")
+ self.lbl_ehull.grid(row=2, column=2, sticky="w", pady=5)
+ self._register_text(self.lbl_ehull, "e_hull_max")
ttk.Entry(form, textvariable=self.mp_e_hull).grid(row=2, column=3, sticky="ew", padx=(8, 0), pady=5)
- ttk.Label(form, text="Subsystem order", style="Card.TLabel").grid(row=3, column=0, sticky="w", pady=5)
- ttk.Entry(form, textvariable=self.mp_subsystem_order).grid(row=3, column=1, sticky="ew", padx=(8, 12), pady=5)
- ttk.Label(form, text="Per subsystem", style="Card.TLabel").grid(row=3, column=2, sticky="w", pady=5)
- ttk.Entry(form, textvariable=self.mp_per_subsystem).grid(row=3, column=3, sticky="ew", padx=(8, 0), pady=5)
- ttk.Label(form, text="Maximum candidates", style="Card.TLabel").grid(row=4, column=0, sticky="w", pady=5)
+ self.lbl_sub_order = ttk.Label(form, text=self._t("subsystem_order"), style="Card.TLabel")
+ self.lbl_sub_order.grid(row=3, column=0, sticky="w", pady=5)
+ self._register_text(self.lbl_sub_order, "subsystem_order")
+ ttk.Entry(form, textvariable=self.mp_subsystem_order).grid(
+ row=3, column=1, sticky="ew", padx=(8, 12), pady=5
+ )
+ self.lbl_per_sub = ttk.Label(form, text=self._t("per_subsystem"), style="Card.TLabel")
+ self.lbl_per_sub.grid(row=3, column=2, sticky="w", pady=5)
+ self._register_text(self.lbl_per_sub, "per_subsystem")
+ ttk.Entry(form, textvariable=self.mp_per_subsystem).grid(
+ row=3, column=3, sticky="ew", padx=(8, 0), pady=5
+ )
+ self.lbl_max_cand = ttk.Label(form, text=self._t("max_candidates"), style="Card.TLabel")
+ self.lbl_max_cand.grid(row=4, column=0, sticky="w", pady=5)
+ self._register_text(self.lbl_max_cand, "max_candidates")
ttk.Entry(form, textvariable=self.mp_limit).grid(row=4, column=1, sticky="ew", padx=(8, 12), pady=5)
- ttk.Checkbutton(form, text="Include deprecated", variable=self.mp_include_deprecated).grid(row=4, column=2, columnspan=2, sticky="w", pady=4)
+ self.chk_deprecated = ttk.Checkbutton(
+ form, text=self._t("include_deprecated"), variable=self.mp_include_deprecated
+ )
+ self.chk_deprecated.grid(row=4, column=2, columnspan=2, sticky="w", pady=4)
+ self._register_text(self.chk_deprecated, "include_deprecated")
form.columnconfigure(1, weight=1)
form.columnconfigure(3, weight=1)
- output_box = ttk.LabelFrame(left, text="Result bundle", padding=10)
+ output_box = self._labeled_frame(left, "result_bundle", padding=10)
output_box.pack(fill="x", pady=(12, 0))
self._path_entry(output_box, self.mp_output, self._choose_mp_output)
- ttk.Checkbutton(output_box, text="Download conventional standard cells", variable=self.mp_conventional).pack(anchor="w", pady=(8, 0))
- ttk.Checkbutton(output_box, text="Replace an existing verified DiffractScout bundle", variable=self.overwrite).pack(anchor="w", pady=(4, 0))
- ttk.Label(
+ self.chk_conventional = ttk.Checkbutton(
+ output_box, text=self._t("conventional_cells"), variable=self.mp_conventional
+ )
+ self.chk_conventional.pack(anchor="w", pady=(8, 0))
+ self._register_text(self.chk_conventional, "conventional_cells")
+ self.chk_overwrite_mp = ttk.Checkbutton(
+ output_box, text=self._t("overwrite_bundle"), variable=self.overwrite
+ )
+ self.chk_overwrite_mp.pack(anchor="w", pady=(4, 0))
+ self._register_text(self.chk_overwrite_mp, "overwrite_bundle")
+ self.mp_key_hint = ttk.Label(
left,
- text="The key remains in memory. Downloaded structures and Cij records retain provider URLs, identifiers, database metadata, and hashes.",
+ text=self._t("mp_key_hint"),
style="Hint.TLabel",
wraplength=480,
- ).pack(anchor="w", pady=(10, 0))
+ )
+ self.mp_key_hint.pack(anchor="w", pady=(10, 0))
+ self._register_text(self.mp_key_hint, "mp_key_hint")
- self._card_title(right, "2. Analyze downloaded structures", "Shared controls match the local workflow; DFT elastic tensors are labeled and frame-checked.")
+ self._card_title(right, "mp_analyze_title", "mp_analyze_hint")
self._analysis_controls(right)
- options = ttk.LabelFrame(right, text="Outputs", padding=10)
+ options = self._labeled_frame(right, "outputs", padding=10)
options.pack(fill="x", pady=(10, 0))
- ttk.Checkbutton(options, text="Evaluate frame-compatible elasticity", variable=self.include_elasticity).pack(side="left", padx=(0, 14))
- ttk.Checkbutton(options, text="Write Excel workbook", variable=self.include_excel).pack(side="left")
- button = ttk.Button(right, text="Run discovery → diffraction pipeline", style="Primary.TButton", command=self._run_mp)
+ self.chk_elasticity_mp = ttk.Checkbutton(
+ options, text=self._t("eval_elasticity"), variable=self.include_elasticity
+ )
+ self.chk_elasticity_mp.pack(side="left", padx=(0, 14))
+ self._register_text(self.chk_elasticity_mp, "eval_elasticity")
+ self.chk_excel_mp = ttk.Checkbutton(
+ options, text=self._t("write_excel"), variable=self.include_excel
+ )
+ self.chk_excel_mp.pack(side="left")
+ self._register_text(self.chk_excel_mp, "write_excel")
+ button = ttk.Button(
+ right, text=self._t("run_mp"), style="Primary.TButton", command=self._run_mp
+ )
button.pack(fill="x", pady=(14, 0))
+ self._register_text(button, "run_mp")
self._run_buttons.append(button)
def _analysis_controls(self, parent: ttk.Frame) -> None:
- box = ttk.LabelFrame(parent, text="Radiation and profile", padding=10)
+ box = self._labeled_frame(parent, "radiation_profile", padding=10)
box.pack(fill="x")
- ttk.Label(box, text="Input mode", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=4)
- mode = ttk.Combobox(box, textvariable=self.input_mode, values=("source", "energy", "wavelength"), state="readonly", width=13)
- mode.grid(row=0, column=1, sticky="ew", padx=(8, 6), pady=4)
- source = ttk.Combobox(box, textvariable=self.source_preset, values=("Cu Ka", "Co Ka", "Fe Ka", "Mo Ka", "Ag Ka", "Custom"), state="readonly", width=13)
- source.grid(row=0, column=2, sticky="ew", padx=6, pady=4)
+
+ lbl_sc = ttk.Label(box, text=self._t("energy_shortcut"), style="Card.TLabel")
+ lbl_sc.grid(row=0, column=0, sticky="w", pady=4)
+ self._register_text(lbl_sc, "energy_shortcut")
+ shortcut = ttk.Combobox(
+ box,
+ textvariable=self.energy_shortcut,
+ values=_ENERGY_SHORTCUTS,
+ state="readonly",
+ width=13,
+ )
+ shortcut.grid(row=0, column=1, sticky="ew", padx=(8, 6), pady=4)
+
+ lbl_mode = ttk.Label(box, text=self._t("input_mode"), style="Card.TLabel")
+ lbl_mode.grid(row=0, column=2, sticky="w", pady=4)
+ self._register_text(lbl_mode, "input_mode")
+ mode = ttk.Combobox(
+ box,
+ textvariable=self.input_mode,
+ values=("source", "energy", "wavelength"),
+ state="readonly",
+ width=13,
+ )
+ mode.grid(row=0, column=3, sticky="ew", padx=(6, 0), pady=4)
+
+ source = ttk.Combobox(
+ box,
+ textvariable=self.source_preset,
+ values=("Cu Ka", "Co Ka", "Fe Ka", "Mo Ka", "Ag Ka", "Custom"),
+ state="readonly",
+ width=13,
+ )
+ source.grid(row=1, column=0, sticky="ew", padx=(0, 6), pady=4)
value = ttk.Entry(box, textvariable=self.radiation_value, width=13)
- value.grid(row=0, column=3, sticky="ew", padx=(6, 0), pady=4)
+ value.grid(row=1, column=1, sticky="ew", padx=(8, 6), pady=4)
self._radiation_source_widgets.append(source)
self._radiation_value_widgets.append(value)
labels = (
- ("2θ min (°)", self.two_theta_min),
- ("2θ max (°)", self.two_theta_max),
- ("Step (°)", self.step),
- ("FWHM (°)", self.fwhm),
- ("Pseudo-Voigt η", self.eta),
- )
- for index, (label, variable) in enumerate(labels):
- row = 1 + index // 2
+ ("two_theta_min", self.two_theta_min),
+ ("two_theta_max", self.two_theta_max),
+ ("step", self.step),
+ ("fwhm", self.fwhm),
+ ("eta", self.eta),
+ ("d_min", self.d_min_A),
+ ("d_max", self.d_max_A),
+ )
+ for index, (key, variable) in enumerate(labels):
+ row = 2 + index // 2
column = (index % 2) * 2
- ttk.Label(box, text=label, style="Card.TLabel").grid(row=row, column=column, sticky="w", pady=4)
- ttk.Entry(box, textvariable=variable, width=13).grid(row=row, column=column + 1, sticky="ew", padx=(8, 10), pady=4)
+ label = ttk.Label(box, text=self._t(key), style="Card.TLabel")
+ label.grid(row=row, column=column, sticky="w", pady=4)
+ self._register_text(label, key)
+ ttk.Entry(box, textvariable=variable, width=13).grid(
+ row=row, column=column + 1, sticky="ew", padx=(8, 10), pady=4
+ )
+
+ row_pm = 2 + (len(labels) + 1) // 2
+ lbl_pm = ttk.Label(box, text=self._t("profile_model"), style="Card.TLabel")
+ lbl_pm.grid(row=row_pm, column=0, sticky="w", pady=4)
+ self._register_text(lbl_pm, "profile_model")
+ ttk.Combobox(
+ box,
+ textvariable=self.profile_model,
+ values=_PROFILE_MODELS,
+ state="readonly",
+ width=13,
+ ).grid(row=row_pm, column=1, sticky="ew", padx=(8, 10), pady=4)
+ lbl_axis = ttk.Label(box, text=self._t("pattern_axis"), style="Card.TLabel")
+ lbl_axis.grid(row=row_pm, column=2, sticky="w", pady=4)
+ self._register_text(lbl_axis, "pattern_axis")
+ ttk.Combobox(
+ box,
+ textvariable=self.pattern_axis,
+ values=_PATTERN_AXES,
+ state="readonly",
+ width=13,
+ ).grid(row=row_pm, column=3, sticky="ew", padx=(8, 0), pady=4)
+
for column in range(4):
box.columnconfigure(column, weight=1)
- limits = ttk.LabelFrame(parent, text="Resource guards", padding=10)
+ limits = self._labeled_frame(parent, "resource_guards", padding=10)
limits.pack(fill="x", pady=(8, 0))
- ttk.Label(limits, text="Profile points", style="Card.TLabel").grid(row=0, column=0, sticky="w")
- ttk.Entry(limits, textvariable=self.max_profile_points, width=13).grid(row=0, column=1, sticky="ew", padx=(8, 16))
- ttk.Label(limits, text="Reciprocal candidates", style="Card.TLabel").grid(row=0, column=2, sticky="w")
- ttk.Entry(limits, textvariable=self.max_reflection_estimate, width=13).grid(row=0, column=3, sticky="ew", padx=(8, 0))
+ lbl_pp = ttk.Label(limits, text=self._t("profile_points"), style="Card.TLabel")
+ lbl_pp.grid(row=0, column=0, sticky="w")
+ self._register_text(lbl_pp, "profile_points")
+ ttk.Entry(limits, textvariable=self.max_profile_points, width=13).grid(
+ row=0, column=1, sticky="ew", padx=(8, 16)
+ )
+ lbl_rc = ttk.Label(limits, text=self._t("reciprocal_candidates"), style="Card.TLabel")
+ lbl_rc.grid(row=0, column=2, sticky="w")
+ self._register_text(lbl_rc, "reciprocal_candidates")
+ ttk.Entry(limits, textvariable=self.max_reflection_estimate, width=13).grid(
+ row=0, column=3, sticky="ew", padx=(8, 0)
+ )
limits.columnconfigure(1, weight=1)
limits.columnconfigure(3, weight=1)
- def _labeled_entry(self, parent: Any, row: int, label: str, variable: Any, **kwargs: Any) -> None:
- ttk.Label(parent, text=label, style="Card.TLabel").grid(row=row, column=0, sticky="w", pady=5)
- ttk.Entry(parent, textvariable=variable, **kwargs).grid(row=row, column=1, sticky="ew", padx=(8, 0), pady=5)
+ def _build_cij_panel(self, parent: ttk.Frame) -> None:
+ box = self._labeled_frame(parent, "cij_panel", padding=10)
+ box.pack(fill="x", pady=(8, 0))
+
+ cubic = ttk.Frame(box, style="Card.TFrame")
+ cubic.pack(fill="x")
+ for key, var in (("c11", self.cij_c11), ("c12", self.cij_c12), ("c44", self.cij_c44)):
+ lbl = ttk.Label(cubic, text=self._t(key), style="Card.TLabel")
+ lbl.pack(side="left")
+ self._register_text(lbl, key)
+ ttk.Entry(cubic, textvariable=var, width=8).pack(side="left", padx=(4, 10))
+ btn_cubic = ttk.Button(
+ cubic, text=self._t("apply_cubic"), style="Secondary.TButton", command=self._apply_cubic_cij
+ )
+ btn_cubic.pack(side="left")
+ self._register_text(btn_cubic, "apply_cubic")
+
+ paste_lbl = ttk.Label(box, text=self._t("cij_paste_hint"), style="Hint.TLabel")
+ paste_lbl.pack(anchor="w", pady=(8, 2))
+ self._register_text(paste_lbl, "cij_paste_hint")
+ self.cij_paste = tk.Text(
+ box,
+ height=4,
+ wrap="none",
+ font=("Cascadia Mono", 8),
+ relief="solid",
+ borderwidth=1,
+ highlightthickness=0,
+ )
+ self.cij_paste.pack(fill="x")
+
+ actions = ttk.Frame(box, style="Card.TFrame")
+ actions.pack(fill="x", pady=(6, 0))
+ btn_matrix = ttk.Button(
+ actions, text=self._t("apply_matrix"), style="Secondary.TButton", command=self._apply_matrix_cij
+ )
+ btn_matrix.pack(side="left")
+ self._register_text(btn_matrix, "apply_matrix")
+ btn_clear = ttk.Button(
+ actions, text=self._t("clear_cij"), style="Danger.TButton", command=self._clear_cij_override
+ )
+ btn_clear.pack(side="left", padx=(8, 0))
+ self._register_text(btn_clear, "clear_cij")
+ ttk.Label(box, textvariable=self.cij_status, style="Hint.TLabel", wraplength=420).pack(
+ anchor="w", pady=(6, 0)
+ )
def _path_entry(self, parent: Any, variable: Any, command: Callable[[], None]) -> None:
row = ttk.Frame(parent, style="Card.TFrame")
row.pack(fill="x")
ttk.Entry(row, textvariable=variable).pack(side="left", fill="x", expand=True)
- ttk.Button(row, text="Browse", style="Secondary.TButton", command=command).pack(side="left", padx=(8, 0))
+ btn = ttk.Button(row, text=self._t("browse"), style="Secondary.TButton", command=command)
+ btn.pack(side="left", padx=(8, 0))
+ self._register_text(btn, "browse")
def _build_activity_panel(self) -> None:
panel = ttk.Frame(self, padding=(18, 4, 18, 0))
panel.pack(fill="x", side="bottom")
title_row = ttk.Frame(panel)
title_row.pack(fill="x")
- ttk.Label(title_row, text="Activity", font=("Segoe UI Semibold", 10), foreground=NAVY).pack(side="left")
- ttk.Button(title_row, text="Copy", style="Secondary.TButton", command=self._copy_log).pack(side="right", padx=(6, 0))
- ttk.Button(title_row, text="Clear", style="Secondary.TButton", command=self._clear_log).pack(side="right")
+ self.activity_label = ttk.Label(
+ title_row, text=self._t("activity"), font=("Segoe UI Semibold", 10), foreground=NAVY
+ )
+ self.activity_label.pack(side="left")
+ self._register_text(self.activity_label, "activity")
+ btn_copy = ttk.Button(
+ title_row, text=self._t("copy"), style="Secondary.TButton", command=self._copy_log
+ )
+ btn_copy.pack(side="right", padx=(6, 0))
+ self._register_text(btn_copy, "copy")
+ btn_clear = ttk.Button(
+ title_row, text=self._t("clear_log"), style="Secondary.TButton", command=self._clear_log
+ )
+ btn_clear.pack(side="right")
+ self._register_text(btn_clear, "clear_log")
log_frame = tk.Frame(panel, bg=LOG_BG, highlightbackground=BORDER, highlightthickness=1)
log_frame.pack(fill="both", expand=True, pady=(5, 6))
self.log = tk.Text(
@@ -448,11 +791,95 @@ def _build_status_bar(self) -> None:
bar = tk.Frame(self, bg="#E5EDF3", height=38)
bar.pack(fill="x", side="bottom")
bar.pack_propagate(False)
- ttk.Label(bar, textvariable=self.status_text, background="#E5EDF3", foreground=NAVY).pack(side="left", padx=18)
+ ttk.Label(bar, textvariable=self.status_text, background="#E5EDF3", foreground=NAVY).pack(
+ side="left", padx=18
+ )
self.progress = ttk.Progressbar(bar, mode="indeterminate", length=170)
self.progress.pack(side="right", padx=(8, 18), pady=9)
- self.open_button = ttk.Button(bar, text="Open result folder", style="Secondary.TButton", command=self._open_last_output, state="disabled")
+ self.open_button = ttk.Button(
+ bar,
+ text=self._t("open_result"),
+ style="Secondary.TButton",
+ command=self._open_last_output,
+ state="disabled",
+ )
self.open_button.pack(side="right", pady=4)
+ self._register_text(self.open_button, "open_result")
+
+ def _enable_dnd(self, widget: Any) -> None:
+ if not _HAS_DND or DND_FILES is None:
+ return
+ try:
+ widget.drop_target_register(DND_FILES)
+ widget.dnd_bind("<>", self._on_drop_files)
+ except Exception: # pragma: no cover - optional path
+ return
+
+ def _on_drop_files(self, event: Any) -> None:
+ raw = getattr(event, "data", "")
+ try:
+ parts = list(self.tk.splitlist(raw))
+ paths = [Path(item) for item in parts if str(item).strip()]
+ except Exception:
+ paths = _parse_drop_paths(str(raw))
+ if paths:
+ self._add_input_paths(paths)
+
+ def _set_language(self, lang: str) -> None:
+ code = str(lang or DEFAULT_LANG).strip().lower()
+ if code not in {"zh", "en"}:
+ code = DEFAULT_LANG
+ self.lang = code
+ self.lang_var.set(code)
+ self._apply_language()
+
+ def _apply_language(self) -> None:
+ for widget, key, attr in self._i18n_targets:
+ try:
+ if attr == "text":
+ widget.configure(text=self._t(key))
+ except tk.TclError:
+ continue
+ for frame, key in self._labelframes:
+ try:
+ frame.configure(text=self._t(key))
+ except tk.TclError:
+ continue
+ for index, key in self._notebook_tabs:
+ try:
+ self.notebook.tab(index, text=self._t(key))
+ except tk.TclError:
+ continue
+ if not self.running and self.status_text.get() in {
+ t("zh", "status_ready"),
+ t("en", "status_ready"),
+ "Ready",
+ "就绪",
+ }:
+ self.status_text.set(self._t("status_ready"))
+ self._refresh_inputs()
+ self._refresh_cij_status()
+
+ def _on_energy_shortcut(self) -> None:
+ if self._syncing_shortcut:
+ return
+ shortcut = self.energy_shortcut.get()
+ self._syncing_shortcut = True
+ try:
+ if shortcut == _SHORTCUT_CU:
+ self.input_mode.set("source")
+ self.source_preset.set("Cu Ka")
+ self.radiation_value.set("1.5406")
+ elif shortcut == _SHORTCUT_30:
+ self.input_mode.set("energy")
+ self.radiation_value.set("30")
+ elif shortcut == _SHORTCUT_83:
+ self.input_mode.set("energy")
+ self.radiation_value.set("83")
+ # Custom: leave mode/value editable without forcing values.
+ finally:
+ self._syncing_shortcut = False
+ self._sync_radiation_controls()
def _sync_radiation_controls(self) -> None:
mode = self.input_mode.get()
@@ -460,16 +887,101 @@ def _sync_radiation_controls(self) -> None:
for widget in self._radiation_source_widgets:
widget.configure(state="readonly" if mode == "source" else "disabled")
for widget in self._radiation_value_widgets:
- widget.configure(state="normal" if mode in {"energy", "wavelength"} or custom_source else "disabled")
+ widget.configure(
+ state="normal" if mode in {"energy", "wavelength"} or custom_source else "disabled"
+ )
defaults = {"energy": "83", "wavelength": "1.5406"}
if mode in defaults and not self.radiation_value.get().strip():
self.radiation_value.set(defaults[mode])
+ if not self._syncing_shortcut:
+ # Keep shortcut label coherent when mode is edited manually.
+ expected = None
+ if mode == "source" and self.source_preset.get() == "Cu Ka":
+ expected = _SHORTCUT_CU
+ elif mode == "energy" and self.radiation_value.get().strip() == "30":
+ expected = _SHORTCUT_30
+ elif mode == "energy" and self.radiation_value.get().strip() == "83":
+ expected = _SHORTCUT_83
+ else:
+ expected = _SHORTCUT_CUSTOM
+ if self.energy_shortcut.get() != expected:
+ self._syncing_shortcut = True
+ try:
+ self.energy_shortcut.set(expected)
+ finally:
+ self._syncing_shortcut = False
def _toggle_key(self) -> None:
self.mp_key_entry.configure(show="" if self.mp_show_key.get() else "•")
+ def _selected_cif_stems(self) -> list[str]:
+ selected = list(self.input_list.curselection())
+ stems: list[str] = []
+ for index in selected:
+ if index < 0 or index >= len(self.local_inputs):
+ continue
+ path = self.local_inputs[index]
+ if path.is_file() and path.suffix.lower() == ".cif":
+ stems.append(path.stem)
+ return stems
+
+ def _apply_cubic_cij(self) -> None:
+ stems = self._selected_cif_stems()
+ if not stems:
+ messagebox.showerror(self._t("err_cij_apply"), self._t("err_cij_select"))
+ return
+ try:
+ tensor = parse_cubic_cij(
+ _required_float(self.cij_c11.get(), "C11"),
+ _required_float(self.cij_c12.get(), "C12"),
+ _required_float(self.cij_c44.get(), "C44"),
+ source="gui_cubic",
+ )
+ except ValueError as exc:
+ messagebox.showerror(self._t("err_cij_apply"), str(exc))
+ return
+ for stem in stems:
+ self.elastic_overrides[stem] = tensor
+ self._refresh_cij_status()
+ self._log(f"Cij cubic override → {', '.join(stems)}", "info")
+
+ def _apply_matrix_cij(self) -> None:
+ stems = self._selected_cif_stems()
+ if not stems:
+ messagebox.showerror(self._t("err_cij_apply"), self._t("err_cij_select"))
+ return
+ try:
+ matrix = parse_cij_paste_text(self.cij_paste.get("1.0", "end"))
+ tensor = parse_cij_matrix_6x6(matrix)
+ except ValueError as exc:
+ messagebox.showerror(self._t("err_cij_apply"), str(exc))
+ return
+ for stem in stems:
+ self.elastic_overrides[stem] = tensor
+ self._refresh_cij_status()
+ self._log(f"Cij matrix override → {', '.join(stems)}", "info")
+
+ def _clear_cij_override(self) -> None:
+ stems = self._selected_cif_stems()
+ if stems:
+ for stem in stems:
+ self.elastic_overrides.pop(stem, None)
+ else:
+ self.elastic_overrides.clear()
+ self._refresh_cij_status()
+
+ def _refresh_cij_status(self) -> None:
+ if not self.elastic_overrides:
+ self.cij_status.set(self._t("cij_none"))
+ return
+ keys = ", ".join(sorted(self.elastic_overrides))
+ self.cij_status.set(f"Cij: {keys}")
+
def _add_cif_files(self) -> None:
- selected = filedialog.askopenfilenames(title="Select CIF files", filetypes=(("CIF structures", "*.cif"), ("All files", "*.*")))
+ selected = filedialog.askopenfilenames(
+ title="Select CIF files",
+ filetypes=(("CIF structures", "*.cif"), ("All files", "*.*")),
+ )
self._add_input_paths(Path(item) for item in selected)
def _add_cif_folder(self) -> None:
@@ -500,7 +1012,10 @@ def _refresh_inputs(self) -> None:
for path in self.local_inputs:
self.input_list.insert("end", str(path))
count = len(self.local_inputs)
- self.input_count_text.set(f"{count} input path{'s' if count != 1 else ''} selected" if count else "No CIF inputs selected")
+ if count:
+ self.input_count_text.set(self._t("inputs_count", n=count))
+ else:
+ self.input_count_text.set(self._t("inputs_none"))
def _choose_local_output(self) -> None:
selected = filedialog.askdirectory(title="Choose or create a result directory", mustexist=False)
@@ -526,6 +1041,13 @@ def _form_analysis_settings(self) -> AnalysisSettings:
"include_elasticity": self.include_elasticity.get(),
"max_profile_points": self.max_profile_points.get(),
"max_reflection_estimate": self.max_reflection_estimate.get(),
+ "d_min_A": self.d_min_A.get(),
+ "d_max_A": self.d_max_A.get(),
+ "profile_model": self.profile_model.get(),
+ "pattern_axis": self.pattern_axis.get(),
+ "include_figures": self.include_figures.get(),
+ "export_lab_views": self.export_lab_views.get(),
+ "include_patterns": self.include_patterns.get(),
}
)
@@ -546,14 +1068,15 @@ def _run_local(self) -> None:
return
output = self.local_output.get().strip()
if not self.local_inputs or not output:
- messagebox.showerror("Missing input", "Add at least one CIF file or folder and choose a result directory.")
+ messagebox.showerror(self._t("err_title_missing"), self._t("err_missing_local"))
return
try:
settings = self._form_analysis_settings()
except ValueError as exc:
- messagebox.showerror("Invalid settings", str(exc))
+ messagebox.showerror(self._t("err_title_settings"), str(exc))
return
inputs = [str(path) for path in self.local_inputs]
+ overrides = dict(self.elastic_overrides) if self.elastic_overrides else None
self._start_task(
"Analyzing local CIF structures",
lambda: analyze_cifs(
@@ -563,6 +1086,7 @@ def _run_local(self) -> None:
recursive=self.local_recursive.get(),
include_excel=self.include_excel.get(),
overwrite=self.overwrite.get(),
+ elastic_overrides=overrides,
),
)
@@ -573,7 +1097,7 @@ def _run_mp(self) -> None:
api_key = self.mp_key.get().strip()
output = self.mp_output.get().strip()
if not composition or not api_key or not output:
- messagebox.showerror("Missing input", "Composition, API key, and result directory are required.")
+ messagebox.showerror(self._t("err_title_missing"), self._t("err_missing_mp"))
return
try:
discovery = self._form_discovery_settings()
@@ -584,7 +1108,7 @@ def _run_mp(self) -> None:
if not self.mp_conventional.get() and analysis.include_elasticity:
raise ValueError("Disable elasticity before requesting primitive cells.")
except ValueError as exc:
- messagebox.showerror("Invalid settings", str(exc))
+ messagebox.showerror(self._t("err_title_settings"), str(exc))
return
def run() -> PipelineResult:
@@ -665,7 +1189,11 @@ def _poll(self) -> None:
for diagnostic in result.diagnostics:
self._log(
f"{diagnostic.stage} · {diagnostic.item}: {diagnostic.message}",
- "error" if diagnostic.level == "error" else "warning" if diagnostic.level == "warning" else "info",
+ "error"
+ if diagnostic.level == "error"
+ else "warning"
+ if diagnostic.level == "warning"
+ else "info",
)
dialog = messagebox.showwarning if (not result.analyses or error_count) else messagebox.showinfo
dialog(
@@ -693,15 +1221,19 @@ def _clear_log(self) -> None:
def _open_last_output(self) -> None:
if self.last_output is None:
return
+ target: Path = Path(self.last_output)
+ xlsx = target / "results.xlsx"
+ if xlsx.is_file():
+ target = xlsx
try:
- open_path(self.last_output)
+ open_path(target)
except Exception as exc:
- messagebox.showerror("Open result folder", str(exc))
+ messagebox.showerror(self._t("err_open_result"), str(exc))
def _on_close(self) -> None:
if self.running and not messagebox.askyesno(
- "Close DiffractScout",
- "A workflow is still running. Closing the window will stop displaying progress. Close now?",
+ self._t("msg_close_title"),
+ self._t("msg_close_running"),
):
return
self.destroy()
diff --git a/src/diffractscout/gui_i18n.py b/src/diffractscout/gui_i18n.py
new file mode 100644
index 0000000..93ef517
--- /dev/null
+++ b/src/diffractscout/gui_i18n.py
@@ -0,0 +1,331 @@
+"""Bilingual UI strings for the DiffractScout desktop workbench.
+
+Default language is Chinese (``zh``). English keys must stay in parity with
+Chinese so tests and language toggles never miss a label.
+"""
+
+from __future__ import annotations
+
+from typing import Mapping
+
+DEFAULT_LANG = "zh"
+SUPPORTED_LANGS = ("zh", "en")
+
+# Keys the workbench and tests rely on remaining present in every language.
+REQUIRED_KEYS = frozenset(
+ {
+ "app_subtitle",
+ "tab_local",
+ "tab_mp",
+ "lang_label",
+ "lang_zh",
+ "lang_en",
+ "local_select_title",
+ "local_select_hint",
+ "btn_add_cif",
+ "btn_add_folder",
+ "btn_remove",
+ "btn_clear",
+ "result_bundle",
+ "browse",
+ "scan_recursive",
+ "overwrite_bundle",
+ "scientific_title",
+ "scientific_hint",
+ "outputs",
+ "pair_elasticity",
+ "write_excel",
+ "export_lab_views",
+ "include_patterns",
+ "include_figures",
+ "analyze_local",
+ "mp_discover_title",
+ "mp_discover_hint",
+ "mp_query",
+ "composition",
+ "api_key",
+ "show_key",
+ "mode",
+ "e_hull_max",
+ "subsystem_order",
+ "per_subsystem",
+ "max_candidates",
+ "include_deprecated",
+ "conventional_cells",
+ "mp_key_hint",
+ "mp_analyze_title",
+ "mp_analyze_hint",
+ "eval_elasticity",
+ "run_mp",
+ "radiation_profile",
+ "energy_shortcut",
+ "input_mode",
+ "two_theta_min",
+ "two_theta_max",
+ "step",
+ "fwhm",
+ "eta",
+ "d_min",
+ "d_max",
+ "profile_model",
+ "pattern_axis",
+ "resource_guards",
+ "profile_points",
+ "reciprocal_candidates",
+ "cij_panel",
+ "c11",
+ "c12",
+ "c44",
+ "apply_cubic",
+ "cij_paste_hint",
+ "apply_matrix",
+ "clear_cij",
+ "cij_none",
+ "activity",
+ "copy",
+ "clear_log",
+ "open_result",
+ "status_ready",
+ "inputs_none",
+ "inputs_count",
+ "err_missing_local",
+ "err_missing_mp",
+ "err_invalid_settings",
+ "err_title_missing",
+ "err_title_settings",
+ "err_open_result",
+ "err_cij_select",
+ "err_cij_apply",
+ "msg_close_running",
+ "msg_close_title",
+ "log_ready",
+ "shortcut_cu",
+ "shortcut_30",
+ "shortcut_83",
+ "shortcut_custom",
+ }
+)
+
+STRINGS: dict[str, dict[str, str]] = {
+ "zh": {
+ "app_subtitle": "候选相 → 校验 CIF → 指标化粉末衍射 → 可选 hkl 弹性",
+ "tab_local": " 本地 CIF 分析 ",
+ "tab_mp": " Materials Project 流程 ",
+ "lang_label": "语言",
+ "lang_zh": "中文",
+ "lang_en": "English",
+ "local_select_title": "1. 选择结构",
+ "local_select_hint": "添加 CIF 文件或扫描文件夹。重复路径会自动去重。",
+ "btn_add_cif": "添加 CIF 文件",
+ "btn_add_folder": "添加文件夹",
+ "btn_remove": "移除",
+ "btn_clear": "清空",
+ "result_bundle": "结果目录",
+ "browse": "浏览",
+ "scan_recursive": "递归扫描所选文件夹",
+ "overwrite_bundle": "覆盖已通过校验的 DiffractScout 结果包",
+ "scientific_title": "2. 科学参数",
+ "scientific_hint": "理论运动学粉末参考。资源上限用于防止意外的大网格占用。",
+ "outputs": "输出选项",
+ "pair_elasticity": "配对数值弹性侧车",
+ "write_excel": "写入 Excel 工作簿",
+ "export_lab_views": "导出实验室视图",
+ "include_patterns": "包含连续谱线",
+ "include_figures": "生成图件(若已实现)",
+ "analyze_local": "分析所选 CIF",
+ "mp_discover_title": "1. 发现候选相",
+ "mp_discover_hint": "输入合金牌号、化学式、化学体系或显式 mp-ID。",
+ "mp_query": "Materials Project 查询",
+ "composition": "成分",
+ "api_key": "API 密钥",
+ "show_key": "显示",
+ "mode": "模式",
+ "e_hull_max": "Eₕᵤₗₗ 上限",
+ "subsystem_order": "子系统阶数",
+ "per_subsystem": "每子系统上限",
+ "max_candidates": "候选总数上限",
+ "include_deprecated": "包含已弃用记录",
+ "conventional_cells": "下载常规标准晶胞",
+ "mp_key_hint": "密钥仅保存在内存中。下载结构与 Cij 会保留提供方 URL、标识、数据库元数据与哈希。",
+ "mp_analyze_title": "2. 分析已下载结构",
+ "mp_analyze_hint": "与本地流程共用辐射/线型控件;DFT 弹性张量会标注并做坐标系检查。",
+ "eval_elasticity": "评估坐标系兼容的弹性",
+ "run_mp": "运行 发现 → 衍射 流程",
+ "radiation_profile": "辐射与线型",
+ "energy_shortcut": "能量快捷",
+ "input_mode": "输入模式",
+ "two_theta_min": "2θ 最小 (°)",
+ "two_theta_max": "2θ 最大 (°)",
+ "step": "步长 (°)",
+ "fwhm": "FWHM (°)",
+ "eta": "Pseudo-Voigt η",
+ "d_min": "d_min (Å)",
+ "d_max": "d_max (Å)",
+ "profile_model": "线型模型",
+ "pattern_axis": "谱图横轴",
+ "resource_guards": "资源保护",
+ "profile_points": "谱线点数",
+ "reciprocal_candidates": "倒易候选上限",
+ "cij_panel": "Cij 覆盖(按所选 CIF 词干)",
+ "c11": "C11",
+ "c12": "C12",
+ "c44": "C44",
+ "apply_cubic": "应用立方",
+ "cij_paste_hint": "粘贴 6×6 Cij(GPa,空格/逗号分隔)",
+ "apply_matrix": "应用矩阵",
+ "clear_cij": "清除覆盖",
+ "cij_none": "当前无用户 Cij 覆盖",
+ "activity": "活动日志",
+ "copy": "复制",
+ "clear_log": "清空",
+ "open_result": "打开结果",
+ "status_ready": "就绪",
+ "inputs_none": "未选择 CIF 输入",
+ "inputs_count": "{n} 个输入路径已选择",
+ "err_missing_local": "请至少添加一个 CIF 文件或文件夹,并选择结果目录。",
+ "err_missing_mp": "需要成分、API 密钥和结果目录。",
+ "err_invalid_settings": "参数无效",
+ "err_title_missing": "缺少输入",
+ "err_title_settings": "参数无效",
+ "err_open_result": "打开结果",
+ "err_cij_select": "请先在列表中选择一个 CIF 文件(非文件夹)。",
+ "err_cij_apply": "Cij 应用失败",
+ "msg_close_running": "流程仍在运行。关闭窗口将停止显示进度。现在关闭?",
+ "msg_close_title": "关闭 DiffractScout",
+ "log_ready": "就绪。源 CIF 与 PhaseScout/CIF2Peaks 仓库为只读输入。",
+ "shortcut_cu": "Cu Kα",
+ "shortcut_30": "30 keV",
+ "shortcut_83": "83 keV",
+ "shortcut_custom": "自定义",
+ },
+ "en": {
+ "app_subtitle": "Candidate phases → validated CIFs → indexed powder diffraction → optional hkl elasticity",
+ "tab_local": " Local CIF analysis ",
+ "tab_mp": " Materials Project pipeline ",
+ "lang_label": "Language",
+ "lang_zh": "中文",
+ "lang_en": "English",
+ "local_select_title": "1. Select structures",
+ "local_select_hint": "Add individual CIF files or scan one or more folders. Duplicate paths are removed.",
+ "btn_add_cif": "Add CIF files",
+ "btn_add_folder": "Add folder",
+ "btn_remove": "Remove",
+ "btn_clear": "Clear",
+ "result_bundle": "Result bundle",
+ "browse": "Browse",
+ "scan_recursive": "Scan selected folders recursively",
+ "overwrite_bundle": "Replace an existing verified DiffractScout bundle",
+ "scientific_title": "2. Scientific controls",
+ "scientific_hint": "Theoretical kinematic powder reference. Limits prevent accidental memory-intensive grids.",
+ "outputs": "Outputs",
+ "pair_elasticity": "Pair numerical elasticity sidecars",
+ "write_excel": "Write Excel workbook",
+ "export_lab_views": "Export lab views",
+ "include_patterns": "Include continuous patterns",
+ "include_figures": "Generate figures (when available)",
+ "analyze_local": "Analyze selected CIFs",
+ "mp_discover_title": "1. Discover candidate phases",
+ "mp_discover_hint": "Enter an alloy grade, formula, chemical system, or explicit mp-IDs.",
+ "mp_query": "Materials Project query",
+ "composition": "Composition",
+ "api_key": "API key",
+ "show_key": "Show",
+ "mode": "Mode",
+ "e_hull_max": "Eₕᵤₗₗ max",
+ "subsystem_order": "Subsystem order",
+ "per_subsystem": "Per subsystem",
+ "max_candidates": "Maximum candidates",
+ "include_deprecated": "Include deprecated",
+ "conventional_cells": "Download conventional standard cells",
+ "mp_key_hint": "The key remains in memory. Downloaded structures and Cij records retain provider URLs, identifiers, database metadata, and hashes.",
+ "mp_analyze_title": "2. Analyze downloaded structures",
+ "mp_analyze_hint": "Shared controls match the local workflow; DFT elastic tensors are labeled and frame-checked.",
+ "eval_elasticity": "Evaluate frame-compatible elasticity",
+ "run_mp": "Run discovery → diffraction pipeline",
+ "radiation_profile": "Radiation and profile",
+ "energy_shortcut": "Energy shortcut",
+ "input_mode": "Input mode",
+ "two_theta_min": "2θ min (°)",
+ "two_theta_max": "2θ max (°)",
+ "step": "Step (°)",
+ "fwhm": "FWHM (°)",
+ "eta": "Pseudo-Voigt η",
+ "d_min": "d_min (Å)",
+ "d_max": "d_max (Å)",
+ "profile_model": "Profile model",
+ "pattern_axis": "Pattern axis",
+ "resource_guards": "Resource guards",
+ "profile_points": "Profile points",
+ "reciprocal_candidates": "Reciprocal candidates",
+ "cij_panel": "Cij overrides (selected CIF stem)",
+ "c11": "C11",
+ "c12": "C12",
+ "c44": "C44",
+ "apply_cubic": "Apply cubic",
+ "cij_paste_hint": "Paste 6×6 Cij (GPa, whitespace/comma separated)",
+ "apply_matrix": "Apply matrix",
+ "clear_cij": "Clear override",
+ "cij_none": "No user Cij overrides",
+ "activity": "Activity",
+ "copy": "Copy",
+ "clear_log": "Clear",
+ "open_result": "Open result",
+ "status_ready": "Ready",
+ "inputs_none": "No CIF inputs selected",
+ "inputs_count": "{n} input path(s) selected",
+ "err_missing_local": "Add at least one CIF file or folder and choose a result directory.",
+ "err_missing_mp": "Composition, API key, and result directory are required.",
+ "err_invalid_settings": "Invalid settings",
+ "err_title_missing": "Missing input",
+ "err_title_settings": "Invalid settings",
+ "err_open_result": "Open result",
+ "err_cij_select": "Select a CIF file (not a folder) in the list first.",
+ "err_cij_apply": "Cij apply failed",
+ "msg_close_running": "A workflow is still running. Closing the window will stop displaying progress. Close now?",
+ "msg_close_title": "Close DiffractScout",
+ "log_ready": "Ready. Source CIFs and the PhaseScout/CIF2Peaks repositories are read-only inputs.",
+ "shortcut_cu": "Cu Kα",
+ "shortcut_30": "30 keV",
+ "shortcut_83": "83 keV",
+ "shortcut_custom": "Custom",
+ },
+}
+
+
+def normalize_lang(lang: str | None) -> str:
+ code = str(lang or DEFAULT_LANG).strip().lower()
+ if code.startswith("zh"):
+ return "zh"
+ if code.startswith("en"):
+ return "en"
+ return DEFAULT_LANG
+
+
+def t(lang: str | None, key: str, **fmt: object) -> str:
+ """Look up a UI string; fall back to English then the key name."""
+
+ code = normalize_lang(lang)
+ table: Mapping[str, str] = STRINGS.get(code) or STRINGS[DEFAULT_LANG]
+ text = table.get(key) or STRINGS["en"].get(key) or key
+ if fmt:
+ try:
+ return text.format(**fmt)
+ except (KeyError, ValueError):
+ return text
+ return text
+
+
+def assert_language_parity() -> None:
+ """Raise AssertionError if zh/en tables disagree on required keys."""
+
+ for lang in SUPPORTED_LANGS:
+ missing = REQUIRED_KEYS - set(STRINGS[lang])
+ if missing:
+ raise AssertionError(f"Language {lang!r} missing keys: {sorted(missing)}")
+ zh_keys = set(STRINGS["zh"])
+ en_keys = set(STRINGS["en"])
+ if zh_keys != en_keys:
+ raise AssertionError(
+ f"zh/en key mismatch: only_zh={sorted(zh_keys - en_keys)} "
+ f"only_en={sorted(en_keys - zh_keys)}"
+ )
diff --git a/src/diffractscout/hkl.py b/src/diffractscout/hkl.py
new file mode 100644
index 0000000..c561d16
--- /dev/null
+++ b/src/diffractscout/hkl.py
@@ -0,0 +1,104 @@
+"""Miller and Miller–Bravais index helpers."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Any
+
+
+def normalize_hkl(values: Iterable[object]) -> tuple[int, ...]:
+ hkl = tuple(int(value) for value in values)
+ if len(hkl) not in {3, 4}:
+ raise ValueError(f"hkl must contain 3 or 4 indices, got {len(hkl)}: {hkl}")
+ return hkl
+
+
+def format_hkl(values: Iterable[object]) -> str:
+ return f"({' '.join(str(value) for value in normalize_hkl(values))})"
+
+
+def split_hkl_components(values: Iterable[object]) -> tuple[int, int, int | None, int]:
+ hkl = normalize_hkl(values)
+ if len(hkl) == 3:
+ h, k, ell = hkl
+ return h, k, None, ell
+ h, k, i, ell = hkl
+ return h, k, i, ell
+
+
+def plane_hkl_for_normal(values: Iterable[object]) -> tuple[int, int, int]:
+ """Return the three-index plane used for reciprocal-lattice normals.
+
+ Four-index Miller–Bravais planes must satisfy ``i = -(h + k)``.
+ """
+
+ hkl = normalize_hkl(values)
+ if len(hkl) == 3:
+ h, k, ell = hkl
+ return h, k, ell
+ h, k, i, ell = hkl
+ if i != -(h + k):
+ raise ValueError(
+ f"Four-index Miller-Bravais plane hkil must satisfy i = -(h + k), got {hkl}."
+ )
+ return h, k, ell
+
+
+def miller_bravais_i(h: int, k: int) -> int:
+ """Return the Miller–Bravais basal index ``i = -(h + k)``."""
+
+ return -(int(h) + int(k))
+
+
+def _uses_miller_bravais(crystal_system: str) -> bool:
+ text = crystal_system.casefold()
+ return "hex" in text or "trigonal" in text
+
+
+def uses_miller_bravais(space_group: Any) -> bool:
+ """True when the space group's crystal system is hexagonal or trigonal."""
+
+ if space_group is None:
+ return False
+ try:
+ system = str(space_group.crystal_system_str())
+ except Exception:
+ return False
+ return _uses_miller_bravais(system)
+
+
+def family_label_hkl(
+ h: int,
+ k: int,
+ l: int, # noqa: E741 - conventional Miller index name
+ *,
+ use_four_index: bool = False,
+ i: int | None = None,
+) -> str:
+ """Curly-brace family label; optional Miller–Bravais four-index form."""
+
+ h_i, k_i, l_i = int(h), int(k), int(l)
+ if use_four_index:
+ index_i = miller_bravais_i(h_i, k_i) if i is None else int(i)
+ return "{" + f"{h_i} {k_i} {index_i} {l_i}" + "}"
+ return "{" + f"{h_i} {k_i} {l_i}" + "}"
+
+
+def label_hkl_for_crystal_system(
+ h: int,
+ k: int,
+ l: int, # noqa: E741 - conventional Miller index name
+ crystal_system: str,
+) -> tuple[str, int | None]:
+ """Return a display label and optional Miller–Bravais ``i``.
+
+ Hexagonal and trigonal systems (``crystal_system`` lowercased contains
+ ``hex`` or ``trigonal``) use four-index Miller–Bravais labels such as
+ ``(1 0 -1 0)``. Other systems use three-index ``(h k l)``.
+ """
+
+ h_i, k_i, l_i = int(h), int(k), int(l)
+ if _uses_miller_bravais(crystal_system):
+ i = miller_bravais_i(h_i, k_i)
+ return format_hkl((h_i, k_i, i, l_i)), i
+ return format_hkl((h_i, k_i, l_i)), None
diff --git a/src/diffractscout/models.py b/src/diffractscout/models.py
index 2b055a6..d4a3d50 100644
--- a/src/diffractscout/models.py
+++ b/src/diffractscout/models.py
@@ -10,6 +10,8 @@
SearchMode = Literal["possible_phases", "near_stable", "single_chemsys", "mpids_only"]
XrayInputMode = Literal["source", "wavelength", "energy"]
DiagnosticLevel = Literal["info", "warning", "error"]
+ProfileModel = Literal["pseudo_voigt", "gaussian", "lorentzian"]
+PatternAxis = Literal["two_theta", "d_spacing", "q", "g"]
@dataclass(frozen=True)
@@ -76,6 +78,16 @@ class AnalysisSettings:
include_elasticity: bool = True
max_profile_points: int = 1_000_000
max_reflection_estimate: int = 2_000_000
+ # Optional d-spacing filter (Å). When set, reflections outside the range are
+ # dropped and the 2θ search window is narrowed by Bragg intersection.
+ d_min_A: float | None = None
+ d_max_A: float | None = None
+ profile_model: ProfileModel = "pseudo_voigt"
+ pattern_axis: PatternAxis = "two_theta"
+ include_figures: bool = False
+ figure_preset: str = "publication"
+ export_lab_views: bool = True
+ include_patterns: bool = True
@dataclass(frozen=True)
@@ -156,6 +168,22 @@ class ReflectionRecord:
young_modulus_hkl_normal_GPa: float | None = None
elastic_status: str = "not_requested"
elastic_note: str = ""
+ # Optional parity / export enrichment fields (defaults preserve lean callers).
+ i: int | None = None # Miller–Bravais i = -(h+k) for hex/trigonal
+ two_theta_cu_ka_deg: float = 0.0
+ inverse_R_hkl: float | None = None
+ inverse_R_hkl_no_lp: float | None = None
+ phase_relative_R_hkl_pct: float = 0.0
+ phase_relative_R_hkl_no_lp_pct: float = 0.0
+ sin_theta: float = 0.0
+ cos_theta: float = 0.0
+ sin_theta_over_lambda: float = 0.0
+ sin2_theta_over_lambda2: float = 0.0
+ mean_structure_factor_sq_per_multiplicity: float = 0.0
+ mean_structure_factor_abs_per_multiplicity: float = 0.0
+ is_multi_family_peak: bool = False
+ coincident_hkl_family_count: int = 1
+ r_hkl_model_note: str = ""
@property
def hkl(self) -> tuple[int, int, int]:
diff --git a/src/diffractscout/pipeline.py b/src/diffractscout/pipeline.py
index f4b63d2..0cf701d 100644
--- a/src/diffractscout/pipeline.py
+++ b/src/diffractscout/pipeline.py
@@ -7,12 +7,12 @@
import tempfile
from dataclasses import replace
from pathlib import Path
-from typing import Iterable, Sequence
+from typing import Iterable, Mapping, Sequence
from uuid import uuid4
from .composition import parse_composition_text
from .diffraction import simulate_powder_pattern
-from .elasticity import discover_elastic_tensor
+from .elasticity import discover_elastic_tensor, validate_elastic_tensor
from .exporters import export_result_bundle
from .models import (
AnalysisSettings,
@@ -161,17 +161,60 @@ def _commit_staging_output(target: Path, staging: Path) -> None:
shutil.rmtree(backup, ignore_errors=True)
+def _lookup_elastic_override(
+ cif_path: Path,
+ overrides: Mapping[str, ElasticTensor] | None,
+) -> ElasticTensor | None:
+ """Match an override by CIF filename or stem (case-sensitive keys)."""
+
+ if not overrides:
+ return None
+ for key in (cif_path.name, cif_path.stem):
+ if key in overrides:
+ return overrides[key]
+ return None
+
+
+def _revalidate_user_tensor(tensor: ElasticTensor) -> ElasticTensor:
+ """Re-run validation so overrides cannot bypass stiffness checks."""
+
+ return validate_elastic_tensor(
+ tensor.stiffness_GPa,
+ source_provider=tensor.source_provider or "user_input",
+ source_record_id=tensor.source_record_id,
+ source_url=tensor.source_url,
+ methodology_url=tensor.methodology_url,
+ nature_of_data=tensor.nature_of_data or "user_input",
+ coordinate_frame=tensor.coordinate_frame,
+ raw_payload_path=tensor.raw_payload_path,
+ )
+
+
def _copy_local_input(
cif_path: Path,
inputs_dir: Path,
*,
include_elasticity: bool,
+ elastic_override: ElasticTensor | None = None,
) -> tuple[Path, ElasticTensor | None]:
digest = sha256_file(cif_path)
target = _unique_input_target(cif_path, inputs_dir, digest)
shutil.copy2(cif_path, target)
- tensor = discover_elastic_tensor(cif_path) if include_elasticity else None
+ if not include_elasticity:
+ return target, None
+
+ if elastic_override is not None:
+ tensor = _revalidate_user_tensor(elastic_override)
+ if tensor.raw_payload_path is not None and tensor.raw_payload_path.is_file():
+ sidecar_target = target.with_name(
+ f"{target.stem}_elasticity{tensor.raw_payload_path.suffix}"
+ )
+ shutil.copy2(tensor.raw_payload_path, sidecar_target)
+ tensor.raw_payload_path = sidecar_target
+ return target, tensor
+
+ tensor = discover_elastic_tensor(cif_path)
if tensor is not None and tensor.raw_payload_path is not None and tensor.raw_payload_path.is_file():
sidecar_target = target.with_name(
f"{target.stem}_elasticity{tensor.raw_payload_path.suffix}"
@@ -235,6 +278,7 @@ def analyze_cifs(
recursive: bool = True,
include_excel: bool = True,
overwrite: bool = False,
+ elastic_overrides: Mapping[str, ElasticTensor] | None = None,
) -> PipelineResult:
settings = settings or AnalysisSettings()
_validate_input_output_separation(inputs, output_dir)
@@ -251,6 +295,11 @@ def analyze_cifs(
path,
inputs_dir,
include_elasticity=settings.include_elasticity,
+ elastic_override=(
+ _lookup_elastic_override(path, elastic_overrides)
+ if settings.include_elasticity
+ else None
+ ),
)
for path in paths
]
diff --git a/src/diffractscout/plotting.py b/src/diffractscout/plotting.py
new file mode 100644
index 0000000..6eda84a
--- /dev/null
+++ b/src/diffractscout/plotting.py
@@ -0,0 +1,1080 @@
+"""Publication-style powder XRD figure export (SVG/PDF/EPS/PNG/TIFF).
+
+SVG, PDF, and EPS use pure writers (no matplotlib). PNG and TIFF prefer
+matplotlib when installed, otherwise a pure-Python raster fallback.
+Adapted from CIF2Peaks plotting for DiffractScout PhaseAnalysis.
+"""
+
+from __future__ import annotations
+
+import html
+import struct
+import zlib
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Sequence
+
+import numpy as np
+
+from .models import PhaseAnalysis
+from .utils import slugify
+
+
+@dataclass(frozen=True)
+class FigureExportPreset:
+ width_in: float
+ height_in: float
+ dpi: int
+ font_family: str
+ label_font_pt: float
+ tick_font_pt: float
+ title_font_pt: float
+ legend_font_pt: float
+ line_width_pt: float
+ marker_size_pt: float
+ axis_width_pt: float
+ color_cycle: tuple[str, ...]
+ scientific_colormap: str
+ constrained_layout: bool = True
+
+
+# Prefer fonts commonly present on Windows/Linux; avoid Helvetica-only warnings.
+PUBLICATION_FONT_STACK = "DejaVu Sans, Arial, Microsoft YaHei, sans-serif"
+COLORBLIND_SAFE_COLORS = ("#2f5d8c", "#b24c3f", "#4f7f52", "#6f5b9a", "#8a6f3d")
+PUBLICATION_EXPORT_FORMATS = ("svg", "pdf", "eps", "png", "tif")
+
+_BITMAP_FONT_5X7 = {
+ " ": ("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
+ "!": ("00100", "00100", "00100", "00100", "00100", "00000", "00100"),
+ "?": ("01110", "10001", "00001", "00010", "00100", "00000", "00100"),
+ ".": ("00000", "00000", "00000", "00000", "00000", "00110", "00110"),
+ ",": ("00000", "00000", "00000", "00000", "00110", "00100", "01000"),
+ ":": ("00000", "00110", "00110", "00000", "00110", "00110", "00000"),
+ "-": ("00000", "00000", "00000", "11111", "00000", "00000", "00000"),
+ "_": ("00000", "00000", "00000", "00000", "00000", "00000", "11111"),
+ "/": ("00001", "00010", "00010", "00100", "01000", "01000", "10000"),
+ "(": ("00010", "00100", "01000", "01000", "01000", "00100", "00010"),
+ ")": ("01000", "00100", "00010", "00010", "00010", "00100", "01000"),
+ "0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
+ "1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
+ "2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
+ "3": ("11110", "00001", "00001", "01110", "00001", "00001", "11110"),
+ "4": ("00010", "00110", "01010", "10010", "11111", "00010", "00010"),
+ "5": ("11111", "10000", "10000", "11110", "00001", "00001", "11110"),
+ "6": ("00110", "01000", "10000", "11110", "10001", "10001", "01110"),
+ "7": ("11111", "00001", "00010", "00100", "01000", "01000", "01000"),
+ "8": ("01110", "10001", "10001", "01110", "10001", "10001", "01110"),
+ "9": ("01110", "10001", "10001", "01111", "00001", "00010", "01100"),
+ "A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
+ "B": ("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
+ "C": ("01111", "10000", "10000", "10000", "10000", "10000", "01111"),
+ "D": ("11110", "10001", "10001", "10001", "10001", "10001", "11110"),
+ "E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
+ "F": ("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
+ "G": ("01111", "10000", "10000", "10011", "10001", "10001", "01111"),
+ "H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
+ "I": ("01110", "00100", "00100", "00100", "00100", "00100", "01110"),
+ "J": ("00001", "00001", "00001", "00001", "10001", "10001", "01110"),
+ "K": ("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
+ "L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
+ "M": ("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
+ "N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
+ "O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
+ "P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
+ "Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
+ "R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
+ "S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
+ "T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
+ "U": ("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
+ "V": ("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
+ "W": ("10001", "10001", "10001", "10101", "10101", "10101", "01010"),
+ "X": ("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
+ "Y": ("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
+ "Z": ("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
+}
+
+FIGURE_EXPORT_PRESETS: dict[str, FigureExportPreset] = {
+ "single_column": FigureExportPreset(
+ width_in=3.35,
+ height_in=2.35,
+ dpi=600,
+ font_family=PUBLICATION_FONT_STACK,
+ label_font_pt=8.0,
+ tick_font_pt=7.0,
+ title_font_pt=8.5,
+ legend_font_pt=7.0,
+ line_width_pt=0.9,
+ marker_size_pt=3.0,
+ axis_width_pt=0.75,
+ color_cycle=COLORBLIND_SAFE_COLORS,
+ scientific_colormap="viridis",
+ ),
+ "double_column": FigureExportPreset(
+ width_in=7.0,
+ height_in=4.2,
+ dpi=600,
+ font_family=PUBLICATION_FONT_STACK,
+ label_font_pt=9.0,
+ tick_font_pt=8.0,
+ title_font_pt=10.0,
+ legend_font_pt=8.0,
+ line_width_pt=1.0,
+ marker_size_pt=3.5,
+ axis_width_pt=0.8,
+ color_cycle=COLORBLIND_SAFE_COLORS,
+ scientific_colormap="viridis",
+ ),
+ "presentation": FigureExportPreset(
+ width_in=10.0,
+ height_in=5.6,
+ dpi=300,
+ font_family=PUBLICATION_FONT_STACK,
+ label_font_pt=18.0,
+ tick_font_pt=14.0,
+ title_font_pt=20.0,
+ legend_font_pt=14.0,
+ line_width_pt=2.0,
+ marker_size_pt=5.0,
+ axis_width_pt=1.2,
+ color_cycle=COLORBLIND_SAFE_COLORS,
+ scientific_colormap="cividis",
+ ),
+ "raw_inspection": FigureExportPreset(
+ width_in=6.0,
+ height_in=3.5,
+ dpi=300,
+ font_family=PUBLICATION_FONT_STACK,
+ label_font_pt=10.0,
+ tick_font_pt=9.0,
+ title_font_pt=11.0,
+ legend_font_pt=9.0,
+ line_width_pt=1.2,
+ marker_size_pt=4.0,
+ axis_width_pt=0.9,
+ color_cycle=COLORBLIND_SAFE_COLORS,
+ scientific_colormap="gray",
+ ),
+ "publication": FigureExportPreset(
+ width_in=3.5,
+ height_in=2.55,
+ dpi=600,
+ font_family=PUBLICATION_FONT_STACK,
+ label_font_pt=8.0,
+ tick_font_pt=7.0,
+ title_font_pt=8.5,
+ legend_font_pt=7.0,
+ line_width_pt=0.9,
+ marker_size_pt=3.0,
+ axis_width_pt=0.75,
+ color_cycle=COLORBLIND_SAFE_COLORS,
+ scientific_colormap="viridis",
+ ),
+}
+
+
+def _preset(name: str) -> FigureExportPreset:
+ try:
+ return FIGURE_EXPORT_PRESETS[name]
+ except KeyError as exc:
+ valid = ", ".join(sorted(FIGURE_EXPORT_PRESETS))
+ raise ValueError(f"Unknown figure export preset: {name}. Valid presets: {valid}") from exc
+
+
+def _nice_ticks(lower: float, upper: float, count: int = 5) -> list[float]:
+ if not np.isfinite(lower) or not np.isfinite(upper) or upper <= lower:
+ return [lower]
+ raw_step = (upper - lower) / max(count - 1, 1)
+ exponent = np.floor(np.log10(raw_step))
+ base = raw_step / (10**exponent)
+ if base <= 1.5:
+ nice_base = 1.0
+ elif base <= 3.0:
+ nice_base = 2.0
+ elif base <= 7.0:
+ nice_base = 5.0
+ else:
+ nice_base = 10.0
+ step = nice_base * (10**exponent)
+ first = np.ceil(lower / step) * step
+ ticks: list[float] = []
+ value = first
+ while value <= upper + step * 0.25:
+ ticks.append(float(value))
+ value += step
+ return ticks or [lower, upper]
+
+
+def _format_tick(value: float) -> str:
+ if abs(value) >= 100 or float(value).is_integer():
+ return f"{value:.0f}"
+ return f"{value:.1f}".rstrip("0").rstrip(".")
+
+
+def _polyline(points: Sequence[tuple[float, float]]) -> str:
+ return " ".join(f"{x:.2f},{y:.2f}" for x, y in points)
+
+
+def _rgb01(hex_color: str) -> tuple[float, float, float]:
+ color = hex_color.lstrip("#")
+ if len(color) != 6:
+ return 0.0, 0.0, 0.0
+ return tuple(int(color[index : index + 2], 16) / 255.0 for index in (0, 2, 4))
+
+
+def _pdf_escape(text: str) -> str:
+ return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
+
+
+def _ps_escape(text: str) -> str:
+ return _pdf_escape(text)
+
+
+def _ascii_plot_title(title: str) -> str:
+ return title.encode("ascii", errors="replace").decode("ascii")
+
+
+def _matplotlib_font_family(preset: FigureExportPreset) -> list[str]:
+ return [font.strip() for font in preset.font_family.split(",") if font.strip()]
+
+
+def _coerce_profile(
+ analysis: PhaseAnalysis | None = None,
+ *,
+ two_theta_grid: np.ndarray | Sequence[float] | None = None,
+ intensity_profile: np.ndarray | Sequence[float] | None = None,
+ title: str | None = None,
+) -> tuple[np.ndarray, np.ndarray, str]:
+ if analysis is not None:
+ x_values = np.asarray(analysis.two_theta_grid, dtype=float)
+ y_values = np.asarray(analysis.intensity_profile, dtype=float)
+ plot_title = title if title is not None else (analysis.phase_name or "Theoretical powder XRD")
+ else:
+ if two_theta_grid is None or intensity_profile is None:
+ raise ValueError("Provide PhaseAnalysis or both two_theta_grid and intensity_profile.")
+ x_values = np.asarray(two_theta_grid, dtype=float)
+ y_values = np.asarray(intensity_profile, dtype=float)
+ plot_title = title or "Theoretical powder XRD"
+ if x_values.size == 0 or y_values.size == 0:
+ raise ValueError("Profile contains no points to plot.")
+ if x_values.size != y_values.size:
+ raise ValueError("Profile x/y arrays have different lengths.")
+ return x_values, y_values, plot_title
+
+
+def _profile_plot_geometry(
+ x_values: np.ndarray,
+ y_values: np.ndarray,
+ preset: FigureExportPreset,
+ units_per_inch: float,
+) -> dict[str, object]:
+
+ width = preset.width_in * units_per_inch
+ height = preset.height_in * units_per_inch
+ margin_left = max(0.48 * units_per_inch, preset.label_font_pt * 4.5)
+ margin_right = max(0.16 * units_per_inch, preset.tick_font_pt * 1.4)
+ margin_top = max(0.28 * units_per_inch, preset.title_font_pt * 2.2)
+ margin_bottom = max(0.42 * units_per_inch, preset.label_font_pt * 4.0)
+ plot_left = margin_left
+ plot_bottom = margin_bottom
+ plot_width = width - margin_left - margin_right
+ plot_height = height - margin_top - margin_bottom
+
+ x_min = float(np.nanmin(x_values))
+ x_max = float(np.nanmax(x_values))
+ y_max = float(np.nanmax(y_values))
+ if y_max <= 0 or not np.isfinite(y_max):
+ y_max = 1.0
+ y_upper = max(100.0, y_max) * 1.04
+
+ def sx(value: float) -> float:
+ return plot_left + (value - x_min) / (x_max - x_min) * plot_width if x_max > x_min else plot_left
+
+ def sy(value: float) -> float:
+ return plot_bottom + (value / y_upper) * plot_height
+
+ points = [(sx(float(x)), sy(float(y))) for x, y in zip(x_values, y_values, strict=True)]
+ return {
+ "width": width,
+ "height": height,
+ "plot_left": plot_left,
+ "plot_bottom": plot_bottom,
+ "plot_width": plot_width,
+ "plot_height": plot_height,
+ "points": points,
+ "x_ticks": _nice_ticks(x_min, x_max, 6),
+ "y_ticks": _nice_ticks(0.0, y_upper, 5),
+ "sx": sx,
+ "sy": sy,
+ }
+
+
+def _draw_pixel(buffer: bytearray, width: int, height: int, x: int, y: int, color: tuple[int, int, int]) -> None:
+ if x < 0 or y < 0 or x >= width or y >= height:
+ return
+ offset = (y * width + x) * 3
+ buffer[offset : offset + 3] = bytes(color)
+
+
+def _draw_dot(buffer: bytearray, width: int, height: int, x: int, y: int, radius: int, color: tuple[int, int, int]) -> None:
+ radius = max(0, radius)
+ for yy in range(y - radius, y + radius + 1):
+ for xx in range(x - radius, x + radius + 1):
+ if (xx - x) ** 2 + (yy - y) ** 2 <= radius**2:
+ _draw_pixel(buffer, width, height, xx, yy, color)
+
+
+def _draw_line(
+ buffer: bytearray,
+ width: int,
+ height: int,
+ start: tuple[float, float],
+ end: tuple[float, float],
+ color: tuple[int, int, int],
+ line_width_px: int = 1,
+) -> None:
+ x1, y1 = start
+ x2, y2 = end
+ steps = max(int(abs(x2 - x1)), int(abs(y2 - y1)), 1)
+ radius = max(0, line_width_px // 2)
+ for index in range(steps + 1):
+ t = index / steps
+ x = int(round(x1 + (x2 - x1) * t))
+ y = int(round(y1 + (y2 - y1) * t))
+ _draw_dot(buffer, width, height, x, y, radius, color)
+
+
+def _font_scale(font_pt: float, dpi: int) -> int:
+ return max(1, int(round((font_pt * dpi / 72.0) / 7.0)))
+
+
+def _text_mask(text: str, scale: int) -> list[list[bool]]:
+ rows = [[] for _ in range(7 * scale)]
+ normalized = text.upper()
+ for character in normalized:
+ glyph = _BITMAP_FONT_5X7.get(character, _BITMAP_FONT_5X7["?"])
+ for glyph_row, pattern in enumerate(glyph):
+ for _ in range(scale):
+ target = rows[glyph_row * scale + _]
+ for bit in pattern:
+ target.extend([bit == "1"] * scale)
+ target.extend([False] * scale)
+ return rows
+
+
+def _rotate_mask(mask: list[list[bool]], rotation: int) -> list[list[bool]]:
+ if rotation == 0:
+ return mask
+ if not mask or not mask[0]:
+ return mask
+ if rotation == -90:
+ return [[mask[row][col] for row in range(len(mask))] for col in range(len(mask[0]) - 1, -1, -1)]
+ if rotation == 90:
+ return [[mask[row][col] for row in range(len(mask) - 1, -1, -1)] for col in range(len(mask[0]))]
+ raise ValueError("Only 0, 90 and -90 degree bitmap text rotations are supported.")
+
+
+def _draw_text(
+ buffer: bytearray,
+ width: int,
+ height: int,
+ text: str,
+ x: float,
+ y: float,
+ scale: int,
+ color: tuple[int, int, int],
+ *,
+ anchor: str = "mm",
+ rotation: int = 0,
+) -> None:
+ mask = _rotate_mask(_text_mask(text, scale), rotation)
+ if not mask or not mask[0]:
+ return
+ text_height = len(mask)
+ text_width = len(mask[0])
+ if anchor[0] == "m":
+ top = int(round(y - text_height / 2))
+ elif anchor[0] == "s":
+ top = int(round(y - text_height))
+ else:
+ top = int(round(y))
+ if anchor[1] == "m":
+ left = int(round(x - text_width / 2))
+ elif anchor[1] == "e":
+ left = int(round(x - text_width))
+ else:
+ left = int(round(x))
+ for row_index, row in enumerate(mask):
+ for col_index, enabled in enumerate(row):
+ if enabled:
+ _draw_pixel(buffer, width, height, left + col_index, top + row_index, color)
+
+
+def _point_color(hex_color: str) -> tuple[int, int, int]:
+ red, green, blue = _rgb01(hex_color)
+ return int(round(red * 255)), int(round(green * 255)), int(round(blue * 255))
+
+
+def _raster_xrd_pattern(
+ x_values: np.ndarray,
+ y_values: np.ndarray,
+ *,
+ title: str,
+ preset_name: str,
+) -> tuple[int, int, int, bytearray, str]:
+ preset = _preset(preset_name)
+ geometry = _profile_plot_geometry(x_values, y_values, preset, float(preset.dpi))
+ width = int(round(float(geometry["width"])))
+ height = int(round(float(geometry["height"])))
+ plot_left = float(geometry["plot_left"])
+ plot_bottom = float(geometry["plot_bottom"])
+ plot_width = float(geometry["plot_width"])
+ plot_height = float(geometry["plot_height"])
+ points = geometry["points"]
+ sx = geometry["sx"]
+ sy = geometry["sy"]
+ x_ticks = geometry["x_ticks"]
+ y_ticks = geometry["y_ticks"]
+
+ buffer = bytearray([255] * (width * height * 3))
+ axis_color = (34, 34, 34)
+ line_color = _point_color(preset.color_cycle[0])
+ axis_width_px = max(1, int(round(preset.axis_width_pt * preset.dpi / 72.0)))
+ line_width_px = max(1, int(round(preset.line_width_pt * preset.dpi / 72.0)))
+
+ def to_raster(point: tuple[float, float]) -> tuple[float, float]:
+ return point[0], height - point[1]
+
+ x_axis_y = height - plot_bottom
+ plot_top_y = height - (plot_bottom + plot_height)
+ _draw_line(buffer, width, height, (plot_left, x_axis_y), (plot_left, plot_top_y), axis_color, axis_width_px)
+ _draw_line(buffer, width, height, (plot_left, x_axis_y), (plot_left + plot_width, x_axis_y), axis_color, axis_width_px)
+
+ tick_scale = _font_scale(preset.tick_font_pt, preset.dpi)
+ label_scale = _font_scale(preset.label_font_pt, preset.dpi)
+ title_scale = _font_scale(preset.title_font_pt, preset.dpi)
+ for tick in x_ticks:
+ x = float(sx(float(tick)))
+ _draw_line(buffer, width, height, (x, x_axis_y), (x, x_axis_y + 6 * tick_scale), axis_color, axis_width_px)
+ _draw_text(buffer, width, height, _format_tick(float(tick)), x, x_axis_y + 9 * tick_scale, tick_scale, axis_color, anchor="nm")
+ for tick in y_ticks:
+ y = height - float(sy(float(tick)))
+ _draw_line(buffer, width, height, (plot_left - 6 * tick_scale, y), (plot_left, y), axis_color, axis_width_px)
+ _draw_text(buffer, width, height, _format_tick(float(tick)), plot_left - 9 * tick_scale, y, tick_scale, axis_color, anchor="me")
+
+ _draw_text(buffer, width, height, "2theta (deg)", plot_left + plot_width / 2, height - 1.4 * label_scale, label_scale, axis_color, anchor="sm")
+ _draw_text(
+ buffer,
+ width,
+ height,
+ "Intensity (a.u.)",
+ max(plot_left * 0.25, 6.0 * label_scale, 48),
+ plot_top_y + plot_height / 2,
+ label_scale,
+ axis_color,
+ anchor="mm",
+ rotation=-90,
+ )
+ safe_title = _ascii_plot_title(title)
+ _draw_text(buffer, width, height, safe_title, width / 2, 4.2 * title_scale, title_scale, axis_color, anchor="mm")
+
+ raster_points = [to_raster((float(x), float(y))) for x, y in points]
+ for start, end in zip(raster_points, raster_points[1:], strict=False):
+ _draw_line(buffer, width, height, start, end, line_color, line_width_px)
+
+ description = (
+ f"Publication-style theoretical powder XRD profile exported by DiffractScout. "
+ f"Preset: {preset_name}; dpi: {preset.dpi}; XLabel: 2theta (deg); YLabel: Intensity (a.u.)."
+ )
+ return width, height, preset.dpi, buffer, description
+
+
+def _matplotlib_xrd_pattern(
+ x_values: np.ndarray,
+ y_values: np.ndarray,
+ *,
+ title: str,
+ preset_name: str,
+) -> tuple[int, int, int, bytearray, str] | None:
+ figure_data = _matplotlib_xrd_figure(x_values, y_values, title=title, preset_name=preset_name)
+ if figure_data is None:
+ return None
+ figure, canvas, preset = figure_data
+ try:
+ canvas.draw()
+ width, height = canvas.get_width_height()
+ rgba = np.asarray(canvas.buffer_rgba(), dtype=np.uint8)
+ rgb = np.ascontiguousarray(rgba[:, :, :3])
+ description = (
+ f"Publication-style theoretical powder XRD profile exported by DiffractScout. "
+ f"Renderer: matplotlib Agg; Preset: {preset_name}; dpi: {preset.dpi}; "
+ "XLabel: 2theta (deg); YLabel: Intensity (a.u.)."
+ )
+ figure.clear()
+ return int(width), int(height), preset.dpi, bytearray(rgb.tobytes()), description
+ except Exception:
+ figure.clear()
+ return None
+
+
+def _matplotlib_xrd_figure(
+ x_values: np.ndarray,
+ y_values: np.ndarray,
+ *,
+ title: str,
+ preset_name: str,
+):
+ try:
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
+ from matplotlib.figure import Figure
+ except Exception:
+ return None
+
+ try:
+ preset = _preset(preset_name)
+ if x_values.size == 0 or y_values.size == 0:
+ raise ValueError("Profile contains no points to plot.")
+ if x_values.size != y_values.size:
+ raise ValueError("Profile x/y arrays have different lengths.")
+
+ figure = Figure(figsize=(preset.width_in, preset.height_in), dpi=preset.dpi, constrained_layout=preset.constrained_layout)
+ canvas = FigureCanvasAgg(figure)
+ axis = figure.add_subplot(111)
+ font_family = _matplotlib_font_family(preset)
+ axis.plot(
+ x_values,
+ y_values,
+ color=preset.color_cycle[0],
+ linewidth=preset.line_width_pt,
+ solid_joinstyle="round",
+ solid_capstyle="round",
+ antialiased=True,
+ )
+ axis.set_xlabel("2θ (°)", fontsize=preset.label_font_pt, fontfamily=font_family)
+ axis.set_ylabel("Intensity (a.u.)", fontsize=preset.label_font_pt, fontfamily=font_family)
+ axis.set_title(title, fontsize=preset.title_font_pt, fontfamily=font_family, pad=max(2.0, preset.title_font_pt * 0.45))
+ axis.set_xlim(float(np.nanmin(x_values)), float(np.nanmax(x_values)))
+ y_max = float(np.nanmax(y_values))
+ if y_max <= 0 or not np.isfinite(y_max):
+ y_max = 1.0
+ axis.set_ylim(0.0, max(100.0, y_max) * 1.04)
+ axis.tick_params(
+ axis="both",
+ which="major",
+ labelsize=preset.tick_font_pt,
+ width=preset.axis_width_pt,
+ length=max(2.0, preset.axis_width_pt * 4.0),
+ direction="out",
+ )
+ for label in [*axis.get_xticklabels(), *axis.get_yticklabels()]:
+ label.set_fontfamily(font_family)
+ for spine in axis.spines.values():
+ spine.set_linewidth(preset.axis_width_pt)
+ spine.set_color("#222222")
+ axis.spines["top"].set_visible(False)
+ axis.spines["right"].set_visible(False)
+ axis.grid(False)
+ figure.patch.set_facecolor("white")
+ axis.set_facecolor("white")
+ return figure, canvas, preset
+ except Exception:
+ return None
+
+
+def _png_chunk(kind: bytes, payload: bytes) -> bytes:
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
+
+
+def _write_png(
+ output_path: str | Path,
+ *,
+ width: int,
+ height: int,
+ dpi: int,
+ buffer: bytearray,
+ title: str,
+ description: str,
+) -> Path:
+ scanlines = bytearray()
+ row_bytes = width * 3
+ for row in range(height):
+ scanlines.append(0)
+ start = row * row_bytes
+ scanlines.extend(buffer[start : start + row_bytes])
+ pixels_per_meter = int(round(dpi / 0.0254))
+ content = b"".join(
+ [
+ b"\x89PNG\r\n\x1a\n",
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)),
+ _png_chunk(b"pHYs", struct.pack(">IIB", pixels_per_meter, pixels_per_meter, 1)),
+ _png_chunk(b"tEXt", b"Title\x00" + _ascii_plot_title(title).encode("latin-1", errors="replace")),
+ _png_chunk(b"tEXt", b"Description\x00" + description.encode("latin-1", errors="replace")),
+ _png_chunk(b"tEXt", b"XLabel\x002theta (deg)"),
+ _png_chunk(b"tEXt", b"YLabel\x00Intensity (a.u.)"),
+ _png_chunk(b"IDAT", zlib.compress(bytes(scanlines), level=9)),
+ _png_chunk(b"IEND", b""),
+ ]
+ )
+ path = Path(output_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
+ return path
+
+
+def _write_tiff(
+ output_path: str | Path,
+ *,
+ width: int,
+ height: int,
+ dpi: int,
+ buffer: bytearray,
+ title: str,
+ description: str,
+) -> Path:
+ image_description = f"{description} Title: {_ascii_plot_title(title)}\x00".encode("ascii", errors="replace")
+ software = b"DiffractScout\x00"
+ entries: list[tuple[int, int, int, int | bytes]] = [
+ (256, 4, 1, width),
+ (257, 4, 1, height),
+ (258, 3, 3, b"\x08\x00\x08\x00\x08\x00"),
+ (259, 3, 1, 1),
+ (262, 3, 1, 2),
+ (270, 2, len(image_description), image_description),
+ (273, 4, 1, 0),
+ (277, 3, 1, 3),
+ (278, 4, 1, height),
+ (279, 4, 1, len(buffer)),
+ (282, 5, 1, struct.pack(" Path:
+ x_values, y_values, plot_title = _coerce_profile(
+ two_theta_grid=two_theta_grid,
+ intensity_profile=intensity_profile,
+ title=title,
+ )
+ width, height, dpi, buffer, description = _matplotlib_xrd_pattern(
+ x_values, y_values, title=plot_title, preset_name=preset_name
+ ) or _raster_xrd_pattern(x_values, y_values, title=plot_title, preset_name=preset_name)
+ return _write_png(
+ output_path,
+ width=width,
+ height=height,
+ dpi=dpi,
+ buffer=buffer,
+ title=plot_title,
+ description=description,
+ )
+
+
+def export_xrd_pattern_tiff(
+ output_path: str | Path,
+ *,
+ two_theta_grid: np.ndarray | Sequence[float],
+ intensity_profile: np.ndarray | Sequence[float],
+ title: str = "Theoretical XRD pattern",
+ preset_name: str = "publication",
+) -> Path:
+ x_values, y_values, plot_title = _coerce_profile(
+ two_theta_grid=two_theta_grid,
+ intensity_profile=intensity_profile,
+ title=title,
+ )
+ width, height, dpi, buffer, description = _matplotlib_xrd_pattern(
+ x_values, y_values, title=plot_title, preset_name=preset_name
+ ) or _raster_xrd_pattern(x_values, y_values, title=plot_title, preset_name=preset_name)
+ return _write_tiff(
+ output_path,
+ width=width,
+ height=height,
+ dpi=dpi,
+ buffer=buffer,
+ title=plot_title,
+ description=description,
+ )
+
+
+def export_xrd_pattern_svg(
+ output_path: str | Path,
+ *,
+ two_theta_grid: np.ndarray | Sequence[float],
+ intensity_profile: np.ndarray | Sequence[float],
+ title: str = "Theoretical XRD pattern",
+ preset_name: str = "publication",
+) -> Path:
+ preset = _preset(preset_name)
+ x_values, y_values, plot_title = _coerce_profile(
+ two_theta_grid=two_theta_grid,
+ intensity_profile=intensity_profile,
+ title=title,
+ )
+
+ width_px = int(round(preset.width_in * preset.dpi))
+ height_px = int(round(preset.height_in * preset.dpi))
+ margin_left = max(58.0, preset.label_font_pt * 6.0)
+ margin_right = max(18.0, preset.tick_font_pt * 2.0)
+ margin_top = max(30.0, preset.title_font_pt * 3.0)
+ margin_bottom = max(50.0, preset.label_font_pt * 5.0)
+ plot_left = margin_left
+ plot_top = margin_top
+ plot_width = width_px - margin_left - margin_right
+ plot_height = height_px - margin_top - margin_bottom
+
+ x_min = float(np.nanmin(x_values))
+ x_max = float(np.nanmax(x_values))
+ y_max = float(np.nanmax(y_values))
+ if y_max <= 0 or not np.isfinite(y_max):
+ y_max = 1.0
+ y_upper = max(100.0, y_max) * 1.04
+
+ def sx(value: float) -> float:
+ return plot_left + (value - x_min) / (x_max - x_min) * plot_width if x_max > x_min else plot_left
+
+ def sy(value: float) -> float:
+ return plot_top + plot_height - (value / y_upper) * plot_height
+
+ profile_points = [(sx(float(x)), sy(float(y))) for x, y in zip(x_values, y_values, strict=True)]
+ x_ticks = _nice_ticks(x_min, x_max, 6)
+ y_ticks = _nice_ticks(0.0, y_upper, 5)
+ axis_color = "#222222"
+ line_color = preset.color_cycle[0]
+ escaped_title = html.escape(plot_title)
+
+ tick_markup: list[str] = []
+ for tick in x_ticks:
+ x = sx(tick)
+ tick_markup.append(
+ f''
+ )
+ tick_markup.append(
+ f'{_format_tick(tick)}'
+ )
+ for tick in y_ticks:
+ y = sy(tick)
+ tick_markup.append(
+ f''
+ )
+ tick_markup.append(
+ f'{_format_tick(tick)}'
+ )
+
+ svg = f'''
+
+'''
+ path = Path(output_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(svg, encoding="utf-8")
+ return path
+
+
+def _path_chunks(points: Sequence[tuple[float, float]], move_command: str, line_command: str) -> list[str]:
+ if not points:
+ return []
+ chunks = [f"{points[0][0]:.2f} {points[0][1]:.2f} {move_command}"]
+ chunks.extend(f"{x:.2f} {y:.2f} {line_command}" for x, y in points[1:])
+ return chunks
+
+
+def export_xrd_pattern_eps(
+ output_path: str | Path,
+ *,
+ two_theta_grid: np.ndarray | Sequence[float],
+ intensity_profile: np.ndarray | Sequence[float],
+ title: str = "Theoretical XRD pattern",
+ preset_name: str = "publication",
+) -> Path:
+ preset = _preset(preset_name)
+ x_values, y_values, plot_title = _coerce_profile(
+ two_theta_grid=two_theta_grid,
+ intensity_profile=intensity_profile,
+ title=title,
+ )
+ geometry = _profile_plot_geometry(x_values, y_values, preset, 72.0)
+ width = float(geometry["width"])
+ height = float(geometry["height"])
+ plot_left = float(geometry["plot_left"])
+ plot_bottom = float(geometry["plot_bottom"])
+ plot_width = float(geometry["plot_width"])
+ plot_height = float(geometry["plot_height"])
+ points = geometry["points"]
+ sx = geometry["sx"]
+ sy = geometry["sy"]
+ x_ticks = geometry["x_ticks"]
+ y_ticks = geometry["y_ticks"]
+ red, green, blue = _rgb01(preset.color_cycle[0])
+ safe_title = _ascii_plot_title(plot_title)
+
+ lines = [
+ "%!PS-Adobe-3.0 EPSF-3.0",
+ f"%%BoundingBox: 0 0 {int(np.ceil(width))} {int(np.ceil(height))}",
+ "%%Creator: DiffractScout",
+ f"%%Title: {_ps_escape(safe_title)}",
+ "%%XLabel: 2theta (deg)",
+ "%%YLabel: Intensity (a.u.)",
+ "%%EndComments",
+ "/Helvetica findfont 8 scalefont setfont",
+ "1 1 1 setrgbcolor",
+ f"0 0 {width:.2f} {height:.2f} rectfill",
+ "0.133 0.133 0.133 setrgbcolor",
+ f"/Helvetica findfont {preset.title_font_pt:.2f} scalefont setfont",
+ f"{width / 2:.2f} {height - preset.title_font_pt * 1.8:.2f} moveto ({_ps_escape(safe_title)}) dup stringwidth pop 2 div neg 0 rmoveto show",
+ f"{preset.axis_width_pt:.2f} setlinewidth",
+ f"{plot_left:.2f} {plot_bottom:.2f} moveto {plot_left:.2f} {plot_bottom + plot_height:.2f} lineto stroke",
+ f"{plot_left:.2f} {plot_bottom:.2f} moveto {plot_left + plot_width:.2f} {plot_bottom:.2f} lineto stroke",
+ f"/Helvetica findfont {preset.tick_font_pt:.2f} scalefont setfont",
+ ]
+ for tick in x_ticks:
+ x = sx(float(tick))
+ label = _format_tick(float(tick))
+ lines.extend(
+ [
+ f"{x:.2f} {plot_bottom:.2f} moveto {x:.2f} {plot_bottom - 4:.2f} lineto stroke",
+ f"{x:.2f} {plot_bottom - 15:.2f} moveto ({_ps_escape(label)}) dup stringwidth pop 2 div neg 0 rmoveto show",
+ ]
+ )
+ for tick in y_ticks:
+ y = sy(float(tick))
+ label = _format_tick(float(tick))
+ lines.extend(
+ [
+ f"{plot_left:.2f} {y:.2f} moveto {plot_left - 4:.2f} {y:.2f} lineto stroke",
+ f"{plot_left - 8:.2f} {y - 2:.2f} moveto ({_ps_escape(label)}) dup stringwidth pop neg 0 rmoveto show",
+ ]
+ )
+ lines.extend(
+ [
+ f"/Helvetica findfont {preset.label_font_pt:.2f} scalefont setfont",
+ f"{plot_left + plot_width / 2:.2f} 10 moveto (2theta \\(deg\\)) dup stringwidth pop 2 div neg 0 rmoveto show",
+ "gsave",
+ f"12 {plot_bottom + plot_height / 2:.2f} translate 90 rotate",
+ "(Intensity \\(a.u.\\)) dup stringwidth pop 2 div neg 0 rmoveto show",
+ "grestore",
+ f"{red:.4f} {green:.4f} {blue:.4f} setrgbcolor",
+ f"{preset.line_width_pt:.2f} setlinewidth",
+ "newpath",
+ *_path_chunks(points, "moveto", "lineto"),
+ "stroke",
+ "showpage",
+ "%%EOF",
+ ]
+ )
+ path = Path(output_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("\n".join(lines) + "\n", encoding="ascii")
+ return path
+
+
+def export_xrd_pattern_pdf(
+ output_path: str | Path,
+ *,
+ two_theta_grid: np.ndarray | Sequence[float],
+ intensity_profile: np.ndarray | Sequence[float],
+ title: str = "Theoretical XRD pattern",
+ preset_name: str = "publication",
+) -> Path:
+ preset = _preset(preset_name)
+ x_values, y_values, plot_title = _coerce_profile(
+ two_theta_grid=two_theta_grid,
+ intensity_profile=intensity_profile,
+ title=title,
+ )
+ geometry = _profile_plot_geometry(x_values, y_values, preset, 72.0)
+ width = float(geometry["width"])
+ height = float(geometry["height"])
+ plot_left = float(geometry["plot_left"])
+ plot_bottom = float(geometry["plot_bottom"])
+ plot_width = float(geometry["plot_width"])
+ plot_height = float(geometry["plot_height"])
+ points = geometry["points"]
+ sx = geometry["sx"]
+ sy = geometry["sy"]
+ x_ticks = geometry["x_ticks"]
+ y_ticks = geometry["y_ticks"]
+ red, green, blue = _rgb01(preset.color_cycle[0])
+ safe_title = _ascii_plot_title(plot_title)
+
+ stream_lines = [
+ "% XLabel: 2theta (deg)",
+ "% YLabel: Intensity (a.u.)",
+ f"% Title: {_pdf_escape(safe_title)}",
+ "1 1 1 rg",
+ f"0 0 {width:.2f} {height:.2f} re f",
+ "0.133 0.133 0.133 RG",
+ "0.133 0.133 0.133 rg",
+ f"BT /F1 {preset.title_font_pt:.2f} Tf {width / 2 - len(safe_title) * preset.title_font_pt * 0.25:.2f} {height - preset.title_font_pt * 1.8:.2f} Td ({_pdf_escape(safe_title)}) Tj ET",
+ f"{preset.axis_width_pt:.2f} w",
+ f"{plot_left:.2f} {plot_bottom:.2f} m {plot_left:.2f} {plot_bottom + plot_height:.2f} l S",
+ f"{plot_left:.2f} {plot_bottom:.2f} m {plot_left + plot_width:.2f} {plot_bottom:.2f} l S",
+ f"BT /F1 {preset.tick_font_pt:.2f} Tf",
+ ]
+ for tick in x_ticks:
+ x = sx(float(tick))
+ label = _format_tick(float(tick))
+ stream_lines.append(f"ET {x:.2f} {plot_bottom:.2f} m {x:.2f} {plot_bottom - 4:.2f} l S BT /F1 {preset.tick_font_pt:.2f} Tf {x - len(label) * 2:.2f} {plot_bottom - 15:.2f} Td ({_pdf_escape(label)}) Tj")
+ for tick in y_ticks:
+ y = sy(float(tick))
+ label = _format_tick(float(tick))
+ stream_lines.append(f"ET {plot_left:.2f} {y:.2f} m {plot_left - 4:.2f} {y:.2f} l S BT /F1 {preset.tick_font_pt:.2f} Tf {plot_left - 12 - len(label) * 4:.2f} {y - 2:.2f} Td ({_pdf_escape(label)}) Tj")
+ stream_lines.extend(
+ [
+ "ET",
+ f"BT /F1 {preset.label_font_pt:.2f} Tf {plot_left + plot_width / 2 - 25:.2f} 10 Td (2theta \\(deg\\)) Tj ET",
+ f"BT /F1 {preset.label_font_pt:.2f} Tf 12 {plot_bottom + plot_height / 2 - 30:.2f} Td (Intensity \\(a.u.\\)) Tj ET",
+ f"{red:.4f} {green:.4f} {blue:.4f} RG",
+ f"{preset.line_width_pt:.2f} w",
+ *_path_chunks(points, "m", "l"),
+ "S",
+ ]
+ )
+ stream = "\n".join(stream_lines).encode("ascii")
+ objects = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width:.2f} {height:.2f}] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".encode("ascii"),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ b"<< /Length " + str(len(stream)).encode("ascii") + b" >>\nstream\n" + stream + b"\nendstream",
+ ]
+ content = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
+ offsets: list[int] = []
+ for index, obj in enumerate(objects, start=1):
+ offsets.append(len(content))
+ content.extend(f"{index} 0 obj\n".encode("ascii"))
+ content.extend(obj)
+ content.extend(b"\nendobj\n")
+ xref_offset = len(content)
+ content.extend(f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("ascii"))
+ for offset in offsets:
+ content.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
+ content.extend(
+ f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("ascii")
+ )
+ path = Path(output_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(bytes(content))
+ return path
+
+
+_FORMAT_WRITERS = {
+ "svg": export_xrd_pattern_svg,
+ "png": export_xrd_pattern_png,
+ "pdf": export_xrd_pattern_pdf,
+ "eps": export_xrd_pattern_eps,
+ "tif": export_xrd_pattern_tiff,
+ "tiff": export_xrd_pattern_tiff,
+}
+
+_FORMAT_EXTENSIONS = {
+ "svg": ".svg",
+ "png": ".png",
+ "pdf": ".pdf",
+ "eps": ".eps",
+ "tif": ".tif",
+ "tiff": ".tif",
+}
+
+
+def _figure_stem(analysis: PhaseAnalysis, *, index: int | None = None) -> str:
+ base = slugify(analysis.phase_name or analysis.structure.cif_path.stem, "phase")
+ if index is None:
+ return base
+ return f"{index:02d}_{base}"
+
+
+def export_phase_figures(
+ analysis: PhaseAnalysis,
+ output_dir: str | Path,
+ *,
+ preset: str = "publication",
+ formats: Sequence[str] = ("svg", "png"),
+ index: int | None = None,
+ title: str | None = None,
+) -> list[Path]:
+ """Write publication figures for one phase analysis into ``output_dir``.
+
+ Supported formats: svg, png, pdf, eps, tif/tiff.
+ SVG/PDF/EPS are pure-Python; PNG/TIFF use matplotlib when available.
+ """
+ _preset(preset) # validate early
+ out = Path(output_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ stem = _figure_stem(analysis, index=index)
+ plot_title = title if title is not None else analysis.phase_name
+ written: list[Path] = []
+ seen: set[str] = set()
+ for raw in formats:
+ fmt = str(raw).strip().lower().lstrip(".")
+ if not fmt or fmt in seen:
+ continue
+ if fmt not in _FORMAT_WRITERS:
+ valid = ", ".join(sorted({"svg", "png", "pdf", "eps", "tif", "tiff"}))
+ raise ValueError(f"Unknown figure format: {raw!r}. Valid formats: {valid}")
+ seen.add(fmt)
+ path = out / f"{stem}{_FORMAT_EXTENSIONS[fmt]}"
+ _FORMAT_WRITERS[fmt](
+ path,
+ two_theta_grid=analysis.two_theta_grid,
+ intensity_profile=analysis.intensity_profile,
+ title=plot_title,
+ preset_name=preset,
+ )
+ written.append(path)
+ return written
diff --git a/src/diffractscout/quick_export.py b/src/diffractscout/quick_export.py
new file mode 100644
index 0000000..5c73bb6
--- /dev/null
+++ b/src/diffractscout/quick_export.py
@@ -0,0 +1,216 @@
+"""One-shot local CIF analysis with lab-friendly defaults.
+
+``quick_export`` wraps :func:`analyze_cifs` and optionally places
+``results.xlsx`` at a user-chosen path while keeping a verifiable bundle.
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import sys
+from pathlib import Path
+from typing import Mapping, Sequence
+
+from .models import AnalysisSettings, PipelineResult
+from .pipeline import analyze_cifs
+from .utils import to_jsonable
+
+# Keyword names accepted as AnalysisSettings fields when building defaults.
+_SETTINGS_KEYS = frozenset(AnalysisSettings.__dataclass_fields__)
+
+
+def _default_settings(**overrides: object) -> AnalysisSettings:
+ """Cu Kα, 5–120°, lab views on; other fields match AnalysisSettings defaults."""
+
+ base: dict[str, object] = {
+ "input_mode": "source",
+ "source_preset": "Cu Ka",
+ "two_theta_min_deg": 5.0,
+ "two_theta_max_deg": 120.0,
+ "export_lab_views": True,
+ }
+ for key, value in overrides.items():
+ if key in _SETTINGS_KEYS:
+ base[key] = value
+ unknown = sorted(str(key) for key in overrides if key not in _SETTINGS_KEYS)
+ if unknown:
+ raise TypeError(
+ "Unexpected keyword arguments for quick_export: " + ", ".join(unknown)
+ )
+ return AnalysisSettings(**base) # type: ignore[arg-type]
+
+
+def quick_export(
+ inputs: Sequence[str | Path],
+ output: Path | str | None = None,
+ **kwargs: object,
+) -> PipelineResult:
+ """Analyze CIFs with lab defaults and optional Excel path shortcut.
+
+ Output policy:
+
+ * ``-o path/to/out.xlsx`` writes Excel to that path **and** a full bundle to
+ ``path/to/out_bundle/``.
+ * ``-o path/to/dir`` is treated as a normal analyze bundle directory.
+ * ``output is None`` uses ``./diffractscout_quick_export``.
+ """
+
+ include_excel = bool(kwargs.pop("include_excel", True))
+ overwrite = bool(kwargs.pop("overwrite", False))
+ recursive = bool(kwargs.pop("recursive", True))
+ elastic_overrides = kwargs.pop("elastic_overrides", None)
+ settings = kwargs.pop("settings", None)
+
+ if settings is not None and not isinstance(settings, AnalysisSettings):
+ raise TypeError("settings must be an AnalysisSettings instance or None.")
+ if settings is None:
+ settings = _default_settings(**kwargs)
+ elif kwargs:
+ leftover = {key: kwargs[key] for key in kwargs if key not in _SETTINGS_KEYS}
+ if leftover:
+ raise TypeError(
+ "Unexpected keyword arguments for quick_export: "
+ + ", ".join(sorted(map(str, leftover)))
+ )
+
+ if elastic_overrides is not None and not isinstance(elastic_overrides, Mapping):
+ raise TypeError("elastic_overrides must be a mapping of name -> ElasticTensor.")
+
+ if output is None:
+ output_path = Path("diffractscout_quick_export").resolve()
+ else:
+ output_path = Path(output).expanduser()
+
+ excel_target: Path | None = None
+ if output_path.suffix.lower() == ".xlsx":
+ excel_target = output_path if output_path.is_absolute() else output_path.resolve()
+ bundle_dir = excel_target.with_name(f"{excel_target.stem}_bundle")
+ # Excel shortcut always materializes the workbook in the bundle first.
+ include_excel = True
+ else:
+ bundle_dir = output_path if output_path.is_absolute() else output_path.resolve()
+
+ result = analyze_cifs(
+ inputs,
+ bundle_dir,
+ settings=settings,
+ recursive=recursive,
+ include_excel=include_excel,
+ overwrite=overwrite,
+ elastic_overrides=elastic_overrides, # type: ignore[arg-type]
+ )
+
+ if excel_target is not None:
+ source_xlsx = result.output_dir / "results.xlsx"
+ if not source_xlsx.is_file():
+ raise RuntimeError(
+ f"Expected results.xlsx in bundle {result.output_dir}, but it is missing."
+ )
+ excel_target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source_xlsx, excel_target)
+
+ return result
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="diffractscout-quick-export",
+ description=(
+ "One-shot DiffractScout export: Cu Kα defaults, optional .xlsx path, "
+ "verifiable result bundle."
+ ),
+ )
+ parser.add_argument("inputs", nargs="+", help="CIF files or directories.")
+ parser.add_argument(
+ "-o",
+ "--output",
+ required=True,
+ help="Bundle directory, or an .xlsx path (bundle becomes _bundle/).",
+ )
+ parser.add_argument("--no-recursive", action="store_true")
+ parser.add_argument("--no-excel", action="store_true", help="Skip Excel (bundle dir mode only).")
+ parser.add_argument("--overwrite", action="store_true")
+ parser.add_argument("--json", action="store_true")
+ parser.add_argument("--source", default="Cu Ka")
+ parser.add_argument("--two-theta-min", type=float, default=5.0)
+ parser.add_argument("--two-theta-max", type=float, default=120.0)
+ parser.add_argument("--step", type=float, default=0.02)
+ parser.add_argument("--fwhm", type=float, default=0.15)
+ parser.add_argument("--eta", type=float, default=0.5)
+ parser.add_argument("--no-elasticity", action="store_true")
+ parser.add_argument("--d-min", type=float, default=None, dest="d_min")
+ parser.add_argument("--d-max", type=float, default=None, dest="d_max")
+ parser.add_argument(
+ "--profile-model",
+ choices=("pseudo_voigt", "gaussian", "lorentzian"),
+ default="pseudo_voigt",
+ )
+ parser.add_argument(
+ "--pattern-axis",
+ choices=("two_theta", "d_spacing", "q", "g"),
+ default="two_theta",
+ )
+ parser.add_argument("--figures", action="store_true")
+ parser.add_argument("--figure-preset", default="publication")
+ parser.add_argument("--no-lab-views", action="store_true")
+ parser.add_argument("--no-patterns", action="store_true")
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ settings = AnalysisSettings(
+ input_mode="source",
+ source_preset=args.source,
+ two_theta_min_deg=args.two_theta_min,
+ two_theta_max_deg=args.two_theta_max,
+ step_deg=args.step,
+ fwhm_deg=args.fwhm,
+ profile_eta=args.eta,
+ include_elasticity=not args.no_elasticity,
+ d_min_A=args.d_min,
+ d_max_A=args.d_max,
+ profile_model=args.profile_model,
+ pattern_axis=args.pattern_axis,
+ include_figures=bool(args.figures),
+ figure_preset=args.figure_preset,
+ export_lab_views=not args.no_lab_views,
+ include_patterns=not args.no_patterns,
+ )
+ result = quick_export(
+ args.inputs,
+ args.output,
+ settings=settings,
+ recursive=not args.no_recursive,
+ include_excel=not args.no_excel,
+ overwrite=args.overwrite,
+ )
+ except (ValueError, FileNotFoundError, FileExistsError, PermissionError, RuntimeError, TypeError) as exc:
+ print(f"ERROR: {exc}", file=sys.stderr)
+ return 2
+
+ if args.json:
+ import json
+
+ print(json.dumps(to_jsonable(result), indent=2, ensure_ascii=False))
+ else:
+ print(f"Output: {result.output_dir}")
+ print(f"Manifest: {result.manifest_path}")
+ print(f"Analyzed phases: {len(result.analyses)}")
+ if str(args.output).lower().endswith(".xlsx"):
+ print(f"Excel: {Path(args.output).expanduser().resolve()}")
+ for warning in result.warnings:
+ print(f"WARNING: {warning}", file=sys.stderr)
+
+ if not result.analyses:
+ return 2
+ if any(item.level == "error" for item in result.diagnostics):
+ return 3
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/diffractscout/structure.py b/src/diffractscout/structure.py
index 3f79e19..02365be 100644
--- a/src/diffractscout/structure.py
+++ b/src/diffractscout/structure.py
@@ -14,6 +14,9 @@
from .models import StructureRecord
from .utils import sha256_file
+# CODATA 2018 Avogadro constant; density uses ų → cm³ via 1e-24.
+AVOGADRO_PER_MOL = 6.02214076e23
+
CELL_TAGS = (
"_cell_length_a",
"_cell_length_b",
@@ -223,6 +226,61 @@ def _spglib_crosscheck(
return number, symbol, status
+def unit_cell_formula_weight_g_mol(small: gemmi.SmallStructure) -> float | None:
+ """Sum elemental atomic weights × occupancy over expanded unit-cell sites.
+
+ Uses ``gemmi.Element.weight`` (IUPAC conventional atomic weights). Returns
+ None when no occupied sites contribute a finite mass.
+ """
+
+ total = 0.0
+ counted = False
+ for site in small.get_all_unit_cell_sites():
+ occ = float(site.occ)
+ if occ <= 0:
+ continue
+ try:
+ mass = float(site.element.weight)
+ except Exception:
+ return None
+ if not math.isfinite(mass) or mass <= 0:
+ return None
+ total += occ * mass
+ counted = True
+ if not counted or not math.isfinite(total) or total <= 0:
+ return None
+ return float(total)
+
+
+def density_g_cm3(formula_weight_g_mol: float, volume_A3: float) -> float | None:
+ """Crystallographic density ρ = M / (N_A · V) with V in cm³ from ų."""
+
+ if not math.isfinite(formula_weight_g_mol) or formula_weight_g_mol <= 0:
+ return None
+ if not math.isfinite(volume_A3) or volume_A3 <= 0:
+ return None
+ # V_cm3 = V_A3 * 1e-24; ρ = M / (N_A * V_cm3) = M * 1e24 / (N_A * V_A3)
+ return float(formula_weight_g_mol * 1.0e24 / (AVOGADRO_PER_MOL * volume_A3))
+
+
+def structure_mass_metadata(structure: StructureRecord) -> dict[str, float | None]:
+ """Return unit-cell formula weight and density for analysis metadata."""
+
+ small = structure.small_structure
+ volume = float(small.cell.volume) if small is not None else float("nan")
+ formula_weight = unit_cell_formula_weight_g_mol(small) if small is not None else None
+ density = (
+ density_g_cm3(formula_weight, volume)
+ if formula_weight is not None and math.isfinite(volume)
+ else None
+ )
+ return {
+ "cell_volume_A3": float(volume) if math.isfinite(volume) else None,
+ "formula_weight_g_mol": formula_weight,
+ "density_g_cm3": density,
+ }
+
+
def load_structure(cif_path: str | Path) -> StructureRecord:
path = Path(cif_path).expanduser().resolve()
if not path.is_file():
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 157a420..b374536 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2,7 +2,7 @@
import pytest
-from diffractscout.cli import _pipeline_exit_code, main
+from diffractscout.cli import _analysis_settings, _pipeline_exit_code, build_parser, main
from diffractscout.models import DiagnosticRecord
from diffractscout.validation import verify_bundle
@@ -13,6 +13,54 @@ def test_demo_cli(tmp_path: Path) -> None:
assert verify_bundle(output)["ok"]
+def test_analysis_cli_flags_parse() -> None:
+ parser = build_parser()
+ args = parser.parse_args(
+ [
+ "analyze",
+ "sample.cif",
+ "-o",
+ "out",
+ "--d-min",
+ "0.8",
+ "--d-max",
+ "3.5",
+ "--profile-model",
+ "gaussian",
+ "--pattern-axis",
+ "q",
+ "--figures",
+ "--figure-preset",
+ "draft",
+ "--no-lab-views",
+ "--no-patterns",
+ ]
+ )
+ assert args.d_min == pytest.approx(0.8)
+ assert args.d_max == pytest.approx(3.5)
+ assert args.profile_model == "gaussian"
+ assert args.pattern_axis == "q"
+ assert args.figures is True
+ assert args.figure_preset == "draft"
+ assert args.no_lab_views is True
+ assert args.no_patterns is True
+ settings = _analysis_settings(args)
+ assert settings.d_min_A == pytest.approx(0.8)
+ assert settings.d_max_A == pytest.approx(3.5)
+ assert settings.profile_model == "gaussian"
+ assert settings.pattern_axis == "q"
+ assert settings.include_figures is True
+ assert settings.figure_preset == "draft"
+ assert settings.export_lab_views is False
+ assert settings.include_patterns is False
+
+
+def test_analyze_help_includes_d_min() -> None:
+ with pytest.raises(SystemExit) as exc_info:
+ main(["analyze", "--help"])
+ assert exc_info.value.code == 0
+
+
def test_cli_rejects_conflicting_radiation_inputs(tmp_path: Path) -> None:
output = tmp_path / "conflicting-radiation"
with pytest.raises(SystemExit) as exc_info:
diff --git a/tests/test_elasticity_input.py b/tests/test_elasticity_input.py
new file mode 100644
index 0000000..e7ff06f
--- /dev/null
+++ b/tests/test_elasticity_input.py
@@ -0,0 +1,52 @@
+import numpy as np
+import pytest
+
+from diffractscout.elasticity_input import (
+ format_cij_matrix,
+ parse_cij_matrix_6x6,
+ parse_cij_paste_text,
+ parse_cubic_cij,
+)
+
+
+def test_parse_cubic_cij_builds_validated_matrix() -> None:
+ tensor = parse_cubic_cij(200.0, 120.0, 40.0, source="handbook_cubic")
+ assert tensor.source_provider == "user_input"
+ assert tensor.status in {"valid", "valid_with_warnings"}
+ expected = np.array(
+ [
+ [200.0, 120.0, 120.0, 0.0, 0.0, 0.0],
+ [120.0, 200.0, 120.0, 0.0, 0.0, 0.0],
+ [120.0, 120.0, 200.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 40.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 40.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 40.0],
+ ]
+ )
+ assert tensor.stiffness_GPa == pytest.approx(expected)
+
+
+def test_parse_cij_matrix_6x6_and_paste_roundtrip() -> None:
+ matrix = parse_cij_paste_text(
+ """
+ 200 120 120 0 0 0
+ 120 200 120 0 0 0
+ 120 120 200 0 0 0
+ 0 0 0 40 0 0
+ 0 0 0 0 40 0
+ 0 0 0 0 0 40
+ """
+ )
+ assert len(matrix) == 6
+ assert len(matrix[0]) == 6
+ tensor = parse_cij_matrix_6x6(matrix)
+ assert tensor.source_provider == "user_input"
+ assert tensor.status in {"valid", "valid_with_warnings"}
+ text = format_cij_matrix(tensor)
+ assert "200" in text
+ assert text.count("\n") == 5
+
+
+def test_parse_cij_paste_text_rejects_wrong_count() -> None:
+ with pytest.raises(ValueError, match="Expected 36"):
+ parse_cij_paste_text("1 2 3")
diff --git a/tests/test_export_views.py b/tests/test_export_views.py
new file mode 100644
index 0000000..c4bc0de
--- /dev/null
+++ b/tests/test_export_views.py
@@ -0,0 +1,120 @@
+"""Lab-view Excel sheets and expanded peak/pattern export columns."""
+
+from __future__ import annotations
+
+import csv
+from pathlib import Path
+
+from openpyxl import load_workbook
+
+from diffractscout.demo import write_demo_inputs
+from diffractscout.export_views import BEGINNER_PEAK_HEADERS_ZH, beginner_peak_rows_zh, user_guide_rows
+from diffractscout.models import AnalysisSettings
+from diffractscout.pipeline import analyze_cifs
+
+
+def test_demo_analyze_writes_lab_view_sheets(tmp_path: Path) -> None:
+ inputs = write_demo_inputs(tmp_path / "inputs")
+ result = analyze_cifs(
+ [inputs],
+ tmp_path / "bundle",
+ settings=AnalysisSettings(export_lab_views=True),
+ include_excel=True,
+ )
+ workbook_path = result.output_dir / "results.xlsx"
+ assert workbook_path.is_file()
+ workbook = load_workbook(workbook_path, data_only=False, read_only=True)
+ assert "推荐峰表" in workbook.sheetnames
+ assert "使用说明" in workbook.sheetnames
+ assert "Peaks" in workbook.sheetnames
+ recommend = workbook["推荐峰表"]
+ headers = [cell.value for cell in next(recommend.iter_rows(min_row=1, max_row=1))]
+ assert headers[0] == "物相名称"
+ assert "相对强度" in headers
+ guide = workbook["使用说明"]
+ guide_rows = list(guide.iter_rows(min_row=1, max_row=3, values_only=True))
+ assert guide_rows[0][0] == "项目"
+ assert any("R_hkl" in str(row[0] or "") or "R_hkl" in str(row[1] or "") for row in guide.iter_rows(values_only=True))
+
+
+def test_peak_reference_has_two_theta_cu_ka_column(tmp_path: Path) -> None:
+ inputs = write_demo_inputs(tmp_path / "inputs")
+ result = analyze_cifs([inputs], tmp_path / "bundle", include_excel=False)
+ peak_path = result.output_dir / "peak_reference.csv"
+ with peak_path.open(encoding="utf-8-sig", newline="") as handle:
+ reader = csv.DictReader(handle)
+ fieldnames = list(reader.fieldnames or [])
+ rows = list(reader)
+ assert "two_theta_cu_ka_deg" in fieldnames
+ assert "phase_relative_R_hkl_pct" in fieldnames
+ assert "inverse_R_hkl" in fieldnames
+ assert "r_hkl_model_note" in fieldnames
+ assert rows
+ first = rows[0]
+ assert first["two_theta_cu_ka_deg"]
+ assert float(first["two_theta_cu_ka_deg"]) > 0
+
+
+def test_pattern_profiles_include_d_axis_columns(tmp_path: Path) -> None:
+ inputs = write_demo_inputs(tmp_path / "inputs")
+ result = analyze_cifs([inputs], tmp_path / "bundle", include_excel=False)
+ pattern_path = result.output_dir / "pattern_profiles.csv"
+ with pattern_path.open(encoding="utf-8-sig", newline="") as handle:
+ reader = csv.DictReader(handle)
+ fieldnames = list(reader.fieldnames or [])
+ rows = list(reader)
+ assert "d_A" in fieldnames or "d_spacing" in fieldnames or "d_spacing_A" in fieldnames
+ assert "two_theta_deg" in fieldnames
+ assert "q_invA" in fieldnames
+ assert "g_invA" in fieldnames
+ assert "x_axis_mode" in fieldnames
+ assert "x" in fieldnames
+ assert "relative_intensity" in fieldnames
+ assert rows
+ sample = rows[len(rows) // 2]
+ assert sample["d_A"]
+ assert float(sample["d_A"]) > 0
+ assert sample["x_axis_mode"] == "two_theta"
+
+
+def test_include_patterns_false_skips_profile_export(tmp_path: Path) -> None:
+ inputs = write_demo_inputs(tmp_path / "inputs")
+ result = analyze_cifs(
+ [inputs],
+ tmp_path / "bundle",
+ settings=AnalysisSettings(include_patterns=False, include_elasticity=False),
+ include_excel=True,
+ )
+ assert not (result.output_dir / "pattern_profiles.csv").exists()
+ workbook = load_workbook(result.output_dir / "results.xlsx", read_only=True)
+ assert "Patterns" not in workbook.sheetnames
+ assert "Peaks" in workbook.sheetnames
+
+
+def test_export_lab_views_false_omits_chinese_sheets(tmp_path: Path) -> None:
+ inputs = write_demo_inputs(tmp_path / "inputs")
+ result = analyze_cifs(
+ [inputs],
+ tmp_path / "bundle",
+ settings=AnalysisSettings(export_lab_views=False, include_elasticity=False),
+ include_excel=True,
+ )
+ workbook = load_workbook(result.output_dir / "results.xlsx", read_only=True)
+ assert "推荐峰表" not in workbook.sheetnames
+ assert "使用说明" not in workbook.sheetnames
+ assert "Peaks" in workbook.sheetnames
+
+
+def test_beginner_and_guide_helpers_are_stable() -> None:
+ assert BEGINNER_PEAK_HEADERS_ZH["物相名称"] == "phase_name"
+ assert BEGINNER_PEAK_HEADERS_ZH["2θ_CuKa_deg"] == "two_theta_cu_ka_deg"
+ mapped = beginner_peak_rows_zh(
+ [{"phase_name": "Al", "normalized_intensity": 100.0, "two_theta_cu_ka_deg": 38.0}]
+ )
+ assert mapped[0]["物相名称"] == "Al"
+ assert mapped[0]["相对强度"] == 100.0
+ guide = user_guide_rows()
+ assert guide[0] == ["项目", "说明"]
+ joined = "\n".join(cell for row in guide for cell in row)
+ assert "不是" in joined and "残差" in joined
+ assert "QPA" in joined
diff --git a/tests/test_gui.py b/tests/test_gui.py
index 1fd5352..b5e5eb2 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -1,6 +1,7 @@
import pytest
from diffractscout.gui import analysis_settings_from_form, discovery_settings_from_form
+from diffractscout.gui_i18n import REQUIRED_KEYS, STRINGS, assert_language_parity, t
def test_analysis_form_builds_energy_settings() -> None:
@@ -24,6 +25,55 @@ def test_analysis_form_builds_energy_settings() -> None:
assert not settings.include_elasticity
+def test_analysis_form_includes_d_min_and_profile_model() -> None:
+ settings = analysis_settings_from_form(
+ {
+ "input_mode": "source",
+ "source_preset": "Cu Ka",
+ "radiation_value": "",
+ "two_theta_min": "5",
+ "two_theta_max": "120",
+ "step": "0.02",
+ "fwhm": "0.15",
+ "eta": "0.5",
+ "max_profile_points": "1000000",
+ "max_reflection_estimate": "2000000",
+ "d_min_A": "0.8",
+ "d_max_A": "4.0",
+ "profile_model": "gaussian",
+ "pattern_axis": "q",
+ "include_figures": True,
+ "export_lab_views": False,
+ "include_patterns": False,
+ }
+ )
+ assert settings.d_min_A == pytest.approx(0.8)
+ assert settings.d_max_A == pytest.approx(4.0)
+ assert settings.profile_model == "gaussian"
+ assert settings.pattern_axis == "q"
+ assert settings.include_figures is True
+ assert settings.export_lab_views is False
+ assert settings.include_patterns is False
+
+
+def test_analysis_form_rejects_unknown_profile_model() -> None:
+ with pytest.raises(ValueError, match="Profile model"):
+ analysis_settings_from_form(
+ {
+ "input_mode": "source",
+ "source_preset": "Cu Ka",
+ "two_theta_min": "5",
+ "two_theta_max": "120",
+ "step": "0.02",
+ "fwhm": "0.15",
+ "eta": "0.5",
+ "max_profile_points": "1000000",
+ "max_reflection_estimate": "2000000",
+ "profile_model": "not_a_model",
+ }
+ )
+
+
def test_discovery_form_rejects_zero_limit() -> None:
settings = discovery_settings_from_form(
{"mode": "possible_phases", "max_total": "0"}
@@ -48,3 +98,14 @@ def test_analysis_form_rejects_non_numeric_value() -> None:
"max_reflection_estimate": "2000000",
}
)
+
+
+def test_i18n_required_keys_zh_en_parity() -> None:
+ assert_language_parity()
+ for key in REQUIRED_KEYS:
+ assert key in STRINGS["zh"]
+ assert key in STRINGS["en"]
+ assert t("zh", key)
+ assert t("en", key)
+ assert t("zh", key) != key
+ assert t("en", key) != key
diff --git a/tests/test_hkl.py b/tests/test_hkl.py
new file mode 100644
index 0000000..01df983
--- /dev/null
+++ b/tests/test_hkl.py
@@ -0,0 +1,26 @@
+"""Unit tests for Miller–Bravais helpers."""
+
+from __future__ import annotations
+
+import gemmi
+
+from diffractscout.hkl import family_label_hkl, miller_bravais_i, uses_miller_bravais
+
+
+def test_miller_bravais_i() -> None:
+ assert miller_bravais_i(1, 0) == -1
+ assert miller_bravais_i(1, 1) == -2
+ assert miller_bravais_i(2, -1) == -1
+
+
+def test_family_label_three_and_four_index() -> None:
+ assert family_label_hkl(1, 1, 1) == "{1 1 1}"
+ assert family_label_hkl(1, 0, 0, use_four_index=True) == "{1 0 -1 0}"
+ assert family_label_hkl(1, 1, 0, use_four_index=True, i=-2) == "{1 1 -2 0}"
+
+
+def test_uses_miller_bravais_crystal_systems() -> None:
+ assert uses_miller_bravais(gemmi.find_spacegroup_by_name("P 63/m m c"))
+ assert uses_miller_bravais(gemmi.find_spacegroup_by_name("P -3 m 1"))
+ assert not uses_miller_bravais(gemmi.find_spacegroup_by_name("F m -3 m"))
+ assert not uses_miller_bravais(None)
diff --git a/tests/test_parity_features.py b/tests/test_parity_features.py
new file mode 100644
index 0000000..9d07ab2
--- /dev/null
+++ b/tests/test_parity_features.py
@@ -0,0 +1,119 @@
+"""Phase-1 parity features: d-range filter, profile models, Cu Kα 2θ, hex labels."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from diffractscout.diffraction import CU_KA_WAVELENGTH_A, simulate_powder_pattern, two_theta_for_d
+from diffractscout.models import AnalysisSettings
+from diffractscout.structure import density_g_cm3, load_structure, unit_cell_formula_weight_g_mol
+
+
+HEX_MG_CIF = """data_synthetic_hex_mg
+_audit_creation_method 'DiffractScout synthetic hexagonal fixture'
+_chemical_formula_sum 'Mg'
+_cell_length_a 3.200000
+_cell_length_b 3.200000
+_cell_length_c 5.200000
+_cell_angle_alpha 90
+_cell_angle_beta 90
+_cell_angle_gamma 120
+_space_group_name_H-M_alt 'P 63/m m c'
+_space_group_IT_number 194
+loop_
+_atom_site_label
+_atom_site_type_symbol
+_atom_site_fract_x
+_atom_site_fract_y
+_atom_site_fract_z
+_atom_site_occupancy
+Mg1 Mg 0.333333 0.666667 0.250000 1
+"""
+
+
+def test_d_range_filters_peaks(demo_inputs: Path) -> None:
+ structure = load_structure(demo_inputs / "synthetic_fcc_al.cif")
+ broad = simulate_powder_pattern(
+ structure,
+ AnalysisSettings(two_theta_min_deg=5, two_theta_max_deg=100),
+ )
+ assert len(broad.reflections) >= 3
+ # Keep only the first peak's d-window around (111).
+ first = broad.reflections[0]
+ d_lo = first.d_spacing_A - 0.02
+ d_hi = first.d_spacing_A + 0.02
+ narrow = simulate_powder_pattern(
+ structure,
+ AnalysisSettings(
+ two_theta_min_deg=5,
+ two_theta_max_deg=100,
+ d_min_A=d_lo,
+ d_max_A=d_hi,
+ ),
+ )
+ assert narrow.reflections
+ for item in narrow.reflections:
+ assert d_lo - 1e-9 <= item.d_spacing_A <= d_hi + 1e-9
+ assert all(item.d_spacing_A <= d_hi + 1e-9 for item in narrow.reflections)
+ assert len(narrow.reflections) < len(broad.reflections)
+ assert narrow.metadata["filter_d_min_A"] == pytest.approx(d_lo)
+ assert narrow.metadata["filter_d_max_A"] == pytest.approx(d_hi)
+
+
+def test_profile_model_gaussian_runs(demo_inputs: Path) -> None:
+ structure = load_structure(demo_inputs / "synthetic_fcc_al.cif")
+ result = simulate_powder_pattern(
+ structure,
+ AnalysisSettings(
+ two_theta_min_deg=5,
+ two_theta_max_deg=80,
+ profile_model="gaussian",
+ ),
+ )
+ assert result.metadata["profile_model"] == "gaussian"
+ assert result.intensity_profile.size > 0
+ assert float(np.max(result.intensity_profile)) == pytest.approx(100.0)
+ assert result.reflections
+
+
+def test_two_theta_cu_ka_present_and_finite(demo_inputs: Path) -> None:
+ structure = load_structure(demo_inputs / "synthetic_fcc_al.cif")
+ result = simulate_powder_pattern(
+ structure,
+ AnalysisSettings(two_theta_min_deg=5, two_theta_max_deg=100),
+ )
+ first = result.reflections[0]
+ assert first.two_theta_cu_ka_deg > 0
+ assert np.isfinite(first.two_theta_cu_ka_deg)
+ expected = two_theta_for_d(first.d_spacing_A, CU_KA_WAVELENGTH_A)
+ assert expected is not None
+ assert first.two_theta_cu_ka_deg == pytest.approx(expected, rel=1e-12)
+ assert first.sin_theta == pytest.approx(np.sin(np.deg2rad(first.theta_deg)), rel=1e-12)
+ assert first.phase_relative_R_hkl_pct == pytest.approx(100.0)
+ assert first.mean_structure_factor_sq_per_multiplicity == pytest.approx(first.structure_factor_sq)
+ assert result.metadata["density_g_cm3"] is not None
+ assert result.metadata["density_g_cm3"] > 0
+ fw = unit_cell_formula_weight_g_mol(structure.small_structure)
+ assert fw is not None
+ assert result.metadata["formula_weight_g_mol"] == pytest.approx(fw)
+ dens = density_g_cm3(fw, float(structure.small_structure.cell.volume))
+ assert dens is not None
+ assert result.metadata["density_g_cm3"] == pytest.approx(dens)
+
+
+def test_hexagonal_miller_bravais_labeling(tmp_path: Path) -> None:
+ cif_path = tmp_path / "synthetic_hex_mg.cif"
+ cif_path.write_text(HEX_MG_CIF, encoding="utf-8")
+ structure = load_structure(cif_path)
+ result = simulate_powder_pattern(
+ structure,
+ AnalysisSettings(two_theta_min_deg=5, two_theta_max_deg=90, include_elasticity=False),
+ )
+ assert result.reflections
+ for item in result.reflections:
+ assert item.i is not None
+ assert item.i == -(item.h + item.k)
+ assert item.family_label == "{" + f"{item.h} {item.k} {item.i} {item.l}" + "}"
diff --git a/tests/test_plotting.py b/tests/test_plotting.py
new file mode 100644
index 0000000..50879e8
--- /dev/null
+++ b/tests/test_plotting.py
@@ -0,0 +1,100 @@
+"""Publication figure export tests."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from diffractscout.models import AnalysisSettings
+from diffractscout.pipeline import analyze_cifs
+from diffractscout.plotting import (
+ FIGURE_EXPORT_PRESETS,
+ export_phase_figures,
+ export_xrd_pattern_svg,
+)
+
+
+def test_export_svg_from_synthetic_arrays(tmp_path: Path) -> None:
+ two_theta = np.linspace(10.0, 90.0, 401)
+ intensity = np.exp(-0.5 * ((two_theta - 38.5) / 0.4) ** 2) * 100.0
+ path = tmp_path / "pattern.svg"
+ written = export_xrd_pattern_svg(
+ path,
+ two_theta_grid=two_theta,
+ intensity_profile=intensity,
+ title="Synthetic FCC Al",
+ preset_name="publication",
+ )
+ assert written == path
+ text = path.read_text(encoding="utf-8")
+ assert text.startswith(" None:
+ result = analyze_cifs(
+ [demo_inputs],
+ tmp_path / "bundle",
+ settings=AnalysisSettings(include_elasticity=False, include_figures=False),
+ include_excel=False,
+ )
+ analysis = result.analyses[0]
+ out = tmp_path / "figs"
+ paths = export_phase_figures(
+ analysis,
+ out,
+ preset="publication",
+ formats=("svg", "png"),
+ )
+ assert len(paths) == 2
+ assert all(path.is_file() for path in paths)
+ assert any(path.suffix == ".svg" for path in paths)
+ assert any(path.suffix == ".png" for path in paths)
+ svg_text = next(path for path in paths if path.suffix == ".svg").read_text(encoding="utf-8")
+ assert "polyline" in svg_text
+ assert analysis.phase_name in svg_text or "svg" in svg_text.lower()
+
+
+def test_include_figures_writes_bundle_figures_and_manifest(
+ demo_inputs: Path, tmp_path: Path
+) -> None:
+ output = tmp_path / "with-figures"
+ result = analyze_cifs(
+ [demo_inputs],
+ output,
+ settings=AnalysisSettings(include_elasticity=False, include_figures=True),
+ include_excel=False,
+ )
+ assert len(result.analyses) == 1
+ figures = list((output / "figures").glob("*.svg"))
+ assert figures, "expected figures/*.svg in the result bundle"
+ assert all(path.is_file() for path in figures)
+ assert list((output / "figures").glob("*.png")), "expected figures/*.png alongside SVG"
+
+ manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
+ figure_entries = [row for row in manifest["files"] if row["path"].startswith("figures/")]
+ assert figure_entries
+ assert all(row["role"] == "figure" for row in figure_entries)
+
+
+def test_unknown_preset_and_format_raise(demo_inputs: Path, tmp_path: Path) -> None:
+ result = analyze_cifs(
+ [demo_inputs],
+ tmp_path / "bundle",
+ settings=AnalysisSettings(include_elasticity=False),
+ include_excel=False,
+ )
+ analysis = result.analyses[0]
+ with pytest.raises(ValueError, match="Unknown figure export preset"):
+ export_phase_figures(analysis, tmp_path / "bad-preset", preset="not-a-preset")
+ with pytest.raises(ValueError, match="Unknown figure format"):
+ export_phase_figures(analysis, tmp_path / "bad-fmt", formats=("webp",))
+
+
+def test_all_named_presets_are_registered() -> None:
+ expected = {"publication", "single_column", "double_column", "presentation", "raw_inspection"}
+ assert expected.issubset(FIGURE_EXPORT_PRESETS)
diff --git a/tests/test_quick_export.py b/tests/test_quick_export.py
new file mode 100644
index 0000000..7ef93f2
--- /dev/null
+++ b/tests/test_quick_export.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+
+import pytest
+from openpyxl import load_workbook
+
+from diffractscout.elasticity_input import parse_cubic_cij
+from diffractscout.pipeline import analyze_cifs
+from diffractscout.quick_export import main as quick_export_main
+from diffractscout.quick_export import quick_export
+from diffractscout.validation import verify_bundle
+
+
+def test_quick_export_xlsx_writes_excel_and_bundle(demo_inputs: Path, tmp_path: Path) -> None:
+ excel = tmp_path / "report.xlsx"
+ result = quick_export([demo_inputs], excel)
+ assert excel.is_file()
+ bundle = tmp_path / "report_bundle"
+ assert result.output_dir == bundle.resolve()
+ assert (bundle / "manifest.json").is_file()
+ assert (bundle / "results.xlsx").is_file()
+ assert verify_bundle(bundle)["ok"]
+ workbook = load_workbook(excel, read_only=True)
+ assert "Peaks" in workbook.sheetnames
+
+
+def test_quick_export_directory_mode(demo_inputs: Path, tmp_path: Path) -> None:
+ output = tmp_path / "lab_bundle"
+ result = quick_export([demo_inputs], output)
+ assert result.output_dir == output.resolve()
+ assert (output / "results.xlsx").is_file()
+ assert result.analyses[0].metadata.get("export_lab_views") is True
+
+
+def test_quick_export_cli_entry(demo_inputs: Path, tmp_path: Path) -> None:
+ excel = tmp_path / "cli_out.xlsx"
+ code = quick_export_main([str(demo_inputs), "-o", str(excel)])
+ assert code == 0
+ assert excel.is_file()
+ assert (tmp_path / "cli_out_bundle" / "manifest.json").is_file()
+
+
+def test_elastic_override_replaces_sidecar(demo_inputs: Path, tmp_path: Path) -> None:
+ """Override keyed by stem must be used instead of the demo sidecar."""
+
+ override = parse_cubic_cij(250.0, 100.0, 50.0, source="override_test")
+ output = tmp_path / "override_bundle"
+ result = analyze_cifs(
+ [demo_inputs],
+ output,
+ include_excel=False,
+ elastic_overrides={"synthetic_fcc_al": override},
+ )
+ tensor = result.analyses[0].elastic_tensor
+ assert tensor is not None
+ assert tensor.stiffness_GPa[0, 0] == pytest.approx(250.0)
+ assert tensor.source_provider == "user_input"
diff --git "a/\345\220\257\345\212\250DiffractScout.bat" "b/\345\220\257\345\212\250DiffractScout.bat"
new file mode 100644
index 0000000..aa86c13
--- /dev/null
+++ "b/\345\220\257\345\212\250DiffractScout.bat"
@@ -0,0 +1,26 @@
+@echo off
+REM Launch the DiffractScout desktop GUI from the repository (or install) root.
+setlocal EnableExtensions
+cd /d "%~dp0"
+
+where py >nul 2>&1
+if %ERRORLEVEL%==0 (
+ py -3 -m diffractscout gui
+ if not errorlevel 1 goto :done
+)
+
+where diffractscout-gui >nul 2>&1
+if %ERRORLEVEL%==0 (
+ diffractscout-gui
+ if not errorlevel 1 goto :done
+)
+
+echo ERROR: Could not start DiffractScout GUI.
+echo Install with: py -3 -m pip install -e ".[gui-dnd]"
+echo Or ensure py -3 -m diffractscout gui works from this directory.
+pause
+exit /b 1
+
+:done
+endlocal
+exit /b 0