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 @@
|
|
|
1
|
+
"""MatterGraph demo API package."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
model_config = SettingsConfigDict(env_prefix="MATTERGRAPH_", extra="ignore")
|
|
9
|
+
|
|
10
|
+
demo_data: Path = Path(
|
|
11
|
+
os.environ.get("MATTERGRAPH_DEMO_DATA", "data/demo/materials_sample.jsonl")
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
settings = Settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
mattergraph_api/main.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from mattergraph_connectors.local_import import ImportLimitError, ImportValidationError
|
|
7
|
+
|
|
8
|
+
from mattergraph_api.routes import datasets, demo, materials, scores, search, simulations, workflows
|
|
9
|
+
from mattergraph_api.services.dataset_registry import (
|
|
10
|
+
DatasetBusyError,
|
|
11
|
+
DatasetCapacityError,
|
|
12
|
+
DatasetNotFoundError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
app = FastAPI(
|
|
16
|
+
title="MatterGraph API",
|
|
17
|
+
description=(
|
|
18
|
+
"Evidence-first demo API for provenanced materials records, reciprocal graph summaries, "
|
|
19
|
+
"audited ranking, and explicitly labeled simulation evidence."
|
|
20
|
+
),
|
|
21
|
+
version="0.1.0",
|
|
22
|
+
)
|
|
23
|
+
app.add_middleware(
|
|
24
|
+
CORSMiddleware,
|
|
25
|
+
allow_origins=["*"],
|
|
26
|
+
allow_credentials=True,
|
|
27
|
+
allow_methods=["*"],
|
|
28
|
+
allow_headers=["*"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
app.include_router(materials.router, tags=["materials"])
|
|
32
|
+
app.include_router(search.router, tags=["search"])
|
|
33
|
+
app.include_router(scores.router, tags=["scores"])
|
|
34
|
+
app.include_router(simulations.router, tags=["simulations"])
|
|
35
|
+
app.include_router(workflows.router, tags=["workflows"])
|
|
36
|
+
app.include_router(demo.router, tags=["demo"])
|
|
37
|
+
app.include_router(datasets.router, tags=["datasets"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.exception_handler(DatasetNotFoundError)
|
|
41
|
+
def dataset_not_found(_request: Request, error: DatasetNotFoundError) -> JSONResponse:
|
|
42
|
+
return JSONResponse(
|
|
43
|
+
status_code=404,
|
|
44
|
+
content={
|
|
45
|
+
"detail": str(error),
|
|
46
|
+
"code": "dataset_evicted" if error.evicted else "dataset_not_found",
|
|
47
|
+
"dataset_id": error.dataset_id,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.exception_handler(DatasetBusyError)
|
|
53
|
+
def dataset_busy(_request: Request, error: DatasetBusyError) -> JSONResponse:
|
|
54
|
+
return JSONResponse(status_code=409, content={"detail": str(error), "code": "dataset_busy"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.exception_handler(ImportLimitError)
|
|
58
|
+
@app.exception_handler(DatasetCapacityError)
|
|
59
|
+
def dataset_limit(_request: Request, error: Exception) -> JSONResponse:
|
|
60
|
+
return JSONResponse(status_code=413, content={"detail": str(error), "code": "dataset_limit"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.exception_handler(ImportValidationError)
|
|
64
|
+
def invalid_import(_request: Request, error: ImportValidationError) -> JSONResponse:
|
|
65
|
+
return JSONResponse(
|
|
66
|
+
status_code=422,
|
|
67
|
+
content={
|
|
68
|
+
"detail": str(error),
|
|
69
|
+
"code": "invalid_import",
|
|
70
|
+
"report": error.report.model_dump(mode="json"),
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.get("/health")
|
|
76
|
+
def health() -> dict[str, str]:
|
|
77
|
+
return {"status": "ok"}
|
mattergraph_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API routes."""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Response
|
|
6
|
+
from mattergraph import MaterialStore
|
|
7
|
+
from mattergraph.datasets import DeduplicationBasis, MatterGraphDataset
|
|
8
|
+
from mattergraph_connectors.local_import import (
|
|
9
|
+
DatasetImportMapping,
|
|
10
|
+
ImportReport,
|
|
11
|
+
ImportResult,
|
|
12
|
+
import_local_content,
|
|
13
|
+
inspect_local_content,
|
|
14
|
+
)
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from mattergraph_api.services.dataset_registry import dataset_registry
|
|
18
|
+
from mattergraph_api.services.demo_service import graph_summary
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/datasets")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InspectRequest(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
filename: str
|
|
27
|
+
format: Literal["csv", "jsonl"]
|
|
28
|
+
content: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ImportRequest(InspectRequest):
|
|
32
|
+
mapping: DatasetImportMapping | None = None
|
|
33
|
+
error_policy: Literal["reject_file", "skip_invalid_rows"] = "reject_file"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SlicePreviewRequest(BaseModel):
|
|
37
|
+
model_config = ConfigDict(extra="forbid")
|
|
38
|
+
|
|
39
|
+
include_elements: list[str] = Field(default_factory=list)
|
|
40
|
+
exclude_elements: list[str] = Field(default_factory=list)
|
|
41
|
+
max_nsites: int | None = Field(default=None, ge=1)
|
|
42
|
+
max_nelements: int | None = Field(default=None, ge=1)
|
|
43
|
+
target: str | None = None
|
|
44
|
+
deduplication_basis: DeduplicationBasis = "immutable_id"
|
|
45
|
+
allow_mixed_functionals: bool = False
|
|
46
|
+
allow_duplicate_records: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("")
|
|
50
|
+
def list_datasets() -> dict[str, Any]:
|
|
51
|
+
return {"datasets": dataset_registry.list(), "registry": dataset_registry.stats()}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.post("/inspect", response_model=ImportReport)
|
|
55
|
+
def inspect_dataset(request: InspectRequest) -> ImportReport:
|
|
56
|
+
return inspect_local_content(
|
|
57
|
+
filename=request.filename,
|
|
58
|
+
format=request.format,
|
|
59
|
+
content=request.content,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/import", response_model=ImportResult)
|
|
64
|
+
def import_dataset(request: ImportRequest) -> ImportResult:
|
|
65
|
+
imported = import_local_content(
|
|
66
|
+
filename=request.filename,
|
|
67
|
+
format=request.format,
|
|
68
|
+
content=request.content,
|
|
69
|
+
mapping=request.mapping,
|
|
70
|
+
error_policy=request.error_policy,
|
|
71
|
+
)
|
|
72
|
+
dataset_registry.register(
|
|
73
|
+
imported.result.manifest,
|
|
74
|
+
imported.normalized_jsonl,
|
|
75
|
+
)
|
|
76
|
+
return imported.result.model_copy(
|
|
77
|
+
update={
|
|
78
|
+
"manifest": imported.result.manifest.model_copy(
|
|
79
|
+
update={"degraded": imported.result.manifest.degraded}
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.get("/{dataset_id}")
|
|
86
|
+
def get_dataset(dataset_id: str) -> dict[str, Any]:
|
|
87
|
+
return dataset_registry.status(dataset_id)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.delete("/{dataset_id}")
|
|
91
|
+
def delete_dataset(dataset_id: str) -> dict[str, Any]:
|
|
92
|
+
manifest = dataset_registry.delete(dataset_id)
|
|
93
|
+
return {"deleted": True, "dataset_id": manifest.dataset_id}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.get("/{dataset_id}/export")
|
|
97
|
+
def export_dataset(dataset_id: str, format: Literal["jsonl"] = "jsonl") -> Response:
|
|
98
|
+
manifest, payload = dataset_registry.export(dataset_id)
|
|
99
|
+
return Response(
|
|
100
|
+
content=payload,
|
|
101
|
+
media_type="application/x-ndjson",
|
|
102
|
+
headers={
|
|
103
|
+
"Content-Disposition": f'attachment; filename="{manifest.dataset_id}.jsonl"',
|
|
104
|
+
"X-MatterGraph-Dataset-Id": manifest.dataset_id,
|
|
105
|
+
"X-MatterGraph-SHA256": manifest.normalized_sha256,
|
|
106
|
+
"X-MatterGraph-Record-Count": str(manifest.record_count),
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.post("/{dataset_id}/slices/preview")
|
|
112
|
+
def preview_slice(dataset_id: str, request: SlicePreviewRequest) -> dict[str, Any]:
|
|
113
|
+
store = dataset_registry.materialize(dataset_id)
|
|
114
|
+
dataset = _dataset_from_store(dataset_id, store)
|
|
115
|
+
candidate = dataset.candidate_pool(
|
|
116
|
+
include=request.include_elements,
|
|
117
|
+
exclude=request.exclude_elements,
|
|
118
|
+
max_nsites=request.max_nsites,
|
|
119
|
+
max_nelements=request.max_nelements,
|
|
120
|
+
)
|
|
121
|
+
candidate_slice = candidate.create_slice(
|
|
122
|
+
"local_workbench_preview",
|
|
123
|
+
allow_mixed_functionals=request.allow_mixed_functionals,
|
|
124
|
+
allow_duplicate_records=request.allow_duplicate_records,
|
|
125
|
+
deduplication_basis=request.deduplication_basis,
|
|
126
|
+
target=request.target,
|
|
127
|
+
)
|
|
128
|
+
graph_ready = 0
|
|
129
|
+
graph_excluded = 0
|
|
130
|
+
material_ids = [str(value) for value in candidate_slice.frame["material_id"].tolist()]
|
|
131
|
+
materials_by_id = {material.material_id: material for material in store.materials}
|
|
132
|
+
for material_id in material_ids:
|
|
133
|
+
try:
|
|
134
|
+
graph_summary(materials_by_id[material_id], max_edges=0)
|
|
135
|
+
graph_ready += 1
|
|
136
|
+
except ValueError:
|
|
137
|
+
graph_excluded += 1
|
|
138
|
+
preview_columns = ["material_id", "formula", "nsites", "nelements"]
|
|
139
|
+
if request.target:
|
|
140
|
+
preview_columns.append(request.target)
|
|
141
|
+
frame = candidate_slice.frame
|
|
142
|
+
preview_columns = [column for column in preview_columns if column in frame.columns]
|
|
143
|
+
benchmark_preview = frame[preview_columns].head(20).where(frame.notna(), None).to_dict(
|
|
144
|
+
orient="records"
|
|
145
|
+
)
|
|
146
|
+
return {
|
|
147
|
+
"slice": candidate_slice.report(),
|
|
148
|
+
"material_ids": material_ids,
|
|
149
|
+
"graph_readiness": {"included_count": graph_ready, "excluded_count": graph_excluded},
|
|
150
|
+
"benchmark_preview": benchmark_preview,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dataset_from_store(dataset_id: str, store: MaterialStore) -> MatterGraphDataset:
|
|
155
|
+
records: list[dict[str, Any]] = []
|
|
156
|
+
property_columns: set[str] = set()
|
|
157
|
+
property_units: dict[str, str] = {}
|
|
158
|
+
for material in store.materials:
|
|
159
|
+
record: dict[str, Any] = {
|
|
160
|
+
"material_id": material.material_id,
|
|
161
|
+
"formula": material.formula,
|
|
162
|
+
"reduced_formula": material.reduced_formula,
|
|
163
|
+
"elements": material.elements,
|
|
164
|
+
"structure": material.structure,
|
|
165
|
+
"immutable_id": material.material_id,
|
|
166
|
+
"provenance": material.provenance,
|
|
167
|
+
}
|
|
168
|
+
for property_value in material.properties:
|
|
169
|
+
record[property_value.name] = property_value.value
|
|
170
|
+
property_columns.add(property_value.name)
|
|
171
|
+
if property_value.unit:
|
|
172
|
+
property_units.setdefault(property_value.name, property_value.unit)
|
|
173
|
+
records.append(record)
|
|
174
|
+
return MatterGraphDataset.from_records(
|
|
175
|
+
records,
|
|
176
|
+
source_dataset=dataset_id,
|
|
177
|
+
source_subset="local_import",
|
|
178
|
+
metadata={
|
|
179
|
+
"default_deduplication_basis": "immutable_id",
|
|
180
|
+
"property_columns": sorted(property_columns),
|
|
181
|
+
"property_units": property_units,
|
|
182
|
+
"provenance_fields": ["immutable_id", "provenance"],
|
|
183
|
+
},
|
|
184
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from mattergraph_api.services import store_service
|
|
8
|
+
from mattergraph_api.services.demo_service import (
|
|
9
|
+
DEFAULT_CONSTRAINTS,
|
|
10
|
+
DEFAULT_OBJECTIVES,
|
|
11
|
+
FIXTURE_DISCLAIMER,
|
|
12
|
+
FIXTURE_RELATIVE_PATH,
|
|
13
|
+
capability_catalog,
|
|
14
|
+
chgnet_state,
|
|
15
|
+
get_default_material_id,
|
|
16
|
+
get_default_scorecard,
|
|
17
|
+
get_demo_manifest,
|
|
18
|
+
graph_summary,
|
|
19
|
+
simulation_readiness,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/capabilities")
|
|
26
|
+
def capabilities() -> dict[str, list[dict[str, Any]]]:
|
|
27
|
+
return {"capabilities": capability_catalog()}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/demo/preflight")
|
|
31
|
+
def demo_preflight() -> dict[str, Any]:
|
|
32
|
+
store = store_service.get_store()
|
|
33
|
+
graph_ready = 0
|
|
34
|
+
graph_excluded = 0
|
|
35
|
+
graph_invalid = 0
|
|
36
|
+
for material in store.materials:
|
|
37
|
+
try:
|
|
38
|
+
summary = graph_summary(material, max_edges=0)
|
|
39
|
+
graph_ready += 1
|
|
40
|
+
if summary["validation"]["state"] != "valid":
|
|
41
|
+
graph_invalid += 1
|
|
42
|
+
except ValueError:
|
|
43
|
+
graph_excluded += 1
|
|
44
|
+
|
|
45
|
+
scorecard = get_default_scorecard()
|
|
46
|
+
score_report = scorecard.report(store.materials)
|
|
47
|
+
simulation_targets = {
|
|
48
|
+
material.material_id: simulation_readiness(material) for material in store.materials
|
|
49
|
+
}
|
|
50
|
+
default_material_id = get_default_material_id()
|
|
51
|
+
manifest = get_demo_manifest()
|
|
52
|
+
ml_state = chgnet_state()
|
|
53
|
+
checks = [
|
|
54
|
+
{
|
|
55
|
+
"id": "fixture",
|
|
56
|
+
"status": "pass" if store.materials else "fail",
|
|
57
|
+
"detail": f"{len(store.materials)} normalized records",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "graphs",
|
|
61
|
+
"status": "pass" if graph_ready and not graph_invalid else "fail",
|
|
62
|
+
"detail": (
|
|
63
|
+
f"{graph_ready} graph-ready; {graph_excluded} excluded; {graph_invalid} invalid"
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "ranking",
|
|
68
|
+
"status": "pass" if score_report["ranked_count"] >= 3 else "warn",
|
|
69
|
+
"detail": (
|
|
70
|
+
f"{score_report['ranked_count']} rank-eligible; "
|
|
71
|
+
f"{score_report['excluded_by_constraints']} excluded"
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "ml_reference",
|
|
76
|
+
"status": "pass" if ml_state["reference_available"] else "warn",
|
|
77
|
+
"detail": str(ml_state["detail"]),
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
overall = "ready" if all(check["status"] == "pass" for check in checks) else "degraded"
|
|
81
|
+
return {
|
|
82
|
+
"status": overall,
|
|
83
|
+
"fixture": {
|
|
84
|
+
"path": FIXTURE_RELATIVE_PATH,
|
|
85
|
+
"kind": "checksummed_real_snapshot",
|
|
86
|
+
"disclaimer": FIXTURE_DISCLAIMER,
|
|
87
|
+
"dataset": manifest["dataset"],
|
|
88
|
+
"subset": manifest["subset"],
|
|
89
|
+
"upstream_revision": manifest["upstream_revision"],
|
|
90
|
+
"hull_dataset": manifest["hull_dataset"],
|
|
91
|
+
"hull_revision": manifest["hull_revision"],
|
|
92
|
+
"license": manifest["license"],
|
|
93
|
+
"citation_doi": manifest["citation_doi"],
|
|
94
|
+
"snapshot_sha256": manifest["snapshot_sha256"],
|
|
95
|
+
"source_population": manifest["source_population"],
|
|
96
|
+
"field_sources": manifest["field_sources"],
|
|
97
|
+
},
|
|
98
|
+
"record_count": len(store.materials),
|
|
99
|
+
"graph": {
|
|
100
|
+
"included_count": graph_ready,
|
|
101
|
+
"excluded_count": graph_excluded,
|
|
102
|
+
"invalid_count": graph_invalid,
|
|
103
|
+
"validation_state": "valid" if graph_ready and not graph_invalid else "invalid",
|
|
104
|
+
},
|
|
105
|
+
"ranking": {
|
|
106
|
+
"ranked_count": score_report["ranked_count"],
|
|
107
|
+
"excluded_by_constraints": score_report["excluded_by_constraints"],
|
|
108
|
+
"binary_normalization": score_report["binary_normalization"],
|
|
109
|
+
"objectives": DEFAULT_OBJECTIVES,
|
|
110
|
+
"constraints": DEFAULT_CONSTRAINTS,
|
|
111
|
+
},
|
|
112
|
+
"default_material_id": default_material_id,
|
|
113
|
+
"chgnet": ml_state,
|
|
114
|
+
"simulation_targets": simulation_targets,
|
|
115
|
+
"checks": checks,
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
4
|
+
from mattergraph import Material
|
|
5
|
+
|
|
6
|
+
from mattergraph_api.services import store_service
|
|
7
|
+
from mattergraph_api.services.demo_service import graph_summary
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/materials")
|
|
13
|
+
def list_materials(dataset_id: str | None = Query(default=None)) -> list[dict]:
|
|
14
|
+
store = store_service.resolve_store(dataset_id)
|
|
15
|
+
return [m.model_dump() for m in store.materials]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/materials/{mid}")
|
|
19
|
+
def get_material(mid: str, dataset_id: str | None = Query(default=None)) -> dict:
|
|
20
|
+
store = store_service.resolve_store(dataset_id)
|
|
21
|
+
m: Material | None = store.get(mid)
|
|
22
|
+
if m is None:
|
|
23
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
24
|
+
return m.model_dump()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/materials/{mid}/graph-summary")
|
|
28
|
+
def get_material_graph_summary(
|
|
29
|
+
mid: str, dataset_id: str | None = Query(default=None)
|
|
30
|
+
) -> dict:
|
|
31
|
+
store = store_service.resolve_store(dataset_id)
|
|
32
|
+
material: Material | None = store.get(mid)
|
|
33
|
+
if material is None:
|
|
34
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
35
|
+
try:
|
|
36
|
+
return graph_summary(material)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from mattergraph import Scorecard
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from mattergraph_api.services import store_service
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
Direction = Literal["minimize", "maximize"]
|
|
15
|
+
MissingPolicy = Literal["worst", "neutral", "exclude"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ObjectiveConfig(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
direction: Direction = "maximize"
|
|
22
|
+
weight: float = Field(default=1.0, ge=0.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConstraintConfig(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
min: float | None = None
|
|
29
|
+
max: float | None = None
|
|
30
|
+
equals: bool | float | str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScoreRequest(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
objectives: dict[str, Direction | ObjectiveConfig] = Field(
|
|
37
|
+
default_factory=dict,
|
|
38
|
+
)
|
|
39
|
+
constraints: dict[str, ConstraintConfig] = Field(default_factory=dict)
|
|
40
|
+
weights: dict[str, float] | None = None
|
|
41
|
+
missing: MissingPolicy = "worst"
|
|
42
|
+
dataset_id: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/scores/rank")
|
|
46
|
+
def rank(request: ScoreRequest) -> list[dict]:
|
|
47
|
+
store = store_service.resolve_store(request.dataset_id)
|
|
48
|
+
sc = _scorecard(request)
|
|
49
|
+
df = sc.rank(store.materials)
|
|
50
|
+
return df.to_dict(orient="records")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post("/scores/rank/audit")
|
|
54
|
+
def rank_with_audit(request: ScoreRequest) -> dict[str, Any]:
|
|
55
|
+
store = store_service.resolve_store(request.dataset_id)
|
|
56
|
+
scorecard = _scorecard(request)
|
|
57
|
+
ranked = scorecard.rank(store.materials).to_dict(orient="records")
|
|
58
|
+
materials_by_id = {material.material_id: material for material in store.materials}
|
|
59
|
+
for row in ranked:
|
|
60
|
+
material = materials_by_id.get(str(row.get("material_id")))
|
|
61
|
+
if material is None:
|
|
62
|
+
continue
|
|
63
|
+
for property_name in request.constraints:
|
|
64
|
+
row.setdefault(property_name, material.get_numeric(property_name))
|
|
65
|
+
return {
|
|
66
|
+
"ranked": ranked,
|
|
67
|
+
"report": scorecard.report(store.materials),
|
|
68
|
+
"request": request.model_dump(mode="json"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _scorecard(request: ScoreRequest) -> Scorecard:
|
|
73
|
+
payload = request.model_dump(mode="python", exclude_none=True)
|
|
74
|
+
payload.pop("dataset_id", None)
|
|
75
|
+
return Scorecard(
|
|
76
|
+
objectives=payload["objectives"],
|
|
77
|
+
constraints=payload["constraints"],
|
|
78
|
+
weights=payload.get("weights"),
|
|
79
|
+
missing=payload["missing"],
|
|
80
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Query
|
|
4
|
+
|
|
5
|
+
from mattergraph_api.services import store_service
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/search")
|
|
11
|
+
def search(
|
|
12
|
+
element: str | None = Query(
|
|
13
|
+
default=None, description="Filter materials whose elements contain this symbol, e.g. Fe"
|
|
14
|
+
),
|
|
15
|
+
dataset_id: str | None = Query(default=None),
|
|
16
|
+
) -> list[dict]:
|
|
17
|
+
store = store_service.resolve_store(dataset_id)
|
|
18
|
+
out = []
|
|
19
|
+
for m in store.materials:
|
|
20
|
+
if not element:
|
|
21
|
+
out.append(m.model_dump())
|
|
22
|
+
continue
|
|
23
|
+
e = element.strip()
|
|
24
|
+
if e in m.elements:
|
|
25
|
+
out.append(m.model_dump())
|
|
26
|
+
return out
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from mattergraph_sim.job_spec import AseJobSpec, SimulationJob
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from mattergraph_api.services import store_service
|
|
8
|
+
from mattergraph_api.services.demo_service import get_chgnet_reference_artifact
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/simulations/chgnet/reference/{material_id}")
|
|
14
|
+
def get_chgnet_reference(material_id: str) -> dict:
|
|
15
|
+
artifact = get_chgnet_reference_artifact()
|
|
16
|
+
if artifact is None:
|
|
17
|
+
raise HTTPException(status_code=503, detail="verified CHGNet reference is unavailable")
|
|
18
|
+
if artifact["material_id"] != material_id:
|
|
19
|
+
raise HTTPException(status_code=404, detail="no CHGNet reference for this material")
|
|
20
|
+
return artifact
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RelaxRequest(BaseModel):
|
|
24
|
+
material_id: str
|
|
25
|
+
dataset_id: str | None = None
|
|
26
|
+
spec: AseJobSpec = Field(default_factory=AseJobSpec)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.post("/simulations/ase/relax")
|
|
30
|
+
def run_relax(body: RelaxRequest) -> dict:
|
|
31
|
+
try:
|
|
32
|
+
from mattergraph_sim import ase_relax
|
|
33
|
+
except ImportError as e:
|
|
34
|
+
raise HTTPException(status_code=503, detail=str(e)) from e
|
|
35
|
+
|
|
36
|
+
store = store_service.resolve_store(body.dataset_id)
|
|
37
|
+
m = store.get(body.material_id)
|
|
38
|
+
if m is None or m.structure is None:
|
|
39
|
+
raise HTTPException(status_code=400, detail="material or structure missing")
|
|
40
|
+
st = m.structure
|
|
41
|
+
job = SimulationJob(
|
|
42
|
+
spec=body.spec,
|
|
43
|
+
input_structure=st.to_json_dict(),
|
|
44
|
+
)
|
|
45
|
+
out = ase_relax(job)
|
|
46
|
+
return out.model_dump()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from mattergraph_api.services.workflow_service import (
|
|
6
|
+
LeMaterialDemoWorkflowResponse,
|
|
7
|
+
build_lematerial_demo_workflow,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get(
|
|
14
|
+
"/workflows/lematerial/demo",
|
|
15
|
+
response_model=LeMaterialDemoWorkflowResponse,
|
|
16
|
+
)
|
|
17
|
+
def lematerial_demo_workflow() -> LeMaterialDemoWorkflowResponse:
|
|
18
|
+
return build_lematerial_demo_workflow()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service layer for the demo API."""
|