diff --git a/AGENTS.md b/AGENTS.md index eb92b88..99a9771 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,4 +9,4 @@ - Write machine-readable results to standard output and concise diagnostics to standard error where practical. - Add tests for numerical rules, invalid input, missing values, non-positive values, and exported files. - Do not claim the skill is research-ready until scientific fixtures and end-to-end tests pass. -- Keep GeoSkills v1 limited to REE pattern plots; do not add other geochemical diagram types before a versioned scope change. +- Preserve the published v0.1.0 REE workflow. GeoSkills v0.2.0 may add only trace-element spider diagrams; defer TAS, Harker, and other diagram families to later reviewed versions. diff --git a/README.md b/README.md index eb6843b..0a377a9 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,26 @@ GeoSkills 是一个面向地质学与地球化学研究的本地 Agent Skill。 -**v1 只做一件事:生成球粒陨石标准化的稀土元素(REE)配分图。** -蛛网图、TAS、Harker 图和其他地球化学图解不属于第一个版本。 +公开的 v0.1.0 只提供球粒陨石标准化 REE 配分图。 +`feature/spider-diagram-v0.2` 分支是已完成人工审核的 v0.2.0 发布候选版:保留 REE,并新增原始地幔或 N-MORB 标准化微量元素蛛网图。TAS、Harker 和其他图解仍不在本版本范围内。 -## v1 可以做什么 +## 当前可以做什么 - 读取 `.csv`、逗号或制表符分隔的 `.txt`、以及 `.xlsx`; - 识别常见的“每行一个样品”表格; - 在结构明确时,自动转换论文补充材料常见的“样品在列、元素在行”表格; - 检查样品编号、ppm 单位、缺失值、低于检出限、非数字、零和负数; - 使用 Sun & McDonough(1989)C1 球粒陨石值标准化 La–Lu; -- 生成投稿尺寸的 REE 配分图,并输出 SVG、PDF、600 dpi TIFF、600 dpi PNG; +- 使用 Sun & McDonough(1989)原始地幔、脚注明确修改的原始地幔或 N-MORB 值生成微量元素蛛网图; +- 对明确标为 wt% 的 `K2O`、`P2O5`、`TiO2` 作可追溯的元素 ppm 换算; +- 生成投稿尺寸的 REE 配分图或蛛网图,并输出 SVG、PDF、600 dpi TIFF、600 dpi PNG; - 同步输出实际绘图数据 CSV 和机器可读的 JSON 运行报告; - 可选择完整四边框,以及经过碰撞检查的图内图例; - 全程在本地处理数据,绘图脚本不请求网络服务。 ## 安装到 Codex -可在 Codex 中让 `$skill-installer` 从以下仓库安装 `skills/geoskills`: +以下公开仓库的默认分支目前仍是经过审核的 v0.1.0 REE 版本;v0.2.0 蛛网图发布候选版通过功能分支和 Pull Request 接受合并检查: ```text https://github.com/gronbow/GeoSkills @@ -58,6 +60,20 @@ py -m venv .venv .\.venv\Scripts\python.exe skills\geoskills\scripts\plot_ree.py skills\geoskills\examples\synthetic_ree_data.csv --output-dir outputs\ree_figure --axes-frame full --legend-layout inside-auto ``` +检查蛛网图示例数据: + +```powershell +.\.venv\Scripts\python.exe skills\geoskills\scripts\inspect_spider_data.py skills\geoskills\examples\synthetic_spider_data.csv +``` + +生成原始地幔标准化蛛网图: + +```powershell +.\.venv\Scripts\python.exe skills\geoskills\scripts\plot_spider.py skills\geoskills\examples\synthetic_spider_data.csv --reference pm-sm89-modified --output-dir outputs\spider_figure +``` + +默认方案为原文脚注推荐的 `pm-sm89-modified`;也可以明确选择 Table 1 印刷版 `pm-sm89` 或 `nmorb-sm89`。程序不会根据曲线形状替用户猜测标准化方案。 + 程序不会修改原始表格。重复使用同一输出名称时,只有显式加入 `--overwrite` 才会替换已有结果。 ## 项目结构 @@ -80,15 +96,16 @@ GeoSkills/ ## 科学与隐私边界 -- 当前仅接受明确为 ppm 的 REE 浓度;不会静默换算 wt% 或 ppb。 -- 缺失值保留为曲线断点;零和负数会阻止对数坐标绘图。 +- 直接元素浓度必须明确为 ppm;只有 `K2O`、`P2O5`、`TiO2` 可在明确为 wt% 时换算。 +- 缺失值和低于检出限状态保留为曲线断点;零和负数会阻止对数坐标绘图。 - 标准化表包含文献、DOI、表格位置、版本和核对记录。 +- 原始地幔表保留文献 Table 1 的 Cs、Pb 数值,并明确提示原文脚注中的 modified 版本;程序不会静默混用。 - 图形可以展示富集程度、斜率和平行性,但不能单独证明岩浆源区、部分熔融、分离结晶或构造环境。 - `local_data/` 和 `outputs/` 已排除在 Git 之外;不要提交私人或未发表数据。 ## 当前状态 -GeoSkills v0.1.0 是仅包含 REE 配分图功能的公开测试版。当前版本已通过 23 项自动测试、真实论文数据端到端测试、投稿导出检查和 Git 历史隐私审计;正式投稿前仍应由研究者核对数据列、单位、标准化方案和最终图件。 +GeoSkills v0.1.0 是仅包含 REE 配分图的公开测试版。v0.2.0 蛛网图发布候选版已通过全部 41 项自动测试、真实数据测试、导出审计和人工图形复核;远端 CI 与 Pull Request 合并完成后再作为稳定版本发布。 ## 许可 diff --git a/skills/geoskills/SKILL.md b/skills/geoskills/SKILL.md index eef148b..419ec55 100644 --- a/skills/geoskills/SKILL.md +++ b/skills/geoskills/SKILL.md @@ -1,49 +1,72 @@ --- name: geoskills -description: "Inspect whole-rock geochemical tables and create validated, submission-ready chondrite-normalized rare-earth-element pattern plots from CSV, TXT, or Excel data. Use GeoSkills v1 only for REE pattern workflows: identify La-Lu columns, check ppm units and invalid values, group samples, generate publication REE diagrams, export editable SVG/PDF plus 600 dpi TIFF/PNG and source data, or explain why a geochemical table cannot yet be plotted safely. Do not use v1 for spider diagrams, TAS, Harker diagrams, or other geochemical plots." +description: "Inspect whole-rock geochemical tables and create validated, submission-oriented normalized element-pattern figures from CSV, TXT, or Excel data. Use GeoSkills for chondrite-normalized rare-earth-element patterns or primitive-mantle/N-MORB-normalized trace-element spider diagrams: identify sample, group, element, and supported oxide columns; verify units and invalid values; apply versioned Sun and McDonough (1989) references; export editable SVG/PDF plus 600 dpi TIFF/PNG and source data; or explain why a table cannot yet be plotted safely. Do not use the current version for TAS, Harker, isotope, discrimination, or other geochemical diagrams." --- -# GeoSkills v1: REE Patterns +# GeoSkills v0.2.0 release candidate -Create reproducible REE pattern plots through a deterministic local Python workflow. Keep data processing and normalization in scripts; do not ask a language model to calculate normalized values directly. +Create reproducible REE patterns and trace-element spider diagrams through deterministic local Python scripts. Use the language model to select and explain the workflow, never to calculate normalized ratios manually. ## Development status -Treat GeoSkills v1 as an REE-only functional beta with a publication-oriented export workflow. Do not add or imply spider diagrams, TAS, Harker diagrams, or other plot types in v1. The environment checker, read-only table inspector, confirmed `Chondrite_SM89` reference, normalization command, plotting command, and automated export tests exist. Both flat tables and unambiguous transposed paper supplements are supported. Continue to report caveats and inspect every generated figure visually at its final physical size before submission. +Treat v0.1.0 REE plotting as the stable published baseline. The v0.2.0 spider workflow is a reviewed release candidate: its scientific fixtures, real-data output, export QA, Skill validation, and user review have passed. Do not imply support for TAS, Harker, isotope, or discrimination diagrams. -## Workflow +## Route the request -1. Run `scripts/check_environment.py` with the Python interpreter that will execute the plotting workflow. -2. Run `scripts/inspect_data.py INPUT` to inspect the input file without modifying it. Allow the inspector to adapt an unambiguous elements-by-row paper supplement in memory. For a multi-sheet workbook, rerun it with `--sheet SHEET_NAME` after the user chooses a sheet. -3. Identify the worksheet, sample identifier, optional group column, REE columns, and units. -4. Stop and request clarification when units or column mappings are ambiguous. -5. Validate missing, non-numeric, zero, and negative values before logarithmic plotting. -6. Run `scripts/normalize_ree.py INPUT --output OUTPUT.csv` only after inspection passes. Use the named, versioned reference composition stored in `assets/`; do not copy values into prompts or recalculate them manually. -7. Run `scripts/plot_ree.py INPUT --output-dir OUTPUT_DIR` to generate editable SVG/PDF, 600 dpi LZW-compressed TIFF, 600 dpi PNG, normalized source-data CSV, and a JSON run report from the same figure object. The default `--y-margin 0.08` sets compact, data-led log-axis limits, then rounds them to clean powers or decimal boundaries without clipping data below or above unity. The publication default omits background gridlines; use `--grid-style major` only when a target journal or comparison needs them. Use `--axes-frame full --legend-layout inside-auto` when a four-sided plot frame and a boxed in-axes legend are required; the plotter checks for overlap and safely uses the right-side legend when no clear in-axes position exists. -8. Inspect the PNG or TIFF visually at the declared final size. Confirm readable text, unobstructed data, interpretable grayscale/symbol encoding, correct legend mapping, and that no empty log-scale decade has been retained only for a reference line. -9. Return the publication bundle with its configuration record, validation report, source data, and concise scientific caveats. +- For chondrite-normalized La–Lu patterns, use the REE workflow. +- For multi-element primitive-mantle or N-MORB-normalized patterns, use the spider workflow. +- Stop and clarify when the requested diagram, unit, reference composition, sample column, or group column is ambiguous. + +## REE workflow + +1. Run `scripts/check_environment.py`. +2. Run `scripts/inspect_data.py INPUT`. +3. Confirm the worksheet, sample identifier, optional group, ppm units, and REE mapping. +4. Run `scripts/normalize_ree.py INPUT --output OUTPUT.csv`. +5. Run `scripts/plot_ree.py INPUT --output-dir OUTPUT_DIR`. +6. Inspect the final-size PNG/TIFF and return the complete figure bundle. + +Use `--axes-frame full --legend-layout inside-auto` when a four-sided frame and collision-checked in-axes legend are requested. The plotter safely falls back to an outside-right legend. + +## Spider workflow + +1. Run `scripts/check_environment.py`. +2. Run `scripts/inspect_spider_data.py INPUT`. +3. Confirm the worksheet, sample identifier, optional group, direct elemental ppm columns, and any supported oxide columns. +4. Choose `pm-sm89`, `pm-sm89-modified`, or `nmorb-sm89`; do not select a reference from the apparent shape of the data. +5. Run `scripts/normalize_spider.py INPUT --reference pm-sm89-modified --output OUTPUT.csv`. +6. Run `scripts/plot_spider.py INPUT --reference pm-sm89-modified --output-dir OUTPUT_DIR`. +7. Inspect the final-size PNG/TIFF, the normalized source-data CSV, conversion records, warnings, and JSON report. + +The spider plot defaults to a full frame, no background grid, and `inside-auto` legend placement. It exports editable SVG/PDF, 600 dpi LZW TIFF, 600 dpi PNG, exact normalized source data, and a machine-readable report from the same figure object. ## Scientific guardrails -- Use the REE order `La, Ce, Pr, Nd, Sm, Eu, Gd, Tb, Dy, Ho, Er, Tm, Yb, Lu`; do not require Pm. -- Require concentration units to be known before normalization. The first release targets ppm input. -- Preserve missing values as gaps unless the user explicitly selects and records another policy. -- Reject zero and negative concentrations on a logarithmic axis; report affected samples and elements. -- Set log-axis limits from the finite positive normalized values, with an 8% default visual margin; use clean integer bounds at or above unity and clean decimal bounds below unity, never clip data, and do not retain an empty decade merely to display `y = 1`. -- Keep v1 limited to chondrite-normalized REE patterns. Route requests for other geochemical diagrams to a future version instead of fabricating unsupported boundaries or reference arrays. -- When a full axes frame or in-axes legend is requested, preserve the data hierarchy: show top and right spines without redundant ticks, place the legend only in a collision-free region, and fall back to a separate right-side key rather than cover data. -- Do not invent or silently substitute reference values. -- Describe visible patterns conservatively. Do not infer petrogenesis from an REE plot alone. -- Keep user data local by default and do not make network requests during plotting. +- Require confirmed ppm units for direct element concentrations. +- Convert only explicit `K2O`, `P2O5`, and `TiO2` wt% columns to K, P, and Ti ppm. Record the CIAAW/IUPAC atomic weights, formula, and factor used. +- Preserve blanks and below-detection-limit states as gaps. Never replace them with zero or an invented detection limit. +- Reject finite zero and negative values on logarithmic axes. +- Preserve the cited Sun and McDonough (1989) incompatibility order even when the user selects a subset. +- Use only the versioned local assets `PrimitiveMantle_SM89`, `PrimitiveMantleModified_SM89`, and `NMORB_SM89` for the spider draft. +- Use the source footnote's modified primitive mantle as the spider default. Keep the printed and modified variants separate; do not silently replace Cs or Pb, and warn when an affected element is plotted with the printed variant. +- Set log limits from finite positive ratios, add a declared margin, round to clean decimal bounds without clipping, and show unity only when it lies inside the range. +- Use colour plus line style for groups and sample symbols so colour is not the sole identifier. +- Keep user data local; plotting scripts must not make network requests. +- Describe enrichment, depletion, slopes, and visible anomalies conservatively. Do not assign a unique source, melting process, mineral control, alteration history, or tectonic setting from one normalized pattern. ## Related resources | Resource | Use it when | |---|---| -| [scripts/check_environment.py](scripts/check_environment.py) | Check whether the selected Python environment contains the required packages | -| [scripts/inspect_data.py](scripts/inspect_data.py) | Read CSV, TXT, or Excel input and produce a structured validation report | -| [scripts/normalize_ree.py](scripts/normalize_ree.py) | Normalize validated ppm concentrations and create a new ratio table | -| [scripts/plot_ree.py](scripts/plot_ree.py) | Generate the validated REE figure bundle and machine-readable run report | -| [assets/normalization/chondrite-sm89.json](assets/normalization/chondrite-sm89.json) | Audit the exact `Chondrite_SM89` values, source, units, and verification record | -| [references/data-contract.md](references/data-contract.md) | Inspect columns, identifiers, units, missing values, and input errors | -| [references/scientific-method.md](references/scientific-method.md) | Implement or audit normalization, element order, axes, and interpretation limits | +| [scripts/inspect_data.py](scripts/inspect_data.py) | Inspect REE input | +| [scripts/normalize_ree.py](scripts/normalize_ree.py) | Normalize REE to C1 chondrite | +| [scripts/plot_ree.py](scripts/plot_ree.py) | Create the REE figure bundle | +| [scripts/inspect_spider_data.py](scripts/inspect_spider_data.py) | Inspect trace elements, units, BDL states, and supported oxides | +| [scripts/normalize_spider.py](scripts/normalize_spider.py) | Normalize trace elements to primitive mantle or N-MORB | +| [scripts/plot_spider.py](scripts/plot_spider.py) | Create the spider-diagram figure bundle | +| [assets/normalization/primitive-mantle-sm89.json](assets/normalization/primitive-mantle-sm89.json) | Audit primitive-mantle values and the Cs/Pb footnote | +| [assets/normalization/primitive-mantle-modified-sm89.json](assets/normalization/primitive-mantle-modified-sm89.json) | Audit the explicit footnote-modified Cs/Pb variant | +| [assets/normalization/nmorb-sm89.json](assets/normalization/nmorb-sm89.json) | Audit N-MORB values | +| [references/data-contract.md](references/data-contract.md) | Audit the REE input contract | +| [references/scientific-method.md](references/scientific-method.md) | Audit the REE method | +| [references/spider-method.md](references/spider-method.md) | Audit spider input, normalization, conversion, plotting, and interpretation rules | diff --git a/skills/geoskills/agents/openai.yaml b/skills/geoskills/agents/openai.yaml index 62f77d8..29c7a29 100644 --- a/skills/geoskills/agents/openai.yaml +++ b/skills/geoskills/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "GeoSkills" - short_description: "Create submission-ready chondrite-normalized REE plots" - default_prompt: "Use $geoskills to inspect my geochemical table and create a submission-ready chondrite-normalized REE pattern figure bundle." + short_description: "Create validated REE and trace-element pattern plots" + default_prompt: "Use $geoskills to inspect my geochemical table and create a validated REE or trace-element spider-plot figure bundle." diff --git a/skills/geoskills/assets/normalization/nmorb-sm89.json b/skills/geoskills/assets/normalization/nmorb-sm89.json new file mode 100644 index 0000000..3072609 --- /dev/null +++ b/skills/geoskills/assets/normalization/nmorb-sm89.json @@ -0,0 +1,136 @@ +{ + "schema_version": "1.0", + "id": "NMORB_SM89", + "display_name": "Sun & McDonough (1989) N-MORB", + "diagram_type": "trace_element_spider", + "unit": "ppm", + "element_order": [ + "Cs", + "Tl", + "Rb", + "Ba", + "W", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Mo", + "Sr", + "P", + "Nd", + "F", + "Sm", + "Zr", + "Hf", + "Eu", + "Sn", + "Sb", + "Ti", + "Gd", + "Tb", + "Dy", + "Li", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + "default_plot_order": [ + "Rb", + "Ba", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Sr", + "P", + "Nd", + "Zr", + "Hf", + "Sm", + "Eu", + "Ti", + "Gd", + "Tb", + "Dy", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + "values": { + "Cs": 0.007, + "Tl": 0.0014, + "Rb": 0.56, + "Ba": 6.3, + "W": 0.01, + "Th": 0.12, + "U": 0.047, + "Nb": 2.33, + "Ta": 0.132, + "K": 600, + "La": 2.5, + "Ce": 7.5, + "Pb": 0.3, + "Pr": 1.32, + "Mo": 0.31, + "Sr": 90, + "P": 510, + "Nd": 7.3, + "F": 210, + "Sm": 2.63, + "Zr": 74, + "Hf": 2.05, + "Eu": 1.02, + "Sn": 1.1, + "Sb": 0.01, + "Ti": 7600, + "Gd": 3.68, + "Tb": 0.67, + "Dy": 4.55, + "Li": 4.3, + "Y": 28, + "Ho": 1.01, + "Er": 2.97, + "Tm": 0.456, + "Yb": 3.05, + "Lu": 0.455 + }, + "source": { + "authors": "S.-s. Sun and W. F. McDonough", + "year": 1989, + "title": "Chemical and isotopic systematics of oceanic basalts: implications for mantle composition and processes", + "publication": "Geological Society, London, Special Publications 42, 313-345", + "doi": "10.1144/GSL.SP.1989.042.01.19", + "table": "Table 1, N-type MORB column", + "note": "The N-type MORB values in Table 1 are literature-survey estimates constrained by internal consistency of elemental ratios." + }, + "crosscheck": { + "implementation": "pyrolite NMORB_SunMcDonough1989.csv", + "release": "v0.3.7", + "commit": "ab651b448ecd2ce1bc22d752b80a977a1f4dd1ca", + "url": "https://raw.githubusercontent.com/morganjwilliams/pyrolite/v0.3.7/pyrolite/data/geochem/refcomp/NMORB_SunMcDonough1989.csv" + }, + "verification": { + "status": "confirmed", + "verified_on": "2026-07-28", + "methods": [ + "transcribed against Sun and McDonough (1989), Table 1", + "cross-checked against pinned pyrolite v0.3.7 reference data" + ] + } +} diff --git a/skills/geoskills/assets/normalization/primitive-mantle-modified-sm89.json b/skills/geoskills/assets/normalization/primitive-mantle-modified-sm89.json new file mode 100644 index 0000000..480d5cd --- /dev/null +++ b/skills/geoskills/assets/normalization/primitive-mantle-modified-sm89.json @@ -0,0 +1,139 @@ +{ + "schema_version": "1.0", + "id": "PrimitiveMantleModified_SM89", + "display_name": "Sun & McDonough (1989) modified primitive mantle", + "diagram_type": "trace_element_spider", + "unit": "ppm", + "element_order": [ + "Cs", + "Tl", + "Rb", + "Ba", + "W", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Mo", + "Sr", + "P", + "Nd", + "F", + "Sm", + "Zr", + "Hf", + "Eu", + "Sn", + "Sb", + "Ti", + "Gd", + "Tb", + "Dy", + "Li", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + "default_plot_order": [ + "Rb", + "Ba", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Sr", + "P", + "Nd", + "Zr", + "Hf", + "Sm", + "Eu", + "Ti", + "Gd", + "Tb", + "Dy", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + "values": { + "Cs": 0.0079, + "Tl": 0.005, + "Rb": 0.635, + "Ba": 6.989, + "W": 0.02, + "Th": 0.085, + "U": 0.021, + "Nb": 0.713, + "Ta": 0.041, + "K": 250, + "La": 0.687, + "Ce": 1.775, + "Pb": 0.071, + "Pr": 0.276, + "Mo": 0.063, + "Sr": 21.1, + "P": 95, + "Nd": 1.354, + "F": 26, + "Sm": 0.444, + "Zr": 11.2, + "Hf": 0.309, + "Eu": 0.168, + "Sn": 0.17, + "Sb": 0.005, + "Ti": 1300, + "Gd": 0.596, + "Tb": 0.108, + "Dy": 0.737, + "Li": 1.6, + "Y": 4.55, + "Ho": 0.164, + "Er": 0.48, + "Tm": 0.074, + "Yb": 0.493, + "Lu": 0.074 + }, + "source": { + "authors": "S.-s. Sun and W. F. McDonough", + "year": 1989, + "title": "Chemical and isotopic systematics of oceanic basalts: implications for mantle composition and processes", + "publication": "Geological Society, London, Special Publications 42, 313-345", + "doi": "10.1144/GSL.SP.1989.042.01.19", + "table": "Table 1, Primitive mantle column with footnote-b Cs and Pb substitutions", + "note": "This explicit modified variant uses the Table 1 footnote-b values Cs=0.0079 ppm and Pb=0.071 ppm for mantle-normalized diagrams. Every other element retains the printed Primitive mantle column value." + }, + "crosscheck": { + "primary_source": "Sun and McDonough (1989), Table 1 and footnote b", + "base_asset": "primitive-mantle-sm89.json", + "overrides": { + "Cs": 0.0079, + "Pb": 0.071 + } + }, + "verification": { + "status": "confirmed", + "verified_on": "2026-07-28", + "methods": [ + "derived element-by-element from the confirmed printed Primitive mantle asset", + "applied only the two substitutions stated in Table 1 footnote b", + "verified that all other 34 values are unchanged" + ] + } +} diff --git a/skills/geoskills/assets/normalization/primitive-mantle-sm89.json b/skills/geoskills/assets/normalization/primitive-mantle-sm89.json new file mode 100644 index 0000000..a868e9d --- /dev/null +++ b/skills/geoskills/assets/normalization/primitive-mantle-sm89.json @@ -0,0 +1,136 @@ +{ + "schema_version": "1.0", + "id": "PrimitiveMantle_SM89", + "display_name": "Sun & McDonough (1989) primitive mantle", + "diagram_type": "trace_element_spider", + "unit": "ppm", + "element_order": [ + "Cs", + "Tl", + "Rb", + "Ba", + "W", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Mo", + "Sr", + "P", + "Nd", + "F", + "Sm", + "Zr", + "Hf", + "Eu", + "Sn", + "Sb", + "Ti", + "Gd", + "Tb", + "Dy", + "Li", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + "default_plot_order": [ + "Rb", + "Ba", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Sr", + "P", + "Nd", + "Zr", + "Hf", + "Sm", + "Eu", + "Ti", + "Gd", + "Tb", + "Dy", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + "values": { + "Cs": 0.032, + "Tl": 0.005, + "Rb": 0.635, + "Ba": 6.989, + "W": 0.02, + "Th": 0.085, + "U": 0.021, + "Nb": 0.713, + "Ta": 0.041, + "K": 250, + "La": 0.687, + "Ce": 1.775, + "Pb": 0.185, + "Pr": 0.276, + "Mo": 0.063, + "Sr": 21.1, + "P": 95, + "Nd": 1.354, + "F": 26, + "Sm": 0.444, + "Zr": 11.2, + "Hf": 0.309, + "Eu": 0.168, + "Sn": 0.17, + "Sb": 0.005, + "Ti": 1300, + "Gd": 0.596, + "Tb": 0.108, + "Dy": 0.737, + "Li": 1.6, + "Y": 4.55, + "Ho": 0.164, + "Er": 0.48, + "Tm": 0.074, + "Yb": 0.493, + "Lu": 0.074 + }, + "source": { + "authors": "S.-s. Sun and W. F. McDonough", + "year": 1989, + "title": "Chemical and isotopic systematics of oceanic basalts: implications for mantle composition and processes", + "publication": "Geological Society, London, Special Publications 42, 313-345", + "doi": "10.1144/GSL.SP.1989.042.01.19", + "table": "Table 1, Primitive mantle column", + "note": "This asset preserves the printed Table 1 primitive-mantle values. The Table 1 footnote separately recommends Cs=0.0079 ppm and Pb=0.071 ppm for modified mantle-normalized diagrams; those modified values are not silently substituted here." + }, + "crosscheck": { + "implementation": "pyrolite PM_SunMcDonough1989.csv", + "release": "v0.3.7", + "commit": "ab651b448ecd2ce1bc22d752b80a977a1f4dd1ca", + "url": "https://raw.githubusercontent.com/morganjwilliams/pyrolite/v0.3.7/pyrolite/data/geochem/refcomp/PM_SunMcDonough1989.csv" + }, + "verification": { + "status": "confirmed", + "verified_on": "2026-07-28", + "methods": [ + "transcribed against Sun and McDonough (1989), Table 1", + "cross-checked against pinned pyrolite v0.3.7 reference data" + ] + } +} diff --git a/skills/geoskills/examples/synthetic_spider_data.csv b/skills/geoskills/examples/synthetic_spider_data.csv new file mode 100644 index 0000000..608b7a2 --- /dev/null +++ b/skills/geoskills/examples/synthetic_spider_data.csv @@ -0,0 +1,4 @@ +Sample,Group,Rb_ppm,Ba_ppm,Th_ppm,U_ppm,Nb_ppm,Ta_ppm,K2O_wt%,La_ppm,Ce_ppm,Pb_ppm,Pr_ppm,Sr_ppm,P2O5_wt%,Nd_ppm,Zr_ppm,Hf_ppm,Sm_ppm,Eu_ppm,TiO2_wt%,Gd_ppm,Tb_ppm,Dy_ppm,Y_ppm,Ho_ppm,Er_ppm,Tm_ppm,Yb_ppm,Lu_ppm +SYN-A,Suite A,30,310,5.2,1.4,16,1.05,2.10,24,48,9.5,6.2,420,0.20,25,150,4.1,5.3,1.55,1.05,5.0,0.78,4.4,24,0.82,2.3,0.34,2.2,0.33 +SYN-B,Suite A,42,430,7.0,1.9,21,1.35,2.55,31,61,12.0,7.7,510,0.24,31,190,5.0,6.3,1.82,1.22,5.8,0.90,5.0,28,0.94,2.6,0.39,2.5,0.37 +SYN-C,Suite B,12,165,2.1,0.62,9.5,0.58,1.25,12,25,5.0,3.3,260,0.12,14,88,2.5,3.2,1.02,0.72,3.2,0.52,3.0,17,0.58,1.7,0.25,1.6,0.24 diff --git a/skills/geoskills/references/spider-method.md b/skills/geoskills/references/spider-method.md new file mode 100644 index 0000000..04785b6 --- /dev/null +++ b/skills/geoskills/references/spider-method.md @@ -0,0 +1,78 @@ +# Trace-element spider-diagram contract + +## Figure purpose + +Use one normalized multi-element pattern to compare relative enrichment, depletion, slopes, and visible anomalies among samples or groups. Treat the plot as comparative or discovery evidence, not as a stand-alone tectonic classifier. + +## Accepted input + +- One unambiguous sample identifier and an optional group column. +- At least five supported elements. +- Direct elemental concentrations explicitly identified as ppm. +- Optional `K2O`, `P2O5`, and `TiO2` values explicitly identified as wt%. +- Flat row-per-sample tables or unambiguous published-supplement tables with samples in columns and unit-labelled major/trace sections. + +Preserve blanks and BDL states as gaps. Reject non-numeric values that are not recognized BDL markers, duplicate mappings, and finite zero or negative values. + +## Reference compositions + +The local assets reproduce Sun and McDonough (1989), Table 1: + +- `PrimitiveMantle_SM89`: printed Primitive mantle column. +- `PrimitiveMantleModified_SM89`: the same column with only the footnote-b Cs and Pb substitutions. +- `NMORB_SM89`: printed N-type MORB column. + +Both assets record ppm units, complete element order, citation, DOI, source-table location, a pinned pyrolite v0.3.7 cross-check, and verification date. + +The printed primitive-mantle asset preserves `Cs=0.032 ppm` and `Pb=0.185 ppm`. The explicit modified asset uses the source footnote recommendations `Cs=0.0079 ppm` and `Pb=0.071 ppm`, with the other 34 values unchanged. Do not mix the variants or silently substitute values. + +## Oxide conversion + +Reference values are elemental ppm. Convert supported major oxides by: + +```text +element_ppm = oxide_wt_percent × element_mass_fraction_in_oxide × 10000 +``` + +Use CIAAW/IUPAC standard atomic weights: + +```text +O = 15.999 +P = 30.973761998 +K = 39.0983 +Ti = 47.867 +``` + +The resulting factors are: + +```text +K2O wt% × 8301.51302183966 = K ppm +P2O5 wt% × 4364.268173626792 = P ppm +TiO2 wt% × 5993.489012708947 = Ti ppm +``` + +Record every conversion in the run report. When both direct element ppm and its supported oxide are present, stop for user selection. + +## Normalization and order + +For every selected element: + +```text +normalized_ratio = sample_element_ppm / reference_element_ppm +``` + +Use deterministic Python. Preserve the Sun and McDonough (1989) incompatibility order defined by the selected asset, including for user-selected subsets. + +## Plot and export + +- Use a logarithmic y-axis. +- Preserve missing values as line breaks. +- Derive limits from finite positive ratios and round to clean decimal bounds without clipping. +- Draw a dashed unity line only when it lies inside the data-led display range. +- Use a white background, restrained colourblind-aware colours, line-style redundancy, and sample symbols. +- Use 5–7 pt final-size text and a collision-checked legend. +- Export SVG, PDF, 600 dpi LZW TIFF, 600 dpi PNG, normalized source CSV, and JSON report from one Matplotlib figure. + +## Interpretation boundary + +Describe observed LILE/HFSE relationships and Nb-Ta, Pb, Sr, P, or Ti anomalies cautiously. Consider analytical limits, alteration, mobility, accessory-mineral control, and source/melting alternatives. Do not infer a unique magma source, melting degree, fractionating phase, tectonic setting, or alteration history from the spider diagram alone. diff --git a/skills/geoskills/scripts/inspect_data.py b/skills/geoskills/scripts/inspect_data.py index 903bef7..f4fe2a9 100644 --- a/skills/geoskills/scripts/inspect_data.py +++ b/skills/geoskills/scripts/inspect_data.py @@ -59,7 +59,7 @@ "suite", } -UNIT_PATTERN = r"ppm|ppb|wt\s*%|wt\s*pct|wt\s*percent" +UNIT_PATTERN = r"ppm|ppb|wt\s*\.?\s*%|wt\s*pct|wt\s*percent" REE_HEADER_PATTERN = re.compile( rf"^\s*({'|'.join(element.lower() for element in REE_ORDER)})" rf"(?:[\s_\-\(\[]*({UNIT_PATTERN})[\s\)\]]*)?\s*$", @@ -91,7 +91,7 @@ def infer_unit(column: object) -> str: return "ppm" if "ppb" in normalized: return "ppb" - if re.search(r"wt\s*(?:%|pct|percent)", normalized): + if re.search(r"wt\s*\.?\s*(?:%|pct|percent)", normalized): return "wt%" return "unknown" diff --git a/skills/geoskills/scripts/inspect_spider_data.py b/skills/geoskills/scripts/inspect_spider_data.py new file mode 100644 index 0000000..a354f7b --- /dev/null +++ b/skills/geoskills/scripts/inspect_spider_data.py @@ -0,0 +1,732 @@ +#!/usr/bin/env python3 +"""Inspect trace-element spider-diagram input without modifying the source.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import unicodedata +from pathlib import Path +from typing import Any + +import pandas as pd + +from inspect_data import ( + BDL_PATTERN, + GROUP_NAMES, + MAX_FILE_SIZE_BYTES, + SAMPLE_NAMES, + SUPPORTED_SUFFIXES, + InspectionError, + clean_name, + issue, + problem_examples, + resolve_sheet, + sniff_text_format, +) + + +SPIDER_ELEMENT_ORDER = [ + "Cs", + "Tl", + "Rb", + "Ba", + "W", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Mo", + "Sr", + "P", + "Nd", + "F", + "Sm", + "Zr", + "Hf", + "Eu", + "Sn", + "Sb", + "Ti", + "Gd", + "Tb", + "Dy", + "Li", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", +] +DEFAULT_SPIDER_PLOT_ORDER = [ + "Rb", + "Ba", + "Th", + "U", + "Nb", + "Ta", + "K", + "La", + "Ce", + "Pb", + "Pr", + "Sr", + "P", + "Nd", + "Zr", + "Hf", + "Sm", + "Eu", + "Ti", + "Gd", + "Tb", + "Dy", + "Y", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", +] +ELEMENT_BY_KEY = {clean_name(element): element for element in SPIDER_ELEMENT_ORDER} +OXIDE_TO_ELEMENT = {"k2o": "K", "p2o5": "P", "tio2": "Ti"} + +# CIAAW/IUPAC standard atomic weights. Oxygen uses the conventional abridged +# value because its natural standard atomic weight is an interval. +ATOMIC_WEIGHTS = { + "O": 15.999, + "P": 30.973761998, + "K": 39.0983, + "Ti": 47.867, +} +OXIDE_ELEMENT_PPM_PER_WT_PERCENT = { + "K2O": ( + 2 * ATOMIC_WEIGHTS["K"] + / (2 * ATOMIC_WEIGHTS["K"] + ATOMIC_WEIGHTS["O"]) + * 10000 + ), + "P2O5": ( + 2 * ATOMIC_WEIGHTS["P"] + / (2 * ATOMIC_WEIGHTS["P"] + 5 * ATOMIC_WEIGHTS["O"]) + * 10000 + ), + "TiO2": ( + ATOMIC_WEIGHTS["Ti"] + / (ATOMIC_WEIGHTS["Ti"] + 2 * ATOMIC_WEIGHTS["O"]) + * 10000 + ), +} +UNIT_SUFFIX_PATTERN = re.compile( + r"[\s_\-\(\[]*(?:ppm|ppb|wt\s*\.?\s*(?:%|pct|percent))" + r"[\s\)\]]*\s*$", + re.IGNORECASE, +) + + +def infer_spider_unit(value: object) -> str: + """Infer a unit only when it is explicitly present in a label or section.""" + normalized = unicodedata.normalize("NFKC", str(value)).lower() + if re.search(r"(?:^|[^a-z])ppm(?:$|[^a-z])", normalized): + return "ppm" + if re.search(r"(?:^|[^a-z])ppb(?:$|[^a-z])", normalized): + return "ppb" + if re.search(r"(?:^|[^a-z])wt\s*\.?\s*(?:%|pct|percent)", normalized): + return "wt%" + return "unknown" + + +def match_spider_analyte(value: object) -> dict[str, str] | None: + """Map an exact elemental or supported oxide label to a canonical element.""" + normalized = unicodedata.normalize("NFKC", str(value)).strip() + base = UNIT_SUFFIX_PATTERN.sub("", normalized) + key = clean_name(base) + if key in ELEMENT_BY_KEY: + element = ELEMENT_BY_KEY[key] + return { + "element": element, + "species": element, + "required_unit": "ppm", + "conversion": "none", + } + if key in OXIDE_TO_ELEMENT: + species = { + "k2o": "K2O", + "p2o5": "P2O5", + "tio2": "TiO2", + }[key] + return { + "element": OXIDE_TO_ELEMENT[key], + "species": species, + "required_unit": "wt%", + "conversion": "oxide_wt_percent_to_element_ppm", + } + return None + + +def adapt_transposed_spider_table( + raw: pd.DataFrame, +) -> tuple[pd.DataFrame, dict[str, Any]] | None: + """Convert a common elements-by-row supplement into one row per sample.""" + if raw.empty or raw.shape[0] < 7 or raw.shape[1] < 3: + return None + + best_label_column: int | None = None + best_rows: list[tuple[int, dict[str, str]]] = [] + for column_index in range(raw.shape[1]): + matches: list[tuple[int, dict[str, str]]] = [] + seen_species: set[str] = set() + duplicate = False + for row_index, value in raw.iloc[:, column_index].items(): + analyte = match_spider_analyte(value) + if analyte is None: + continue + if analyte["species"] in seen_species: + duplicate = True + break + seen_species.add(analyte["species"]) + matches.append((int(row_index), analyte)) + if not duplicate and len(matches) > len(best_rows): + best_label_column = column_index + best_rows = matches + + if best_label_column is None or len(best_rows) < 5: + return None + + sample_rows = [ + int(row_index) + for row_index, value in raw.iloc[:, best_label_column].items() + if clean_name(value) in SAMPLE_NAMES + and int(raw.iloc[int(row_index)].notna().sum()) >= 3 + ] + if len(sample_rows) != 1: + return None + sample_row = sample_rows[0] + + sample_columns = [ + column_index + for column_index in range(raw.shape[1]) + if column_index != best_label_column + and pd.notna(raw.iat[sample_row, column_index]) + and str(raw.iat[sample_row, column_index]).strip() + ] + if len(sample_columns) < 2: + return None + + group_row: int | None = None + for row_index in range(sample_row): + if clean_name(raw.iat[row_index, best_label_column]) in GROUP_NAMES: + group_row = row_index + + unit_by_row: dict[int, str] = {} + current_unit = "unknown" + matched_rows = {row_index for row_index, _ in best_rows} + for row_index in range(sample_row + 1, raw.shape[0]): + label = raw.iat[row_index, best_label_column] + explicit_unit = infer_spider_unit(label) + if explicit_unit != "unknown" and row_index not in matched_rows: + current_unit = explicit_unit + if row_index in matched_rows: + unit_by_row[row_index] = ( + explicit_unit if explicit_unit != "unknown" else current_unit + ) + + samples = [str(raw.iat[sample_row, column]).strip() for column in sample_columns] + converted: dict[str, list[Any]] = {"Sample": samples} + if group_row is not None: + group_values = pd.Series( + [raw.iat[group_row, column] for column in sample_columns], + dtype="object", + ).ffill() + if group_values.notna().any(): + converted["Group"] = [ + None if pd.isna(value) else str(value).strip() + for value in group_values.tolist() + ] + + recognized: list[dict[str, Any]] = [] + output_names: set[str] = set(converted) + for row_index, analyte in best_rows: + unit = unit_by_row.get(row_index, "unknown") + output_name = ( + f"{analyte['species']}_{unit}" + if unit != "unknown" + else analyte["species"] + ) + if output_name in output_names: + return None + output_names.add(output_name) + converted[output_name] = [ + raw.iat[row_index, column] for column in sample_columns + ] + recognized.append( + { + **analyte, + "source_row": row_index + 1, + "source_label": str(raw.iat[row_index, best_label_column]), + "inferred_unit": unit, + "output_column": output_name, + } + ) + + return pd.DataFrame(converted), { + "method": "auto_transpose_trace_elements_by_row", + "sample_header_row": sample_row + 1, + "sample_identifier_label": str(raw.iat[sample_row, best_label_column]), + "sample_count": len(sample_columns), + "element_label_column": best_label_column + 1, + "group_header_row": None if group_row is None else group_row + 1, + "group_label": ( + None if group_row is None else str(raw.iat[group_row, best_label_column]) + ), + "recognized_analytes": recognized, + } + + +def read_spider_table( + path: Path, requested_sheet: str | None +) -> tuple[pd.DataFrame | None, dict[str, Any]]: + """Read one supported table with spider-aware transposed-layout handling.""" + if not path.exists() or not path.is_file(): + raise InspectionError("找不到输入文件。") + if path.stat().st_size > MAX_FILE_SIZE_BYTES: + raise InspectionError("文件超过 20 MB;当前版本暂不处理更大的文件。") + suffix = path.suffix.lower() + if suffix not in SUPPORTED_SUFFIXES: + raise InspectionError("不支持该文件格式;请使用 .csv、.txt 或 .xlsx。") + + source: dict[str, Any] = { + "file_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size_bytes": path.stat().st_size, + "format": suffix, + "sheet": None, + "sheet_names": [], + "encoding": None, + "delimiter": None, + "layout": None, + "transformation": None, + } + try: + if suffix in {".csv", ".txt"}: + encoding, delimiter = sniff_text_format(path) + source["encoding"] = encoding + source["delimiter"] = "TAB" if delimiter == "\t" else delimiter + raw = pd.read_csv(path, sep=delimiter, encoding=encoding, header=None) + adapted = adapt_transposed_spider_table(raw) + if adapted is not None: + frame, transformation = adapted + source["layout"] = "column_per_sample_transposed" + source["transformation"] = transformation + return frame, source + source["layout"] = "row_per_sample" + return pd.read_csv(path, sep=delimiter, encoding=encoding), source + + workbook = pd.ExcelFile(path) + source["sheet_names"] = workbook.sheet_names + selected_sheet = resolve_sheet(workbook.sheet_names, requested_sheet) + if selected_sheet is None: + return None, source + source["sheet"] = selected_sheet + raw = pd.read_excel(workbook, sheet_name=selected_sheet, header=None) + adapted = adapt_transposed_spider_table(raw) + if adapted is not None: + frame, transformation = adapted + source["layout"] = "column_per_sample_transposed" + source["transformation"] = transformation + return frame, source + source["layout"] = "row_per_sample" + return pd.read_excel(workbook, sheet_name=selected_sheet), source + except InspectionError: + raise + except Exception as exc: + raise InspectionError(f"无法读取表格:{exc}") from exc + + +def inspect_spider_frame( + frame: pd.DataFrame, + source: dict[str, Any], + requested_sample_column: str | None = None, + requested_group_column: str | None = None, +) -> dict[str, Any]: + """Inspect identifiers, analyte mappings, units, and invalid cell states.""" + columns = [str(column) for column in frame.columns] + automatic_sample_candidates = [ + column for column in frame.columns if clean_name(column) in SAMPLE_NAMES + ] + automatic_group_candidates = [ + column for column in frame.columns if clean_name(column) in GROUP_NAMES + ] + requested_sample_matches = [ + column for column in frame.columns if str(column) == requested_sample_column + ] + requested_group_matches = [ + column for column in frame.columns if str(column) == requested_group_column + ] + sample_candidates = ( + requested_sample_matches + if requested_sample_column is not None + else automatic_sample_candidates + ) + group_candidates = ( + requested_group_matches + if requested_group_column is not None + else automatic_group_candidates + ) + sample_column = sample_candidates[0] if len(sample_candidates) == 1 else None + issues: list[dict[str, Any]] = [] + + if frame.empty: + issues.append(issue("E101", "error", "表格没有数据行。")) + if requested_sample_column is not None and len(requested_sample_matches) != 1: + issues.append( + issue( + "E206", + "error", + "指定的样品编号列不存在或不唯一。", + requested=requested_sample_column, + ) + ) + elif not sample_candidates: + issues.append(issue("E201", "review", "未自动识别样品编号列,请明确指定。")) + elif len(sample_candidates) > 1: + issues.append( + issue( + "E201", + "review", + "识别到多个可能的样品编号列,请确认使用哪一列。", + columns=[str(column) for column in sample_candidates], + ) + ) + if requested_group_column is not None and len(requested_group_matches) != 1: + issues.append( + issue( + "E207", + "error", + "指定的分组列不存在或不唯一。", + requested=requested_group_column, + ) + ) + + if sample_column is not None: + sample_series = frame[sample_column] + sample_text = sample_series.astype("string").str.strip() + missing_sample_mask = sample_series.isna() | sample_text.eq("") + duplicate_sample_mask = ~missing_sample_mask & sample_text.duplicated(keep=False) + if int(missing_sample_mask.sum()): + issues.append( + issue( + "E204", + "review", + "样品编号列包含空白值;每行都需要样品编号。", + column=str(sample_column), + ) + ) + if int(duplicate_sample_mask.sum()): + issues.append( + issue( + "E205", + "review", + "样品编号存在重复值,请确认这些行是否代表同一样品。", + column=str(sample_column), + values=sorted( + sample_text[duplicate_sample_mask].dropna().unique().tolist() + )[:10], + ) + ) + + recognized: list[dict[str, Any]] = [] + by_element: dict[str, list[str]] = {} + for column in frame.columns: + analyte = match_spider_analyte(column) + if analyte is None: + continue + element = analyte["element"] + by_element.setdefault(element, []).append(str(column)) + series = frame[column] + text_values = series.astype("string").str.strip() + missing_mask = series.isna() | text_values.eq("") + bdl_mask = text_values.str.match(BDL_PATTERN, na=False) & ~missing_mask + numeric = pd.to_numeric( + series.where(~missing_mask & ~bdl_mask), + errors="coerce", + ) + nonnumeric_mask = ~missing_mask & ~bdl_mask & numeric.isna() + nonpositive_mask = numeric.notna() & numeric.le(0) + unit = infer_spider_unit(column) + factor = ( + OXIDE_ELEMENT_PPM_PER_WT_PERCENT[analyte["species"]] + if analyte["conversion"] != "none" + else 1.0 + ) + recognized.append( + { + **analyte, + "column": str(column), + "unit": unit, + "element_ppm_factor": factor, + "missing": int(missing_mask.sum()), + "below_detection_limit": int(bdl_mask.sum()), + "non_numeric": int(nonnumeric_mask.sum()), + "non_positive": int(nonpositive_mask.sum()), + } + ) + + if unit != analyte["required_unit"]: + message = ( + f"{analyte['species']} 列名或所在分区没有明确单位" + f" {analyte['required_unit']}。" + if unit == "unknown" + else ( + f"{analyte['species']} 必须使用 {analyte['required_unit']}," + f"但当前标记为 {unit}。" + ) + ) + issues.append( + issue( + "E421", + "review", + message, + element=element, + species=analyte["species"], + column=str(column), + ) + ) + if int(bdl_mask.sum()): + issues.append( + issue( + "W431", + "warning", + "检测到低于检出限的值;程序保留为曲线空缺,没有替换成零或检出限数值。", + element=element, + column=str(column), + examples=problem_examples( + frame, bdl_mask, column, sample_column + ), + ) + ) + if int(nonnumeric_mask.sum()): + issues.append( + issue( + "E431", + "review", + "检测到无法转换为数字的单元格。", + element=element, + column=str(column), + examples=problem_examples( + frame, nonnumeric_mask, column, sample_column + ), + ) + ) + if int(nonpositive_mask.sum()): + issues.append( + issue( + "E432", + "review", + "检测到零或负数,不能用于对数坐标。", + element=element, + column=str(column), + examples=problem_examples( + frame, nonpositive_mask, column, sample_column + ), + ) + ) + if int(missing_mask.sum()): + issues.append( + issue( + "W411", + "warning", + "检测到缺失值;绘图时保留为曲线空缺。", + element=element, + column=str(column), + count=int(missing_mask.sum()), + ) + ) + + recognized_elements = {item["element"] for item in recognized} + if not recognized: + issues.append(issue("E402", "error", "没有识别到可用于蛛网图的元素列。")) + elif len(recognized_elements) < 5: + issues.append( + issue("E403", "error", "微量元素蛛网图至少需要 5 个不同元素。") + ) + duplicates = { + element: names for element, names in by_element.items() if len(names) > 1 + } + if duplicates: + issues.append( + issue( + "E411", + "review", + "同一元素对应多个输入列,请明确选择直接元素值或氧化物换算值。", + columns=duplicates, + ) + ) + missing_default = [ + element + for element in DEFAULT_SPIDER_PLOT_ORDER + if element not in recognized_elements + ] + if missing_default and recognized: + issues.append( + issue( + "W401", + "warning", + "部分常用蛛网图元素未出现;程序只绘制已验证元素。", + elements=missing_default, + ) + ) + + severities = {item["severity"] for item in issues} + if "error" in severities: + status = "error" + elif "review" in severities: + status = "needs_review" + else: + status = "ready" + return { + "status": status, + "source": source, + "shape": {"rows": int(frame.shape[0]), "columns": int(frame.shape[1])}, + "columns": columns, + "sample_id_candidates": [str(column) for column in sample_candidates], + "group_candidates": [str(column) for column in group_candidates], + "trace_elements": { + "reference_order": SPIDER_ELEMENT_ORDER, + "default_plot_order": DEFAULT_SPIDER_PLOT_ORDER, + "recognized": recognized, + "missing_default_elements": missing_default, + }, + "oxide_conversion": { + "atomic_weights": ATOMIC_WEIGHTS, + "source": "CIAAW/IUPAC standard atomic weights", + "formula": "oxide_wt_percent * element_mass_fraction * 10000", + "factors": OXIDE_ELEMENT_PPM_PER_WT_PERCENT, + }, + "issues": issues, + } + + +def prepare_elemental_ppm_frame( + frame: pd.DataFrame, + inspection: dict[str, Any], +) -> tuple[pd.DataFrame, list[dict[str, Any]]]: + """Create canonical elemental ppm columns from a ready inspection.""" + if inspection["status"] != "ready": + raise InspectionError( + f"输入检查状态为 {inspection['status']},不能安全换算元素浓度。" + ) + sample_candidates = inspection["sample_id_candidates"] + if len(sample_candidates) != 1: + raise InspectionError("必须明确识别一个样品编号列。") + output_columns = [sample_candidates[0]] + if len(inspection["group_candidates"]) == 1: + output_columns.append(inspection["group_candidates"][0]) + elemental = frame[output_columns].copy() + conversions: list[dict[str, Any]] = [] + for item in inspection["trace_elements"]["recognized"]: + numeric = pd.to_numeric(frame[item["column"]], errors="coerce") + elemental[item["element"]] = numeric * item["element_ppm_factor"] + if item["conversion"] != "none": + conversions.append( + { + "element": item["element"], + "source_column": item["column"], + "source_species": item["species"], + "source_unit": item["unit"], + "target_unit": "ppm", + "factor": item["element_ppm_factor"], + "formula": ( + "oxide_wt_percent * element_mass_fraction * 10000" + ), + } + ) + return elemental, conversions + + +def spider_error_report(path: Path, message: str) -> dict[str, Any]: + return { + "status": "error", + "operation": "trace_element_spider_inspection", + "source": {"format": path.suffix.lower()}, + "issues": [issue("E400", "error", message)], + } + + +def inspect_spider_path( + path: Path, + requested_sheet: str | None = None, + requested_sample_column: str | None = None, + requested_group_column: str | None = None, +) -> dict[str, Any]: + """Inspect one spider-diagram input path and return a JSON-ready report.""" + try: + frame, source = read_spider_table(path, requested_sheet) + if frame is None: + return { + "status": "needs_sheet", + "operation": "trace_element_spider_inspection", + "source": source, + "issues": [ + issue( + "E111", + "review", + "Excel 文件包含多个工作表,请使用 --sheet 指定一个工作表。", + sheet_names=source["sheet_names"], + ) + ], + } + report = inspect_spider_frame( + frame, + source, + requested_sample_column, + requested_group_column, + ) + report["operation"] = "trace_element_spider_inspection" + return report + except (InspectionError, OSError) as exc: + return spider_error_report(path, str(exc)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="只读检查微量元素蛛网图输入,并输出 JSON 报告。" + ) + parser.add_argument("input", type=Path, help=".csv、.txt 或 .xlsx 输入文件") + parser.add_argument("--sheet", help="Excel 工作表名称,或从 0 开始的编号") + parser.add_argument("--sample-column", help="明确指定样品编号列") + parser.add_argument("--group-column", help="明确指定可选的分组列") + return parser.parse_args() + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + args = parse_args() + report = inspect_spider_path( + args.input, + args.sheet, + args.sample_column, + args.group_column, + ) + json.dump(report, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + print(f"蛛网图数据检查完成:{report['status']}", file=sys.stderr) + if report["status"] == "ready": + return 0 + if report["status"] == "error": + return 1 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/geoskills/scripts/normalize_spider.py b/skills/geoskills/scripts/normalize_spider.py new file mode 100644 index 0000000..32121fb --- /dev/null +++ b/skills/geoskills/scripts/normalize_spider.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Normalize validated trace elements for spider-diagram workflows.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +import pandas as pd + +from inspect_data import InspectionError, issue +from inspect_spider_data import ( + inspect_spider_frame, + prepare_elemental_ppm_frame, + read_spider_table, +) +from normalize_ree import NormalizationError, load_reference + + +SKILL_DIR = Path(__file__).resolve().parents[1] +REFERENCE_PATHS = { + "pm-sm89": ( + SKILL_DIR + / "assets" + / "normalization" + / "primitive-mantle-sm89.json" + ), + "pm-sm89-modified": ( + SKILL_DIR + / "assets" + / "normalization" + / "primitive-mantle-modified-sm89.json" + ), + "nmorb-sm89": ( + SKILL_DIR / "assets" / "normalization" / "nmorb-sm89.json" + ), +} +DEFAULT_REFERENCE_KEY = "pm-sm89-modified" + + +def resolve_reference(reference_key: str) -> Path: + """Resolve a supported reference name to its versioned local asset.""" + try: + return REFERENCE_PATHS[reference_key] + except KeyError as exc: + raise NormalizationError( + "未知标准化方案;可选值为:" + ", ".join(REFERENCE_PATHS) + "。" + ) from exc + + +def load_spider_reference( + path: Path, +) -> tuple[dict[str, Any], dict[str, float]]: + """Load and validate one spider-diagram reference composition.""" + reference, values = load_reference(path) + if reference.get("diagram_type") != "trace_element_spider": + raise NormalizationError("该标准化文件不属于微量元素蛛网图。") + default_order = reference.get("default_plot_order") + if not isinstance(default_order, list) or len(default_order) < 5: + raise NormalizationError("蛛网图标准化文件缺少有效 default_plot_order。") + unknown = [element for element in default_order if element not in values] + if unknown: + raise NormalizationError( + "default_plot_order 包含未定义元素:" + ", ".join(unknown) + "。" + ) + return reference, values + + +def spider_reference_summary( + path: Path, + reference: dict[str, Any], +) -> dict[str, Any]: + """Return a provenance block safe to include in a shareable report.""" + return { + "id": reference["id"], + "display_name": reference["display_name"], + "unit": reference["unit"], + "doi": reference["source"]["doi"], + "table": reference["source"]["table"], + "source_note": reference["source"].get("note"), + "asset": path.name, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "crosscheck": reference.get("crosscheck"), + "verification": reference.get("verification"), + } + + +def normalize_spider_frame( + frame: pd.DataFrame, + inspection: dict[str, Any], + reference_values: dict[str, float], + reference_order: list[str], +) -> tuple[pd.DataFrame, list[dict[str, Any]]]: + """Return identifiers plus dimensionless sample/reference ratios.""" + elemental, conversions = prepare_elemental_ppm_frame(frame, inspection) + sample_column = inspection["sample_id_candidates"][0] + output_columns = [sample_column] + if len(inspection["group_candidates"]) == 1: + output_columns.append(inspection["group_candidates"][0]) + normalized = elemental[output_columns].copy() + for element in reference_order: + if element not in elemental: + continue + normalized[f"{element}_N"] = ( + pd.to_numeric(elemental[element], errors="coerce") + / reference_values[element] + ) + return normalized, conversions + + +def normalization_error(path: Path, message: str) -> dict[str, Any]: + return { + "status": "error", + "operation": "trace_element_spider_normalization", + "source": {"format": path.suffix.lower()}, + "issues": [issue("E440", "error", message)], + } + + +def normalize_spider_path( + input_path: Path, + output_path: Path, + reference_key: str = DEFAULT_REFERENCE_KEY, + requested_sheet: str | None = None, + overwrite: bool = False, + requested_sample_column: str | None = None, + requested_group_column: str | None = None, +) -> dict[str, Any]: + """Inspect, normalize, and write a new CSV without changing the input.""" + try: + if input_path.resolve() == output_path.resolve(): + raise NormalizationError("输出文件不能与输入文件相同。") + if output_path.suffix.lower() != ".csv": + raise NormalizationError("标准化结果只支持输出为 .csv。") + if output_path.exists() and not overwrite: + raise NormalizationError( + "输出文件已经存在;如需替换,请显式使用 --overwrite。" + ) + + reference_path = resolve_reference(reference_key) + reference, reference_values = load_spider_reference(reference_path) + frame, source = read_spider_table(input_path, requested_sheet) + if frame is None: + return { + "status": "needs_sheet", + "operation": "trace_element_spider_normalization", + "source": source, + "reference": spider_reference_summary( + reference_path, reference + ), + "issues": [ + issue( + "E111", + "review", + "Excel 文件包含多个工作表,请使用 --sheet 指定一个工作表。", + sheet_names=source["sheet_names"], + ) + ], + } + + inspection = inspect_spider_frame( + frame, + source, + requested_sample_column, + requested_group_column, + ) + if inspection["status"] != "ready": + return { + "status": "blocked", + "operation": "trace_element_spider_normalization", + "reference": spider_reference_summary( + reference_path, reference + ), + "input_inspection": inspection, + "issues": [ + issue( + "E441", + "review", + "输入数据未通过安全检查,因此没有生成标准化结果。", + ) + ], + } + + normalized, conversions = normalize_spider_frame( + frame, + inspection, + reference_values, + reference["element_order"], + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + normalized.to_csv( + output_path, + index=False, + encoding="utf-8", + float_format="%.10g", + ) + normalized_columns = [ + column for column in normalized.columns if column.endswith("_N") + ] + return { + "status": "ready", + "operation": "trace_element_spider_normalization", + "formula": "sample_element_ppm / reference_element_ppm", + "source": source, + "reference": spider_reference_summary(reference_path, reference), + "oxide_conversions": conversions, + "output": { + "filename": output_path.name, + "format": ".csv", + "rows": int(normalized.shape[0]), + "columns": [str(column) for column in normalized.columns], + "normalized_columns": normalized_columns, + }, + "issues": inspection["issues"], + } + except ( + InspectionError, + NormalizationError, + OSError, + ValueError, + ) as exc: + return normalization_error(input_path, str(exc)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "使用 Sun & McDonough (1989) 原始地幔或 N-MORB 值" + "标准化微量元素。" + ) + ) + parser.add_argument("input", type=Path, help="已满足数据规则的输入表格") + parser.add_argument("--output", type=Path, required=True, help="新建的 CSV 文件") + parser.add_argument( + "--reference", + choices=tuple(REFERENCE_PATHS), + default=DEFAULT_REFERENCE_KEY, + help=( + "标准化方案:pm-sm89-modified(默认)、pm-sm89 " + "或 nmorb-sm89" + ), + ) + parser.add_argument("--sheet", help="Excel 工作表名称,或从 0 开始的编号") + parser.add_argument("--sample-column", help="明确指定样品编号列") + parser.add_argument("--group-column", help="明确指定可选分组列") + parser.add_argument( + "--overwrite", + action="store_true", + help="明确允许替换已存在的输出文件", + ) + return parser.parse_args() + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + args = parse_args() + report = normalize_spider_path( + args.input, + args.output, + args.reference, + args.sheet, + args.overwrite, + args.sample_column, + args.group_column, + ) + json.dump(report, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + print(f"蛛网图标准化完成:{report['status']}", file=sys.stderr) + if report["status"] == "ready": + return 0 + if report["status"] == "error": + return 1 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/geoskills/scripts/plot_ree.py b/skills/geoskills/scripts/plot_ree.py index aaf0128..2718ca9 100644 --- a/skills/geoskills/scripts/plot_ree.py +++ b/skills/geoskills/scripts/plot_ree.py @@ -224,6 +224,9 @@ def build_figure( axes_frame: str = "open", legend_layout: str = "outside", grid_style: str = "none", + y_label: str = "Sample / C1 chondrite", + reference_note: str | None = None, + x_tick_labelsize: float | None = None, ) -> tuple[plt.Figure, dict[str, Any]]: """Build one figure; export callers must reuse this same figure object.""" if not 50 <= width_mm <= 400 or not 50 <= height_mm <= 400: @@ -314,10 +317,12 @@ def build_figure( ax.set_xlim(-0.4, len(elements) - 0.6) ax.set_xticks(x) ax.set_xticklabels(elements) + if x_tick_labelsize is not None: + ax.tick_params(axis="x", labelsize=x_tick_labelsize) # Element symbols already define the categorical x axis; omitting a repeated # x-axis title preserves space and improves readability after journal scaling. ax.set_xlabel("") - ax.set_ylabel("Sample / C1 chondrite") + ax.set_ylabel(y_label) ax.tick_params(axis="both", which="major", direction="out", length=3) ax.tick_params(axis="y", which="minor", direction="out", length=1.8) ax.yaxis.set_major_locator(LogLocator(base=10)) @@ -505,7 +510,11 @@ def add_inside_legends() -> list[Any]: legend_position = "inside_upper_right" else: add_outside_legends() - reference_note = "Normalization: Sun & McDonough (1989) C1 chondrite" + reference_note = ( + reference_note + if reference_note is not None + else "Normalization: Sun & McDonough (1989) C1 chondrite" + ) if unity_line_visible: reference_note += "; dashed line = unity." else: diff --git a/skills/geoskills/scripts/plot_spider.py b/skills/geoskills/scripts/plot_spider.py new file mode 100644 index 0000000..dca4aea --- /dev/null +++ b/skills/geoskills/scripts/plot_spider.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +"""Create a publication-oriented normalized trace-element spider diagram.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from inspect_data import InspectionError, issue +from inspect_spider_data import inspect_spider_frame, read_spider_table +from normalize_ree import NormalizationError +from normalize_spider import ( + DEFAULT_REFERENCE_KEY, + REFERENCE_PATHS, + load_spider_reference, + normalize_spider_frame, + resolve_reference, + spider_reference_summary, +) +from plot_ree import ( + AXES_FRAMES, + DEFAULT_LOG_Y_MARGIN, + FORMATS, + GRID_STYLES, + LEGEND_LAYOUTS, + MARKERS, + PlottingError, + build_figure, + output_targets, +) + + +def parse_spider_elements( + selection: str | None, + available: list[str], + reference_order: list[str], + default_order: list[str], +) -> list[str]: + """Resolve a user selection while preserving the cited incompatibility order.""" + available_set = set(available) + if selection is None: + selected = [element for element in default_order if element in available_set] + if len(selected) < 5: + selected = [ + element for element in reference_order if element in available_set + ] + else: + canonical = {element.casefold(): element for element in reference_order} + requested: list[str] = [] + for item in selection.split(","): + cleaned = item.strip() + if not cleaned: + continue + element = canonical.get(cleaned.casefold()) + if element is None: + raise PlottingError(f"未知蛛网图元素:{cleaned}。") + requested.append(element) + if len(set(requested)) != len(requested): + raise PlottingError("--elements 中不能重复同一个元素。") + missing = [element for element in requested if element not in available_set] + if missing: + raise PlottingError( + "所选元素未在已验证输入中出现:" + ", ".join(missing) + "。" + ) + requested_set = set(requested) + selected = [ + element for element in reference_order if element in requested_set + ] + if len(selected) < 5: + raise PlottingError("微量元素蛛网图至少需要 5 个已验证元素。") + return selected + + +def shareable_file_record(path: Path) -> dict[str, Any]: + """Describe an output without leaking a private local directory.""" + return { + "format": path.suffix.lower().lstrip("."), + "filename": path.name, + "bytes": path.stat().st_size, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + + +def plotting_error(path: Path, message: str) -> dict[str, Any]: + return { + "status": "error", + "operation": "trace_element_spider_plot", + "source": {"format": path.suffix.lower()}, + "issues": [issue("E550", "error", message)], + } + + +def plot_spider_path( + input_path: Path, + output_dir: Path, + stem: str | None = None, + reference_key: str = DEFAULT_REFERENCE_KEY, + requested_sheet: str | None = None, + requested_sample_column: str | None = None, + requested_group_column: str | None = None, + requested_elements: str | None = None, + title: str | None = None, + width_mm: float = 183.0, + height_mm: float = 120.0, + dpi: int = 600, + y_margin: float = DEFAULT_LOG_Y_MARGIN, + axes_frame: str = "full", + legend_layout: str = "inside-auto", + grid_style: str = "none", + overwrite: bool = False, +) -> dict[str, Any]: + """Validate input and export a submission-oriented spider-plot bundle.""" + figure = None + try: + if not 72 <= dpi <= 1200: + raise PlottingError("PNG/TIFF 分辨率必须在 72–1200 dpi 之间。") + reference_path = resolve_reference(reference_key) + reference, reference_values = load_spider_reference(reference_path) + resolved_stem = stem or f"spider_{reference_key}" + figure_paths, source_data_path, report_path = output_targets( + output_dir, resolved_stem + ) + existing = [ + path + for path in [*figure_paths, source_data_path, report_path] + if path.exists() + ] + if existing and not overwrite: + raise PlottingError( + "输出文件已经存在;如需替换,请显式使用 --overwrite。" + ) + + frame, source = read_spider_table(input_path, requested_sheet) + if frame is None: + return { + "status": "needs_sheet", + "operation": "trace_element_spider_plot", + "source": source, + "reference": spider_reference_summary( + reference_path, reference + ), + "issues": [ + issue( + "E111", + "review", + "Excel 文件包含多个工作表,请使用 --sheet 指定一个工作表。", + sheet_names=source["sheet_names"], + ) + ], + } + + inspection = inspect_spider_frame( + frame, + source, + requested_sample_column, + requested_group_column, + ) + if inspection["status"] != "ready": + return { + "status": "blocked", + "operation": "trace_element_spider_plot", + "reference": spider_reference_summary( + reference_path, reference + ), + "input_inspection": inspection, + "issues": [ + issue( + "E551", + "review", + "输入数据未通过安全检查,因此没有生成图像。", + ) + ], + } + + recognized_elements = { + item["element"] + for item in inspection["trace_elements"]["recognized"] + } + available = [ + element + for element in reference["element_order"] + if element in recognized_elements + ] + elements = parse_spider_elements( + requested_elements, + available, + reference["element_order"], + reference["default_plot_order"], + ) + normalized, conversions = normalize_spider_frame( + frame, + inspection, + reference_values, + reference["element_order"], + ) + sample_column = inspection["sample_id_candidates"][0] + group_column = ( + inspection["group_candidates"][0] + if len(inspection["group_candidates"]) == 1 + else None + ) + is_printed_primitive_mantle = ( + reference["id"] == "PrimitiveMantle_SM89" + ) + if reference["id"] == "PrimitiveMantleModified_SM89": + axis_reference = "modified primitive mantle" + elif is_printed_primitive_mantle: + axis_reference = "primitive mantle" + else: + axis_reference = "N-MORB" + table_label = ( + "Table 1 footnote b" + if reference["id"] == "PrimitiveMantleModified_SM89" + else "Table 1" + ) + note_reference = ( + "Normalization: Sun & McDonough (1989) " + + axis_reference + + f" ({table_label})" + ) + figure, plot_info = build_figure( + normalized, + sample_column, + group_column, + elements, + reference["id"], + title, + width_mm, + height_mm, + y_margin=y_margin, + axes_frame=axes_frame, + legend_layout=legend_layout, + grid_style=grid_style, + y_label=f"Sample / {axis_reference}", + reference_note=note_reference, + x_tick_labelsize=6.2, + ) + + output_dir.mkdir(parents=True, exist_ok=True) + for path in figure_paths: + save_options: dict[str, Any] = {"facecolor": "white"} + if path.suffix.lower() in {".png", ".tiff"}: + save_options["dpi"] = dpi + if path.suffix.lower() == ".tiff": + save_options["pil_kwargs"] = {"compression": "tiff_lzw"} + figure.savefig(path, **save_options) + source_columns = [sample_column] + if group_column is not None: + source_columns.append(group_column) + source_columns.extend(f"{element}_N" for element in elements) + normalized.loc[:, source_columns].to_csv( + source_data_path, + index=False, + encoding="utf-8", + float_format="%.8g", + ) + plt.close(figure) + figure = None + + run_issues = list(inspection["issues"]) + if plot_info["plotted_sample_count"] > 15: + run_issues.append( + issue( + "W551", + "warning", + "样品数超过 15,图例和曲线可能拥挤;建议按组分图。", + sample_count=plot_info["plotted_sample_count"], + ) + ) + if plot_info["palette_repeated"]: + run_issues.append( + issue( + "W552", + "warning", + "分组数量超过基础色板,部分颜色被重复使用。", + group_count=plot_info["group_count"], + ) + ) + if plot_info["line_style_repeated"]: + run_issues.append( + issue( + "W553", + "warning", + "分组数量超过基础线型数量;建议按组分图。", + group_count=plot_info["group_count"], + ) + ) + if plot_info["marker_repeated"]: + run_issues.append( + issue( + "W554", + "warning", + "样品数超过可用符号数量,部分符号重复;建议按组分图。", + sample_count=plot_info["plotted_sample_count"], + unique_marker_count=len(MARKERS), + ) + ) + if plot_info["skipped_samples"]: + run_issues.append( + issue( + "W555", + "warning", + "部分样品在所选元素上全部缺失,未绘制曲线。", + samples=plot_info["skipped_samples"], + ) + ) + if plot_info["legend_fallback"]: + run_issues.append( + issue( + "W556", + "warning", + "图内图例会遮挡数据,已自动改为右侧布局。", + ) + ) + if is_printed_primitive_mantle and {"Cs", "Pb"} & set(elements): + run_issues.append( + issue( + "W557", + "warning", + "当前使用 Table 1 原始地幔列;原文脚注另给出用于" + " modified mantle-normalized diagrams 的 Cs 和 Pb 值。" + "本程序没有静默替换,请在研究方法中明确所选版本。", + selected_affected_elements=sorted( + {"Cs", "Pb"} & set(elements) + ), + ) + ) + + report = { + "status": "ready", + "operation": "trace_element_spider_plot", + "source": source, + "reference": spider_reference_summary(reference_path, reference), + "figure_contract": { + "core_conclusion": ( + "Compare relative multi-element enrichment, depletion, " + "and visible anomalies among samples and groups against " + "one declared reference composition." + ), + "archetype": "single-panel quantitative figure", + "backend": "Python/matplotlib", + "role": "comparative and discovery evidence", + "evidence": ( + "Sample-to-reference ratios across a cited " + "incompatibility-ordered element sequence" + ), + "target_output": "double-column publication figure", + "review_risks": [ + "reference-composition ambiguity", + "ppm versus oxide wt% unit conversion", + "log-axis invalid values", + "missing-value connections", + "overplotting and legend crowding", + "overinterpretation of individual anomalies", + ], + }, + "configuration": { + "sample_column": sample_column, + "group_column": group_column, + "elements": elements, + "element_order_policy": ( + "Sun and McDonough (1989) incompatibility order" + ), + "reference_key": reference_key, + "y_scale": "log10", + "y_limit_policy": plot_info["y_limits"]["policy"], + "y_margin_fraction": y_margin, + "y_limits": plot_info["y_limits"], + "unity_line": plot_info["unity_line_visible"], + "reference_line_value": 1.0, + "axes_frame": axes_frame, + "legend_layout": legend_layout, + "legend_position": plot_info["legend_position"], + "grid_style": grid_style, + "width_mm": width_mm, + "height_mm": height_mm, + "png_dpi": dpi, + "tiff_dpi": dpi, + "formats": list(FORMATS), + }, + "oxide_conversions": conversions, + "plot": plot_info, + "outputs": [ + shareable_file_record(path) for path in figure_paths + ], + "source_data": shareable_file_record(source_data_path), + "submission_qa": { + "final_size_mm": [width_mm, height_mm], + "svg_text_editable": True, + "pdf_font_type": 42, + "raster_dpi": dpi, + "tiff_compression": "LZW", + "white_background": True, + "colourblind_support": ( + "group colour plus line style; unique sample symbol" + if not plot_info["marker_repeated"] + else ( + "group colour plus line style; sample symbols repeat " + "with warning" + ) + ), + "source_data_exported": True, + }, + "report_file": report_path.name, + "issues": run_issues, + "interpretation_guidance": [ + "Describe relative enrichment, depletion, slopes, and visible anomalies before proposing causes.", + "Check whether mobile elements such as Rb, Ba, K, Sr, and Pb may reflect alteration or fluid effects.", + "Evaluate Nb-Ta, Sr, P, and Ti anomalies with petrography, mineral chemistry, and major-element evidence.", + "Treat gaps as missing measurements rather than zero concentrations.", + "Do not assign a tectonic setting from a spider diagram alone.", + ], + "scientific_caveat": ( + "A normalized trace-element pattern does not uniquely identify " + "mantle source, melting degree, fractionating minerals, " + "alteration history, or tectonic setting." + ), + } + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return report + except ( + InspectionError, + NormalizationError, + PlottingError, + OSError, + ValueError, + ) as exc: + return plotting_error(input_path, str(exc)) + finally: + if figure is not None: + plt.close(figure) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "生成 Sun & McDonough (1989) 原始地幔或 N-MORB 标准化" + "微量元素蛛网图。" + ) + ) + parser.add_argument("input", type=Path, help="CSV、TXT 或 Excel 输入表格") + parser.add_argument("--output-dir", type=Path, required=True, help="输出文件夹") + parser.add_argument("--stem", help="不含扩展名的输出文件名") + parser.add_argument( + "--reference", + choices=tuple(REFERENCE_PATHS), + default=DEFAULT_REFERENCE_KEY, + help=( + "标准化方案:pm-sm89-modified(默认)、pm-sm89 " + "或 nmorb-sm89" + ), + ) + parser.add_argument("--sheet", help="Excel 工作表名称,或从 0 开始的编号") + parser.add_argument("--sample-column", help="明确指定样品编号列") + parser.add_argument("--group-column", help="明确指定可选分组列") + parser.add_argument( + "--elements", + help="逗号分隔的元素;程序仍按文献不相容性顺序排列", + ) + parser.add_argument("--title", help="可选图题;论文图通常留空") + parser.add_argument("--width-mm", type=float, default=183.0, help="图宽") + parser.add_argument("--height-mm", type=float, default=120.0, help="图高") + parser.add_argument("--dpi", type=int, default=600, help="PNG/TIFF 分辨率") + parser.add_argument( + "--y-margin", + type=float, + default=DEFAULT_LOG_Y_MARGIN, + help="对数纵坐标边距比例,默认 0.08", + ) + parser.add_argument( + "--axes-frame", + choices=AXES_FRAMES, + default="full", + help="坐标轴边框:open(左下)或 full(四边框,默认)", + ) + parser.add_argument( + "--legend-layout", + choices=LEGEND_LAYOUTS, + default="inside-auto", + help="图例:outside 或 inside-auto(默认,必要时自动移到右侧)", + ) + parser.add_argument( + "--grid-style", + choices=GRID_STYLES, + default="none", + help="横向网格:none(默认)或 major", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="明确允许替换已存在的整套输出文件", + ) + return parser.parse_args() + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + args = parse_args() + report = plot_spider_path( + args.input, + args.output_dir, + stem=args.stem, + reference_key=args.reference, + requested_sheet=args.sheet, + requested_sample_column=args.sample_column, + requested_group_column=args.group_column, + requested_elements=args.elements, + title=args.title, + width_mm=args.width_mm, + height_mm=args.height_mm, + dpi=args.dpi, + y_margin=args.y_margin, + axes_frame=args.axes_frame, + legend_layout=args.legend_layout, + grid_style=args.grid_style, + overwrite=args.overwrite, + ) + json.dump(report, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + print(f"蛛网图绘制完成:{report['status']}", file=sys.stderr) + if report["status"] == "ready": + return 0 + if report["status"] == "error": + return 1 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_inspect_spider_data.py b/tests/test_inspect_spider_data.py new file mode 100644 index 0000000..76cbdde --- /dev/null +++ b/tests/test_inspect_spider_data.py @@ -0,0 +1,181 @@ +import json +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / "skills" / "geoskills" +SCRIPT = SKILL / "scripts" / "inspect_spider_data.py" +EXAMPLE = SKILL / "examples" / "synthetic_spider_data.csv" +SCRIPTS = SKILL / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from inspect_spider_data import ( # noqa: E402 + OXIDE_ELEMENT_PPM_PER_WT_PERCENT, + inspect_spider_frame, + prepare_elemental_ppm_frame, + read_spider_table, +) + + +def run_inspector(*arguments: object) -> tuple[subprocess.CompletedProcess[str], dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), *(str(argument) for argument in arguments)], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + return result, json.loads(result.stdout) + + +def test_valid_flat_spider_csv_is_ready() -> None: + result, report = run_inspector(EXAMPLE) + + assert result.returncode == 0 + assert report["status"] == "ready" + assert report["shape"] == {"rows": 3, "columns": 30} + assert report["sample_id_candidates"] == ["Sample"] + assert report["group_candidates"] == ["Group"] + recognized = { + item["element"]: item for item in report["trace_elements"]["recognized"] + } + assert recognized["K"]["species"] == "K2O" + assert recognized["K"]["unit"] == "wt%" + assert recognized["P"]["species"] == "P2O5" + assert recognized["Ti"]["species"] == "TiO2" + assert report["issues"] == [] + + +def test_transposed_supplement_tracks_major_and_trace_units(tmp_path: Path) -> None: + path = tmp_path / "transposed.xlsx" + raw = pd.DataFrame( + [ + ["Rock type", "Suite A", None], + ["Sample No.", "S1", "S2"], + ["Major element (wt.%)", None, None], + ["TiO2", 1.0, 1.2], + ["K2O", 2.0, 2.2], + ["P2O5", 0.2, 0.25], + ["Trace element (ppm)", None, None], + ["Rb", 30, 35], + ["Ba", 300, 340], + ["Th", 5, 6], + ["U", 1.2, 1.5], + ["Nb", 14, 16], + ["Ta", 0.9, 1.0], + ["La", 20, 22], + ] + ) + raw.to_excel(path, index=False, header=False) + + frame, source = read_spider_table(path, None) + assert frame is not None + report = inspect_spider_frame(frame, source) + + assert report["status"] == "ready" + assert source["layout"] == "column_per_sample_transposed" + assert source["transformation"]["sample_header_row"] == 2 + assert list(frame["Group"]) == ["Suite A", "Suite A"] + units = { + item["species"]: item["unit"] + for item in report["trace_elements"]["recognized"] + } + assert units["TiO2"] == "wt%" + assert units["K2O"] == "wt%" + assert units["P2O5"] == "wt%" + assert units["Rb"] == "ppm" + + +def test_oxide_conversion_uses_declared_stoichiometric_factors() -> None: + frame = pd.DataFrame( + { + "Sample": ["S1"], + "Rb_ppm": [1.0], + "Ba_ppm": [1.0], + "Th_ppm": [1.0], + "U_ppm": [1.0], + "Nb_ppm": [1.0], + "K2O_wt%": [1.0], + "P2O5_wt%": [1.0], + "TiO2_wt%": [1.0], + } + ) + report = inspect_spider_frame(frame, {"filename": "test.csv"}) + elemental, conversions = prepare_elemental_ppm_frame(frame, report) + + assert report["status"] == "ready" + assert elemental.at[0, "K"] == pytest.approx( + OXIDE_ELEMENT_PPM_PER_WT_PERCENT["K2O"] + ) + assert elemental.at[0, "P"] == pytest.approx( + OXIDE_ELEMENT_PPM_PER_WT_PERCENT["P2O5"] + ) + assert elemental.at[0, "Ti"] == pytest.approx( + OXIDE_ELEMENT_PPM_PER_WT_PERCENT["TiO2"] + ) + assert {item["source_species"] for item in conversions} == { + "K2O", + "P2O5", + "TiO2", + } + + +def test_bdl_is_preserved_as_gap_without_blocking() -> None: + frame = pd.DataFrame( + { + "Sample": ["S1", "S2"], + "Rb_ppm": ["bdl", 2.0], + "Ba_ppm": [1.0, 2.0], + "Th_ppm": [1.0, 2.0], + "U_ppm": [1.0, 2.0], + "Nb_ppm": [1.0, 2.0], + } + ) + report = inspect_spider_frame(frame, {"filename": "bdl.csv"}) + elemental, _ = prepare_elemental_ppm_frame(frame, report) + + assert report["status"] == "ready" + assert any(item["code"] == "W431" for item in report["issues"]) + assert np.isnan(elemental.at[0, "Rb"]) + + +def test_nonpositive_value_blocks_logarithmic_plotting() -> None: + frame = pd.DataFrame( + { + "Sample": ["S1"], + "Rb_ppm": [0], + "Ba_ppm": [1], + "Th_ppm": [1], + "U_ppm": [1], + "Nb_ppm": [1], + } + ) + report = inspect_spider_frame(frame, {"filename": "zero.csv"}) + + assert report["status"] == "needs_review" + assert any(item["code"] == "E432" for item in report["issues"]) + + +def test_direct_element_and_oxide_duplicate_requires_review() -> None: + frame = pd.DataFrame( + { + "Sample": ["S1"], + "Rb_ppm": [1], + "Ba_ppm": [1], + "Th_ppm": [1], + "U_ppm": [1], + "K_ppm": [1000], + "K2O_wt%": [1.0], + } + ) + report = inspect_spider_frame(frame, {"filename": "duplicate.csv"}) + + assert report["status"] == "needs_review" + duplicate = next(item for item in report["issues"] if item["code"] == "E411") + assert duplicate["details"]["columns"]["K"] == ["K_ppm", "K2O_wt%"] diff --git a/tests/test_normalize_spider.py b/tests/test_normalize_spider.py new file mode 100644 index 0000000..f9799c3 --- /dev/null +++ b/tests/test_normalize_spider.py @@ -0,0 +1,153 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / "skills" / "geoskills" +SCRIPT = SKILL / "scripts" / "normalize_spider.py" +PM_REFERENCE = ( + SKILL / "assets" / "normalization" / "primitive-mantle-sm89.json" +) +PM_MODIFIED_REFERENCE = ( + SKILL + / "assets" + / "normalization" + / "primitive-mantle-modified-sm89.json" +) +NMORB_REFERENCE = SKILL / "assets" / "normalization" / "nmorb-sm89.json" +EXAMPLE = SKILL / "examples" / "synthetic_spider_data.csv" + + +def run_normalizer(*arguments: object) -> tuple[subprocess.CompletedProcess[str], dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), *(str(argument) for argument in arguments)], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + return result, json.loads(result.stdout) + + +def test_sm89_spider_references_match_table_one_values() -> None: + pm = json.loads(PM_REFERENCE.read_text(encoding="utf-8")) + modified = json.loads(PM_MODIFIED_REFERENCE.read_text(encoding="utf-8")) + nmorb = json.loads(NMORB_REFERENCE.read_text(encoding="utf-8")) + + assert pm["id"] == "PrimitiveMantle_SM89" + assert pm["source"]["table"] == "Table 1, Primitive mantle column" + assert pm["values"]["Rb"] == 0.635 + assert pm["values"]["Ba"] == 6.989 + assert pm["values"]["Nb"] == 0.713 + assert pm["values"]["Pb"] == 0.185 + assert pm["values"]["Ti"] == 1300 + assert list(pm["values"]) == pm["element_order"] + assert "Cs=0.0079" in pm["source"]["note"] + assert pm["crosscheck"]["release"] == "v0.3.7" + + assert modified["id"] == "PrimitiveMantleModified_SM89" + assert modified["values"]["Cs"] == 0.0079 + assert modified["values"]["Pb"] == 0.071 + changed = [ + element + for element in pm["element_order"] + if pm["values"][element] != modified["values"][element] + ] + assert changed == ["Cs", "Pb"] + + assert nmorb["id"] == "NMORB_SM89" + assert nmorb["source"]["table"] == "Table 1, N-type MORB column" + assert nmorb["values"]["Rb"] == 0.56 + assert nmorb["values"]["Nb"] == 2.33 + assert nmorb["values"]["Ti"] == 7600 + assert list(nmorb["values"]) == nmorb["element_order"] + + +def test_reference_values_normalize_to_unity(tmp_path: Path) -> None: + input_path = tmp_path / "unity.csv" + output_path = tmp_path / "normalized.csv" + pm = json.loads(PM_REFERENCE.read_text(encoding="utf-8")) + elements = ["Rb", "Ba", "Th", "U", "Nb", "Ta", "La"] + frame = {"Sample": ["REF"]} + for element in elements: + frame[f"{element}_ppm"] = [pm["values"][element]] + pd.DataFrame(frame).to_csv(input_path, index=False) + + result, report = run_normalizer( + input_path, + "--output", + output_path, + "--reference", + "pm-sm89", + ) + normalized = pd.read_csv(output_path) + + assert result.returncode == 0 + assert report["status"] == "ready" + assert report["reference"]["id"] == "PrimitiveMantle_SM89" + assert report["output"]["filename"] == "normalized.csv" + for element in elements: + assert normalized.at[0, f"{element}_N"] == pytest.approx(1.0) + + +def test_reference_switch_changes_ratios(tmp_path: Path) -> None: + pm_output = tmp_path / "pm.csv" + nmorb_output = tmp_path / "nmorb.csv" + + pm_result, pm_report = run_normalizer( + EXAMPLE, + "--output", + pm_output, + "--reference", + "pm-sm89", + ) + nmorb_result, nmorb_report = run_normalizer( + EXAMPLE, + "--output", + nmorb_output, + "--reference", + "nmorb-sm89", + ) + pm = pd.read_csv(pm_output) + nmorb = pd.read_csv(nmorb_output) + + assert pm_result.returncode == 0 + assert nmorb_result.returncode == 0 + assert pm_report["reference"]["id"] == "PrimitiveMantle_SM89" + assert nmorb_report["reference"]["id"] == "NMORB_SM89" + assert pm.at[0, "Nb_N"] != pytest.approx(nmorb.at[0, "Nb_N"]) + assert pm_report["oxide_conversions"] + + +def test_modified_primitive_mantle_is_explicitly_selectable( + tmp_path: Path, +) -> None: + output_path = tmp_path / "modified.csv" + + result, report = run_normalizer( + EXAMPLE, + "--output", + output_path, + "--reference", + "pm-sm89-modified", + ) + + assert result.returncode == 0 + assert report["reference"]["id"] == "PrimitiveMantleModified_SM89" + assert "footnote-b" in report["reference"]["table"] + + +def test_existing_output_requires_explicit_overwrite(tmp_path: Path) -> None: + output_path = tmp_path / "normalized.csv" + output_path.write_text("keep\n", encoding="utf-8") + + result, report = run_normalizer(EXAMPLE, "--output", output_path) + + assert result.returncode == 1 + assert report["status"] == "error" + assert output_path.read_text(encoding="utf-8") == "keep\n" diff --git a/tests/test_plot_spider.py b/tests/test_plot_spider.py new file mode 100644 index 0000000..a7ac934 --- /dev/null +++ b/tests/test_plot_spider.py @@ -0,0 +1,235 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest +from PIL import Image + + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / "skills" / "geoskills" +SCRIPT = SKILL / "scripts" / "plot_spider.py" +EXAMPLE = SKILL / "examples" / "synthetic_spider_data.csv" + + +def run_plotter(*arguments: object) -> tuple[subprocess.CompletedProcess[str], dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), *(str(argument) for argument in arguments)], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + return result, json.loads(result.stdout) + + +def test_exports_spider_publication_bundle(tmp_path: Path) -> None: + output_dir = tmp_path / "figure" + result, report = run_plotter( + EXAMPLE, + "--output-dir", + output_dir, + "--stem", + "spider_test", + "--width-mm", + "100", + "--height-mm", + "70", + "--dpi", + "100", + ) + paths = { + extension: output_dir / f"spider_test.{extension}" + for extension in ("svg", "pdf", "tiff", "png") + } + source_path = output_dir / "spider_test.source_data.csv" + report_path = output_dir / "spider_test.report.json" + + assert result.returncode == 0 + assert report["status"] == "ready" + assert report["reference"]["id"] == "PrimitiveMantleModified_SM89" + assert report["configuration"]["axes_frame"] == "full" + assert report["configuration"]["legend_layout"] == "inside-auto" + assert report["configuration"]["grid_style"] == "none" + assert report["configuration"]["elements"][:6] == [ + "Rb", + "Ba", + "Th", + "U", + "Nb", + "Ta", + ] + assert report["source"]["format"] == ".csv" + assert report["source"]["size_bytes"] == EXAMPLE.stat().st_size + assert len(report["source"]["file_sha256"]) == 64 + assert "filename" not in report["source"] + assert report["source"]["layout"] == "row_per_sample" + assert all(path.exists() and path.stat().st_size > 0 for path in paths.values()) + assert source_path.exists() + assert report_path.exists() + assert paths["pdf"].read_bytes().startswith(b"%PDF") + svg = paths["svg"].read_text(encoding="utf-8") + assert " None: + output_dir = tmp_path / "custom" + result, report = run_plotter( + EXAMPLE, + "--output-dir", + output_dir, + "--stem", + "custom", + "--elements", + "Lu,Rb,Nb,La,Ti,Y", + "--dpi", + "90", + ) + + assert result.returncode == 0 + assert report["configuration"]["elements"] == [ + "Rb", + "Nb", + "La", + "Ti", + "Y", + "Lu", + ] + + +def test_nmorb_reference_is_selectable(tmp_path: Path) -> None: + output_dir = tmp_path / "nmorb" + result, report = run_plotter( + EXAMPLE, + "--output-dir", + output_dir, + "--stem", + "nmorb", + "--reference", + "nmorb-sm89", + "--dpi", + "90", + ) + + assert result.returncode == 0 + assert report["reference"]["id"] == "NMORB_SM89" + assert report["configuration"]["reference_key"] == "nmorb-sm89" + assert not any(item["code"] == "W557" for item in report["issues"]) + + +def test_printed_primitive_mantle_warns_about_alternate_values( + tmp_path: Path, +) -> None: + output_dir = tmp_path / "printed" + result, report = run_plotter( + EXAMPLE, + "--output-dir", + output_dir, + "--stem", + "printed", + "--reference", + "pm-sm89", + "--dpi", + "90", + ) + + assert result.returncode == 0 + assert report["reference"]["id"] == "PrimitiveMantle_SM89" + assert any(item["code"] == "W557" for item in report["issues"]) + + +def test_bdl_value_creates_source_data_gap(tmp_path: Path) -> None: + input_path = tmp_path / "bdl.csv" + output_dir = tmp_path / "bdl" + pd.DataFrame( + { + "Sample": ["S1", "S2"], + "Rb_ppm": ["bdl", 2.0], + "Ba_ppm": [2.0, 3.0], + "Th_ppm": [1.0, 1.2], + "U_ppm": [0.5, 0.6], + "Nb_ppm": [4.0, 5.0], + "Ta_ppm": [0.3, 0.4], + } + ).to_csv(input_path, index=False) + + result, report = run_plotter( + input_path, + "--output-dir", + output_dir, + "--stem", + "bdl", + "--dpi", + "90", + ) + source = pd.read_csv(output_dir / "bdl.source_data.csv") + + assert result.returncode == 0 + assert report["status"] == "ready" + assert any(item["code"] == "W431" for item in report["issues"]) + assert pd.isna(source.at[0, "Rb_N"]) + + +def test_nonpositive_value_blocks_outputs(tmp_path: Path) -> None: + input_path = tmp_path / "invalid.csv" + output_dir = tmp_path / "invalid" + pd.DataFrame( + { + "Sample": ["S1"], + "Rb_ppm": [0], + "Ba_ppm": [2], + "Th_ppm": [1], + "U_ppm": [0.5], + "Nb_ppm": [4], + } + ).to_csv(input_path, index=False) + + result, report = run_plotter( + input_path, + "--output-dir", + output_dir, + "--stem", + "invalid", + ) + + assert result.returncode == 2 + assert report["status"] == "blocked" + assert not output_dir.exists() + + +def test_existing_bundle_requires_overwrite(tmp_path: Path) -> None: + output_dir = tmp_path / "existing" + first, _ = run_plotter( + EXAMPLE, + "--output-dir", + output_dir, + "--stem", + "same", + "--dpi", + "90", + ) + second, report = run_plotter( + EXAMPLE, + "--output-dir", + output_dir, + "--stem", + "same", + "--dpi", + "90", + ) + + assert first.returncode == 0 + assert second.returncode == 1 + assert report["status"] == "error"