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 DiffractScout Materials Project 流程界面

-界面提供: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 ![Local CIF analysis interface](assets/gui-local.png) @@ -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''' + + {escaped_title} + Publication-style theoretical powder XRD profile exported by DiffractScout. Preset: {html.escape(preset_name)}; dpi: {preset.dpi}. + + + {escaped_title} + + + {"".join(tick_markup)} + 2θ (°) + Intensity (a.u.) + + + +''' + 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