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 CHANGED
@@ -17,4 +17,4 @@ Typical use (the same steps the ``meidnet`` command runs)::
17
17
  generate(cfg) # design candidates (writes CIFs + generation_report.html)
18
18
  """
19
19
 
20
- __version__ = "2.4.0.dev2"
20
+ __version__ = "2.4.0.dev3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meidnet
3
- Version: 2.4.0.dev2
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.dev2) so the application's `meidnet>=2.2.0,<3` dependency resolves to it and nothing is fetched from PyPI.
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.dev2 is this snapshot, uploaded by MEIDNet-Matter's release workflow so that `pip install meidnet-matter`
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-08. To refresh it: `python scripts/vendor_engine.py --src <checkout>`. The upstream release of this
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=QJ1fzSjr8ozynxR6WdOVI1p8AxSujXqsRuQA3wn2IyY,797
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=O4uPhSpHifWuAuFJFvEUd2xXt-F6g0n2P_cVu5xDLso,20459
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=SdPopBQzJPksTv2wgsupfve9941aaf989RJOpqR-r1I,20034
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=zQbMM6pOSvIoltWVX7C9kU_RHNTI_Q2uu06awzmJ8IU,8527
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=69B5Cw709qGH7hPJl8tkZs6ChCWifFdqOz1V47_FUkc,14771
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=fMVzCnXjg-8byfL7aNeG2iofH863qMSuTh5oZ5cegd8,22348
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=RTS-g2Atc6y0AcLUxyJ4n35rJREA6zsxRvTWBVKt04Y,15500
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.dev2.dist-info/METADATA,sha256=eieT-qLmVXmyQg1HiZ120YLfvcsSYKZ_6zAEtG69SwM,1676
88
- meidnet-2.4.0.dev2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
89
- meidnet-2.4.0.dev2.dist-info/entry_points.txt,sha256=N0rGAumst_v4exbrTZn_K1-zvBIPzkLd6cO8NbP98LY,45
90
- meidnet-2.4.0.dev2.dist-info/top_level.txt,sha256=zVMmF3NOP2ZV2soL4l2UaYHqPwvHTKoybycSDjawOpw,21
91
- meidnet-2.4.0.dev2.dist-info/RECORD,,
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,,
@@ -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
- ratio = contact_ratio(Structure.from_file(src))
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__
@@ -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) and whether the optimiser converged before the step limit. The relaxed cells are kept, because a band gap
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 contact_ratio(s):
72
- """The shortest interatomic distance divided by the sum of the two atoms' radii (pymatgen's atomic radii), images of an
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
- rad = np.array(rad)
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
 
@@ -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: it is counted in the funnel and listed,
7
- and its gap is left out of every number.
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 _contact(a, f):
60
- """Contact ratio of the relaxed cell behind a candidates-table row (the relaxed folder's copy, else the relaxer's own)."""
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 contact_ratio(Structure.from_file(p))
72
+ return Structure.from_file(p)
71
73
  except Exception:
72
- return np.nan
73
- return np.nan
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, amd: float | None) -> str:
101
- """What relaxation revealed: a known structure found again, a new polymorph of a known formula, or a new composition."""
102
- if known and amd is not None and amd < 0.3:
103
- return "rediscovered known structure"
104
- if known and amd is not None and amd < 0.35:
105
- return "rediscovered (borderline)"
106
- if known:
107
- return "new polymorph of known formula"
108
- return "new composition, new structure"
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, and a charge-balance flag."""
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), d) if is_known is not None else "",
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
- print(f"accuracy: MAE generated {acc['mae_generated_cells']:.2f} -> {'relaxed' if cells == 'relaxed' else 'kept (unrelaxed)'} {acc['mae_relaxed_cells']:.2f} eV "
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")
@@ -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=HERE, env=env, stdout=f, stderr=subprocess.STDOUT)
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([PY, "pipeline_checkup.py", f"sc_{name}", ckpt, "--label-source", "structure", "--latent-space", "sphere",
49
- "--skip-search", "--family", "none"], env, log)
50
- src = os.path.join(HERE, "results", "checkups", f"sc_{name}.json")
51
- if os.path.exists(src):
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
- run([PY, "model_metrics.py", f"sc_{name}", ckpt, mj, intake], env, log)
54
- run([PY, "decoder_autopsy.py", ckpt, "--data", intake, "--json", dj], env, log)
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 = dict(m.split("=", 1) for m in a.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
- run(cmd, env, os.path.join(a.out, "diagnostics.log"))
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__":