analytical-figures
A figure + analysis skill for spectroscopy and analytical QC. It assumes you
already know matplotlib. Its job is the two things that actually go wrong:
the numbers are subtly wrong before they ever reach a plot, and the figure
looks "fine" but is broken in a way a quick glance misses. So it forces
verification and it locks a house style. Nothing here is a plotting tutorial.
If a request is just "how do I do X in matplotlib", answer it directly; this
skill is for producing deliverables from analytical data.
Output contract: a self-contained script, not a figure
The deliverable this skill produces is one runnable, self-contained Python
script — a CONFIG block plus the analysis body — that the user runs to generate
the figure. Do not render the figure in-session and hand over a PNG; hand over
the script that produces it, so it is reproducible, editable, and attachable to
a report. The modules in scripts/ are the maintained source of truth; you write
the analysis against them, then bundle.py amalgamates the modules it uses + your
body into a single file that depends only on the scientific stack (numpy,
matplotlib, optionally scipy/scienceplots), not on this skill being installed:
python bundle.py my_analysis.py -o standalone.py # hand over standalone.py
Provenance is stamped, not narrated. bundle.py writes a provenance header into the
deliverable — skill version, the Python + scientific-stack versions it was built against,
the source body, and a one-line plain-language description (--describe "…", or a
DESCRIPTION="…" constant in the body) — so the "exact code + environment that produced
it" travels with the figure and is recoverable months later. style.save_fig embeds the
same version + cfg.description into each figure FILE's metadata (PDF/SVG/PNG). At handoff
bundle.py also runs a critic pass (below).
Portability (NumPy 2.0+): np.trapz was removed in NumPy 2.0 and renamed
np.trapezoid. A bundled standalone must run on current NumPy, so never write
np.trapz in the analysis body — use np.trapezoid, or the family shim
spectra._trapz (= getattr(np, "trapezoid", getattr(np, "trapz", None)), works on
both), and prefer spectra.band_metric for band integration.
Form is the skill's job; judgment is the model's
The skill enforces form — the style, the gates, the script scaffold, the label
typography. The model supplies judgment — which bands to label and what each
label says, which chart answers the question, the narrative. Never bake a content
decision into the skill. (Concretely: label_peaks styles labels you pass in; it
does not detect peaks or write text. Guardrails that enforce correctness —
refusing low-n mean bars, mandating residual panels — are form, not content, and
stay.)
Prefer captions to floating text. Keep the plot area clean: avoid free-floating
annotations, value labels, target-line labels, and stat strings inside the axes —
put those details in the caption. Identify traces by position (waterfall stack
order; an x-axis that already encodes the variable) and state the order in the
caption, rather than by in-axes labels or a colour-only legend. Keep silent
visual pointers (shaded bands, position guides, a dashed target line) and explain
them in the caption. When you strip text, emit the details (band assignments, stack
order, numbers) for the caption — don't lose them.
The one rule that holds it together: a single CONFIG block
Everything tunable lives in the Config dataclass (scripts/config.py) and is
set ONCE at the top of an analysis. Copy templates/analysis_template.py and
edit only that block. If you want to tune something that isn't in Config, add
it to Config first — do not bury knobs in script bodies.
Workflow
- Choose the figure — before touching code, settle WHAT to plot and WHY with
references/figure_selection.md (data shape × argument × scale → figure type, with the
citable rule behind each call). It standardises the form decision; it doesn't decide your
science. A capable model can answer this ad hoc — the doc's value is a consistent answer.
- Configure — copy the template, fill the CONFIG block (domain, journal,
windows, conf level…).
- Ingest + gate the raw numbers with
verify.check_ingest / check_trace
BEFORE plotting (finite, monotonic axis, sane point count). A decode error
caught here saves a wrong figure later.
- Process identically across a batch. Baseline/normalise/integrate every
rep with the same cfg, record how, and assert it with
verify.same_processing
so rep-to-rep differences are real, not artefacts.
- Stats via
verify.summarize — returns mean, SD, SEM, t-based CI and a
caption naming the error type and n. Never present an error bar without saying
which statistic it is and what n is.
- Plot with the family module (
spectra / calibration / charts) using
style.figure. For a composite (multi-panel) figure, after drawing all
panels call style.finalize_figure(fig) then style.add_panel_labels(fig, cfg) — in that order (settle the layout, then anchor the letters).
- Visual QA loop (
verify.render_preview → verify.audit_layout [glyphs,
clipping, data escaping the axes, x+y tick overlap, one a/b/c letter per panel] →
open the PNG with the Read tool and walk verify.READ_IMAGE_CHECKLIST → fix →
re-render). Export the vector master only after a clean pass.
Normalise stacked traces by subtracting the window MINIMUM, not the median. With median
subtraction a sloping baseline puts the window minimum below zero and the lowest trace runs off
the bottom of the panel — a fault no text-clipping check can see, because no text is clipped.
Subtracting the minimum maps each trace to exactly [0, 1]; size the panel from
(n_traces - 1) * step + ~1.08. audit_layout now catches the residual cases: it judges y
only, and only for points already on-screen in x, so a deliberate zoom, axvline/axhline
guides and an explicit clip_on=False are all left alone.
7. Export with style.save_fig (vector + raster proof, sized at final dims).
Hard principles (these are gates, not preferences)
- Size at final print dimensions. Set
figsize/journal+column once; never
rescale in Word/LaTeX (pt fonts shrink with it).
- Vector first + a raster proof. PDF/SVG for data figures; PNG/TIFF only for
images. Never JPEG (
save_fig refuses it).
- Colour-blind safe + redundant encoding. Okabe-Ito/colorblind palette plus a
second channel so grayscale still separates — line-style/marker for generic charts,
but spectra stay solid (continuous) by default and take their redundancy from
waterfall position (dashes are opt-in only; see
references/spectra.md).
- Fonts ≥ 6 pt at final size (
style enforces the floor).
- Errors are always named — SD vs SEM vs CI is a √n factor; state the
statistic, n, and (for CI) the t-multiplier.
- Numbers carry units — every written quantity states its unit. Axis tick
numbers get theirs from the axis label, so each axis label must name a unit (or
mark it
dimensionless / a.u. / ratio / % when it genuinely has none —
e.g. absorbance, R²). The same applies to residual axes (same unit as the
response) and to any value quoted in a caption. audit_layout WARNs on an axis
whose label carries no unit cue.
- On-figure numbers must trace to the code. A figure-of-merit printed on the figure
or written into a caption (R², RMSEP, LOD, recovery, %RSD, p, n, ±error…) is
interpolated from the computed value (an f-string), never hand-typed — a pasted
literal silently desyncs the moment you refit ("R2 = 0.98" in the text,
0.91 in the
code). verify.check_number_provenance (the Tier-3 critic, run automatically at
bundle.py handoff) flags a hard-typed statistic; a fixed physical identifier (a band
centre like 1748 cm⁻¹) is a constant, not a result, and is exempt. Adapted from the
actor-critic reviewer in Anthropic's Claude Science.
- Calibration: a residual panel is mandatory — R² alone may not stand as the
linearity claim; refuse extrapolation outside the calibrated range.
- Identical processing across a batch — rep-to-rep differences must not be an
artefact of inconsistent baselines/windows.
- No positional jitter on a quantitative axis. Replicates that share an x-value
are drawn AT that value — the other axis separates them. Never nudge points along a
measured/numeric axis to un-stack them: it fabricates coordinates that don't exist (a
40 % w/w point drawn at 42 reads as 42 %). If markers overlap, separate them on the
perpendicular axis, use open/transparent markers or small multiples, or make the axis
deliberately CATEGORICAL (x carries no numeric meaning) — but a value axis shows true
values only.
Domain profiles (spectra)
|
FTIR / IR / ATR |
PXRD / XRD |
| x axis |
wavenumber cm⁻¹, inverted |
2θ degrees, normal |
| y |
absorbance |
intensity (counts / normalised) |
| baseline |
arPLS / rubberband, then integrate bands |
background subtract; watch amorphous halo |
| idiom |
overlaid reps + waterfall; band area / %RSD |
stacked patterns + reference stick lines; phase ID |
Set cfg.domain and the modules flip conventions automatically. Details and
recipes in references/spectra.md.
Chemometrics (multivariate calibration: PLS / PCR / PCA)
When no single band is selective for the analyte (overlap, matrix effects, or
scatter/sample-loading variation that swings every band together), regress the WHOLE
spectrum instead of one integrated band. scripts/chemometrics.py is the family for
this — its value is the same as the rest of the skill: it gates the things that go
silently wrong in chemometrics, which are not the same as in univariate work. Three
gates, encoded so you can't bypass them by accident: (1) CV leakage — a fresh
Preprocessor is re-fit on the training rows INSIDE every fold (MSC reference,
mean-centring, autoscaling all leak if fit on the full set; SNV/derivatives don't, but
the path is uniform); (2) component over-fit — choose_n_components takes the
parsimony pick (fewest components within cfg.lv_parsimony_tol of the global-min
RMSECV), and the RMSECV-vs-components curve is shown not asserted; (3) the optimistic-CV
trap — leave-one-out over replicate spectra answers "predict a SEEN level", so every
result NAMES its question and cross_validate WARNs you to pass groups=level-labels
for leave-one-LEVEL-out when predicting a new level. Report RMSECV / R²(CV) / bias,
never calibration R² as accuracy. diagnostics_figure is the turnkey 2×2 (RMSECV·
coefficients·scores·loadings) on a constrained-layout figure with the colourbar and
panel letters handled; methods_text writes the paste-ready paragraph. scikit-learn
is a LAZY dependency (imported only when a model is fit, like the crystal heavy deps) —
an FTIR/univariate job never pulls it, and a bundled deliverable keeps the import inside
the functions. A separate outlier/diagnostic gate (diagnostics / plot_influence) does
PCA-based per-sample QC — Hotelling T², Q/SPE, leverage and the DD-SIMCA SD–OD acceptance
plot (flag "bad load" vs "extreme but valid") — using numpy+scipy only, so it runs as
raw-spectrum QC before any calibration. EMSC (emsc) adds a jointly-fitted-baseline scatter
correction with an optional pure-component interferent term (model a known matrix component away);
a validation trio (permutation_test, corrected_paired_t = Nadeau–Bengio, rpd/rpiq)
gives defensible figures-of-merit for a small calibration. Conventions, the preprocessing menu, and
the coefficient-interpretability caveat (raw model to SEE bands, SNV model to REPORT error) are in
references/chemometrics.md.
Crystal structures (CIF)
A third family, for crystallography: a CIF → (1) a validation table (density
triple-check, geometry, H-bonds, CheckCIF-style alerts), (2) calculated PXRD, and (3)
a deterministic 3D structure view. crystal_engine validates before anything is
drawn — a figure is a view of the validated computation, never independent. Heavy deps
(gemmi, Dans_Diffraction, optional pymatgen/py3Dmol) install lazily at run time, so
an FTIR/calibration job never pulls them. Presentation conventions (what to show,
orientation, ellipsoids, labels, captions — grounded in IUCr/ORTEP) live in
references/crystal.md. The hard floor is correctness + reproducibility (the camera is
numbers in CONFIG); which view and what to label is the model's case-by-case call —
PCA face-on is the auto default, custom/vector/axis cameras are first-class. The 3D view
renders via PyVista/VTK (real depth buffer → gap-free, SSAO+SSAA, offscreen) with a
matplotlib fallback; structure images are high-res raster, PXRD/table stay vector. Views:
render (molecule), render_unit_cell, render_packing, render_hbond_environment; ORTEP
displacement ellipsoids via view_style='ellipsoid' (adp_probability), packing via pack_cells.
render_hbond_environment neighbour extent = hbond_neighbour (stub default / site / whole,
symmetry mates auto-superscripted); unit-cell/packing fill = cell_fill (molecule / clip).
Cocrystal ID — start with references/cocrystal_id.md (the decision layer: cocrystal vs salt
vs physical mixture vs polymorph, and which evidence settles it — PXRD new-phase, the FTIR
proton-transfer/ΔpKa salt discriminator, the lab-capability gate — routing into the tools below).
crystal_pxrd.plot_overlay_patterns waterfalls calc(cocrystal) vs starting materials (+ experimental) for
phase discrimination; peak_table/write_peaks_csv emit a (2θ, d, hkl, I) list;
cfg.color_by_component mutes all but the largest molecule to distinguish API vs coformer.
Composite / multi-panel figures
Most report figures are composites — an a/b panel pair, a spectrum over its
residual strip, three calibrations on shared axes. Build them as ONE figure, never
by pasting separate PNGs together (and panels that invite comparison must literally
sharex=/sharey= so the comparison is honest):
- Grid:
fig, axes = style.figure(cfg, nrows, ncols, height_ratios=…, width_ratios=…) — real subplots, sized once at final print dimensions. The
default height scales with nrows/ncols so a grid isn't squashed into one
row's height; width_ratios sizes columns (e.g. make one panel a bit narrower so
its neighbour's right-margin edge labels have room).
- Draw each panel with the family modules. A waterfall panel keys its traces
with
spectra.edge_labels(ax, items) — right-margin labels aligned per trace
(grayscale-safe, beats a colour-only legend box). Prefer keying the RIGHTMOST panel
so the labels fall in the figure's right margin (reserve it with rect, next step);
if you must label an interior panel, its labels land in the gutter — widen that with
wspace. When panels share a colour map + stack order, ONE such key serves them all.
- Finalise, THEN label — in that order.
style.finalize_figure(fig, rect=…, wspace=…, hspace=…) settles the margins (constrained layout, tight
fallback) so nothing clips or overlaps. The layout engine can't see edge labels
(clip_on=False), so rect=(left,bottom,width,height) RESERVES an outer margin for
right-edge labels, and wspace/hspace widen inter-panel gutters for interior
ones. Only then style.add_panel_labels(fig, cfg) places the a/b/c letters —
anchored at each panel's top-left and pushed by ONE shared points offset, so they
line up whatever the tick-label widths (pass x_offset_pt="auto" to derive that one
offset from the widest y-furniture). Labelling before the layout settles misplaces them.
audit_layout confirms one letter per panel AND that no text spills from one
panel into another (widen wspace or shorten the label if it does); the
Read-the-PNG pass confirms the letters line up.
House rule — keep gutters TIGHT (~0.1, never 0.2–0.4). For a compact print figure
the inter-panel wspace/hspace belong around 0.1 (as the chemometrics panel uses:
wspace=0.10, hspace=0.12), NOT the 0.2–0.4 that spreads panels apart and shrinks the
data. Start tight; only widen one specific gutter when an interior panel's right-margin
edge labels would otherwise spill — never inflate every gutter to make room for one.
If panels still look far apart at wspace≈0.1, the gutter is NOT the cause — TEXT is.
Constrained layout sizes each gutter from the neighbouring axes' tight bboxes, which
include y-labels, tick labels and any clip_on=False text. So a single long string silently
buys itself a wide gutter, and raising wspace to "fix" the look makes it worse. Diagnose by
printing ax.get_position() for every panel — if the axes occupy well under ~85 % of the
figure width, the space is going to text, not spacing. Two habitual offenders and their fixes:
- A long y-axis label (
"ΔRwp on deleting theophylline / pp") sits in the inter-panel
gutter and pushes the columns apart. Break it over two lines (\n) — same information,
half the width.
- Long categorical tick labels (
"1:1 mix (weighed)") eat the outer margin. Shorten the
tick text and put the qualifier in the caption.
Do not pass rect to reserve room for right-margin edge_labels under constrained layout —
it already counts them in the axes' tight bbox, so rect pays for the same text twice and
squeezes every panel inwards. Reserve with rect only when the layout engine genuinely cannot
see the artist (a fig.text in figure coordinates), and confirm by reading the SAVED figure.
The letter is in the IMAGE; the figure number and caption are not. Bake only
the panel letter into the figure. "Figure 3", the caption, and cross-references
("see Figure 3") live in the document (Word/LaTeX caption feature), so the image
stays reusable and renumbering can't desync. Never render "Figure 3" into the plot.
Sectioned composites & repeated-panel grids (the page-figure idioms)
A full-page figure that stacks logically distinct sections (e.g. "what we measure"
over "candidate calibrations" over "figures of merit") goes beyond one flat grid:
- Sections via subfigures.
fig = plt.figure(layout="constrained"), then
subs = fig.subfigures(n, 1, height_ratios=…); give each section its own internal grid
and, if it needs one, its own legend (sf.legend(loc="outside lower center")). Each
section's spacing stays independent.
- Guarantee an outer margin. Constrained layout can pull axes flush to the edge.
fig.get_layout_engine().set(w_pad=…, h_pad=…) reserves margin on ALL sides. Judge the
margin on the SAVED figure, not render_preview — the preview tight-crops, so it always
looks margin-less.
- Repeated panel-units → a CSS-grid, NOT a tall grid with spacer rows. For N identical
units (a calibration over its residual strip, one per candidate), build an OUTER gridspec
of the cells with ONE uniform
hspace/wspace, and an INNER subgridspec per cell for
the tightly-coupled sub-axes:outer = sf.add_gridspec(2, 2, hspace=0.22, wspace=0.34) # one uniform gap between cells
for i, k in enumerate(items):
r, c = divmod(i, 2)
cell = outer[r, c].subgridspec(2, 1, height_ratios=[3.4, 1], hspace=0.06) # cal+resid, tight
a0 = sf.add_subplot(cell[0]); a1 = sf.add_subplot(cell[1], sharex=a0)
A single tall grid with spacer rows makes the gaps COMPOUND (spacer height + two hspaces)
into an uneven "big gap in the middle"; the nested grid gives one even gap, like CSS gap.
- Per-chart letters in reading order, OUTSIDE the corner. Sectioning/nesting breaks
add_panel_labels' automatic enumeration, so pass the charts explicitly, in reading order:
fig.canvas.draw(); style.add_panel_labels(fig, cfg, axes=[…], x_offset_pt="auto") — the
letters then sit outside the top-left corner, aligned, exactly as on a flat grid. A coupled
cal+residual pair is ONE chart → letter only its main axes (audit_layout treats a residual
strip as part of its chart). style.panel_letter(ax, s) puts a letter INSIDE the corner and is
only for the rare panel with no margin to spare; never mix the two conventions in one figure.
- Direction-of-good in comparison titles. Any FoM/comparison panel where taller/larger
isn't self-evidently "better" states the good direction IN its title —
"Precision\n(lower = better)", "Accuracy\n(100 = ideal)", "Linearity\n(higher = better)" — so a bar chart
can't be misread.
- The title names the species, not just the band.
"Aspirin ester C=O (1748 cm⁻¹)", not
"Ester C=O" — so a metric named elsewhere ("ester area") resolves to a species. Band
identifiers in titles/tick-labels carry their unit (1066 cm⁻¹, never bare 1066).
Files
scripts/config.py — the Config dataclass (the one tunable block).
scripts/style.py — apply_style, figure (takes width_ratios; grid-aware
default height), save_fig, plus composite helpers finalize_figure (takes
wspace/hspace/w_pad/h_pad to widen gutters, rect to reserve an outer margin
for right-edge labels) + add_panel_labels (aligned a/b/c letters for a flat grid;
x_offset_pt="auto" derives one shared offset clearing the y-furniture) + panel_letter
(ONE inside-corner letter, for hand-lettering sectioned/nested composites in reading order).
Plus check_figure_width (verify a figure is at the journal's physical width — no silent
rescaling) and export_mplstyle (write a standalone .mplstyle; presets in assets/styles/).
save_fig stamps provenance (skill version + cfg.description + producing software)
into each figure FILE's metadata (best-effort; a backend that rejects it still writes the file).
scripts/verify.py — data gates + summarize + the render/read-image QA loop
(audit_layout also covers clipping, data running outside the y-limits, x+y tick overlap,
composite panel letters, and text from one panel spilling into another). Plus adjust_pvalues / multiplicity_check
(Holm/Bonferroni/BH correction for a FAMILY of p-values — annotate adjusted, not raw),
flag_outliers_mad (robust median/MAD outlier flag for replicate band-metrics — the
univariate analog of chemometrics.diagnostics; never raises, flag-don't-hide), and the
Tier-3 critic check_number_provenance (static scan: a hand-typed figure-of-merit on
the figure/in a caption that should be interpolated from the computed value — the desync-
on-refit failure mode; keyed on the skill's FoM vocabulary so band identifiers are exempt).
scripts/spectra.py — FTIR+PXRD ingest, baseline, integration, waterfall/overlay,
optional Savitzky-Golay (apply_smooth), %T display (as_transmittance /
cfg.ftir_yaxis), edge_labels (right-margin per-trace waterfall key, grayscale-safe;
reusable for hand-built or composite panels), and label_peaks (styles the labels
YOU supply — it does not pick peaks or write the text).
scripts/calibration.py — regression, bands, residuals, LOD/LOQ, predict.
scripts/chemometrics.py — multivariate calibration (PLS/PCR/PCA): leakage-safe
cross_validate/component_scan, parsimony choose_n_components, Preprocessor
(SNV/MSC/d1/d2/centre/autoscale), vip, ax-level panel plots, the diagnostics_figure
composite, and methods_text. sklearn is a lazy dep. Plus the PCA-based outlier gate
(diagnostics/plot_influence: T²/Q/leverage + DD-SIMCA; numpy+scipy only), a one-class
DD-SIMCA classifier (ddsimca_fit/ddsimca_predict/class_fom — sensitivity/specificity/
efficiency; the "is this the target phase?" call for cocrystal ID), emsc (EMSC
scatter+baseline+interferents), and the validation trio (permutation_test,
corrected_paired_t, rpd/rpiq). All validated by skill_validation/chemometrics/ (100 checks).
scripts/charts.py — generic line/bar with anti-pattern guards.
scripts/doe.py — seeded, operator-balanced run-order schedules (operator orthogonal to
concentration — fixes the operator/level confound) + CSV run sheet. Self-validates: python scripts/doe.py (determinism + balance + coverage).
scripts/report.py — methods_report(cfg, ...): paste-ready methods text + SI table.
scripts/crystal_engine.py — CIF → validated crystal data (parse, symmetry-expand,
density triple-check, geometry, H-bonds); the single source of truth for the crystal family.
scripts/crystal_pxrd.py — calculated PXRD from a CIF (Dans_Diffraction + pymatgen/Bragg
cross-check), plotted through the spectra pxrd domain. reflection_list extracts Dans's
physically-correct powder reflections [(2θ, I, hkl)…]; realistic_pattern composes them with
pxrd_realism (the turn-key Cu-Kα-scan simulator).
scripts/pxrd_realism.py — make a calc pattern match a real lab Cu-Kα scan: march_dollase
(preferred-orientation intensity), kalpha2_doublet (Cu Kα₁/Kα₂ splitting), caglioti_fwhm +
pseudo_voigt (angle-dependent width/shape), composed by simulate_pattern. Numpy-only, so it
operates on a reflection list [(2θ, I, hkl)…] (Dans supplies the structure factors) and is
fully testable without heavy deps. Validated by skill_validation/pxrd/ (physics, dep-light) and
end-to-end on real CIFs (aspirin, lactose) via crystal_pxrd.realistic_pattern.
scripts/crystal_view.py — deterministic 3D structure render (PCA or custom/vector/axis
camera, bonds, dashed H-bonds, C–H hiding, heteroatom labels).
scripts/cocrystal.py — cocrystal-ID arithmetic the cocrystal_id.md decision layer routes
to. Wave 1: delta_pka/classify_ionisation (the ΔpKa salt/cocrystal/continuum call,
Cruz-Cabeza 2012; zero-dep, never raises) + rwp/sum_of_parents/phase_report (NNLS
new-phase-vs-physical-mixture discriminator with Rwp + new/lost-peak table; scipy, lazy).
Validated by skill_validation/cocrystal/ (26 checks).
templates/analysis_template.py — copy this; shows the CONFIG-block shape.
bundle.py — amalgamate the modules an analysis uses + its CONFIG/body into one
self-contained deliverable script. Stamps a provenance header (skill version, the
Python + scientific-stack versions, source, --describe/DESCRIPTION text) and runs the
Tier-3 critic on the body at handoff (--no-critic to skip).
scripts/sources.py — fetch reference data from the OPEN databases (stdlib-only, best-effort,
cites the URL, retries transient DNS/timeouts): cod_search/cod_fetch_cif (Crystallography Open
Database → CIF → crystal_engine) and pubchem_properties (PUG-REST identity/computed props) —
both live-verified end-to-end. The full sourcing map —
incl. the paywalled/manual ones — is references/databases.md. Pure URL/parse logic is gate-tested
(tests/test_sources.py); the network wrappers aren't.
cli.py — scripts-first QC shortcuts: spc-rsd (.spc replicates → band area/%RSD) and
calibrate (conc,signal CSV → slope/R²/LOD/LOQ). Convenience for routine checks; the report
deliverable is still a written analysis, not CLI output.
tests/ — pytest skill self-tests (module imports, SKILL.md structure, bundle.py
round-trip, multiplicity vs statsmodels, .mplstyle/width, the CLI) that also bridge in the
numeric suites. python -m pytest tests/ is one green gate for the whole skill.
assets/styles/ — .mplstyle house-style presets per journal (nature/acs/ieee/general).
skill_validation/chemometrics/ — the numeric-parity validation suite (100 checks; see its SPEC.md).
skill_validation/cocrystal/ — the numeric-parity validation suite for the cocrystal-ID family
(ΔpKa zones + NNLS sum-of-parents/Rwp, 26 checks), bridged into pytest tests/.
skill_validation/pxrd/ — the numeric-parity validation suite for PXRD realism (23 checks:
March-Dollase, Kα₂ doublet, Caglioti, pseudo-Voigt, simulate_pattern), bridged into pytest tests/.
skill_validation/crystal/ — the CIF edge-case set (COD CIFs + synthetic broken inputs; CCDC extras
optional) with the engine / independent-gate / ADP / realism checks (run.py; needs gemmi +
Dans_Diffraction, pytest skips it otherwise) and crystal_demo.py, the worked example.
skill_validation/ftir_integration/ — the band integrator against published calibrations (pectin
DM, BMC 2021) plus a deterministic closed-form ground-truth test (14 checks, bridged into pytest tests/).
references/ — figure_selection.md (the "what to plot" decision layer — start here),
cocrystal_id.md (the "which evidence identifies a cocrystal" decision layer — start here
for cocrystal ID), databases.md (where to source each external fact — open first,
cite always), verification.md, spectra.md, calibration.md,
chemometrics.md, charts.md, crystal.md, adding_a_family.md. View on demand, not all at once.
When NOT to over-reach
Don't psychoanalyse the data or invent conclusions. Report what the gates and
the numbers show. If n is small, say the CIs are wide and lead with the trend.
Build the figure that was asked for. Produce the panels the request names; don't
annex an adjacent analysis, an extra panel, or a supporting figure nobody asked for.
Where a second figure looks genuinely warranted, say so in one line and let the user
decide — an unrequested panel costs a re-render and dilutes the one that answers the
question. The gates above are the exception: a residual panel, a named error bar and a
QA pass are part of the deliverable being correct, not scope creep.
Length is calibrated to the artefact. A caption is a caption; methods_text /
methods_report emit paste-ready prose, not a review. The standalone script carries the
analysis plus its provenance header — it does not need step-by-step narration in
comments, and the house comment rule applies (state a constraint the code can't show;
never restate what the next line does). Prose that isn't carrying information is prose
the reader skips.
External facts get looked up, not recalled. Any value that comes from outside the
data in front of you — a reagent/column price or availability, a literature reference
value, a pharmacopoeial limit, a band assignment you're quoting as authoritative — must be
fetched with a live web search (or read from a cited document) and reported with its
source + date, never stated from training memory. Prices and catalog availability drift;
a confidently recalled number that's stale silently corrupts a budget or a spec. Same
provenance discipline as the figure/number gates, applied to the world outside the dataset.
1---2name: analytical-figures3description: Publication-grade figures and the gated analysis behind them for analytical chemistry / spectroscopy work. Use for ANY of: plotting FTIR / IR / ATR or PXRD / XRD spectra (overlays, waterfalls, baseline correction, band integration, %RSD/precision); building calibration curves (linear regression, confidence/prediction bands, residuals, LOD/LOQ, recovery, ICH Q2); generic line/bar charts for lab data; or turning raw instrument files (.spc, xy/csv) into journal-ready PDF+PNG. Trigger on: spectrum, spectra, FTIR, IR, ATR, PXRD, XRD, diffractogram, waterfall, baseline, band area, integration, RSD, calibration curve, linearity, LOD, LOQ, residuals, recovery, publication figure, journal figure; crystal structure, CIF, .cif, space group, hydrogen bond, displacement ellipsoid / ORTEP, packing diagram, calculated PXRD from a CIF, structure validation table.4license: MIT5---67# analytical-figures89A figure + analysis skill for spectroscopy and analytical QC. It assumes you10already know matplotlib. Its job is the two things that actually go wrong:11**the numbers are subtly wrong before they ever reach a plot**, and **the figure12looks "fine" but is broken in a way a quick glance misses**. So it forces13verification and it locks a house style. Nothing here is a plotting tutorial.1415If a request is just "how do I do X in matplotlib", answer it directly; this16skill is for producing *deliverables* from analytical data.1718## Output contract: a self-contained script, not a figure1920The deliverable this skill produces is **one runnable, self-contained Python21script** — a CONFIG block plus the analysis body — that the user runs to generate22the figure. Do **not** render the figure in-session and hand over a PNG; hand over23the *script that produces it*, so it is reproducible, editable, and attachable to24a report. The modules in `scripts/` are the maintained source of truth; you write25the analysis against them, then `bundle.py` amalgamates the modules it uses + your26body into a single file that depends only on the scientific stack (numpy,27matplotlib, optionally scipy/scienceplots), not on this skill being installed:2829```30python bundle.py my_analysis.py -o standalone.py # hand over standalone.py31```3233**Provenance is stamped, not narrated.** `bundle.py` writes a provenance header into the34deliverable — skill version, the Python + scientific-stack versions it was built against,35the source body, and a one-line plain-language description (`--describe "…"`, or a36`DESCRIPTION="…"` constant in the body) — so the "exact code + environment that produced37it" travels with the figure and is recoverable months later. `style.save_fig` embeds the38same version + `cfg.description` into each figure FILE's metadata (PDF/SVG/PNG). At handoff39`bundle.py` also runs a **critic pass** (below).4041**Portability (NumPy 2.0+):** `np.trapz` was **removed** in NumPy 2.0 and renamed42**`np.trapezoid`**. A bundled standalone must run on current NumPy, so never write43`np.trapz` in the analysis body — use `np.trapezoid`, or the family shim44`spectra._trapz` (`= getattr(np, "trapezoid", getattr(np, "trapz", None))`, works on45both), and prefer `spectra.band_metric` for band integration.4647## Form is the skill's job; judgment is the model's4849The skill enforces **form** — the style, the gates, the script scaffold, the label50*typography*. The model supplies **judgment** — which bands to label and what each51label says, which chart answers the question, the narrative. Never bake a content52decision into the skill. (Concretely: `label_peaks` styles labels you pass in; it53does not detect peaks or write text. Guardrails that enforce *correctness* —54refusing low-n mean bars, mandating residual panels — are form, not content, and55stay.)5657**Prefer captions to floating text.** Keep the plot area clean: avoid free-floating58annotations, value labels, target-line labels, and stat strings inside the axes —59put those details in the caption. Identify traces by position (waterfall stack60order; an x-axis that already encodes the variable) and state the order in the61caption, rather than by in-axes labels or a colour-only legend. Keep *silent*62visual pointers (shaded bands, position guides, a dashed target line) and explain63them in the caption. When you strip text, emit the details (band assignments, stack64order, numbers) for the caption — don't lose them.6566## The one rule that holds it together: a single CONFIG block6768Everything tunable lives in the `Config` dataclass (`scripts/config.py`) and is69set ONCE at the top of an analysis. Copy `templates/analysis_template.py` and70edit only that block. If you want to tune something that isn't in `Config`, add71it to `Config` first — do not bury knobs in script bodies.7273## Workflow74750. **Choose the figure** — before touching code, settle WHAT to plot and WHY with76 `references/figure_selection.md` (data shape × argument × scale → figure type, with the77 citable rule behind each call). It standardises the *form* decision; it doesn't decide your78 science. A capable model can answer this ad hoc — the doc's value is a *consistent* answer.791. **Configure** — copy the template, fill the CONFIG block (domain, journal,80 windows, conf level…).812. **Ingest + gate** the raw numbers with `verify.check_ingest` / `check_trace`82 BEFORE plotting (finite, monotonic axis, sane point count). A decode error83 caught here saves a wrong figure later.843. **Process identically across a batch.** Baseline/normalise/integrate every85 rep with the same cfg, record how, and assert it with `verify.same_processing`86 so rep-to-rep differences are real, not artefacts.874. **Stats** via `verify.summarize` — returns mean, SD, SEM, t-based CI *and* a88 caption naming the error type and n. Never present an error bar without saying89 which statistic it is and what n is.905. **Plot** with the family module (`spectra` / `calibration` / `charts`) using91 `style.figure`. For a **composite** (multi-panel) figure, after drawing all92 panels call `style.finalize_figure(fig)` **then** `style.add_panel_labels(fig,93 cfg)` — in that order (settle the layout, *then* anchor the letters).946. **Visual QA loop** (`verify.render_preview` → `verify.audit_layout` [glyphs,95 clipping, data escaping the axes, x+y tick overlap, one a/b/c letter per panel] →96 open the PNG with the **Read tool** and walk `verify.READ_IMAGE_CHECKLIST` → fix →97 re-render). Export the vector master only after a clean pass.9899**Normalise stacked traces by subtracting the window MINIMUM, not the median.** With median100subtraction a sloping baseline puts the window minimum below zero and the lowest trace runs off101the bottom of the panel — a fault no text-clipping check can see, because no text is clipped.102Subtracting the minimum maps each trace to exactly [0, 1]; size the panel from103`(n_traces - 1) * step + ~1.08`. `audit_layout` now catches the residual cases: it judges **y104only**, and only for points already on-screen in x, so a deliberate zoom, `axvline`/`axhline`105guides and an explicit `clip_on=False` are all left alone.1067. **Export** with `style.save_fig` (vector + raster proof, sized at final dims).107108## Hard principles (these are gates, not preferences)109110- **Size at final print dimensions.** Set `figsize`/journal+column once; never111 rescale in Word/LaTeX (pt fonts shrink with it).112- **Vector first + a raster proof.** PDF/SVG for data figures; PNG/TIFF only for113 images. **Never JPEG** (`save_fig` refuses it).114- **Colour-blind safe + redundant encoding.** Okabe-Ito/colorblind palette plus a115 second channel so grayscale still separates — line-style/marker for generic charts,116 but **spectra stay solid (continuous) by default** and take their redundancy from117 waterfall *position* (dashes are opt-in only; see `references/spectra.md`).118- **Fonts ≥ 6 pt at final size** (`style` enforces the floor).119- **Errors are always named** — SD vs SEM vs CI is a √n factor; state the120 statistic, n, and (for CI) the t-multiplier.121- **Numbers carry units** — every written quantity states its unit. Axis tick122 numbers get theirs from the axis label, so each axis label must name a unit (or123 mark it `dimensionless` / `a.u.` / `ratio` / `%` when it genuinely has none —124 e.g. absorbance, R²). The same applies to residual axes (same unit as the125 response) and to any value quoted in a caption. `audit_layout` WARNs on an axis126 whose label carries no unit cue.127- **On-figure numbers must trace to the code.** A figure-of-merit printed on the figure128 or written into a caption (R², RMSEP, LOD, recovery, %RSD, p, n, ±error…) is129 **interpolated from the computed value** (an f-string), never hand-typed — a pasted130 literal silently desyncs the moment you refit ("R2 = 0.98" in the text, `0.91` in the131 code). `verify.check_number_provenance` (the **Tier-3 critic**, run automatically at132 `bundle.py` handoff) flags a hard-typed statistic; a fixed physical identifier (a band133 centre like 1748 cm⁻¹) is a *constant*, not a result, and is exempt. Adapted from the134 actor-critic reviewer in Anthropic's Claude Science.135- **Calibration: a residual panel is mandatory** — R² alone may not stand as the136 linearity claim; refuse extrapolation outside the calibrated range.137- **Identical processing across a batch** — rep-to-rep differences must not be an138 artefact of inconsistent baselines/windows.139- **No positional jitter on a quantitative axis.** Replicates that share an x-value140 are drawn AT that value — the *other* axis separates them. Never nudge points along a141 measured/numeric axis to un-stack them: it fabricates coordinates that don't exist (a142 40 % w/w point drawn at 42 reads as 42 %). If markers overlap, separate them on the143 perpendicular axis, use open/transparent markers or small multiples, or make the axis144 deliberately CATEGORICAL (x carries no numeric meaning) — but a value axis shows true145 values only.146147## Domain profiles (spectra)148149| | FTIR / IR / ATR | PXRD / XRD |150|---|---|---|151| x axis | wavenumber cm⁻¹, **inverted** | 2θ degrees, **normal** |152| y | absorbance | intensity (counts / normalised) |153| baseline | arPLS / rubberband, then integrate bands | background subtract; watch amorphous halo |154| idiom | overlaid reps + waterfall; band area / %RSD | stacked patterns + reference stick lines; phase ID |155156Set `cfg.domain` and the modules flip conventions automatically. Details and157recipes in `references/spectra.md`.158159## Chemometrics (multivariate calibration: PLS / PCR / PCA)160161When **no single band is selective** for the analyte (overlap, matrix effects, or162scatter/sample-loading variation that swings every band together), regress the WHOLE163spectrum instead of one integrated band. `scripts/chemometrics.py` is the family for164this — its value is the same as the rest of the skill: it gates the things that go165*silently* wrong in chemometrics, which are not the same as in univariate work. Three166gates, encoded so you can't bypass them by accident: **(1) CV leakage** — a fresh167`Preprocessor` is re-fit on the training rows INSIDE every fold (MSC reference,168mean-centring, autoscaling all leak if fit on the full set; SNV/derivatives don't, but169the path is uniform); **(2) component over-fit** — `choose_n_components` takes the170parsimony pick (fewest components within `cfg.lv_parsimony_tol` of the global-min171RMSECV), and the RMSECV-vs-components curve is shown not asserted; **(3) the optimistic-CV172trap** — leave-one-out over replicate spectra answers *"predict a SEEN level"*, so every173result NAMES its question and `cross_validate` WARNs you to pass `groups=level-labels`174for leave-one-LEVEL-out when predicting a new level. Report **RMSECV / R²(CV) / bias**,175never calibration R² as accuracy. `diagnostics_figure` is the turnkey 2×2 (RMSECV·176coefficients·scores·loadings) on a constrained-layout figure with the colourbar and177panel letters handled; `methods_text` writes the paste-ready paragraph. **scikit-learn178is a LAZY dependency** (imported only when a model is fit, like the crystal heavy deps) —179an FTIR/univariate job never pulls it, and a bundled deliverable keeps the import inside180the functions. A separate **outlier/diagnostic gate** (`diagnostics` / `plot_influence`) does181PCA-based per-sample QC — Hotelling T², Q/SPE, leverage and the **DD-SIMCA** SD–OD acceptance182plot (flag "bad load" vs "extreme but valid") — using **numpy+scipy only**, so it runs as183raw-spectrum QC *before* any calibration. **EMSC** (`emsc`) adds a jointly-fitted-baseline scatter184correction with an optional pure-component **interferent** term (model a known matrix component away);185a **validation trio** (`permutation_test`, `corrected_paired_t` = Nadeau–Bengio, `rpd`/`rpiq`)186gives defensible figures-of-merit for a small calibration. Conventions, the preprocessing menu, and187the coefficient-interpretability caveat (raw model to SEE bands, SNV model to REPORT error) are in188`references/chemometrics.md`.189190## Crystal structures (CIF)191192A third family, for **crystallography**: a CIF → (1) a **validation table** (density193triple-check, geometry, H-bonds, CheckCIF-style alerts), (2) **calculated PXRD**, and (3)194a **deterministic 3D structure view**. `crystal_engine` validates *before* anything is195drawn — a figure is a view of the validated computation, never independent. Heavy deps196(`gemmi`, `Dans_Diffraction`, optional `pymatgen`/`py3Dmol`) install lazily at run time, so197an FTIR/calibration job never pulls them. Presentation conventions (what to show,198orientation, ellipsoids, labels, captions — grounded in IUCr/ORTEP) live in199`references/crystal.md`. The hard floor is **correctness + reproducibility** (the camera is200numbers in CONFIG); *which* view and what to label is the model's case-by-case call —201PCA face-on is the auto default, custom/`vector`/axis cameras are first-class. The 3D view202renders via **PyVista/VTK** (real depth buffer → gap-free, SSAO+SSAA, offscreen) with a203matplotlib fallback; structure images are high-res raster, PXRD/table stay vector. Views:204`render` (molecule), `render_unit_cell`, `render_packing`, `render_hbond_environment`; ORTEP205displacement ellipsoids via `view_style='ellipsoid'` (`adp_probability`), packing via `pack_cells`.206`render_hbond_environment` neighbour extent = `hbond_neighbour` (`stub` default / `site` / `whole`,207symmetry mates auto-superscripted); unit-cell/packing fill = `cell_fill` (`molecule` / `clip`).208Cocrystal ID — **start with `references/cocrystal_id.md`** (the decision layer: cocrystal vs salt209vs physical mixture vs polymorph, and which evidence settles it — PXRD new-phase, the FTIR210proton-transfer/ΔpKa salt discriminator, the lab-capability gate — routing into the tools below).211`crystal_pxrd.plot_overlay_patterns` waterfalls calc(cocrystal) vs starting materials (+ experimental) for212phase discrimination; `peak_table`/`write_peaks_csv` emit a (2θ, d, hkl, I) list;213`cfg.color_by_component` mutes all but the largest molecule to distinguish API vs coformer.214215## Composite / multi-panel figures216217Most report figures are composites — an a/b panel pair, a spectrum over its218residual strip, three calibrations on shared axes. Build them as ONE figure, never219by pasting separate PNGs together (and panels that invite comparison must literally220`sharex=`/`sharey=` so the comparison is honest):2212221. **Grid:** `fig, axes = style.figure(cfg, nrows, ncols, height_ratios=…,223 width_ratios=…)` — real subplots, sized once at final print dimensions. The224 default height scales with `nrows/ncols` so a grid isn't squashed into one225 row's height; `width_ratios` sizes columns (e.g. make one panel a bit narrower so226 its neighbour's right-margin edge labels have room).2272. **Draw** each panel with the family modules. A waterfall panel keys its traces228 with `spectra.edge_labels(ax, items)` — right-margin labels aligned per trace229 (grayscale-safe, beats a colour-only legend box). Prefer keying the RIGHTMOST panel230 so the labels fall in the figure's right margin (reserve it with `rect`, next step);231 if you must label an interior panel, its labels land in the gutter — widen that with232 `wspace`. When panels share a colour map + stack order, ONE such key serves them all.2333. **Finalise, THEN label — in that order.** `style.finalize_figure(fig,234 rect=…, wspace=…, hspace=…)` settles the margins (constrained layout, tight235 fallback) so nothing clips or overlaps. The layout engine can't see edge labels236 (clip_on=False), so `rect=(left,bottom,width,height)` RESERVES an outer margin for237 right-edge labels, and `wspace`/`hspace` widen inter-panel gutters for interior238 ones. Only then `style.add_panel_labels(fig, cfg)` places the a/b/c letters —239 anchored at each panel's top-left and pushed by ONE shared points offset, so they240 line up whatever the tick-label widths (pass `x_offset_pt="auto"` to derive that one241 offset from the widest y-furniture). Labelling *before* the layout settles misplaces them.2424. `audit_layout` confirms one letter per panel AND that no text spills from one243 panel into another (widen `wspace` or shorten the label if it does); the244 Read-the-PNG pass confirms the letters line up.245246**House rule — keep gutters TIGHT (~0.1, never 0.2–0.4).** For a compact print figure247the inter-panel `wspace`/`hspace` belong **around 0.1** (as the chemometrics panel uses:248`wspace=0.10, hspace=0.12`), NOT the 0.2–0.4 that spreads panels apart and shrinks the249data. Start tight; only *widen one specific gutter* when an interior panel's right-margin250edge labels would otherwise spill — never inflate every gutter to make room for one.251252**If panels still look far apart at `wspace≈0.1`, the gutter is NOT the cause — TEXT is.**253Constrained layout sizes each gutter from the neighbouring axes' *tight* bboxes, which254include y-labels, tick labels and any `clip_on=False` text. So a single long string silently255buys itself a wide gutter, and raising `wspace` to "fix" the look makes it worse. Diagnose by256printing `ax.get_position()` for every panel — if the axes occupy well under ~85 % of the257figure width, the space is going to text, not spacing. Two habitual offenders and their fixes:258- **A long y-axis label** (`"ΔRwp on deleting theophylline / pp"`) sits in the *inter-panel*259 gutter and pushes the columns apart. **Break it over two lines** (`\n`) — same information,260 half the width.261- **Long categorical tick labels** (`"1:1 mix (weighed)"`) eat the outer margin. Shorten the262 tick text and put the qualifier in the caption.263264**Do not pass `rect` to reserve room for right-margin `edge_labels` under constrained layout** —265it already counts them in the axes' tight bbox, so `rect` pays for the same text twice and266squeezes every panel inwards. Reserve with `rect` only when the layout engine genuinely cannot267see the artist (a `fig.text` in figure coordinates), and confirm by reading the SAVED figure.268269**The letter is in the IMAGE; the figure number and caption are not.** Bake only270the panel letter into the figure. "Figure 3", the caption, and cross-references271("see Figure 3") live in the document (Word/LaTeX caption feature), so the image272stays reusable and renumbering can't desync. Never render "Figure 3" into the plot.273274### Sectioned composites & repeated-panel grids (the page-figure idioms)275276A full-page figure that stacks logically distinct *sections* (e.g. "what we measure"277over "candidate calibrations" over "figures of merit") goes beyond one flat grid:278279- **Sections via subfigures.** `fig = plt.figure(layout="constrained")`, then280 `subs = fig.subfigures(n, 1, height_ratios=…)`; give each section its own internal grid281 and, if it needs one, its own legend (`sf.legend(loc="outside lower center")`). Each282 section's spacing stays independent.283- **Guarantee an outer margin.** Constrained layout can pull axes flush to the edge.284 `fig.get_layout_engine().set(w_pad=…, h_pad=…)` reserves margin on ALL sides. **Judge the285 margin on the SAVED figure, not `render_preview`** — the preview tight-crops, so it always286 looks margin-less.287- **Repeated panel-units → a CSS-grid, NOT a tall grid with spacer rows.** For N identical288 units (a calibration over its residual strip, one per candidate), build an OUTER gridspec289 of the *cells* with ONE uniform `hspace`/`wspace`, and an INNER `subgridspec` per cell for290 the tightly-coupled sub-axes:291 ```292 outer = sf.add_gridspec(2, 2, hspace=0.22, wspace=0.34) # one uniform gap between cells293 for i, k in enumerate(items):294 r, c = divmod(i, 2)295 cell = outer[r, c].subgridspec(2, 1, height_ratios=[3.4, 1], hspace=0.06) # cal+resid, tight296 a0 = sf.add_subplot(cell[0]); a1 = sf.add_subplot(cell[1], sharex=a0)297 ```298 A single tall grid with spacer rows makes the gaps COMPOUND (spacer height + two hspaces)299 into an uneven "big gap in the middle"; the nested grid gives one even gap, like CSS `gap`.300- **Per-chart letters in reading order, OUTSIDE the corner.** Sectioning/nesting breaks301 `add_panel_labels`' automatic enumeration, so pass the charts explicitly, in reading order:302 `fig.canvas.draw(); style.add_panel_labels(fig, cfg, axes=[…], x_offset_pt="auto")` — the303 letters then sit outside the top-left corner, aligned, exactly as on a flat grid. A coupled304 cal+residual pair is ONE chart → letter only its main axes (`audit_layout` treats a residual305 strip as part of its chart). `style.panel_letter(ax, s)` puts a letter INSIDE the corner and is306 only for the rare panel with no margin to spare; never mix the two conventions in one figure.307- **Direction-of-good in comparison titles.** Any FoM/comparison panel where taller/larger308 isn't self-evidently "better" states the good direction IN its title — `"Precision\n(lower309 = better)"`, `"Accuracy\n(100 = ideal)"`, `"Linearity\n(higher = better)"` — so a bar chart310 can't be misread.311- **The title names the species, not just the band.** `"Aspirin ester C=O (1748 cm⁻¹)"`, not312 `"Ester C=O"` — so a metric named elsewhere ("ester area") resolves to a species. Band313 identifiers in titles/tick-labels carry their unit (`1066 cm⁻¹`, never bare `1066`).314315## Files316317- `scripts/config.py` — the `Config` dataclass (the one tunable block).318- `scripts/style.py` — `apply_style`, `figure` (takes `width_ratios`; grid-aware319 default height), `save_fig`, plus composite helpers `finalize_figure` (takes320 `wspace`/`hspace`/`w_pad`/`h_pad` to widen gutters, `rect` to reserve an outer margin321 for right-edge labels) + `add_panel_labels` (aligned a/b/c letters for a flat grid;322 `x_offset_pt="auto"` derives one shared offset clearing the y-furniture) + `panel_letter`323 (ONE inside-corner letter, for hand-lettering sectioned/nested composites in reading order).324 Plus `check_figure_width` (verify a figure is at the journal's physical width — no silent325 rescaling) and `export_mplstyle` (write a standalone `.mplstyle`; presets in `assets/styles/`).326 `save_fig` stamps provenance (skill version + `cfg.description` + producing software)327 into each figure FILE's metadata (best-effort; a backend that rejects it still writes the file).328- `scripts/verify.py` — data gates + `summarize` + the render/read-image QA loop329 (`audit_layout` also covers clipping, **data running outside the y-limits**, x+y tick overlap,330 composite panel letters, and text from one panel spilling into another). Plus `adjust_pvalues` / `multiplicity_check`331 (Holm/Bonferroni/BH correction for a FAMILY of p-values — annotate adjusted, not raw),332 `flag_outliers_mad` (robust median/MAD outlier flag for replicate band-metrics — the333 univariate analog of `chemometrics.diagnostics`; never raises, flag-don't-hide), and the334 **Tier-3 critic** `check_number_provenance` (static scan: a hand-typed figure-of-merit on335 the figure/in a caption that should be interpolated from the computed value — the desync-336 on-refit failure mode; keyed on the skill's FoM vocabulary so band identifiers are exempt).337- `scripts/spectra.py` — FTIR+PXRD ingest, baseline, integration, waterfall/overlay,338 optional Savitzky-Golay (`apply_smooth`), %T display (`as_transmittance` /339 `cfg.ftir_yaxis`), `edge_labels` (right-margin per-trace waterfall key, grayscale-safe;340 reusable for hand-built or composite panels), and `label_peaks` (styles the labels341 YOU supply — it does not pick peaks or write the text).342- `scripts/calibration.py` — regression, bands, residuals, LOD/LOQ, predict.343- `scripts/chemometrics.py` — multivariate calibration (PLS/PCR/PCA): leakage-safe344 `cross_validate`/`component_scan`, parsimony `choose_n_components`, `Preprocessor`345 (SNV/MSC/d1/d2/centre/autoscale), `vip`, ax-level panel plots, the `diagnostics_figure`346 composite, and `methods_text`. sklearn is a lazy dep. Plus the PCA-based **outlier gate**347 (`diagnostics`/`plot_influence`: T²/Q/leverage + DD-SIMCA; numpy+scipy only), a **one-class348 DD-SIMCA classifier** (`ddsimca_fit`/`ddsimca_predict`/`class_fom` — sensitivity/specificity/349 efficiency; the "is this the target phase?" call for cocrystal ID), **`emsc`** (EMSC350 scatter+baseline+interferents), and the **validation trio** (`permutation_test`,351 `corrected_paired_t`, `rpd`/`rpiq`). All validated by `skill_validation/chemometrics/` (100 checks).352- `scripts/charts.py` — generic line/bar with anti-pattern guards.353- `scripts/doe.py` — seeded, operator-balanced **run-order schedules** (operator orthogonal to354 concentration — fixes the operator/level confound) + CSV run sheet. Self-validates: `python355 scripts/doe.py` (determinism + balance + coverage).356- `scripts/report.py` — `methods_report(cfg, ...)`: paste-ready methods text + SI table.357- `scripts/crystal_engine.py` — CIF → validated crystal data (parse, symmetry-expand,358 density triple-check, geometry, H-bonds); the single source of truth for the crystal family.359- `scripts/crystal_pxrd.py` — calculated PXRD from a CIF (Dans_Diffraction + pymatgen/Bragg360 cross-check), plotted through the `spectra` pxrd domain. `reflection_list` extracts Dans's361 physically-correct powder reflections `[(2θ, I, hkl)…]`; `realistic_pattern` composes them with362 `pxrd_realism` (the turn-key Cu-Kα-scan simulator).363- `scripts/pxrd_realism.py` — make a calc pattern match a real lab Cu-Kα scan: `march_dollase`364 (preferred-orientation intensity), `kalpha2_doublet` (Cu Kα₁/Kα₂ splitting), `caglioti_fwhm` +365 `pseudo_voigt` (angle-dependent width/shape), composed by `simulate_pattern`. Numpy-only, so it366 operates on a reflection list `[(2θ, I, hkl)…]` (Dans supplies the structure factors) and is367 fully testable without heavy deps. Validated by `skill_validation/pxrd/` (physics, dep-light) and368 end-to-end on real CIFs (aspirin, lactose) via `crystal_pxrd.realistic_pattern`.369- `scripts/crystal_view.py` — deterministic 3D structure render (PCA or custom/vector/axis370 camera, bonds, dashed H-bonds, C–H hiding, heteroatom labels).371- `scripts/cocrystal.py` — cocrystal-ID arithmetic the `cocrystal_id.md` decision layer routes372 to. Wave 1: `delta_pka`/`classify_ionisation` (the ΔpKa salt/cocrystal/continuum call,373 Cruz-Cabeza 2012; zero-dep, never raises) + `rwp`/`sum_of_parents`/`phase_report` (NNLS374 new-phase-vs-physical-mixture discriminator with Rwp + new/lost-peak table; scipy, lazy).375 Validated by `skill_validation/cocrystal/` (26 checks).376- `templates/analysis_template.py` — copy this; shows the CONFIG-block shape.377- `bundle.py` — amalgamate the modules an analysis uses + its CONFIG/body into one378 self-contained deliverable script. Stamps a **provenance header** (skill version, the379 Python + scientific-stack versions, source, `--describe`/`DESCRIPTION` text) and runs the380 **Tier-3 critic** on the body at handoff (`--no-critic` to skip).381- `scripts/sources.py` — fetch reference data from the OPEN databases (stdlib-only, best-effort,382 cites the URL, retries transient DNS/timeouts): `cod_search`/`cod_fetch_cif` (Crystallography Open383 Database → CIF → `crystal_engine`) and `pubchem_properties` (PUG-REST identity/computed props) —384 both live-verified end-to-end. The full sourcing map —385 incl. the paywalled/manual ones — is `references/databases.md`. Pure URL/parse logic is gate-tested386 (`tests/test_sources.py`); the network wrappers aren't.387- `cli.py` — scripts-first QC shortcuts: `spc-rsd` (.spc replicates → band area/%RSD) and388 `calibrate` (conc,signal CSV → slope/R²/LOD/LOQ). Convenience for routine checks; the report389 deliverable is still a written analysis, not CLI output.390- `tests/` — pytest **skill self-tests** (module imports, SKILL.md structure, `bundle.py`391 round-trip, multiplicity vs statsmodels, `.mplstyle`/width, the CLI) that also bridge in the392 numeric suites. `python -m pytest tests/` is one green gate for the whole skill.393- `assets/styles/` — `.mplstyle` house-style presets per journal (nature/acs/ieee/general).394- `skill_validation/chemometrics/` — the numeric-parity validation suite (100 checks; see its SPEC.md).395- `skill_validation/cocrystal/` — the numeric-parity validation suite for the cocrystal-ID family396 (ΔpKa zones + NNLS sum-of-parents/Rwp, 26 checks), bridged into `pytest tests/`.397- `skill_validation/pxrd/` — the numeric-parity validation suite for PXRD realism (23 checks:398 March-Dollase, Kα₂ doublet, Caglioti, pseudo-Voigt, `simulate_pattern`), bridged into `pytest tests/`.399- `skill_validation/crystal/` — the CIF edge-case set (COD CIFs + synthetic broken inputs; CCDC extras400 optional) with the engine / independent-gate / ADP / realism checks (`run.py`; needs gemmi +401 Dans_Diffraction, pytest skips it otherwise) and `crystal_demo.py`, the worked example.402- `skill_validation/ftir_integration/` — the band integrator against published calibrations (pectin403 DM, BMC 2021) plus a deterministic closed-form ground-truth test (14 checks, bridged into `pytest tests/`).404- `references/` — `figure_selection.md` (the "what to plot" decision layer — start here),405 `cocrystal_id.md` (the "which evidence identifies a cocrystal" decision layer — start here406 for cocrystal ID), `databases.md` (where to source each external fact — open first,407 cite always), `verification.md`, `spectra.md`, `calibration.md`,408 `chemometrics.md`, `charts.md`, `crystal.md`, `adding_a_family.md`. View on demand, not all at once.409410## When NOT to over-reach411412Don't psychoanalyse the data or invent conclusions. Report what the gates and413the numbers show. If n is small, say the CIs are wide and lead with the trend.414415**Build the figure that was asked for.** Produce the panels the request names; don't416annex an adjacent analysis, an extra panel, or a supporting figure nobody asked for.417Where a second figure looks genuinely warranted, say so in one line and let the user418decide — an unrequested panel costs a re-render and dilutes the one that answers the419question. The gates above are the exception: a residual panel, a named error bar and a420QA pass are part of the deliverable being *correct*, not scope creep.421422**Length is calibrated to the artefact.** A caption is a caption; `methods_text` /423`methods_report` emit paste-ready prose, not a review. The standalone script carries the424analysis plus its provenance header — it does not need step-by-step narration in425comments, and the house comment rule applies (state a constraint the code can't show;426never restate what the next line does). Prose that isn't carrying information is prose427the reader skips.428429**External facts get looked up, not recalled.** Any value that comes from *outside* the430data in front of you — a reagent/column price or availability, a literature reference431value, a pharmacopoeial limit, a band assignment you're quoting as authoritative — must be432fetched with a live web search (or read from a cited document) and reported **with its433source + date**, never stated from training memory. Prices and catalog availability drift;434a confidently recalled number that's stale silently corrupts a budget or a spec. Same435provenance discipline as the figure/number gates, applied to the world outside the dataset.