From 782dad1414105164a435eea799b02b1b5ce6d7eb Mon Sep 17 00:00:00 2001 From: Delun Gong Date: Wed, 30 Sep 2026 13:10:09 +0800 Subject: [PATCH] feat: Deliver validated initial CIF preparation and workflow guidance --- .../diffractscout-cif-reference/SKILL.md | 40 + .github/pull_request_template.md | 20 +- AGENTS.md | 42 + CHANGELOG.md | 4 + CONTRIBUTING.md | 52 +- MANIFEST.in | 3 + NOTICE.md | 17 + README.md | 45 +- README.zh-CN.md | 62 +- docs/AGENT_WORKFLOW.md | 79 + docs/API.md | 40 + docs/ARCHITECTURE.md | 6 + docs/CLI.md | 102 ++ docs/GUI.md | 36 + docs/INITIAL_CIFS.md | 138 ++ docs/README.md | 26 + docs/RELEASE.md | 40 +- docs/SCIENTIFIC_CONTRACTS.md | 4 +- docs/VALIDATION.md | 17 +- examples/phase_parameters.template.json | 21 + pyproject.toml | 2 +- src/diffractscout/__init__.py | 6 + src/diffractscout/cif_quality.py | 241 +++ src/diffractscout/cli.py | 180 ++- src/diffractscout/gui.py | 13 + src/diffractscout/gui_cifs.py | 830 ++++++++++ src/diffractscout/initial_cifs.py | 777 +++++++++ src/diffractscout/phase_cif.py | 1436 +++++++++++++++++ .../prototype_data/cod_1522498_ti_nb_hcp.cif | 85 + .../prototype_data/cod_1523304_ti_nb_cmcm.cif | 74 + .../prototype_data/cod_9008554_beta_ti.cif | 160 ++ tests/test_gui_cifs.py | 405 +++++ tests/test_initial_cifs.py | 395 +++++ tests/test_phase_cif.py | 580 +++++++ 34 files changed, 5947 insertions(+), 31 deletions(-) create mode 100644 .agents/skills/diffractscout-cif-reference/SKILL.md create mode 100644 AGENTS.md create mode 100644 docs/AGENT_WORKFLOW.md create mode 100644 docs/INITIAL_CIFS.md create mode 100644 docs/README.md create mode 100644 examples/phase_parameters.template.json create mode 100644 src/diffractscout/cif_quality.py create mode 100644 src/diffractscout/gui_cifs.py create mode 100644 src/diffractscout/initial_cifs.py create mode 100644 src/diffractscout/phase_cif.py create mode 100644 src/diffractscout/prototype_data/cod_1522498_ti_nb_hcp.cif create mode 100644 src/diffractscout/prototype_data/cod_1523304_ti_nb_cmcm.cif create mode 100644 src/diffractscout/prototype_data/cod_9008554_beta_ti.cif create mode 100644 tests/test_gui_cifs.py create mode 100644 tests/test_initial_cifs.py create mode 100644 tests/test_phase_cif.py diff --git a/.agents/skills/diffractscout-cif-reference/SKILL.md b/.agents/skills/diffractscout-cif-reference/SKILL.md new file mode 100644 index 0000000..3d501bf --- /dev/null +++ b/.agents/skills/diffractscout-cif-reference/SKILL.md @@ -0,0 +1,40 @@ +--- +name: diffractscout-cif-reference +description: Generate and verify theoretical XRD peak tables and Excel from local CIFs in DiffractScout. Use for CIF reference exports, not experimental fitting or general code edits. +--- + +# 本地 CIF 理论峰表 + +从用户指定的本地 CIF 生成可追溯理论峰表,交付 Excel 与保留来源、设置和完整性信息的结果包。使用现有 `quick-export`、`verify` 和 `inspect`;不另写衍射引擎。 + +## 输入与选择 + +- 定位用户指定文件或目录,核对 CIF 身份、有效数据块和原文件哈希。输出使用新目标,与输入目录分离;保留原始 CIF。 +- 使用已授权的辐射条件和扫描范围。仅对普通实验室快速参考、且用户未指定实验条件的请求,可以声明采用 `Cu Ka`(λ=1.5406 Å)、2θ=5–120° 的程序默认值。 +- 同步辐射或与实测谱比较缺少波长/能量时,先完成文件清点,再询问该关键条件。波长为 Å、能量为 keV,二者选择一种;预设与显式覆盖的规则见 [CLI](../../../docs/CLI.md)。 +- 只要峰表时使用 `--no-patterns`;没有合适弹性输入或未请求弹性时使用 `--no-elasticity`。需要谱图或弹性时按请求选择已有选项,不把这些简化参数强加给用户。 + +## 执行示例 + +从仓库根目录使用装有依赖的 Python。Windows 已验证系统环境可用 `py -3`;虚拟环境使用其 `Scripts/python.exe`。如下是实验室参考示例,输入、输出和辐射条件按实际请求替换: + +```powershell +py -3 scripts/diffractscout_entry.py quick-export "sample.cif" -o "outputs/sample.xlsx" --source "Cu Ka" --two-theta-min 5 --two-theta-max 120 --no-elasticity --no-patterns +py -3 scripts/diffractscout_entry.py verify "outputs/sample_bundle" +py -3 scripts/diffractscout_entry.py inspect "outputs/sample_bundle" +``` + +`-o sample.xlsx` 同时产生 `sample_bundle/`。以命令返回的实际路径为准。请求的 `.xlsx` 或对应的 `_bundle/` 任一已存在时,选择新的输出名称;不要自动覆盖、删除旧结果或更改 manifest 使校验通过。 + +默认文本输出已包含运行摘要。只有需要机器解析时使用 `--json`;完整导出 JSON 可能含大量中间计算,应保存到结果包外的新日志文件,再读取需要的字段,避免把整个响应塞入上下文。 + +## 核验与交付 + +- 查看退出码和逐物相诊断。校验成功只说明包的完整性;仍需检查是否有可分析物相、反射,以及失败或警告的 CIF。错误诊断不能被“Excel 已创建”掩盖。 +- 对照 `provenance.json`、`phase_summary.csv`、`peak_reference.csv` 和工作簿的 `Peaks`、`Diagnostics`,检查输入身份、辐射条件、扫描范围、峰数和单位是否一致。 +- 命名源模式下,`analysis_settings.wavelength_A=null` 表示没有显式波长覆盖;已解析的有效波长从 `phase_summary.csv` 的 `wavelength_A` 读取,不能把设置空值当成未指定辐射条件。 +- 用导出波长与 d 独立检查 Bragg 几何,并检查 `q=2π/d`、`g=1/d`;强度通道、系统消光与弹性方向的含义按 [科学约定](../../../docs/SCIENTIFIC_CONTRACTS.md) 解读。未出现的峰还可能涉及范围、阈值或重叠,不能直接视作物相不存在。 +- `sample.xlsx` 是结果包中工作簿的副本;交付前核对副本与包内 `results.xlsx` 的哈希。需要编辑时另存副本,保留可验证的包内文件。 +- 给出 Excel 和结果包的实际位置、采用的条件、核验结果及具体警告。说明它是 CIF 平均结构的运动学理论参考;实验物相识别、定量结果或机制结论需要各自的实验依据。 + +完成边界是用户要求的参考文件已经产生并核验。原始输入缺损或关键条件未解决时明确报告缺口,继续完成可独立执行的部分;不把诊断包或空峰表报为完整参考。 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e1ff8f5..4d55a47 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,19 +8,21 @@ List the files, numerical contracts, data schemas, providers, or user workflows ## Scientific evidence -Provide analytical checks, independent comparisons, public/synthetic fixtures, units, tolerances, and interpretation of any changed result. +For changed numerical behavior or scientific contracts, provide analytical +checks, relevant independent comparisons, public/synthetic fixtures, units, +tolerances, and interpretation of changed results. Otherwise state that this +section does not apply. ## Validation -- [ ] `ruff check src tests scripts` -- [ ] `python -m compileall -q src tests scripts` -- [ ] `pytest --cov=diffractscout --cov-fail-under=65` -- [ ] `diffractscout demo -o outputs/pr_demo` -- [ ] `diffractscout benchmark -o outputs/pr_benchmark` -- [ ] `diffractscout verify outputs/pr_demo` -- [ ] `python scripts/joss_readiness.py --output outputs/pr_readiness` completes in non-strict mode +List the relevant local checks and results, and explain any skipped check that +affects confidence in this change. Use [the workflow guide](../docs/AGENT_WORKFLOW.md) +to choose checks; required CI checks remain the merge gate. Full release and +JOSS preflights apply when preparing their respective candidates. + +- [ ] Checks appropriate to the changed behavior completed and reported - [ ] Documentation, validation evidence, and changelog updated when applicable -- [ ] No API keys, restricted data, build artifacts, or local paths committed +- [ ] No API keys, restricted data, generated build artifacts, or private local paths committed ## Compatibility and provenance diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..48ec0ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# DiffractScout 项目协作约定 + +本项目是独立的 Python 科研软件仓库:Windows 桌面、CLI 和 API 共用理论衍射与可追溯导出流程。以下是本仓库的具体约定,适用于 Astra 和其他编码 agent。 + +## 执行与范围 + +- 用户当前请求和已有授权决定任务范围。修改、修复、构建请求应持续完成调查、实现和适当验证;只有用户要求“只审阅”“先给方案”等时才停在审阅或方案阶段。 +- 已确定在本仓库工作时,直接读取相关文件;上级工作区的 MAP 只在定位项目或变更项目清单时使用。文档按下表选择,不必每次重读整个 README、架构、发布和投稿资料。 +- 先查看工作区改动,保留用户已有工作。只修改与任务相关的内容,不覆盖未提交修改,不原地改写原始实验数据或用户 CIF。 +- 本地检查、派生输出和请求范围内的修复无需逐步确认。缺失信息会实质改变科学正确性或目标时才提问,并先完成不依赖答案的工作。 +- 发布、共享、合并、删除远端分支和不可逆 Git 操作遵循用户的明确授权与审批要求;执行前完成可审阅的准备工作。不要把审查、技能调用或普通测试另设为批准门槛。 +- 独立调查、审查或验证能明显提高效率时可用子 agent;共享写入范围保持串行,主线程复核关键科研结论。简单任务直接完成,不强制固定模型、人数或 reviewer 链。 + +## 按任务读取 + +| 当前任务 | 相关入口 | +| --- | --- | +| 安装、使用与文档导航 | [README](README.md)、[中文说明](README.zh-CN.md)、[文档索引](docs/README.md) | +| 开发环境、检查范围、完成标准 | [贡献指南](CONTRIBUTING.md)、[工作流](docs/AGENT_WORKFLOW.md) | +| 跨模块调用或职责调整 | [架构](docs/ARCHITECTURE.md) | +| 数值、单位、物理定义、结果字段 | [科学约定](docs/SCIENTIFIC_CONTRACTS.md)、[API](docs/API.md)、相关模块与测试 | +| CLI、桌面或工作簿行为 | [CLI](docs/CLI.md)、[GUI](docs/GUI.md)、[Excel](docs/EXCEL.md) 中与任务有关的部分 | +| 发布候选或 JOSS 投稿 | [发布流程](docs/RELEASE.md) 或 [投稿入口](docs/joss/README.md),仅在对应任务中加载 | + +## 科研与实现约束 + +- CLI、GUI、API 使用共享 settings 和 `pipeline.py`;保留兼容入口、规范字段和来源记录。优先修复现有路径,避免另建数值实现。 +- 核对 CIF 身份与哈希、辐射条件、扫描范围和单位:`2theta` 为度,`d` 和波长为 Å,`q=2π/d`、`g=1/d` 为 Å⁻¹。强度通道、弹性 Voigt 与坐标系定义以科学约定为准。 +- 缺失数据保留缺失;数据库失败不能写成“没有候选”。系统消光、强度阈值、重叠和空间群推断分别核查,不能凭峰表缺项断言物相不存在。 +- 理论峰表、合成基准和测试通过分别支持软件定义与数值实现;实验物相识别、定量相分数和机制结论需要相应实验依据。明确区分实验事实、数据解释、机制推断和作者主张。 +- 派生输出使用新目标,保持输入输出分离。已有结果包按程序的完整性校验和事务规则处理;不要为了重跑而自动删除输出、绕过锁或加入覆盖参数。 + +## 验证与交付 + +- 选择能验证改动的检查:纯文档检查链接和差异;代码检查受影响测试;科学核心增加解析基准;共享接口、打包或发布再扩大范围。具体命令见工作流。 +- 默认测试使用离线临时夹具。修复请求范围内的失败后重跑受影响检查;检查通过后,只有新改动、失败或未解决问题才扩大或重复验证。GUI 验证需要可用的 Tk 显示环境。 +- 数值定义变化补充有意义的回归证据,更新科学约定;不兼容输出变化更新 schema。领域审阅要求见贡献指南;工程检查不替代科研接受。 +- 交付说明实际改动、验证结果和具体缺口;跳过或未执行的检查不能报为通过。历史计划和审计快照按日期解释,不自动转成每个任务的待办清单。 + +## 项目技能 + +本仓库技能放在 `.agents/skills/`,按请求匹配加载。[$diffractscout-cif-reference](.agents/skills/diffractscout-cif-reference/SKILL.md) 用于从本地 CIF 生成并核验理论峰表与 Excel;普通代码修改或文档修正直接走上述入口。 diff --git a/CHANGELOG.md b/CHANGELOG.md index d16d746..0c4ba05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes are recorded here. The project follows semantic versioning a ### Added +- `fetch-prototypes` and `adapt` for symmetry-prototype CIFs and caller-supplied composition or cited lattice edits. Alpha (194), beta (229), and alpha-double-prime (63) selection stays outside `discover` / `run`. Literature numbers are supplied by the caller; the package does not extract them. The alpha-double-prime fallback is the public-domain COD 1523304 Ti–20 at% Nb scaffold. +- `prepare-cifs` and the desktop Initial CIFs dialog produce checked starting models with raw sources, provenance, per-phase lattice/chemistry assumptions, a theoretical peak preview and a verified manifest. Chemically resolved P1 atoms can be standardized into their verified parent symmetry; multiorbit compounds are rejected and subsequent ranked candidates are tried. All three Ti families have attributed offline scaffolds. Cited phase parameters can override lattice, internal coordinates and bulk chemistry; no literature values are invented. +- `adapt` links symmetry-equivalent conventional cell axes and rejects conflicts. Formula, Z and formula mass now follow expanded occupied sites; stale atom-type/geometry tables are removed from derivatives. Interstitial percentages are not substituted onto metal sites. + - Windows desktop acceptance target and UTF-8 command-line output, including redirected inherited exports on non-Chinese Windows installations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d99e0e9..4df04cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,14 +11,29 @@ Contributions are welcome through GitHub issues and pull requests. ## Development setup +Windows / PowerShell: + +```powershell +git clone https://github.com/D-sudoasd/DiffractScout.git +cd DiffractScout +py -3 -m venv .venv +.\.venv\Scripts\python.exe -m pip install -e ".[test]" +.\.venv\Scripts\python.exe -m pytest -q --ignore=tests/test_gui.py +``` + +Bash: + ```bash git clone https://github.com/D-sudoasd/DiffractScout.git cd DiffractScout python -m venv .venv -python -m pip install -e ".[test]" -pytest -q +.venv/bin/python -m pip install -e ".[test]" +.venv/bin/python -m pytest -q --ignore=tests/test_gui.py ``` +Use that same environment for subsequent commands below. GUI interaction +tests need a working Tk display; see the [GUI guide](docs/GUI.md). + Normal development and test work only needs `.[test]`. Before running the complete local release preflight, install the additional release tooling with: @@ -36,15 +51,25 @@ Tests must not depend on a live Materials Project API unless they are explicitly ## Pull-request requirements -- Add or update tests for changed numerical behavior. +- Use change-specific local checks from the [workflow guide](docs/AGENT_WORKFLOW.md). + Documentation-only changes normally need `python scripts/check_docs.py` + and `git diff --check`; code changes need the affected tests and lint checks. +- Add or update meaningful tests for changed numerical behavior or contracts. - Update `docs/SCIENTIFIC_CONTRACTS.md` when a definition or assumption changes. - Update the schema version when a machine-readable output contract changes incompatibly. - Preserve missing values; do not replace absent scientific data with guessed numbers. - Add source and unit metadata for new numerical fields. -- Run `python -m diffractscout benchmark -o outputs/pr_benchmark` for scientific-core changes. -- Run `python scripts/check_release.py --skip-wheel`; use the full release check before a tagged release. +- Run the analytic benchmark for scientific-core changes, using a fresh output + directory; record settings, tolerances and any changed result. Do not + overwrite earlier evidence merely to rerun a check. +- Required CI checks remain the PR gate. Broaden local checks for shared + interfaces, dependencies or packaging. Use the complete + `python scripts/check_release.py` for release candidates; `--skip-wheel` + still runs the full tests, demo and benchmark and is not a routine shortcut. - Update `docs/evidence/impact_evidence.json` only for completed, traceable public records; never infer impact from private or prospective activity. -- For GUI changes, run the Xvfb smoke command in `docs/GUI.md` and update reference screenshots when the layout changes. +- For GUI changes, exercise affected interactions with a working Tk display; + Linux contributors can use the Xvfb commands in `docs/GUI.md`. Record any + unavailable GUI checks and update reference screenshots when layout changes. - Explain any result differences in the pull-request description. ## Validation and evidence contributions @@ -55,6 +80,21 @@ Real-material, independent-software, or external-installation reports should use A change involving structure factors, systematic absences, tensor conventions, coordinate transforms, elastic moduli, database semantics, or uncertainty handling requires review by a contributor with relevant domain expertise. +Complete the authorized implementation and numerical evidence before requesting +that review. Documentation corrections that do not change these meanings do +not automatically require scientific review. Synthetic benchmarks and passing +tests do not establish experimental validity. + +## Working with coding agents + +Project instructions are in [AGENTS.md](AGENTS.md); task-specific documents are +listed in the [documentation guide](docs/README.md). The repository skill +[$diffractscout-cif-reference](.agents/skills/diffractscout-cif-reference/SKILL.md) +handles local CIF peak-table exports. Load skills when their workflow applies, +preserve existing user changes, and continue through appropriate verification +when implementation has been requested. A read-only review request remains +read-only. + ## Release process The maintainer updates the changelog and version, runs the complete tests, analytic benchmark, demo, readiness audit, and package validation, creates an annotated Git tag, publishes release notes, and archives the tagged source with Zenodo or an equivalent repository. See `docs/RELEASE.md`. diff --git a/MANIFEST.in b/MANIFEST.in index 8011e27..75a965e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,8 @@ 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 AGENTS.md +recursive-include .agents/skills *.md include 启动DiffractScout.bat quick_export_diffractscout.bat recursive-include docs *.md *.svg *.png *.json recursive-include paper *.md *.bib *.svg *.png *.sh *.py @@ -12,6 +14,7 @@ recursive-include tests *.py recursive-include tests *.json recursive-include src/diffractscout/compat LICENSE recursive-include src/diffractscout/benchmark_data *.cif *.json +recursive-include src/diffractscout/prototype_data *.cif recursive-include docs/joss *.md *.json include scripts/check_joss_artifacts.py diff --git a/NOTICE.md b/NOTICE.md index c5a5f53..90f33f1 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -19,3 +19,20 @@ recorded in `docs/COMPAT_SOURCE_INVENTORY.json`. Original MIT license texts ship `diffractscout/compat/cif2peaks/LICENSE` and `diffractscout/compat/phasescout/LICENSE`. PhaseScout imports and writable storage paths were adapted for package isolation. Inherited CLI output is explicitly configured as UTF-8 for Windows pipe compatibility. + +`src/diffractscout/prototype_data/cod_1523304_ti_nb_cmcm.cif` is a copy of the +public-domain Crystallography Open Database entry 1523304 (Brown, Clark, +Eastabrook, and Jepson, Nature (London) 201 (1964) 914–915). `fetch-prototypes` +uses it as a Ti–20 at% Nb Cmcm symmetry scaffold. Its lattice parameters and +Nb occupancy remain those of that entry until a caller edits a derivative with +`adapt` and supplies the replacement values. + +`prepare-cifs` additionally packages COD 1522498 (McHargue, Adair and Hammond, +1953, Ti–2.6 at% Nb hcp), with its public-domain header intact, and COD 9008554 / +AMCSD 0011232 (Wyckoff, *Crystal Structures* 1, 1963, pp. 7–83, beta Ti at +1173 K). The latter file retains its attribution requirement: use within the +scientific community requires proper attribution to the source work. Its +temperature and lattice are source conditions, not room-temperature values +for a target alloy. Original source files and citations remain in each initial +CIF bundle; generated CIFs explicitly identify their source and starting-model +status. diff --git a/README.md b/README.md index 9f77ac6..d12eb2f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ # DiffractScout +[Documentation guide](docs/README.md) · [中文说明](README.zh-CN.md) · +[Contributing](CONTRIBUTING.md) · [Agent workflow](docs/AGENT_WORKFLOW.md) + Windows is the supported desktop platform. Linux CI remains for automated core validation; macOS desktop support is not an acceptance requirement. CLI output uses UTF-8, including when redirected; subprocess consumers should decode UTF-8. @@ -31,12 +34,14 @@ for retained formats, engine differences and validation limits. Candidate-phase assessment commonly involves several disconnected operations: interpret an alloy grade, enumerate chemical subsystems, query a computed-materials database, download structures, inspect CIF metadata, calculate theoretical reflections, locate elastic constants, and prepare tables for experimental planning. Ad hoc scripts often lose the relationship between the provider record, exact CIF setting, tensor basis, diffraction settings, and final spreadsheet. -DiffractScout represents that chain as one research object. It supports two entry points: +DiffractScout represents that chain as one research object. It supports three entry points: | Workflow | Input | Main output | |---|---|---| | Local structure analysis | CIF files or folders | Validated structures, indexed theoretical reflections, optional paired `Cij`, profiles, diagnostics, manifest | | Candidate-phase pipeline | Alloy grade, formula, chemical system, or Materials Project IDs | Candidate catalogue, downloaded conventional CIFs, optional DFT tensors, diffraction tables, provenance, diagnostics, manifest | +| Symmetry prototype and explicit edit | Alloy system, then caller-supplied composition and a cited cell | `alpha.cif`, `beta.cif`, `alpha-double-prime.cif`, then a new CIF and an adapt sidecar | +| Initial CIF preparation | Alloy grade or explicit percentages, optional cited phase parameters | Validated starting CIFs, preserved sources, refinement guide, theoretical peak preview and integrity manifest | The base installation works offline. Materials Project access is optional and uses the researcher's own API key. @@ -169,6 +174,44 @@ diffractscout discover "Ti-Al-V" -o outputs/ti_al_v_candidates Remove-Item Env:MP_API_KEY ``` +### Symmetry prototypes + +For ready-to-load starting models, use the desktop **Initial CIFs** menu or: + +```bash +diffractscout prepare-cifs TC4 -o outputs/TC4_initial --offline +diffractscout verify outputs/TC4_initial +``` + +The three Ti phase families work offline with attributed packaged scaffolds. +Read `report.md` and load the CIFs in `initial/`. Without sample-specific +parameters, their lattice and internal coordinates remain prototype values; +nominal chemistry is a starting assumption. For cited per-phase lattice or +partitioned compositions, fill a copy of +[the parameter template](examples/phase_parameters.template.json) and pass +`--parameters your-parameters.json`. Online preparation tries ranked database +prototypes, restores P1 symmetry from actual atoms, checks the single metal +orbit, and falls back to the Ti scaffolds when needed. See the +[initial CIF guide](docs/INITIAL_CIFS.md) for output semantics and limitations. + +`run` answers a chemical-system search. For one alpha, beta, or +alpha-double-prime cell, fetch the symmetry prototype and then edit a copy: + +```bash +diffractscout fetch-prototypes "Ti-6Al-4V" -o prototypes \ + --phase alpha --phase beta --phase alpha-double-prime +diffractscout adapt prototypes/alpha.cif -o TC4_alpha.cif \ + --nominal tc4 --a 2.935 --c 4.673 \ + --citation "Author, Journal, volume, pages, year, DOI" +``` + +`fetch-prototypes` does not assign the alloy composition. The COD 1523304 +fallback is a Ti–20 at% Nb Cmcm scaffold. `--nominal tc4` is only the +conventional 6 wt% Al, 4 wt% V grade, and a lattice or coordinate change +requires the citation you supply. The package does not read numbers out of +papers. The full sequence, including nominal versus partitioned chemistry, is +in the [CLI guide](docs/CLI.md). + ### Optional extras | Extra | Install | Use | diff --git a/README.zh-CN.md b/README.zh-CN.md index fe3c2de..8cb2507 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,12 +20,17 @@ macOS 桌面不作为支持目标。命令行输出统一为 UTF-8,重定向 [英文主页](README.md) · [CLI 使用说明](docs/CLI.md) · [API](docs/API.md) · [GUI 使用说明](docs/GUI.md) · [科研计算约定](docs/SCIENTIFIC_CONTRACTS.md) · [验证策略](docs/VALIDATION.md) · [发布流程](docs/RELEASE.md) · [JOSS 准备状态](docs/JOSS_READINESS.md) -## 两类工作流 +## 三类工作流 + +使用与开发入口:[文档索引](docs/README.md) · [贡献指南](CONTRIBUTING.md) · +[项目协作约定](AGENTS.md) · [开发与 agent 工作流](docs/AGENT_WORKFLOW.md)。 | 工作流 | 输入 | 主要输出 | |---|---|---| | 本地 CIF 分析 | 单个或多个 CIF、CIF 文件夹 | 结构检查、理论峰表、可选 `Cij` 配对、连续显示谱线、诊断和校验清单 | | Materials Project 流程 | 合金牌号、化学式、化学体系或 `mp-` 编号 | 候选相、常规标准晶胞、可选 DFT 弹性张量、理论衍射表、来源记录和校验清单 | +| 对称性原型与显式精修 | 化学体系,以及调用者给出的成分和带出处的晶格 | `alpha.cif`、`beta.cif`、`alpha-double-prime.cif`,以及一份新 CIF 和 adapt 侧车 | +| 初始 CIF 准备 | 牌号或明确百分比,以及可选的逐相引用参数 | 可载入的初始 CIF、原始来源、拟合建议、理论峰表预览及完整性清单 | 基础安装可以离线分析本地 CIF。Materials Project 支持为可选依赖,API 密钥由用户自行提供。 @@ -224,6 +229,61 @@ Remove-Item Env:MP_API_KEY 默认下载常规标准晶胞。raw/POSCAR 和 IEEE 格式张量会保留数值及来源,但由于当前 provider 没有持久化足够的上游结构取向,无法验证其到导出 CIF Cartesian 坐标系的变换,因此两者均标记为 `frame_transform_required`,不输出方向模量,直至用户提供经过验证的坐标变换。原胞下载需要同时使用 `--no-elasticity`。软件会在访问数据库前估算化学子体系查询数量,超过 `--max-subsystems`(默认 4096)时停止,避免高元体系产生组合式查询膨胀。 +## 对称性原型与成分、晶格精修 + +推荐先使用主窗口的“初始 CIF”菜单,或一次生成完整起始模型包: + +```powershell +diffractscout prepare-cifs TC4 -o outputs/TC4_initial --offline +diffractscout verify outputs/TC4_initial +``` + +TC4、Ti64、Ti-6Al-4V 在这个新入口中会按名义 6 wt% Al、4 wt% V 展开。 +宿主默认按最大的原子分数确定,不受元素输入顺序影响。其它合金须明确 +`--wt` 或 `--at`,也可以用 `--host` 指定母相宿主。 +最终应载入 `initial/` 中的 CIF,并阅读 `report.md`。 +三种 Ti 相族均有带出处的离线原型;缺少目标合金参数时,报告和 CIF 内会 +明确写出“原型晶格、初始成分假设”,不会宣称已经得到该样品的精修结构。 + +有目标相文献值时,复制[逐相参数模板](examples/phase_parameters.template.json), +填入晶格、引用和适用条件,再增加 `--parameters your-parameters.json`。 +可以逐相指定实际相成分,替代整体合金占位假设。在线模式会检查候选真实 +原子位点,对可复核的 P1 数据恢复显式空间群,拒绝仅有同一空间群的多轨道 +化合物,并保留原始 CIF。详见[初始 CIF 使用指南](docs/INITIAL_CIFS.md)。 + +要的是某一个 α、β 或 α'' 晶胞时,用下面两条命令。`run "Ti64"` 会在整个 Ti-Al-V 体系里检索,结果里会包括金属间化合物;它不负责把原型改成指定合金的占位和晶格。 + +```powershell +diffractscout fetch-prototypes "Ti-6Al-4V" -o prototypes ` + --phase alpha --phase beta --phase alpha-double-prime +diffractscout adapt prototypes\alpha.cif -o TC4_alpha.cif ` + --nominal tc4 --a 2.935 --c 4.673 ` + --citation "作者, 期刊, 卷, 页, 年份, DOI" +``` + +`fetch-prototypes` 只复制对称性原型,并写 `prototype_index.csv`。这一步的 `target_composition` 一律是 `false`。 + +- `alpha`:空间群 194。D0₁₉(例如 Ti₃Al)和 C14 不会进入。ω 是 191,也不会进入。 +- `beta`:空间群 229。成分字符串里的第一个元素是宿主;有该元素的单质原型时优先用它,所以 Ti-Al-V 的 β 是 Ti,而不是 V。这里不用能量上限,因为 β-Ti 明显高于常见的 near-stable 阈值。 +- `alpha-double-prime`:空间群 63,化学元素必须落在给定体系内。Ti 宿主在库里没有合格结构时,使用随包附带的 COD 1523304。那是 Ti–20 at% Nb 的 Cmcm 骨架,晶格和 Nb 占位都属于 Brown 等人 1964 年的那条记录。索引会写明这一点;其它宿主须提供合格候选或模板。 + +已有对称性完整的 CIF 时,可以用 `--template alpha=路径` 跳过数据库。Materials Project 常规 CIF 常被标成 P1;`adapt` 要求文件里的 Hermann–Mauguin 符号已经和结构一致,这种 P1 文件会被拒绝。 + +文献里的晶格常数由调用者查完后写在命令里。软件不从论文抽取数字。建议按这个顺序做: + +1. 先跑 `fetch-prototypes`。状态为 `scaffold` 或 `template` 的文件,其晶格和占位仍是来源结构。 +2. 按用户给的成分查该相的实测晶格。优先同一合金、同一相、室温、并写明热处理。记下出处、条件和不确定度。 +3. 论文没有该相的晶格时,停下来说明缺哪一项。借用相近合金时,出处和文件名都要写明这是对照,而不是目标合金的实测。 +4. 论文没给的坐标保持原型原值。α'' 的 `y` 只有在论文报告了它,或用户明确写了 `--fract y=...` 时才改。 +5. 名义合金成分和平衡分配后的相成分分开。马氏体可以继承母相成分;Ti-6Al-4V 里平衡 α、β 通常经过分配,不是整体的 6Al–4V。 +6. 再跑 `adapt`,并把加载器的空间群交叉检查结果告诉用户。结果必须是 `match`,否则文件不会留下。 + +`--nominal tc4`、`ti64`、`ti-6al-4v` 只表示常规牌号:6 wt% Al、4 wt% V、余量 Ti。这是名义牌号。`discover` 和 `run` 里的同名别名仍然只表示元素集合 Ti、Al、V。其它合金用 `--wt Ti=90,Al=6,V=4` 或 `--at`。百分数之和要在 100±0.05 以内。占位先保留五位小数,残差加到输入顺序的最后一个元素上,使总和正好为 1。 + +未指定的独立轴、角度和分数坐标保持原型值。六方/四方的 a、b 自动联动,立方的 a、b、c 自动联动;显式给出相互矛盾的等价轴会被拒绝。改了晶格或坐标就必须有 `--citation`。只改成分时不需要。新文件旁边会有 `名称.adapt.json`,里面有来源 CIF 的 SHA-256、成分依据、出处、改过的轴和按对称性联动的轴。化学式、Z、化学式质量按实际展开的占位和位点重算。源文件、已存在的目标文件和侧车都不会被覆盖。 + +没有 API key 时,只请求 `alpha-double-prime` 也可以完成,走的是 COD 骨架。请求 α 或 β 时需要 key,或者用 `--template`。设置了 key 时,α'' 会先检索;没有合格 Cmcm 结构才退回骨架。 + ## 结果包与写入安全 程序先在同级临时目录生成全部结果并执行完整性校验,通过后才移动到目标目录。覆盖已有结果需要同时满足: diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md new file mode 100644 index 0000000..2051f9d --- /dev/null +++ b/docs/AGENT_WORKFLOW.md @@ -0,0 +1,79 @@ +# 项目开发与 agent 工作流 + +本指南提供按任务选择的入口和命令。长期协作约定在 [AGENTS.md](../AGENTS.md),贡献与领域审阅要求在 [CONTRIBUTING.md](../CONTRIBUTING.md)。普通任务只读取需要的部分。 + +## 环境与日常工作 + +先确认当前 checkout、未提交修改和实际 Python 解释器。使用装有本项目依赖的解释器;不假定每个工作区都有 `.venv`,也不为文档修改安装完整 Materials Project 或发布依赖。 + +新建开发环境见贡献指南。以下 `python` 代表选定的同一个解释器:Windows 虚拟环境可将其替换为 `.\.venv\Scripts\python.exe`;已验证依赖的系统环境可用 `py -3`。从仓库根目录运行。 + +| 改动 | 有意义的本地检查 | 扩大检查的触发条件 | +| --- | --- | --- | +| 文案、链接、项目规则、技能说明 | `python scripts/check_docs.py`、`git diff --check`,核对新增命令与实际接口 | 修改可执行示例时试跑该示例;纯文字不需要数值全量测试 | +| 单个模块修复 | 受影响的 pytest 模块,改动文件的 Ruff 检查 | 共享调用、失败路径或输出变化时增加调用方测试 | +| 衍射、结构解析、弹性或单位定义 | 受影响测试与 `benchmark` 解析基准,检查差异与容差 | 公共数值契约变化时补充独立比较或领域审阅 | +| CLI、API、schema、导出、事务行为 | 相关模块与端到端测试,检查结果包和元数据 | 跨入口影响时扩大到离线非 GUI 套件 | +| GUI | 设置与状态测试,在可用显示环境中执行受影响交互 | Linux Xvfb 命令见 [GUI](GUI.md);只读代码不能报成功交互 | +| 依赖、打包、正式发布候选 | [完整发布 preflight](RELEASE.md) 与相应 CI | 发布验收仍要求完整通过;简化运行不生成完整验收凭据 | + +例如修复快速导出行为时,可从相关模块开始: + +```powershell +python -m pytest -q tests/test_quick_export.py tests/test_cli.py +python -m ruff check src/diffractscout/quick_export.py src/diffractscout/cli.py +``` + +确有共享影响时,运行离线非 GUI 套件: + +```powershell +python -m pytest -q --ignore=tests/test_gui.py +``` + +GUI 模块与启动分类按 [GUI 指南](GUI.md) 和当前 CI 选择。PR 仍须满足配置中的 CI 检查;CI 的全量覆盖不意味着每次局部修改都要在本地手工重复整套发布流程。 + +演示与基准会保护已有输出。重跑使用新目录,例如: + +```powershell +$runId = [guid]::NewGuid().ToString("N") +python -m diffractscout demo -o "outputs/dev-demo-$runId" +python -m diffractscout verify "outputs/dev-demo-$runId" +python -m diffractscout benchmark -o "outputs/dev-benchmark-$runId" +``` + +这些命令需要已安装当前 checkout。源码 CLI 也可通过 `python scripts/diffractscout_entry.py` 调用。现有目录只在用户授权替换、且满足程序完整性规则时使用 `--overwrite`;无需自动删除旧结果。 + +## 项目技能的入口 + +本仓库提供一个具体工作流:[$diffractscout-cif-reference](../.agents/skills/diffractscout-cif-reference/SKILL.md)。例如:“用这个 CIF 生成 Cu Kα、5–120° 的理论峰表,导出 Excel,并核验来源与单位。” + +技能位于 Codex 支持的 `.agents/skills/` 仓库目录。其他 agent 可直接读取对应 `SKILL.md`。无需复制到全局技能库或修改全局模型设置;如果当前会话尚未显示新技能,可直接指定文件,或重新打开项目会话检查发现结果。 + +技能复用现有 CLI 和校验工具;详细公式链接到科学约定。只在本地 CIF 导出与核验时触发。新增技能应解决一个真实重复流程,保持名称和描述短而明确,避免把通用编码任务吸入科研导出流程。 + +## 完成标准与规则回归 + +一次修改完成时,用户要求的文件或行为已产生,相关检查通过,未完成部分有具体原因。调查摘要、第一版实现、审阅报告和“可以继续”均不替代已经授权的交付。 + +维护规则或技能后,用以下场景检查其边界;仅对实际新增或改动的流程试跑,不设每次任务必做的固定清单。 + +| 场景 | 应有行为 | +| --- | --- | +| “修正文档的一处拼写” | 局部修正与文档检查;不加载发布、投稿或 CIF 导出流程 | +| “修复快速导出并验证” | 调查、实现、检查、修正失败直至交付;不在第一版自动等批准 | +| “从 CIF 导出理论峰表” | 明确辐射与范围,保留原始文件,交付 Excel 和可验证结果包 | +| “把这个峰表用于同步辐射实验比较”,但没有辐射条件 | 先检查输入;询问会改变峰位的波长或能量,不能沿用实验室默认值 | +| “只审阅,不改文件” | 返回证据与建议,遵守只读范围 | +| “准备发布,先让我看” | 完成验证与可审阅材料;最终外部动作等待该请求要求的确认 | + +## 本次调整依据 + +2026-09-30 检查发现本仓库没有独立 `AGENTS.md` 或项目技能;上级工作区文件还保留“先读 MAP”的步骤,是否自动加载取决于宿主的指令发现范围。用户提供的科研协作原则已包含自主执行、按需读取与适度验证;主要需要调整的是贡献指南与 PR 模板堆叠的日常检查。此次新增项目入口并按改动路由检查,同时修正 CI 平台描述、虚拟环境用法和机器专属 PDF 渲染路径。完整发布、输出保护和科学证据要求仍按各自契约执行。 + +官方依据核对于 2026-09-30: + +- [Rethinking skills and prompts for GPT-6 Astra](https://developers.openai.com/blog/rethinking-skills-and-prompts-for-gpt-6-astra):按任务读取文档,精简技能描述与固定步骤,明确完成标准。 +- [GPT-6 prompting guidance](https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra):说明自主执行和真实决策边界,按风险选择验证,明确何时并行。 +- [AGENTS.md 加载规则](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 与 [技能发现和编写](https://learn.chatgpt.com/docs/build-skills):项目约定使用标准文件,技能使用仓库 `.agents/skills/` 目录。 + +这些是工作流设计的依据。科研定义、实验接受标准与软件输出契约来自本项目和实际证据,不能从模型能力推导。 diff --git a/docs/API.md b/docs/API.md index 95ca739..28a1838 100644 --- a/docs/API.md +++ b/docs/API.md @@ -208,6 +208,46 @@ overrides it: Scientific meanings and units are defined in `docs/SCIENTIFIC_CONTRACTS.md`. +## Symmetry prototypes and explicit CIF edits + +`diffractscout.phase_cif.fetch_prototypes(composition, output_dir, ...)` copies +alpha (space group 194), beta (229), or alpha-double-prime (63) prototypes into +stable filenames and writes `prototype_index.csv`. It does not assign the +requested alloy composition. The first parsed element is the host unless `host` is supplied. There is no +energy-above-hull cutoff. When no eligible Cmcm candidate is available, +alpha-double-prime uses the packaged COD 1523304 scaffold. + +`diffractscout.phase_cif.adapt_cif(source, destination, ...)` writes a new CIF. +Composition comes from `nominal="tc4"` (also `ti64` or `ti-6al-4v`), +`weight_percent`, or `atomic_percent`. Lattice arguments and `fract` require +`citation`. The source file is unchanged. `analyze`, `discover`, and `run` do +not call either function. + +## Initial CIF preparation + +```python +from diffractscout import prepare_cifs + +result = prepare_cifs("TC4", "outputs/TC4_initial", offline=True) +print(result.exit_code, result.report_path) +``` + +`prepare_cifs` accepts `nominal`, `weight_percent`, `atomic_percent`, `host`, +`phases`, `templates`, `parameter_file` (JSON) or `phase_parameters` (mapping), +`offline`, `api_key`, `provider`, `max_subsystems`, `max_prototype_attempts` +and `preview_wavelength_A`. +Known Ti-6Al-4V grade aliases default to nominal composition only in this API; +other alloys require explicit percentages. The largest atomic fraction selects +the host; equal major fractions need an explicit host. Explicit phase chemistry +overrides the bulk starting assumption and remains identifiable in provenance. +The result is `PrepareCifsResult` with per-phase `records`, artifact paths and +`exit_code` (0 complete, 3 partial, 2 no usable CIF). A complete result can still +retain prototype lattice parameters; read `lattice_basis` and the guide. +The function preserves raw sources, validates symmetry and single-orbit +topology, creates a peak preview, and publishes a verified new directory. +It does not infer equilibrium chemistry, read papers, or alter `analyze`/`run`. +See [the initial CIF contract](INITIAL_CIFS.md). + ## Analytic benchmark API ```python diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57a6d58..83f3d2a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,6 +36,12 @@ validation.py — strict independent bundle verification The GUI contains no separate numerical implementation. It creates `AnalysisSettings` and `DiscoverySettings`, calls the pipeline API, and renders returned diagnostics. +`phase_cif.py` is a separate, explicit preparation path for symmetry-prototype +acquisition and derived composition/lattice/site edits. It writes new CIFs; +ordinary analysis and discovery do not invoke it automatically. See the +[API guide](API.md#symmetry-prototypes-and-explicit-cif-edits) for its inputs +and provenance requirements. + `gui_settings.py` validates and persists a versioned, explicit allowlist of analysis controls. Presets exclude credentials, paths, overwrite authorization, and structure-specific elastic tensors. Excel presentation is separate from diff --git a/docs/CLI.md b/docs/CLI.md index f018c95..98cf8fd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -107,3 +107,105 @@ Database discovery additionally needs the optional Materials Project dependencies and credentials. Prefer `MP_API_KEY` over placing a credential in a shell command. The [API guide](API.md) and [scientific contracts](SCIENTIFIC_CONTRACTS.md) describe the calculation fields and tensor-coordinate requirements. + +## Prepare usable initial CIFs + +```bash +diffractscout prepare-cifs TC4 -o outputs/TC4_initial --offline +diffractscout verify outputs/TC4_initial +diffractscout prepare-cifs Ti-Nb -o outputs/TiNb_initial --at Ti=80,Nb=20 --offline +``` + +Load `initial/*.cif` and read `report.md` for inherited lattice parameters, +composition assumptions and refinement suggestions. This preparation command +recognizes nominal Ti-6Al-4V aliases, selects the host by the largest atomic +fraction, checks actual parent-lattice atom orbits, restores chemically verified +P1 symmetry, and preserves source files. All three Ti families have attributed +offline scaffolds; their lattice/internal coordinates are not target-alloy +measurements. Add `--parameters phase-parameters.json` for cited per-phase +values, `--template phase=path.cif` for overrides, `--host` for explicit host, +or `--json` for machine-readable artifact paths. `--max-prototype-attempts` +controls the per-phase download/validation budget (default 8); remaining untried +candidates are reported. Online mode uses `MP_API_KEY`; +`--offline` never contacts a provider. Exit codes are 0 complete, 3 partial, +2 no usable CIF or invalid input. The output directory must be new. +See [the parameter template](../examples/phase_parameters.template.json) and +[the initial CIF guide](INITIAL_CIFS.md) for the exact scientific contract. + +## Fetch a symmetry prototype, then adapt it + +`discover` and `run` enumerate chemical subsystems. For an alloy grade they can +download intermetallics as well as the elemental prototypes. They do not edit +occupancy or lattice parameters, and they do not read papers. + +Use this sequence when the goal is one alpha, beta, or alpha-double-prime CIF +for a stated composition. + +1. Run `fetch-prototypes` first. A row whose status is `scaffold` or `template`, + and any row whose `target_composition` is `false`, still has the source + occupancy and lattice. The COD 1523304 scaffold is Ti–20 at% Nb, Cmcm. Its + Nb occupancy and cell are the Brown, Clark, Eastabrook, and Jepson (1964) + entry, not the alloy being requested. +2. Look up a measurement for the user's composition and phase. Prefer the same + alloy, the same phase, room temperature, and a stated heat treatment. Record + the citation, the specimen condition, and the reported uncertainty. +3. If that paper does not report a lattice for the phase, stop and say which + value is missing. An analogue alloy can be used only when the citation and + the output filename both say that it is a comparison. +4. Leave every coordinate the paper does not report at the prototype value. + Change alpha-double-prime `y` only when the paper reports it or the user + passes `--fract y=...`. +5. Keep the nominal alloy composition separate from equilibrium phase chemistry. + Martensite can inherit the parent composition. Equilibrium alpha and beta in + Ti-6Al-4V are commonly partitioned and are not bulk 6 wt% Al–4 wt% V. +6. Run `adapt`. Report the loader's space-group cross-check. A result other + than `match` means the file was not written. + +```bash +diffractscout fetch-prototypes "Ti-6Al-4V" -o prototypes \ + --phase alpha --phase beta --phase alpha-double-prime +diffractscout adapt prototypes/alpha.cif -o TC4_alpha.cif \ + --nominal tc4 --a 2.935 --b 2.935 --c 4.673 \ + --citation "Author, Journal, volume, pages, year, DOI" +``` + +`--nominal tc4`, `ti64`, and `ti-6al-4v` mean the conventional grade: 6 wt% Al, +4 wt% V, balance Ti. That switch exists on `adapt` and `prepare-cifs`. In `discover` and `run` those aliases +still expand to the element set Ti, Al, V. Other alloys need `--wt` or `--at`. +The percentages must sum to 100 within 0.05. Occupancies are rounded to five +decimal places and the residual is added to the last element. + +Independent axes, angles, and fractional coordinates change only when supplied. +Hexagonal/tetragonal a and b, and cubic a, b and c, are linked by symmetry. +Specify one of the equal axes; conflicting explicit values are rejected. `--citation` is +required for every lattice or coordinate edit. Composition-only edits do not +need one. The command writes `name.adapt.json` beside the new CIF and refuses +to overwrite the source, the destination, or the sidecar. + +`fetch-prototypes` queries every chemical subsystem and applies no +energy-above-hull cutoff, because beta-Ti is well above the usual near-stable +threshold. The default `--max-subsystems` is 64. The host is the first parsed +element unless overridden with `--host`, so `Ti-Al-V` and `Ti-6Al-4V` use Ti. An elemental host prototype is +preferred over a lower-energy multielement candidate. Alpha requires space +group 194 and excludes D0₁₉ and C14. Beta requires 229. Alpha-double-prime +requires 63 and a formula whose elements are inside the requested system, which +excludes oxides of an oxygen-free alloy. + +Alpha and beta need a Materials Project key or `--template phase=path`. +Alpha-double-prime can be fetched with no key: the packaged scaffold is copied +and the index says so. If a key is set, the command searches first and uses the +scaffold only when no eligible Cmcm hit exists. The packaged scaffold is restricted +to host Ti; other hosts need a candidate or template. A Materials Project conventional +CIF is stored unchanged. When its declared symmetry is P1, `adapt` will refuse +it; pass a symmetry-declared file with `--template`. + +`fetch-prototypes` is the low-level raw acquisition command: CIFs and the index +are individually created without overwrite, but the directory is not a +transactional bundle. Use separate output directories for concurrent fetches; +use `prepare-cifs` for a fully staged, verified atomic bundle. + +| Exit status | `fetch-prototypes` | `adapt` | +|---|---|---| +| `0` | Every requested phase produced a CIF. | The new CIF and sidecar passed validation. | +| `2` | Invalid input, missing API key for alpha or beta, or another fatal error. | Invalid input, missing citation, or the symmetry check failed. No destination file is left behind. | +| `3` | The index was written and at least one requested phase is missing. | Not used. | diff --git a/docs/GUI.md b/docs/GUI.md index 833ea25..c19ba25 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -85,6 +85,42 @@ The query can be an alloy grade, chemical formula, chemical system, or explicit The API key remains in process memory. DiffractScout does not save it in configuration, result, log, or repository files. Users should still remove keys before sharing screenshots, terminal history, or diagnostic material. +## Prepare starting CIFs + +Choose **Prepare initial CIFs / 初始 CIF 准备** from the menu to open the +standalone phase-preparation window. Enter an element system such as `Ti-Al-V`, +choose the composition basis, select phases, and choose a new output folder. +The form and per-phase results scroll vertically on shorter screens; the +status and main action buttons remain fixed at the bottom. +For the common nominal `TC4`, `Ti64`, or `Ti-6Al-4V` grade, the form starts +with `TC4`; the workflow interprets it as 6 wt% Al, 4 wt% V, balance Ti. For +other alloys, enter the complete composition, such as `Ti=90,Al=6,V=4` wt% or +`Ti=86,Al=10,V=4` at%. The optional host defaults to the element with the +largest supplied fraction; specify it explicitly for ties or a different +parent lattice. + +The advanced section accepts a literature-parameter JSON, per-phase template +CIFs, an optional Materials Project key, and an offline switch. Leave the key +empty to use `MP_API_KEY` from the process environment. The GUI does not store +the key. Offline Ti runs can use packaged, cited prototypes; a non-Ti system +must use its own compatible prototypes or templates. If no literature +parameters are provided, the generated files remain explicitly labelled +starting models with the prototype lattice retained as an initial assumption. +The program does not search or interpret papers. + +The result table reports each requested phase and its source/status, with the +record note shown below the table. Use **View report** for the complete +provenance report, **Open output folder** to inspect files, or **Load results +for analysis** to add the generated CIFs to the main window's local analysis +list. The worker never accesses Tk widgets; closing this dialog stops its UI +polling while the preparation can finish writing the requested output. A +successful or partial result remains in the selected output folder. + +These CIFs are starting structures. A database or packaged prototype's +composition and lattice do not become measurements of the target alloy, and +the workflow does not identify phases, infer equilibrium partitioning, or +replace refinement against experimental data. + ## Radiation controls | Mode | Required control | Interpretation | diff --git a/docs/INITIAL_CIFS.md b/docs/INITIAL_CIFS.md new file mode 100644 index 0000000..849a0a3 --- /dev/null +++ b/docs/INITIAL_CIFS.md @@ -0,0 +1,138 @@ +# 初始 CIF 准备 / Initial CIF preparation + +`prepare-cifs` 生成可载入后续拟合软件的初始平均结构。当前相族为 Ti 常见的 +α(hcp,194)、β(bcc,229)和 α″(Cmcm,63)。它检查结构是否适合该母相 +原型,不判断实验里有哪些相,不进行精修,也不把数据库或相近合金参数当作 +当前样品的实测值。 + +## 最短使用路径 + +```powershell +diffractscout prepare-cifs TC4 -o outputs/TC4_initial --offline +diffractscout verify outputs/TC4_initial +``` + +主窗口的“初始 CIF”菜单提供同一功能,可选相、选成分基准、浏览参数文件和 +逐相模板,完成后载入 `initial/` 中的 CIF 继续分析。输出目录必须是一个新目录。 + +TC4、Ti64、Ti-6Al-4V 在这个准备入口中默认展开为名义 6 wt% Al、4 wt% V、 +余量 Ti。其它合金必须给出完整百分比。例如: + +```powershell +diffractscout prepare-cifs Ti-Nb -o outputs/TiNb_initial --at Ti=80,Nb=20 --offline +``` + +百分比必须为正,合计 100±0.05。质量分数先按原子量换算成原子分数,占位 +保留五位小数且和为 1。宿主由最大的原子分数确定,平分时须用 `--host`。 +显式宿主必须属于输入体系。该成分描述的是金属替代位点;O、N、C 等间隙 +元素需要独立位点模型,不能以百分比直接替代金属位点。 + +`discover`、`run` 的同名牌号仍只表示元素集合。`prepare-cifs` 的名义成分 +语义不改变已有分析行为。 + +## 提供目标相的参数 + +复制 [phase_parameters.template.json](../examples/phase_parameters.template.json), +只填写有来源的值: + +```powershell +Copy-Item examples/phase_parameters.template.json my_phase_parameters.json +diffractscout prepare-cifs TC4 -o outputs/TC4_cited --offline --parameters my_phase_parameters.json +``` + +文件使用 `schema: "diffractscout_phase_parameters_v1"` 和 `phases` 对象。 +每相接受以下字段,未知字段、重复字段、无效数值和未选中的相会被拒绝: + +| 字段 | 含义 | +|---|---| +| `lattice` | a、b、c(Å)和 alpha、beta、gamma(°);空对象保留原型 | +| `fract` | 分数坐标 x、y、z;空对象保留原型 | +| `citation` | 晶格或坐标有输入时必填;建议 DOI、论文、表/图位置 | +| `conditions` | 参数的温度、热处理、应力/应变状态、测量方法及适用范围 | +| `weight_percent` | 该相的完整质量百分比字符串,如 `Ti=90,Al=6,V=4` | +| `atomic_percent` | 该相的完整原子百分比字符串;与其它成分基准互斥 | +| `nominal` | 该相明确采用名义牌号时使用;与其它成分基准互斥 | + +整体成分是默认起始假设。相参数中显式给出的成分会替代该相的整体成分, +报告写成 `caller_supplied_phase`;软件不会把调用者输入自行认证为实测相成分。 +马氏体可能继承母相成分,平衡 α/β 通常存在元素分配;这两种情况需要调用者 +根据证据选择。没有相成分时仍可生成初始模型,但报告明确其假设。 + +晶格只需填写独立参数:α 的 a、c,β 的 a,α″ 的 a、b、c。 +六方/四方 a=b、立方 a=b=c 会自动联动,并在 `.adapt.json` 中记录。 +显式输入互相冲突的等价轴、破坏所需对称性的角度或坐标,会让该相失败。 +Cmcm 的 y 是独立内坐标;没有文献或拟合依据时保留来源值,并列为待调整项。 + +程序不会获取或解析论文。代理或使用者查文献时,应优先匹配目标合金、相、 +温度和处理条件,并记录引用;输入引用本身不等于软件已验证引用内容。 +没有目标证据时允许交付明确标注的原型起始模型,不能称为目标样品的实测结构。 + +## 原型选择与质量检查 + +明确指定的 `--template alpha=path.cif` 等逐相模板具有最高优先级。 +模板不适配时该相失败,不会静默换成别的模板。 + +在线模式读取 `MP_API_KEY`(CLI/GUI)或显式 `api_key`(API)。先查询宿主 +单质,再按需查询体系子集;访问前检查 `--max-subsystems`(默认 64)。候选 +排序优先宿主单质,其次能量和 ID。不会以 energy-above-hull 阈值排除亚稳 +母相。每相默认最多尝试 8 个数据库候选,可用 `--max-prototype-attempts` +增加尝试数;未尝试的候选数、选择/拒绝依据留在 `preparation.json`,避免将 +预算限制误报为所有候选均不合格。`max_subsystems` 控制查询个数,不控制 +每次查询返回的条目总量;需要更窄检索时使用明确模板和相选择。 + +实际 CIF 必须同时满足: + +- 声明空间群、Gemmi 读取和 spglib 搜索一致; +- 实际原子构成一条完全占据的金属替代轨道,常规晶胞有 2/2/4 个金属位点; +- Cmcm 原型为 4c 轨道;多轨道化合物不能仅凭空间群编号混入; +- 位点占位和为 1,目标元素、正体积和质量可检查; +- 最终化学式、Z、化学式质量与展开后的占位/多重性一致。 + +数据库 P1 CIF 可以由其完整、带元素/混合占位身份的原子列表独立恢复空间群, +使用 `symprec=0.001 Å` 的标准常规晶胞。恢复过程会理想化容差内的坐标/晶格; +报告记录容差与恢复状态,原始文件原样归档。错误的非 P1 声明不能借此被强行 +改成指定空间群。相关算法定义见 [spglib dataset 文档](https://spglib.readthedocs.io/en/latest/dataset.html)。 + +Ti 缺少合格数据库结构、网络不可用或处于离线模式时,有三份带出处的备用原型: + +| 相族 | 来源 | 必须保留的解释 | +|---|---|---| +| α | COD 1522498,McHargue 等,1953 | Ti–2.6 at% Nb 原型,晶格不属于目标 TC4 | +| β | COD 9008554 / AMCSD 0011232,Wyckoff,1963 | 纯 β-Ti,来源为 1173 K,不是目标合金室温测量 | +| α″ | COD 1523304,Brown 等,1964 | Ti–20 at% Nb 原型,y=0.20 不是目标合金内坐标 | + +这些备用原型只用于 Ti 宿主。其它宿主需要合格数据库候选或明确模板, +不会收到 Ti 的备用文件。声明的相族不等于相在当前样品中存在。 +原始 CIF 的许可/引用保留,见 [NOTICE.md](../NOTICE.md)。 + +## 输出与完成状态 + +| 文件 | 用法 | +|---|---| +| `initial/*.cif` | 交付的初始模型;显式完整空间群操作和混合占位 | +| `initial/*.adapt.json` | 逐相成分基准、参数变更、联动轴、原型来源和输入/输出哈希 | +| `sources/*.cif` | 未改写的原始来源,包含原始文献及许可信息 | +| `prototypes/*.cif` | 经过实际原子检查的适配原型 | +| `report.md` | 逐相条件、初始假设、继承参数和后续拟合建议 | +| `initial_cifs.csv` | 易于浏览的逐相状态索引 | +| `peak_preview.csv` | 5–120° 理论峰表,默认 λ=1.5406 Å;`--preview-wavelength` 可改 | +| `preparation.json` | 结构检查、候选/拒绝依据、provider 元数据、输入参数及软件版本 | +| `manifest.json` | 所有文件的 SHA-256 和大小,可用 `diffractscout verify` 复核 | + +`lattice_basis` 为 `prototype_lattice`、`mixed_cited_and_prototype_lattice` +或 `caller_cited_lattice`,分别表示原型、混用、独立晶轴全部使用引用输入。 +`ready` 表示可读取且符合该原型的结构检查,不代表已经完成样品精修。 +初始 CIF 清除原来源的测量/物性标签,避免把原型温度、密度或文献信息当成 +派生模型的实测属性;原始信息仍在 `sources/` 与来源记录中。 + +峰表的 q=2π/d,强度在每相内独立归一到 100,是平均结构的运动学理论参考, +不能用于确定相含量。不同相的归一强度不可直接比较。 + +退出码:0 为所有请求相均生成;3 为部分相生成;2 为无可用 CIF 或致命输入 +错误。逐相失败也会有报告和可校验包;输入格式错误在创建包前报错。 +所有输出先在私有临时目录生成并检查,再发布到新目录;已有目录、源 CIF +及实验数据不会被覆盖。可进一步运行: + +```powershell +diffractscout analyze outputs/TC4_initial/initial -o outputs/TC4_peak_tables +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..03d854c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# Documentation guide + +Start with the guide for the task at hand. The English [README](../README.md) +and [Chinese README](../README.zh-CN.md) cover installation and first use. + +| Task | Guide | +| --- | --- | +| Run commands, reuse presets, inspect a bundle | [CLI](CLI.md) | +| Use the desktop application | [GUI](GUI.md) | +| Read or save Excel results | [Excel](EXCEL.md) | +| Call the Python API | [API](API.md) | +| Interpret equations, units, assumptions and limits | [Scientific contracts](SCIENTIFIC_CONTRACTS.md) | +| Understand field aliases and engine differences | [Schema aliases](SCHEMA_ALIASES.md), [engine parity](ENGINE_PARITY.md) | +| Make a code or documentation change | [Contributing](../CONTRIBUTING.md), [agent workflow](AGENT_WORKFLOW.md), [project instructions](../AGENTS.md) | +| Understand module boundaries | [Architecture](ARCHITECTURE.md) | +| Validate numerical results | [Validation](VALIDATION.md), [analytic benchmarks](ANALYTIC_BENCHMARKS.md), [validation cases](../validation_cases/README.md) | +| Contribute an experimental or independent comparison | [Validation-case template](VALIDATION_CASE_TEMPLATE.md) | +| Prepare a software release | [Release procedure](RELEASE.md) | +| Prepare a JOSS submission | [Current submission guide](joss/README.md) | +| Check inherited source and licenses | [Source lineage](SOURCE_LINEAGE.md), [notice](../NOTICE.md) | + +The dated reports in [evidence](evidence/README.md), the +[integration report](../INTEGRATION_REPORT.md), and the +[six-month plan](JOSS_6_MONTH_PLAN.md) describe their recorded snapshots. Use the +current guides, code and CI for present behavior; preserve historical evidence +instead of rewriting it as current results. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 68ef8b0..5363914 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,5 +1,9 @@ # Release procedure +Use this procedure for release candidates. Routine changes use the +[scoped development checks](AGENT_WORKFLOW.md); the complete release acceptance +and source-bound receipt described below remain required for a release. + ## 1. Prepare the release branch 1. Confirm that the working tree contains only intended changes. @@ -31,6 +35,14 @@ artifacts, use an empty directory outside the checkout, for example: python scripts/check_release.py --dist-dir /tmp/diffractscout-release-dist ``` +On Windows / PowerShell, use the same Python environment that holds the +installed checkout and release tools, with a fresh temporary target: + +```powershell +$releaseDist = Join-Path $env:TEMP ("diffractscout-release-" + [guid]::NewGuid().ToString("N")) +.\.venv\Scripts\python.exe scripts/check_release.py --dist-dir $releaseDist +``` + This checks required files, version consistency, bibliography keys, compilation, tests, the offline demo, analytic benchmark, manifest verification, wheel and sdist creation, Twine metadata, and a source-independent install/run of the @@ -83,7 +95,24 @@ python -m venv /tmp/diffractscout-wheel-test /tmp/diffractscout-wheel-test/bin/python -m diffractscout verify /tmp/diffractscout-demo ``` -Windows paths should use the environment's `Scripts\python.exe`. +Windows / PowerShell equivalent, using the wheel produced in `$releaseDist`: + +```powershell +$wheelTestRoot = Join-Path $env:TEMP ("diffractscout-wheel-" + [guid]::NewGuid().ToString("N")) +.\.venv\Scripts\python.exe -m venv $wheelTestRoot +$wheelPython = Join-Path $wheelTestRoot "Scripts/python.exe" +$wheelFiles = @(Get-ChildItem -LiteralPath $releaseDist -Filter "diffractscout-*.whl" -File) +if ($wheelFiles.Count -ne 1) { throw "Expected exactly one DiffractScout wheel." } +& $wheelPython -m pip install $wheelFiles[0].FullName +Push-Location $wheelTestRoot +try { + & $wheelPython -m diffractscout demo -o demo + & $wheelPython -m diffractscout verify demo +} finally { + Pop-Location +} +``` + For a local preflight whose normal temporary directory is inaccessible or lies inside the checkout, set `DIFFRACTSCOUT_CLEAN_WHEEL_ROOT` to a writable directory outside the repository. The smoke test rejects an import that resolves outside @@ -117,12 +146,9 @@ The exact JOSS draft is generated by `.github/workflows/draft-pdf.yml`. When Doc bash scripts/build_paper.sh ``` -Render and inspect the PDF: - -```bash -python /home/oai/skills/pdfs/scripts/render_pdf.py paper/paper.pdf \ - --out_dir /tmp/diffractscout-paper-render --dpi 200 -``` +Inspect every PDF page using a local viewer or renderer as described in +[PDF verification](VALIDATION.md#pdf-verification). Check equations, figures, +references and missing glyphs, and record any unverified pages. ## 6. Publish diff --git a/docs/SCIENTIFIC_CONTRACTS.md b/docs/SCIENTIFIC_CONTRACTS.md index 9d93060..ef650d3 100644 --- a/docs/SCIENTIFIC_CONTRACTS.md +++ b/docs/SCIENTIFIC_CONTRACTS.md @@ -11,6 +11,8 @@ This document defines the numerical quantities and validation rules emitted by D - Local inputs remain unchanged. A dedicated Gemmi structure copy is used for structure-factor occupancy conversion. - Partial occupancies are retained as an average-structure model and generate an explicit warning. - DiffractScout does not invent or repair atom labels, occupancies, compositions, oxidation states, disorder models, or crystallographic settings. +- `adapt_cif` is outside that rule. It writes a new file from caller-supplied composition and, when a citation is supplied, caller-supplied lattice parameters or fractional coordinates. It leaves the source CIF unchanged. `analyze`, `discover`, and `run` do not call it, and it does not extract numbers from literature. +- `prepare_cifs` is a separate explicit derivation workflow. It checks actual metal-site topology as well as the phase-family space group, restores chemically verified fully enumerated P1 structures into standard conventional cells at `symprec=0.001 Å`, archives the original bytes, and records the transformation. It never relabels an incompatible non-P1 structure. Nominal/bulk occupancy and any inherited lattice/internal coordinates are starting assumptions. Cited caller values are not verified experimental measurements by the software. Derived CIF formula, Z and formula mass are consistent with expanded occupied sites. Detailed contracts are in [INITIAL_CIFS.md](INITIAL_CIFS.md). ### Space-group resolution @@ -315,7 +317,7 @@ Database query failure, no property document, a document without a tensor, a ten ## 10. Result-bundle integrity -All outputs are first written to a staging directory. CSV and workbook files use temporary-file replacement. The completed staging bundle is verified before it can replace the target directory. +Analysis result bundles and `prepare-cifs` packages are first written to a staging directory. CSV and workbook files use temporary-file replacement. The completed staging bundle is verified before publication to the target directory. The low-level `fetch-prototypes` command creates raw CIFs and its index individually without overwrite; it is not a transactional result bundle and concurrent fetches should use separate directories. The manifest verifier checks SHA-256, byte size, path safety, duplicate paths, symbolic links, root escapes, missing files, modified files, and files present on disk but absent from the manifest. A pre-existing bundle must itself pass verification before overwrite is allowed. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 617d286..f09befe 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -1,5 +1,10 @@ # Validation strategy +Choose local checks according to the change using the +[workflow guide](AGENT_WORKFLOW.md). The broader suites below describe +scientific, CI and release validation; they are not a mandatory local checklist +for every documentation correction. + ## Current automated suite The default suite is offline and deterministic: @@ -150,7 +155,7 @@ The configured GitHub Actions checks are: - source compilation; - coverage threshold of 65% for the headless scientific, orchestration, provider, export, and verification code; the Tk controller and one-line module launcher are excluded from the line metric and checked by form-unit tests plus the Xvfb construction smoke test; - offline demo and manifest verification; -- Windows and macOS tests and demo smoke runs; +- Windows tests and demo smoke runs; macOS desktop acceptance is not configured; - Linux Xvfb GUI construction; - wheel build; - wheel installation in a clean virtual environment; @@ -182,13 +187,17 @@ Scheduled audit runs show that one public commit remains reproducible at a later ## PDF verification -After `paper/paper.pdf` is rebuilt, render it to images and inspect every page: +After `paper/paper.pdf` is rebuilt, inspect every page with a local PDF viewer +or render page images. If Poppler's `pdftoppm` is available, this example works +from the repository root after creating `build/paper-render/`: ```bash -python /home/oai/skills/pdfs/scripts/render_pdf.py paper/paper.pdf \ - --out_dir /tmp/diffractscout-paper-render --dpi 200 +pdftoppm -png -r 200 paper/paper.pdf build/paper-render/page ``` +Rendering is optional tooling; a page-by-page visual inspection is required +for paper delivery. Record the tool used and any pages that could not be checked. + Check headings, equations, table/figure placement, references, clipping, missing glyphs, and page balance. The exact JOSS draft is produced by the Open Journals workflow. ## Evidence still required before JOSS submission diff --git a/examples/phase_parameters.template.json b/examples/phase_parameters.template.json new file mode 100644 index 0000000..07247e6 --- /dev/null +++ b/examples/phase_parameters.template.json @@ -0,0 +1,21 @@ +{ + "schema": "diffractscout_phase_parameters_v1", + "phases": { + "alpha": { + "lattice": {}, + "citation": "", + "conditions": "" + }, + "beta": { + "lattice": {}, + "citation": "", + "conditions": "" + }, + "alpha-double-prime": { + "lattice": {}, + "fract": {}, + "citation": "", + "conditions": "" + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 5563508..7f3581d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ package-dir = {"" = "src"} where = ["src"] [tool.setuptools.package-data] -diffractscout = ["benchmark_data/*.cif", "benchmark_data/*.json"] +diffractscout = ["benchmark_data/*.cif", "benchmark_data/*.json", "prototype_data/*.cif"] "diffractscout.compat.cif2peaks" = ["LICENSE"] "diffractscout.compat.phasescout" = ["LICENSE"] diff --git a/src/diffractscout/__init__.py b/src/diffractscout/__init__.py index 90c6f7e..f7c9fef 100644 --- a/src/diffractscout/__init__.py +++ b/src/diffractscout/__init__.py @@ -2,6 +2,8 @@ from .models import AnalysisSettings, CandidateRecord, ParsedComposition from .pipeline import analyze_cifs, discover_candidates, run_pipeline +from .phase_cif import adapt_cif, fetch_prototypes +from .initial_cifs import PrepareCifsResult, prepare_cifs __all__ = [ "AnalysisSettings", @@ -10,6 +12,10 @@ "analyze_cifs", "discover_candidates", "run_pipeline", + "adapt_cif", + "fetch_prototypes", + "prepare_cifs", + "PrepareCifsResult", "__version__", ] diff --git a/src/diffractscout/cif_quality.py b/src/diffractscout/cif_quality.py new file mode 100644 index 0000000..5ee1d55 --- /dev/null +++ b/src/diffractscout/cif_quality.py @@ -0,0 +1,241 @@ +"""Validate substitutional phase prototypes and restore explicit P1 symmetry. + +This module creates derived models only. It never edits source CIFs or assigns +a phase from experimental data. Chemical species are retained during the +spglib search, including coincident mixed-occupancy sites. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import Any +import warnings + +import gemmi +import numpy as np +import spglib + +from .structure import load_structure, unit_cell_formula_weight_g_mol + +PHASE_NUMBERS = {"alpha": 194, "beta": 229, "alpha-double-prime": 63} +PHASE_MULTIPLICITIES = {"alpha": 2, "beta": 2, "alpha-double-prime": 4} +SYMMETRY_TOLERANCE_A = 1e-3 +INTERSTITIALS = frozenset({"H", "B", "C", "N", "O", "F", "P", "S", "Cl", "Br", "I"}) + + +def _spglib_cell(small: gemmi.SmallStructure) -> tuple[tuple, dict[int, tuple]]: + """Group periodic coincident atoms by their complete occupancy fingerprint.""" + groups: list[tuple[np.ndarray, dict[str, float]]] = [] + for site in small.get_all_unit_cell_sites(): + if site.element.name in INTERSTITIALS: + raise ValueError( + "A substitutional metal prototype cannot contain interstitial elements." + ) + position = np.asarray([site.fract.x, site.fract.y, site.fract.z]) % 1.0 + for previous, amounts in groups: + delta = position - previous + if np.max(np.abs(delta - np.rint(delta))) < 1e-4: + amounts[site.element.name] = amounts.get(site.element.name, 0.0) + site.occ + break + else: + groups.append((position, {site.element.name: float(site.occ)})) + if not groups: + raise ValueError("Prototype contains no occupied metal sites.") + fingerprints: dict[tuple, int] = {} + types: list[int] = [] + for _position, amounts in groups: + if abs(sum(amounts.values()) - 1.0) > 1e-5: + raise ValueError( + "Every prototype metal site must have occupancy sum 1 without vacancies." + ) + fingerprint = tuple( + sorted((element, round(value, 8)) for element, value in amounts.items()) + ) + types.append(fingerprints.setdefault(fingerprint, len(fingerprints) + 1)) + cell = ( + np.asarray(small.cell.orth.mat, dtype=float).T, + np.asarray([position for position, _ in groups]), + np.asarray(types, dtype=int), + ) + return cell, {number: fingerprint for fingerprint, number in fingerprints.items()} + + +def _dataset(cell: tuple): + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Set OLD_ERROR_HANDLING to false and catch the errors directly.", + category=DeprecationWarning, + module=r"spglib(?:\..*)?", + ) + dataset = spglib.get_symmetry_dataset(cell, symprec=SYMMETRY_TOLERANCE_A) + if dataset is None: + raise ValueError("spglib could not determine the prototype symmetry.") + return dataset + + +def _check_topology(dataset, phase: str) -> None: + if int(dataset.number) != PHASE_NUMBERS[phase]: + raise ValueError( + f"{phase} requires space group {PHASE_NUMBERS[phase]}, " + f"but the atoms give {dataset.international} ({dataset.number})." + ) + # One fully occupied orbit distinguishes the parent substitutional lattice + # from ordered compounds that happen to share its space-group number. + if len(set(dataset.equivalent_atoms)) != 1: + raise ValueError( + f"{phase} requires one substitutional metal orbit; this structure has several." + ) + if len(dataset.std_positions) != PHASE_MULTIPLICITIES[phase]: + raise ValueError( + f"{phase} has the wrong number of metal sites in the standard conventional cell." + ) + if phase == "alpha-double-prime" and set(dataset.wyckoffs) != {"c"}: + raise ValueError("alpha-double-prime requires the Cmcm 4c metal orbit.") + + +def validate_phase_model(path: str | Path, phase: str, *, host: str = "") -> Any: + """Validate declared symmetry, chemistry and the parent metal topology.""" + loaded = load_structure(path) + if loaded.space_group_number != PHASE_NUMBERS[phase]: + raise ValueError(f"Declared space group does not match the requested {phase} family.") + if loaded.source_metadata.get("symmetry_crosscheck") != "match": + raise ValueError("Prototype does not pass the independent symmetry cross-check.") + unexpected = [ + warning + for warning in loaded.warnings + if not warning.startswith("Partial occupancies are included") + ] + if unexpected: + raise ValueError("Prototype structure warnings: " + "; ".join(unexpected)) + cell, fingerprints = _spglib_cell(loaded.small_structure) + _check_topology(_dataset(cell), phase) + present = {element for fingerprint in fingerprints.values() for element, _ in fingerprint} + if host and host not in present: + raise ValueError(f"The prototype does not contain host {host}.") + return loaded + + +def write_clean_model(small: gemmi.SmallStructure, destination: Path, *, details: str) -> None: + """Write an explicit-symmetry average model without inherited measurement tags. + + Formula amounts describe the occupied conventional cell, with Z=1. This + makes formula, multiplicities and cell mass consistent for mixed sites. + """ + group = gemmi.find_spacegroup_by_name(small.spacegroup_hm) + if group is None: + raise ValueError("A model needs an explicit recognized space group.") + document = gemmi.cif.Document() + block = document.add_new_block("diffractscout_initial_model") + block.set_pair( + "_audit_creation_method", gemmi.cif.quote("DiffractScout derived starting model") + ) + block.set_pair("_audit_update_record", gemmi.cif.quote(details)) + block.set_pair("_space_group_name_H-M_alt", gemmi.cif.quote(group.xhm())) + block.set_pair("_space_group_name_Hall", gemmi.cif.quote(group.hall)) + block.set_pair("_space_group_IT_number", str(group.number)) + cell = small.cell + for key in ("a", "b", "c"): + block.set_pair(f"_cell_length_{key}", format(getattr(cell, key), ".10g")) + for key in ("alpha", "beta", "gamma"): + block.set_pair(f"_cell_angle_{key}", format(getattr(cell, key), ".10g")) + block.set_pair("_cell_volume", format(cell.volume, ".10g")) + totals: dict[str, float] = defaultdict(float) + for site in small.get_all_unit_cell_sites(): + totals[site.element.name] += site.occ + formula = " ".join(f"{element}{amount:.8g}" for element, amount in sorted(totals.items())) + block.set_pair("_chemical_formula_sum", gemmi.cif.quote(formula)) + block.set_pair("_cell_formula_units_Z", "1") + mass = unit_cell_formula_weight_g_mol(small) + if mass is not None: + block.set_pair("_chemical_formula_weight", format(mass, ".10g")) + operations = block.init_loop("_space_group_symop_", ["id", "operation_xyz"]) + for index, operation in enumerate(group.operations(), start=1): + operations.add_row([str(index), gemmi.cif.quote(operation.triplet())]) + atoms = block.init_loop( + "_atom_site_", + ["label", "type_symbol", "fract_x", "fract_y", "fract_z", "occupancy", "U_iso_or_equiv"], + ) + for index, site in enumerate(small.sites, start=1): + atoms.add_row( + [ + f"{site.element.name}{index}", + site.element.name, + *[format(value, ".10g") for value in (site.fract.x, site.fract.y, site.fract.z)], + format(site.occ, ".8g"), + format(site.u_iso, ".8g"), + ] + ) + document.write_file(str(destination)) + + +def normalize_prototype(source: Path, destination: Path, phase: str, *, host: str = "") -> dict: + """Preserve a valid prototype or derive a checked conventional CIF from P1. + + P1 restoration requires chemically resolved atoms to independently give + the requested family and its single orbit. A wrong non-P1 declaration is + rejected. Original bytes remain in the caller's source archive. + """ + source = Path(source).expanduser().resolve() + destination = Path(destination).expanduser().resolve() + if source == destination: + raise ValueError("Prototype normalization refuses to overwrite the source CIF.") + if destination.exists(): + raise FileExistsError(f"Refusing to overwrite {destination}.") + loaded = load_structure(source) + cell, fingerprints = _spglib_cell(loaded.small_structure) + dataset = _dataset(cell) + _check_topology(dataset, phase) + restored = loaded.space_group_number == 1 and int(dataset.number) != 1 + if restored: + standard_cell = (dataset.std_lattice, dataset.std_positions, dataset.std_types) + standard = _dataset(standard_cell) + _check_topology(standard, phase) + lengths = [float(np.linalg.norm(vector)) for vector in dataset.std_lattice] + angles = [] + for first, second in ((1, 2), (0, 2), (0, 1)): + cosine = np.dot(dataset.std_lattice[first], dataset.std_lattice[second]) / ( + lengths[first] * lengths[second] + ) + angles.append(float(np.degrees(np.arccos(np.clip(cosine, -1, 1))))) + small = gemmi.SmallStructure() + small.cell = gemmi.UnitCell(*lengths, *angles) + small.spacegroup_hm = gemmi.find_spacegroup_by_number(PHASE_NUMBERS[phase]).xhm() + for orbit in sorted(set(standard.equivalent_atoms)): + index = list(standard.equivalent_atoms).index(orbit) + for element, occupancy in fingerprints[int(dataset.std_types[index])]: + site = gemmi.SmallStructure.Site() + site.element = gemmi.Element(element) + site.type_symbol = element + site.label = f"{element}{len(small.sites) + 1}" + site.occ = occupancy + site.fract = gemmi.Fractional( + *[float(value) for value in dataset.std_positions[index]] + ) + small.add_site(site) + write_clean_model( + small, + destination, + details="Symmetry restored from fully enumerated P1 atoms using spglib; source CIF archived separately. Source lattice is a prototype, not a target-alloy measurement.", + ) + else: + destination.write_bytes(source.read_bytes()) + validated = validate_phase_model(destination, phase, host=host) + return { + "symmetry_restored_from_p1": restored, + "symprec_A": SYMMETRY_TOLERANCE_A, + "source_space_group": loaded.space_group_number, + "validated_space_group": PHASE_NUMBERS[phase], + "metal_orbits": 1, + "conventional_metal_site_count": PHASE_MULTIPLICITIES[phase], + "source_cell_parameters": loaded.cell_parameters, + "prototype_cell_parameters": validated.cell_parameters, + "standardization": { + "transformation_matrix": dataset.transformation_matrix.tolist(), + "origin_shift": dataset.origin_shift.tolist(), + "std_rotation_matrix": dataset.std_rotation_matrix.tolist(), + } + if restored + else None, + } diff --git a/src/diffractscout/cli.py b/src/diffractscout/cli.py index 398c96c..c14e844 100644 --- a/src/diffractscout/cli.py +++ b/src/diffractscout/cli.py @@ -6,7 +6,7 @@ import sys import tempfile from pathlib import Path -from typing import Sequence +from typing import Any, Sequence from . import __version__ from .console import configure_cli_output @@ -126,6 +126,28 @@ def _print_result( print(f"{level}: {message}", file=sys.stderr) +def _print_fetch(result: Any) -> None: + print(f"Output: {result.output_dir}") + print(f"Index: {result.index_path}") + print(f"Host: {result.host}") + for record in result.records: + filename = record.cif_path.name if record.cif_path is not None else "-" + identity = record.material_id or record.source or record.status + print(f"{record.phase}: {record.status} {identity} -> {filename}") + if record.note: + print(f"NOTE: {record.note}") + + +def _print_adapt(result: Any) -> None: + print(f"Output: {result.cif_path}") + print(f"Sidecar: {result.sidecar_path}") + print(f"Space group: {result.space_group_symbol} ({result.space_group_number})") + print(f"Cross-check: {result.symmetry_crosscheck}") + print("Occupancies: " + " ".join(f"{element}={value}" for element, value in result.occupancies.items())) + for warning in result.warnings: + print(f"WARNING: {warning}", file=sys.stderr) + + def _pipeline_exit_code(result: object) -> int: """Return a machine-actionable status for batch workflows. @@ -252,6 +274,101 @@ def build_parser() -> argparse.ArgumentParser: preset_show.add_argument("file", help="Preset JSON file.") preset_show.add_argument("--json", action="store_true", help="Print the normalized preset values as JSON.") + fetch = subparsers.add_parser( + "fetch-prototypes", + help="Copy alpha, beta, or alpha-double-prime symmetry prototypes without assigning alloy composition.", + ) + fetch.add_argument( + "composition", + help="Alloy grade or chemical system. The first parsed element is the host.", + ) + fetch.add_argument("-o", "--output", required=True, help="Directory for the prototype CIFs and prototype_index.csv.") + fetch.add_argument( + "--phase", + action="append", + dest="phases", + default=None, + help="Repeat for alpha, beta, or alpha-double-prime. Default: all three.", + ) + fetch.add_argument( + "--template", + action="append", + dest="templates", + default=None, + help="Local CIF for one phase, as phase=path. Example: alpha=alpha.cif.", + ) + fetch.add_argument( + "--max-subsystems", + type=int, + default=64, + help="Maximum chemical-subsystem queries. Default 64; this command applies no energy-above-hull cutoff.", + ) + fetch.add_argument("--api-key", default="", help="Materials Project API key. MP_API_KEY is preferred.") + fetch.add_argument("--host", default="", help="Override the first parsed host element.") + + prepare = subparsers.add_parser("prepare-cifs", help="Prepare validated, traceable alloy starting CIFs and a refinement guide.") + prepare.add_argument("composition", help="Chemical system or known nominal grade (TC4/Ti64/Ti-6Al-4V).") + prepare.add_argument("-o", "--output", required=True, help="New directory for initial CIFs, sources, checks and peak preview.") + prepare.add_argument("--nominal", default="", help="Nominal grade. Known Ti-6Al-4V aliases are recognized automatically here.") + prepare.add_argument("--wt", default="", help="Complete weight percentages, e.g. Ti=90,Al=6,V=4.") + prepare.add_argument("--at", default="", help="Complete atomic percentages, e.g. Ti=80,Nb=20.") + prepare.add_argument("--host", default="", help="Parent lattice host; otherwise the largest supplied atomic fraction is used.") + prepare.add_argument("--phase", action="append", dest="phases", default=None, help="Repeat alpha, beta or alpha-double-prime; default all three.") + prepare.add_argument("--template", action="append", dest="templates", default=None, help="Override one prototype with phase=local.cif.") + prepare.add_argument("--parameters", default=None, help="Strict JSON with cited per-phase lattice, coordinates and optional phase chemistry.") + prepare.add_argument("--offline", action="store_true", help="Use local templates and packaged Ti scaffolds; never contact the provider.") + prepare.add_argument("--api-key", default="", help="Materials Project API key. MP_API_KEY is preferred.") + prepare.add_argument("--max-subsystems", type=int, default=64, help="Maximum subsystem queries before expansion; default 64.") + prepare.add_argument("--max-prototype-attempts", type=int, default=8, help="Maximum downloaded candidates attempted per phase; untried alternatives are reported. Default 8.") + prepare.add_argument("--preview-wavelength", type=float, default=1.5406, help="Preview wavelength in angstroms; default Cu Ka 1.5406.") + prepare.add_argument("--json", action="store_true", help="Print structured artifact paths and per-phase statuses.") + + adapt_cmd = subparsers.add_parser( + "adapt", + help="Write caller-supplied composition and cited lattice edits into a new CIF.", + ) + adapt_cmd.add_argument("cif", help="Source CIF. The source file is left unchanged.") + adapt_cmd.add_argument("-o", "--output", required=True, help="Destination .cif path. Existing files are refused.") + adapt_cmd.add_argument( + "--nominal", + default="", + help="Conventional grade: tc4, ti64, or ti-6al-4v (6 wt percent Al, 4 wt percent V, balance Ti).", + ) + adapt_cmd.add_argument( + "--wt", + default="", + help="Weight percent, for example Ti=90,Al=6,V=4. The values must sum to 100 within 0.05.", + ) + adapt_cmd.add_argument( + "--at", + default="", + help="Atomic percent, for example Ti=86.2,Al=10.2,V=3.6. The values must sum to 100 within 0.05.", + ) + adapt_cmd.add_argument("--a", type=float, default=None, help="Replacement cell length a in angstroms.") + adapt_cmd.add_argument("--b", type=float, default=None, help="Replacement cell length b in angstroms.") + adapt_cmd.add_argument("--c", type=float, default=None, help="Replacement cell length c in angstroms.") + adapt_cmd.add_argument("--alpha", type=float, default=None, help="Replacement cell angle alpha in degrees.") + adapt_cmd.add_argument( + "--beta", + type=float, + default=None, + dest="beta_angle", + help="Replacement cell angle beta in degrees.", + ) + adapt_cmd.add_argument("--gamma", type=float, default=None, help="Replacement cell angle gamma in degrees.") + adapt_cmd.add_argument( + "--fract", + action="append", + dest="fract", + default=None, + help="Fractional coordinate shared by every metal site, for example y=0.166667.", + ) + adapt_cmd.add_argument( + "--citation", + default="", + help="Required when a lattice parameter or fractional coordinate is set.", + ) + subparsers.add_parser("gui", help="Launch the optional Tk desktop interface.") subparsers.add_parser("compat", help="Run built-in CIF2Peaks/PhaseScout workflows; use compat --help.") return parser @@ -394,6 +511,67 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"{key}: {value}") return 0 + if args.command == "fetch-prototypes": + from .phase_cif import fetch_prototypes + + result = fetch_prototypes( + args.composition, + args.output, + phases=args.phases, + template_args=args.templates, + api_key=str(args.api_key or os.environ.get("MP_API_KEY", "")).strip() or None, + max_subsystems=args.max_subsystems, + host=args.host, + ) + _print_fetch(result) + return result.exit_code + + if args.command == "prepare-cifs": + from .initial_cifs import prepare_cifs + + result = prepare_cifs( + args.composition, args.output, nominal=args.nominal, + weight_percent=args.wt, atomic_percent=args.at, host=args.host, + phases=args.phases, template_args=args.templates, + parameter_file=args.parameters, offline=args.offline, + api_key=str(args.api_key or os.environ.get("MP_API_KEY", "")).strip() or None, + max_subsystems=args.max_subsystems, max_prototype_attempts=args.max_prototype_attempts, + preview_wavelength_A=args.preview_wavelength, + ) + if args.json: + print(json.dumps(to_jsonable(result), indent=2, ensure_ascii=False)) + else: + print(f"Initial CIFs: {result.output_dir / 'initial'}") + print(f"Guide: {result.report_path}") + print(f"Index: {result.index_path}") + print(f"Manifest: {result.manifest_path}") + for record in result.records: + print(f"{record.phase}: {record.status} -> {record.cif_path or '-'}") + print(f"NOTE: {record.note}") + print("Review report.md for prototype lattice values and composition assumptions before refinement.") + return result.exit_code + + if args.command == "adapt": + from .phase_cif import adapt_cif, parse_fractional_assignments + + adapted = adapt_cif( + args.cif, + args.output, + nominal=args.nominal, + weight_percent=args.wt, + atomic_percent=args.at, + a=args.a, + b=args.b, + c=args.c, + alpha=args.alpha, + beta=args.beta_angle, + gamma=args.gamma, + fract=parse_fractional_assignments(args.fract), + citation=args.citation, + ) + _print_adapt(adapted) + return 0 + if args.command == "gui": from .gui import main as gui_main diff --git a/src/diffractscout/gui.py b/src/diffractscout/gui.py index 1e31234..6630b3e 100644 --- a/src/diffractscout/gui.py +++ b/src/diffractscout/gui.py @@ -479,6 +479,10 @@ def _create_variables(self) -> None: def _build_compat_menu(self) -> None: menu = tk.Menu(self) + menu.add_command( + label="初始 CIF 准备 / Prepare initial CIFs", + command=self._open_initial_cif_dialog, + ) workflows = tk.Menu(menu, tearoff=False) for label, workflow in ( ("CIF2Peaks · 峰表与图谱 / Peaks and figures", "cif2peaks-gui"), @@ -490,6 +494,15 @@ def _build_compat_menu(self) -> None: menu.add_cascade(label="兼容工作台 / Compatibility", menu=workflows) self.configure(menu=menu) + def _open_initial_cif_dialog(self) -> None: + from .gui_cifs import InitialCifDialog + + InitialCifDialog( + self, + language=self.lang, + on_load=self._add_input_paths, + ) + def _launch_compat(self, workflow: str) -> None: try: command = [sys.executable] diff --git a/src/diffractscout/gui_cifs.py b/src/diffractscout/gui_cifs.py new file mode 100644 index 0000000..c72a76a --- /dev/null +++ b/src/diffractscout/gui_cifs.py @@ -0,0 +1,830 @@ +"""Tk dialog for preparing traceable starting CIFs for selected phases.""" + +from __future__ import annotations + +import os +import queue +import re +import threading +import webbrowser +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Mapping + +try: # Keep the core package importable when Tk is unavailable. + import tkinter as tk + from tkinter import filedialog, messagebox, ttk +except ImportError: # pragma: no cover - platform-dependent + tk = None # type: ignore[assignment] + filedialog = messagebox = ttk = None # type: ignore[assignment] + + +PHASES = ("alpha", "beta", "alpha-double-prime") +_PHASE_LABELS = { + "alpha": {"zh": "α 相", "en": "α phase"}, + "beta": {"zh": "β 相", "en": "β phase"}, + "alpha-double-prime": {"zh": "α″ 马氏体", "en": "α″ martensite"}, +} +_STATUS_LABELS = { + "ready": {"zh": "可用起始 CIF", "en": "ready starting CIF"}, + "failed": {"zh": "该相准备失败", "en": "phase preparation failed"}, + "downloaded": {"zh": "数据库原型", "en": "database prototype"}, + "template": {"zh": "用户模板", "en": "user template"}, + "scaffold": {"zh": "随包原型", "en": "packaged prototype"}, + "prepared": {"zh": "已生成起始 CIF", "en": "starting CIF prepared"}, + "adapted": {"zh": "已生成起始 CIF", "en": "starting CIF prepared"}, + "missing": {"zh": "未生成", "en": "not generated"}, +} + + +def prepare_kwargs_from_form( + values: Mapping[str, Any], *, environment_key: str | None = None +) -> dict[str, Any]: + """Validate a plain form snapshot and translate it to ``prepare_cifs`` kwargs.""" + + composition = str(values.get("composition", "")).strip() + output_text = str(values.get("output_dir", "")).strip() + if not composition: + raise ValueError("请输入合金成分或元素系统,例如 Ti-Al-V。" if values.get("lang") != "en" else "Enter a composition or element system, for example Ti-Al-V.") + if not output_text: + raise ValueError("请选择新建的输出文件夹。" if values.get("lang") != "en" else "Choose a new output folder.") + + basis = str(values.get("basis", "nominal")) + nominal = str(values.get("nominal", "")).strip() + percent_text = str(values.get("percent_text", "")).strip() + if basis == "nominal": + if not nominal: + raise ValueError("请输入名义牌号,例如 TC4、Ti64 或 Ti-6Al-4V。" if values.get("lang") != "en" else "Enter a nominal grade, for example TC4, Ti64, or Ti-6Al-4V.") + composition_kwargs = {"nominal": nominal} + elif basis == "weight_percent": + if not percent_text: + raise ValueError("请输入完整质量百分比,例如 Ti=90,Al=6,V=4。" if values.get("lang") != "en" else "Enter the full weight percentages, for example Ti=90,Al=6,V=4.") + composition_kwargs = {"weight_percent": percent_text} + elif basis == "atomic_percent": + if not percent_text: + raise ValueError("请输入完整原子百分比,例如 Ti=86,Al=10,V=4。" if values.get("lang") != "en" else "Enter the full atomic percentages, for example Ti=86,Al=10,V=4.") + composition_kwargs = {"atomic_percent": percent_text} + else: + raise ValueError("未知的成分基准。" if values.get("lang") != "en" else "Unknown composition basis.") + + phases = [phase for phase in PHASES if bool(values.get(f"phase_{phase}", False))] + if not phases: + raise ValueError("至少选择一个相。" if values.get("lang") != "en" else "Select at least one phase.") + + templates: dict[str, Path] = {} + for phase in phases: + raw_path = str(values.get(f"template_{phase}", "")).strip() + if not raw_path: + continue + path = Path(raw_path).expanduser() + if not path.is_file() or path.suffix.lower() != ".cif": + label = _PHASE_LABELS[phase]["en" if values.get("lang") == "en" else "zh"] + raise ValueError(f"{label}: 模板必须是存在的 CIF 文件。" if values.get("lang") != "en" else f"{label}: template must be an existing CIF file.") + templates[phase] = path.resolve() + + parameter_text = str(values.get("parameter_file", "")).strip() + parameter_file: Path | None = None + if parameter_text: + parameter_file = Path(parameter_text).expanduser() + if not parameter_file.is_file(): + raise ValueError("参数文件不存在。" if values.get("lang") != "en" else "Parameter file does not exist.") + parameter_file = parameter_file.resolve() + + offline = bool(values.get("offline", False)) + typed_key = str(values.get("api_key", "")).strip() + api_key = None if offline else (typed_key or str(environment_key or "").strip() or None) + return { + "composition": composition, + "output_dir": Path(output_text).expanduser().resolve(), + **composition_kwargs, + "phases": phases, + "host": str(values.get("host", "")).strip(), + "templates": templates, + "parameter_file": parameter_file, + "offline": offline, + "api_key": api_key, + "max_subsystems": 64, + } + + +def _prepare_cifs(**kwargs: Any) -> Any: + """Import the optional workflow only when a user starts the operation.""" + + from .initial_cifs import prepare_cifs + + return prepare_cifs(**kwargs) + + +def _worker_run( + kwargs: dict[str, Any], events: queue.Queue[tuple[str, object]] +) -> None: + """Perform the API call without retaining or touching any Tk widget.""" + + try: + events.put(("done", _prepare_cifs(**kwargs))) + except Exception as exc: # Surface provider and validation failures in the dialog. + events.put(("error", exc)) + + +def _open_path(path: Path) -> None: + """Open a report or output directory using the platform's registered app.""" + + if os.name == "nt": # pragma: no cover - Windows integration + os.startfile(str(path)) # type: ignore[attr-defined] + elif path.is_dir(): + webbrowser.open(path.as_uri()) + else: + webbrowser.open(path.as_uri()) + + +if tk is not None: + + class InitialCifDialog(tk.Toplevel): + """Independent window for building and loading initial CIF files.""" + + def __init__( + self, + master: Any, + *, + language: str = "zh", + on_load: Callable[[list[Path]], Any] | None = None, + on_close: Callable[[], Any] | None = None, + ) -> None: + super().__init__(master) + self.lang = "en" if str(language).lower() == "en" else "zh" + self._on_load = on_load + self._on_close = on_close + self._closed = False + self._events: queue.Queue[tuple[str, object]] = queue.Queue() + self._after_id: str | None = None + self._running = False + self._result: Any = None + self._result_paths: list[Path] = [] + self._input_widgets: list[Any] = [] + self._template_vars: dict[str, Any] = {} + + self.title(self._t("title")) + screen_height = self.winfo_screenheight() + self._window_height_limit = max(1, min(770, screen_height - 100)) + self._window_width = min(920, max(680, self.winfo_screenwidth() - 60)) + initial_height = min(770, self._window_height_limit) + self.geometry(f"{self._window_width}x{initial_height}") + self.minsize(min(820, self._window_width), min(660, initial_height)) + self.protocol("WM_DELETE_WINDOW", self._close) + self.transient(master) + self._make_variables() + self._build_widgets() + self._set_status(self._t("ready")) + + def _t(self, key: str, **fmt: object) -> str: + return _TEXT[self.lang][key].format(**fmt) + + def _make_variables(self) -> None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", "Ti-Al-V").strip("._-") + self.composition_var = tk.StringVar(value="Ti-Al-V") + self.basis_var = tk.StringVar(value="nominal") + self.nominal_var = tk.StringVar(value="TC4") + self.percent_var = tk.StringVar(value="") + self.host_var = tk.StringVar(value="") + self.output_var = tk.StringVar( + value=str((Path.cwd() / f"initial_cifs_{safe_name}_{timestamp}").resolve()) + ) + self.parameter_var = tk.StringVar(value="") + self.offline_var = tk.BooleanVar(value=not bool(os.environ.get("MP_API_KEY", "").strip())) + self.api_key_var = tk.StringVar(value="") + self.show_key_var = tk.BooleanVar(value=False) + self.phase_vars = {phase: tk.BooleanVar(value=True) for phase in PHASES} + self.basis_var.trace_add("write", lambda *_args: self._refresh_basis_label()) + self.show_key_var.trace_add("write", lambda *_args: self._refresh_key_visibility()) + self.offline_var.trace_add("write", lambda *_args: self._refresh_key_visibility()) + + def _build_widgets(self) -> None: + outer = ttk.Frame(self) + outer.pack(fill="both", expand=True) + footer = ttk.Frame(outer, padding=(14, 4, 14, 10)) + footer.pack(side="bottom", fill="x") + scroll_host = ttk.Frame(outer) + scroll_host.pack(fill="both", expand=True) + self._scroll_canvas = tk.Canvas( + scroll_host, highlightthickness=0, borderwidth=0, background="#F3F6F9" + ) + self._scroll_canvas.pack(side="left", fill="both", expand=True) + self._scrollbar = ttk.Scrollbar( + scroll_host, orient="vertical", command=self._scroll_canvas.yview + ) + self._scrollbar.pack(side="right", fill="y") + self._scroll_canvas.configure(yscrollcommand=self._scrollbar.set) + content = ttk.Frame(self._scroll_canvas, padding=14) + self._scroll_content = content + self._scroll_window = self._scroll_canvas.create_window( + (0, 0), window=content, anchor="nw" + ) + content.bind("", self._on_scroll_content_configure, add="+") + self._scroll_canvas.bind("", self._on_scroll_canvas_configure, add="+") + self.bind("", self._scroll_mousewheel, add="+") + self.bind("", self._scroll_mousewheel, add="+") + self.bind("", self._scroll_mousewheel, add="+") + + ttk.Label(content, text=self._t("heading"), font=("Segoe UI Semibold", 14)).pack(anchor="w") + ttk.Label(content, text=self._t("scroll_hint"), style="Hint.TLabel").pack(anchor="w", pady=(2, 0)) + ttk.Label( + content, + text=self._t("warning"), + wraplength=max(400, self._window_width - 80), + justify="left", + foreground="#8a4b08", + ).pack(anchor="w", fill="x", pady=(4, 10)) + + form = ttk.LabelFrame(content, text=self._t("inputs"), padding=10) + form.pack(fill="x") + form.columnconfigure(1, weight=1) + ttk.Label(form, text=self._t("composition")).grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4) + self.composition_entry = ttk.Entry(form, textvariable=self.composition_var) + self.composition_entry.grid(row=0, column=1, sticky="ew", pady=4) + self._input_widgets.append(self.composition_entry) + ttk.Label(form, text=self._t("composition_hint"), style="Hint.TLabel").grid( + row=1, column=1, sticky="w", pady=(0, 5) + ) + + basis_frame = ttk.LabelFrame(form, text=self._t("basis"), padding=(8, 4)) + basis_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(3, 7)) + for column, basis in enumerate(("nominal", "weight_percent", "atomic_percent")): + radio = ttk.Radiobutton( + basis_frame, + text=self._t(f"basis_{basis}"), + value=basis, + variable=self.basis_var, + ) + radio.grid(row=0, column=column, sticky="w", padx=(0, 14)) + self._input_widgets.append(radio) + self.basis_value_label = ttk.Label(basis_frame, text="") + self.basis_value_label.grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4) + self.nominal_entry = ttk.Entry(basis_frame, textvariable=self.nominal_var, width=28) + self.nominal_entry.grid(row=1, column=1, sticky="ew", pady=4) + self.percent_entry = ttk.Entry(basis_frame, textvariable=self.percent_var, width=28) + self.percent_entry.grid(row=1, column=1, sticky="ew", pady=4) + basis_frame.columnconfigure(1, weight=1) + self._input_widgets.extend((self.nominal_entry, self.percent_entry)) + self.basis_hint = ttk.Label(basis_frame, text="", style="Hint.TLabel") + self.basis_hint.grid(row=2, column=1, sticky="w", pady=(0, 3)) + + ttk.Label(form, text=self._t("host")).grid(row=3, column=0, sticky="w", padx=(0, 8), pady=4) + self.host_entry = ttk.Entry(form, textvariable=self.host_var) + self.host_entry.grid(row=3, column=1, sticky="ew", pady=4) + self._input_widgets.append(self.host_entry) + ttk.Label(form, text=self._t("host_hint"), style="Hint.TLabel").grid( + row=4, column=1, sticky="w", pady=(0, 4) + ) + + ttk.Label(form, text=self._t("output")).grid(row=5, column=0, sticky="w", padx=(0, 8), pady=4) + output_row = ttk.Frame(form) + output_row.grid(row=5, column=1, sticky="ew", pady=4) + output_row.columnconfigure(0, weight=1) + self.output_entry = ttk.Entry(output_row, textvariable=self.output_var) + self.output_entry.grid(row=0, column=0, sticky="ew") + output_button = ttk.Button(output_row, text=self._t("browse_parent"), command=self._browse_output_parent) + output_button.grid(row=0, column=1, padx=(6, 0)) + self._input_widgets.extend((self.output_entry, output_button)) + + phases_frame = ttk.LabelFrame(form, text=self._t("phases"), padding=(8, 4)) + phases_frame.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(7, 0)) + for column, phase in enumerate(PHASES): + checkbox = ttk.Checkbutton( + phases_frame, + text=_PHASE_LABELS[phase][self.lang], + variable=self.phase_vars[phase], + ) + checkbox.grid(row=0, column=column, sticky="w", padx=(0, 16)) + self._input_widgets.append(checkbox) + + self.advanced_button = ttk.Button( + content, text=self._t("advanced_show"), command=self._toggle_advanced + ) + self.advanced_button.pack(anchor="w", pady=(8, 2)) + self.advanced_frame = ttk.LabelFrame(content, text=self._t("advanced"), padding=9) + self.advanced_frame.columnconfigure(1, weight=1) + self.advanced_visible = False + self.advanced_frame.pack_forget() + self._build_advanced() + + progress_row = ttk.Frame(footer) + progress_row.pack(fill="x", pady=(6, 3)) + self.status_var = tk.StringVar(value="") + self.status_label = ttk.Label(progress_row, textvariable=self.status_var) + self.status_label.pack(side="left", fill="x", expand=True, anchor="w") + self.progress = ttk.Progressbar(progress_row, mode="indeterminate", length=120) + self.progress.pack(side="right") + + results = ttk.LabelFrame(content, text=self._t("results"), padding=7) + results.pack(fill="both", expand=True, pady=(4, 5)) + results.columnconfigure(0, weight=1) + results.rowconfigure(0, weight=1) + self.tree = ttk.Treeview( + results, + columns=("phase", "status", "cif"), + show="headings", + height=4, + selectmode="browse", + ) + for key, width in (("phase", 145), ("status", 160), ("cif", 270)): + self.tree.heading(key, text=self._t(f"column_{key}")) + self.tree.column(key, width=width, anchor="w") + self.tree.grid(row=0, column=0, sticky="nsew") + result_scroll = ttk.Scrollbar(results, orient="vertical", command=self.tree.yview) + result_scroll.grid(row=0, column=1, sticky="ns") + self.tree.configure(yscrollcommand=result_scroll.set) + self.tree.bind("<>", self._show_record_details) + self.details = tk.Text(results, height=3, wrap="word", state="disabled", relief="flat", background="#F4F7FA") + self.details.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0)) + + actions = ttk.Frame(footer) + actions.pack(fill="x", pady=(4, 0)) + self.run_button = ttk.Button(actions, text=self._t("run"), command=self._start, style="Accent.TButton") + self.run_button.pack(side="left") + self.load_button = ttk.Button(actions, text=self._t("load"), command=self._load_results, state="disabled") + self.load_button.pack(side="left", padx=(6, 0)) + self.report_button = ttk.Button(actions, text=self._t("open_report"), command=self._open_report, state="disabled") + self.report_button.pack(side="left", padx=(6, 0)) + self.folder_button = ttk.Button(actions, text=self._t("open_folder"), command=self._open_folder, state="disabled") + self.folder_button.pack(side="left", padx=(6, 0)) + ttk.Button(actions, text=self._t("close"), command=self._close).pack(side="right") + self._refresh_basis_label() + self._refresh_key_visibility() + + def _on_scroll_content_configure(self, _event: Any = None) -> None: + bounds = self._scroll_canvas.bbox("all") + if bounds is not None: + self._scroll_canvas.configure(scrollregion=bounds) + + def _on_scroll_canvas_configure(self, event: Any) -> None: + self._scroll_canvas.itemconfigure(self._scroll_window, width=event.width) + + def _scroll_mousewheel(self, event: Any) -> str | None: + widget = event.widget + inside_content = widget is self._scroll_canvas + while widget is not None and not inside_content: + inside_content = widget is self._scroll_content + widget = getattr(widget, "master", None) + if not inside_content: + return None + if getattr(event, "num", None) == 4: + amount = -1 + elif getattr(event, "num", None) == 5: + amount = 1 + else: + delta = int(getattr(event, "delta", 0)) + amount = -1 if delta > 0 else 1 if delta < 0 else 0 + if amount: + self._scroll_canvas.yview_scroll(amount, "units") + return "break" + + def _build_advanced(self) -> None: + ttk.Label(self.advanced_frame, text=self._t("parameter_file")).grid( + row=0, column=0, sticky="w", padx=(0, 8), pady=3 + ) + self.parameter_entry = ttk.Entry(self.advanced_frame, textvariable=self.parameter_var) + self.parameter_entry.grid(row=0, column=1, sticky="ew", pady=3) + parameter_button = ttk.Button( + self.advanced_frame, text=self._t("browse"), command=self._browse_parameter + ) + parameter_button.grid(row=0, column=2, padx=(6, 0)) + self._input_widgets.extend((self.parameter_entry, parameter_button)) + + self.offline_check = ttk.Checkbutton( + self.advanced_frame, + text=self._t("offline"), + variable=self.offline_var, + ) + self.offline_check.grid(row=1, column=0, columnspan=3, sticky="w", pady=(5, 2)) + self._input_widgets.append(self.offline_check) + ttk.Label(self.advanced_frame, text=self._t("api_key")).grid( + row=2, column=0, sticky="w", padx=(0, 8), pady=3 + ) + self.api_key_entry = ttk.Entry( + self.advanced_frame, textvariable=self.api_key_var, show="•" + ) + self.api_key_entry.grid(row=2, column=1, sticky="ew", pady=3) + self.show_key_check = ttk.Checkbutton( + self.advanced_frame, + text=self._t("show_key"), + variable=self.show_key_var, + ) + self.show_key_check.grid(row=2, column=2, sticky="w", padx=(6, 0)) + ttk.Label(self.advanced_frame, text=self._t("key_hint"), style="Hint.TLabel").grid( + row=3, column=1, columnspan=2, sticky="w", pady=(0, 4) + ) + self._input_widgets.extend((self.api_key_entry, self.show_key_check)) + + for row, phase in enumerate(PHASES, start=4): + ttk.Label( + self.advanced_frame, + text=self._t("template", phase=_PHASE_LABELS[phase][self.lang]), + ).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=3) + var = tk.StringVar(value="") + self._template_vars[phase] = var + entry = ttk.Entry(self.advanced_frame, textvariable=var) + entry.grid(row=row, column=1, sticky="ew", pady=3) + button = ttk.Button( + self.advanced_frame, + text=self._t("browse"), + command=lambda name=phase: self._browse_template(name), + ) + button.grid(row=row, column=2, padx=(6, 0)) + self._input_widgets.extend((entry, button)) + ttk.Label( + self.advanced_frame, + text=self._t("parameter_hint"), + style="Hint.TLabel", + wraplength=760, + justify="left", + ).grid(row=7, column=0, columnspan=3, sticky="w", pady=(5, 0)) + + def _refresh_basis_label(self) -> None: + basis = self.basis_var.get() + is_nominal = basis == "nominal" + self.basis_value_label.configure( + text=self._t("nominal_value") if is_nominal else self._t("percent_value") + ) + if is_nominal: + self.nominal_entry.grid() + self.percent_entry.grid_remove() + self.basis_hint.configure(text=self._t("nominal_hint")) + else: + self.nominal_entry.grid_remove() + self.percent_entry.grid() + self.basis_hint.configure( + text=self._t("weight_hint" if basis == "weight_percent" else "atomic_hint") + ) + + def _refresh_key_visibility(self) -> None: + self.api_key_entry.configure( + show="" if self.show_key_var.get() else "•", + state="disabled" if self.offline_var.get() else "normal", + ) + self.show_key_check.configure(state="disabled" if self.offline_var.get() else "normal") + + def _toggle_advanced(self) -> None: + self.advanced_visible = not self.advanced_visible + if self.advanced_visible: + self.advanced_frame.pack(fill="x", pady=(0, 3), after=self.advanced_button) + self.advanced_button.configure(text=self._t("advanced_hide")) + else: + self.advanced_frame.pack_forget() + self.advanced_button.configure(text=self._t("advanced_show")) + self.update_idletasks() + self._on_scroll_content_configure() + + def _browse_output_parent(self) -> None: + current = Path(self.output_var.get()).expanduser() + parent = filedialog.askdirectory( + parent=self, + title=self._t("choose_parent"), + initialdir=str(current.parent if current.parent.is_dir() else Path.cwd()), + ) + if parent: + suffix = current.name or "initial_cifs" + self.output_var.set(str((Path(parent) / suffix).resolve())) + + def _browse_parameter(self) -> None: + selected = filedialog.askopenfilename( + parent=self, + title=self._t("choose_parameter"), + filetypes=(("JSON", "*.json"), ("All files", "*.*")), + ) + if selected: + self.parameter_var.set(selected) + + def _browse_template(self, phase: str) -> None: + selected = filedialog.askopenfilename( + parent=self, + title=self._t("choose_template", phase=_PHASE_LABELS[phase][self.lang]), + filetypes=(("CIF", "*.cif *.CIF"), ("All files", "*.*")), + ) + if selected: + self._template_vars[phase].set(selected) + + def _form_values(self) -> dict[str, Any]: + values: dict[str, Any] = { + "lang": self.lang, + "composition": self.composition_var.get(), + "basis": self.basis_var.get(), + "nominal": self.nominal_var.get(), + "percent_text": self.percent_var.get(), + "host": self.host_var.get(), + "output_dir": self.output_var.get(), + "parameter_file": self.parameter_var.get(), + "offline": self.offline_var.get(), + "api_key": self.api_key_var.get(), + } + values.update({f"phase_{phase}": variable.get() for phase, variable in self.phase_vars.items()}) + values.update( + {f"template_{phase}": variable.get() for phase, variable in self._template_vars.items()} + ) + return values + + def _start(self) -> None: + if self._running: + return + try: + kwargs = prepare_kwargs_from_form( + self._form_values(), environment_key=os.environ.get("MP_API_KEY", "") + ) + output_dir = kwargs["output_dir"] + if output_dir.exists(): + raise ValueError( + self._t("output_exists", path=output_dir) + ) + except (OSError, ValueError) as exc: + self._set_status(str(exc), error=True) + messagebox.showerror(self._t("error_title"), str(exc), parent=self) + return + + self._result = None + self._result_paths = [] + self.tree.delete(*self.tree.get_children()) + self._write_details("") + self.load_button.configure(state="disabled") + self.report_button.configure(state="disabled") + self.folder_button.configure(state="disabled") + self._running = True + self.run_button.configure(state="disabled") + self._set_form_enabled(False) + self.progress.start(12) + self._set_status(self._t("running")) + thread = threading.Thread( + target=_worker_run, args=(kwargs, self._events), daemon=True, name="initial-cif-worker" + ) + thread.start() + self._schedule_poll() + + def _schedule_poll(self) -> None: + if not self._closed: + try: + self._after_id = self.after(100, self._poll_events) + except tk.TclError: + self._after_id = None + + def _poll_events(self) -> None: + self._after_id = None + try: + kind, payload = self._events.get_nowait() + except queue.Empty: + if self._running: + self._schedule_poll() + return + self._running = False + self.progress.stop() + self.run_button.configure(state="normal") + self._set_form_enabled(True) + if kind == "done": + self._show_result(payload) + else: + error = str(payload) + self._set_status(self._t("failed"), error=True) + self._write_details(error) + self.run_button.focus_set() + + def _show_result(self, result: Any) -> None: + self._result = result + self._result_paths = [ + Path(record.cif_path).resolve() + for record in getattr(result, "records", ()) + if getattr(record, "cif_path", None) is not None + and Path(record.cif_path).is_file() + ] + records = tuple(getattr(result, "records", ())) + for index, record in enumerate(records): + phase = str(getattr(record, "phase", "")) + status = str(getattr(record, "status", "")) + path = getattr(record, "cif_path", None) + phase_label = _PHASE_LABELS.get(phase, {}).get(self.lang, phase) + status_label = _STATUS_LABELS.get(status, {}).get(self.lang, status) + self.tree.insert( + "", + "end", + iid=str(index), + values=(phase_label, status_label, Path(path).name if path else "—"), + ) + report_path = getattr(result, "report_path", None) + output_dir = getattr(result, "output_dir", None) + exit_code = int(getattr(result, "exit_code", 2)) + if exit_code == 0: + self._set_status(self._t("complete", count=len(self._result_paths))) + elif exit_code == 3: + self._set_status(self._t("partial", count=len(self._result_paths))) + else: + self._set_status(self._t("empty"), error=True) + self.load_button.configure(state="normal" if self._result_paths else "disabled") + self.report_button.configure(state="normal" if report_path else "disabled") + self.folder_button.configure(state="normal" if output_dir else "disabled") + first = next(iter(self.tree.get_children()), None) + if first is not None: + self.tree.selection_set(first) + self.tree.focus(first) + self._show_record_details() + elif report_path: + self._write_details(str(report_path)) + + def _show_record_details(self, _event: Any = None) -> None: + selection = self.tree.selection() + if not selection or self._result is None: + return + try: + record = self._result.records[int(selection[0])] + except (AttributeError, IndexError, TypeError, ValueError): + return + detail = str(getattr(record, "note", "") or "") + path = getattr(record, "cif_path", None) + if path: + detail = f"{path}\n{detail}" if detail else str(path) + self._write_details(detail or self._t("no_details")) + + def _write_details(self, text: str) -> None: + self.details.configure(state="normal") + self.details.delete("1.0", "end") + self.details.insert("1.0", text) + self.details.configure(state="disabled") + + def _load_results(self) -> None: + if self._on_load is None or not self._result_paths: + return + # This button runs in Tk's event loop; the main-window callback is + # therefore never invoked from the worker thread. + self._on_load(list(self._result_paths)) + self._set_status(self._t("loaded", count=len(self._result_paths))) + + def _open_report(self) -> None: + path = getattr(self._result, "report_path", None) + self._open_result_path(path) + + def _open_folder(self) -> None: + path = getattr(self._result, "output_dir", None) + self._open_result_path(path) + + def _open_result_path(self, raw_path: Any) -> None: + if raw_path is None: + return + path = Path(raw_path) + if not path.exists(): + self._set_status(self._t("path_missing", path=path), error=True) + return + try: + _open_path(path) + except OSError as exc: + self._set_status(str(exc), error=True) + + def _set_status(self, text: str, *, error: bool = False) -> None: + self.status_var.set(text) + self.status_label.configure(foreground="#B42318" if error else "#203040") + + def _set_form_enabled(self, enabled: bool) -> None: + state = "normal" if enabled else "disabled" + for widget in self._input_widgets: + try: + widget.configure(state=state) + except tk.TclError: + continue + if enabled: + self._refresh_key_visibility() + + def _close(self) -> None: + if self._closed: + return + self._closed = True + if self._after_id is not None: + try: + self.after_cancel(self._after_id) + except tk.TclError: + pass + self._after_id = None + # The worker only owns a queue and an immutable request snapshot. + # It may finish writing output after this window is closed, without + # calling after(), configure(), or any other Tk method. + if self._on_close is not None: + self._on_close() + self.destroy() + +else: + + class InitialCifDialog: # pragma: no cover - import guard + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + raise RuntimeError("Tkinter is unavailable in this Python installation.") + + +_TEXT = { + "zh": { + "title": "初始 CIF 准备", + "heading": "为选定相准备可追溯的起始 CIF", + "scroll_hint": "表单和逐相结果可滚动查看;状态与主操作固定在窗口底部。", + "warning": "生成文件用于后续结构分析或精修起步。原型晶格和占位是初始假设;它们不代表实测结构、相鉴定、平衡相成分或热分析结果。", + "inputs": "合金与输出", + "composition": "成分 / 元素系统", + "composition_hint": "填写元素系统(例如 Ti-Al-V);牌号或成分数值在下方按基准填写。", + "basis": "成分基准", + "basis_nominal": "名义牌号", + "basis_weight_percent": "质量百分比", + "basis_atomic_percent": "原子百分比", + "nominal_value": "牌号", + "percent_value": "元素含量", + "nominal_hint": "TC4、Ti64 或 Ti-6Al-4V 按常用名义成分 6 wt% Al、4 wt% V 处理。", + "weight_hint": "完整示例 Ti=90,Al=6,V=4,合计 100 wt%。", + "atomic_hint": "完整示例 Ti=86,Al=10,V=4,合计 100 at%。", + "host": "基体元素(可选)", + "host_hint": "留空时按占比最高的元素选择;若最高占比并列或需指定其他基体,请手动填写。", + "output": "新建输出文件夹", + "browse_parent": "选择父文件夹…", + "phases": "目标相(可多选)", + "advanced_show": "高级选项 ▸", + "advanced_hide": "高级选项 ▾", + "advanced": "参数来源与模板", + "parameter_file": "文献参数 JSON", + "parameter_hint": "参数 JSON 由用户或代理按文献填写;程序不会搜索论文。无参数时仍可生成清楚标记的起始模型。相模板只用于对应已勾选的相。", + "offline": "离线:只用随包原型与本地模板,不访问 Materials Project", + "api_key": "Materials Project API key", + "show_key": "显示", + "key_hint": "留空时读取进程环境变量 MP_API_KEY;密钥不会保存到文件。", + "template": "{phase} 模板 CIF(可选)", + "browse": "浏览…", + "results": "逐相结果", + "column_phase": "目标相", + "column_status": "来源 / 状态", + "column_cif": "生成的 CIF", + "run": "准备 CIF", + "load": "将结果载入分析", + "open_report": "查看报告", + "open_folder": "打开输出文件夹", + "close": "关闭", + "ready": "填写参数后开始。", + "running": "正在准备起始 CIF…完成后会逐相显示结果。", + "complete": "完成:已准备 {count} 个 CIF。", + "partial": "部分完成:已准备 {count} 个 CIF;查看各相状态和报告。", + "empty": "没有可用 CIF;请查看逐相状态和报告。", + "failed": "准备失败;请查看下方错误信息。", + "loaded": "已将 {count} 个 CIF 加入主窗口的本地分析列表。", + "no_details": "该相没有附加说明。", + "path_missing": "结果路径不存在:{path}", + "output_exists": "输出目标已存在。为避免混入旧结果,请选择一个新的文件夹:\n{path}", + "error_title": "无法开始", + "choose_parent": "选择新输出文件夹的父目录", + "choose_parameter": "选择文献参数 JSON", + "choose_template": "选择 {phase} 模板 CIF", + }, + "en": { + "title": "Initial CIF preparation", + "heading": "Prepare traceable starting CIFs for selected phases", + "scroll_hint": "The form and phase results scroll; status and main actions stay at the bottom.", + "warning": "These files are starting points for later structural analysis or refinement. Prototype lattices and occupancies are initial assumptions; they do not represent measured structures, phase identification, equilibrium phase chemistry, or thermal analysis.", + "inputs": "Alloy and output", + "composition": "Composition / element system", + "composition_hint": "Enter the element system (for example Ti-Al-V); enter the grade or amounts below using the selected basis.", + "basis": "Composition basis", + "basis_nominal": "Nominal grade", + "basis_weight_percent": "Weight percent", + "basis_atomic_percent": "Atomic percent", + "nominal_value": "Grade", + "percent_value": "Element amounts", + "nominal_hint": "TC4, Ti64, and Ti-6Al-4V use the common nominal composition 6 wt% Al and 4 wt% V.", + "weight_hint": "Complete example: Ti=90,Al=6,V=4; total = 100 wt%.", + "atomic_hint": "Complete example: Ti=86,Al=10,V=4; total = 100 at%.", + "host": "Host element (optional)", + "host_hint": "When blank, the workflow chooses the element with the largest fraction; specify a host for ties or a different parent lattice.", + "output": "New output folder", + "browse_parent": "Choose parent…", + "phases": "Target phases (select any)", + "advanced_show": "Advanced options ▸", + "advanced_hide": "Advanced options ▾", + "advanced": "Parameter sources and templates", + "parameter_file": "Literature parameter JSON", + "parameter_hint": "The user or agent supplies literature-backed values in JSON; the program does not search papers. Starting models remain clearly marked when no parameters are supplied. Templates apply only to checked phases.", + "offline": "Offline: use packaged prototypes and local templates; do not query Materials Project", + "api_key": "Materials Project API key", + "show_key": "Show", + "key_hint": "When blank, read MP_API_KEY from the process environment. The key is never saved to a file.", + "template": "{phase} template CIF (optional)", + "browse": "Browse…", + "results": "Per-phase results", + "column_phase": "Target phase", + "column_status": "Source / status", + "column_cif": "Output CIF", + "run": "Prepare CIFs", + "load": "Load results for analysis", + "open_report": "View report", + "open_folder": "Open output folder", + "close": "Close", + "ready": "Enter the inputs and start.", + "running": "Preparing starting CIFs… per-phase results will appear when complete.", + "complete": "Complete: prepared {count} CIF files.", + "partial": "Partial: prepared {count} CIF files; inspect phase status and report.", + "empty": "No CIF files are available; inspect phase status and report.", + "failed": "Preparation failed; see the error details below.", + "loaded": "Added {count} CIF files to the main window's local analysis list.", + "no_details": "No additional details for this phase.", + "path_missing": "Result path does not exist: {path}", + "output_exists": "The output target already exists. Choose a new folder to keep results unambiguous:\n{path}", + "error_title": "Could not start", + "choose_parent": "Choose the parent folder for a new output folder", + "choose_parameter": "Choose literature parameter JSON", + "choose_template": "Choose {phase} template CIF", + }, +} diff --git a/src/diffractscout/initial_cifs.py b/src/diffractscout/initial_cifs.py new file mode 100644 index 0000000..0dbf919 --- /dev/null +++ b/src/diffractscout/initial_cifs.py @@ -0,0 +1,777 @@ +"""Prepare traceable, validated initial CIFs for subsequent refinement. + +Literature searches remain outside the calculation package. Caller-supplied +phase parameters take precedence over bulk composition and prototype lattice +values, with each assumption retained in the delivery report. +""" + +from __future__ import annotations + +import csv +import json +import os +import re +import tempfile +from dataclasses import dataclass +from importlib.resources import files +from pathlib import Path +from typing import Any, Mapping, Sequence + +from .cif_quality import INTERSTITIALS, normalize_prototype, validate_phase_model, write_clean_model +from .composition import chemsys_subsystems, normalize_element, parse_composition_text +from .diffraction import simulate_powder_pattern, validate_analysis_settings +from .models import AnalysisSettings, CandidateRecord +from .phase_cif import ( + adapt_cif, + canonical_phase, + canonicalize_phases, + parse_template_assignments, + rank_prototypes, + resolve_occupancies, +) +from .structure import structure_mass_metadata +from .utils import package_versions, sha256_file, to_jsonable, utc_now_iso, write_json +from .validation import verify_bundle + +_PARAMETER_SCHEMA = "diffractscout_phase_parameters_v1" +_INDEPENDENT_AXES = {"alpha": ("a", "c"), "beta": ("a",), "alpha-double-prime": ("a", "b", "c")} +_PACKAGED = { + "alpha": ( + "cod_1522498_ti_nb_hcp.cif", + "COD 1522498", + "McHargue et al. (1953); Ti–2.6 at% Nb hcp scaffold.", + ), + "beta": ( + "cod_9008554_beta_ti.cif", + "COD 9008554 / AMCSD 0011232", + "Wyckoff (1963); elemental beta Ti at 1173 K. Source temperature is not the target condition.", + ), + "alpha-double-prime": ( + "cod_1523304_ti_nb_cmcm.cif", + "COD 1523304", + "Brown et al. (1964); Ti–20 at% Nb Cmcm scaffold; internal y=0.20 is inherited.", + ), +} +_BULK_WARNING = "Bulk/nominal composition is a uniform substitutional starting assumption, not the measured composition of this phase. Equilibrium partitioning is not inferred." + + +@dataclass(frozen=True) +class InitialCifRecord: + phase: str + status: str + cif_path: Path | None + note: str + + +@dataclass(frozen=True) +class PrepareCifsResult: + output_dir: Path + report_path: Path + index_path: Path + manifest_path: Path + records: tuple[InitialCifRecord, ...] + + @property + def exit_code(self) -> int: + usable = sum(record.cif_path is not None for record in self.records) + return 0 if usable == len(self.records) and usable else 3 if usable else 2 + + +def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + output = {} + for key, value in pairs: + if key in output: + raise ValueError(f"Duplicate parameter key {key!r}.") + output[key] = value + return output + + +def read_phase_parameters(path: str | Path) -> dict[str, dict]: + """Read strict JSON, rejecting unknown/duplicate keys before output creation.""" + payload = json.loads( + Path(path).read_text(encoding="utf-8-sig"), object_pairs_hook=_no_duplicate_keys + ) + if not isinstance(payload, dict) or payload.get("schema") != _PARAMETER_SCHEMA: + raise ValueError(f"Phase parameter file must use schema {_PARAMETER_SCHEMA!r}.") + if set(payload) - {"schema", "phases"}: + raise ValueError("Unknown top-level phase parameter fields.") + return _validate_phase_parameters(payload.get("phases")) + + +def _validate_phase_parameters(payload: Any) -> dict[str, dict]: + if not isinstance(payload, Mapping): + raise ValueError("Phase parameters 'phases' must be an object.") + output = {} + allowed = { + "lattice", + "fract", + "citation", + "conditions", + "nominal", + "weight_percent", + "atomic_percent", + } + for raw_phase, raw in payload.items(): + phase = canonical_phase(str(raw_phase)) + if phase in output: + raise ValueError(f"Repeated phase {phase} through aliases.") + if not isinstance(raw, Mapping) or set(raw) - allowed: + raise ValueError(f"Unknown or invalid parameter fields for {phase}.") + parameters = dict(raw) + for field in ("citation", "conditions", "nominal", "weight_percent", "atomic_percent"): + if field in parameters and not isinstance(parameters[field], str): + raise ValueError(f"{phase}.{field} must be text.") + for field, keys in ( + ("lattice", {"a", "b", "c", "alpha", "beta", "gamma"}), + ("fract", {"x", "y", "z"}), + ): + values = parameters.get(field, {}) + if not isinstance(values, Mapping) or set(values) - keys: + raise ValueError(f"Unknown {field} axes for {phase}.") + if any( + isinstance(value, bool) or not isinstance(value, (int, float)) + for value in values.values() + ): + raise ValueError(f"{phase}.{field} must contain numeric values.") + # Reuse adapt's boundary checks without writing an output. + from .phase_cif import _validate_requested_cell, _validate_requested_coordinates + + (_validate_requested_cell if field == "lattice" else _validate_requested_coordinates)( + values + ) + if (parameters.get("lattice") or parameters.get("fract")) and not parameters.get( + "citation", "" + ).strip(): + raise ValueError(f"{phase} lattice/coordinate parameters require a citation.") + chemistry = _chemistry_options(parameters) + if any(chemistry.values()): + resolve_occupancies(**chemistry) + output[phase] = parameters + return output + + +def _chemistry_options(values: Mapping[str, Any]) -> dict[str, str]: + return { + key: str(values.get(key, "")) for key in ("nominal", "weight_percent", "atomic_percent") + } + + +def _resolve_request( + composition: str, chemistry: dict[str, str], host: str, parameters: dict +) -> tuple[tuple[str, ...], str, dict[str, str]]: + elements = parse_composition_text(composition).elements + if not elements: + raise ValueError("prepare-cifs requires a chemical system or a known alloy grade.") + if not any(chemistry.values()): + alias = re.sub(r"[\s–—−-]+", "", composition.lower()) + if alias in {"tc4", "ti64", "ti6al4v"}: + chemistry["nominal"] = "tc4" + elif len(elements) == 1: + chemistry["atomic_percent"] = f"{elements[0]}=100" + occupancies = {} + if any(chemistry.values()): + _basis, _note, occupancies = resolve_occupancies(**chemistry) + if set(occupancies) & INTERSTITIALS: + raise ValueError( + "Initial CIF composition describes the substitutional metal lattice; interstitials require an explicit site model." + ) + if set(occupancies) != set(elements): + raise ValueError("Bulk composition elements must match the requested chemical system.") + if host: + host = normalize_element(host) or "" + if host not in elements: + raise ValueError("Host must be an element in the requested chemical system.") + elif occupancies: + largest = max(occupancies.values()) + dominant = [element for element, value in occupancies.items() if value == largest] + if len(dominant) != 1: + raise ValueError( + "Equal major atomic fractions: specify --host to choose the parent lattice." + ) + host = dominant[0] + else: + raise ValueError( + "Provide --nominal, --wt or --at and, if needed, --host. Element names alone do not specify alloy percentages." + ) + for phase, values in parameters.items(): + overrides = _chemistry_options(values) + if any(overrides.values()): + _basis, _note, fractions = resolve_occupancies(**overrides) + if set(fractions) & INTERSTITIALS: + raise ValueError( + f"{phase} interstitial composition requires an explicit site model." + ) + if not set(fractions) <= set(elements) or host not in fractions: + raise ValueError( + f"{phase} composition must contain host {host} and only requested elements." + ) + return elements, host, chemistry + + +def _query_candidates( + provider: Any, + elements: tuple[str, ...], + host: str, + phases: tuple[str, ...], + max_subsystems: int, +) -> tuple[list[CandidateRecord], list[dict]]: + if isinstance(max_subsystems, bool) or max_subsystems <= 0: + raise ValueError("max_subsystems must be a positive integer.") + # Check the count before constructing the exponentially growing list. + if (1 << len(elements)) - 1 > max_subsystems: + raise ValueError(f"Chemical system exceeds --max-subsystems {max_subsystems}.") + found: dict[str, CandidateRecord] = {} + queries = [] + systems = [host, *[system for system in chemsys_subsystems(elements) if system != host]] + for system in systems: + if system != host and all( + rank_prototypes(list(found.values()), phase=phase, elements=elements, host=host) + for phase in phases + ): + break + candidates = provider.search_subsystem( + system, e_hull_max_eV_atom=None, exclude_deprecated=True + ) + queries.append({"chemsys": system, "candidate_count": len(candidates)}) + for candidate in candidates: + found.setdefault(candidate.material_id, candidate) + return list(found.values()), queries + + +def _acquire_prototype( + phase: str, + staging: Path, + *, + template: Path | None, + candidates: list[CandidateRecord], + provider: Any, + elements: tuple[str, ...], + host: str, + max_prototype_attempts: int, +) -> tuple[Path, dict]: + raw_path = staging / "sources" / f"{phase}.cif" + model_path = staging / "prototypes" / f"{phase}.cif" + attempts: list[dict] = [] + options: list[tuple[str, Any]] = [] + shortlist = ( + rank_prototypes(candidates, phase=phase, elements=elements, host=host) + if template is None + else [] + ) + if template is not None: + options.append(("template", template)) + else: + options.extend( + ("Materials Project", candidate) for candidate in shortlist[:max_prototype_attempts] + ) + if host == "Ti": + options.append(("packaged_scaffold", _PACKAGED[phase])) + for origin, option in options: + try: + identity = {} + if origin == "template": + raw_path.write_bytes(option.read_bytes()) + identity = { + "source": "caller_template", + "source_filename": option.name, + "note": "Caller-supplied prototype; target lattice and site preference are not inferred.", + } + elif origin == "packaged_scaffold": + filename, source, note = option + raw_path.write_bytes( + files("diffractscout").joinpath("prototype_data", filename).read_bytes() + ) + identity = {"source": source, "note": note} + else: + with tempfile.TemporaryDirectory(prefix="mp-prototype-") as download_dir: + artifacts = provider.download_candidates( + [option], + Path(download_dir), + conventional_unit_cell=True, + include_elasticity=False, + ) + artifact = next( + ( + item + for item in artifacts + if item.candidate.material_id == option.material_id + and item.status == "ok" + and item.cif_path is not None + ), + None, + ) + if artifact is None: + raise ValueError("Provider did not return a usable CIF for this candidate.") + raw_path.write_bytes(Path(artifact.cif_path).read_bytes()) + identity = { + "source": "Materials Project", + "candidate": to_jsonable(option), + "note": "DFT/database parent prototype; its lattice is not a target-alloy measurement.", + } + audit = normalize_prototype(raw_path, model_path, phase, host=host) + return model_path, { + **identity, + **audit, + "source_cif": raw_path.relative_to(staging).as_posix(), + "source_sha256": sha256_file(raw_path), + "prototype_cif": model_path.relative_to(staging).as_posix(), + "prototype_sha256": sha256_file(model_path), + "selection_attempts": attempts, + "shortlisted_candidate_count": len(shortlist), + "attempted_database_candidates": sum( + item["source"] == "Materials Project" for item in attempts + ) + + int(origin == "Materials Project"), + "untried_candidate_count": len(shortlist) + - sum(item["source"] == "Materials Project" for item in attempts) + - int(origin == "Materials Project"), + "prototype_attempt_limit": max_prototype_attempts, + "selection_rule": "Caller template first; otherwise elemental host, energy and id shortlist, then atom-level symmetry and single-orbit validation; Ti scaffold fallback.", + } + except Exception as exc: + attempts.append( + { + "source": origin, + "material_id": getattr(option, "material_id", ""), + "status": "rejected", + "reason": str(exc), + } + ) + raw_path.unlink(missing_ok=True) + model_path.unlink(missing_ok=True) + raise ValueError( + f"No validated {phase} prototype for host {host}. Untried database candidates: {max(0, len(shortlist) - max_prototype_attempts)}. Attempts: {attempts or 'no eligible candidate or local template'}" + ) + + +def _redact_text(value: Any, key: str | Sequence[str]) -> Any: + if isinstance(value, str): + for secret in [key] if isinstance(key, str) else key: + if secret: + value = value.replace(secret, "[redacted]") + return value + if isinstance(value, dict): + return {name: _redact_text(item, key) for name, item in value.items()} + if isinstance(value, (list, tuple)): + return [_redact_text(item, key) for item in value] + return value + + +def _write_csv(path: Path, fields: Sequence[str], rows: list[dict]) -> None: + from .exporters import _safe_spreadsheet_text + + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter( + handle, fieldnames=fields, lineterminator="\n", extrasaction="ignore" + ) + writer.writeheader() + writer.writerows( + { + key: _safe_spreadsheet_text(value) + if isinstance(value := row.get(key, ""), str) + else value + for key in fields + } + for row in rows + ) + + +def _write_report( + staging: Path, composition: str, host: str, records: list[dict], wavelength: float +) -> None: + lines = [ + "# 初始 CIF 使用说明 / Initial CIF guide", + "", + f"体系:{composition};母相宿主:{host}。", + "", + "`initial/` 中的 CIF 已通过读取、空间群、单一金属轨道及占位检查,可作为后续拟合的初始模型。", + "模型的可读取性不证明相存在,也不证明晶格、相成分或内坐标符合当前样品。", + "", + "| 相 | 状态 | 晶格基准 | CIF |", + "|---|---|---|---|", + ] + for record in records: + lines.append( + f"| {record['phase']} | {record['status']} | {record.get('lattice_basis', '—')} | {record.get('cif', '—')} |" + ) + lines.extend(["", "## 逐相依据与待调整参数", ""]) + for record in records: + lines.extend([f"### {record['phase']}", "", record["note"], ""]) + if record.get("cif"): + lines.append(f"- 来源:{record['prototype']['source']}。{record['prototype']['note']}") + lines.append(f"- 晶胞 a,b,c,α,β,γ:{record['cell_parameters']}(Å、°)。") + lines.append( + f"- 实际写入的原子分数:{record['occupancies']};成分语义:{record['composition_role']}。" + ) + lines.append(f"- 引用:{record['citation'] or '未提供目标相晶格引用;保留原型晶格。'}") + lines.append(f"- 条件:{record['conditions'] or '调用者未提供测量/处理条件。'}") + lines.append(f"- 建议拟合晶格参数:{', '.join(_INDEPENDENT_AXES[record['phase']])}。") + if record["phase"] == "alpha-double-prime": + lines.append( + "- Cmcm 的 y 控制原子位移和强度,未经该样品文献或拟合确认的 y 仍是起始假设;不能由晶格常数推断。" + ) + for warning in record["warnings"]: + lines.append(f"- 提醒:{warning}") + lines.append("") + lines.extend( + [ + "## 文件与后续使用", + "", + "- `initial/`:优先载入这些派生 CIF;每份 `.adapt.json` 记录成分、晶格变更和 SHA-256。", + "- `sources/`:原始来源 CIF;`prototypes/`:通过检查的原型。请保持这些来源文件不变。", + "- `preparation.json`:逐相检查、选择依据、被拒候选、相成分假设及软件版本。", + f"- `peak_preview.csv`:λ={wavelength:.8g} Å、2θ=5–120° 的理论峰表,q=2π/d;强度按每相内部归一到100。", + "- 可用 `diffractscout analyze <本包>/initial -o <新的分析目录>` 进行完整峰表导出。", + "- 可用 `diffractscout verify <本包>` 检查文件完整性。验证通过不等于实验或精修验收。", + "", + "若当前保留原型晶格,请在参数文件中填入目标合金、目标相和适用条件下的晶格及引用,再生成新目录。", + "软件不会自动查阅论文、预测相分配或宣称该三相在样品中同时存在。", + "", + ] + ) + (staging / "report.md").write_text("\n".join(lines), encoding="utf-8") + + +def prepare_cifs( + composition: str, + output_dir: str | Path, + *, + nominal: str = "", + weight_percent: str = "", + atomic_percent: str = "", + phases: Sequence[str] | None = None, + host: str = "", + templates: Mapping[str, str | Path] | None = None, + template_args: Sequence[str] | None = None, + parameter_file: str | Path | None = None, + phase_parameters: Mapping[str, Any] | None = None, + offline: bool = False, + api_key: str | None = None, + provider: Any = None, + max_subsystems: int = 64, + max_prototype_attempts: int = 8, + preview_wavelength_A: float = 1.5406, +) -> PrepareCifsResult: + """Deliver explicit-symmetry alloy starting models in a new verified directory. + + Known Ti-6Al-4V aliases automatically use the nominal grade *in this + preparation command only*. Global composition is a bulk assumption; + explicit per-phase compositions override it and are recorded separately. + No lattice values are predicted or extracted from literature. + """ + selected = canonicalize_phases(phases) + if parameter_file is not None and phase_parameters is not None: + raise ValueError("Use parameter_file or phase_parameters, not both.") + parameters = ( + read_phase_parameters(parameter_file) + if parameter_file is not None + else _validate_phase_parameters(phase_parameters or {}) + ) + if set(parameters) - set(selected): + raise ValueError( + "Parameter file includes unselected phases; select them or remove their parameters." + ) + template_map = parse_template_assignments(template_args) + for name, path in (templates or {}).items(): + phase = canonical_phase(name) + if phase in template_map: + raise ValueError(f"Duplicate template for {phase}.") + template_map[phase] = Path(path).expanduser().resolve() + if set(template_map) - set(selected): + raise ValueError("Templates include unselected phases.") + for path in template_map.values(): + if not path.is_file(): + raise FileNotFoundError(f"Template CIF not found: {path}") + chemistry = { + "nominal": nominal, + "weight_percent": weight_percent, + "atomic_percent": atomic_percent, + } + elements, host, chemistry = _resolve_request(composition, chemistry, host, parameters) + if ( + isinstance(max_subsystems, bool) + or not isinstance(max_subsystems, int) + or max_subsystems <= 0 + ): + raise ValueError("max_subsystems must be a positive integer.") + if ( + isinstance(max_prototype_attempts, bool) + or not isinstance(max_prototype_attempts, int) + or max_prototype_attempts <= 0 + ): + raise ValueError("max_prototype_attempts must be a positive integer.") + preview_settings = AnalysisSettings( + input_mode="wavelength", + wavelength_A=preview_wavelength_A, + include_elasticity=False, + include_patterns=False, + ) + validate_analysis_settings(preview_settings) + from .pipeline import _reject_reparse_components, _rename_directory_noreplace + + target = Path(os.path.abspath(os.fspath(Path(output_dir).expanduser()))) + _reject_reparse_components(target, label="initial CIF output") + if target.exists(): + raise FileExistsError(f"Refusing to overwrite {target}; choose a new output directory.") + target.parent.mkdir(parents=True, exist_ok=True) + key = (api_key or "").strip() + redaction_keys = [key] + if not offline and provider is not None: + provider_key = getattr(provider, "api_key", "") + if isinstance(provider_key, str) and provider_key.strip(): + redaction_keys.append(provider_key.strip()) + candidates: list[CandidateRecord] = [] + query_records = [] + query_warnings = [] + query_status = "offline" if offline else "not_requested" + missing = tuple(phase for phase in selected if phase not in template_map) + if ( + missing + and not offline + and (provider is not None or key) + and (1 << len(elements)) - 1 > max_subsystems + ): + raise ValueError(f"Chemical system exceeds --max-subsystems {max_subsystems}.") + if missing and not offline and (provider is not None or key): + try: + if provider is None: + from .providers.materials_project import MaterialsProjectProvider + + provider = MaterialsProjectProvider(key) + candidates, query_records = _query_candidates( + provider, elements, host, missing, max_subsystems + ) + query_status = "queried" + except Exception as exc: + query_status = "failed" + query_warnings.append(str(exc).replace(key, "[redacted]") if key else str(exc)) + elif missing and not offline: + query_status = "no_api_key" + provider_metadata = {} + if not offline and provider is not None and callable(getattr(provider, "metadata", None)): + try: + metadata = provider.metadata() + provider_metadata = { + field: metadata.get(field) + for field in ("provider", "client", "queried_at_utc", "database_version") + } + except Exception as exc: + query_warnings.append("Provider metadata unavailable: " + str(exc)) + with tempfile.TemporaryDirectory( + prefix=".diffractscout-initial-", dir=target.parent + ) as temporary: + staging = Path(temporary) / "bundle" + for folder in ("sources", "prototypes", "initial"): + (staging / folder).mkdir(parents=True, exist_ok=True) + records: list[dict] = [] + peaks: list[dict] = [] + for phase in selected: + values = parameters.get(phase, {}) + destination = staging / "initial" / f"{phase}.cif" + try: + prototype, provenance = _acquire_prototype( + phase, + staging, + template=template_map.get(phase), + candidates=candidates, + provider=provider, + elements=elements, + host=host, + max_prototype_attempts=max_prototype_attempts, + ) + provenance = _redact_text(provenance, redaction_keys) + phase_chemistry = _chemistry_options(values) + role = ( + "caller_supplied_phase" + if any(phase_chemistry.values()) + else "bulk_starting_assumption" + ) + if role == "bulk_starting_assumption": + phase_chemistry = chemistry + adapted = adapt_cif( + prototype, + destination, + **phase_chemistry, + **values.get("lattice", {}), + fract=values.get("fract"), + citation=values.get("citation", ""), + ) + loaded = validate_phase_model(destination, phase, host=host) + sidecar = json.loads(adapted.sidecar_path.read_text(encoding="utf-8")) + source_axes = set(sidecar["lattice_unchanged"]) & set(_INDEPENDENT_AXES[phase]) + lattice_basis = ( + "caller_cited_lattice" + if not source_axes + else "prototype_lattice" + if not sidecar["lattice_edits"] + else "mixed_cited_and_prototype_lattice" + ) + warnings = list(query_warnings) + if ( + provenance["untried_candidate_count"] + and provenance["source"] != "Materials Project" + ): + warnings.append( + f"{provenance['untried_candidate_count']} ranked database candidates were not attempted; increase --max-prototype-attempts or provide a template to inspect alternatives." + ) + if role == "bulk_starting_assumption": + warnings.append(_BULK_WARNING) + if source_axes: + warnings.append( + "Prototype lattice retained on independent axes " + + ", ".join(sorted(source_axes)) + + "; adjust these for the target sample." + ) + if phase == "alpha-double-prime" and "y" not in values.get("fract", {}): + warnings.append( + "Cmcm y is inherited from the prototype and is not a target-alloy measurement." + ) + warnings.append( + "Atomic displacement parameters are prototype starting values (zero if absent), not measured target-alloy thermal parameters." + ) + if values.get("citation") and not values.get("conditions"): + warnings.append( + "Citation supplied without measurement/processing conditions; applicability requires review." + ) + details = f"Starting model for {composition}, phase family {phase}. Composition role: {role}. Lattice basis: {lattice_basis}. Source: {provenance['source']}. Citation: {values.get('citation', '') or 'none'}. Conditions: {values.get('conditions', '') or 'unspecified'}. Original source and hashes are recorded in preparation.json. Not an experimentally refined target structure." + write_clean_model( + loaded.small_structure, + destination, + details=_redact_text(details, redaction_keys), + ) + loaded = validate_phase_model(destination, phase, host=host) + sidecar.update( + { + "source_cif": provenance["prototype_cif"], + "output_cif": destination.relative_to(staging).as_posix(), + "output_sha256": sha256_file(destination), + "composition_role": role, + "lattice_basis": lattice_basis, + "prototype": provenance, + } + ) + write_json(adapted.sidecar_path, _redact_text(sidecar, redaction_keys)) + analysis = simulate_powder_pattern(loaded, preview_settings) + for reflection in analysis.reflections: + peaks.append( + { + "phase": phase, + "h": reflection.h, + "k": reflection.k, + "l": reflection.l, + "d_A": reflection.d_spacing_A, + "q_invA": reflection.q_invA, + "two_theta_deg": reflection.two_theta_deg, + "relative_intensity": reflection.normalized_intensity, + } + ) + records.append( + { + "phase": phase, + "status": "ready", + "cif": destination.relative_to(staging).as_posix(), + "sha256": sha256_file(destination), + "lattice_basis": lattice_basis, + "composition_basis": adapted.composition_basis, + "composition_role": role, + "occupancies": adapted.occupancies, + "citation": values.get("citation", ""), + "conditions": values.get("conditions", ""), + "cell_parameters": loaded.cell_parameters, + "symmetry_crosscheck": "match", + "space_group": loaded.space_group_number, + "prototype": provenance, + "lattice_edits": sidecar["lattice_edits"], + "inherited_independent_axes": sorted(source_axes), + "coordinate_edits": sidecar["coordinate_edits"], + "warnings": warnings, + "note": "Validated initial model; review sample-specific lattice, chemistry and internal coordinates before refinement.", + "preview_peak_count": len(analysis.reflections), + **structure_mass_metadata(loaded), + } + ) + except (ValueError, RuntimeError, OSError) as exc: + destination.unlink(missing_ok=True) + destination.with_suffix(".adapt.json").unlink(missing_ok=True) + records.append( + { + "phase": phase, + "status": "failed", + "note": str(exc).replace(key, "[redacted]") if key else str(exc), + } + ) + records = _redact_text(records, redaction_keys) + payload = { + "schema": "diffractscout_initial_cifs_v1", + "generated_at_utc": utc_now_iso(), + "composition": composition, + "host": host, + "elements": elements, + "bulk_composition": chemistry, + "phase_parameters": parameters, + "query_status": query_status, + "queries": query_records, + "max_prototype_attempts": max_prototype_attempts, + "provider_metadata": provider_metadata, + "preview": { + "wavelength_A": preview_wavelength_A, + "two_theta_range_deg": [5, 120], + "q_definition": "2*pi/d", + "intensity_normalization": "maximum 100 within each phase", + }, + "not_phase_identification": True, + "not_experimental_refinement": True, + "software_versions": package_versions(), + "records": records, + } + write_json(staging / "preparation.json", _redact_text(payload, redaction_keys)) + _write_csv( + staging / "initial_cifs.csv", + ("phase", "status", "cif", "space_group", "lattice_basis", "composition_role", "note"), + records, + ) + _write_csv( + staging / "peak_preview.csv", + ("phase", "h", "k", "l", "d_A", "q_invA", "two_theta_deg", "relative_intensity"), + peaks, + ) + _write_report(staging, composition, host, records, preview_wavelength_A) + members = [ + { + "path": path.relative_to(staging).as_posix(), + "sha256": sha256_file(path), + "size_bytes": path.stat().st_size, + } + for path in sorted(staging.rglob("*")) + if path.is_file() + ] + write_json( + staging / "manifest.json", + { + "schema": "diffractscout_bundle_manifest_v1", + "generated_at_utc": utc_now_iso(), + "files": members, + }, + ) + verification = verify_bundle(staging) + if not verification["ok"]: + raise RuntimeError( + "Initial CIF bundle integrity check failed: " + "; ".join(verification["errors"]) + ) + _reject_reparse_components(target, label="initial CIF output") + _rename_directory_noreplace(staging, target) + return PrepareCifsResult( + target, + target / "report.md", + target / "initial_cifs.csv", + target / "manifest.json", + tuple( + InitialCifRecord( + record["phase"], + record["status"], + target / record["cif"] if record.get("cif") else None, + record["note"], + ) + for record in records + ), + ) diff --git a/src/diffractscout/phase_cif.py b/src/diffractscout/phase_cif.py new file mode 100644 index 0000000..ffa31d3 --- /dev/null +++ b/src/diffractscout/phase_cif.py @@ -0,0 +1,1436 @@ +"""Symmetry-prototype download and caller-supplied CIF composition edits. + +``fetch_prototypes`` chooses alpha, beta, or alpha-double-prime symmetry +prototypes. ``adapt_cif`` writes a new CIF from caller-supplied composition +and cited lattice or coordinate values. Neither function reads literature, +and ``analyze``, ``discover``, and ``run`` do not call them. +""" + +from __future__ import annotations + +import csv +import math +import os +import re +import tempfile +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from importlib.resources import files +from pathlib import Path +from typing import Any, Mapping, Sequence + +import gemmi + +from .composition import ( + chemsys_subsystems, + formula_elements, + normalize_element, + parse_composition_text, +) +from .models import CandidateRecord +from .structure import load_structure, select_structure_block, unit_cell_formula_weight_g_mol +from .structure_types import infer_structure_type +from .utils import sha256_file + +_SCAFFOLD_NAME = "cod_1523304_ti_nb_cmcm.cif" +_SCAFFOLD_SOURCE = "COD 1523304" +_SCAFFOLD_NOTE = ( + "Ti-20 at% Nb symmetry scaffold from COD 1523304. " + "The lattice and Nb occupancy are not the target alloy." +) +_PARTIAL_OCCUPANCY_WARNING = ( + "Partial occupancies are included in the kinematic structure-factor calculation" +) +_ADAPT_COMMENT = ( + "# DiffractScout adapt: publication and database fields already in this file " + "describe the source scaffold, not the edited composition or lattice." +) +_OCCUPANCY_QUANTUM = Decimal("0.00001") +_PERCENT_TOLERANCE = Decimal("0.05") +_SITE_OCCUPANCY_TOLERANCE = Decimal("0.02") +_INTERSTITIALS = frozenset({"H", "B", "C", "N", "O", "F", "P", "S", "Cl", "Br", "I"}) +_CELL_TAGS = { + "a": "_cell_length_a", + "b": "_cell_length_b", + "c": "_cell_length_c", + "alpha": "_cell_angle_alpha", + "beta": "_cell_angle_beta", + "gamma": "_cell_angle_gamma", +} +_LENGTH_KEYS = ("a", "b", "c") +_ANGLE_KEYS = ("alpha", "beta", "gamma") +_FRACT_KEYS = ("x", "y", "z") +_FORMULA_TAGS = ( + "_chemical_formula_sum", + "_chemical_formula_structural", + "_chemical_name_systematic", +) +_DENSITY_TAGS = ( + "_exptl_crystal_density_diffrn", + "_exptl_crystal_density_meas", +) +_INDEX_FIELDS = ( + "phase", + "status", + "material_id", + "source", + "space_group", + "declared_symmetry", + "formula", + "target_composition", + "cif", + "note", +) +_TC4_WEIGHTS = ( + ("Ti", Decimal("90")), + ("Al", Decimal("6")), + ("V", Decimal("4")), +) +_NOMINAL_GRADES = { + "tc4": _TC4_WEIGHTS, + "ti64": _TC4_WEIGHTS, + "ti-6al-4v": _TC4_WEIGHTS, + "ti6al4v": _TC4_WEIGHTS, +} +_NOMINAL_NOTE = ( + "Conventional Ti-6Al-4V grade: 6 wt% Al, 4 wt% V, balance Ti. " + "Nominal grade, not a heat analysis and not an equilibrium phase composition." +) +_PHASE_ALIASES = { + "alpha": "alpha", + "α": "alpha", + "beta": "beta", + "β": "beta", + "alpha-double-prime": "alpha-double-prime", + "alpha''": "alpha-double-prime", + "a''": "alpha-double-prime", + "α''": "alpha-double-prime", +} + + +@dataclass(frozen=True) +class _PhaseRule: + number: int + filename: str + + +PHASE_RULES: dict[str, _PhaseRule] = { + "alpha": _PhaseRule(194, "alpha.cif"), + "beta": _PhaseRule(229, "beta.cif"), + "alpha-double-prime": _PhaseRule(63, "alpha-double-prime.cif"), +} +DEFAULT_PHASES = ("alpha", "beta", "alpha-double-prime") + + +@dataclass(frozen=True) +class PrototypeRecord: + phase: str + status: str + material_id: str = "" + source: str = "" + space_group: str = "" + declared_symmetry: str = "" + formula: str = "" + target_composition: bool = False + cif_path: Path | None = None + note: str = "" + + +@dataclass(frozen=True) +class FetchPrototypesResult: + output_dir: Path + index_path: Path + host: str + elements: tuple[str, ...] + records: tuple[PrototypeRecord, ...] + + @property + def exit_code(self) -> int: + if any(record.status == "missing" for record in self.records): + return 3 + if self.records and all(record.cif_path is not None for record in self.records): + return 0 + return 2 + + +@dataclass(frozen=True) +class AdaptResult: + cif_path: Path + sidecar_path: Path + composition_basis: str + occupancies: dict[str, str] + citation: str + symmetry_crosscheck: str + space_group_symbol: str + space_group_number: int | None + warnings: tuple[str, ...] + + +@dataclass +class _SiteGroup: + kind: str + rows: list[dict[str, str]] + elements: tuple[str, ...] + fract: dict[str, str] + values: dict[str, float] + + +def canonical_phase(name: str) -> str: + """Return the canonical phase id, or raise ``ValueError``.""" + + compact = re.sub(r"[\s_]+", "-", name.strip().lower()) + compact = compact.replace("′", "'").replace("’", "'").replace("″", "''") + phase = _PHASE_ALIASES.get(compact) + if phase is None: + raise ValueError(f"Unknown phase {name!r}. Use alpha, beta, or alpha-double-prime.") + return phase + + +def canonicalize_phases(names: Sequence[str] | None) -> tuple[str, ...]: + if not names: + return DEFAULT_PHASES + output: list[str] = [] + for name in names: + phase = canonical_phase(name) + if phase not in output: + output.append(phase) + return tuple(output) + + +def scaffold_resource() -> Any: + """Return the packaged COD 1523304 file, or the checkout example copy.""" + + resource = files("diffractscout").joinpath("prototype_data", _SCAFFOLD_NAME) + if resource.is_file(): + return resource + fallback = ( + Path(__file__).resolve().parents[2] + / "examples" + / "inherited_cif2peaks" + / "ti_nb_orthorhombic_cmcm_partial_occupancy.cif" + ) + if fallback.is_file(): + return fallback + raise FileNotFoundError("The alpha-double-prime COD 1523304 scaffold is not installed.") + + +def select_prototype( + candidates: Sequence[CandidateRecord], + *, + phase: str, + elements: Sequence[str], + host: str, +) -> CandidateRecord | None: + """Choose one symmetry prototype, or ``None`` when the family is absent. + + An elemental prototype of ``host`` outranks a lower-energy multielement + candidate. Alpha additionally excludes D0_19 and C14. Omega is space + group 191, so it cannot enter the alpha family. A formula element outside + ``elements`` excludes the candidate; that is what keeps oxides out of an + oxygen-free alloy. + """ + + eligible = rank_prototypes(candidates, phase=phase, elements=elements, host=host) + return eligible[0] if eligible else None + + +def rank_prototypes(candidates: Sequence[CandidateRecord], *, phase: str, elements: Sequence[str], host: str) -> list[CandidateRecord]: + """Metadata shortlist; each candidate still requires an atom-level check.""" + phase_name = canonical_phase(phase) + allowed = _element_set(elements) + host_symbol = _required_element(host) + if host_symbol not in allowed: + raise ValueError(f"Host element {host_symbol} is not in the requested composition.") + eligible = [ + candidate + for candidate in candidates + if _eligible(candidate, phase=phase_name, elements=allowed, host=host_symbol) + ] + return sorted(eligible, key=lambda candidate: _rank_key(candidate, host_symbol)) + + +def resolve_occupancies( + *, + nominal: str = "", + weight_percent: str = "", + atomic_percent: str = "", +) -> tuple[str, str, dict[str, Decimal]]: + """Return ``(basis, note, occupancies)`` summing exactly to 1. + + Each fraction is rounded to five decimal places. The residual needed to + make the sum exact is added to the last element in input order. + """ + + sources = { + "nominal_grade": nominal.strip(), + "weight_percent": weight_percent.strip(), + "atomic_percent": atomic_percent.strip(), + } + chosen = [name for name, value in sources.items() if value] + if len(chosen) != 1: + raise ValueError("Provide exactly one of nominal, weight percent, or atomic percent.") + basis = chosen[0] + if basis == "nominal_grade": + pairs = _nominal_pairs(sources[basis]) + note = _NOMINAL_NOTE + occupancies = _occupancies_from_amounts(pairs, atomic=False) + elif basis == "weight_percent": + pairs = _parse_amount_assignments(sources[basis]) + note = ( + "Caller-supplied weight percent. This records the supplied composition, " + "not a heat analysis or an equilibrium phase composition." + ) + occupancies = _occupancies_from_amounts(pairs, atomic=False) + else: + pairs = _parse_amount_assignments(sources[basis]) + note = ( + "Caller-supplied atomic percent. This records the supplied composition, " + "not a heat analysis or an equilibrium phase composition." + ) + occupancies = _occupancies_from_amounts(pairs, atomic=True) + return basis, note, occupancies + + +def parse_template_assignments(items: Sequence[str] | None) -> dict[str, Path]: + mapping: dict[str, Path] = {} + for item in items or []: + phase, path = _parse_template_assignment(item) + if phase in mapping: + raise ValueError(f"Duplicate --template for {phase}.") + mapping[phase] = path + return mapping + + +def parse_fractional_assignments(items: Sequence[str] | None) -> dict[str, float]: + mapping: dict[str, float] = {} + for item in items or []: + axis, value = _parse_fractional_assignment(item) + if axis in mapping: + raise ValueError(f"Fractional coordinate {axis} was given more than once.") + mapping[axis] = value + return mapping + + +def adapt_cif( + source: str | Path, + destination: str | Path, + *, + nominal: str = "", + weight_percent: str = "", + atomic_percent: str = "", + a: float | None = None, + b: float | None = None, + c: float | None = None, + alpha: float | None = None, + beta: float | None = None, + gamma: float | None = None, + fract: Mapping[str, float] | None = None, + citation: str = "", +) -> AdaptResult: + """Write a derivative CIF without modifying ``source``. + + Lattice parameters and fractional coordinates that appear in the call are + caller-supplied values and require ``citation``. Composition alone does + not. The destination is replaced only after ``load_structure`` reports a + matching symmetry cross-check. + """ + + source_path = Path(source).expanduser().resolve() + destination_path = Path(destination).expanduser().resolve() + if not source_path.is_file(): + raise FileNotFoundError(f"CIF file not found: {source_path}") + if destination_path.suffix.lower() != ".cif": + raise ValueError("adapt output must use a .cif suffix.") + if destination_path.exists() and destination_path.is_dir(): + raise ValueError("adapt output must be a .cif file, not a directory.") + sidecar_path = destination_path.with_name(destination_path.stem + ".adapt.json") + if _same_path(source_path, destination_path) or _same_path(source_path, sidecar_path): + raise ValueError("adapt refuses to overwrite the source CIF.") + if destination_path.exists(): + raise FileExistsError(f"Refusing to overwrite {destination_path}.") + if sidecar_path.exists(): + raise FileExistsError(f"Refusing to overwrite {sidecar_path}.") + + basis, composition_note, occupancies = resolve_occupancies( + nominal=nominal, + weight_percent=weight_percent, + atomic_percent=atomic_percent, + ) + if set(occupancies) & _INTERSTITIALS: + raise ValueError("adapt composition describes the substitutional metal sublattice; interstitial percentages cannot be substituted onto metal sites.") + citation_text = _flatten_citation(citation) + lattice = { + "a": a, + "b": b, + "c": c, + "alpha": alpha, + "beta": beta, + "gamma": gamma, + } + coordinates = {str(axis): float(value) for axis, value in dict(fract or {}).items()} + _validate_requested_cell(lattice) + lattice, implied_axes = _symmetry_constrained_lattice(source_path, lattice) + _validate_requested_coordinates(coordinates) + if (any(value is not None for value in lattice.values()) or coordinates) and not citation_text: + raise ValueError( + "Pass --citation when changing a lattice parameter or fractional coordinate." + ) + + destination_path.parent.mkdir(parents=True, exist_ok=True) + cif_descriptor, cif_name = tempfile.mkstemp(prefix=f".{destination_path.stem}-", suffix=".tmp", dir=destination_path.parent) + os.close(cif_descriptor) + json_descriptor, json_name = tempfile.mkstemp(prefix=f".{sidecar_path.stem}-", suffix=".tmp", dir=sidecar_path.parent) + os.close(json_descriptor) + temp_cif = Path(cif_name) + temp_json = Path(json_name) + moved_cif = False + output_hash = "" + source_hash = sha256_file(source_path) + try: + edit = _write_adapted_cif( + source_path, + temp_cif, + occupancies=occupancies, + lattice=lattice, + coordinates=coordinates, + citation=citation_text, + ) + loaded = _validated_structure( + temp_cif, + expected_elements=set(occupancies) | set(edit["interstitials_preserved"]), + ) + if sha256_file(source_path) != source_hash: + raise ValueError("Source CIF changed during adaptation; no output was published.") + output_hash = sha256_file(temp_cif) + payload = { + "schema": "diffractscout_adapt_v1", + "source_cif": str(source_path), + "source_sha256": source_hash, + "output_cif": destination_path.name, + "output_sha256": output_hash, + "composition_basis": basis, + "composition_note": composition_note, + "citation": citation_text or None, + "occupancies": {element: _occupancy_token(value) for element, value in occupancies.items()}, + "lattice_edits": edit["lattice_edits"], + "lattice_axes_implied_by_symmetry": implied_axes, + "lattice_unchanged": edit["lattice_unchanged"], + "coordinate_edits": edit["coordinate_edits"], + "coordinates_unchanged": edit["coordinates_unchanged"], + "interstitials_preserved": edit["interstitials_preserved"], + "symmetry_crosscheck": loaded.source_metadata.get("symmetry_crosscheck"), + "space_group_symbol": loaded.space_group_symbol, + "space_group_number": loaded.space_group_number, + "warnings": list(loaded.warnings), + } + _write_json(temp_json, payload) + _publish_new_file(temp_cif, destination_path) + moved_cif = True + _publish_new_file(temp_json, sidecar_path) + except Exception: + temp_cif.unlink(missing_ok=True) + temp_json.unlink(missing_ok=True) + if moved_cif and destination_path.is_file() and sha256_file(destination_path) == output_hash: + destination_path.unlink(missing_ok=True) + raise + finally: + temp_cif.unlink(missing_ok=True) + temp_json.unlink(missing_ok=True) + + occupancy_text = {element: _occupancy_token(value) for element, value in occupancies.items()} + return AdaptResult( + cif_path=destination_path, + sidecar_path=sidecar_path, + composition_basis=basis, + occupancies=occupancy_text, + citation=citation_text, + symmetry_crosscheck=str(loaded.source_metadata.get("symmetry_crosscheck") or ""), + space_group_symbol=loaded.space_group_symbol, + space_group_number=loaded.space_group_number, + warnings=tuple(loaded.warnings), + ) + + +def _publish_new_file(temporary: Path, destination: Path) -> None: + """Reuse the bundle's platform no-replace primitive for a derived file.""" + from .pipeline import _rename_directory_noreplace + + _rename_directory_noreplace(temporary, destination) + + +def _symmetry_constrained_lattice(source: Path, lattice: Mapping[str, float | None]) -> tuple[dict[str, float | None], list[str]]: + """Tie conventional symmetry-equivalent axes without inventing a length.""" + block = select_structure_block(gemmi.cif.read_file(str(source))) + small = gemmi.make_small_structure_from_block(block) + group = gemmi.find_spacegroup_by_name(_hm_symbol(block)) + if group is None and _it_number(block): + group = gemmi.find_spacegroup_by_number(int(_it_number(block))) + axes: tuple[str, ...] = () + if group is not None: + number = group.number + if 195 <= number <= 230: + axes = ("a", "b", "c") + elif 75 <= number <= 142 or 168 <= number <= 194: + axes = ("a", "b") + elif 143 <= number <= 167 and abs(small.cell.gamma - 120) < 1e-5: + axes = ("a", "b") + output = dict(lattice) + supplied = [float(output[axis]) for axis in axes if output[axis] is not None] + if supplied and any(not math.isclose(value, supplied[0], rel_tol=1e-8, abs_tol=1e-8) for value in supplied): + raise ValueError(f"Space group {group.xhm()} requires equal cell axes {', '.join(axes)}.") + implied = [] + if supplied: + for axis in axes: + if output[axis] is None: + output[axis] = supplied[0] + implied.append(axis) + return output, implied + + +def fetch_prototypes( + composition: str, + output_dir: str | Path, + *, + phases: Sequence[str] | None = None, + templates: Mapping[str, str | Path] | None = None, + template_args: Sequence[str] | None = None, + provider: Any = None, + api_key: str | None = None, + max_subsystems: int = 64, + host: str = "", +) -> FetchPrototypesResult: + """Copy one CIF per requested phase and write ``prototype_index.csv``. + + The copied file keeps the database or scaffold contents. Target-alloy + occupancy is always false at this step. Alpha-double-prime uses the + packaged COD scaffold when Materials Project has no eligible hit, or when + no API key and no provider were supplied. Alpha and beta then raise + ``ValueError`` instead of writing an empty result. + """ + + selected = canonicalize_phases(phases) + template_map = _template_map(templates, template_args) + parsed = parse_composition_text(composition) + if not parsed.elements: + raise ValueError( + "fetch-prototypes needs a composition that contains elements. " + "The first parsed element is the host." + ) + elements = parsed.elements + host = _required_element(host) if host else elements[0] + if host not in elements: + raise ValueError(f"Host element {host} is not in the requested composition.") + key = api_key.strip() if isinstance(api_key, str) and api_key.strip() else None + needs_mp = [phase for phase in selected if phase not in template_map and phase != "alpha-double-prime"] + search_scaffold = "alpha-double-prime" in selected and "alpha-double-prime" not in template_map + will_search = bool(needs_mp or (search_scaffold and (provider is not None or key))) + if needs_mp and provider is None and key is None: + raise ValueError( + "No Materials Project API key. Pass --api-key or set MP_API_KEY. " + "alpha and beta need Materials Project or --template. " + "alpha-double-prime can use the packaged COD 1523304 scaffold without a key." + ) + if will_search: + _check_subsystem_cap(elements, max_subsystems) + if provider is None and key and will_search: + from .providers.materials_project import MaterialsProjectProvider + + try: + provider = MaterialsProjectProvider(key) + except Exception as exc: + raise RuntimeError(_redact_provider_error(exc, key, None)) from None + + destination = Path(output_dir).expanduser().resolve() + _reject_existing_outputs(destination, selected) + candidates: list[CandidateRecord] = [] + if provider is not None and will_search: + try: + candidates = _collect_candidates(provider, elements, max_subsystems) + except Exception as exc: + raise RuntimeError(_redact_provider_error(exc, key, provider)) from None + + chosen: dict[str, CandidateRecord] = {} + missing_notes: dict[str, str] = {} + for phase in selected: + if phase in template_map: + continue + hit = select_prototype(candidates, phase=phase, elements=elements, host=host) + if hit is not None: + chosen[phase] = hit + elif phase != "alpha-double-prime": + missing_notes[phase] = ( + f"No {phase} candidate in space group {PHASE_RULES[phase].number} " + f"contains host {host} inside {'-'.join(elements)}." + ) + + destination.mkdir(parents=True, exist_ok=True) + try: + downloaded = _download_bytes(provider, tuple(chosen.values())) if chosen else {} + except Exception as exc: + raise RuntimeError(_redact_provider_error(exc, key, provider)) from None + downloaded = {material_id: (data, _redact_provider_error(error, key, provider)) for material_id, (data, error) in downloaded.items()} + records: list[PrototypeRecord] = [] + for phase in selected: + if phase in template_map: + records.append( + _store_bytes( + template_map[phase].read_bytes(), + destination / PHASE_RULES[phase].filename, + phase=phase, + status="template", + material_id="", + origin=f"template:{template_map[phase]}", + space_group="", + formula="", + note=( + "User-supplied template. fetch-prototypes leaves its composition " + "and lattice unchanged." + ), + use_file_identity=True, + ) + ) + continue + if phase in chosen: + candidate = chosen[phase] + data, error = downloaded.get( + candidate.material_id, + (None, "Materials Project did not return this structure."), + ) + if data is not None: + records.append( + _store_bytes( + data, + destination / PHASE_RULES[phase].filename, + phase=phase, + status="downloaded", + material_id=candidate.material_id, + origin="Materials Project", + space_group=str(candidate.space_group_number or ""), + formula=candidate.formula, + note=_download_note(candidate, host), + use_file_identity=False, + ) + ) + else: + records.append(_missing_record(phase, error)) + continue + if phase == "alpha-double-prime": + if host != "Ti": + records.append(_missing_record(phase, "The packaged COD 1523304 scaffold is for host Ti only. Supply a suitable --template for this host.")) + continue + records.append( + _store_bytes( + _read_bytes(scaffold_resource()), + destination / PHASE_RULES[phase].filename, + phase=phase, + status="scaffold", + material_id="", + origin=_SCAFFOLD_SOURCE, + space_group="63", + formula="", + note=_scaffold_note(searched=will_search), + use_file_identity=True, + ) + ) + continue + records.append(_missing_record(phase, missing_notes.get(phase, "No prototype was selected."))) + + index_path = destination / "prototype_index.csv" + _write_index(index_path, records) + return FetchPrototypesResult( + output_dir=destination, + index_path=index_path, + host=host, + elements=elements, + records=tuple(records), + ) + + +def _redact_provider_error(message: object, key: str | None, provider: Any) -> str: + text = str(message) + for secret in (key, getattr(provider, "api_key", "")): + if isinstance(secret, str) and secret.strip(): + text = text.replace(secret.strip(), "[redacted]") + return text + + +def _element_set(elements: Sequence[str]) -> frozenset[str]: + allowed: set[str] = set() + for raw in elements: + symbol = _required_element(str(raw)) + allowed.add(symbol) + if not allowed: + raise ValueError("Composition has no elements.") + return frozenset(allowed) + + +def _required_element(symbol: str) -> str: + normalized = normalize_element(symbol) + if normalized is None: + raise ValueError(f"Unknown element symbol {symbol!r}.") + return normalized + + +def _eligible( + candidate: CandidateRecord, + *, + phase: str, + elements: frozenset[str], + host: str, +) -> bool: + if candidate.deprecated is True: + return False + number = candidate.space_group_number + if number is None or int(number) != PHASE_RULES[phase].number: + return False + present = formula_elements(candidate.formula) + if not present or not set(present) <= elements or host not in present: + return False + if phase == "alpha": + kind = infer_structure_type( + candidate.formula, + candidate.space_group, + int(number), + ) + if kind.name in {"D019", "C14"}: + return False + return True + + +def _rank_key(candidate: CandidateRecord, host: str) -> tuple[object, ...]: + present = formula_elements(candidate.formula) + elemental_host = 0 if present == (host,) else 1 + energy = candidate.energy_above_hull_eV_atom + if energy is None or not math.isfinite(float(energy)): + energy_rank: tuple[int, float] = (1, 0.0) + else: + energy_rank = (0, float(energy)) + return (elemental_host, energy_rank[0], energy_rank[1], candidate.material_id) + + +def _nominal_pairs(name: str) -> tuple[tuple[str, Decimal], ...]: + key = name.strip().lower().replace("–", "-").replace("—", "-").replace("−", "-") + key = re.sub(r"\s+", "", key) + pairs = _NOMINAL_GRADES.get(key) + if pairs is None: + raise ValueError( + f"Unknown nominal grade {name!r}. Supported grades: tc4, ti64, ti-6al-4v." + ) + return pairs + + +def _parse_amount_assignments(text: str) -> tuple[tuple[str, Decimal], ...]: + chunks = _assignment_chunks(text) + if not chunks: + raise ValueError("Composition assignment is empty.") + pairs: list[tuple[str, Decimal]] = [] + seen: set[str] = set() + for chunk in chunks: + match = re.fullmatch( + r"([A-Za-z]{1,2})\s*[=:]\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))", + chunk.strip(), + ) + if match is None: + raise ValueError(f"Cannot read a composition assignment from {chunk!r}.") + symbol = normalize_element(match.group(1)) + if symbol is None: + raise ValueError(f"Unknown element in composition assignment {chunk!r}.") + if symbol in seen: + raise ValueError(f"Element {symbol} appears more than once.") + try: + amount = Decimal(match.group(2)) + except Exception as exc: + raise ValueError(f"Amount for {symbol} is not numeric.") from exc + if not amount.is_finite() or amount <= 0: + raise ValueError(f"Amount for {symbol} must be positive.") + seen.add(symbol) + pairs.append((symbol, amount)) + total = sum((amount for _, amount in pairs), Decimal(0)) + if abs(total - Decimal(100)) > _PERCENT_TOLERANCE: + raise ValueError(f"Composition percentages sum to {total}, outside 100 ± 0.05.") + return tuple(pairs) + + +def _assignment_chunks(text: str) -> list[str]: + chunks: list[str] = [] + for part in re.split(r"[,;]+", text.strip()): + piece = part.strip() + if not piece: + continue + if piece.count("=") + piece.count(":") > 1: + chunks.extend(item for item in piece.split() if item) + else: + chunks.append(piece) + return chunks + + +def _occupancies_from_amounts( + pairs: Sequence[tuple[str, Decimal]], + *, + atomic: bool, +) -> dict[str, Decimal]: + if atomic: + moles = list(pairs) + else: + moles = [(element, amount / _atomic_weight(element)) for element, amount in pairs] + total = sum((amount for _, amount in moles), Decimal(0)) + if total <= 0: + raise ValueError("Composition amounts must be positive.") + exact = [(element, amount / total) for element, amount in moles] + rounded = [ + (element, value.quantize(_OCCUPANCY_QUANTUM, rounding=ROUND_HALF_UP)) + for element, value in exact + ] + residual = Decimal(1) - sum((value for _, value in rounded), Decimal(0)) + last_element, last_value = rounded[-1] + rounded[-1] = (last_element, last_value + residual) + total_rounded = sum((value for _, value in rounded), Decimal(0)) + if total_rounded != Decimal(1) or any(value <= 0 or value > 1 for _, value in rounded): + raise ValueError("Rounded occupancies are outside (0, 1] or do not sum to 1.") + return dict(rounded) + + +def _atomic_weight(symbol: str) -> Decimal: + element = gemmi.Element(symbol) + weight = float(element.weight) + if element.atomic_number <= 0 or not math.isfinite(weight) or weight <= 0: + raise ValueError(f"{symbol} has no positive atomic weight.") + return Decimal(str(weight)) + + +def _occupancy_token(value: Decimal) -> str: + if value == 1: + return "1" + return f"{value:.5f}" + + +def _parse_template_assignment(text: str) -> tuple[str, Path]: + if "=" not in text: + raise ValueError("--template expects phase=path.") + raw_phase, raw_path = text.split("=", 1) + phase = canonical_phase(raw_phase) + path = Path(raw_path.strip()).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"Template CIF not found: {path}") + return phase, path.resolve() + + +def _parse_fractional_assignment(text: str) -> tuple[str, float]: + if "=" not in text: + raise ValueError("--fract expects x=value, y=value, or z=value.") + raw_axis, raw_value = text.split("=", 1) + axis = raw_axis.strip().lower() + if axis not in _FRACT_KEYS: + raise ValueError(f"Fractional coordinate axis must be x, y, or z, not {raw_axis!r}.") + value = _parse_float(raw_value) + return axis, value + + +def _flatten_citation(citation: str) -> str: + return re.sub(r"[\x00-\x1f]+", " ", citation).strip() + + +def _validate_requested_cell(lattice: Mapping[str, float | None]) -> None: + for key in _LENGTH_KEYS: + value = lattice.get(key) + if value is None: + continue + if not math.isfinite(float(value)) or float(value) <= 0: + raise ValueError(f"Cell length {key} must be positive and finite.") + for key in _ANGLE_KEYS: + value = lattice.get(key) + if value is None: + continue + angle = float(value) + if not math.isfinite(angle) or angle <= 0 or angle >= 180: + raise ValueError(f"Cell angle {key} must lie strictly between 0 and 180 degrees.") + + +def _validate_requested_coordinates(coordinates: Mapping[str, float]) -> None: + for axis, value in coordinates.items(): + if axis not in _FRACT_KEYS: + raise ValueError(f"Fractional coordinate axis must be x, y, or z, not {axis!r}.") + if not math.isfinite(value): + raise ValueError(f"Fractional coordinate {axis} must be finite.") + + +def _write_adapted_cif( + source: Path, + destination: Path, + *, + occupancies: Mapping[str, Decimal], + lattice: Mapping[str, float | None], + coordinates: Mapping[str, float], + citation: str, +) -> dict[str, Any]: + document = gemmi.cif.read_file(str(source)) + block = select_structure_block(document) + lattice_edits, lattice_changed = _apply_lattice(block, lattice) + groups = _site_groups(block) + metal_groups = [group for group in groups if group.kind == "metal"] + if not metal_groups: + raise ValueError("CIF has no metal site that can receive the composition.") + _require_unique_coordinates(metal_groups, coordinates) + tags = _output_tags(_loop_tags(block)) + rows, interstitials = _composition_rows(groups, tags, occupancies, coordinates) + suffixes = [tag[len("_atom_site_") :] for tag in tags] + block.init_loop("_atom_site_", suffixes) + loop = block.find_loop("_atom_site_label").get_loop() + for row in rows: + loop.add_row(row) + _update_formula_tags(block, groups, occupancies) + if lattice_changed: + _update_volume(block) + for tag in _DENSITY_TAGS: + _erase_pair(block, tag) + document.write_file(str(destination)) + _prepend_comment(destination, citation) + return { + "lattice_edits": lattice_edits, + "lattice_unchanged": [key for key in (*_LENGTH_KEYS, *_ANGLE_KEYS) if key not in lattice_edits], + "coordinate_edits": { + axis: format(value, ".10g") for axis, value in coordinates.items() + }, + "coordinates_unchanged": [axis for axis in _FRACT_KEYS if axis not in coordinates], + "interstitials_preserved": interstitials, + } + + +def _apply_lattice( + block: gemmi.cif.Block, + lattice: Mapping[str, float | None], +) -> tuple[dict[str, dict[str, str]], bool]: + edits: dict[str, dict[str, str]] = {} + for key, value in lattice.items(): + if value is None: + continue + tag = _CELL_TAGS[key] + previous = _required_cif_value(block, tag) + formatted = format(float(value), ".10g") + block.set_pair(tag, formatted) + edits[key] = {"from": previous, "to": formatted} + return edits, bool(edits) + + +def _loop_tags(block: gemmi.cif.Block) -> list[str]: + column = block.find_loop("_atom_site_label") + if len(column) == 0: + raise ValueError("CIF has no atom-site loop.") + loop = column.get_loop() + tags = [str(tag) for tag in loop.tags] + if any(not tag.startswith("_atom_site_") for tag in tags): + raise ValueError("CIF atom-site loop contains an unexpected column.") + return tags + + +def _output_tags(tags: list[str]) -> list[str]: + output = list(tags) + if "_atom_site_label" not in output: + raise ValueError("CIF atom-site loop has no _atom_site_label column.") + for axis in _FRACT_KEYS: + if f"_atom_site_fract_{axis}" not in output: + raise ValueError(f"CIF atom-site loop has no _atom_site_fract_{axis} column.") + if "_atom_site_type_symbol" not in output: + output.insert(output.index("_atom_site_label") + 1, "_atom_site_type_symbol") + if "_atom_site_occupancy" not in output: + output.append("_atom_site_occupancy") + return output + + +def _site_groups(block: gemmi.cif.Block) -> list[_SiteGroup]: + tags = _loop_tags(block) + column = block.find_loop("_atom_site_label") + loop = column.get_loop() + grouped: dict[tuple[float, float, float], _SiteGroup] = {} + order: list[tuple[float, float, float]] = [] + for index in range(loop.length()): + raw = {tag: _clean_cif(loop[index, position]) for position, tag in enumerate(tags)} + element = _element_from_row(raw) + fract = {axis: raw.get(f"_atom_site_fract_{axis}", "") for axis in _FRACT_KEYS} + values = {axis: _parse_float(fract[axis]) for axis in _FRACT_KEYS} + key = tuple(round(values[axis], 4) for axis in _FRACT_KEYS) + group = grouped.get(key) + if group is None: + group = _SiteGroup("metal", [], (), fract, values) + grouped[key] = group + order.append(key) + group.rows.append({**raw, "_parsed_element": element}) + groups: list[_SiteGroup] = [] + for key in order: + group = grouped[key] + elements = tuple(dict.fromkeys(row["_parsed_element"] for row in group.rows)) + interstitial = set(elements) <= _INTERSTITIALS + mixed = bool(set(elements) & _INTERSTITIALS) and not interstitial + if mixed: + raise ValueError( + "A coordinate group mixes metal and interstitial atoms. " + "adapt leaves interstitial clusters unchanged and will not guess which rows to replace." + ) + group.kind = "interstitial" if interstitial else "metal" + group.elements = elements + groups.append(group) + return groups + + +def _element_from_row(row: Mapping[str, str]) -> str: + token = row.get("_atom_site_type_symbol") or row.get("_atom_site_label") or "" + match = re.match(r"([A-Z][a-z]?)", token.strip()) + if match is None: + raise ValueError(f"Cannot read an element symbol from {token!r}.") + symbol = normalize_element(match.group(1)) + if symbol is None: + raise ValueError(f"Unrecognized element symbol in {token!r}.") + return symbol + + +def _require_unique_coordinates(groups: Sequence[_SiteGroup], coordinates: Mapping[str, float]) -> None: + for axis in coordinates: + seen = {format(group.values[axis], ".6f") for group in groups} + if len(seen) > 1: + raise ValueError( + f"Metal sites do not share one fractional {axis}. " + "Pass a CIF where that coordinate is already the same on every metal site." + ) + + +def _composition_rows( + groups: Sequence[_SiteGroup], + tags: Sequence[str], + occupancies: Mapping[str, Decimal], + coordinates: Mapping[str, float], +) -> tuple[list[list[str]], list[str]]: + rows: list[list[str]] = [] + labels: set[str] = set() + interstitials: list[str] = [] + site_index = 0 + for group in groups: + if group.kind == "interstitial": + for source in group.rows: + values = {tag: source.get(tag, "") for tag in tags} + if not values.get("_atom_site_type_symbol"): + values["_atom_site_type_symbol"] = source["_parsed_element"] + if not values.get("_atom_site_occupancy"): + values["_atom_site_occupancy"] = "1" + label = values.get("_atom_site_label", "") + if label in labels: + raise ValueError(f"Adapted CIF would repeat atom label {label}.") + labels.add(label) + element = source["_parsed_element"] + if element not in interstitials: + interstitials.append(element) + rows.append([values[tag] for tag in tags]) + continue + _require_full_metal_occupancy(group) + site_index += 1 + for element, occupancy in occupancies.items(): + source = group.rows[0] + values = {tag: source.get(tag, "") for tag in tags} + label = f"{element}{site_index}" + if label in labels: + raise ValueError(f"Adapted CIF would repeat atom label {label}.") + labels.add(label) + values["_atom_site_label"] = label + values["_atom_site_type_symbol"] = element + values["_atom_site_occupancy"] = _occupancy_token(occupancy) + for axis in _FRACT_KEYS: + if axis in coordinates: + values[f"_atom_site_fract_{axis}"] = format(coordinates[axis], ".10g") + else: + values[f"_atom_site_fract_{axis}"] = group.fract[axis] + rows.append([values[tag] for tag in tags]) + return rows, interstitials + + +def _require_full_metal_occupancy(group: _SiteGroup) -> None: + if "_atom_site_occupancy" not in group.rows[0] and all( + "_atom_site_occupancy" not in row for row in group.rows + ): + return + total = sum( + (_parse_decimal(row["_atom_site_occupancy"]) for row in group.rows if row.get("_atom_site_occupancy")), + Decimal(0), + ) + if abs(total - Decimal(1)) > _SITE_OCCUPANCY_TOLERANCE: + raise ValueError( + "A metal site occupancy does not sum to 1. " + "adapt will not replace it with a fully occupied composition." + ) + + +def _update_formula_tags( + block: gemmi.cif.Block, + groups: Sequence[_SiteGroup], + occupancies: Mapping[str, Decimal], +) -> None: + small = gemmi.make_small_structure_from_block(block) + totals: dict[str, Decimal] = {} + for site in small.get_all_unit_cell_sites(): + element = site.element.name + totals[element] = totals.get(element, Decimal(0)) + Decimal(str(site.occ)) + metal_count = sum((value for element, value in totals.items() if element not in _INTERSTITIALS), Decimal(0)) + formula_units = int(round(metal_count)) + if formula_units <= 0 or abs(metal_count - formula_units) > Decimal("0.0001"): + raise ValueError("Expanded metal content must be a positive integer number of occupied sites.") + totals = {element: value / Decimal(formula_units) for element, value in totals.items()} + formula = " ".join( + element if _occupancy_token(amount) == "1" else f"{element}{_occupancy_token(amount)}" + for element, amount in totals.items() + ) + quoted = _quote_cif(formula) + for tag in (*_FORMULA_TAGS, "_chemical_formula_moiety"): + if tag == "_chemical_formula_sum" or _has_cif_value(block, tag): + block.set_pair(tag, quoted) + block.set_pair("_cell_formula_units_Z", str(formula_units)) + mass = unit_cell_formula_weight_g_mol(small) + if mass is not None: + block.set_pair("_chemical_formula_weight", format(mass / formula_units, ".10g")) + # Species and labels have changed; these derived source tables cannot be + # inherited as if they described the new model. + for item in list(block): + tag = item.pair[0] if item.pair else item.loop.tags[0] if item.loop else "" + if tag.startswith(("_atom_type_", "_atom_site_aniso_", "_geom_")): + item.erase() + block.set_pair("_audit_creation_method", gemmi.cif.quote("DiffractScout adapt: derived starting model; inherited publication and database fields describe the source scaffold.")) + + +def _update_volume(block: gemmi.cif.Block) -> None: + lengths = [_required_float(block, _CELL_TAGS[key]) for key in _LENGTH_KEYS] + angles = [_required_float(block, _CELL_TAGS[key]) for key in _ANGLE_KEYS] + try: + volume = float(gemmi.UnitCell(*lengths, *angles).volume) + except Exception as exc: + raise ValueError(f"Edited unit cell is invalid: {exc}") from exc + if not math.isfinite(volume) or volume <= 0: + raise ValueError("Edited unit-cell volume is not positive.") + block.set_pair("_cell_volume", format(volume, ".10g")) + + +def _prepend_comment(path: Path, citation: str) -> None: + lines = [_ADAPT_COMMENT] + if citation: + lines.append(f"# DiffractScout adapt citation: {citation}") + prefix = ("\n".join(lines) + "\n").encode("utf-8") + path.write_bytes(prefix + path.read_bytes()) + + +def _validated_structure(path: Path, *, expected_elements: set[str]): + try: + loaded = load_structure(path) + except ValueError as exc: + raise ValueError(f"Adapted CIF failed validation and was not written: {exc}") from exc + crosscheck = str(loaded.source_metadata.get("symmetry_crosscheck") or "") + if crosscheck != "match": + detected = loaded.source_metadata.get("detected_space_group_symbol") or "unavailable" + detected_number = loaded.source_metadata.get("detected_space_group_number") + raise ValueError( + "Adapted CIF failed the symmetry cross-check " + f"({loaded.space_group_symbol} No. {loaded.space_group_number}; " + f"spglib {detected} No. {detected_number}; status {crosscheck}). " + "The output was not written." + ) + unexpected = [item for item in loaded.warnings if not item.startswith(_PARTIAL_OCCUPANCY_WARNING)] + if unexpected: + raise ValueError( + "Adapted CIF produced an unexpected structure warning: " + "; ".join(unexpected) + ) + actual = {str(site.element.name) for site in loaded.small_structure.sites} + if actual != expected_elements: + raise ValueError( + "Adapted CIF elements are " + f"{', '.join(sorted(actual))}; expected {', '.join(sorted(expected_elements))}." + ) + if not math.isfinite(float(loaded.small_structure.cell.volume)) or float(loaded.small_structure.cell.volume) <= 0: + raise ValueError("Adapted CIF volume is not positive. The output was not written.") + return loaded + + +def _template_map( + templates: Mapping[str, str | Path] | None, + template_args: Sequence[str] | None, +) -> dict[str, Path]: + mapping = parse_template_assignments(template_args) + for raw_phase, raw_path in dict(templates or {}).items(): + phase = canonical_phase(str(raw_phase)) + if phase in mapping: + raise ValueError(f"Duplicate --template for {phase}.") + path = Path(raw_path).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"Template CIF not found: {path}") + mapping[phase] = path.resolve() + return mapping + + +def _check_subsystem_cap(elements: Sequence[str], max_subsystems: int) -> None: + if isinstance(max_subsystems, bool) or not isinstance(max_subsystems, int) or max_subsystems < 1: + raise ValueError("--max-subsystems must be an integer of at least 1.") + subsystem_count = (1 << len(set(elements))) - 1 + if subsystem_count > max_subsystems: + chemsys = "-".join(sorted(elements)) + raise ValueError( + f"{chemsys} expands to {subsystem_count} chemical-subsystem queries, " + f"which is above --max-subsystems {max_subsystems}. " + "fetch-prototypes does not apply an energy-above-hull filter." + ) + + +def _collect_candidates(provider: Any, elements: Sequence[str], max_subsystems: int) -> list[CandidateRecord]: + systems = chemsys_subsystems(elements) + if len(systems) > max_subsystems: + raise ValueError( + f"Refusing to query {len(systems)} chemical subsystems above the limit of {max_subsystems}." + ) + found: dict[str, CandidateRecord] = {} + for system in systems: + for candidate in provider.search_subsystem( + system, + e_hull_max_eV_atom=None, + exclude_deprecated=True, + ): + if candidate.material_id and candidate.material_id not in found: + found[candidate.material_id] = candidate + return list(found.values()) + + +def _download_bytes( + provider: Any, + candidates: Sequence[CandidateRecord], +) -> dict[str, tuple[bytes | None, str]]: + if not candidates: + return {} + with tempfile.TemporaryDirectory(prefix="diffractscout_fetch_") as temporary: + artifacts = provider.download_candidates( + list(candidates), + Path(temporary), + conventional_unit_cell=True, + include_elasticity=False, + ) + payloads: dict[str, tuple[bytes | None, str]] = {} + for artifact in artifacts: + data = None + path = artifact.cif_path + if artifact.status == "ok" and path is not None and Path(path).is_file(): + data = Path(path).read_bytes() + error = artifact.error or "Materials Project did not return this structure." + payloads[artifact.candidate.material_id] = (data, error) + return payloads + + +def _store_bytes( + data: bytes, + destination: Path, + *, + phase: str, + status: str, + material_id: str, + origin: str, + space_group: str, + formula: str, + note: str, + use_file_identity: bool, +) -> PrototypeRecord: + with destination.open("xb") as handle: + handle.write(data) + declared, number, file_formula = _read_cif_identity(destination) + if use_file_identity: + space_group = space_group or number + formula = formula or file_formula + return PrototypeRecord( + phase=phase, + status=status, + material_id=material_id, + source=origin, + space_group=space_group, + declared_symmetry=declared, + formula=formula, + target_composition=False, + cif_path=destination, + note=_with_symmetry_note(note, declared), + ) + + +def _missing_record(phase: str, note: str) -> PrototypeRecord: + return PrototypeRecord(phase=phase, status="missing", target_composition=False, note=note) + + +def _download_note(candidate: CandidateRecord, host: str) -> str: + present = formula_elements(candidate.formula) + prefix = "" + if present == (host,): + prefix = f"Elemental {host} prototype in space group {candidate.space_group_number}. " + return ( + prefix + + f"Symmetry prototype for host {host}. " + + "The composition and lattice are the database record, not the requested alloy." + ) + + +def _scaffold_note(*, searched: bool) -> str: + if searched: + prefix = "No eligible Cmcm (63) candidate contained the host element. " + else: + prefix = "Materials Project was not queried. " + return prefix + _SCAFFOLD_NOTE + + +def _with_symmetry_note(note: str, declared: str) -> str: + compact = re.sub(r"[^A-Za-z0-9-]+", "", declared.replace("−", "-")).lower() + if compact == "p1": + suffix = ( + " Declared symmetry is P1. adapt requires a CIF whose Hermann-Mauguin " + "symbol already describes the structure. Pass that file with --template." + ) + elif not declared: + suffix = " No Hermann-Mauguin symbol was declared." + else: + suffix = "" + return (note + suffix).strip() + + +def _read_bytes(source: Any) -> bytes: + reader = getattr(source, "read_bytes", None) + if reader is None: + return Path(source).read_bytes() + return reader() + + +def _read_cif_identity(path: Path) -> tuple[str, str, str]: + try: + document = gemmi.cif.read_file(str(path)) + block = select_structure_block(document) + except Exception: + return "", "", "" + return _hm_symbol(block), _it_number(block), _formula_text(block) + + +def _hm_symbol(block: gemmi.cif.Block) -> str: + for tag in ( + "_symmetry_space_group_name_H-M", + "_space_group_name_H-M_alt", + "_symmetry_space_group_name_Hall", + ): + if _has_cif_value(block, tag): + return _clean_cif(block.find_value(tag)) + return "" + + +def _it_number(block: gemmi.cif.Block) -> str: + for tag in ("_space_group_IT_number", "_symmetry_Int_Tables_number"): + if _has_cif_value(block, tag): + return _clean_cif(block.find_value(tag)).split("(", 1)[0].strip() + return "" + + +def _formula_text(block: gemmi.cif.Block) -> str: + for tag in ("_chemical_formula_sum", "_chemical_formula_structural"): + if _has_cif_value(block, tag): + return _clean_cif(block.find_value(tag)) + return "" + + +def _write_index(path: Path, records: Sequence[PrototypeRecord]) -> None: + with path.open("x", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=_INDEX_FIELDS, lineterminator="\n") + writer.writeheader() + for record in records: + writer.writerow( + { + "phase": record.phase, + "status": record.status, + "material_id": record.material_id, + "source": record.source, + "space_group": record.space_group, + "declared_symmetry": record.declared_symmetry, + "formula": record.formula, + "target_composition": str(record.target_composition).lower(), + "cif": record.cif_path.name if record.cif_path is not None else "", + "note": record.note, + } + ) + + +def _reject_existing_outputs(output_dir: Path, phases: Sequence[str]) -> None: + if output_dir.exists() and not output_dir.is_dir(): + raise ValueError(f"Output path is not a directory: {output_dir}") + if not output_dir.exists(): + return + index_path = output_dir / "prototype_index.csv" + if index_path.exists(): + raise FileExistsError(f"Refusing to overwrite {index_path}.") + for phase in phases: + path = output_dir / PHASE_RULES[phase].filename + if path.exists(): + raise FileExistsError(f"Refusing to overwrite {path}.") + + +def _same_path(left: Path, right: Path) -> bool: + return os.path.normcase(str(left)) == os.path.normcase(str(right)) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + import json + + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _clean_cif(value: object | None) -> str: + if value is None: + return "" + return str(value).strip().strip("'\"") + + +def _has_cif_value(block: gemmi.cif.Block, tag: str) -> bool: + return _clean_cif(block.find_value(tag)) not in {"", "?", "."} + + +def _required_cif_value(block: gemmi.cif.Block, tag: str) -> str: + if not _has_cif_value(block, tag): + raise ValueError(f"CIF is missing {tag}.") + return _clean_cif(block.find_value(tag)) + + +def _required_float(block: gemmi.cif.Block, tag: str) -> float: + return _parse_float(_required_cif_value(block, tag)) + + +def _parse_float(token: str) -> float: + text = _clean_cif(token).split("(", 1)[0].strip() + if text in {"", "?", "."}: + raise ValueError(f"CIF number is missing: {token!r}.") + if re.fullmatch(r"[+-]?\d+(?:\.\d+)?/[+-]?\d+(?:\.\d+)?", text): + numerator, denominator = text.split("/", 1) + value = float(numerator) / float(denominator) + else: + try: + value = float(text) + except ValueError as exc: + raise ValueError(f"CIF number is not numeric: {token!r}.") from exc + if not math.isfinite(value): + raise ValueError(f"CIF number is not finite: {token!r}.") + return value + + +def _parse_decimal(token: str) -> Decimal: + text = _clean_cif(token).split("(", 1)[0].strip() + try: + value = Decimal(text) + except Exception as exc: + raise ValueError(f"CIF occupancy is not numeric: {token!r}.") from exc + if not value.is_finite() or value < 0 or value > 1: + raise ValueError(f"CIF occupancy is outside [0, 1]: {token!r}.") + return value + + +def _quote_cif(value: str) -> str: + if value and re.fullmatch(r"[A-Za-z0-9_./+\-]+", value): + return value + if "'" not in value: + return f"'{value}'" + if '"' not in value: + return f'"{value}"' + raise ValueError("CIF text contains both quote characters.") + + +def _erase_pair(block: gemmi.cif.Block, tag: str) -> None: + if not _has_cif_value(block, tag): + return + block.find_pair_item(tag).erase() diff --git a/src/diffractscout/prototype_data/cod_1522498_ti_nb_hcp.cif b/src/diffractscout/prototype_data/cod_1522498_ti_nb_hcp.cif new file mode 100644 index 0000000..6c0f609 --- /dev/null +++ b/src/diffractscout/prototype_data/cod_1522498_ti_nb_hcp.cif @@ -0,0 +1,85 @@ +#------------------------------------------------------------------------------ +#$Date: 2015-07-10 18:51:04 +0300 (Fri, 10 Jul 2015) $ +#$Revision: 142725 $ +#$URL: svn://www.crystallography.net/cod/cif/1/52/24/1522498.cif $ +#------------------------------------------------------------------------------ +# +# This file is available in the Crystallography Open Database (COD), +# http://www.crystallography.net/ +# +# All data on this site have been placed in the public domain by the +# contributors. +# +data_1522498 +loop_ +_publ_author_name +'McHargue, C.J.' +'Adair, S.E.jr.' +'Hammond, J.P.' +_publ_section_title +; + Effects of solid solution alloying on the cold-rolled texture of titanium +; +_journal_name_full +; +Transactions of the American Institute of Mining, Metallurgical and Petroleum +Engineers +; +_journal_page_first 1199 +_journal_page_last 1203 +_journal_volume 197 +_journal_year 1953 +_chemical_formula_sum 'Nb0.026 Ti0.974' +_chemical_name_systematic '(Nb0.026 Ti0.974)' +_space_group_IT_number 194 +_symmetry_space_group_name_Hall '-P 6c 2c' +_symmetry_space_group_name_H-M 'P 63/m m c' +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 120 +_cell_formula_units_Z 2 +_cell_length_a 2.942 +_cell_length_b 2.942 +_cell_length_c 4.68 +_cell_volume 35.080 +_citation_journal_id_ASTM TAIMAF +_cod_data_source_file McHargue_TAIMAF_1953_1413.cif +_cod_data_source_block Nb0.026Ti0.974 +_cod_original_cell_volume 35.08018 +_cod_database_code 1522498 +loop_ +_symmetry_equiv_pos_as_xyz +x,y,z +x-y,x,z+1/2 +-y,x-y,z +-x,-y,z+1/2 +-x+y,-x,z +y,-x+y,z+1/2 +-y,-x,-z+1/2 +x-y,-y,-z +x,x-y,-z+1/2 +y,x,-z +-x+y,y,-z+1/2 +-x,-x+y,-z +-x,-y,-z +-x+y,-x,-z-1/2 +y,-x+y,-z +x,y,-z-1/2 +x-y,x,-z +-y,x-y,-z-1/2 +y,x,z-1/2 +-x+y,y,z +-x,-x+y,z-1/2 +-y,-x,z +x-y,-y,z-1/2 +x,x-y,z +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +_atom_site_U_iso_or_equiv +Nb1 Nb 0.3333 0.6667 0.25 0.026 0.0 +Ti1 Ti 0.3333 0.6667 0.25 0.974 0.0 diff --git a/src/diffractscout/prototype_data/cod_1523304_ti_nb_cmcm.cif b/src/diffractscout/prototype_data/cod_1523304_ti_nb_cmcm.cif new file mode 100644 index 0000000..552684f --- /dev/null +++ b/src/diffractscout/prototype_data/cod_1523304_ti_nb_cmcm.cif @@ -0,0 +1,74 @@ +#------------------------------------------------------------------------------ +#$Date: 2015-07-11 03:04:30 +0300 (Sat, 11 Jul 2015) $ +#$Revision: 143919 $ +#$URL: svn://www.crystallography.net/cod/cif/1/52/33/1523304.cif $ +#------------------------------------------------------------------------------ +# +# This file is available in the Crystallography Open Database (COD), +# http://www.crystallography.net/ +# +# All data on this site have been placed in the public domain by the +# contributors. +# +data_1523304 +loop_ +_publ_author_name +'Brown, A.R.G.' +'Clark, D.' +'Eastabrook, J.' +'Jepson, K.S.' +_publ_section_title +; + The titanium-niobium system +; +_journal_name_full 'Nature (London)' +_journal_page_first 914 +_journal_page_last 915 +_journal_volume 201 +_journal_year 1964 +_chemical_formula_sum 'Nb0.2 Ti0.8' +_chemical_name_systematic '(Nb0.2 Ti0.8)' +_space_group_IT_number 63 +_symmetry_space_group_name_Hall '-C 2c 2' +_symmetry_space_group_name_H-M 'C m c m' +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_cell_formula_units_Z 4 +_cell_length_a 3.166 +_cell_length_b 4.854 +_cell_length_c 4.652 +_cell_volume 71.491 +_citation_journal_id_ASTM NATUAS +_cod_data_source_file Brown_NATUAS_1964_1412.cif +_cod_data_source_block Nb0.2Ti0.8 +_cod_original_cell_volume 71.49084 +_cod_database_code 1523304 +loop_ +_symmetry_equiv_pos_as_xyz +x,y,z +-x,-y,z+1/2 +x,-y,-z +-x,y,-z+1/2 +-x,-y,-z +x,y,-z-1/2 +-x,y,z +x,-y,z-1/2 +x+1/2,y+1/2,z +-x+1/2,-y+1/2,z+1/2 +x+1/2,-y+1/2,-z +-x+1/2,y+1/2,-z+1/2 +-x+1/2,-y+1/2,-z +x+1/2,y+1/2,-z-1/2 +-x+1/2,y+1/2,z +x+1/2,-y+1/2,z-1/2 +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +_atom_site_U_iso_or_equiv +Ti1 Ti 0 0.2 0.25 0.8 0.0 +Nb1 Nb 0 0.2 0.25 0.2 0.0 diff --git a/src/diffractscout/prototype_data/cod_9008554_beta_ti.cif b/src/diffractscout/prototype_data/cod_9008554_beta_ti.cif new file mode 100644 index 0000000..212bc78 --- /dev/null +++ b/src/diffractscout/prototype_data/cod_9008554_beta_ti.cif @@ -0,0 +1,160 @@ +#------------------------------------------------------------------------------ +#$Date: 2024-05-06 09:39:41 +0200 (Mon 06 de May de 2024) $ +#$Revision: 291735 $ +#$URL: svn://www.crystallography.net/cod/cif/9/00/85/9008554.cif $ +#------------------------------------------------------------------------------ +# +# This file is available in the Crystallography Open Database (COD), +# http://www.crystallography.net/. The original data for this entry +# were provided the American Mineralogist Crystal Structure Database, +# http://rruff.geo.arizona.edu/AMS/amcsd.php +# +# The file may be used within the scientific community so long as +# proper attribution is given to the journal article from which the +# data were obtained. +# +data_9008554 +loop_ +_publ_author_name +'Wyckoff, R. W. G.' +_publ_section_title +; + Second edition. Interscience Publishers, New York, New York + Sample at T = 1173 K + Body centered cubic, bcc, structure +; +_journal_name_full 'Crystal Structures' +_journal_page_first 7 +_journal_page_last 83 +_journal_volume 1 +_journal_year 1963 +_chemical_formula_sum Ti +_chemical_name_common Titanium-beta +_chemical_name_mineral Titanium-beta +_space_group_IT_number 229 +_symmetry_space_group_name_Hall '-I 4 2 3' +_symmetry_space_group_name_H-M 'I m -3 m' +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_cell_length_a 3.3065 +_cell_length_b 3.3065 +_cell_formula_units_Z 2 +_cell_length_c 3.3065 +_cell_volume 36.150 +_database_code_amcsd 0011232 +_diffrn_ambient_temperature 1173 +_exptl_crystal_density_diffrn 4.399 +_cod_original_sg_symbol_H-M 'I m 3 m' +_cod_database_code 9008554 +loop_ +_space_group_symop_operation_xyz +x,y,z +1/2+x,1/2+y,1/2+z +z,-x,y +1/2+z,1/2-x,1/2+y +-y,z,-x +1/2-y,1/2+z,1/2-x +x,-y,z +1/2+x,1/2-y,1/2+z +-z,x,-y +1/2-z,1/2+x,1/2-y +y,-z,x +1/2+y,1/2-z,1/2+x +-x,y,-z +1/2-x,1/2+y,1/2-z +x,-z,-y +1/2+x,1/2-z,1/2-y +-z,y,x +1/2-z,1/2+y,1/2+x +y,-x,-z +1/2+y,1/2-x,1/2-z +-x,z,y +1/2-x,1/2+z,1/2+y +z,-y,-x +1/2+z,1/2-y,1/2-x +-y,x,z +1/2-y,1/2+x,1/2+z +x,z,y +1/2+x,1/2+z,1/2+y +-z,-y,-x +1/2-z,1/2-y,1/2-x +y,x,z +1/2+y,1/2+x,1/2+z +-x,-z,-y +1/2-x,1/2-z,1/2-y +z,y,x +1/2+z,1/2+y,1/2+x +-y,-x,-z +1/2-y,1/2-x,1/2-z +z,x,-y +1/2+z,1/2+x,1/2-y +-y,-z,x +1/2-y,1/2-z,1/2+x +x,y,-z +1/2+x,1/2+y,1/2-z +-z,-x,y +1/2-z,1/2-x,1/2+y +y,z,-x +1/2+y,1/2+z,1/2-x +-x,-y,z +1/2-x,1/2-y,1/2+z +-z,x,y +1/2-z,1/2+x,1/2+y +y,-z,-x +1/2+y,1/2-z,1/2-x +-x,y,z +1/2-x,1/2+y,1/2+z +z,-x,-y +1/2+z,1/2-x,1/2-y +-y,z,x +1/2-y,1/2+z,1/2+x +x,-y,-z +1/2+x,1/2-y,1/2-z +-x,z,-y +1/2-x,1/2+z,1/2-y +z,-y,x +1/2+z,1/2-y,1/2+x +-y,x,-z +1/2-y,1/2+x,1/2-z +x,-z,y +1/2+x,1/2-z,1/2+y +-z,y,-x +1/2-z,1/2+y,1/2-x +y,-x,z +1/2+y,1/2-x,1/2+z +-x,-z,y +1/2-x,1/2-z,1/2+y +z,y,-x +1/2+z,1/2+y,1/2-x +-y,-x,z +1/2-y,1/2-x,1/2+z +x,z,-y +1/2+x,1/2+z,1/2-y +-z,-y,x +1/2-z,1/2-y,1/2+x +y,x,-z +1/2+y,1/2+x,1/2-z +-z,-x,-y +1/2-z,1/2-x,1/2-y +y,z,x +1/2+y,1/2+z,1/2+x +-x,-y,-z +1/2-x,1/2-y,1/2-z +z,x,y +1/2+z,1/2+x,1/2+y +-y,-z,-x +1/2-y,1/2-z,1/2-x +loop_ +_atom_site_label +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +Ti 0.00000 0.00000 0.00000 +loop_ +_cod_related_entry_id +_cod_related_entry_database +_cod_related_entry_code +1 ChemSpider 22402 +2 MPOD 1000062 +3 AMCSD 0011232 diff --git a/tests/test_gui_cifs.py b/tests/test_gui_cifs.py new file mode 100644 index 0000000..db9ec52 --- /dev/null +++ b/tests/test_gui_cifs.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import diffractscout.gui_cifs as gui_cifs + + +def _form(**overrides): + values = { + "lang": "zh", + "composition": "Ti-Al-V", + "basis": "nominal", + "nominal": "TC4", + "percent_text": "", + "host": "", + "output_dir": "out/initial", + "parameter_file": "", + "offline": True, + "api_key": "typed-key", + "phase_alpha": True, + "phase_beta": True, + "phase_alpha-double-prime": True, + } + values.update(overrides) + return values + + +def test_nominal_form_maps_to_prepare_api_without_network(tmp_path: Path) -> None: + kwargs = gui_cifs.prepare_kwargs_from_form( + _form(output_dir=str(tmp_path / "fresh")), environment_key="environment-key" + ) + + assert kwargs == { + "composition": "Ti-Al-V", + "output_dir": (tmp_path / "fresh").resolve(), + "nominal": "TC4", + "phases": ["alpha", "beta", "alpha-double-prime"], + "host": "", + "templates": {}, + "parameter_file": None, + "offline": True, + "api_key": None, + "max_subsystems": 64, + } + + +@pytest.mark.parametrize( + ("basis", "expected_key", "amounts"), + ( + ("weight_percent", "weight_percent", "Ti=90,Al=6,V=4"), + ("atomic_percent", "atomic_percent", "Ti=86,Al=10,V=4"), + ), +) +def test_percent_form_maps_only_selected_composition_basis( + tmp_path: Path, basis: str, expected_key: str, amounts: str +) -> None: + kwargs = gui_cifs.prepare_kwargs_from_form( + _form( + basis=basis, + nominal="ignored grade", + percent_text=amounts, + offline=False, + output_dir=str(tmp_path / "fresh"), + ), + environment_key="environment-key", + ) + + assert kwargs[expected_key] == amounts + other_key = "weight_percent" if expected_key == "atomic_percent" else "atomic_percent" + assert other_key not in kwargs + assert kwargs["api_key"] == "typed-key" + + +def test_environment_key_is_used_only_when_online_and_no_key_was_typed(tmp_path: Path) -> None: + kwargs = gui_cifs.prepare_kwargs_from_form( + _form(offline=False, api_key="", output_dir=str(tmp_path / "fresh")), + environment_key="environment-key", + ) + + assert kwargs["api_key"] == "environment-key" + assert "api_key" not in str(kwargs["output_dir"]) + + +def test_selected_templates_and_parameter_file_are_paths(tmp_path: Path) -> None: + alpha = tmp_path / "alpha.cif" + alpha.write_text("data_alpha\n", encoding="utf-8") + parameters = tmp_path / "parameters.json" + parameters.write_text("{}\n", encoding="utf-8") + kwargs = gui_cifs.prepare_kwargs_from_form( + _form( + phase_beta=False, + phase_alpha=False, + **{ + "phase_alpha-double-prime": True, + "template_alpha-double-prime": str(alpha), + }, + parameter_file=str(parameters), + output_dir=str(tmp_path / "fresh"), + ) + ) + + assert kwargs["phases"] == ["alpha-double-prime"] + assert kwargs["templates"] == {"alpha-double-prime": alpha.resolve()} + assert kwargs["parameter_file"] == parameters.resolve() + + +def test_invalid_form_fails_before_api_call(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="至少选择一个相"): + gui_cifs.prepare_kwargs_from_form( + _form( + phase_alpha=False, + phase_beta=False, + **{"phase_alpha-double-prime": False}, + output_dir=str(tmp_path / "fresh"), + ) + ) + + with pytest.raises(ValueError, match="Ti=90,Al=6,V=4"): + gui_cifs.prepare_kwargs_from_form( + _form( + basis="weight_percent", + percent_text="", + output_dir=str(tmp_path / "fresh"), + ) + ) + + with pytest.raises(ValueError, match="Ti=86,Al=10,V=4"): + gui_cifs.prepare_kwargs_from_form( + _form( + basis="atomic_percent", + percent_text="", + output_dir=str(tmp_path / "fresh"), + ) + ) + + +def _make_hidden_dialog(**kwargs): + if gui_cifs.tk is None: + pytest.skip("Tkinter unavailable") + last_error = None + for _attempt in range(2): + root = None + try: + root = gui_cifs.tk.Tk() + root.withdraw() + dialog = gui_cifs.InitialCifDialog(root, **kwargs) + dialog.withdraw() + root.update_idletasks() + return root, dialog + except gui_cifs.tk.TclError as exc: + last_error = exc + if root is not None: + try: + root.destroy() + except gui_cifs.tk.TclError: + pass + pytest.skip(f"Tk display unavailable: {last_error}") + + +def _wait_for(root, condition, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + root.update() + if condition(): + return + time.sleep(0.01) + pytest.fail("Timed out waiting for the dialog worker result") + + +def test_hidden_dialog_accepts_entries_and_loads_result_paths_on_tk_thread( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + output = tmp_path / "prepared" + source_thread = threading.get_ident() + loaded: list[tuple[list[Path], int]] = [] + opened: list[Path] = [] + + def prepare(**kwargs): + assert kwargs["composition"] == "Ti-Al-V" + assert kwargs["nominal"] == "TC4" + assert kwargs["phases"] == ["alpha"] + output.mkdir() + cif = output / "alpha.cif" + cif.write_text("data_alpha\n", encoding="utf-8") + report = output / "report.md" + report.write_text("starting model\n", encoding="utf-8") + return SimpleNamespace( + output_dir=output, + report_path=report, + index_path=output / "index.csv", + manifest_path=output / "manifest.json", + exit_code=3, + records=( + SimpleNamespace( + phase="alpha", + status="prepared", + cif_path=cif, + note="Packaged prototype; lattice is an initial assumption.", + ), + ), + ) + + monkeypatch.setattr(gui_cifs, "_prepare_cifs", prepare) + monkeypatch.setattr(gui_cifs, "_open_path", lambda path: opened.append(path)) + root, dialog = _make_hidden_dialog( + language="zh", + on_load=lambda paths: loaded.append((paths, threading.get_ident())), + ) + try: + dialog.composition_entry.delete(0, "end") + dialog.composition_entry.insert(0, "Ti-Al-V") + dialog.output_entry.delete(0, "end") + dialog.output_entry.insert(0, str(output)) + dialog.phase_vars["beta"].set(False) + dialog.phase_vars["alpha-double-prime"].set(False) + dialog.run_button.invoke() + + _wait_for(root, lambda: not dialog._running) + assert dialog.tree.get_children() == ("0",) + assert Path(dialog._result.records[0].cif_path) == output / "alpha.cif" + assert "部分完成" in dialog.status_var.get() + assert "initial assumption" in dialog.details.get("1.0", "end") + assert str((output / "alpha.cif").resolve()) in dialog.details.get("1.0", "end") + + dialog.report_button.invoke() + dialog.folder_button.invoke() + assert opened == [output / "report.md", output] + dialog.load_button.invoke() + assert loaded == [([output / "alpha.cif"], source_thread)] + finally: + dialog._close() + root.destroy() + + +def test_hidden_dialog_surfaces_worker_failure_and_reenables_run( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + def fail(**_kwargs): + raise RuntimeError("parameter JSON is invalid") + + monkeypatch.setattr(gui_cifs, "_prepare_cifs", fail) + root, dialog = _make_hidden_dialog(language="en") + try: + dialog.output_entry.delete(0, "end") + dialog.output_entry.insert(0, str(tmp_path / "fresh")) + dialog.run_button.invoke() + _wait_for(root, lambda: not dialog._running) + + assert "failed" in dialog.status_var.get().lower() + assert "parameter JSON is invalid" in dialog.details.get("1.0", "end") + assert str(dialog.run_button.cget("state")) == "normal" + finally: + dialog._close() + root.destroy() + + +def test_closing_dialog_during_worker_has_no_tk_callback(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + started = threading.Event() + release = threading.Event() + + def delayed(**kwargs): + started.set() + release.wait(timeout=2) + return SimpleNamespace( + output_dir=kwargs["output_dir"], + report_path=None, + exit_code=2, + records=(), + ) + + monkeypatch.setattr(gui_cifs, "_prepare_cifs", delayed) + root, dialog = _make_hidden_dialog(language="en") + dialog.output_entry.delete(0, "end") + dialog.output_entry.insert(0, str(tmp_path / "fresh")) + dialog.run_button.invoke() + assert started.wait(timeout=2) + dialog._close() + release.set() + try: + deadline = time.monotonic() + 2 + while time.monotonic() < deadline and not dialog._events.qsize(): + root.update() + time.sleep(0.01) + root.update() + finally: + root.destroy() + + +def test_small_screen_scroll_keeps_status_and_actions_visible( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + if gui_cifs.tk is None: + pytest.skip("Tkinter unavailable") + monkeypatch.setattr(gui_cifs.InitialCifDialog, "winfo_screenheight", lambda _self: 768) + monkeypatch.setattr( + gui_cifs, + "_prepare_cifs", + lambda **kwargs: SimpleNamespace( + output_dir=kwargs["output_dir"], report_path=None, exit_code=2, records=() + ), + ) + root = None + try: + root = gui_cifs.tk.Tk() + root.attributes("-alpha", 0.0) + root.deiconify() + dialog = gui_cifs.InitialCifDialog(root, language="en") + dialog.attributes("-alpha", 0.0) + dialog.deiconify() + root.update() + except gui_cifs.tk.TclError as exc: + if root is not None: + try: + root.destroy() + except gui_cifs.tk.TclError: + pass + pytest.skip(f"Mapped Tk layout unavailable: {exc}") + + try: + client_height = dialog.winfo_height() + assert client_height <= 668 + assert dialog.minsize()[1] <= client_height + assert dialog.minsize()[1] <= 668 + + dialog._toggle_advanced() + root.update() + scroll_region = dialog._scroll_canvas.bbox("all") + assert scroll_region is not None + assert scroll_region[3] > dialog._scroll_canvas.winfo_height() + dialog._scroll_canvas.yview_moveto(1.0) + root.update() + assert dialog._scroll_canvas.yview()[0] > 0 + + pinned = ( + dialog.status_label, + dialog.run_button, + dialog.load_button, + dialog.report_button, + dialog.folder_button, + ) + for widget in pinned: + bottom = widget.winfo_rooty() + widget.winfo_height() - dialog.winfo_rooty() + assert bottom <= client_height + + dialog.output_entry.delete(0, "end") + dialog.output_entry.insert(0, str(tmp_path / "small_screen_output")) + dialog.run_button.invoke() + _wait_for(root, lambda: not dialog._running) + assert "No CIF files" in dialog.status_var.get() + assert str(dialog.run_button.cget("state")) == "normal" + finally: + dialog._close() + root.destroy() + + +def test_offline_tc4_runs_real_service_through_gui_and_loads_cifs( + tmp_path: Path, +) -> None: + output = tmp_path / "offline_tc4" + main_thread = threading.get_ident() + loaded: list[tuple[list[Path], int]] = [] + root, dialog = _make_hidden_dialog( + language="zh", + on_load=lambda paths: loaded.append((paths, threading.get_ident())), + ) + try: + dialog.composition_entry.delete(0, "end") + dialog.composition_entry.insert(0, "TC4") + dialog.output_entry.delete(0, "end") + dialog.output_entry.insert(0, str(output)) + dialog.offline_var.set(True) + dialog.run_button.invoke() + + _wait_for(root, lambda: not dialog._running, timeout=30) + + assert dialog._result.exit_code == 0 + assert [record.phase for record in dialog._result.records] == [ + "alpha", + "beta", + "alpha-double-prime", + ] + assert all(record.status == "ready" for record in dialog._result.records) + assert all(record.cif_path.is_file() for record in dialog._result.records) + assert dialog._result.report_path.is_file() + assert dialog._result.index_path.is_file() + assert dialog._result.manifest_path.is_file() + assert "完成" in dialog.status_var.get() + assert [dialog.tree.set(item, "status") for item in dialog.tree.get_children()] == [ + "可用起始 CIF", + "可用起始 CIF", + "可用起始 CIF", + ] + + dialog.load_button.invoke() + assert loaded == [ + ([record.cif_path.resolve() for record in dialog._result.records], main_thread) + ] + finally: + dialog._close() + root.destroy() diff --git a/tests/test_initial_cifs.py b/tests/test_initial_cifs.py new file mode 100644 index 0000000..0033e0e --- /dev/null +++ b/tests/test_initial_cifs.py @@ -0,0 +1,395 @@ +import csv +import json +import math +from pathlib import Path + +import gemmi +import pytest + +from diffractscout.cif_quality import normalize_prototype, validate_phase_model, write_clean_model +from diffractscout.cli import main +from diffractscout.initial_cifs import prepare_cifs, read_phase_parameters +from diffractscout.models import CandidateRecord, DownloadArtifact +from diffractscout.structure import load_structure +from diffractscout.utils import sha256_file +from diffractscout.validation import verify_bundle + +ROOT = Path(__file__).parents[1] +PROTOTYPES = ROOT / "src" / "diffractscout" / "prototype_data" + + +def _payload(result): + return json.loads((result.output_dir / "preparation.json").read_text(encoding="utf-8")) + + +def _p1_bcc(path: Path, *, a: float = 3.2, second_site: bool = True): + small = gemmi.SmallStructure() + small.cell = gemmi.UnitCell(a, a, a, 90, 90, 90) + small.spacegroup_hm = "P 1" + positions = [(0, 0, 0), (0.5, 0.5, 0.5)] if second_site else [(0, 0, 0)] + for index, position in enumerate(positions): + site = gemmi.SmallStructure.Site() + site.element = gemmi.Element("Ti") + site.label = f"Ti{index}" + site.type_symbol = "Ti" + site.fract = gemmi.Fractional(*position) + site.occ = 1 + small.add_site(site) + write_clean_model(small, path, details="Synthetic P1 test input.") + + +def test_offline_nominal_bundle_is_portable_validated_and_explicit(tmp_path): + class NoNetwork: + def __getattr__(self, name): + raise AssertionError(f"Offline mode attempted provider access: {name}") + + result = prepare_cifs( + "TC4", tmp_path / "bundle", offline=True, provider=NoNetwork(), api_key="never-use-this" + ) + assert result.exit_code == 0 + assert verify_bundle(result.output_dir)["ok"] + payload = _payload(result) + assert payload["host"] == "Ti" + assert payload["query_status"] == "offline" + assert payload["queries"] == [] + assert payload["not_experimental_refinement"] is True + assert len(payload["records"]) == 3 + for record in payload["records"]: + assert record["status"] == "ready" + assert record["lattice_basis"] == "prototype_lattice" + assert record["composition_role"] == "bulk_starting_assumption" + assert record["occupancies"] == {"Ti": "0.86204", "Al": "0.10195", "V": "0.03601"} + assert any("not the measured composition" in warning for warning in record["warnings"]) + cif = result.output_dir / record["cif"] + loaded = validate_phase_model(cif, record["phase"], host="Ti") + assert loaded.source_metadata["symmetry_crosscheck"] == "match" + assert record["sha256"] == sha256_file(cif) + assert ( + sha256_file(result.output_dir / record["prototype"]["source_cif"]) + == record["prototype"]["source_sha256"] + ) + text = cif.read_text(encoding="utf-8") + assert "_audit_creation_method" in text + assert "_cod_database_code" not in text + assert "_publ_author_name" not in text + assert "_diffrn_ambient_temperature" not in text + sidecar = json.loads(cif.with_suffix(".adapt.json").read_text(encoding="utf-8")) + assert sidecar["source_cif"].startswith("prototypes/") + assert sidecar["output_sha256"] == sha256_file(cif) + all_json = "\n".join( + path.read_text(encoding="utf-8") for path in result.output_dir.rglob("*.json") + ) + assert ".diffractscout-initial-" not in all_json + assert "never-use-this" not in all_json + assert "1173 K" in result.report_path.read_text(encoding="utf-8") + + +def test_cited_independent_axes_and_per_phase_chemistry(tmp_path): + values = { + "alpha": { + "lattice": {"a": 2.95, "c": 4.70}, + "citation": "Synthetic test lattice, not literature", + "conditions": "test only", + }, + "beta": { + "lattice": {"a": 3.22}, + "citation": "Synthetic test lattice", + "weight_percent": "Ti=80,Al=2,V=18", + }, + "alpha-double-prime": { + "lattice": {"a": 3.08, "b": 4.64, "c": 4.62}, + "fract": {"y": 0.17}, + "citation": "Synthetic test lattice", + }, + } + result = prepare_cifs("Ti64", tmp_path / "cited", offline=True, phase_parameters=values) + assert result.exit_code == 0 + records = {record["phase"]: record for record in _payload(result)["records"]} + assert all(record["lattice_basis"] == "caller_cited_lattice" for record in records.values()) + assert all(record["inherited_independent_axes"] == [] for record in records.values()) + assert records["alpha"]["cell_parameters"][:3] == [2.95, 2.95, 4.7] + assert records["beta"]["cell_parameters"][:3] == [3.22] * 3 + assert records["beta"]["composition_role"] == "caller_supplied_phase" + assert records["beta"]["occupancies"] != records["alpha"]["occupancies"] + assert records["alpha-double-prime"]["coordinate_edits"] == {"y": "0.17"} + assert not any( + "y is inherited" in warning for warning in records["alpha-double-prime"]["warnings"] + ) + + +def test_peak_preview_obeys_independent_bragg_and_q_relations(tmp_path): + result = prepare_cifs("TC4", tmp_path / "bundle", offline=True, preview_wavelength_A=0.5) + with (result.output_dir / "peak_preview.csv").open(encoding="utf-8-sig") as handle: + rows = list(csv.DictReader(handle)) + assert rows + for row in rows: + spacing = float(row["d_A"]) + assert float(row["q_invA"]) == pytest.approx(2 * math.pi / spacing, rel=1e-12) + angle = math.degrees(2 * math.asin(0.5 / (2 * spacing))) + assert float(row["two_theta_deg"]) == pytest.approx(angle, abs=1e-9) + assert 5 - 1e-9 <= angle <= 120 + 1e-9 + + +def test_p1_symmetry_restoration_preserves_source(tmp_path): + source = tmp_path / "raw.cif" + _p1_bcc(source) + before = sha256_file(source) + destination = tmp_path / "normalized.cif" + audit = normalize_prototype(source, destination, "beta", host="Ti") + assert audit["symmetry_restored_from_p1"] is True + assert sha256_file(source) == before + loaded = validate_phase_model(destination, "beta", host="Ti") + assert loaded.space_group_number == 229 + assert len(loaded.small_structure.sites) == 1 + assert loaded.cell_parameters[:3] == pytest.approx((3.2, 3.2, 3.2)) + + +def test_reject_bad_candidate_and_try_next_with_provenance(tmp_path): + class Provider: + def search_subsystem(self, system, **kwargs): + assert kwargs["e_hull_max_eV_atom"] is None + return [ + CandidateRecord("mp-bad", "Ti", 0, space_group_number=229), + CandidateRecord("mp-good", "Ti", 0.05, space_group_number=229), + ] + + def download_candidates(self, candidates, output, **kwargs): + assert kwargs == {"conventional_unit_cell": True, "include_elasticity": False} + candidate = candidates[0] + path = output / "raw.cif" + _p1_bcc(path, second_site=candidate.material_id != "mp-bad") + return [DownloadArtifact(candidate, path)] + + def metadata(self): + return {"database_version": "test-db", "api_key": "should-not-appear"} + + result = prepare_cifs("TC4", tmp_path / "mp", phases=["beta"], provider=Provider()) + assert result.exit_code == 0 + payload = _payload(result) + record = payload["records"][0] + assert record["prototype"]["candidate"]["material_id"] == "mp-good" + assert record["prototype"]["symmetry_restored_from_p1"] is True + assert record["prototype"]["selection_attempts"][0]["material_id"] == "mp-bad" + assert "api_key" not in payload["provider_metadata"] + assert payload["provider_metadata"]["database_version"] == "test-db" + + +def test_same_space_group_multiple_orbits_are_not_a_parent_prototype(tmp_path): + small = gemmi.SmallStructure() + small.cell = gemmi.UnitCell(3.1, 4.7, 4.6, 90, 90, 90) + small.spacegroup_hm = "C m c m" + for index, value in enumerate((0.20, 0.36)): + site = gemmi.SmallStructure.Site() + site.element = gemmi.Element("Ti") + site.type_symbol = "Ti" + site.label = f"Ti{index}" + site.fract = gemmi.Fractional(0, value, 0.25) + site.occ = 1 + small.add_site(site) + source = tmp_path / "multiple.cif" + write_clean_model(small, source, details="Synthetic two-orbit test") + assert load_structure(source).space_group_number == 63 + with pytest.raises(ValueError, match="orbit"): + validate_phase_model(source, "alpha-double-prime", host="Ti") + + +def test_failed_explicit_template_is_not_silently_replaced(tmp_path): + source = tmp_path / "wrong.cif" + _p1_bcc(source) + before = sha256_file(source) + result = prepare_cifs("TC4", tmp_path / "partial", offline=True, templates={"alpha": source}) + assert result.exit_code == 3 + assert result.records[0].status == "failed" + assert result.records[0].cif_path is None + assert not (result.output_dir / "initial" / "alpha.cif").exists() + assert sha256_file(source) == before + assert verify_bundle(result.output_dir)["ok"] + + +def test_non_ti_system_does_not_receive_a_ti_scaffold(tmp_path): + result = prepare_cifs("Ni", tmp_path / "nickel", offline=True) + assert result.exit_code == 2 + assert all(record.cif_path is None for record in result.records) + assert not list((result.output_dir / "initial").glob("*.cif")) + assert verify_bundle(result.output_dir)["ok"] + + +def test_host_is_dominant_atomic_fraction_not_first_input_element(tmp_path): + result = prepare_cifs( + "Al-Ti-V", + tmp_path / "host", + offline=True, + weight_percent="Al=6,V=4,Ti=90", + phases=["alpha"], + ) + assert _payload(result)["host"] == "Ti" + with pytest.raises(ValueError, match="Host"): + prepare_cifs("TC4", tmp_path / "bad-host", offline=True, host="Ni") + with pytest.raises(ValueError, match="Equal"): + prepare_cifs("Ti-Nb", tmp_path / "tie", offline=True, atomic_percent="Ti=50,Nb=50") + + +@pytest.mark.parametrize( + "kwargs", + [ + {"phase_parameters": {"alpha": {"lattice": {"a": 3.0}}}}, + {"phase_parameters": {"alpha": {"lattice": {"aa": 3.0}, "citation": "test"}}}, + {"phase_parameters": {"alpha": {"lattice": {"a": float("nan")}, "citation": "test"}}}, + {"phase_parameters": {"alpha": {"fract": {"y": True}, "citation": "test"}}}, + {"phase_parameters": {"alpha": {"unknown": "test"}}}, + {"weight_percent": "Ti=90,Al=10"}, + {"nominal": "tc4", "atomic_percent": "Ti=100"}, + {"preview_wavelength_A": 0}, + ], +) +def test_invalid_inputs_fail_before_creating_output(tmp_path, kwargs): + destination = tmp_path / "invalid" + with pytest.raises(ValueError): + prepare_cifs("TC4", destination, offline=True, **kwargs) + assert not destination.exists() + + +def test_plain_element_set_does_not_invent_bulk_percentages(tmp_path): + with pytest.raises(ValueError, match="Provide"): + prepare_cifs("Ti-Al-V", tmp_path / "unknown", offline=True) + + +def test_json_duplicate_keys_are_rejected(tmp_path): + path = tmp_path / "duplicate.json" + path.write_text( + '{"schema":"diffractscout_phase_parameters_v1","phases":{"alpha":{"citation":"a","citation":"b"}}}' + ) + with pytest.raises(ValueError, match="Duplicate"): + read_phase_parameters(path) + + +def test_existing_output_remains_untouched(tmp_path): + output = tmp_path / "existing" + output.mkdir() + sentinel = output / "sample.txt" + sentinel.write_text("keep") + with pytest.raises(FileExistsError): + prepare_cifs("TC4", output, offline=True) + assert sentinel.read_text() == "keep" + assert list(output.iterdir()) == [sentinel] + + +def test_cli_prepare_and_bundle_verify(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("MP_API_KEY", "never-use-this-key") + output = tmp_path / "cli" + assert main(["prepare-cifs", "Ti-6Al-4V", "-o", str(output), "--offline", "--json"]) == 0 + response = json.loads(capsys.readouterr().out) + assert len(response["records"]) == 3 + assert main(["verify", str(output)]) == 0 + assert "PASS" in capsys.readouterr().out + + +def test_optional_independent_cif_reader_accepts_disorder_and_formula(tmp_path): + pymatgen_cif = pytest.importorskip("pymatgen.io.cif") + result = prepare_cifs("TC4", tmp_path / "interop", offline=True) + for record in result.records: + parser = pymatgen_cif.CifParser(str(record.cif_path)) + structure = parser.parse_structures(primitive=False, on_error="raise")[0] + fractions = structure.composition.fractional_composition.get_el_amt_dict() + assert fractions == pytest.approx({"Ti": 0.86204, "Al": 0.10195, "V": 0.03601}, abs=1e-8) + assert structure.volume == pytest.approx( + load_structure(record.cif_path).small_structure.cell.volume, rel=1e-8 + ) + + +def test_subsystem_cap_rejects_before_provider_construction(tmp_path): + class NoQuery: + def search_subsystem(self, *_args, **_kwargs): + raise AssertionError("cap must be checked first") + + output = tmp_path / "cap" + with pytest.raises(ValueError, match="max-subsystems"): + prepare_cifs("TC4", output, provider=NoQuery(), max_subsystems=1) + assert not output.exists() + + +@pytest.mark.parametrize("pass_key", [True, False]) +def test_candidate_failure_credentials_are_redacted_after_fallback(tmp_path, pass_key): + key = "secret-key-for-redaction-test" + + class Provider: + api_key = key + + def search_subsystem(self, *_args, **_kwargs): + return [CandidateRecord("mp-test", "Ti", space_group_number=229)] + + def download_candidates(self, *_args, **_kwargs): + raise RuntimeError(f"request failed with api_key={key}") + + result = prepare_cifs( + "TC4", + tmp_path / "redacted", + phases=["beta"], + provider=Provider(), + api_key=key if pass_key else None, + ) + assert result.exit_code == 0 + for path in result.output_dir.rglob("*"): + if path.is_file(): + assert key not in path.read_text(encoding="utf-8-sig") + assert "[redacted]" in (result.output_dir / "preparation.json").read_text(encoding="utf-8") + + +def test_publication_race_preserves_external_target(tmp_path, monkeypatch): + import diffractscout.pipeline as pipeline + + output = tmp_path / "race" + original = pipeline._rename_directory_noreplace + + def raced(source, target): + if target == output: + target.mkdir() + (target / "external.txt").write_text("keep external data") + original(source, target) + + monkeypatch.setattr(pipeline, "_rename_directory_noreplace", raced) + with pytest.raises(FileExistsError): + prepare_cifs("TC4", output, offline=True) + assert (output / "external.txt").read_text() == "keep external data" + assert not list(tmp_path.glob(".diffractscout-initial-*")) + + +def test_normalization_never_overwrites_source_or_destination(tmp_path): + source = tmp_path / "raw.cif" + _p1_bcc(source) + before = sha256_file(source) + with pytest.raises(ValueError, match="source"): + normalize_prototype(source, source, "beta") + assert sha256_file(source) == before + destination = tmp_path / "existing.cif" + destination.write_text("keep") + with pytest.raises(FileExistsError): + normalize_prototype(source, destination, "beta") + assert destination.read_text() == "keep" + + +def test_candidate_attempt_budget_is_visible_and_can_reach_later_valid_candidate(tmp_path): + class Provider: + def search_subsystem(self, *_args, **_kwargs): + return [ + CandidateRecord(f"mp-{index}", "Ti", index / 100, space_group_number=229) + for index in range(9) + ] + + def download_candidates(self, candidates, output, **_kwargs): + candidate = candidates[0] + path = output / "raw.cif" + _p1_bcc(path, second_site=candidate.material_id == "mp-8") + return [DownloadArtifact(candidate, path)] + + capped = prepare_cifs("TC4", tmp_path / "capped", phases=["beta"], provider=Provider()) + record = _payload(capped)["records"][0] + assert record["prototype"]["untried_candidate_count"] == 1 + assert record["prototype"]["source"].startswith("COD") + assert any("not attempted" in warning for warning in record["warnings"]) + extended = prepare_cifs( + "TC4", tmp_path / "extended", phases=["beta"], provider=Provider(), max_prototype_attempts=9 + ) + record = _payload(extended)["records"][0] + assert record["prototype"]["candidate"]["material_id"] == "mp-8" + assert record["prototype"]["untried_candidate_count"] == 0 diff --git a/tests/test_phase_cif.py b/tests/test_phase_cif.py new file mode 100644 index 0000000..3b3edf5 --- /dev/null +++ b/tests/test_phase_cif.py @@ -0,0 +1,580 @@ +import csv +import json +import re +from decimal import Decimal +from pathlib import Path + +import pytest + +from diffractscout.cli import main +from diffractscout.models import CandidateRecord +from diffractscout.phase_cif import ( + adapt_cif, + fetch_prototypes, + resolve_occupancies, + scaffold_resource, + select_prototype, +) +from diffractscout.structure import load_structure +from diffractscout.utils import sha256_file + +ROOT = Path(__file__).parents[1] +HCP = ROOT / "examples" / "inherited_cif2peaks" / "ti_nb_hcp_p63mmc.cif" +CMCM = ROOT / "examples" / "inherited_cif2peaks" / "ti_nb_orthorhombic_cmcm_partial_occupancy.cif" +BCC = ROOT / "examples" / "inherited_cif2peaks" / "ti_beta_bcc_im3m.cif" +ELEMENTS = ("Ti", "Al", "V") +CITATION = "Test citation for a caller-supplied lattice edit." + + +def _candidate( + material_id: str, + formula: str, + space_group_number: int, + *, + energy: float | None = 0.0, + symbol: str = "", + deprecated: bool = False, +) -> CandidateRecord: + return CandidateRecord( + material_id=material_id, + formula=formula, + energy_above_hull_eV_atom=energy, + space_group=symbol, + space_group_number=space_group_number, + deprecated=deprecated, + ) + + +def _tag(text: str, name: str) -> str: + match = re.search(rf"(?m)^{re.escape(name)}\s+(\S+)", text) + assert match is not None, name + return match.group(1).strip("'\"") + + +def _atom_rows(text: str, label: str) -> list[list[str]]: + return [line.split() for line in text.splitlines() if line.startswith(label + " ")] + + +def test_nominal_tc4_occupancies_sum_to_one() -> None: + basis, _note, occupancies = resolve_occupancies(nominal="tc4") + assert basis == "nominal_grade" + assert occupancies == { + "Ti": Decimal("0.86204"), + "Al": Decimal("0.10195"), + "V": Decimal("0.03601"), + } + assert sum(occupancies.values(), Decimal(0)) == Decimal(1) + _basis, _other, from_weight = resolve_occupancies(weight_percent="Ti=90,Al=6,V=4") + assert from_weight == occupancies + + +def test_composition_percentages_must_be_positive_and_complete() -> None: + with pytest.raises(ValueError, match="100"): + resolve_occupancies(weight_percent="Ti=50,Al=40") + with pytest.raises(ValueError, match="positive"): + resolve_occupancies(weight_percent="Ti=96,Al=0,V=4") + with pytest.raises(ValueError, match="exactly one"): + resolve_occupancies(nominal="tc4", weight_percent="Ti=90,Al=6,V=4") + + +def test_alpha_family_rejects_intermetallic_and_omega_prototypes() -> None: + ti3al = _candidate("mp-ti3al", "Ti3Al", 194, symbol="P6_3/mmc") + omega = _candidate("mp-omega", "Ti", 191, symbol="P6/mmm") + c14 = _candidate("mp-c14", "TiAl2", 194, symbol="P6_3/mmc") + alpha = _candidate("mp-46", "Ti", 194, energy=0.02, symbol="P6_3/mmc") + chosen = select_prototype( + [ti3al, omega, c14, alpha], + phase="alpha", + elements=ELEMENTS, + host="Ti", + ) + assert chosen is not None + assert chosen.material_id == "mp-46" + assert ( + select_prototype([ti3al, omega, c14], phase="alpha", elements=ELEMENTS, host="Ti") + is None + ) + assert ( + select_prototype( + [_candidate("mp-46", "Ti", 194, deprecated=True)], + phase="alpha", + elements=ELEMENTS, + host="Ti", + ) + is None + ) + + +def test_beta_prefers_elemental_host_and_rejects_pure_vanadium() -> None: + vanadium = _candidate("mp-v", "V", 229, energy=0.0) + alloy = _candidate("mp-tiv", "TiV", 229, energy=0.01) + beta = _candidate("mp-73", "Ti", 229, energy=0.15) + chosen = select_prototype( + [vanadium, alloy, beta], + phase="beta", + elements=ELEMENTS, + host="Ti", + ) + assert chosen is not None + assert chosen.material_id == "mp-73" + assert select_prototype([vanadium], phase="beta", elements=ELEMENTS, host="Ti") is None + + +def test_alpha_double_prime_rejects_an_oxide() -> None: + oxide = _candidate("mp-o", "TiO2", 63, symbol="Cmcm") + assert ( + select_prototype([oxide], phase="alpha-double-prime", elements=ELEMENTS, host="Ti") + is None + ) + + +def test_hcp_lattice_edit_keeps_space_group_and_coordinates(tmp_path: Path) -> None: + source_hash = sha256_file(HCP) + destination = tmp_path / "alpha.cif" + result = adapt_cif( + HCP, + destination, + nominal="TC4", + a=2.935, + b=2.935, + c=4.673, + citation=CITATION, + ) + + text = destination.read_text(encoding="utf-8") + assert text.startswith("# DiffractScout adapt:") + assert CITATION in text + assert "McHargue" in text + assert _tag(text, "_cell_length_a") == "2.935" + assert _tag(text, "_cell_length_b") == "2.935" + assert _tag(text, "_cell_length_c") == "4.673" + assert _tag(text, "_cell_angle_gamma") == "120" + assert _tag(text, "_cell_volume") != "35.080" + assert _atom_rows(text, "Ti1")[0][2:5] == ["0.3333", "0.6667", "0.25"] + loaded = load_structure(destination) + assert loaded.space_group_number == 194 + assert loaded.source_metadata["symmetry_crosscheck"] == "match" + assert result.symmetry_crosscheck == "match" + assert result.occupancies == {"Ti": "0.86204", "Al": "0.10195", "V": "0.03601"} + sidecar = json.loads(result.sidecar_path.read_text(encoding="utf-8")) + assert sidecar["source_sha256"] == source_hash + assert sidecar["composition_basis"] == "nominal_grade" + assert sidecar["lattice_edits"]["a"]["to"] == "2.935" + assert sidecar["coordinates_unchanged"] == ["x", "y", "z"] + assert sha256_file(HCP) == source_hash + + +def test_cmcm_lattice_edit_leaves_prototype_y(tmp_path: Path) -> None: + destination = tmp_path / "alpha-double-prime.cif" + adapt_cif( + CMCM, + destination, + nominal="ti64", + a=2.956, + b=5.05, + c=4.61, + citation=CITATION, + ) + + text = destination.read_text(encoding="utf-8") + assert "Brown, A.R.G." in text + assert _tag(text, "_cell_length_a") == "2.956" + assert _tag(text, "_cell_length_b") == "5.05" + assert _tag(text, "_cell_length_c") == "4.61" + assert _tag(text, "_cell_volume") != "71.491" + for label in ("Ti1", "Al1", "V1"): + assert _atom_rows(text, label)[0][2:5] == ["0", "0.2", "0.25"] + loaded = load_structure(destination) + assert loaded.space_group_number == 63 + assert loaded.source_metadata["symmetry_crosscheck"] == "match" + + +def test_cited_fractional_coordinate_replaces_only_that_axis(tmp_path: Path) -> None: + destination = tmp_path / "moved-y.cif" + adapt_cif( + CMCM, + destination, + nominal="ti-6al-4v", + fract={"y": 0.166667}, + citation=CITATION, + ) + text = destination.read_text(encoding="utf-8") + assert _atom_rows(text, "Ti1")[0][2:5] == ["0", "0.166667", "0.25"] + assert load_structure(destination).space_group_number == 63 + + +def test_adapt_refuses_missing_citation_source_overwrite_and_bad_symmetry(tmp_path: Path) -> None: + missing = tmp_path / "missing-citation.cif" + with pytest.raises(ValueError, match="citation"): + adapt_cif(HCP, missing, nominal="tc4", a=2.935, b=2.935, c=4.673) + assert not missing.exists() + + source = tmp_path / "source.cif" + source.write_bytes(CMCM.read_bytes()) + before = sha256_file(source) + with pytest.raises(ValueError, match="source"): + adapt_cif(source, source, nominal="tc4") + assert sha256_file(source) == before + + existing = tmp_path / "existing.cif" + existing.write_text("keep", encoding="utf-8") + with pytest.raises(FileExistsError): + adapt_cif(CMCM, existing, nominal="tc4") + assert existing.read_text(encoding="utf-8") == "keep" + + broken = tmp_path / "broken.cif" + with pytest.raises(ValueError, match="cross-check"): + adapt_cif(BCC, broken, nominal="tc4", gamma=95, citation=CITATION) + assert not broken.exists() + assert not Path(str(broken).replace(".cif", ".adapt.json")).exists() + + +def test_beta_composition_edit_keeps_im3m(tmp_path: Path) -> None: + destination = tmp_path / "beta.cif" + result = adapt_cif(BCC, destination, nominal="tc4") + text = destination.read_text(encoding="utf-8") + assert "Wyckoff" in text + assert "_exptl_crystal_density_diffrn" not in text + assert _tag(text, "_cell_length_a") == "3.3065" + assert "Ti0.86204" in text + loaded = load_structure(destination) + assert loaded.space_group_number == 229 + assert loaded.source_metadata["symmetry_crosscheck"] == "match" + assert result.warnings + assert all(item.startswith("Partial occupancies are included") for item in result.warnings) + sidecar = json.loads((tmp_path / "beta.adapt.json").read_text(encoding="utf-8")) + assert sidecar["citation"] is None + assert sidecar["lattice_edits"] == {} + + +def test_equivalent_lattice_axes_are_linked_and_conflicts_refused(tmp_path: Path) -> None: + cubic = adapt_cif(BCC, tmp_path / "cubic.cif", nominal="tc4", a=3.2, citation=CITATION) + assert load_structure(cubic.cif_path).cell_parameters[:3] == (3.2, 3.2, 3.2) + sidecar = json.loads(cubic.sidecar_path.read_text(encoding="utf-8")) + assert sidecar["lattice_axes_implied_by_symmetry"] == ["b", "c"] + hexagonal = adapt_cif(HCP, tmp_path / "hex.cif", nominal="tc4", a=2.94, c=4.69, citation=CITATION) + assert load_structure(hexagonal.cif_path).cell_parameters[:3] == (2.94, 2.94, 4.69) + with pytest.raises(ValueError, match="equal cell axes"): + adapt_cif(BCC, tmp_path / "conflict.cif", nominal="tc4", a=3.2, b=3.3, citation=CITATION) + + +def test_formula_and_Z_use_expanded_multiplicities(tmp_path: Path) -> None: + result = adapt_cif(BCC, tmp_path / "formula.cif", nominal="tc4") + text = result.cif_path.read_text(encoding="utf-8") + assert _tag(text, "_cell_formula_units_Z") == "2" + expected = 0.86204 * 47.867 + 0.10195 * 26.9815385 + 0.03601 * 50.9415 + assert float(_tag(text, "_chemical_formula_weight")) == pytest.approx(expected, rel=1e-8) + with pytest.raises(ValueError, match="interstitial"): + adapt_cif(BCC, tmp_path / "oxygen.cif", atomic_percent="Ti=90,O=10") + + +def test_adapt_publish_race_never_replaces_external_files(tmp_path, monkeypatch): + import diffractscout.phase_cif as phase_cif + + original = phase_cif._publish_new_file + output = tmp_path / "race.cif" + + def publish(temporary, destination): + if destination == output: + destination.write_text("external CIF", encoding="utf-8") + original(temporary, destination) + + monkeypatch.setattr(phase_cif, "_publish_new_file", publish) + with pytest.raises(FileExistsError): + adapt_cif(BCC, output, nominal="tc4") + assert output.read_text(encoding="utf-8") == "external CIF" + assert not output.with_suffix(".adapt.json").exists() + + +def test_adapt_sidecar_race_preserves_external_sidecar(tmp_path, monkeypatch): + import diffractscout.phase_cif as phase_cif + + original = phase_cif._publish_new_file + output = tmp_path / "race.cif" + sidecar = output.with_suffix(".adapt.json") + + def publish(temporary, destination): + if destination == sidecar: + destination.write_text("external sidecar", encoding="utf-8") + original(temporary, destination) + + monkeypatch.setattr(phase_cif, "_publish_new_file", publish) + with pytest.raises(FileExistsError): + adapt_cif(BCC, output, nominal="tc4") + assert sidecar.read_text(encoding="utf-8") == "external sidecar" + assert not output.exists() + + +def test_distinct_fractional_axis_and_mixed_site_are_refused(tmp_path: Path) -> None: + distinct = tmp_path / "two-y.cif" + distinct.write_text( + """data_test +_symmetry_space_group_name_H-M 'P 1' +_cell_length_a 4 +_cell_length_b 5 +_cell_length_c 6 +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +loop_ +_symmetry_equiv_pos_as_xyz +x,y,z +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +Ti1 Ti 0.10 0.20 0.30 1 +Ti2 Ti 0.10 0.40 0.30 1 +""", + encoding="utf-8", + ) + refused = tmp_path / "refused.cif" + with pytest.raises(ValueError, match="fractional y"): + adapt_cif(distinct, refused, nominal="tc4", fract={"y": 0.2}, citation=CITATION) + assert not refused.exists() + + mixed = tmp_path / "mixed.cif" + mixed.write_text( + distinct.read_text(encoding="utf-8").replace( + "Ti2 Ti 0.10 0.40 0.30 1", + "O1 O 0.10 0.20 0.30 1", + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="interstitial"): + adapt_cif(mixed, tmp_path / "mixed-out.cif", nominal="tc4") + + +def test_interstitial_site_is_preserved(tmp_path: Path) -> None: + source = tmp_path / "with-oxygen.cif" + source.write_text( + """data_test +_symmetry_space_group_name_H-M 'P 1' +_cell_length_a 4.2 +_cell_length_b 5.3 +_cell_length_c 6.4 +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +loop_ +_symmetry_equiv_pos_as_xyz +x,y,z +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +Ti1 Ti 0.13 0.27 0.41 1 +O1 O 0.61 0.19 0.73 1 +""", + encoding="utf-8", + ) + destination = tmp_path / "kept-oxygen.cif" + adapt_cif(source, destination, nominal="tc4") + loaded = load_structure(destination) + assert {site.element.name for site in loaded.small_structure.sites} == {"Ti", "Al", "V", "O"} + assert loaded.source_metadata["symmetry_crosscheck"] == "match" + assert _atom_rows(destination.read_text(encoding="utf-8"), "O1")[0][2:5] == ["0.61", "0.19", "0.73"] + + +def test_packaged_scaffold_is_the_cod_entry() -> None: + data = scaffold_resource().read_bytes() + assert b"1523304" in data + assert b"Nb" in data + + +def test_non_ti_fetch_does_not_receive_ti_scaffold(tmp_path: Path) -> None: + result = fetch_prototypes("Ni", tmp_path / "nickel", phases=["alpha-double-prime"]) + assert result.exit_code == 3 + assert result.records[0].status == "missing" + assert not (result.output_dir / "alpha-double-prime.cif").exists() + + +def test_fetch_query_and_download_errors_redact_provider_credentials(tmp_path): + from diffractscout.models import DownloadArtifact + + key = "fetch-secret-key" + + class QueryFailure: + api_key = key + + def search_subsystem(self, *_args, **_kwargs): + raise RuntimeError(f"Query failed for {key}") + + with pytest.raises(RuntimeError) as captured: + fetch_prototypes("TC4", tmp_path / "query", phases=["alpha"], provider=QueryFailure()) + assert key not in str(captured.value) + assert "[redacted]" in str(captured.value) + + class DownloadFailure: + api_key = key + + def search_subsystem(self, *_args, **_kwargs): + return [_candidate("mp-46", "Ti", 194)] + + def download_candidates(self, candidates, *_args, **_kwargs): + return [DownloadArtifact(candidates[0], None, status="failed", error=f"download failed for {key}")] + + result = fetch_prototypes("TC4", tmp_path / "download", phases=["alpha"], provider=DownloadFailure()) + assert result.exit_code == 3 + assert key not in result.index_path.read_text(encoding="utf-8") + assert "[redacted]" in result.index_path.read_text(encoding="utf-8") + + +def test_fetch_alpha_double_prime_uses_scaffold_without_a_provider(tmp_path: Path) -> None: + output = tmp_path / "prototypes" + result = fetch_prototypes("Ti-6Al-4V", output, phases=["alpha-double-prime"]) + + assert result.exit_code == 0 + assert result.host == "Ti" + copied = output / "alpha-double-prime.cif" + assert copied.read_bytes() == scaffold_resource().read_bytes() + rows = list(csv.DictReader(result.index_path.open(encoding="utf-8"))) + assert rows[0]["status"] == "scaffold" + assert rows[0]["target_composition"] == "false" + assert rows[0]["source"] == "COD 1523304" + assert "not the target alloy" in rows[0]["note"] + assert "Nb" in rows[0]["formula"] + + +def test_template_supplies_alpha_without_a_key(tmp_path: Path) -> None: + output = tmp_path / "from-template" + result = fetch_prototypes( + "Ti-Al-V", + output, + phases=["alpha"], + templates={"alpha": HCP}, + ) + assert result.exit_code == 0 + assert (output / "alpha.cif").read_bytes() == HCP.read_bytes() + assert result.records[0].status == "template" + assert result.records[0].target_composition is False + + +def test_subsystem_cap_stops_before_a_provider_call(tmp_path: Path) -> None: + class Boom: + def search_subsystem(self, *_args, **_kwargs): + raise AssertionError("subsystem cap should stop before the provider") + + with pytest.raises(ValueError, match="7"): + fetch_prototypes( + "Ti-Al-V", + tmp_path / "capped", + phases=["alpha"], + provider=Boom(), + max_subsystems=1, + ) + assert not (tmp_path / "capped").exists() + + +def test_downloaded_prototype_is_copied_under_a_stable_name(tmp_path: Path) -> None: + class Provider: + def search_subsystem(self, chemsys, **_kwargs): + if chemsys != "Ti": + return [] + return [_candidate("mp-46", "Ti", 194, energy=0.02, symbol="P6_3/mmc")] + + def download_candidates(self, candidates, output_dir, **kwargs): + from diffractscout.models import DownloadArtifact + + assert kwargs["include_elasticity"] is False + assert kwargs["conventional_unit_cell"] is True + path = Path(output_dir) / "mp-46.cif" + path.write_text( + "data_mp\n_symmetry_space_group_name_H-M 'P 1'\n" + "_cell_length_a 1\n_cell_length_b 1\n_cell_length_c 1\n" + "_cell_angle_alpha 90\n_cell_angle_beta 90\n_cell_angle_gamma 90\n" + "loop_\n_atom_site_fract_x\n_atom_site_fract_y\n_atom_site_fract_z\n" + "0 0 0\n", + encoding="utf-8", + ) + return [ + DownloadArtifact(candidate=candidates[0], cif_path=path, status="ok") + ] + + output = tmp_path / "downloaded" + result = fetch_prototypes( + "Ti64", + output, + phases=["alpha"], + provider=Provider(), + ) + assert result.exit_code == 0 + text = (output / "alpha.cif").read_text(encoding="utf-8") + assert "P 1" in text + assert result.records[0].target_composition is False + assert result.records[0].space_group == "194" + assert "P1" in result.records[0].note + assert not list(output.glob("*elasticity*")) + + +def test_partial_prototype_fetch_exits_3(tmp_path: Path) -> None: + class Empty: + def search_subsystem(self, *_args, **_kwargs): + return [] + + output = tmp_path / "partial" + result = fetch_prototypes( + "Ti-Al-V", + output, + phases=["alpha", "alpha-double-prime"], + provider=Empty(), + ) + assert result.exit_code == 3 + assert (output / "alpha-double-prime.cif").is_file() + assert not (output / "alpha.cif").exists() + statuses = {record.phase: record.status for record in result.records} + assert statuses == {"alpha": "missing", "alpha-double-prime": "scaffold"} + + +def test_cli_scaffold_and_missing_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MP_API_KEY", raising=False) + output = tmp_path / "cli-prototypes" + assert ( + main( + [ + "fetch-prototypes", + "Ti-6Al-4V", + "-o", + str(output), + "--phase", + "alpha-double-prime", + ] + ) + == 0 + ) + assert b"1523304" in (output / "alpha-double-prime.cif").read_bytes() + + missing_key = tmp_path / "needs-key" + assert ( + main(["fetch-prototypes", "Ti-6Al-4V", "-o", str(missing_key), "--phase", "alpha"]) + == 2 + ) + assert not missing_key.exists() + + destination = tmp_path / "cli-adapt.cif" + assert ( + main( + [ + "adapt", + str(HCP), + "-o", + str(destination), + "--nominal", + "tc4", + "--a", + "2.935", + "--b", + "2.935", + "--c", + "4.673", + ] + ) + == 2 + ) + assert not destination.exists()