Skip to content

Commit 35228cf

Browse files
StarlitnightlyZehua Zengclaude
authored
Real DFT as a level of theory, four more potentials, and two silent unit errors (#15)
* Real DFT as a level of theory, four more potentials, and two silent unit errors matverse could reach six levels and every one of them was a surrogate. GPAW is now registered as gpaw-pbe and gpaw-pbe-fast, with kind="dft" and surrogate=False, so the one level that solves the Kohn-Sham equations is distinguishable from the ones that reproduce something that did. m3gnet, m3gnet-r2scan, tensornet and orb are added alongside. Validated by running it, not by citing it. Through matverse, on real PBE: mv.calc.relax -> Si lattice constant 5.479 A (PBE literature 5.47) mv.prop.eos -> Si bulk modulus 88.7 GPa (PBE literature 88-89) fit residual 0.002 meV/atom Two things that would have been wrong silently. matgl returns stress in GPa while ASE's contract is eV/A^3, so leaving stress_unit alone makes every stress-derived quantity - elastic constants, bulk modulus, pressure - exactly 160.2x too large with no error raised anywhere. The factor is the GPa-to-eV/A^3 conversion, which is what makes it recognisable once you look. Fixed, with a test that fails on the unfixed version. The second took longer to find and is not GPAW's fault. I first gave the DFT levels a k-point *density*, which is the sensible thing for comparing different materials and the wrong thing for scanning one material's volume: the mesh changes discretely as the cell grows, and each change puts a step in E(V) that the fit reads as curvature. Silicon, same calculator, only the k-points varying: density 2.0 -> -879 GPa (125 -> 64 k-points) density 2.5 -> 319 GPa (216 -> 125) density 3.0 -> 125 GPa (343 -> 216) density 4.0 -> 85.7 GPa (729 -> 512) fixed 8x8x8 -> 88.7 GPa (unchanged) A negative bulk modulus is not a soft crystal. Raising the density only shrinks the jump; it never removes it. The plane-wave cutoff was converged the whole time - PW(400), PW(600) and PW(800) agree to 0.1 GPa - so the entire error was the mesh. The built-in levels use a fixed mesh, and mv.prop.eos now says why. obs['eos_residual'] catches it long before the modulus does: 9.5 meV/atom against 0.002, a factor of nearly five thousand, and the advice already in that function's notes - a residual far above a meV is a fit that should not be read - was enough to reject it without knowing the cause. On pymatviz I made a judgement rather than adding it wholesale. Most of it duplicates mv.pl, which already has hull, parity, periodic_table, spectra, scatter, bands and distribution; wrapping those again would be the mistake the coverage map exists to prevent. The one genuinely absent capability is the space-group distribution, so mv.pl.spacegroups does that directly, grouped and coloured by crystal system rather than plotted as 230 flat categories. mv.pp.symmetry gained spacegroup_number and spacegroup_symbol while I was there - it was already building the analyser, and a symmetry report that gives the crystal system and the point group but not the group itself is missing the one label everybody cites. 960 tests pass on py3.12 with GPAW and matgl, 850 on py3.10 without them. The level-metadata tests skip where a backend is absent rather than failing, which they did not do at first. Contract-verified rate stays 165/165, all 16 notebooks execute, docs build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wh5NzNE9Wn49nuUm7zg2a * Give GPAW and the torch-backed potentials a CI job of their own All three test legs went red on the previous commit. GPAW has no wheel: it compiles against libxc and BLAS, and the runner carries neither, so pip got as far as "fatal error: xc.h: No such file or directory" before anything ran. It built here only because this node happens to have both. Installing it in the main matrix was the wrong call regardless of the headers. GPAW compiles from source, and matgl and orb-models pull torch, which nothing else in the matrix needs — three legs would each pay a couple of minutes to cover one job's worth of tests. They now share a 'calculators' job, the same shape the anharmonic job already has and for the same reason. The job prints mv.calc.available() before running anything, so a level whose backend failed to install is visible as a line in the log rather than as a silently skipped test. * Add build-essential for GPAW's C++ headers in the calculators job GPAW 26 includes <algorithm> from sources the build compiles as C, so the runner needs a C++ standard library it does not carry by default. The previous attempt got past libxc and stopped at 'fatal error: algorithm: No such file or directory'. * Stop trying to build GPAW on the runner Three attempts, same error. GPAW ships no wheel and its 26.x sources include C++ headers from files the build compiles as C, so it stops at 'fatal error: algorithm: No such file or directory' whether or not libxc, BLAS and build-essential are installed. It builds here because this node's compiler puts the C++ headers on the default include path; a stock ubuntu runner's does not. So the calculators job installs the torch-backed potentials only, and keeps the test that matters most there — the one that fails when matgl's stress is left in GPa, a 160x error nothing else would catch. The real-DFT numbers stay where they were measured, in the notes, and TestRealDFT says why it skips rather than leaving a reader to assume CI covers it. --------- Co-authored-by: Zehua Zeng <steorra@sh04-04n05.int> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 41b58cc commit 35228cf

26 files changed

Lines changed: 3213 additions & 2613 deletions

‎.github/workflows/test.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,50 @@ jobs:
6262
# failing assertion.
6363
run: pytest tests/test_contracts.py -q -s -k "rate or audit"
6464

65+
calculators:
66+
# The torch-backed potentials get a job of their own: matgl and orb-models
67+
# pull torch, which nothing else in this matrix needs, and making all three
68+
# main legs carry it to cover one job's worth of tests is a bad trade.
69+
#
70+
# GPAW is deliberately NOT installed here. It ships no wheel, and its 26.x
71+
# sources include C++ headers from files the build compiles as C, so on a
72+
# stock runner it stops at "fatal error: algorithm: No such file or
73+
# directory" whether or not libxc, BLAS and build-essential are present —
74+
# three attempts, same error. It builds on machines whose compiler puts the
75+
# C++ headers on the default include path, which is where the real-DFT
76+
# numbers in the notes were measured: a silicon lattice constant of 5.479 A
77+
# and a bulk modulus of 88.7 GPa. TestRealDFT skips here, and says so
78+
# rather than pretending to cover it.
79+
runs-on: ubuntu-latest
80+
steps:
81+
- uses: actions/checkout@v4
82+
83+
- uses: actions/setup-python@v5
84+
with:
85+
python-version: "3.12"
86+
87+
- name: Install
88+
run: |
89+
python -m pip install --upgrade pip
90+
pip install -e ".[dev,plot,potentials]"
91+
92+
- name: Show what is actually installed
93+
run: |
94+
python -c "
95+
import matverse as mv
96+
for name, meta in sorted(mv.calc.available().items()):
97+
state = meta.get('unavailable', meta.get('method'))
98+
print(f'{name:16s} {state}')
99+
"
100+
101+
- name: Test the levels of theory
102+
# MatglStress is the one that matters here: it fails on the version
103+
# that leaves matgl's stress in GPa, which is a 160x error nothing
104+
# else would catch.
105+
run: |
106+
pytest tests/test_pipeline.py -q \
107+
-k "LevelsOfTheory or MatglStress" --durations=5
108+
65109
anharmonic:
66110
# A job of its own, because hiphive reaches numba through trainstation and
67111
# numba caps numpy below 2.5. Installing it alongside the others would

‎matverse/calc.py‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@
4747
_CALCULATORS: dict[str, tuple] = {}
4848

4949
#: Levels matverse knows how to build, if their backend happens to be installed.
50-
BUILTIN_LEVELS = ("emt", "lj", "mace-mpa", "mace-omat", "sevennet", "chgnet")
50+
BUILTIN_LEVELS = ("emt", "lj", "mace-mpa", "mace-omat", "sevennet", "chgnet",
51+
"m3gnet", "m3gnet-r2scan", "tensornet", "orb",
52+
"gpaw-pbe", "gpaw-pbe-fast")
5153

5254

5355
@register_function(
@@ -122,6 +124,86 @@ def _builtin(name: str):
122124
"surrogate": True, "license": "BSD-3-Clause", "uncertainty": None,
123125
"note": "Superseded on Matbench Discovery by OMat24-trained models; "
124126
"kept because a great deal of published work used it."})
127+
if name in ("m3gnet", "m3gnet-r2scan", "tensornet"):
128+
import matgl
129+
from matgl.ext.ase import PESCalculator
130+
checkpoint, functional = {
131+
"m3gnet": ("M3GNet-PES-MatPES-PBE-2025.2", "PBE"),
132+
"m3gnet-r2scan": ("M3GNet-PES-MatPES-r2SCAN-2025.2", "r2SCAN"),
133+
"tensornet": ("TensorNet-PES-MatPES-PBE-2025.2", "PBE"),
134+
}[name]
135+
# stress_unit is not optional. matgl returns stress in GPa by
136+
# default while ASE's contract is eV/A^3, so leaving it alone makes
137+
# every stress-derived quantity - elastic constants, bulk modulus,
138+
# pressure - exactly 160.2x too large, with no error anywhere.
139+
return (lambda: PESCalculator(matgl.load_model(checkpoint),
140+
stress_unit="eV/A3"),
141+
{"kind": "mlip", "method": checkpoint,
142+
"reference": f"{functional} (MatPES)", "surrogate": True,
143+
"license": "BSD-3-Clause", "uncertainty": None,
144+
"note": "MatPES-trained, so it reproduces plain "
145+
f"{functional} rather than the PBE+U of the MP-"
146+
"trained generation. Which functional a surrogate "
147+
"was fitted to is not a detail: mixing a PBE+U "
148+
"surrogate with a PBE one is the same class of error "
149+
"as mixing PBE with HSE06, and it is why "
150+
"reference= is recorded on every level."})
151+
if name == "orb":
152+
from orb_models.forcefield import pretrained
153+
from orb_models.forcefield.calculator import ORBCalculator
154+
return (lambda: ORBCalculator(
155+
pretrained.orb_v3_conservative_inf_omat(device="cpu"),
156+
device="cpu"),
157+
{"kind": "mlip", "method": "ORB v3 conservative",
158+
"reference": "PBE+U (OMat24)", "surrogate": True,
159+
"license": "Apache-2.0", "uncertainty": None,
160+
"note": "Conservative variant: forces are the energy gradient, "
161+
"which is what a phonon or a relaxation needs. The "
162+
"'direct' variants predict forces independently and are "
163+
"faster and not usable for either."})
164+
if name in ("gpaw-pbe", "gpaw-pbe-fast"):
165+
from gpaw import GPAW, PW
166+
# Not a surrogate. This is the one level here that solves the
167+
# Kohn-Sham equations rather than reproducing something that did, and
168+
# kind/surrogate say so — every downstream record inherits it.
169+
cutoff, mesh = ((500.0, (8, 8, 8)) if name == "gpaw-pbe"
170+
else (400.0, (6, 6, 6)))
171+
return (lambda: GPAW(mode=PW(cutoff), xc="PBE", kpts=mesh,
172+
txt=None, symmetry={"point_group": False}),
173+
{"kind": "dft", "method": f"GPAW PBE, PW({cutoff:g} eV)",
174+
"reference": "PBE", "surrogate": False,
175+
"license": "GPL-3.0", "uncertainty": None,
176+
"plane_wave_cutoff_eV": cutoff,
177+
"kpoint_mesh": list(mesh),
178+
"note": "Real plane-wave DFT, not a model of it. The cutoff "
179+
"and the k-point mesh are the two settings that "
180+
"decide whether a number is converged, so they are "
181+
"recorded on the level rather than left implicit.\n\n"
182+
"The mesh is **fixed** rather than set by a k-point "
183+
"density, and that is not a stylistic choice. A "
184+
"density-based mesh changes discretely as a cell "
185+
"changes size, which puts a step in E(V) and "
186+
"destroys anything fitted to it. Silicon, same "
187+
"calculator, only the k-points varying:\n\n"
188+
" density 2.0 -> -879 GPa (125 -> 64 k-points)\n"
189+
" density 2.5 -> 319 GPa (216 -> 125)\n"
190+
" density 3.0 -> 125 GPa (343 -> 216)\n"
191+
" density 4.0 -> 85.7 GPa (729 -> 512)\n"
192+
" fixed 8x8x8 -> 88.7 GPa (unchanged)\n\n"
193+
"against a PBE literature 88-89. A negative bulk "
194+
"modulus is not a soft crystal, it is a "
195+
"discontinuity. Raising the density only shrinks the "
196+
"relative size of the jump; it never removes it. The "
197+
"plane-wave cutoff, by contrast, was already "
198+
"converged - PW(400) and PW(600) differ by 0.1 GPa - "
199+
"so the whole error was the mesh.\n\n"
200+
"A fixed mesh suits small cells. Register your own "
201+
"level for anything large, where 8x8x8 is wasteful, "
202+
"and keep it fixed across any volume scan.\n\n"
203+
"point_group symmetry is off because matverse hands "
204+
"in displaced and strained cells whose symmetry is "
205+
"lower than the analyser infers from a rounded "
206+
"geometry."})
125207
raise KeyError(
126208
f"unknown level {name!r}. Runnable here: "
127209
f"{sorted(available(check_imports=False))}. Register your own with "

‎matverse/pl.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import numpy as np
20+
import pandas as pd
2021
from anndata import AnnData
2122

2223
from ._core import grid_of, structures
@@ -1162,3 +1163,99 @@ def distribution(md: AnnData, column: str, by: str | None = None,
11621163
ax.spines[spine].set_visible(False)
11631164
ax._matverse_dropped = dropped
11641165
return ax
1166+
1167+
1168+
@register_function(
1169+
aliases=["space group distribution", "symmetry distribution",
1170+
"spacegroup bar", "which space groups", "crystal system "
1171+
"distribution", "how symmetric is this set"],
1172+
category="pl",
1173+
description="Distribution of space groups across a dataset, grouped by "
1174+
"crystal system — what symmetry a generated or screened set "
1175+
"actually has.",
1176+
requires={"obs": ["{column}"]},
1177+
examples=["mv.pl.spacegroups(built)",
1178+
"mv.pl.spacegroups(md, column='spacegroup_number')",
1179+
"mv.pl.spacegroups(built, column='requested_space_group')"],
1180+
related=["mv.gen.from_symmetry", "mv.pp.describe", "mv.pl.distribution"],
1181+
notes="A generated set has a symmetry distribution, and it is rarely the "
1182+
"one that was asked for. mv.gen.from_symmetry records both the "
1183+
"requested group and the one the structure actually has, and "
1184+
"plotting the two side by side is how the difference becomes "
1185+
"visible rather than a column nobody reads.\\n\\n"
1186+
"Bars are grouped and coloured by crystal system rather than "
1187+
"plotted as 230 flat categories, because the number itself carries "
1188+
"no order a reader can use — 62 is not 'between' 61 and 63 in any "
1189+
"sense that matters, but Pnma being orthorhombic does.",
1190+
)
1191+
def spacegroups(md: AnnData, column: str = "spacegroup_number",
1192+
compare: str | None = None, top: int = 20, ax=None):
1193+
"""Space-group distribution grouped by crystal system. Returns the axis."""
1194+
if column not in md.obs:
1195+
raise ValueError(
1196+
f"obs[{column!r}] absent; run mv.pp.symmetry(md) for "
1197+
f"'spacegroup_number', or point column= at "
1198+
f"mv.gen.from_symmetry's 'space_group' — this object has "
1199+
f"{sorted(md.obs.columns)[:8]}...")
1200+
if compare is not None and compare not in md.obs:
1201+
raise ValueError(f"obs[{compare!r}] absent")
1202+
1203+
numbers = pd.to_numeric(md.obs[column], errors="coerce").dropna()
1204+
if numbers.empty:
1205+
raise ValueError(f"obs[{column!r}] holds no usable space-group number")
1206+
1207+
counts = numbers.astype(int).value_counts().sort_values(ascending=False)
1208+
keep = counts.head(int(top))
1209+
order = sorted(keep.index)
1210+
1211+
ax = _axis(ax)
1212+
systems = [_crystal_system(n) for n in order]
1213+
palette = {"triclinic": "#8e44ad", "monoclinic": "#4c72b0",
1214+
"orthorhombic": "#2a9d8f", "tetragonal": "#e9c46a",
1215+
"trigonal": "#e76f51", "hexagonal": "#d35400",
1216+
"cubic": "#c1121f"}
1217+
positions = np.arange(len(order))
1218+
width = 0.4 if compare is not None else 0.7
1219+
ax.bar(positions - (width / 2 if compare is not None else 0),
1220+
[keep[n] for n in order], width=width,
1221+
color=[palette[s] for s in systems],
1222+
label=column if compare is not None else None)
1223+
1224+
if compare is not None:
1225+
other = pd.to_numeric(md.obs[compare], errors="coerce").dropna()
1226+
other = other.astype(int).value_counts()
1227+
ax.bar(positions + width / 2, [int(other.get(n, 0)) for n in order],
1228+
width=width, facecolor="none", edgecolor="#333333",
1229+
linewidth=0.9, label=compare)
1230+
ax.legend(frameon=False, fontsize=8)
1231+
1232+
ax.set_xticks(positions)
1233+
ax.set_xticklabels([str(n) for n in order], rotation=90, fontsize=7)
1234+
ax.set_xlabel("space group number")
1235+
ax.set_ylabel("materials")
1236+
for spine in ("top", "right"):
1237+
ax.spines[spine].set_visible(False)
1238+
1239+
seen = list(dict.fromkeys(systems))
1240+
handles = [_plt().Line2D([], [], color=palette[s], linewidth=6, label=s)
1241+
for s in seen]
1242+
ax.add_artist(ax.legend(handles=handles, frameon=False, fontsize=7,
1243+
loc="upper right", title="crystal system",
1244+
title_fontsize=7))
1245+
ax._matverse_n_groups = len(order)
1246+
ax._matverse_dropped = int(len(counts) - len(keep))
1247+
return ax
1248+
1249+
1250+
#: Space-group number ranges, in the international convention.
1251+
_CRYSTAL_SYSTEMS = ((2, "triclinic"), (15, "monoclinic"), (74, "orthorhombic"),
1252+
(142, "tetragonal"), (167, "trigonal"), (194, "hexagonal"),
1253+
(230, "cubic"))
1254+
1255+
1256+
def _crystal_system(number: int) -> str:
1257+
"""The crystal system a space-group number belongs to."""
1258+
for limit, name in _CRYSTAL_SYSTEMS:
1259+
if number <= limit:
1260+
return name
1261+
return "cubic"

‎matverse/pp.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,7 @@ def prototype(md: AnnData, source: str = "input") -> None:
919919
"which Wyckoff positions the atoms occupy.",
920920
requires={"structures": ["{source}"]},
921921
produces={"obs": ["crystal_system", "point_group",
922+
"spacegroup_number", "spacegroup_symbol",
922923
"n_symmetry_operations", "n_wyckoff", "wyckoff",
923924
"min_site_symmetry", "max_site_symmetry"]},
924925
examples=["mv.pp.symmetry(md)",
@@ -957,6 +958,8 @@ def symmetry(md: AnnData, source: str = "input", symprec: float = 0.01) -> None:
957958
systems = np.empty(md.n_obs, dtype=object)
958959
points = np.empty(md.n_obs, dtype=object)
959960
orders = np.full(md.n_obs, np.nan)
961+
numbers = np.full(md.n_obs, np.nan)
962+
group_symbols = np.empty(md.n_obs, dtype=object)
960963
wyckoff_count = np.full(md.n_obs, np.nan)
961964
wyckoff = np.empty(md.n_obs, dtype=object)
962965
lowest = np.full(md.n_obs, np.nan)
@@ -968,12 +971,19 @@ def symmetry(md: AnnData, source: str = "input", symprec: float = 0.01) -> None:
968971
systems[i] = ""
969972
points[i] = ""
970973
wyckoff[i] = ""
974+
group_symbols[i] = ""
971975
try:
972976
with warnings.catch_warnings():
973977
warnings.simplefilter("ignore")
974978
analyzer = SpacegroupAnalyzer(structure, symprec=symprec)
975979
systems[i] = str(analyzer.get_crystal_system())
976980
points[i] = str(analyzer.get_point_group_symbol())
981+
# The analyser is already built, so the space group costs
982+
# nothing extra - and a symmetry report that gives the crystal
983+
# system and the point group but not the group itself is
984+
# missing the one label everybody actually cites.
985+
numbers[i] = float(analyzer.get_space_group_number())
986+
group_symbols[i] = str(analyzer.get_space_group_symbol())
977987
symbols = list(
978988
analyzer.get_symmetrized_structure().wyckoff_symbols)
979989
wyckoff[i] = ", ".join(str(w) for w in symbols)
@@ -993,6 +1003,8 @@ def symmetry(md: AnnData, source: str = "input", symprec: float = 0.01) -> None:
9931003

9941004
md.obs["crystal_system"] = systems.astype(str)
9951005
md.obs["point_group"] = points.astype(str)
1006+
md.obs["spacegroup_number"] = numbers
1007+
md.obs["spacegroup_symbol"] = group_symbols.astype(str)
9961008
md.obs["n_symmetry_operations"] = orders
9971009
md.obs["n_wyckoff"] = wyckoff_count
9981010
md.obs["wyckoff"] = wyckoff.astype(str)

‎matverse/prop.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,22 @@ def elastic(md: AnnData, level: str = "emt", source: str = "input",
229229
"absolute volume: materials of different size have no common volume "
230230
"axis, and the strain series they were computed on is common by "
231231
"construction. obs['eos_residual'] is the RMS misfit in eV/atom; a "
232-
"value far above a meV is a fit that should not be read.",
232+
"value far above a meV is a fit that should not be read.\n\n"
233+
"**With a plane-wave DFT calculator, hold the k-point mesh fixed "
234+
"across the scan.** A mesh set by a k-point *density* changes "
235+
"discretely as the cell grows, and each change puts a step in E(V) "
236+
"that the fit reads as curvature. On silicon with GPAW, varying "
237+
"nothing but the k-points, the bulk modulus went -879, 319, 125 and "
238+
"85.7 GPa at densities of 2.0, 2.5, 3.0 and 4.0, against 88.7 GPa "
239+
"from the same calculator at a fixed 8x8x8 and a PBE literature "
240+
"88-89. A negative bulk modulus is not a soft crystal; it is a "
241+
"discontinuity. Raising the density only shrinks the jump.\n\n"
242+
"obs['eos_residual'] catches it before the modulus does, and by a "
243+
"wide margin: 9.5 meV/atom for the density-2.0 fit against 0.002 "
244+
"for the fixed mesh, a factor of nearly five thousand. The existing "
245+
"advice above — a residual far above a meV is a fit that should not "
246+
"be read — was already enough to reject it. The built-in gpaw-pbe "
247+
"levels use a fixed mesh so the situation does not arise.",
233248
)
234249
def eos(md: AnnData, level: str = "emt", source: str = "input",
235250
scales=None, model: str = "birch_murnaghan",

‎matverse_guide/docs/_scripts/nb_chemical_space.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,27 @@ def mass_and_volume(structures):
456456
and everything else work on it unchanged. The funnel is now complete: elements →
457457
compositions → structures → energies.
458458
459+
`mv.pl.spacegroups` shows the distribution, and putting the requested and the
460+
achieved group side by side is how the difference stops being a column nobody
461+
reads. Bars are grouped and coloured by crystal system rather than plotted as
462+
230 flat categories, because the number itself carries no order a reader can
463+
use — 62 is not "between" 61 and 63 in any sense that matters, but Pnma being
464+
orthorhombic does."""),
465+
466+
("code", """\
467+
try:
468+
ax = mv.pl.spacegroups(many, column="space_group",
469+
compare="requested_space_group")
470+
ax.set_title("requested (hollow) vs achieved (filled)")
471+
except (ImportError, NameError, ValueError) as exc:
472+
print("needs PyXtal and matplotlib:", exc)"""),
473+
474+
("markdown", """\
475+
It also works on any dataset that has been through `mv.pp.symmetry`, which now
476+
records `spacegroup_number` and `spacegroup_symbol` alongside the crystal system
477+
and point group — the analyser was already being built, so the group itself cost
478+
nothing to report and is the one label everybody actually cites.
479+
459480
```{seealso}
460481
[Beyond one number](beyond_one_number.ipynb) covers the results that are not a
461482
single number per material: curves, per-atom values and measurements.

0 commit comments

Comments
 (0)