mattergraph-api 0.1.0__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.
- mattergraph_api/__init__.py +1 -0
- mattergraph_api/config.py +15 -0
- mattergraph_api/db/migrations/.gitkeep +1 -0
- mattergraph_api/db/models.py +6 -0
- mattergraph_api/db/session.py +4 -0
- mattergraph_api/main.py +77 -0
- mattergraph_api/py.typed +0 -0
- mattergraph_api/routes/__init__.py +1 -0
- mattergraph_api/routes/datasets.py +184 -0
- mattergraph_api/routes/demo.py +116 -0
- mattergraph_api/routes/materials.py +38 -0
- mattergraph_api/routes/scores.py +80 -0
- mattergraph_api/routes/search.py +26 -0
- mattergraph_api/routes/simulations.py +46 -0
- mattergraph_api/routes/workflows.py +18 -0
- mattergraph_api/services/__init__.py +1 -0
- mattergraph_api/services/dataset_registry.py +201 -0
- mattergraph_api/services/demo_service.py +482 -0
- mattergraph_api/services/store_service.py +38 -0
- mattergraph_api/services/workflow_service.py +195 -0
- mattergraph_api-0.1.0.dist-info/METADATA +60 -0
- mattergraph_api-0.1.0.dist-info/RECORD +23 -0
- mattergraph_api-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gc
|
|
4
|
+
import threading
|
|
5
|
+
from collections import OrderedDict, deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mattergraph import DatasetManifest, MaterialStore
|
|
10
|
+
|
|
11
|
+
MAX_DATASETS = 8
|
|
12
|
+
MAX_NORMALIZED_BYTES = 32 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatasetRegistryError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DatasetNotFoundError(DatasetRegistryError):
|
|
20
|
+
def __init__(self, dataset_id: str, *, evicted: bool = False) -> None:
|
|
21
|
+
self.dataset_id = dataset_id
|
|
22
|
+
self.evicted = evicted
|
|
23
|
+
label = "evicted" if evicted else "unknown"
|
|
24
|
+
super().__init__(f"{label} dataset {dataset_id!r}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DatasetBusyError(DatasetRegistryError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DatasetCapacityError(DatasetRegistryError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class RegistryEntry:
|
|
37
|
+
manifest: DatasetManifest
|
|
38
|
+
payload: bytes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DatasetRegistry:
|
|
42
|
+
"""Byte-budgeted LRU registry with one lazily materialized store."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
max_entries: int = MAX_DATASETS,
|
|
48
|
+
max_bytes: int = MAX_NORMALIZED_BYTES,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.max_entries = max_entries
|
|
51
|
+
self.max_bytes = max_bytes
|
|
52
|
+
self._entries: OrderedDict[str, RegistryEntry] = OrderedDict()
|
|
53
|
+
self._lock = threading.RLock()
|
|
54
|
+
self._active_dataset_id: str | None = None
|
|
55
|
+
self._active_store: MaterialStore | None = None
|
|
56
|
+
self._evicted_ids: deque[str] = deque(maxlen=64)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def total_bytes(self) -> int:
|
|
60
|
+
with self._lock:
|
|
61
|
+
return sum(len(entry.payload) for entry in self._entries.values())
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def active_dataset_id(self) -> str | None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
return self._active_dataset_id
|
|
67
|
+
|
|
68
|
+
def register(self, manifest: DatasetManifest, payload: bytes) -> dict[str, Any]:
|
|
69
|
+
if len(payload) != manifest.normalized_bytes:
|
|
70
|
+
msg = "manifest normalized_bytes does not match the payload"
|
|
71
|
+
raise ValueError(msg)
|
|
72
|
+
if len(payload) > self.max_bytes:
|
|
73
|
+
msg = f"normalized payload exceeds registry byte budget of {self.max_bytes}"
|
|
74
|
+
raise DatasetCapacityError(msg)
|
|
75
|
+
with self._lock:
|
|
76
|
+
prior = self._entries.pop(manifest.dataset_id, None)
|
|
77
|
+
if prior is not None and self._active_dataset_id == manifest.dataset_id:
|
|
78
|
+
self._release_active_locked()
|
|
79
|
+
self._entries[manifest.dataset_id] = RegistryEntry(manifest=manifest, payload=payload)
|
|
80
|
+
evicted = self._evict_to_budget_locked(protected_id=manifest.dataset_id)
|
|
81
|
+
return {
|
|
82
|
+
"manifest": manifest.model_dump(mode="json"),
|
|
83
|
+
"evicted_dataset_ids": evicted,
|
|
84
|
+
"registry": self.stats(),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def list(self) -> list[dict[str, Any]]:
|
|
88
|
+
with self._lock:
|
|
89
|
+
return [
|
|
90
|
+
self._entry_status(dataset_id, entry)
|
|
91
|
+
for dataset_id, entry in reversed(self._entries.items())
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def get(self, dataset_id: str) -> RegistryEntry:
|
|
95
|
+
with self._lock:
|
|
96
|
+
entry = self._entry_locked(dataset_id)
|
|
97
|
+
self._entries.move_to_end(dataset_id)
|
|
98
|
+
return entry
|
|
99
|
+
|
|
100
|
+
def status(self, dataset_id: str) -> dict[str, Any]:
|
|
101
|
+
with self._lock:
|
|
102
|
+
entry = self._entry_locked(dataset_id)
|
|
103
|
+
self._entries.move_to_end(dataset_id)
|
|
104
|
+
return self._entry_status(dataset_id, entry)
|
|
105
|
+
|
|
106
|
+
def materialize(self, dataset_id: str) -> MaterialStore:
|
|
107
|
+
with self._lock:
|
|
108
|
+
entry = self._entry_locked(dataset_id)
|
|
109
|
+
self._entries.move_to_end(dataset_id)
|
|
110
|
+
if self._active_dataset_id == dataset_id and self._active_store is not None:
|
|
111
|
+
return self._active_store
|
|
112
|
+
|
|
113
|
+
# Enforce the single-store invariant before parsing the next payload.
|
|
114
|
+
self._release_active_locked()
|
|
115
|
+
gc.collect()
|
|
116
|
+
try:
|
|
117
|
+
store = MaterialStore.from_jsonl_text(
|
|
118
|
+
entry.payload.decode("utf-8"),
|
|
119
|
+
max_rows=entry.manifest.record_count,
|
|
120
|
+
)
|
|
121
|
+
except Exception:
|
|
122
|
+
self._active_dataset_id = None
|
|
123
|
+
self._active_store = None
|
|
124
|
+
raise
|
|
125
|
+
self._active_dataset_id = dataset_id
|
|
126
|
+
self._active_store = store
|
|
127
|
+
return store
|
|
128
|
+
|
|
129
|
+
def export(self, dataset_id: str) -> tuple[DatasetManifest, bytes]:
|
|
130
|
+
entry = self.get(dataset_id)
|
|
131
|
+
return entry.manifest, entry.payload
|
|
132
|
+
|
|
133
|
+
def delete(self, dataset_id: str) -> DatasetManifest:
|
|
134
|
+
if not self._lock.acquire(blocking=False):
|
|
135
|
+
msg = f"dataset {dataset_id!r} is currently being replaced or deleted"
|
|
136
|
+
raise DatasetBusyError(msg)
|
|
137
|
+
try:
|
|
138
|
+
entry = self._entry_locked(dataset_id)
|
|
139
|
+
if self._active_dataset_id == dataset_id:
|
|
140
|
+
self._release_active_locked()
|
|
141
|
+
del self._entries[dataset_id]
|
|
142
|
+
return entry.manifest
|
|
143
|
+
finally:
|
|
144
|
+
self._lock.release()
|
|
145
|
+
|
|
146
|
+
def clear(self) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._release_active_locked()
|
|
149
|
+
self._entries.clear()
|
|
150
|
+
self._evicted_ids.clear()
|
|
151
|
+
|
|
152
|
+
def stats(self) -> dict[str, int | str | None]:
|
|
153
|
+
with self._lock:
|
|
154
|
+
return {
|
|
155
|
+
"entry_count": len(self._entries),
|
|
156
|
+
"normalized_bytes": sum(len(entry.payload) for entry in self._entries.values()),
|
|
157
|
+
"max_entries": self.max_entries,
|
|
158
|
+
"max_normalized_bytes": self.max_bytes,
|
|
159
|
+
"active_dataset_id": self._active_dataset_id,
|
|
160
|
+
"eviction_policy": "weighted_lru",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
def _entry_locked(self, dataset_id: str) -> RegistryEntry:
|
|
164
|
+
entry = self._entries.get(dataset_id)
|
|
165
|
+
if entry is None:
|
|
166
|
+
raise DatasetNotFoundError(dataset_id, evicted=dataset_id in self._evicted_ids)
|
|
167
|
+
return entry
|
|
168
|
+
|
|
169
|
+
def _entry_status(self, dataset_id: str, entry: RegistryEntry) -> dict[str, Any]:
|
|
170
|
+
return {
|
|
171
|
+
"manifest": entry.manifest.model_dump(mode="json"),
|
|
172
|
+
"readiness": "ready",
|
|
173
|
+
"normalized_bytes": len(entry.payload),
|
|
174
|
+
"materialized": dataset_id == self._active_dataset_id and self._active_store is not None,
|
|
175
|
+
"eviction": {
|
|
176
|
+
"policy": "least_recently_used",
|
|
177
|
+
"entry_limit": self.max_entries,
|
|
178
|
+
"byte_limit": self.max_bytes,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def _evict_to_budget_locked(self, *, protected_id: str) -> list[str]:
|
|
183
|
+
evicted: list[str] = []
|
|
184
|
+
while len(self._entries) > self.max_entries or self.total_bytes > self.max_bytes:
|
|
185
|
+
victim = next((key for key in self._entries if key != protected_id), None)
|
|
186
|
+
if victim is None:
|
|
187
|
+
msg = "registry limits cannot accommodate the normalized dataset"
|
|
188
|
+
raise DatasetCapacityError(msg)
|
|
189
|
+
if victim == self._active_dataset_id:
|
|
190
|
+
self._release_active_locked()
|
|
191
|
+
del self._entries[victim]
|
|
192
|
+
self._evicted_ids.append(victim)
|
|
193
|
+
evicted.append(victim)
|
|
194
|
+
return evicted
|
|
195
|
+
|
|
196
|
+
def _release_active_locked(self) -> None:
|
|
197
|
+
self._active_store = None
|
|
198
|
+
self._active_dataset_id = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
dataset_registry = DatasetRegistry()
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mattergraph import Material, MaterialStore, Scorecard
|
|
10
|
+
from mattergraph.datasets import MatterGraphDataset
|
|
11
|
+
from mattergraph.graph import CrystalGraphBuilder
|
|
12
|
+
from mattergraph_connectors import LeMatBulk
|
|
13
|
+
from mattergraph_sim.ase_runner import EMT_SUPPORTED_SPECIES
|
|
14
|
+
|
|
15
|
+
FIXTURE_RELATIVE_PATH = "data/demo/spc_real_snapshot.json"
|
|
16
|
+
CHGNET_REFERENCE_RELATIVE_PATH = "data/demo/chgnet_reference.json"
|
|
17
|
+
SOURCE_DATASET = "LeMaterial/LeMat-Bulk"
|
|
18
|
+
SOURCE_SUBSET = "compatible_pbe"
|
|
19
|
+
SLICE_NAME = "spc_tialn_candidates_v1"
|
|
20
|
+
TARGET = "energy_above_hull"
|
|
21
|
+
WORKFLOW_VERSION = "v1.0"
|
|
22
|
+
RUN_ID = "spc_evidence_first_snapshot_v1"
|
|
23
|
+
FIXTURE_DISCLAIMER = (
|
|
24
|
+
"A checksummed 24-record offline snapshot of real public records. "
|
|
25
|
+
"It demonstrates a reproducible workflow, not the scale of the 5.34M-row source dataset."
|
|
26
|
+
)
|
|
27
|
+
ML_BOUNDARY = (
|
|
28
|
+
"CHGNet relaxation is ML-based proposal support, not a DFT or experimental measurement."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
DEFAULT_OBJECTIVES: dict[str, dict[str, float | str]] = {
|
|
32
|
+
"density": {"direction": "minimize", "weight": 0.6},
|
|
33
|
+
"energy_above_hull": {"direction": "minimize", "weight": 0.4},
|
|
34
|
+
}
|
|
35
|
+
DEFAULT_CONSTRAINTS: dict[str, dict[str, float]] = {
|
|
36
|
+
"energy_above_hull": {"max": 0.05},
|
|
37
|
+
"max_force": {"max": 0.2},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def repo_root() -> Path:
|
|
42
|
+
return Path(__file__).resolve().parents[4]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fixture_path() -> Path:
|
|
46
|
+
return repo_root() / FIXTURE_RELATIVE_PATH
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def chgnet_reference_path() -> Path:
|
|
50
|
+
return repo_root() / CHGNET_REFERENCE_RELATIVE_PATH
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@lru_cache(maxsize=1)
|
|
54
|
+
def get_demo_artifact() -> dict[str, Any]:
|
|
55
|
+
return json.loads(fixture_path().read_text())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_demo_manifest() -> dict[str, Any]:
|
|
59
|
+
return dict(get_demo_artifact()["manifest"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@lru_cache(maxsize=1)
|
|
63
|
+
def get_demo_dataset() -> MatterGraphDataset:
|
|
64
|
+
artifact = get_demo_artifact()
|
|
65
|
+
dataset = LeMatBulk.from_records(
|
|
66
|
+
artifact["records"],
|
|
67
|
+
source_dataset=SOURCE_DATASET,
|
|
68
|
+
subset=SOURCE_SUBSET,
|
|
69
|
+
)
|
|
70
|
+
dataset.metadata["snapshot_manifest"] = artifact["manifest"]
|
|
71
|
+
return dataset
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_filtered_demo_dataset() -> MatterGraphDataset:
|
|
75
|
+
return (
|
|
76
|
+
get_demo_dataset()
|
|
77
|
+
.filter_elements(include=["Ti", "Al", "N"])
|
|
78
|
+
.filter_complexity(
|
|
79
|
+
max_nsites=16,
|
|
80
|
+
max_nelements=3,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@lru_cache(maxsize=1)
|
|
86
|
+
def get_demo_store() -> MaterialStore:
|
|
87
|
+
return get_demo_dataset().to_material_store()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_default_scorecard() -> Scorecard:
|
|
91
|
+
return Scorecard(
|
|
92
|
+
objectives=DEFAULT_OBJECTIVES, # type: ignore[arg-type]
|
|
93
|
+
constraints=DEFAULT_CONSTRAINTS,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_default_material_id() -> str:
|
|
98
|
+
ranked = get_default_scorecard().rank(get_demo_store().materials)
|
|
99
|
+
if ranked.empty:
|
|
100
|
+
return get_demo_store().materials[0].material_id
|
|
101
|
+
return str(ranked.iloc[0]["material_id"])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def graph_summary(material: Material, *, max_edges: int = 256) -> dict[str, Any]:
|
|
105
|
+
if material.structure is None:
|
|
106
|
+
msg = "material structure missing; graph export excluded this record"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
|
|
109
|
+
builder = CrystalGraphBuilder(cutoff_radius=5.0, max_neighbors=12)
|
|
110
|
+
graph = builder.build(material.structure)
|
|
111
|
+
edge_count = int(graph.edge_index.shape[1])
|
|
112
|
+
kept_edges = min(edge_count, max(0, min(max_edges, 256)))
|
|
113
|
+
edges = []
|
|
114
|
+
for index in range(kept_edges):
|
|
115
|
+
source = int(graph.edge_index[0, index])
|
|
116
|
+
target = int(graph.edge_index[1, index])
|
|
117
|
+
source_cartesian = graph.cartesian_coordinates[source]
|
|
118
|
+
displacement = graph.displacement_vectors[index]
|
|
119
|
+
target_cartesian = source_cartesian + displacement
|
|
120
|
+
edges.append(
|
|
121
|
+
{
|
|
122
|
+
"source": source,
|
|
123
|
+
"target": target,
|
|
124
|
+
"distance": float(graph.edge_features[index, 0]),
|
|
125
|
+
"image": [int(value) for value in graph.image_offsets[index].tolist()],
|
|
126
|
+
"source_cartesian": [float(value) for value in source_cartesian],
|
|
127
|
+
"target_cartesian": [float(value) for value in target_cartesian],
|
|
128
|
+
"displacement_cartesian": [float(value) for value in displacement],
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
all_edges = [
|
|
133
|
+
(
|
|
134
|
+
int(graph.edge_index[0, index]),
|
|
135
|
+
int(graph.edge_index[1, index]),
|
|
136
|
+
tuple(int(value) for value in graph.image_offsets[index]),
|
|
137
|
+
float(graph.edge_features[index, 0]),
|
|
138
|
+
)
|
|
139
|
+
for index in range(edge_count)
|
|
140
|
+
]
|
|
141
|
+
edge_keys = {(source, target, image) for source, target, image, _distance in all_edges}
|
|
142
|
+
reciprocal = all(
|
|
143
|
+
(target, source, tuple(-value for value in image)) in edge_keys
|
|
144
|
+
for source, target, image, _distance in all_edges
|
|
145
|
+
)
|
|
146
|
+
zero_distance_count = sum(distance <= 1e-8 for *_edge, distance in all_edges)
|
|
147
|
+
displacement_consistent = all(
|
|
148
|
+
abs(float(graph.edge_features[index, 0]) - float(_norm(graph.displacement_vectors[index])))
|
|
149
|
+
<= 1e-8
|
|
150
|
+
for index in range(edge_count)
|
|
151
|
+
)
|
|
152
|
+
coordination_numbers = _coordination_numbers(all_edges, graph.num_atoms)
|
|
153
|
+
warnings = []
|
|
154
|
+
if not reciprocal:
|
|
155
|
+
warnings.append("graph is not reciprocal")
|
|
156
|
+
if zero_distance_count:
|
|
157
|
+
warnings.append(f"{zero_distance_count} zero-distance edges")
|
|
158
|
+
if graph.info["truncated_sources"]:
|
|
159
|
+
warnings.append("one or more neighbor lists were truncated after a complete distance shell")
|
|
160
|
+
if kept_edges < edge_count:
|
|
161
|
+
warnings.append("rendering geometry is capped at 256 edges")
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"material_id": material.material_id,
|
|
165
|
+
"formula": material.formula,
|
|
166
|
+
"nodes": [
|
|
167
|
+
{
|
|
168
|
+
"index": index,
|
|
169
|
+
"species": species,
|
|
170
|
+
"fractional_coordinates": [float(value) for value in coords],
|
|
171
|
+
"cartesian_coordinates": [
|
|
172
|
+
float(value) for value in graph.cartesian_coordinates[index].tolist()
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
for index, (species, coords) in enumerate(
|
|
176
|
+
zip(material.structure.species, material.structure.coords, strict=True)
|
|
177
|
+
)
|
|
178
|
+
],
|
|
179
|
+
"edges": edges,
|
|
180
|
+
"edge_count": edge_count,
|
|
181
|
+
"edges_truncated": kept_edges < edge_count,
|
|
182
|
+
"lattice_vectors": [
|
|
183
|
+
[float(value) for value in vector] for vector in graph.cell.tolist()
|
|
184
|
+
] if graph.cell is not None else [],
|
|
185
|
+
"distance_shells": _distance_shells(all_edges),
|
|
186
|
+
"coordination_numbers": coordination_numbers,
|
|
187
|
+
"node_feature_shape": [int(value) for value in graph.node_features.shape],
|
|
188
|
+
"edge_feature_shape": [int(value) for value in graph.edge_features.shape],
|
|
189
|
+
"global_features": graph.global_features,
|
|
190
|
+
"builder": graph.info,
|
|
191
|
+
"validation": {
|
|
192
|
+
"state": (
|
|
193
|
+
"valid"
|
|
194
|
+
if reciprocal and not zero_distance_count and displacement_consistent
|
|
195
|
+
else "invalid"
|
|
196
|
+
),
|
|
197
|
+
"ordered_structure": True,
|
|
198
|
+
"reciprocal": reciprocal,
|
|
199
|
+
"zero_distance_edges": zero_distance_count,
|
|
200
|
+
"displacement_consistent": displacement_consistent,
|
|
201
|
+
"complete_tied_shells": True,
|
|
202
|
+
"symmetry": {
|
|
203
|
+
"status": "determined" if graph.global_features["spacegroup_number"] else "unknown",
|
|
204
|
+
"spacegroup_number": graph.global_features["spacegroup_number"],
|
|
205
|
+
},
|
|
206
|
+
"truncated": bool(graph.info["truncated_sources"]),
|
|
207
|
+
"warnings": warnings,
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _norm(vector: Any) -> float:
|
|
213
|
+
return sum(float(value) ** 2 for value in vector) ** 0.5
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _coordination_numbers(
|
|
217
|
+
edges: list[tuple[int, int, tuple[int, int, int], float]],
|
|
218
|
+
atom_count: int,
|
|
219
|
+
) -> list[int]:
|
|
220
|
+
coordination: list[int] = []
|
|
221
|
+
for atom in range(atom_count):
|
|
222
|
+
distances = [distance for source, _target, _image, distance in edges if source == atom]
|
|
223
|
+
if not distances:
|
|
224
|
+
coordination.append(0)
|
|
225
|
+
continue
|
|
226
|
+
first = min(distances)
|
|
227
|
+
coordination.append(sum(distance <= first + 0.1 for distance in distances))
|
|
228
|
+
return coordination
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _distance_shells(
|
|
232
|
+
edges: list[tuple[int, int, tuple[int, int, int], float]],
|
|
233
|
+
) -> list[dict[str, float | int]]:
|
|
234
|
+
distances = sorted(distance for _source, _target, _image, distance in edges)
|
|
235
|
+
shells: list[list[float]] = []
|
|
236
|
+
for distance in distances:
|
|
237
|
+
if not shells or distance - shells[-1][-1] > 0.1:
|
|
238
|
+
shells.append([distance])
|
|
239
|
+
else:
|
|
240
|
+
shells[-1].append(distance)
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
"index": index + 1,
|
|
244
|
+
"distance": sum(shell) / len(shell),
|
|
245
|
+
"directed_edge_count": len(shell),
|
|
246
|
+
}
|
|
247
|
+
for index, shell in enumerate(shells)
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def simulation_readiness(material: Material) -> dict[str, Any]:
|
|
252
|
+
ase_available = importlib.util.find_spec("ase") is not None
|
|
253
|
+
unsupported = sorted(set(material.elements) - set(EMT_SUPPORTED_SPECIES))
|
|
254
|
+
structure_present = material.structure is not None
|
|
255
|
+
ready = ase_available and structure_present and not unsupported
|
|
256
|
+
if not ase_available:
|
|
257
|
+
reason = "ASE is not installed in this environment."
|
|
258
|
+
elif not structure_present:
|
|
259
|
+
reason = "No crystal structure is available for relaxation."
|
|
260
|
+
elif unsupported:
|
|
261
|
+
reason = f"EMT does not support: {', '.join(unsupported)}."
|
|
262
|
+
else:
|
|
263
|
+
reason = "ASE/EMT supports every species in this structure."
|
|
264
|
+
return {
|
|
265
|
+
"ready": ready,
|
|
266
|
+
"ase_available": ase_available,
|
|
267
|
+
"calculator": "emt",
|
|
268
|
+
"unsupported_species": unsupported,
|
|
269
|
+
"reason": reason,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@lru_cache(maxsize=1)
|
|
274
|
+
def get_chgnet_reference_artifact() -> dict[str, Any] | None:
|
|
275
|
+
path = chgnet_reference_path()
|
|
276
|
+
if not path.is_file():
|
|
277
|
+
return None
|
|
278
|
+
artifact = json.loads(path.read_text())
|
|
279
|
+
artifact["scientific_boundary"] = ML_BOUNDARY
|
|
280
|
+
return artifact
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def chgnet_state() -> dict[str, Any]:
|
|
284
|
+
reference = get_chgnet_reference_artifact()
|
|
285
|
+
if reference is None:
|
|
286
|
+
return {
|
|
287
|
+
"state": "unavailable",
|
|
288
|
+
"live_available": False,
|
|
289
|
+
"reference_available": False,
|
|
290
|
+
"detail": "No verified local CHGNet artifact is bundled.",
|
|
291
|
+
"scientific_boundary": ML_BOUNDARY,
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
"state": "cached_only",
|
|
295
|
+
"live_available": False,
|
|
296
|
+
"reference_available": True,
|
|
297
|
+
"reference_material_id": reference["material_id"],
|
|
298
|
+
"model_version": reference["model"]["version"],
|
|
299
|
+
"detail": "A versioned cached reference is available; live execution is not enabled.",
|
|
300
|
+
"scientific_boundary": ML_BOUNDARY,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def capability_catalog() -> list[dict[str, Any]]:
|
|
305
|
+
return [
|
|
306
|
+
_cap(
|
|
307
|
+
"lematerial",
|
|
308
|
+
"LeMaterial adapter",
|
|
309
|
+
"workflow",
|
|
310
|
+
"demo_ready",
|
|
311
|
+
"/workflows/lematerial/demo",
|
|
312
|
+
),
|
|
313
|
+
_cap(
|
|
314
|
+
"candidate_slices",
|
|
315
|
+
"Candidate slicing + guardrails",
|
|
316
|
+
"workflow",
|
|
317
|
+
"demo_ready",
|
|
318
|
+
"CandidateSlice.report",
|
|
319
|
+
),
|
|
320
|
+
_cap(
|
|
321
|
+
"crystal_graphs",
|
|
322
|
+
"Crystal graph export",
|
|
323
|
+
"graphs",
|
|
324
|
+
"demo_ready",
|
|
325
|
+
"/materials/{id}/graph-summary",
|
|
326
|
+
),
|
|
327
|
+
_cap(
|
|
328
|
+
"benchmark_frames",
|
|
329
|
+
"Benchmark-ready frames",
|
|
330
|
+
"benchmarks",
|
|
331
|
+
"demo_ready",
|
|
332
|
+
"MatterGraphDataset.to_benchmark_frame",
|
|
333
|
+
),
|
|
334
|
+
_cap(
|
|
335
|
+
"scorecard_audit",
|
|
336
|
+
"Transparent scorecard audit",
|
|
337
|
+
"ranking",
|
|
338
|
+
"demo_ready",
|
|
339
|
+
"/scores/rank/audit",
|
|
340
|
+
),
|
|
341
|
+
_cap(
|
|
342
|
+
"local_workbench",
|
|
343
|
+
"Ephemeral local contributor workbench",
|
|
344
|
+
"workflow",
|
|
345
|
+
"demo_ready",
|
|
346
|
+
"/datasets/inspect + /datasets/import",
|
|
347
|
+
boundary="Local, unauthenticated, in-memory, and limited to small exploratory datasets.",
|
|
348
|
+
),
|
|
349
|
+
_cap(
|
|
350
|
+
"chgnet_reference",
|
|
351
|
+
"CHGNet reference relaxation",
|
|
352
|
+
"simulation",
|
|
353
|
+
"demo_ready",
|
|
354
|
+
"/simulations/chgnet/reference/{material_id}",
|
|
355
|
+
boundary=ML_BOUNDARY,
|
|
356
|
+
),
|
|
357
|
+
_cap(
|
|
358
|
+
"ase_relax",
|
|
359
|
+
"ASE/EMT smoke-test runner",
|
|
360
|
+
"simulation",
|
|
361
|
+
"sdk_ready",
|
|
362
|
+
"/simulations/ase/relax",
|
|
363
|
+
optional_dependency="ase",
|
|
364
|
+
boundary="Retained for SDK smoke testing; EMT is not evidence for Ti–Al–N.",
|
|
365
|
+
),
|
|
366
|
+
_cap(
|
|
367
|
+
"materials_project",
|
|
368
|
+
"Materials Project",
|
|
369
|
+
"connectors",
|
|
370
|
+
"sdk_ready",
|
|
371
|
+
"MaterialsProjectConnector",
|
|
372
|
+
optional_dependency="mp-api",
|
|
373
|
+
),
|
|
374
|
+
_cap(
|
|
375
|
+
"jarvis",
|
|
376
|
+
"JARVIS-DFT",
|
|
377
|
+
"connectors",
|
|
378
|
+
"sdk_ready",
|
|
379
|
+
"JarvisConnector",
|
|
380
|
+
optional_dependency="jarvis-tools",
|
|
381
|
+
),
|
|
382
|
+
_cap("nomad", "NOMAD public metadata", "connectors", "sdk_ready", "NOMADConnector"),
|
|
383
|
+
_cap("optimade", "OPTIMADE / OQMD", "connectors", "sdk_ready", "OptimadeConnector"),
|
|
384
|
+
_cap(
|
|
385
|
+
"local_csv",
|
|
386
|
+
"Bounded local CSV / JSONL import",
|
|
387
|
+
"connectors",
|
|
388
|
+
"sdk_ready",
|
|
389
|
+
"inspect_local_content + import_local_content",
|
|
390
|
+
),
|
|
391
|
+
_cap(
|
|
392
|
+
"connector_http_policy",
|
|
393
|
+
"Connector HTTP resilience policy",
|
|
394
|
+
"connectors",
|
|
395
|
+
"sdk_ready",
|
|
396
|
+
"ConnectorHTTPPolicy",
|
|
397
|
+
),
|
|
398
|
+
_cap(
|
|
399
|
+
"generated_schemas",
|
|
400
|
+
"Pydantic-generated JSON Schemas",
|
|
401
|
+
"schema",
|
|
402
|
+
"sdk_ready",
|
|
403
|
+
"scripts/generate_schemas.py --check",
|
|
404
|
+
),
|
|
405
|
+
_cap(
|
|
406
|
+
"simulation_result_envelope",
|
|
407
|
+
"Simulation result interchange",
|
|
408
|
+
"simulation",
|
|
409
|
+
"sdk_ready",
|
|
410
|
+
"SimulationResultEnvelope",
|
|
411
|
+
boundary="Result import and parsing only; no simulator orchestration.",
|
|
412
|
+
),
|
|
413
|
+
_cap(
|
|
414
|
+
"elastic", "Derived elasticity", "derived", "sdk_ready", "mattergraph.derived.elastic"
|
|
415
|
+
),
|
|
416
|
+
_cap(
|
|
417
|
+
"benchmark_utilities",
|
|
418
|
+
"Metrics, splits + uncertainty",
|
|
419
|
+
"benchmarks",
|
|
420
|
+
"sdk_ready",
|
|
421
|
+
"mattergraph-benchmarks",
|
|
422
|
+
optional_dependency="scikit-learn / matbench",
|
|
423
|
+
),
|
|
424
|
+
_cap(
|
|
425
|
+
"oqmd_native",
|
|
426
|
+
"Native OQMD connector",
|
|
427
|
+
"connectors",
|
|
428
|
+
"stub",
|
|
429
|
+
"OQMDStubConnector",
|
|
430
|
+
boundary="Use the working OPTIMADE OQMD provider instead.",
|
|
431
|
+
),
|
|
432
|
+
_cap("lammps", "LAMMPS runner", "simulation", "stub", "run_lammps"),
|
|
433
|
+
_cap(
|
|
434
|
+
"quantum_espresso",
|
|
435
|
+
"Quantum ESPRESSO runner",
|
|
436
|
+
"simulation",
|
|
437
|
+
"stub",
|
|
438
|
+
"run_quantum_espresso",
|
|
439
|
+
),
|
|
440
|
+
_cap(
|
|
441
|
+
"persistence",
|
|
442
|
+
"Persistent workflow database",
|
|
443
|
+
"platform",
|
|
444
|
+
"out_of_scope",
|
|
445
|
+
"open-source demo uses an in-memory store",
|
|
446
|
+
),
|
|
447
|
+
_cap(
|
|
448
|
+
"production_ranking",
|
|
449
|
+
"Production ranking + orchestration",
|
|
450
|
+
"platform",
|
|
451
|
+
"out_of_scope",
|
|
452
|
+
"not part of the public baseline",
|
|
453
|
+
),
|
|
454
|
+
_cap(
|
|
455
|
+
"active_learning",
|
|
456
|
+
"Active-learning operations",
|
|
457
|
+
"platform",
|
|
458
|
+
"out_of_scope",
|
|
459
|
+
"not part of the public baseline",
|
|
460
|
+
),
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _cap(
|
|
465
|
+
capability_id: str,
|
|
466
|
+
label: str,
|
|
467
|
+
category: str,
|
|
468
|
+
status: str,
|
|
469
|
+
evidence: str,
|
|
470
|
+
*,
|
|
471
|
+
optional_dependency: str | None = None,
|
|
472
|
+
boundary: str | None = None,
|
|
473
|
+
) -> dict[str, Any]:
|
|
474
|
+
return {
|
|
475
|
+
"id": capability_id,
|
|
476
|
+
"label": label,
|
|
477
|
+
"category": category,
|
|
478
|
+
"status": status,
|
|
479
|
+
"evidence": evidence,
|
|
480
|
+
"optional_dependency": optional_dependency,
|
|
481
|
+
"boundary": boundary,
|
|
482
|
+
}
|