meidnet 2.4.0.dev2__py3-none-any.whl → 2.4.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meidnet/__init__.py +1 -1
- {meidnet-2.4.0.dev2.dist-info → meidnet-2.4.0.dev3.dist-info}/METADATA +4 -4
- {meidnet-2.4.0.dev2.dist-info → meidnet-2.4.0.dev3.dist-info}/RECORD +12 -12
- meidnet_eval/check_candidates.py +17 -5
- meidnet_eval/conditional_generate.py +13 -1
- meidnet_eval/d1_mlip_check.py +50 -5
- meidnet_eval/instrument_sheet.py +102 -29
- meidnet_eval/pipeline_checkup.py +3 -2
- meidnet_eval/scorecard.py +60 -11
- {meidnet-2.4.0.dev2.dist-info → meidnet-2.4.0.dev3.dist-info}/WHEEL +0 -0
- {meidnet-2.4.0.dev2.dist-info → meidnet-2.4.0.dev3.dist-info}/entry_points.txt +0 -0
- {meidnet-2.4.0.dev2.dist-info → meidnet-2.4.0.dev3.dist-info}/top_level.txt +0 -0
meidnet/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meidnet
|
|
3
|
-
Version: 2.4.0.
|
|
3
|
+
Version: 2.4.0.dev3
|
|
4
4
|
Summary: MEIDNet engine snapshot vendored by MEIDNet Matter: multimodal inverse design for crystalline materials
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -23,10 +23,10 @@ Requires-Dist: openpyxl>=3.1
|
|
|
23
23
|
(upstream commit 26a0387862 plus the uncommitted work of the staged-evaluation rounds), and `meidnet_eval/` holds the
|
|
24
24
|
evaluation components (the ten blocks and their bands in `stages.py`, the generation, judging, relaxation and
|
|
25
25
|
calibration scripts). Install with `pip install ./engine` **before** `pip install -e .`; the distribution is named
|
|
26
|
-
`meidnet` (2.4.0.
|
|
26
|
+
`meidnet` (2.4.0.dev3) so the application's `meidnet>=2.2.0,<3` dependency resolves to it and nothing is fetched from PyPI.
|
|
27
27
|
|
|
28
|
-
On PyPI, `meidnet` 2.4.0.
|
|
28
|
+
On PyPI, `meidnet` 2.4.0.dev3 is this snapshot, uploaded by MEIDNet-Matter's release workflow so that `pip install meidnet-matter`
|
|
29
29
|
resolves without a clone; the upstream MEIDNet release 2.4.0 (https://github.com/ABnano/MEIDNet) will replace it.
|
|
30
30
|
|
|
31
|
-
Snapshot taken 2026-10-
|
|
31
|
+
Snapshot taken 2026-10-09. To refresh it: `python scripts/vendor_engine.py --src <checkout>`. The upstream release of this
|
|
32
32
|
work is planned as meidnet 2.4.0; when it exists, this folder is removed and the dependency pinned to that release.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
meidnet/__init__.py,sha256=
|
|
1
|
+
meidnet/__init__.py,sha256=Excw9KoMB5C16gZ-V3HlLgNAJY87N4Xdk3uvjU6RrG0,797
|
|
2
2
|
meidnet/__main__.py,sha256=7y_DSGhoKEkFQgmA2BZlaWRUGxRtE7KGr4585i3XG3g,138
|
|
3
3
|
meidnet/benchmark.py,sha256=hHRo8yElEFliQ2INZNWn4ZOb25uSwJBmjwJrnnncfn8,26912
|
|
4
4
|
meidnet/checkpoint.py,sha256=AmUGIz0BZynfZ0ndmIoMYh74gIq3Zn78pD7WhjrtbBY,8429
|
|
@@ -38,11 +38,11 @@ meidnet/studio/studio.html,sha256=TFH4RDVV6DPx5bUMeZP_IUQZxmPP-J54aMmPMce1ybQ,18
|
|
|
38
38
|
meidnet_eval/__init__.py,sha256=e1Gl68TKQTg9_21rVZhMNPZarIZqVkVhYk1JJ10zOps,3589
|
|
39
39
|
meidnet_eval/auto_family.py,sha256=aejS_M6R9Gr-ffTipV9BrQ6iY3LDakBBJVq2S-RySVw,16609
|
|
40
40
|
meidnet_eval/candidate_cells.py,sha256=2ghXaeSsAWBKTJLABdb1D8RirYXXAdC0JmohEyJ5OxI,4598
|
|
41
|
-
meidnet_eval/check_candidates.py,sha256=
|
|
41
|
+
meidnet_eval/check_candidates.py,sha256=Ta6gpFd8GWNUinIc_XH4qqNeAPu2WYqvGanQWEtjMjI,21483
|
|
42
42
|
meidnet_eval/component_report.py,sha256=hvRJ6Mt2B3M8r9xCPUQ8_XNzPNLgJC8EzI-6nddoe1s,12385
|
|
43
|
-
meidnet_eval/conditional_generate.py,sha256=
|
|
43
|
+
meidnet_eval/conditional_generate.py,sha256=5nS7zjeeqcHgUZ5WWM5SX83hhl6WYIdIX-i2q-SpF4M,20957
|
|
44
44
|
meidnet_eval/d1_demo.py,sha256=0mM22XHZQN1wnkk2QmRVVKH_EcPQe50vRtUrxkjS3J8,9250
|
|
45
|
-
meidnet_eval/d1_mlip_check.py,sha256=
|
|
45
|
+
meidnet_eval/d1_mlip_check.py,sha256=Nm2hd1ZXWdhFKN8xKh02cKZCWNPjkqfae3xqqnBgK3Y,10752
|
|
46
46
|
meidnet_eval/decoder_autopsy.py,sha256=FgwYqR39YJbZARnMCZKBfVE3oHukDey_2HkWqWKkBcQ,5548
|
|
47
47
|
meidnet_eval/discover.py,sha256=12uslj5MFiClifOEysnH93oBFWRd78dHTM19ZY6nyRk,41165
|
|
48
48
|
meidnet_eval/discovery_report.py,sha256=tBHTpXexiv4ZZAK5yAYEhxJhk5lfVZTMFw1qfOn8zXc,6789
|
|
@@ -58,7 +58,7 @@ meidnet_eval/generate_to_target.py,sha256=kasRREFK-kxZLnWkyP5gVvNTU3zWRTjWy916ZH
|
|
|
58
58
|
meidnet_eval/generator_vs_truth.py,sha256=AD8c7VYrdAWDND6LVEYE4H49brZlsUqJKiUJ2MXcu70,4466
|
|
59
59
|
meidnet_eval/hull_mlip.py,sha256=kW9Rg8T124yEXBAn4T1S4yDjotABxSUegiXoiiso2ME,28256
|
|
60
60
|
meidnet_eval/ingest_upload.py,sha256=cPcKNhsL3JJQMFVEzX2eO7FxX57p-e0-LmlHozZvLCQ,9803
|
|
61
|
-
meidnet_eval/instrument_sheet.py,sha256=
|
|
61
|
+
meidnet_eval/instrument_sheet.py,sha256=WcdVC5gt2ca6XI_x5fcx7AOoU4kNlTIcMZoNyDdbjYo,19219
|
|
62
62
|
meidnet_eval/intake.py,sha256=rJq_b7B1PF6i2zmIBAapTrLTjUuUI7YYQ7FygASuqSw,7378
|
|
63
63
|
meidnet_eval/journey.py,sha256=MpaNWWe3-Rypu2zMdIwp8is_4N5YOtrEt2t0Z-nvaQI,3667
|
|
64
64
|
meidnet_eval/judge_consensus.py,sha256=wKmnP9gIvH_uMozQtsu3G11xa0sLC4WHUzkVtMHOIyw,6196
|
|
@@ -68,13 +68,13 @@ meidnet_eval/mixed_anion_coverage.py,sha256=YHXuGoVaU6jN8NZi8PulF1LGvy4BJnymHDY7
|
|
|
68
68
|
meidnet_eval/model_metrics.py,sha256=Hk8WdBqV_vO3kUm6w1cW6E5tdOcSx4inhtXtApuOKYw,4776
|
|
69
69
|
meidnet_eval/mp_perovskite_dataset.py,sha256=3lWp7hhBNDm0p4Yi90xEPQ0zovKTZWfn8lGlatOCCrI,6079
|
|
70
70
|
meidnet_eval/perovskite_geometry.py,sha256=o6yZpXNhKPBk5ISCK_Ub-1EyOTkgiKDAW5VJVrjuwUE,2752
|
|
71
|
-
meidnet_eval/pipeline_checkup.py,sha256=
|
|
71
|
+
meidnet_eval/pipeline_checkup.py,sha256=Ydg2YMgzFDu6N5dNeCeAFAQgoINS0ZS0A4ctnL3OOy4,22521
|
|
72
72
|
meidnet_eval/plot_ablation.py,sha256=vqqyDTgC_BCvKf_7e45x2qQe9mu_Rg6JGieCwS0r12U,4316
|
|
73
73
|
meidnet_eval/prefetch_mp.py,sha256=uzO44j1Q3j-9BwhBoDDY_l3y_19HfgB4Lml4URc6Yu4,1447
|
|
74
74
|
meidnet_eval/preview.py,sha256=FucAbUzi1J_bW9_RbwL8M4THhW3s9q2PqnIWIJk5jAI,9882
|
|
75
75
|
meidnet_eval/relax_cache.py,sha256=HhOUlLWpHRBHIkUGq-St8x8c_zw-NyZ1iRyVnrSsuIg,2525
|
|
76
76
|
meidnet_eval/reliability_map.py,sha256=06XhRdGY5utzxlR_SuhwhUUR7IHYybOWQ2Js2Gseemw,10869
|
|
77
|
-
meidnet_eval/scorecard.py,sha256=
|
|
77
|
+
meidnet_eval/scorecard.py,sha256=jne3Ib1LNn7TSBHUS3a9IqPEbFmolzSwg39giodbg9Q,18619
|
|
78
78
|
meidnet_eval/screen_local.py,sha256=ZJO8nOiNthwV5A9fYZK9zy-zy3lg1FMotwTyFlu-_h0,12665
|
|
79
79
|
meidnet_eval/screen_polymorphs.py,sha256=txc6V5Qf-AmL4RNVEjGDZUOtUVEwr4C9Vn7c0LI5eew,15832
|
|
80
80
|
meidnet_eval/stability_distorted.py,sha256=VnhFEgaX5rUoWPv5DfMteQf08P0Rt5NR2CQ36wO_QrQ,3680
|
|
@@ -84,8 +84,8 @@ meidnet_eval/sun_validate.py,sha256=DcJmxk0_KM52HmQvrgkg6G3Eztg89PuuwraJTQqrHVk,
|
|
|
84
84
|
meidnet_eval/target_calibration.py,sha256=KzuM2fZY8MOhZ5jXN_z6PgtTqqXvlYeMcxbYqsK_QWg,11629
|
|
85
85
|
meidnet_eval/unseen_element_test.py,sha256=PZYPZWJBNHpxAjvW9UZ4ZbIZynNQIsnkIx1tRQ1mIoE,3098
|
|
86
86
|
meidnet_eval/wyckoff_targets.py,sha256=yXiHEPaxfdS9sUXfWicydRZvCezcFLV1SQmKK9HPWb4,5494
|
|
87
|
-
meidnet-2.4.0.
|
|
88
|
-
meidnet-2.4.0.
|
|
89
|
-
meidnet-2.4.0.
|
|
90
|
-
meidnet-2.4.0.
|
|
91
|
-
meidnet-2.4.0.
|
|
87
|
+
meidnet-2.4.0.dev3.dist-info/METADATA,sha256=wln2GnKHzos0n2vlrdobpLIw2sgPvLBoTodJljBEYSg,1676
|
|
88
|
+
meidnet-2.4.0.dev3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
89
|
+
meidnet-2.4.0.dev3.dist-info/entry_points.txt,sha256=N0rGAumst_v4exbrTZn_K1-zvBIPzkLd6cO8NbP98LY,45
|
|
90
|
+
meidnet-2.4.0.dev3.dist-info/top_level.txt,sha256=zVMmF3NOP2ZV2soL4l2UaYHqPwvHTKoybycSDjawOpw,21
|
|
91
|
+
meidnet-2.4.0.dev3.dist-info/RECORD,,
|
meidnet_eval/check_candidates.py
CHANGED
|
@@ -6,7 +6,8 @@ table of structures and the band gap each was asked for. This command gives eve
|
|
|
6
6
|
1 label the model's own label, read from each returned cell (re-encoded), never from the search
|
|
7
7
|
2 judge the independent judge (MEGNet), qualified on the held-out split first; two-model consensus in the window
|
|
8
8
|
3 relax the consensus cells relaxed by two potentials of different architecture (TensorNet, CHGNet); a relaxed cell
|
|
9
|
-
whose closest atoms sit nearer than 0.6 of their radii is collapsed, not a crystal, and goes no further
|
|
9
|
+
whose closest atoms sit nearer than 0.6 of their radii is collapsed, not a crystal, and goes no further;
|
|
10
|
+
nor does one with an empty layer thicker than 6 A or a packing fraction below 0.12 (a slab or a sparse cell)
|
|
10
11
|
4 re-judge both readings again on the relaxed cells; the consensus on those is the deliverable
|
|
11
12
|
5 stable optional: energy above the convex hull, one potential for every phase (hull_mlip.py --reference ...)
|
|
12
13
|
6 novel AMD distance to the nearest structure of the data; known compositions carry the data's own values
|
|
@@ -140,26 +141,34 @@ def check(out, ckpt, intake, gap, window=0.5, test_csv=None, relax="tensornet",
|
|
|
140
141
|
rel = os.path.join(out, "relaxed"); os.makedirs(os.path.join(rel, "cifs"), exist_ok=True)
|
|
141
142
|
from pymatgen.core import Structure
|
|
142
143
|
try:
|
|
143
|
-
from meidnet_eval.d1_mlip_check import COLLAPSED, contact_ratio
|
|
144
|
+
from meidnet_eval.d1_mlip_check import COLLAPSED, bulk_problem, contact_ratio, empty_layer, packing_fraction
|
|
144
145
|
except ImportError:
|
|
145
146
|
sys.path.insert(0, HERE)
|
|
146
|
-
from d1_mlip_check import COLLAPSED, contact_ratio
|
|
147
|
-
kept, collapsed = [], []
|
|
147
|
+
from d1_mlip_check import COLLAPSED, bulk_problem, contact_ratio, empty_layer, packing_fraction
|
|
148
|
+
kept, collapsed, not_bulk = [], [], []
|
|
148
149
|
for _, r in c0.iterrows():
|
|
149
150
|
src = os.path.join(rdir, "relaxed_tensornet", os.path.basename(r["file"]))
|
|
150
151
|
if os.path.exists(src):
|
|
151
152
|
# a potential can drive atoms into each other and still report a low energy: such a cell is not judged again
|
|
152
|
-
|
|
153
|
+
s_rel = Structure.from_file(src)
|
|
154
|
+
ratio = contact_ratio(s_rel)
|
|
153
155
|
if ratio < COLLAPSED:
|
|
154
156
|
collapsed.append({"file": os.path.basename(r["file"]), "formula": r.get("formula"), "target": float(r["target"]),
|
|
155
157
|
"contact_ratio": round(ratio, 2)})
|
|
156
158
|
continue
|
|
159
|
+
# nor is a slab or a sparse cage a bulk crystal: its band gap is not one
|
|
160
|
+
why = bulk_problem(s_rel)
|
|
161
|
+
if why:
|
|
162
|
+
not_bulk.append({"file": os.path.basename(r["file"]), "formula": r.get("formula"), "target": float(r["target"]),
|
|
163
|
+
"reason": why, "empty_layer": round(empty_layer(s_rel), 2), "packing": round(packing_fraction(s_rel), 3)})
|
|
164
|
+
continue
|
|
157
165
|
shutil.copyfile(src, os.path.join(rel, "cifs", os.path.basename(r["file"])))
|
|
158
166
|
r = dict(r); r["file"] = "cifs/" + os.path.basename(r["file"]); kept.append(r)
|
|
159
167
|
c1 = pd.DataFrame(kept, columns=list(c0.columns))
|
|
160
168
|
c1.to_csv(os.path.join(rel, "candidates.csv"), index=False)
|
|
161
169
|
report["stages"]["3_relaxed"] = int(len(c1))
|
|
162
170
|
report["collapsed_on_relaxation"] = collapsed
|
|
171
|
+
report["not_bulk_on_relaxation"] = not_bulk
|
|
163
172
|
if len(c1):
|
|
164
173
|
run(py_model, "target_calibration.py", rel, "--judge", "reencode", "--ckpt", ckpt, "--gap-col", gap, log=log)
|
|
165
174
|
run(py_judge, "target_calibration.py", rel, "--test-csv", test_csv, "--gap-col", gap, "--judge", "megnet",
|
|
@@ -239,6 +248,9 @@ def check(out, ckpt, intake, gap, window=0.5, test_csv=None, relax="tensornet",
|
|
|
239
248
|
if report.get("collapsed_on_relaxation"):
|
|
240
249
|
L += ["", "Collapsed on relaxation, not judged again (closest atoms as a share of their two radii; a sound crystal is near 1): "
|
|
241
250
|
+ ", ".join(f"{c['formula']} at {c['target']:g} eV ({c['contact_ratio']:.2f})" for c in report["collapsed_on_relaxation"]) + "."]
|
|
251
|
+
if report.get("not_bulk_on_relaxation"):
|
|
252
|
+
L += ["", "Not a bulk crystal after relaxation, not judged again: "
|
|
253
|
+
+ ", ".join(f"{c['formula']} at {c['target']:g} eV ({c['reason']})" for c in report["not_bulk_on_relaxation"]) + "."]
|
|
242
254
|
q = report.get("judge") or {}
|
|
243
255
|
if q:
|
|
244
256
|
L += ["", f"Judge: MEGNet fidelity {q.get('fidelity')}, qualified on {q.get('split')}: MAE {q.get('mae', float('nan')):.2f} eV, "
|
|
@@ -121,13 +121,25 @@ def structure_label(lm, s):
|
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
def plausible(s, min_d=0.7, max_vol_per_atom=200.0):
|
|
124
|
-
"""Reject cells that cannot be a material at all, so the judges are not asked to score nonsense
|
|
124
|
+
"""Reject cells that cannot be a material at all, so the judges are not asked to score nonsense: atoms on top of each
|
|
125
|
+
other, an implausible volume, or a slab or sparse cage (d1_mlip_check.bulk_problem), which relaxation does not turn
|
|
126
|
+
into a bulk crystal. Atoms closer than 0.6 of their radii are not rejected here: relaxation repairs a third of such
|
|
127
|
+
cells, so that test is made on the relaxed cell."""
|
|
128
|
+
try:
|
|
129
|
+
from meidnet_eval.d1_mlip_check import MAX_EMPTY_LAYER, MIN_PACKING, empty_layer, packing_fraction
|
|
130
|
+
except ImportError:
|
|
131
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
132
|
+
from d1_mlip_check import MAX_EMPTY_LAYER, MIN_PACKING, empty_layer, packing_fraction
|
|
125
133
|
try:
|
|
126
134
|
if s.volume / len(s) > max_vol_per_atom or s.volume / len(s) < 2.0:
|
|
127
135
|
return False, "implausible volume per atom"
|
|
128
136
|
d = s.distance_matrix + np.eye(len(s)) * 99
|
|
129
137
|
if len(s) > 1 and d.min() < min_d:
|
|
130
138
|
return False, f"atoms {d.min():.2f} A apart"
|
|
139
|
+
if empty_layer(s) > MAX_EMPTY_LAYER:
|
|
140
|
+
return False, f"empty layer thicker than {MAX_EMPTY_LAYER:g} A (a slab, not a bulk crystal)"
|
|
141
|
+
if packing_fraction(s) < MIN_PACKING:
|
|
142
|
+
return False, f"packing fraction below {MIN_PACKING:g} (too little matter for a bulk crystal)"
|
|
131
143
|
return True, ""
|
|
132
144
|
except Exception as e:
|
|
133
145
|
return False, type(e).__name__
|
meidnet_eval/d1_mlip_check.py
CHANGED
|
@@ -4,7 +4,8 @@ Two potentials of different architecture (TensorNet and CHGNet, both trained on
|
|
|
4
4
|
agreeing with itself is not evidence. Per candidate: whether each relaxed at all, the energy per atom, how far the
|
|
5
5
|
energy fell on relaxation (a sound structure falls by less than 0.1 eV/atom), how far atoms moved, whether the designed
|
|
6
6
|
space group survived, the shortest interatomic distance afterwards, that distance as a share of the two atoms' radii (below
|
|
7
|
-
0.6 the cell collapsed: COLLAPSED, contact_ratio)
|
|
7
|
+
0.6 the cell collapsed: COLLAPSED, contact_ratio), the thickest empty layer and the packing fraction (a slab or a sparse cell
|
|
8
|
+
is not a bulk crystal: bulk_problem) and whether the optimiser converged before the step limit. The relaxed cells are kept, because a band gap
|
|
8
9
|
judged on a cell that then moves by an angstrom describes no material: the judges must be re-run on the relaxed cell.
|
|
9
10
|
|
|
10
11
|
Usage: python d1_mlip_check.py RESULTS_DIR [--cifs-dir DIR] [--steps 300] [--shard 0 --nshards 1] [--subset N]
|
|
@@ -68,9 +69,8 @@ def spacegroup(s):
|
|
|
68
69
|
COLLAPSED = 0.6
|
|
69
70
|
|
|
70
71
|
|
|
71
|
-
def
|
|
72
|
-
"""
|
|
73
|
-
atom included: about 1 in a sound crystal, below COLLAPSED when atoms were pushed into each other."""
|
|
72
|
+
def _radii(s):
|
|
73
|
+
"""pymatgen's atomic radius of every site (1.5 A when the element has none)."""
|
|
74
74
|
from pymatgen.core import Element
|
|
75
75
|
rad = []
|
|
76
76
|
for site in s:
|
|
@@ -83,7 +83,13 @@ def contact_ratio(s):
|
|
|
83
83
|
except Exception:
|
|
84
84
|
r = None
|
|
85
85
|
rad.append(float(r) if r else 1.5)
|
|
86
|
-
|
|
86
|
+
return np.array(rad)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def contact_ratio(s):
|
|
90
|
+
"""The shortest interatomic distance divided by the sum of the two atoms' radii (pymatgen's atomic radii), images of an
|
|
91
|
+
atom included: about 1 in a sound crystal, below COLLAPSED when atoms were pushed into each other."""
|
|
92
|
+
rad = _radii(s)
|
|
87
93
|
ratio = float(min(s.lattice.get_lll_reduced_lattice().abc) / (2 * rad.max()))
|
|
88
94
|
if len(s) > 1:
|
|
89
95
|
d = s.distance_matrix + np.eye(len(s)) * 99
|
|
@@ -91,6 +97,44 @@ def contact_ratio(s):
|
|
|
91
97
|
return ratio
|
|
92
98
|
|
|
93
99
|
|
|
100
|
+
# A cell with a thick empty layer is a slab, and one with little matter in it a sparse cage: the band gap of either is not
|
|
101
|
+
# that of a bulk crystal. Calibrated on all 45,229 known MP-20 crystals (meidnet_journey/gate_calibration): 99% have no
|
|
102
|
+
# empty layer thicker than 4.25 A, 0.84% have one above 6 A (nearly all layered sulfides and selenides whose layers a PBE
|
|
103
|
+
# relaxation pushed apart), and 0.48% have a packing fraction below 0.12 (molecular solids such as PCl5). Relaxation does not
|
|
104
|
+
# remove either: in the MP-20 study every generated cell beyond these lines was still beyond them after relaxation.
|
|
105
|
+
MAX_EMPTY_LAYER = 6.0
|
|
106
|
+
MIN_PACKING = 0.12
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def empty_layer(s):
|
|
110
|
+
"""The thickest empty layer between atomic planes, in A: along each lattice direction of the LLL-reduced cell, the widest
|
|
111
|
+
gap between consecutive fractional coordinates times the spacing of those planes."""
|
|
112
|
+
s = s.get_reduced_structure()
|
|
113
|
+
spacing = 1.0 / np.array(s.lattice.reciprocal_lattice_crystallographic.abc)
|
|
114
|
+
widest = 0.0
|
|
115
|
+
for i in range(3):
|
|
116
|
+
f = np.sort(np.mod(s.frac_coords[:, i], 1.0))
|
|
117
|
+
widest = max(widest, float(np.diff(np.concatenate([f, [f[0] + 1.0]])).max() * spacing[i]))
|
|
118
|
+
return widest
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def packing_fraction(s):
|
|
122
|
+
"""The volume of the atoms' spheres (pymatgen's atomic radii) over the volume of the cell."""
|
|
123
|
+
return float((4.0 / 3.0 * np.pi * _radii(s) ** 3).sum() / s.volume)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def bulk_problem(s):
|
|
127
|
+
"""'' for a cell that can be a bulk crystal, else why not: an empty layer thicker than MAX_EMPTY_LAYER or a packing
|
|
128
|
+
fraction below MIN_PACKING."""
|
|
129
|
+
layer = empty_layer(s)
|
|
130
|
+
if layer > MAX_EMPTY_LAYER:
|
|
131
|
+
return f"empty layer {layer:.1f} A (a slab, not a bulk crystal)"
|
|
132
|
+
pack = packing_fraction(s)
|
|
133
|
+
if pack < MIN_PACKING:
|
|
134
|
+
return f"packing fraction {pack:.2f} (too little matter for a bulk crystal)"
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
94
138
|
def relax_one(s0, name, steps, fmax=0.05):
|
|
95
139
|
"""One cell, one potential -> the relaxed structure and its record."""
|
|
96
140
|
res = relaxer(name).relax(s0, fmax=fmax, steps=steps)
|
|
@@ -103,6 +147,7 @@ def relax_one(s0, name, steps, fmax=0.05):
|
|
|
103
147
|
# matgl records the start, every step and the end: a run stopped by the step limit holds steps + 2 frames
|
|
104
148
|
f"{name}_converged": bool(len(traj.energies) < steps + 1),
|
|
105
149
|
f"{name}_contact_ratio": round(contact_ratio(s1), 3),
|
|
150
|
+
f"{name}_empty_layer": round(empty_layer(s1), 2), f"{name}_packing": round(packing_fraction(s1), 3),
|
|
106
151
|
f"{name}_ok": True}
|
|
107
152
|
return s1, rec
|
|
108
153
|
|
meidnet_eval/instrument_sheet.py
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Stated the way a measuring device is specified: response curve, bias, precision, accuracy with a confidence interval,
|
|
4
4
|
linearity, resolution between adjacent requests, serviceable range, yield per stage, plus the stability proxy and the
|
|
5
5
|
novelty of the delivered cells. Everything is read from the result folders a run leaves behind. A relaxed cell that
|
|
6
|
-
collapsed (closest atoms nearer than 0.6 of their radii) describes no material
|
|
7
|
-
|
|
6
|
+
collapsed (closest atoms nearer than 0.6 of their radii) describes no material, and one with an empty layer thicker than
|
|
7
|
+
6 A or a packing fraction below 0.12 is a slab or a sparse cage, not a bulk crystal: each is counted in the funnel and
|
|
8
|
+
listed, and its gap is left out of every number. The judge's error before relaxation is also given on the same cells
|
|
9
|
+
as after it, so the two can be compared.
|
|
8
10
|
|
|
9
11
|
Usage: python instrument_sheet.py --pool DIR --relaxed DIR --consensus DIR --out DIR [--window 0.5] [--min-gap 0.1]
|
|
10
|
-
[--sun FILE] [--sun-per-candidate FILE] [--known-formulas FILE]
|
|
12
|
+
[--sun FILE] [--sun-per-candidate FILE] [--known-formulas FILE] [--reference-intake DIR]
|
|
11
13
|
"""
|
|
12
14
|
import argparse, glob, json, os
|
|
13
15
|
|
|
@@ -30,11 +32,15 @@ def load(a):
|
|
|
30
32
|
rel["contact_ratio"] = rel["file"].map(lambda f: _contact(a, f))
|
|
31
33
|
collapsed = rel[rel["contact_ratio"] < _collapsed_line()]
|
|
32
34
|
rel = rel.drop(collapsed.index)
|
|
35
|
+
# nor is a slab or a sparse cage a bulk crystal: left out the same way
|
|
36
|
+
rel["bulk_problem"] = rel["file"].map(lambda f: _bulk_problem(a, f))
|
|
37
|
+
not_bulk = rel[rel["bulk_problem"] != ""]
|
|
38
|
+
rel = rel.drop(not_bulk.index)
|
|
33
39
|
else: # no relaxed folder: the sheet describes the generated (unrelaxed) cells and says so
|
|
34
40
|
rel = cons.copy()
|
|
35
41
|
rel["label_structure"] = rel.get("label_gap", rel.get("label_structure_gap"))
|
|
36
42
|
rel["judge"] = rel["file"].map(lambda f: pcal["per_candidate"].get(f, {}).get("independent_gap"))
|
|
37
|
-
collapsed = rel.iloc[0:0]
|
|
43
|
+
collapsed = not_bulk = rel.iloc[0:0]
|
|
38
44
|
mlip = [r for f in glob.glob(f"{a.consensus}/mlip_shard*.json") for r in json.load(open(f))] if a.consensus else []
|
|
39
45
|
drop = {os.path.basename(r["file"]): r.get("tensornet_drop_per_atom") for r in mlip}
|
|
40
46
|
kept = {os.path.basename(r["file"]): r.get("tensornet_spacegroup_relaxed") == r.get("spacegroup_designed") for r in mlip}
|
|
@@ -45,7 +51,7 @@ def load(a):
|
|
|
45
51
|
if os.path.exists(cand):
|
|
46
52
|
sun = json.load(open(cand))
|
|
47
53
|
break
|
|
48
|
-
return pool, pcal, cons, rel, sun, collapsed
|
|
54
|
+
return pool, pcal, cons, rel, sun, collapsed, not_bulk
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
def _collapsed_line():
|
|
@@ -56,21 +62,37 @@ def _collapsed_line():
|
|
|
56
62
|
return COLLAPSED
|
|
57
63
|
|
|
58
64
|
|
|
59
|
-
def
|
|
60
|
-
"""
|
|
61
|
-
try:
|
|
62
|
-
from meidnet_eval.d1_mlip_check import contact_ratio
|
|
63
|
-
except ImportError:
|
|
64
|
-
from d1_mlip_check import contact_ratio
|
|
65
|
+
def _relaxed_cell(a, f):
|
|
66
|
+
"""The relaxed cell behind a candidates-table row (the relaxed folder's copy, else the relaxer's own), or None."""
|
|
65
67
|
from pymatgen.core import Structure
|
|
66
68
|
for p in ([os.path.join(a.relaxed, f)] if a.relaxed else []) + \
|
|
67
69
|
([os.path.join(a.consensus, "relaxed_tensornet", os.path.basename(f))] if a.consensus else []):
|
|
68
70
|
if os.path.exists(p):
|
|
69
71
|
try:
|
|
70
|
-
return
|
|
72
|
+
return Structure.from_file(p)
|
|
71
73
|
except Exception:
|
|
72
|
-
return
|
|
73
|
-
return
|
|
74
|
+
return None
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _contact(a, f):
|
|
79
|
+
"""Contact ratio of the relaxed cell behind a candidates-table row."""
|
|
80
|
+
try:
|
|
81
|
+
from meidnet_eval.d1_mlip_check import contact_ratio
|
|
82
|
+
except ImportError:
|
|
83
|
+
from d1_mlip_check import contact_ratio
|
|
84
|
+
s = _relaxed_cell(a, f)
|
|
85
|
+
return contact_ratio(s) if s is not None else np.nan
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bulk_problem(a, f):
|
|
89
|
+
"""'' when the relaxed cell behind a row can be a bulk crystal, else why not (empty layer, packing)."""
|
|
90
|
+
try:
|
|
91
|
+
from meidnet_eval.d1_mlip_check import bulk_problem
|
|
92
|
+
except ImportError:
|
|
93
|
+
from d1_mlip_check import bulk_problem
|
|
94
|
+
s = _relaxed_cell(a, f)
|
|
95
|
+
return bulk_problem(s) if s is not None else ""
|
|
74
96
|
|
|
75
97
|
|
|
76
98
|
def per_target(pool, cons, ok, final):
|
|
@@ -97,20 +119,51 @@ def per_target(pool, cons, ok, final):
|
|
|
97
119
|
return rows
|
|
98
120
|
|
|
99
121
|
|
|
100
|
-
def classify(known: bool,
|
|
101
|
-
"""What relaxation revealed: a known structure found again
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
def classify(known: bool, match_id: str | None, compared: bool = True) -> str:
|
|
123
|
+
"""What relaxation revealed: a known structure found again (its structure matches an entry of the same formula), a new
|
|
124
|
+
polymorph of a known formula, or a new composition. Without a structure comparison a known formula is only that."""
|
|
125
|
+
if not known:
|
|
126
|
+
return "new composition, new structure"
|
|
127
|
+
if not compared:
|
|
128
|
+
return "known formula (structure not compared)"
|
|
129
|
+
return "rediscovered known structure" if match_id else "new polymorph of known formula"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def same_formula_match(structure, intake: str, cache: dict) -> str | None:
|
|
133
|
+
"""The id of the first entry of the intake (train/val/test CSVs with a cif column) with the same reduced formula whose
|
|
134
|
+
structure matches (pymatgen StructureMatcher, default tolerances, volumes scaled), else None. Every entry of that
|
|
135
|
+
formula is compared, not a sample, and no entry of another composition can match."""
|
|
136
|
+
from pymatgen.analysis.structure_matcher import StructureMatcher
|
|
137
|
+
from pymatgen.core import Composition, Structure
|
|
138
|
+
if "by_formula" not in cache:
|
|
139
|
+
rows = {}
|
|
140
|
+
for split in ("train", "val", "test"):
|
|
141
|
+
p = os.path.join(intake, f"{split}.csv")
|
|
142
|
+
if not os.path.exists(p):
|
|
143
|
+
continue
|
|
144
|
+
t = pd.read_csv(p)
|
|
145
|
+
ids = t["material_id"].astype(str) if "material_id" in t.columns else pd.Series([f"{split}-{i}" for i in range(len(t))])
|
|
146
|
+
for mid, cif, fml in zip(ids, t["cif"], t["formula"] if "formula" in t.columns else [None] * len(t)):
|
|
147
|
+
try:
|
|
148
|
+
key = Composition(fml).reduced_formula if isinstance(fml, str) else Structure.from_str(cif, fmt="cif").composition.reduced_formula
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
rows.setdefault(key, []).append((mid, cif))
|
|
152
|
+
cache["by_formula"] = rows
|
|
153
|
+
matcher = StructureMatcher()
|
|
154
|
+
for mid, cif in cache["by_formula"].get(structure.composition.reduced_formula, []):
|
|
155
|
+
try:
|
|
156
|
+
if matcher.fit(structure, Structure.from_str(cif, fmt="cif")):
|
|
157
|
+
return mid
|
|
158
|
+
except Exception:
|
|
159
|
+
continue
|
|
160
|
+
return None
|
|
109
161
|
|
|
110
162
|
|
|
111
163
|
def accepted_table(final, a) -> pd.DataFrame:
|
|
112
164
|
"""One row per accepted material with the evidence a reader needs: both gaps on the relaxed cell, the AMD distance,
|
|
113
|
-
whether the formula exists in the training data (and at which recorded gaps), the class
|
|
165
|
+
whether the formula exists in the training data (and at which recorded gaps), the class (with the matching entry when the
|
|
166
|
+
structure was compared), and a charge-balance flag."""
|
|
114
167
|
amd, by_file = {}, False
|
|
115
168
|
if a.sun_per_candidate and os.path.exists(a.sun_per_candidate):
|
|
116
169
|
t = pd.read_csv(a.sun_per_candidate)
|
|
@@ -123,11 +176,16 @@ def accepted_table(final, a) -> pd.DataFrame:
|
|
|
123
176
|
import gzip
|
|
124
177
|
opener = gzip.open if a.known_formulas.endswith(".gz") else open
|
|
125
178
|
known = json.load(opener(a.known_formulas, "rt"))
|
|
126
|
-
rows = []
|
|
179
|
+
rows, cache = [], {}
|
|
180
|
+
compare = bool(getattr(a, "reference_intake", None))
|
|
127
181
|
for _, r in final.sort_values(["target", "formula"]).iterrows():
|
|
128
182
|
d = amd.get(os.path.basename(str(r["file"]))) if by_file else amd.get(r["formula"])
|
|
129
183
|
d = None if d is None or d != d else float(d)
|
|
130
184
|
is_known = r["formula"] in known if known else None
|
|
185
|
+
match = None
|
|
186
|
+
if is_known and compare:
|
|
187
|
+
s = _relaxed_cell(a, r["file"])
|
|
188
|
+
match = same_formula_match(s, a.reference_intake, cache) if s is not None else None
|
|
131
189
|
try:
|
|
132
190
|
from pymatgen.core import Composition
|
|
133
191
|
balanced = bool(Composition(r["formula"]).oxi_state_guesses(max_sites=-1))
|
|
@@ -136,13 +194,20 @@ def accepted_table(final, a) -> pd.DataFrame:
|
|
|
136
194
|
rows.append(dict(requested=float(r["target"]), formula=r["formula"], label_structure=round(float(r["label_structure"]), 3),
|
|
137
195
|
judge=round(float(r["judge"]), 3), amd_nearest=None if d is None else round(d, 3),
|
|
138
196
|
known_formula=is_known, recorded_gaps=",".join(f"{g:.2f}" for g in known.get(r["formula"], [])) if known else "",
|
|
139
|
-
cls=classify(bool(is_known),
|
|
197
|
+
cls=classify(bool(is_known), match, compare) if is_known is not None else "", reference_id=match or "",
|
|
140
198
|
charge_balanced=balanced, drop_eV_atom=r.get("drop"), spacegroup_kept=r.get("sg_kept"),
|
|
141
199
|
natoms=int(r["natoms"]) if "natoms" in r else None, file=r.get("file"),
|
|
142
200
|
flag="judges disagree" if abs(float(r["label_structure"]) - float(r["judge"])) > 0.5 else ""))
|
|
143
201
|
return pd.DataFrame(rows).rename(columns={"cls": "class"})
|
|
144
202
|
|
|
145
203
|
|
|
204
|
+
def _mae_before(pool, ok):
|
|
205
|
+
"""The judge's error on the generated (unrelaxed) versions of the cells that make up the relaxed set."""
|
|
206
|
+
names = set(ok["file"].map(os.path.basename))
|
|
207
|
+
same = pool[pool["file"].map(os.path.basename).isin(names)].dropna(subset=["judge_generated"])
|
|
208
|
+
return float(np.abs(same["judge_generated"] - same["target"]).mean()) if len(same) else None
|
|
209
|
+
|
|
210
|
+
|
|
146
211
|
def main():
|
|
147
212
|
ap = argparse.ArgumentParser()
|
|
148
213
|
ap.add_argument("--pool", required=True, help="the generation folder (candidates.csv, calibration.json, candidates_consensus.csv)")
|
|
@@ -156,9 +221,11 @@ def main():
|
|
|
156
221
|
ap.add_argument("--sun", default=None, help="metrics_sun JSON for the novelty line (default: the relaxed folder's sun_relaxed_strict.json or sun.json)")
|
|
157
222
|
ap.add_argument("--sun-per-candidate", default=None, help="metrics_sun per-candidate CSV (adds the AMD distance per material)")
|
|
158
223
|
ap.add_argument("--known-formulas", default=None, help="JSON {reduced formula: [gaps]} of the training data (adds known/new and the recorded gaps)")
|
|
224
|
+
ap.add_argument("--reference-intake", default=None, help="the intake folder (train/val/test CSVs with a cif column): a known formula is called "
|
|
225
|
+
"rediscovered only when its relaxed cell matches an entry of that formula (StructureMatcher)")
|
|
159
226
|
a = ap.parse_args()
|
|
160
227
|
os.makedirs(a.out, exist_ok=True)
|
|
161
|
-
pool, pcal, cons, rel, sun, collapsed = load(a)
|
|
228
|
+
pool, pcal, cons, rel, sun, collapsed, not_bulk = load(a)
|
|
162
229
|
|
|
163
230
|
ok = rel.dropna(subset=["label_structure", "judge"]).copy()
|
|
164
231
|
in_window = (abs(ok["label_structure"] - ok["target"]) <= a.window) & (abs(ok["judge"] - ok["target"]) <= a.window)
|
|
@@ -184,12 +251,15 @@ def main():
|
|
|
184
251
|
sheet = dict(
|
|
185
252
|
window_eV=a.window, metal_floor_eV=a.min_gap,
|
|
186
253
|
funnel=dict(generated=int(len(pool)), both_judges=int(len(cons)), collapsed_on_relaxation=int(len(collapsed)),
|
|
187
|
-
relaxed=int(len(ok)), final=int(len(final))),
|
|
254
|
+
not_bulk_on_relaxation=int(len(not_bulk)), relaxed=int(len(ok)), final=int(len(final))),
|
|
188
255
|
collapsed_on_relaxation=[dict(formula=r["formula"], target=float(r["target"]), contact_ratio=round(float(r["contact_ratio"]), 2))
|
|
189
256
|
for _, r in collapsed.iterrows()],
|
|
257
|
+
not_bulk_on_relaxation=[dict(formula=r["formula"], target=float(r["target"]), reason=r["bulk_problem"]) for _, r in not_bulk.iterrows()],
|
|
190
258
|
judge=pcal.get("judge_qualification"),
|
|
191
259
|
per_target=rows,
|
|
192
260
|
accuracy=dict(mae_generated_cells=float(np.abs(pool["judge_generated"] - pool["target"]).mean()),
|
|
261
|
+
# the same cells before relaxation, so that before and after compare like with like
|
|
262
|
+
mae_same_cells_before_relaxation=_mae_before(pool, ok), same_cells=int(len(ok)),
|
|
193
263
|
mae_relaxed_cells=float(np.abs(y - x).mean()),
|
|
194
264
|
mae_relaxed_ci95=[float(np.percentile(boots, 2.5)), float(np.percentile(boots, 97.5))],
|
|
195
265
|
within_window=int((np.abs(y - x) <= a.window).sum()), of=int(len(x))),
|
|
@@ -220,7 +290,10 @@ def main():
|
|
|
220
290
|
acc, lin = sheet["accuracy"], sheet["linearity"]
|
|
221
291
|
cells = sheet.get("cells", "relaxed")
|
|
222
292
|
print(f"\ncells: {cells}")
|
|
223
|
-
|
|
293
|
+
before = acc["mae_same_cells_before_relaxation"]
|
|
294
|
+
print(f"accuracy: MAE on all generated cells {acc['mae_generated_cells']:.2f} eV; on the same {acc['same_cells']} cells "
|
|
295
|
+
+ (f"{before:.2f} before relaxation -> " if before is not None else "")
|
|
296
|
+
+ f"{'relaxed' if cells == 'relaxed' else 'kept (unrelaxed)'} {acc['mae_relaxed_cells']:.2f} eV "
|
|
224
297
|
f"(95% CI {acc['mae_relaxed_ci95'][0]:.2f}-{acc['mae_relaxed_ci95'][1]:.2f}); within {a.window} eV: {acc['within_window']} of {acc['of']}")
|
|
225
298
|
if len(x) >= 3 and lin["slope"] == lin["slope"]:
|
|
226
299
|
print(f"linearity: delivered = {lin['intercept']:.2f} + {lin['slope']:.2f} x requested (R2 {lin['r2']:.2f}, Spearman {lin['spearman']:.2f}); ideal 0 + 1.00 x")
|
meidnet_eval/pipeline_checkup.py
CHANGED
|
@@ -173,6 +173,7 @@ def main():
|
|
|
173
173
|
ap.add_argument("--family", default=None, help="family for the design-space checks of stage 0 (default: the Perov-5 demo "
|
|
174
174
|
"family); 'none' skips them, e.g. when discover.py checks the design space in its own stage")
|
|
175
175
|
ap.add_argument("--variant", default="oxide")
|
|
176
|
+
ap.add_argument("--out-dir", default=None, help="where <tag>.json and <tag>.md go (default: results/checkups beside this file)")
|
|
176
177
|
a = ap.parse_args()
|
|
177
178
|
torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", "4")))
|
|
178
179
|
t0 = time.time()
|
|
@@ -288,7 +289,7 @@ def main():
|
|
|
288
289
|
def decode(self, Z, *x, **k):
|
|
289
290
|
pops.append(Z.clone()); return super().decode(Z, *x, **k)
|
|
290
291
|
d = D(lm, fam_t, cfg.generation, device=torch.device("cpu"), log=lambda *x: None, reference_latents=ref)
|
|
291
|
-
res = d.run(os.path.join(HERE, "results", "checkups", f"{a.tag}_search", f"T{tgt:g}"), ranges=property_ranges(lm))
|
|
292
|
+
res = d.run(os.path.join(a.out_dir or os.path.join(HERE, "results", "checkups"), f"{a.tag}_search", f"T{tgt:g}"), ranges=property_ranges(lm))
|
|
292
293
|
Zall.append(torch.cat(pops).numpy())
|
|
293
294
|
for c in res.saved:
|
|
294
295
|
cand = build_candidate(fam_t, c.elements)
|
|
@@ -328,7 +329,7 @@ def main():
|
|
|
328
329
|
else:
|
|
329
330
|
add("6 end-to-end", "returned structures follow the target (DFT)", "FAIL", "no candidate returned for any target", value=None)
|
|
330
331
|
|
|
331
|
-
out_dir = os.path.join(HERE, "results", "checkups"); os.makedirs(out_dir, exist_ok=True)
|
|
332
|
+
out_dir = a.out_dir or os.path.join(HERE, "results", "checkups"); os.makedirs(out_dir, exist_ok=True)
|
|
332
333
|
json.dump(dict(tag=a.tag, ckpt=a.ckpt, switches=dict(label_source=a.label_source, latent_space=a.latent_space,
|
|
333
334
|
manifold_weight=a.manifold_weight), thresholds=THRESHOLDS, checks=checks, data=data,
|
|
334
335
|
seconds=time.time() - t0), open(f"{out_dir}/{a.tag}.json", "w"), indent=1, default=float)
|
meidnet_eval/scorecard.py
CHANGED
|
@@ -31,10 +31,16 @@ FID = os.environ.get("MEIDNET_FIDELITY_DIR", "") # the CGCNN judge checkout, w
|
|
|
31
31
|
PY = sys.executable
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def run(cmd, env, log):
|
|
34
|
+
def run(cmd, env, log, cwd):
|
|
35
|
+
"""One component, from a work folder inside --out (never the package's own folder) by its absolute path; its exit
|
|
36
|
+
status is returned and recorded in the log."""
|
|
37
|
+
if cmd[1].endswith(".py") and not os.path.isabs(cmd[1]):
|
|
38
|
+
cmd = [cmd[0], os.path.join(HERE, cmd[1]), *cmd[2:]]
|
|
39
|
+
os.makedirs(cwd, exist_ok=True)
|
|
35
40
|
with open(log, "a") as f:
|
|
36
41
|
f.write(f"\n$ {' '.join(cmd)}\n"); f.flush()
|
|
37
|
-
r = subprocess.run(cmd, cwd=
|
|
42
|
+
r = subprocess.run(cmd, cwd=cwd, env=env, stdout=f, stderr=subprocess.STDOUT)
|
|
43
|
+
f.write(f"[exit {r.returncode}]\n")
|
|
38
44
|
return r.returncode
|
|
39
45
|
|
|
40
46
|
|
|
@@ -44,14 +50,26 @@ def collect(out, name, intake, ckpt, gap, cost, targets, env, skip):
|
|
|
44
50
|
mj = os.path.join(out, f"metrics_{name}.json")
|
|
45
51
|
dj = os.path.join(out, f"decoder_{name}.json")
|
|
46
52
|
log = os.path.join(out, "diagnostics.log")
|
|
53
|
+
work = os.path.join(out, "_work")
|
|
54
|
+
failed = []
|
|
47
55
|
if not skip:
|
|
48
|
-
run(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
# results of an earlier run (another model, perhaps) must never stand in for this one: clear them first
|
|
57
|
+
src = os.path.join(work, "checkups", f"sc_{name}.json")
|
|
58
|
+
for stale in (cj, mj, dj, src):
|
|
59
|
+
if os.path.exists(stale):
|
|
60
|
+
os.remove(stale)
|
|
61
|
+
rc = run([PY, "pipeline_checkup.py", f"sc_{name}", ckpt, "--label-source", "structure", "--latent-space", "sphere",
|
|
62
|
+
"--skip-search", "--family", "none", "--out-dir", os.path.join(work, "checkups")], env, log, work)
|
|
63
|
+
if rc == 0 and os.path.exists(src):
|
|
52
64
|
json.dump(json.load(open(src)), open(cj, "w"), indent=1)
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
else:
|
|
66
|
+
failed.append(f"pipeline_checkup.py (exit {rc})")
|
|
67
|
+
rc = run([PY, "model_metrics.py", f"sc_{name}", ckpt, mj, intake], env, log, work)
|
|
68
|
+
if rc != 0 or not os.path.exists(mj):
|
|
69
|
+
failed.append(f"model_metrics.py (exit {rc})")
|
|
70
|
+
rc = run([PY, "decoder_autopsy.py", ckpt, "--data", intake, "--json", dj], env, log, work)
|
|
71
|
+
if rc != 0 or not os.path.exists(dj):
|
|
72
|
+
failed.append(f"decoder_autopsy.py (exit {rc})")
|
|
55
73
|
|
|
56
74
|
v, raw = {}, {}
|
|
57
75
|
if os.path.exists(mj):
|
|
@@ -87,6 +105,7 @@ def collect(out, name, intake, ckpt, gap, cost, targets, env, skip):
|
|
|
87
105
|
v["head_length_sensitivity"] = val
|
|
88
106
|
if os.path.exists(dj):
|
|
89
107
|
raw["decoder"] = json.load(open(dj))
|
|
108
|
+
raw["failed"] = failed
|
|
90
109
|
return {k: x for k, x in v.items() if x is not None}, raw
|
|
91
110
|
|
|
92
111
|
|
|
@@ -101,8 +120,19 @@ def main():
|
|
|
101
120
|
ap.add_argument("--judge-n", type=int, default=80)
|
|
102
121
|
ap.add_argument("--out", required=True); ap.add_argument("--skip-run", action="store_true")
|
|
103
122
|
a = ap.parse_args()
|
|
123
|
+
# absolute paths: the components run from a work folder, and a relative path must mean what the user meant
|
|
124
|
+
a.intake, a.out = os.path.abspath(a.intake), os.path.abspath(a.out)
|
|
104
125
|
os.makedirs(a.out, exist_ok=True)
|
|
105
|
-
models =
|
|
126
|
+
models = {k: os.path.abspath(v) for k, v in (m.split("=", 1) for m in a.models)}
|
|
127
|
+
if not a.cost: # the second property, when the intake has an obvious one
|
|
128
|
+
try:
|
|
129
|
+
cols = [c for c in pd.read_csv(os.path.join(a.intake, "train.csv"), nrows=1).columns if c != a.gap]
|
|
130
|
+
cands = [c for c in cols if any(w in c.lower() for w in ("formation", "heat", "e_form", "dhf"))]
|
|
131
|
+
if len(cands) == 1:
|
|
132
|
+
a.cost = cands[0]
|
|
133
|
+
print(f"--cost not given: using the intake's {a.cost}", flush=True)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
106
136
|
if "main" not in models: # the first model named is the one every block is computed for
|
|
107
137
|
first = next(iter(models))
|
|
108
138
|
print(f"no model is named 'main': treating '{first}' as the main model (control= and seed1= are the comparisons)", flush=True)
|
|
@@ -118,6 +148,8 @@ def main():
|
|
|
118
148
|
print(f" {name}: checkpoint missing ({ckpt}) — skipped", flush=True); continue
|
|
119
149
|
print(f"running diagnostics for {name} ...", flush=True)
|
|
120
150
|
measured[name], raws[name] = collect(a.out, name, a.intake, ckpt, a.gap, a.cost, a.targets, env, a.skip_run)
|
|
151
|
+
if "main" not in measured:
|
|
152
|
+
raise SystemExit(f"the main model's checkpoint was not found: {models['main']}")
|
|
121
153
|
|
|
122
154
|
# block S9 needs the reliability map, which is a separate script
|
|
123
155
|
rel = os.path.join(a.out, "reliability_map.csv")
|
|
@@ -126,7 +158,10 @@ def main():
|
|
|
126
158
|
"--out", a.out]
|
|
127
159
|
if a.targets:
|
|
128
160
|
cmd += ["--targets"] + [str(t) for t in a.targets]
|
|
129
|
-
|
|
161
|
+
if os.path.exists(rel):
|
|
162
|
+
os.remove(rel)
|
|
163
|
+
if run(cmd, env, os.path.join(a.out, "diagnostics.log"), os.path.join(a.out, "_work")) != 0 or not os.path.exists(rel):
|
|
164
|
+
raws.setdefault("main", {}).setdefault("failed", []).append("reliability_map.py")
|
|
130
165
|
if os.path.exists(rel):
|
|
131
166
|
R = pd.read_csv(rel)
|
|
132
167
|
measured.setdefault("main", {})["servable_targets"] = int((R.verdict == "servable").sum())
|
|
@@ -195,11 +230,18 @@ def main():
|
|
|
195
230
|
text += " In this configuration the structure losses are already on, so the limit is the data (block S0's density), not the losses."
|
|
196
231
|
return text
|
|
197
232
|
report = {}
|
|
233
|
+
failures = {name: (raws.get(name) or {}).get("failed", []) for name in measured}
|
|
198
234
|
for name, vals in measured.items():
|
|
199
235
|
blocks = {}
|
|
236
|
+
model_failed = [f for f in failures.get(name, []) if not f.startswith("reliability_map")]
|
|
200
237
|
for sid in ("S1", "S2", "S3", "S4", "S8", "S9"):
|
|
201
238
|
st = BY_ID[sid]
|
|
202
239
|
sub = {m.id: vals[m.id] for m in st.metrics if m.id in vals}
|
|
240
|
+
if sid in ("S1", "S2", "S3", "S4") and model_failed:
|
|
241
|
+
# a block whose diagnostics failed is not graded: neither from partial values nor from an earlier run
|
|
242
|
+
blocks[sid] = dict(name=st.name, question=st.question, verdict="NOT COMPUTED", metrics=[],
|
|
243
|
+
reason="failed: " + ", ".join(model_failed) + " (see diagnostics.log)")
|
|
244
|
+
continue
|
|
203
245
|
if not sub:
|
|
204
246
|
continue
|
|
205
247
|
verdict, grades = st.verdict(sub, context=vals)
|
|
@@ -230,7 +272,7 @@ def main():
|
|
|
230
272
|
if k in measured["main"] and k in measured["seed1"]}
|
|
231
273
|
|
|
232
274
|
json.dump(dict(intake=a.intake, gap=a.gap, cost=a.cost, models=models, measured=measured, report=report,
|
|
233
|
-
comparisons=comparisons, judge=judge), open(os.path.join(a.out, "scorecard.json"), "w"),
|
|
275
|
+
comparisons=comparisons, judge=judge, failures=failures), open(os.path.join(a.out, "scorecard.json"), "w"),
|
|
234
276
|
indent=1, default=float)
|
|
235
277
|
|
|
236
278
|
# ── render ──
|
|
@@ -249,6 +291,9 @@ def main():
|
|
|
249
291
|
for sid, b in main.items():
|
|
250
292
|
L += [f"## {sid} — {b['name']}: **{b['verdict']}**", "",
|
|
251
293
|
"| metric | value | band | grade |", "|---|---|---|---|"]
|
|
294
|
+
if b["verdict"] == "NOT COMPUTED":
|
|
295
|
+
L += [f"Not computed: {b['reason']}.", ""]
|
|
296
|
+
continue
|
|
252
297
|
for r in b["metrics"]:
|
|
253
298
|
L.append(f"| {r['metric']} ({r['unit']}) | {r['value']:.4g} | {r['band']} | **{r['grade']}** |")
|
|
254
299
|
L.append("")
|
|
@@ -275,6 +320,10 @@ def main():
|
|
|
275
320
|
open(os.path.join(a.out, "scorecard.md"), "w").write("\n".join(L))
|
|
276
321
|
print("\n".join(L))
|
|
277
322
|
print(f"\nwrote {a.out}/scorecard.md and scorecard.json")
|
|
323
|
+
failed = {k: v for k, v in failures.items() if v}
|
|
324
|
+
if failed:
|
|
325
|
+
print("\nNOT COMPUTED: " + "; ".join(f"{k}: {', '.join(v)}" for k, v in failed.items()) + f" (log: {a.out}/diagnostics.log)", flush=True)
|
|
326
|
+
sys.exit(1)
|
|
278
327
|
|
|
279
328
|
|
|
280
329
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|