prisma-api 0.3.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.
- prisma_api/__init__.py +6 -0
- prisma_api/config.py +182 -0
- prisma_api/prisma_api.py +539 -0
- prisma_api/prisma_api_v2.py +1533 -0
- prisma_api-0.3.0.dist-info/METADATA +245 -0
- prisma_api-0.3.0.dist-info/RECORD +9 -0
- prisma_api-0.3.0.dist-info/WHEEL +5 -0
- prisma_api-0.3.0.dist-info/licenses/LICENSE +674 -0
- prisma_api-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PrISMa API v2 client wrappers.
|
|
3
|
+
|
|
4
|
+
Mirrors the v2 REST surface documented in
|
|
5
|
+
integration/prisma-v2/prisma_cloud_apis_v2/api_v2_examples.md
|
|
6
|
+
|
|
7
|
+
All methods authenticate using the same API key as the v1 client.
|
|
8
|
+
List endpoints return pandas DataFrames by default; set
|
|
9
|
+
``return_format='json'`` for raw list-of-dicts output instead.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
import prisma_api
|
|
13
|
+
api = prisma_api.init() # standard v1 init
|
|
14
|
+
api.v2.get_isotherm(mof='ABEXEM', molecule='CO2')
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
import requests
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_BASE_PROD = "https://prisma-platform.org/api/v2"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PrismaAPIv2:
|
|
28
|
+
"""
|
|
29
|
+
Thin wrapper around the PrISMa v2 REST endpoints.
|
|
30
|
+
Instantiated automatically as ``api.v2`` by the v1 prisma_api class.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, key: str, dev: bool = False, dev_host_port: str = "",
|
|
34
|
+
return_format: str = "json"):
|
|
35
|
+
self._key = key
|
|
36
|
+
self._dev = dev
|
|
37
|
+
self._dev_host_port = dev_host_port
|
|
38
|
+
self._return_format = return_format # 'dataframe' | 'json'
|
|
39
|
+
|
|
40
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def _base_url(self) -> str:
|
|
43
|
+
if self._dev:
|
|
44
|
+
return f"http://localhost:{self._dev_host_port}/api/v2"
|
|
45
|
+
return _BASE_PROD
|
|
46
|
+
|
|
47
|
+
def set_return_format(self, fmt: str) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Set the output format for all list endpoints.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
fmt: ``'dataframe'`` (default) — return ``pd.DataFrame``.
|
|
53
|
+
``'json'`` — return a plain ``list[dict]``.
|
|
54
|
+
"""
|
|
55
|
+
if fmt not in ("dataframe", "json"):
|
|
56
|
+
raise ValueError("return_format must be 'dataframe' or 'json'")
|
|
57
|
+
self._return_format = fmt
|
|
58
|
+
|
|
59
|
+
def _headers(self) -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"X-API-Key": self._key,
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def _get(self, path: str, params: dict | None = None) -> Any:
|
|
66
|
+
"""GET request to the v2 API."""
|
|
67
|
+
url = (
|
|
68
|
+
f"http://localhost:{self._dev_host_port}/api/v2{path}"
|
|
69
|
+
if self._dev
|
|
70
|
+
else f"{_BASE_PROD}{path}"
|
|
71
|
+
)
|
|
72
|
+
resp = requests.get(url, params=params, headers=self._headers(), timeout=60)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return resp.json()
|
|
75
|
+
|
|
76
|
+
def _put(self, path: str, data: list) -> dict:
|
|
77
|
+
"""PUT (upsert) request."""
|
|
78
|
+
url = (
|
|
79
|
+
f"http://localhost:{self._dev_host_port}/api/v2{path}"
|
|
80
|
+
if self._dev
|
|
81
|
+
else f"{_BASE_PROD}{path}"
|
|
82
|
+
)
|
|
83
|
+
resp = requests.put(url, json=data, headers=self._headers(), timeout=120)
|
|
84
|
+
resp.raise_for_status()
|
|
85
|
+
return resp.json()
|
|
86
|
+
|
|
87
|
+
def _to_df(self, response: Any, key: str = "results") -> "pd.DataFrame | list":
|
|
88
|
+
"""Convert a list-endpoint response envelope to a DataFrame or list of dicts."""
|
|
89
|
+
records = response.get(key, response) if isinstance(response, dict) else response
|
|
90
|
+
records = records or []
|
|
91
|
+
if self._return_format == "json":
|
|
92
|
+
return records
|
|
93
|
+
return pd.DataFrame(records) if records else pd.DataFrame()
|
|
94
|
+
|
|
95
|
+
def _resolve_cif_url_df(self, data: "pd.DataFrame | list") -> "pd.DataFrame | list":
|
|
96
|
+
"""Prepend the base URL to any relative cif_url values in a DataFrame or list of dicts."""
|
|
97
|
+
base = self._base_url().rstrip("/").rsplit("/api/v2", 1)[0]
|
|
98
|
+
if isinstance(data, pd.DataFrame):
|
|
99
|
+
if "cif_url" not in data.columns:
|
|
100
|
+
return data
|
|
101
|
+
data = data.copy()
|
|
102
|
+
data["cif_url"] = data["cif_url"].apply(
|
|
103
|
+
lambda v: f"{base}{v}" if isinstance(v, str) and v.startswith("/") else v
|
|
104
|
+
)
|
|
105
|
+
return data
|
|
106
|
+
# list of dicts
|
|
107
|
+
return [
|
|
108
|
+
{**r, "cif_url": f"{base}{r['cif_url']}"}
|
|
109
|
+
if isinstance(r.get("cif_url"), str) and r["cif_url"].startswith("/")
|
|
110
|
+
else r
|
|
111
|
+
for r in data
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def _resolve_cif_url_dict(self, d: dict) -> dict:
|
|
115
|
+
"""Prepend the base URL to a relative cif_url value in a detail dict."""
|
|
116
|
+
if isinstance(d.get("cif_url"), str) and d["cif_url"].startswith("/"):
|
|
117
|
+
base = self._base_url().rstrip("/").rsplit("/api/v2", 1)[0]
|
|
118
|
+
d = {**d, "cif_url": f"{base}{d['cif_url']}"}
|
|
119
|
+
return d
|
|
120
|
+
|
|
121
|
+
# ── Health ────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def health(self) -> dict:
|
|
124
|
+
"""GET /api/v2/health/ — returns status dict."""
|
|
125
|
+
return self._get("/health/")
|
|
126
|
+
|
|
127
|
+
def get_flowsheet(self, name: str = "dac_min") -> dict:
|
|
128
|
+
"""
|
|
129
|
+
GET /api/v2/flowsheets/{name}/
|
|
130
|
+
|
|
131
|
+
Returns the DB-normalised flowsheet payload for the named object.
|
|
132
|
+
"""
|
|
133
|
+
return self._get(f"/flowsheets/{name}/")
|
|
134
|
+
|
|
135
|
+
# ── Catalog ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def list_materials(self, name: str | None = None,
|
|
138
|
+
limit: int = 10_000) -> pd.DataFrame:
|
|
139
|
+
"""
|
|
140
|
+
GET /api/v2/materials/
|
|
141
|
+
|
|
142
|
+
Fetches all matching materials using an internal paginate-in-loop
|
|
143
|
+
strategy (page size 500) so that result sets larger than the server
|
|
144
|
+
default are returned transparently.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
name: Case-insensitive substring filter on material name.
|
|
148
|
+
limit: Maximum total records to return across all pages
|
|
149
|
+
(default 10 000). Pass ``limit=0`` for no cap.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of materials (format controlled by ``set_return_format``).
|
|
153
|
+
Each record includes the following fields:
|
|
154
|
+
|
|
155
|
+
* ``id`` / ``name`` / ``cif_url``
|
|
156
|
+
* ``material_id`` — same as ``name``, used as the slug identifier
|
|
157
|
+
* ``material_backend`` — always ``'tabular_binary_iast'``
|
|
158
|
+
* ``gas_basis`` — ``['CO2','N2','H2O']`` if Water KPI data exist, else ``['CO2','N2']``
|
|
159
|
+
* ``supports_humid_ternary`` — ``None`` (reserved)
|
|
160
|
+
* ``tags`` — ``[]`` (reserved)
|
|
161
|
+
* ``provenance`` — always ``'tabular_material'``
|
|
162
|
+
* ``lifecycle`` — ``{"object_kind": "catalog", "version": "legacy.v1"}``
|
|
163
|
+
* ``metadata`` — ``{"django_tables": [...], "source": "live_db"}``
|
|
164
|
+
* ``source_path`` — ``None`` (reserved)
|
|
165
|
+
"""
|
|
166
|
+
page_size = 500
|
|
167
|
+
all_records: list = []
|
|
168
|
+
offset = 0
|
|
169
|
+
while True:
|
|
170
|
+
fetch = page_size if (limit == 0) else min(page_size, limit - len(all_records))
|
|
171
|
+
params = _compact(name=name, limit=fetch, offset=offset)
|
|
172
|
+
raw = self._get("/materials/", params)
|
|
173
|
+
page: list = raw.get("results", raw) if isinstance(raw, dict) else (raw or [])
|
|
174
|
+
all_records.extend(page)
|
|
175
|
+
if len(page) < fetch:
|
|
176
|
+
break
|
|
177
|
+
offset += fetch
|
|
178
|
+
if limit != 0 and len(all_records) >= limit:
|
|
179
|
+
break
|
|
180
|
+
server = self._base_url().rsplit("/api/v2", 1)[0]
|
|
181
|
+
print(f"{len(all_records)} materials loaded from {server}")
|
|
182
|
+
return self._resolve_cif_url_df(self._to_df({"results": all_records}))
|
|
183
|
+
|
|
184
|
+
def get_material(self, material_id: int) -> dict:
|
|
185
|
+
"""
|
|
186
|
+
GET /api/v2/materials/{material_id}/
|
|
187
|
+
|
|
188
|
+
Returns a dict with material detail including element composition.
|
|
189
|
+
"""
|
|
190
|
+
return self._resolve_cif_url_dict(self._get(f"/materials/{material_id}/"))
|
|
191
|
+
|
|
192
|
+
def get_materials_psdi(self, name: str | None = None,
|
|
193
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
194
|
+
"""
|
|
195
|
+
GET /api/v2/materials-psdi/
|
|
196
|
+
|
|
197
|
+
Extended material list including full crystallographic and PSDI fields:
|
|
198
|
+
chemical formulae, SMILES, space group, cell geometry, CIF URL/filename.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
name: Case-insensitive substring filter on material name.
|
|
202
|
+
limit: Max records to return (default 500).
|
|
203
|
+
offset: Pagination offset.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
DataFrame with one row per material.
|
|
207
|
+
"""
|
|
208
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
209
|
+
return self._resolve_cif_url_df(self._to_df(self._get("/materials-psdi/", params)))
|
|
210
|
+
|
|
211
|
+
def get_material_psdi(self, material_id: int) -> dict:
|
|
212
|
+
"""
|
|
213
|
+
GET /api/v2/materials-psdi/{material_id}/
|
|
214
|
+
|
|
215
|
+
Full extended MOF record: all crystallographic fields, CIF URL/filename,
|
|
216
|
+
linker/node chemistry (SMILES, formulae, PubChem pipeline outputs),
|
|
217
|
+
and element composition.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
material_id: Integer PK of the material.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
dict with all PSDI fields plus nested 'elements' list.
|
|
224
|
+
"""
|
|
225
|
+
return self._resolve_cif_url_dict(self._get(f"/materials-psdi/{material_id}/"))
|
|
226
|
+
|
|
227
|
+
def get_material_property_bundle(self, mof: str,
|
|
228
|
+
sim_or_exp: str | None = None,
|
|
229
|
+
good_structure: bool | None = None,
|
|
230
|
+
limit: int = 500,
|
|
231
|
+
offset: int = 0,
|
|
232
|
+
query: dict[str, dict[str, Any]] | None = None) -> dict:
|
|
233
|
+
"""
|
|
234
|
+
Fetch all science data for a given MOF in a single call.
|
|
235
|
+
|
|
236
|
+
Aggregates isotherms, simulated Zeo++, experimental Zeo++ and
|
|
237
|
+
water KPIs into one dict, applying consistent filters across all
|
|
238
|
+
four sub-queries.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
mof: MOF name (substring match applied to all sub-queries).
|
|
242
|
+
sim_or_exp: 'sim' or 'exp' filter for isotherms and water KPIs.
|
|
243
|
+
good_structure: Good-structure filter for isotherms, water KPIs
|
|
244
|
+
and simulated Zeo++.
|
|
245
|
+
limit: Max records per sub-query (default 500).
|
|
246
|
+
offset: Pagination offset for all sub-queries.
|
|
247
|
+
query: Optional advanced parameter map for endpoint-specific
|
|
248
|
+
filtering. Supported keys:
|
|
249
|
+
``materials``, ``isotherms``, ``zeopp_simulated``,
|
|
250
|
+
``zeopp_experimental``, ``water_kpis``, and
|
|
251
|
+
``common``.
|
|
252
|
+
|
|
253
|
+
``common`` is merged into all four science endpoints.
|
|
254
|
+
Endpoint-specific keys override defaults.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
dict with keys:
|
|
258
|
+
'isotherms' – isotherm records
|
|
259
|
+
'zeopp_simulated' – simulated Zeo++ records
|
|
260
|
+
'zeopp_experimental' – experimental Zeo++ records
|
|
261
|
+
'water_kpis' – water KPI records
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: if the name matches more than one material — use an
|
|
265
|
+
exact name or a more specific substring.
|
|
266
|
+
"""
|
|
267
|
+
if query is not None and not isinstance(query, dict):
|
|
268
|
+
raise TypeError("query must be a dict[str, dict] or None")
|
|
269
|
+
|
|
270
|
+
query = query or {}
|
|
271
|
+
|
|
272
|
+
common_filters = query.get("common", {}) or {}
|
|
273
|
+
if not isinstance(common_filters, dict):
|
|
274
|
+
raise TypeError("query['common'] must be a dict when provided")
|
|
275
|
+
|
|
276
|
+
def _merge_filters(defaults: dict[str, Any], key: str) -> dict[str, Any]:
|
|
277
|
+
endpoint_filters = query.get(key, {}) or {}
|
|
278
|
+
if not isinstance(endpoint_filters, dict):
|
|
279
|
+
raise TypeError(f"query['{key}'] must be a dict when provided")
|
|
280
|
+
merged = {**defaults, **common_filters, **endpoint_filters}
|
|
281
|
+
return _compact(**merged)
|
|
282
|
+
|
|
283
|
+
matches = self._get(
|
|
284
|
+
"/materials/",
|
|
285
|
+
_merge_filters({"name": mof, "limit": 50}, "materials"),
|
|
286
|
+
).get("results", [])
|
|
287
|
+
# The API name filter is a substring match — narrow to exact matches only
|
|
288
|
+
exact_matches = [m for m in matches if m["name"] == mof]
|
|
289
|
+
# Fall back to substring matches only if there is no exact match
|
|
290
|
+
# (supports callers who intentionally pass a substring)
|
|
291
|
+
candidates = exact_matches if exact_matches else matches
|
|
292
|
+
if len(candidates) > 1:
|
|
293
|
+
names = [m["name"] for m in candidates]
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"'{mof}' matched {len(candidates)} materials: {names}. "
|
|
296
|
+
"Use a more specific name."
|
|
297
|
+
)
|
|
298
|
+
true_name = candidates[0]["name"] if candidates else mof
|
|
299
|
+
|
|
300
|
+
# Fields in water_kpis that carry DB integer PKs for MOF / Molecule
|
|
301
|
+
# (capitalised keys) duplicate the human-readable 'mof' / 'molecule'
|
|
302
|
+
# string fields and are stripped here to keep the bundle clean.
|
|
303
|
+
_WK_DROP = frozenset({"MOF", "Molecule"})
|
|
304
|
+
|
|
305
|
+
def _drop_wk_id_fields(records):
|
|
306
|
+
if isinstance(records, list):
|
|
307
|
+
return [{k: v for k, v in r.items() if k not in _WK_DROP} for r in records]
|
|
308
|
+
import pandas as pd
|
|
309
|
+
if isinstance(records, pd.DataFrame):
|
|
310
|
+
return records.drop(columns=[c for c in _WK_DROP if c in records.columns])
|
|
311
|
+
return records
|
|
312
|
+
|
|
313
|
+
good_structure_q = None if good_structure is None else str(good_structure).lower()
|
|
314
|
+
|
|
315
|
+
bundle = {
|
|
316
|
+
"isotherms": self._to_df(self._get(
|
|
317
|
+
"/isotherms/",
|
|
318
|
+
_merge_filters({
|
|
319
|
+
"mof": mof,
|
|
320
|
+
"sim_or_exp": sim_or_exp,
|
|
321
|
+
"good_structure": good_structure_q,
|
|
322
|
+
"limit": limit,
|
|
323
|
+
"offset": offset,
|
|
324
|
+
}, "isotherms"),
|
|
325
|
+
)),
|
|
326
|
+
"zeopp_simulated": self._to_df(self._get(
|
|
327
|
+
"/carbon-zeopp/",
|
|
328
|
+
_merge_filters({
|
|
329
|
+
"mof": mof,
|
|
330
|
+
"good_structure": good_structure_q,
|
|
331
|
+
"limit": limit,
|
|
332
|
+
"offset": offset,
|
|
333
|
+
}, "zeopp_simulated"),
|
|
334
|
+
)),
|
|
335
|
+
"zeopp_experimental": self._to_df(self._get(
|
|
336
|
+
"/carbon-zeopp-experimental/",
|
|
337
|
+
_merge_filters({
|
|
338
|
+
"mof": mof,
|
|
339
|
+
"limit": limit,
|
|
340
|
+
"offset": offset,
|
|
341
|
+
}, "zeopp_experimental"),
|
|
342
|
+
)),
|
|
343
|
+
"water_kpis": _drop_wk_id_fields(self._to_df(self._get(
|
|
344
|
+
"/water-kpis/",
|
|
345
|
+
_merge_filters({
|
|
346
|
+
"mof": mof,
|
|
347
|
+
"sim_or_exp": sim_or_exp,
|
|
348
|
+
"good_structure": good_structure_q,
|
|
349
|
+
"limit": limit,
|
|
350
|
+
"offset": offset,
|
|
351
|
+
}, "water_kpis"),
|
|
352
|
+
))),
|
|
353
|
+
}
|
|
354
|
+
label = true_name if true_name == mof else f"{true_name} (matched from partial string: '{mof}')"
|
|
355
|
+
print(f"Property bundle for '{label}':")
|
|
356
|
+
for key, val in bundle.items():
|
|
357
|
+
print(f" {key:25s}: {len(val)} records")
|
|
358
|
+
return bundle
|
|
359
|
+
|
|
360
|
+
def preflight_material_check(self, name: str) -> bool:
|
|
361
|
+
"""
|
|
362
|
+
Check whether a material with the given name exists in the database.
|
|
363
|
+
|
|
364
|
+
Calls ``list_materials(name=name, limit=1)`` and returns ``True`` if
|
|
365
|
+
at least one result is returned.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
name: Material name to search for (substring match).
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
``True`` if at least one matching material is found, ``False`` otherwise.
|
|
372
|
+
"""
|
|
373
|
+
results = self.list_materials(name=name, limit=1)
|
|
374
|
+
if isinstance(results, list):
|
|
375
|
+
return len(results) > 0
|
|
376
|
+
return not results.empty
|
|
377
|
+
|
|
378
|
+
# ── Internal record helpers ───────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _as_records(self, result: Any) -> list[dict]:
|
|
381
|
+
"""Normalise a DataFrame or list[dict] to list[dict]."""
|
|
382
|
+
if isinstance(result, pd.DataFrame):
|
|
383
|
+
return result.to_dict(orient="records")
|
|
384
|
+
if isinstance(result, list):
|
|
385
|
+
return result
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
def _properties_for(self, object_id: int, limit: int = 2000) -> list[dict]:
|
|
389
|
+
"""Return all Property records linked to *object_id* via GenericForeignKey.
|
|
390
|
+
|
|
391
|
+
The GFK fields (``object_id``, ``content_type``, ``content_type_id``)
|
|
392
|
+
are stripped from every returned record — they served their purpose for
|
|
393
|
+
the fetch and carry no semantic value in the assembled bundle.
|
|
394
|
+
Records are sorted alphabetically by ``name``.
|
|
395
|
+
"""
|
|
396
|
+
_GFK_DROP = frozenset({"id", "object_id", "content_type", "content_type_id"})
|
|
397
|
+
records = self._as_records(
|
|
398
|
+
self.get_properties(object_id=object_id, limit=limit)
|
|
399
|
+
)
|
|
400
|
+
cleaned = [
|
|
401
|
+
{k: v for k, v in r.items() if k not in _GFK_DROP}
|
|
402
|
+
for r in records
|
|
403
|
+
]
|
|
404
|
+
return sorted(cleaned, key=lambda r: str(r.get("name", "")).lower())
|
|
405
|
+
|
|
406
|
+
# ── Cases bundle ──────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def get_cases_bundle(
|
|
409
|
+
self,
|
|
410
|
+
name: str | None = None,
|
|
411
|
+
source: str | None = None,
|
|
412
|
+
sink: str | None = None,
|
|
413
|
+
region: str | None = None,
|
|
414
|
+
limit_cases: int = 100,
|
|
415
|
+
limit_props: int = 2000,
|
|
416
|
+
) -> list[dict]:
|
|
417
|
+
"""
|
|
418
|
+
Aggregate all related data for one or more CaseStudy records into a
|
|
419
|
+
structured bundle, following the full relationship graph:
|
|
420
|
+
|
|
421
|
+
CaseStudy
|
|
422
|
+
├── source → Source + properties (GFK)
|
|
423
|
+
├── sink → Sink + properties (GFK)
|
|
424
|
+
├── region → Region + properties (GFK, ambient params)
|
|
425
|
+
├── utilities → Utility[] + properties (GFK) each
|
|
426
|
+
├── subsystems → Subsystem[] + properties (GFK) each (if present on case detail)
|
|
427
|
+
└── scenarios → Scenario[]
|
|
428
|
+
└── process_conditions → ProcessConditions + properties (GFK)
|
|
429
|
+
└── configurations → ProcessConfiguration[] + properties (GFK) each
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
name: Substring filter on CaseStudy name (passed to get_cases).
|
|
433
|
+
source: Substring filter on source name (passed to get_cases).
|
|
434
|
+
sink: Substring filter on sink name (passed to get_cases).
|
|
435
|
+
region: Exact ISO code filter on region (passed to get_cases).
|
|
436
|
+
limit_cases: Maximum number of cases to fetch (default 100).
|
|
437
|
+
limit_props: Maximum properties per related object (default 2000).
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
list[dict] — one entry per matched case, each structured as::
|
|
441
|
+
|
|
442
|
+
{
|
|
443
|
+
"case": { ...case fields... },
|
|
444
|
+
"source": {
|
|
445
|
+
"record": { ...source fields... },
|
|
446
|
+
"properties": [ ...property records... ]
|
|
447
|
+
},
|
|
448
|
+
"sink": {
|
|
449
|
+
"record": { ...sink fields... },
|
|
450
|
+
"properties": [ ...property records... ]
|
|
451
|
+
},
|
|
452
|
+
"region": {
|
|
453
|
+
"record": { ...region fields... },
|
|
454
|
+
"properties": [ ...property records... ]
|
|
455
|
+
},
|
|
456
|
+
"utilities": [
|
|
457
|
+
{ "record": {...}, "properties": [...] },
|
|
458
|
+
...
|
|
459
|
+
],
|
|
460
|
+
"subsystems": [
|
|
461
|
+
{ "record": {...}, "properties": [...] },
|
|
462
|
+
...
|
|
463
|
+
],
|
|
464
|
+
"scenarios": [
|
|
465
|
+
{
|
|
466
|
+
"record": { ...scenario fields... },
|
|
467
|
+
"process_conditions": {
|
|
468
|
+
"record": { ...process condition fields... },
|
|
469
|
+
"properties": [ ...property records... ],
|
|
470
|
+
"configurations": [
|
|
471
|
+
{ "record": {...}, "properties": [...] },
|
|
472
|
+
...
|
|
473
|
+
]
|
|
474
|
+
} | None
|
|
475
|
+
},
|
|
476
|
+
...
|
|
477
|
+
]
|
|
478
|
+
}
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
# ── helpers ────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
def _sort_props(props: list[dict]) -> list[dict]:
|
|
484
|
+
"""Sort a property list alphabetically by name (case-insensitive)."""
|
|
485
|
+
return sorted(props, key=lambda r: str(r.get("name", "")).lower())
|
|
486
|
+
|
|
487
|
+
def _first_record(result: Any) -> dict | None:
|
|
488
|
+
records = self._as_records(result)
|
|
489
|
+
return records[0] if records else None
|
|
490
|
+
|
|
491
|
+
def _object_node(record: dict | None) -> dict:
|
|
492
|
+
"""Wrap a single record + its properties into a bundle node."""
|
|
493
|
+
if record is None:
|
|
494
|
+
return {"record": None, "properties": []}
|
|
495
|
+
obj_id = record.get("id")
|
|
496
|
+
props = _sort_props(self._properties_for(obj_id, limit=limit_props)) if obj_id else []
|
|
497
|
+
return {"record": record, "properties": props}
|
|
498
|
+
|
|
499
|
+
# ── fetch matching cases ───────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
cases = self._as_records(
|
|
502
|
+
self.get_cases(source=source, sink=sink,
|
|
503
|
+
region=region, limit=limit_cases)
|
|
504
|
+
)
|
|
505
|
+
# Client-side name substring filter (get_cases has no name param)
|
|
506
|
+
if name:
|
|
507
|
+
name_lower = name.lower()
|
|
508
|
+
cases = [c for c in cases if name_lower in str(c.get("name", "")).lower()]
|
|
509
|
+
|
|
510
|
+
bundles: list[dict] = []
|
|
511
|
+
|
|
512
|
+
for case in cases:
|
|
513
|
+
case_id = case["id"]
|
|
514
|
+
|
|
515
|
+
# ── source ────────────────────────────────────────────────────
|
|
516
|
+
source_name = case.get("source")
|
|
517
|
+
source_rec = _first_record(
|
|
518
|
+
self.get_sources(name=source_name, limit=10)
|
|
519
|
+
) if source_name else None
|
|
520
|
+
# prefer exact match
|
|
521
|
+
if source_rec and source_rec.get("name") != source_name:
|
|
522
|
+
candidates = [
|
|
523
|
+
r for r in self._as_records(self.get_sources(name=source_name, limit=50))
|
|
524
|
+
if r.get("name") == source_name
|
|
525
|
+
]
|
|
526
|
+
if candidates:
|
|
527
|
+
source_rec = candidates[0]
|
|
528
|
+
|
|
529
|
+
# ── sink ──────────────────────────────────────────────────────
|
|
530
|
+
sink_name = case.get("sink")
|
|
531
|
+
sink_rec = _first_record(
|
|
532
|
+
self.get_sinks(name=sink_name, limit=10)
|
|
533
|
+
) if sink_name else None
|
|
534
|
+
if sink_rec and sink_rec.get("name") != sink_name:
|
|
535
|
+
candidates = [
|
|
536
|
+
r for r in self._as_records(self.get_sinks(name=sink_name, limit=50))
|
|
537
|
+
if r.get("name") == sink_name
|
|
538
|
+
]
|
|
539
|
+
if candidates:
|
|
540
|
+
sink_rec = candidates[0]
|
|
541
|
+
|
|
542
|
+
# ── region ────────────────────────────────────────────────────
|
|
543
|
+
region_code = case.get("region")
|
|
544
|
+
region_rec = _first_record(
|
|
545
|
+
self.get_regions(code=region_code, limit=1)
|
|
546
|
+
) if region_code else None
|
|
547
|
+
|
|
548
|
+
# ── utilities ─────────────────────────────────────────────────
|
|
549
|
+
utils_raw = case.get("utilities") or []
|
|
550
|
+
if isinstance(utils_raw, str):
|
|
551
|
+
utils_raw = [utils_raw] if utils_raw else []
|
|
552
|
+
utility_nodes: list[dict] = []
|
|
553
|
+
for util_name in utils_raw:
|
|
554
|
+
util_rec = _first_record(self.get_utilities(name=util_name, limit=10))
|
|
555
|
+
if util_rec and util_rec.get("name") != util_name:
|
|
556
|
+
candidates = [
|
|
557
|
+
r for r in self._as_records(self.get_utilities(name=util_name, limit=50))
|
|
558
|
+
if r.get("name") == util_name
|
|
559
|
+
]
|
|
560
|
+
if candidates:
|
|
561
|
+
util_rec = candidates[0]
|
|
562
|
+
utility_nodes.append(_object_node(util_rec))
|
|
563
|
+
|
|
564
|
+
# ── subsystems (M2M — present on case detail if serialiser exposes them)
|
|
565
|
+
subsystem_nodes: list[dict] = []
|
|
566
|
+
case_detail = self.get_case(case_id)
|
|
567
|
+
subsystems_raw = case_detail.get("subsystems") or []
|
|
568
|
+
if isinstance(subsystems_raw, str):
|
|
569
|
+
subsystems_raw = [subsystems_raw] if subsystems_raw else []
|
|
570
|
+
for sub_item in subsystems_raw:
|
|
571
|
+
# sub_item may be a name string or a dict with 'name'/'id'
|
|
572
|
+
if isinstance(sub_item, dict):
|
|
573
|
+
sub_rec = sub_item
|
|
574
|
+
else:
|
|
575
|
+
sub_name = str(sub_item)
|
|
576
|
+
sub_rec = _first_record(self.get_subsystems(name=sub_name, limit=10))
|
|
577
|
+
if sub_rec and sub_rec.get("name") != sub_name:
|
|
578
|
+
candidates = [
|
|
579
|
+
r for r in self._as_records(self.get_subsystems(name=sub_name, limit=50))
|
|
580
|
+
if r.get("name") == sub_name
|
|
581
|
+
]
|
|
582
|
+
if candidates:
|
|
583
|
+
sub_rec = candidates[0]
|
|
584
|
+
subsystem_nodes.append(_object_node(sub_rec))
|
|
585
|
+
|
|
586
|
+
# ── scenarios ─────────────────────────────────────────────────
|
|
587
|
+
scenario_nodes: list[dict] = []
|
|
588
|
+
scenarios = self._as_records(
|
|
589
|
+
self.get_scenarios(case_id=case_id, limit=200)
|
|
590
|
+
)
|
|
591
|
+
for scen in scenarios:
|
|
592
|
+
scen_id = scen["id"]
|
|
593
|
+
scen_detail = self.get_scenario(scen_id)
|
|
594
|
+
|
|
595
|
+
# Process conditions: look for process_conditions or
|
|
596
|
+
# process_conditions_id in the scenario detail response
|
|
597
|
+
pc_node: dict | None = None
|
|
598
|
+
pc_name = scen_detail.get("process_conditions")
|
|
599
|
+
pc_id = scen_detail.get("process_conditions_id")
|
|
600
|
+
|
|
601
|
+
if pc_id:
|
|
602
|
+
try:
|
|
603
|
+
pc_rec = self.get_process_condition(int(pc_id))
|
|
604
|
+
except Exception:
|
|
605
|
+
pc_rec = None
|
|
606
|
+
elif pc_name:
|
|
607
|
+
pc_rec = _first_record(
|
|
608
|
+
self.get_process_conditions(name=pc_name, limit=10)
|
|
609
|
+
)
|
|
610
|
+
if pc_rec and pc_rec.get("name") != pc_name:
|
|
611
|
+
candidates = [
|
|
612
|
+
r for r in self._as_records(
|
|
613
|
+
self.get_process_conditions(name=pc_name, limit=50)
|
|
614
|
+
)
|
|
615
|
+
if r.get("name") == pc_name
|
|
616
|
+
]
|
|
617
|
+
pc_rec = candidates[0] if candidates else pc_rec
|
|
618
|
+
else:
|
|
619
|
+
pc_rec = None
|
|
620
|
+
|
|
621
|
+
if pc_rec:
|
|
622
|
+
pc_obj_id = pc_rec.get("id")
|
|
623
|
+
pc_props = _sort_props(self._properties_for(pc_obj_id, limit=limit_props)) if pc_obj_id else []
|
|
624
|
+
|
|
625
|
+
# ProcessConfigurations that reference this ProcessConditions
|
|
626
|
+
# (purge / desiccant / process_data FKs on ProcessConfiguration)
|
|
627
|
+
cfg_nodes: list[dict] = []
|
|
628
|
+
cfgs = self._as_records(
|
|
629
|
+
self.get_process_configurations(name=pc_rec.get("name"), limit=100)
|
|
630
|
+
)
|
|
631
|
+
for cfg in cfgs:
|
|
632
|
+
cfg_obj_id = cfg.get("id")
|
|
633
|
+
cfg_props = _sort_props(self._properties_for(cfg_obj_id, limit=limit_props)) if cfg_obj_id else []
|
|
634
|
+
cfg_nodes.append({"record": cfg, "properties": cfg_props})
|
|
635
|
+
|
|
636
|
+
pc_node = {
|
|
637
|
+
"record": pc_rec,
|
|
638
|
+
"properties": pc_props,
|
|
639
|
+
"configurations": cfg_nodes,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
scenario_nodes.append({
|
|
643
|
+
"record": scen_detail,
|
|
644
|
+
"process_conditions": pc_node,
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
# ── assemble bundle ───────────────────────────────────────────
|
|
648
|
+
bundle = {
|
|
649
|
+
"case": case_detail,
|
|
650
|
+
"source": _object_node(source_rec),
|
|
651
|
+
"sink": _object_node(sink_rec),
|
|
652
|
+
"region": _object_node(region_rec),
|
|
653
|
+
"utilities": utility_nodes,
|
|
654
|
+
"subsystems": subsystem_nodes,
|
|
655
|
+
"scenarios": scenario_nodes,
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
# ── summary print (mirrors get_material_property_bundle style) ──
|
|
659
|
+
n_props = (
|
|
660
|
+
len(bundle["source"]["properties"])
|
|
661
|
+
+ len(bundle["sink"]["properties"])
|
|
662
|
+
+ len(bundle["region"]["properties"])
|
|
663
|
+
+ sum(u["properties"].__len__() for u in utility_nodes)
|
|
664
|
+
+ sum(s["properties"].__len__() for s in subsystem_nodes)
|
|
665
|
+
+ sum(
|
|
666
|
+
len(sn["process_conditions"]["properties"]) if sn["process_conditions"] else 0
|
|
667
|
+
for sn in scenario_nodes
|
|
668
|
+
)
|
|
669
|
+
+ sum(
|
|
670
|
+
len(c["properties"])
|
|
671
|
+
for sn in scenario_nodes
|
|
672
|
+
if sn["process_conditions"]
|
|
673
|
+
for c in sn["process_conditions"]["configurations"]
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
print(
|
|
677
|
+
f"Case bundle '{case_detail.get('name', case_id)}': "
|
|
678
|
+
f"{len(scenarios)} scenario(s), "
|
|
679
|
+
f"{len(utility_nodes)} utility/ies, "
|
|
680
|
+
f"{len(subsystem_nodes)} subsystem(s), "
|
|
681
|
+
f"{n_props} total property records"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
bundles.append(bundle)
|
|
685
|
+
|
|
686
|
+
return bundles
|
|
687
|
+
|
|
688
|
+
def get_molecules(self, name: str | None = None,
|
|
689
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
690
|
+
"""GET /api/v2/molecules/"""
|
|
691
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
692
|
+
return self._to_df(self._get("/molecules/", params))
|
|
693
|
+
|
|
694
|
+
def get_molecule(self, molecule_id: int) -> dict:
|
|
695
|
+
"""GET /api/v2/molecules/{molecule_id}/"""
|
|
696
|
+
return self._get(f"/molecules/{molecule_id}/")
|
|
697
|
+
|
|
698
|
+
def get_elements(self, symbol: str | None = None, name: str | None = None,
|
|
699
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
700
|
+
"""
|
|
701
|
+
GET /api/v2/elements/
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
symbol: Exact symbol filter (case-insensitive), e.g. 'Fe'.
|
|
705
|
+
name: Substring filter on element name.
|
|
706
|
+
"""
|
|
707
|
+
params = _compact(symbol=symbol, name=name, limit=limit, offset=offset)
|
|
708
|
+
return self._to_df(self._get("/elements/", params))
|
|
709
|
+
|
|
710
|
+
def get_element(self, element_id: int) -> dict:
|
|
711
|
+
"""GET /api/v2/elements/{element_id}/"""
|
|
712
|
+
return self._get(f"/elements/{element_id}/")
|
|
713
|
+
|
|
714
|
+
def get_regions(self, code: str | None = None, name: str | None = None,
|
|
715
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
716
|
+
"""
|
|
717
|
+
GET /api/v2/regions/
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
code: Exact ISO code filter (case-insensitive), e.g. 'GB'.
|
|
721
|
+
name: Substring filter on region name.
|
|
722
|
+
"""
|
|
723
|
+
params = _compact(code=code, name=name, limit=limit, offset=offset)
|
|
724
|
+
return self._to_df(self._get("/regions/", params))
|
|
725
|
+
|
|
726
|
+
def get_region(self, region_id: int) -> dict:
|
|
727
|
+
"""GET /api/v2/regions/{region_id}/"""
|
|
728
|
+
return self._get(f"/regions/{region_id}/")
|
|
729
|
+
|
|
730
|
+
def get_sources(self, name: str | None = None,
|
|
731
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
732
|
+
"""GET /api/v2/sources/"""
|
|
733
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
734
|
+
return self._to_df(self._get("/sources/", params))
|
|
735
|
+
|
|
736
|
+
def get_source(self, source_id: int) -> dict:
|
|
737
|
+
"""GET /api/v2/sources/{source_id}/"""
|
|
738
|
+
return self._get(f"/sources/{source_id}/")
|
|
739
|
+
|
|
740
|
+
def get_sinks(self, name: str | None = None,
|
|
741
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
742
|
+
"""GET /api/v2/sinks/"""
|
|
743
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
744
|
+
return self._to_df(self._get("/sinks/", params))
|
|
745
|
+
|
|
746
|
+
def get_sink(self, sink_id: int) -> dict:
|
|
747
|
+
"""GET /api/v2/sinks/{sink_id}/"""
|
|
748
|
+
return self._get(f"/sinks/{sink_id}/")
|
|
749
|
+
|
|
750
|
+
def get_transport_scenarios(self, name: str | None = None,
|
|
751
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
752
|
+
"""GET /api/v2/transport-scenarios/"""
|
|
753
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
754
|
+
return self._to_df(self._get("/transport-scenarios/", params))
|
|
755
|
+
|
|
756
|
+
def get_transport_scenario(self, ts_id: int) -> dict:
|
|
757
|
+
"""GET /api/v2/transport-scenarios/{ts_id}/"""
|
|
758
|
+
return self._get(f"/transport-scenarios/{ts_id}/")
|
|
759
|
+
|
|
760
|
+
def get_utilities(self, name: str | None = None,
|
|
761
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
762
|
+
"""GET /api/v2/utilities/"""
|
|
763
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
764
|
+
return self._to_df(self._get("/utilities/", params))
|
|
765
|
+
|
|
766
|
+
def get_utility(self, utility_id: int) -> dict:
|
|
767
|
+
"""GET /api/v2/utilities/{utility_id}/"""
|
|
768
|
+
return self._get(f"/utilities/{utility_id}/")
|
|
769
|
+
|
|
770
|
+
def get_references(self, name: str | None = None, doi: str | None = None,
|
|
771
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
772
|
+
"""
|
|
773
|
+
GET /api/v2/references/
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
name: Substring filter on reference name.
|
|
777
|
+
doi: Exact DOI filter (case-insensitive).
|
|
778
|
+
"""
|
|
779
|
+
params = _compact(name=name, doi=doi, limit=limit, offset=offset)
|
|
780
|
+
return self._to_df(self._get("/references/", params))
|
|
781
|
+
|
|
782
|
+
def get_reference(self, ref_id: int) -> dict:
|
|
783
|
+
"""GET /api/v2/references/{ref_id}/"""
|
|
784
|
+
return self._get(f"/references/{ref_id}/")
|
|
785
|
+
|
|
786
|
+
def get_transports(self, name: str | None = None,
|
|
787
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
788
|
+
"""GET /api/v2/transports/"""
|
|
789
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
790
|
+
return self._to_df(self._get("/transports/", params))
|
|
791
|
+
|
|
792
|
+
def get_transport(self, transport_id: int) -> dict:
|
|
793
|
+
"""GET /api/v2/transports/{transport_id}/"""
|
|
794
|
+
return self._get(f"/transports/{transport_id}/")
|
|
795
|
+
|
|
796
|
+
def get_subsystems(self, name: str | None = None, type: str | None = None,
|
|
797
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
798
|
+
"""
|
|
799
|
+
GET /api/v2/subsystems/
|
|
800
|
+
|
|
801
|
+
Args:
|
|
802
|
+
name: Substring filter on subsystem name.
|
|
803
|
+
type: Exact type filter (e.g. 'dac').
|
|
804
|
+
"""
|
|
805
|
+
params = _compact(name=name, type=type, limit=limit, offset=offset)
|
|
806
|
+
return self._to_df(self._get("/subsystems/", params))
|
|
807
|
+
|
|
808
|
+
def get_subsystem(self, subsystem_id: int) -> dict:
|
|
809
|
+
"""GET /api/v2/subsystems/{subsystem_id}/"""
|
|
810
|
+
return self._get(f"/subsystems/{subsystem_id}/")
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def get_properties(self,
|
|
814
|
+
name: str | None = None,
|
|
815
|
+
domain: str | None = None,
|
|
816
|
+
category: str | None = None,
|
|
817
|
+
object_id: int | None = None,
|
|
818
|
+
limit: int = 500,
|
|
819
|
+
offset: int = 0) -> pd.DataFrame:
|
|
820
|
+
"""
|
|
821
|
+
GET /api/v2/properties/
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
name: Substring filter on property name.
|
|
825
|
+
domain: Domain filter (e.g. 'TEA').
|
|
826
|
+
category: Category filter (e.g. 'params_amb').
|
|
827
|
+
object_id: Exact object PK filter.
|
|
828
|
+
"""
|
|
829
|
+
params = _compact(name=name, domain=domain, category=category,
|
|
830
|
+
object_id=object_id, limit=limit, offset=offset)
|
|
831
|
+
return self._to_df(self._get("/properties/", params))
|
|
832
|
+
|
|
833
|
+
def get_property(self, property_id: int) -> dict:
|
|
834
|
+
"""GET /api/v2/properties/{property_id}/"""
|
|
835
|
+
return self._get(f"/properties/{property_id}/")
|
|
836
|
+
|
|
837
|
+
def get_equipment(self, name: str | None = None, group: str | None = None,
|
|
838
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
839
|
+
"""
|
|
840
|
+
GET /api/v2/equipment/
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
name: Substring filter on equipment name.
|
|
844
|
+
group: Exact group filter (e.g. 'Blower').
|
|
845
|
+
"""
|
|
846
|
+
params = _compact(name=name, group=group, limit=limit, offset=offset)
|
|
847
|
+
return self._to_df(self._get("/equipment/", params))
|
|
848
|
+
|
|
849
|
+
def get_equipment_item(self, equipment_id: int) -> dict:
|
|
850
|
+
"""GET /api/v2/equipment/{equipment_id}/"""
|
|
851
|
+
return self._get(f"/equipment/{equipment_id}/")
|
|
852
|
+
|
|
853
|
+
def get_equipment_costs(self, equipment_id: int | None = None,
|
|
854
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
855
|
+
"""
|
|
856
|
+
GET /api/v2/equipment-costs/
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
equipment_id: Exact equipment PK filter.
|
|
860
|
+
"""
|
|
861
|
+
params = _compact(equipment_id=equipment_id, limit=limit, offset=offset)
|
|
862
|
+
return self._to_df(self._get("/equipment-costs/", params))
|
|
863
|
+
|
|
864
|
+
def get_equipment_cost(self, cost_id: int) -> dict:
|
|
865
|
+
"""GET /api/v2/equipment-costs/{cost_id}/"""
|
|
866
|
+
return self._get(f"/equipment-costs/{cost_id}/")
|
|
867
|
+
|
|
868
|
+
def get_equipment_designs(self, equipment_id: int | None = None,
|
|
869
|
+
key: str | None = None,
|
|
870
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
871
|
+
"""
|
|
872
|
+
GET /api/v2/equipment-designs/
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
equipment_id: Exact equipment PK filter.
|
|
876
|
+
key: Exact design parameter key filter (e.g. 'D1').
|
|
877
|
+
"""
|
|
878
|
+
params = _compact(equipment_id=equipment_id, key=key,
|
|
879
|
+
limit=limit, offset=offset)
|
|
880
|
+
return self._to_df(self._get("/equipment-designs/", params))
|
|
881
|
+
|
|
882
|
+
def get_equipment_design(self, design_id: int) -> dict:
|
|
883
|
+
"""GET /api/v2/equipment-designs/{design_id}/"""
|
|
884
|
+
return self._get(f"/equipment-designs/{design_id}/")
|
|
885
|
+
|
|
886
|
+
def get_process_conditions(self, name: str | None = None,
|
|
887
|
+
type: str | None = None,
|
|
888
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
889
|
+
"""
|
|
890
|
+
GET /api/v2/process-conditions/
|
|
891
|
+
|
|
892
|
+
Args:
|
|
893
|
+
name: Substring filter on process condition name.
|
|
894
|
+
type: Exact type filter (e.g. 'tvsa').
|
|
895
|
+
"""
|
|
896
|
+
params = _compact(name=name, type=type, limit=limit, offset=offset)
|
|
897
|
+
return self._to_df(self._get("/process-conditions/", params))
|
|
898
|
+
|
|
899
|
+
def get_process_condition(self, condition_id: int) -> dict:
|
|
900
|
+
"""GET /api/v2/process-conditions/{condition_id}/"""
|
|
901
|
+
return self._get(f"/process-conditions/{condition_id}/")
|
|
902
|
+
|
|
903
|
+
def get_process_configurations(self, name: str | None = None,
|
|
904
|
+
type: str | None = None,
|
|
905
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
906
|
+
"""
|
|
907
|
+
GET /api/v2/process-configurations/
|
|
908
|
+
|
|
909
|
+
Args:
|
|
910
|
+
name: Substring filter on process configuration name.
|
|
911
|
+
type: Exact type filter (e.g. 'dac').
|
|
912
|
+
"""
|
|
913
|
+
params = _compact(name=name, type=type, limit=limit, offset=offset)
|
|
914
|
+
return self._to_df(self._get("/process-configurations/", params))
|
|
915
|
+
|
|
916
|
+
def get_process_configuration(self, config_id: int) -> dict:
|
|
917
|
+
"""GET /api/v2/process-configurations/{config_id}/"""
|
|
918
|
+
return self._get(f"/process-configurations/{config_id}/")
|
|
919
|
+
|
|
920
|
+
def get_contactor_configurations(self, name: str | None = None,
|
|
921
|
+
type: str | None = None,
|
|
922
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
923
|
+
"""
|
|
924
|
+
GET /api/v2/contactor-configurations/
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
name: Substring filter on contactor configuration name.
|
|
928
|
+
type: Exact type filter (e.g. 'kiln').
|
|
929
|
+
"""
|
|
930
|
+
params = _compact(name=name, type=type, limit=limit, offset=offset)
|
|
931
|
+
return self._to_df(self._get("/contactor-configurations/", params))
|
|
932
|
+
|
|
933
|
+
def get_contactor_configuration(self, config_id: int) -> dict:
|
|
934
|
+
"""GET /api/v2/contactor-configurations/{config_id}/"""
|
|
935
|
+
return self._get(f"/contactor-configurations/{config_id}/")
|
|
936
|
+
|
|
937
|
+
def get_cost_indices(self, year: int | None = None,
|
|
938
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
939
|
+
"""
|
|
940
|
+
GET /api/v2/cost-indices/
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
year: Exact year filter.
|
|
944
|
+
"""
|
|
945
|
+
params = _compact(year=year, limit=limit, offset=offset)
|
|
946
|
+
return self._to_df(self._get("/cost-indices/", params))
|
|
947
|
+
|
|
948
|
+
def get_cost_index(self, index_id: int) -> dict:
|
|
949
|
+
"""GET /api/v2/cost-indices/{index_id}/"""
|
|
950
|
+
return self._get(f"/cost-indices/{index_id}/")
|
|
951
|
+
|
|
952
|
+
def get_constants(self, param: str | None = None,
|
|
953
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
954
|
+
"""
|
|
955
|
+
GET /api/v2/constants/
|
|
956
|
+
|
|
957
|
+
Args:
|
|
958
|
+
param: Exact parameter symbol filter (e.g. 'R').
|
|
959
|
+
"""
|
|
960
|
+
params = _compact(param=param, limit=limit, offset=offset)
|
|
961
|
+
return self._to_df(self._get("/constants/", params))
|
|
962
|
+
|
|
963
|
+
def get_constant(self, constant_id: int) -> dict:
|
|
964
|
+
"""GET /api/v2/constants/{constant_id}/"""
|
|
965
|
+
return self._get(f"/constants/{constant_id}/")
|
|
966
|
+
|
|
967
|
+
def get_mea_baselines(self, name: str | None = None,
|
|
968
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
969
|
+
"""GET /api/v2/mea/"""
|
|
970
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
971
|
+
return self._to_df(self._get("/mea/", params))
|
|
972
|
+
|
|
973
|
+
def get_mea_baseline(self, mea_id: int) -> dict:
|
|
974
|
+
"""GET /api/v2/mea/{mea_id}/"""
|
|
975
|
+
return self._get(f"/mea/{mea_id}/")
|
|
976
|
+
|
|
977
|
+
def get_mea_kpis(self, name: str | None = None, category: str | None = None,
|
|
978
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
979
|
+
"""
|
|
980
|
+
GET /api/v2/mea-kpis/
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
name: Substring filter on KPI name.
|
|
984
|
+
category: Exact category filter (e.g. 'CAC').
|
|
985
|
+
"""
|
|
986
|
+
params = _compact(name=name, category=category, limit=limit, offset=offset)
|
|
987
|
+
return self._to_df(self._get("/mea-kpis/", params))
|
|
988
|
+
|
|
989
|
+
def get_mea_kpi(self, kpi_id: int) -> dict:
|
|
990
|
+
"""GET /api/v2/mea-kpis/{kpi_id}/"""
|
|
991
|
+
return self._get(f"/mea-kpis/{kpi_id}/")
|
|
992
|
+
|
|
993
|
+
# ── Science data ──────────────────────────────────────────────────────────
|
|
994
|
+
|
|
995
|
+
def get_isotherm(self,
|
|
996
|
+
mof: str | None = None,
|
|
997
|
+
molecule: str | None = None,
|
|
998
|
+
temperature_min: float | None = None,
|
|
999
|
+
temperature_max: float | None = None,
|
|
1000
|
+
sim_or_exp: str | None = None,
|
|
1001
|
+
good_structure: bool | None = None,
|
|
1002
|
+
limit: int = 500,
|
|
1003
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1004
|
+
"""
|
|
1005
|
+
GET /api/v2/isotherms/
|
|
1006
|
+
|
|
1007
|
+
Args:
|
|
1008
|
+
mof: MOF name substring filter.
|
|
1009
|
+
molecule: Molecule name substring filter.
|
|
1010
|
+
temperature_min: Lower bound on T_ref_K [K].
|
|
1011
|
+
temperature_max: Upper bound on T_ref_K [K].
|
|
1012
|
+
sim_or_exp: 'sim' or 'exp'.
|
|
1013
|
+
good_structure: Filter to good/bad structures.
|
|
1014
|
+
limit: Max records (default 500).
|
|
1015
|
+
offset: Pagination offset.
|
|
1016
|
+
|
|
1017
|
+
Returns:
|
|
1018
|
+
DataFrame with one row per isotherm record.
|
|
1019
|
+
"""
|
|
1020
|
+
params = _compact(
|
|
1021
|
+
mof=mof, molecule=molecule,
|
|
1022
|
+
temperature_min=temperature_min, temperature_max=temperature_max,
|
|
1023
|
+
sim_or_exp=sim_or_exp,
|
|
1024
|
+
good_structure=None if good_structure is None else str(good_structure).lower(),
|
|
1025
|
+
limit=limit, offset=offset,
|
|
1026
|
+
)
|
|
1027
|
+
return self._to_df(self._get("/isotherms/", params))
|
|
1028
|
+
|
|
1029
|
+
def get_water_kpis(self,
|
|
1030
|
+
mof: str | None = None,
|
|
1031
|
+
molecule: str | None = None,
|
|
1032
|
+
source: str | None = None,
|
|
1033
|
+
sim_or_exp: str | None = None,
|
|
1034
|
+
good_structure: bool | None = None,
|
|
1035
|
+
limit: int = 500,
|
|
1036
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1037
|
+
"""
|
|
1038
|
+
GET /api/v2/water-kpis/
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
mof: MOF name substring filter.
|
|
1042
|
+
molecule: Molecule name substring filter.
|
|
1043
|
+
source: Source name substring filter.
|
|
1044
|
+
sim_or_exp: 'sim' or 'exp'.
|
|
1045
|
+
good_structure: Filter to good/bad structures.
|
|
1046
|
+
"""
|
|
1047
|
+
params = _compact(
|
|
1048
|
+
mof=mof, molecule=molecule, source=source,
|
|
1049
|
+
sim_or_exp=sim_or_exp,
|
|
1050
|
+
good_structure=None if good_structure is None else str(good_structure).lower(),
|
|
1051
|
+
limit=limit, offset=offset,
|
|
1052
|
+
)
|
|
1053
|
+
records = self._to_df(self._get("/water-kpis/", params))
|
|
1054
|
+
# Strip integer FK fields 'MOF' and 'Molecule' (DB PKs) — the
|
|
1055
|
+
# human-readable equivalents are kept as 'mof' and 'molecule'.
|
|
1056
|
+
_drop = {"MOF", "Molecule"}
|
|
1057
|
+
if isinstance(records, list):
|
|
1058
|
+
return [{k: v for k, v in r.items() if k not in _drop} for r in records]
|
|
1059
|
+
if isinstance(records, pd.DataFrame):
|
|
1060
|
+
return records.drop(columns=[c for c in _drop if c in records.columns])
|
|
1061
|
+
return records
|
|
1062
|
+
|
|
1063
|
+
def get_carbon_zeopp(self,
|
|
1064
|
+
mof: str | None = None,
|
|
1065
|
+
good_structure: bool | None = None,
|
|
1066
|
+
limit: int = 500,
|
|
1067
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1068
|
+
"""
|
|
1069
|
+
GET /api/v2/carbon-zeopp/
|
|
1070
|
+
|
|
1071
|
+
Simulated Zeo++ geometric characterisation data.
|
|
1072
|
+
|
|
1073
|
+
Args:
|
|
1074
|
+
mof: MOF name substring filter.
|
|
1075
|
+
good_structure: Filter to good/bad structures.
|
|
1076
|
+
"""
|
|
1077
|
+
params = _compact(
|
|
1078
|
+
mof=mof,
|
|
1079
|
+
good_structure=None if good_structure is None else str(good_structure).lower(),
|
|
1080
|
+
limit=limit, offset=offset,
|
|
1081
|
+
)
|
|
1082
|
+
return self._to_df(self._get("/carbon-zeopp/", params))
|
|
1083
|
+
|
|
1084
|
+
def get_carbon_zeopp_item(self, item_id: int) -> dict:
|
|
1085
|
+
"""GET /api/v2/carbon-zeopp/{item_id}/"""
|
|
1086
|
+
return self._get(f"/carbon-zeopp/{item_id}/")
|
|
1087
|
+
|
|
1088
|
+
def get_carbon_zeopp_experimental(self,
|
|
1089
|
+
mof: str | None = None,
|
|
1090
|
+
limit: int = 500,
|
|
1091
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1092
|
+
"""
|
|
1093
|
+
GET /api/v2/carbon-zeopp-experimental/
|
|
1094
|
+
|
|
1095
|
+
Experimental Zeo++ geometric characterisation data.
|
|
1096
|
+
|
|
1097
|
+
Args:
|
|
1098
|
+
mof: MOF name substring filter.
|
|
1099
|
+
"""
|
|
1100
|
+
params = _compact(mof=mof, limit=limit, offset=offset)
|
|
1101
|
+
return self._to_df(self._get("/carbon-zeopp-experimental/", params))
|
|
1102
|
+
|
|
1103
|
+
def get_carbon_zeopp_experimental_item(self, item_id: int) -> dict:
|
|
1104
|
+
"""GET /api/v2/carbon-zeopp-experimental/{item_id}/"""
|
|
1105
|
+
return self._get(f"/carbon-zeopp-experimental/{item_id}/")
|
|
1106
|
+
|
|
1107
|
+
# ── TEA / LCA data ────────────────────────────────────────────────────────
|
|
1108
|
+
|
|
1109
|
+
def get_output_kpis(self,
|
|
1110
|
+
scenario_id: int | None = None,
|
|
1111
|
+
mof: str | None = None,
|
|
1112
|
+
good_structure: bool | None = None,
|
|
1113
|
+
limit: int = 500,
|
|
1114
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1115
|
+
"""
|
|
1116
|
+
GET /api/v2/output-kpis/
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
scenario_id: Exact scenario PK filter.
|
|
1120
|
+
mof: MOF name substring filter.
|
|
1121
|
+
good_structure: Filter to good/bad structures.
|
|
1122
|
+
"""
|
|
1123
|
+
params = _compact(
|
|
1124
|
+
scenario_id=scenario_id, mof=mof,
|
|
1125
|
+
good_structure=None if good_structure is None else str(good_structure).lower(),
|
|
1126
|
+
limit=limit, offset=offset,
|
|
1127
|
+
)
|
|
1128
|
+
return self._to_df(self._get("/output-kpis/", params))
|
|
1129
|
+
|
|
1130
|
+
def get_output_kpi(self, kpi_id: int) -> dict:
|
|
1131
|
+
"""GET /api/v2/output-kpis/{kpi_id}/"""
|
|
1132
|
+
return self._get(f"/output-kpis/{kpi_id}/")
|
|
1133
|
+
|
|
1134
|
+
def upsert_output_kpis(self, df: pd.DataFrame) -> dict:
|
|
1135
|
+
"""
|
|
1136
|
+
PUT /api/v2/output-kpis/
|
|
1137
|
+
|
|
1138
|
+
Bulk upsert. Lookup key: (scenario, MOF) integer PKs.
|
|
1139
|
+
|
|
1140
|
+
Args:
|
|
1141
|
+
df: DataFrame with columns matching the OutputKpi write schema.
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
dict with keys 'created', 'updated', and optionally 'errors'.
|
|
1145
|
+
"""
|
|
1146
|
+
return self._put("/output-kpis/", df.to_dict(orient="records"))
|
|
1147
|
+
|
|
1148
|
+
def get_region_costs(self,
|
|
1149
|
+
region: str | None = None,
|
|
1150
|
+
name: str | None = None,
|
|
1151
|
+
year: int | None = None,
|
|
1152
|
+
limit: int = 500,
|
|
1153
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1154
|
+
"""
|
|
1155
|
+
GET /api/v2/region-costs/
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
region: Exact region ISO code filter.
|
|
1159
|
+
name: Substring filter on cost name.
|
|
1160
|
+
year: Exact year filter.
|
|
1161
|
+
"""
|
|
1162
|
+
params = _compact(region=region, name=name, year=year, limit=limit, offset=offset)
|
|
1163
|
+
return self._to_df(self._get("/region-costs/", params))
|
|
1164
|
+
|
|
1165
|
+
def get_region_cost(self, rc_id: int) -> dict:
|
|
1166
|
+
"""GET /api/v2/region-costs/{rc_id}/"""
|
|
1167
|
+
return self._get(f"/region-costs/{rc_id}/")
|
|
1168
|
+
|
|
1169
|
+
def upsert_region_costs(self, df: pd.DataFrame) -> dict:
|
|
1170
|
+
"""
|
|
1171
|
+
PUT /api/v2/region-costs/ Lookup key: Name (unique).
|
|
1172
|
+
|
|
1173
|
+
Args:
|
|
1174
|
+
df: DataFrame with columns matching the RegionCost write schema.
|
|
1175
|
+
"""
|
|
1176
|
+
return self._put("/region-costs/", df.to_dict(orient="records"))
|
|
1177
|
+
|
|
1178
|
+
def get_ambient_parameters(self, name: str | None = None,
|
|
1179
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
1180
|
+
"""GET /api/v2/ambient-parameters/"""
|
|
1181
|
+
params = _compact(name=name, limit=limit, offset=offset)
|
|
1182
|
+
return self._to_df(self._get("/ambient-parameters/", params))
|
|
1183
|
+
|
|
1184
|
+
def get_ambient_parameter(self, ap_id: int) -> dict:
|
|
1185
|
+
"""GET /api/v2/ambient-parameters/{ap_id}/"""
|
|
1186
|
+
return self._get(f"/ambient-parameters/{ap_id}/")
|
|
1187
|
+
|
|
1188
|
+
def upsert_ambient_parameters(self, df: pd.DataFrame) -> dict:
|
|
1189
|
+
"""
|
|
1190
|
+
PUT /api/v2/ambient-parameters/ Lookup key: Name (unique).
|
|
1191
|
+
|
|
1192
|
+
Args:
|
|
1193
|
+
df: DataFrame with columns matching the AmbientParameter write schema.
|
|
1194
|
+
"""
|
|
1195
|
+
return self._put("/ambient-parameters/", df.to_dict(orient="records"))
|
|
1196
|
+
|
|
1197
|
+
# ── Cases & Scenarios ─────────────────────────────────────────────────────
|
|
1198
|
+
|
|
1199
|
+
def get_cases(self,
|
|
1200
|
+
source: str | None = None,
|
|
1201
|
+
sink: str | None = None,
|
|
1202
|
+
region: str | None = None,
|
|
1203
|
+
study: str | None = None,
|
|
1204
|
+
limit: int = 500,
|
|
1205
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1206
|
+
"""
|
|
1207
|
+
GET /api/v2/cases/
|
|
1208
|
+
|
|
1209
|
+
Args:
|
|
1210
|
+
source: Source name substring filter.
|
|
1211
|
+
sink: Sink name substring filter.
|
|
1212
|
+
region: Exact region ISO code filter.
|
|
1213
|
+
study: Exact study label filter.
|
|
1214
|
+
"""
|
|
1215
|
+
params = _compact(source=source, sink=sink, region=region,
|
|
1216
|
+
study=study, limit=limit, offset=offset)
|
|
1217
|
+
return self._to_df(self._get("/cases/", params))
|
|
1218
|
+
|
|
1219
|
+
def get_case(self, case_id: int) -> dict:
|
|
1220
|
+
"""GET /api/v2/cases/{case_id}/"""
|
|
1221
|
+
return self._get(f"/cases/{case_id}/")
|
|
1222
|
+
|
|
1223
|
+
def get_scenarios(self,
|
|
1224
|
+
case_id: int | None = None,
|
|
1225
|
+
name: str | None = None,
|
|
1226
|
+
type: str | None = None,
|
|
1227
|
+
limit: int = 500,
|
|
1228
|
+
offset: int = 0) -> pd.DataFrame:
|
|
1229
|
+
"""
|
|
1230
|
+
GET /api/v2/scenarios/
|
|
1231
|
+
|
|
1232
|
+
Args:
|
|
1233
|
+
case_id: Exact case PK filter.
|
|
1234
|
+
name: Substring filter on name or print_name.
|
|
1235
|
+
type: Exact scenario type filter (e.g. 'TEA').
|
|
1236
|
+
"""
|
|
1237
|
+
params = _compact(case_id=case_id, name=name, type=type,
|
|
1238
|
+
limit=limit, offset=offset)
|
|
1239
|
+
return self._to_df(self._get("/scenarios/", params))
|
|
1240
|
+
|
|
1241
|
+
def get_scenario(self, scenario_id: int) -> dict:
|
|
1242
|
+
"""GET /api/v2/scenarios/{scenario_id}/"""
|
|
1243
|
+
return self._get(f"/scenarios/{scenario_id}/")
|
|
1244
|
+
|
|
1245
|
+
# ── Case-pack builders (ImportedCasePack spec) ────────────────────────────
|
|
1246
|
+
|
|
1247
|
+
@staticmethod
|
|
1248
|
+
def _component_spec(component_type: str, name: str | None) -> dict | None:
|
|
1249
|
+
"""
|
|
1250
|
+
Build a minimal ``CaseComponentSpec`` dict from a name string.
|
|
1251
|
+
|
|
1252
|
+
Fields that require a local YAML document (``document``,
|
|
1253
|
+
``region_use``, ``region_synthesis``, ``region_storage``,
|
|
1254
|
+
``sink_type``) are set to ``None`` — they are not stored on the
|
|
1255
|
+
remote Django models and can only be populated from the originating
|
|
1256
|
+
YAML pack.
|
|
1257
|
+
"""
|
|
1258
|
+
if name is None:
|
|
1259
|
+
return None
|
|
1260
|
+
return {
|
|
1261
|
+
"component_type": component_type,
|
|
1262
|
+
"name": name,
|
|
1263
|
+
"document": None,
|
|
1264
|
+
"region_use": None,
|
|
1265
|
+
"region_synthesis": None,
|
|
1266
|
+
"region_storage": None,
|
|
1267
|
+
"sink_type": None,
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
def build_case_spec(self, case_id: int) -> dict:
|
|
1271
|
+
"""
|
|
1272
|
+
Fetch ``GET /api/v2/cases/{case_id}/`` and return a ``CaseSpec``-shaped
|
|
1273
|
+
nested dict conforming to the ``ImportedCasePack`` spec.
|
|
1274
|
+
|
|
1275
|
+
Fields that live only in the originating YAML pack (``root_case_path``,
|
|
1276
|
+
per-component ``document`` sub-trees, ``import_issues``) are
|
|
1277
|
+
returned as ``None`` / ``[]``.
|
|
1278
|
+
|
|
1279
|
+
Args:
|
|
1280
|
+
case_id: PK of the ``CaseStudy`` record.
|
|
1281
|
+
|
|
1282
|
+
Returns:
|
|
1283
|
+
``dict`` matching the ``CaseSpec`` schema::
|
|
1284
|
+
|
|
1285
|
+
{
|
|
1286
|
+
"case_name": str,
|
|
1287
|
+
"source_name": str,
|
|
1288
|
+
"sink_name": str,
|
|
1289
|
+
"region": str,
|
|
1290
|
+
"root_case_path": None,
|
|
1291
|
+
"source": { CaseComponentSpec },
|
|
1292
|
+
"sink": { CaseComponentSpec },
|
|
1293
|
+
"transport": { CaseComponentSpec } | None,
|
|
1294
|
+
"utilities": [ CaseComponentSpec, ... ],
|
|
1295
|
+
"tea_general": None,
|
|
1296
|
+
"import_issues": []
|
|
1297
|
+
}
|
|
1298
|
+
"""
|
|
1299
|
+
case = self.get_case(case_id)
|
|
1300
|
+
transport_name = case.get("transport_scenario")
|
|
1301
|
+
utilities_raw = case.get("utilities") or []
|
|
1302
|
+
# utilities may come back as a string or list depending on serializer
|
|
1303
|
+
if isinstance(utilities_raw, str):
|
|
1304
|
+
utilities_raw = [utilities_raw] if utilities_raw else []
|
|
1305
|
+
|
|
1306
|
+
return {
|
|
1307
|
+
"case_name": case.get("name"),
|
|
1308
|
+
"source_name": case.get("source"),
|
|
1309
|
+
"sink_name": case.get("sink"),
|
|
1310
|
+
"region": case.get("region"),
|
|
1311
|
+
"root_case_path": None,
|
|
1312
|
+
"source": self._component_spec("source", case.get("source")),
|
|
1313
|
+
"sink": self._component_spec("sink", case.get("sink")),
|
|
1314
|
+
"transport": self._component_spec("transport", transport_name),
|
|
1315
|
+
"utilities": [
|
|
1316
|
+
self._component_spec("utility", u)
|
|
1317
|
+
for u in utilities_raw
|
|
1318
|
+
if u
|
|
1319
|
+
],
|
|
1320
|
+
"tea_general": None,
|
|
1321
|
+
"import_issues": [],
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
def build_scenario_spec(self, scenario_id: int) -> dict:
|
|
1325
|
+
"""
|
|
1326
|
+
Fetch ``GET /api/v2/scenarios/{scenario_id}/`` and return a
|
|
1327
|
+
``ScenarioSpec``-shaped nested dict.
|
|
1328
|
+
|
|
1329
|
+
``process``, ``adsorption_scenario``, ``process_preview`` and the
|
|
1330
|
+
compiled science sub-objects are not stored on the remote Django
|
|
1331
|
+
models; they are returned as ``None``.
|
|
1332
|
+
|
|
1333
|
+
Args:
|
|
1334
|
+
scenario_id: PK of the ``Scenario`` record.
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
``dict`` matching the ``ScenarioSpec`` schema::
|
|
1338
|
+
|
|
1339
|
+
{
|
|
1340
|
+
"scenario_name": str,
|
|
1341
|
+
"case_name": str,
|
|
1342
|
+
"source_name": None, # not on Scenario model
|
|
1343
|
+
"sink_name": None,
|
|
1344
|
+
"region": None,
|
|
1345
|
+
"process": None,
|
|
1346
|
+
"adsorption_scenario": None,
|
|
1347
|
+
"process_preview": None,
|
|
1348
|
+
"utilities": [],
|
|
1349
|
+
"tea_general": None,
|
|
1350
|
+
"import_issues": []
|
|
1351
|
+
}
|
|
1352
|
+
"""
|
|
1353
|
+
scenario = self.get_scenario(scenario_id)
|
|
1354
|
+
return {
|
|
1355
|
+
"scenario_name": scenario.get("name"),
|
|
1356
|
+
"case_name": scenario.get("case_study_name"),
|
|
1357
|
+
"source_name": None,
|
|
1358
|
+
"sink_name": None,
|
|
1359
|
+
"region": None,
|
|
1360
|
+
"process": None,
|
|
1361
|
+
"adsorption_scenario": None,
|
|
1362
|
+
"process_preview": None,
|
|
1363
|
+
"utilities": [],
|
|
1364
|
+
"tea_general": None,
|
|
1365
|
+
"import_issues": [],
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
def build_case_pack(self, case_id: int,
|
|
1369
|
+
scenario_id: int | None = None) -> dict:
|
|
1370
|
+
"""
|
|
1371
|
+
Assemble an ``ImportedCasePack``-shaped nested dict for a single case,
|
|
1372
|
+
using two remote calls:
|
|
1373
|
+
|
|
1374
|
+
* ``GET /api/v2/cases/{case_id}/``
|
|
1375
|
+
* ``GET /api/v2/scenarios/?case_id={case_id}`` *(or a specific scenario)*
|
|
1376
|
+
|
|
1377
|
+
The result conforms to the ``ImportedCasePack`` JSON contract::
|
|
1378
|
+
|
|
1379
|
+
{
|
|
1380
|
+
"pack_root": None,
|
|
1381
|
+
"case_spec": { CaseSpec },
|
|
1382
|
+
"scenario_spec": { ScenarioSpec } | None,
|
|
1383
|
+
"available_documents": [],
|
|
1384
|
+
"import_issues": []
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
``pack_root``, ``available_documents``, and per-document ``sections``/
|
|
1388
|
+
``scalar_entries`` sub-trees are not stored on the remote Django models;
|
|
1389
|
+
they are returned as ``None`` / ``[]``. The caller can merge in locally
|
|
1390
|
+
scanned document data if needed.
|
|
1391
|
+
|
|
1392
|
+
Args:
|
|
1393
|
+
case_id: PK of the ``CaseStudy`` record.
|
|
1394
|
+
scenario_id: Optional specific ``Scenario`` PK. When omitted the
|
|
1395
|
+
first scenario found for the case is used (if any).
|
|
1396
|
+
Pass ``-1`` to suppress scenario resolution entirely
|
|
1397
|
+
and always return ``scenario_spec: null``.
|
|
1398
|
+
|
|
1399
|
+
Returns:
|
|
1400
|
+
Nested ``dict`` matching the ``ImportedCasePack`` spec.
|
|
1401
|
+
"""
|
|
1402
|
+
case_spec = self.build_case_spec(case_id)
|
|
1403
|
+
|
|
1404
|
+
scenario_spec: dict | None = None
|
|
1405
|
+
if scenario_id != -1:
|
|
1406
|
+
if scenario_id is not None:
|
|
1407
|
+
scenario_spec = self.build_scenario_spec(scenario_id)
|
|
1408
|
+
else:
|
|
1409
|
+
# Resolve the first available scenario for this case
|
|
1410
|
+
raw = self._get("/scenarios/", {"case_id": case_id, "limit": 1, "offset": 0})
|
|
1411
|
+
results = raw.get("results", [])
|
|
1412
|
+
if results:
|
|
1413
|
+
sid = results[0]["id"]
|
|
1414
|
+
scenario_spec = self.build_scenario_spec(sid)
|
|
1415
|
+
|
|
1416
|
+
return {
|
|
1417
|
+
"pack_root": None,
|
|
1418
|
+
"case_spec": case_spec,
|
|
1419
|
+
"scenario_spec": scenario_spec,
|
|
1420
|
+
"available_documents": [],
|
|
1421
|
+
"import_issues": [],
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
def list_case_packs(self,
|
|
1425
|
+
source: str | None = None,
|
|
1426
|
+
sink: str | None = None,
|
|
1427
|
+
region: str | None = None,
|
|
1428
|
+
study: str | None = None,
|
|
1429
|
+
include_scenarios: bool = False,
|
|
1430
|
+
limit: int = 100,
|
|
1431
|
+
offset: int = 0) -> list[dict]:
|
|
1432
|
+
"""
|
|
1433
|
+
Return a list of ``ImportedCasePack``-shaped dicts for every matching
|
|
1434
|
+
case, using ``GET /api/v2/cases/``.
|
|
1435
|
+
|
|
1436
|
+
By default ``scenario_spec`` is ``null`` for every record to keep the
|
|
1437
|
+
response lightweight. Set ``include_scenarios=True`` to resolve the
|
|
1438
|
+
first scenario for each case (one extra GET per case).
|
|
1439
|
+
|
|
1440
|
+
Args:
|
|
1441
|
+
source: Source name substring filter.
|
|
1442
|
+
sink: Sink name substring filter.
|
|
1443
|
+
region: Exact region ISO code filter.
|
|
1444
|
+
study: Exact study label filter.
|
|
1445
|
+
include_scenarios: If ``True``, attach ``scenario_spec`` for each
|
|
1446
|
+
case (N+1 requests — use with small result sets).
|
|
1447
|
+
limit: Max cases to return (default 100).
|
|
1448
|
+
offset: Pagination offset.
|
|
1449
|
+
|
|
1450
|
+
Returns:
|
|
1451
|
+
``list[dict]`` — each element is an ``ImportedCasePack`` dict.
|
|
1452
|
+
"""
|
|
1453
|
+
params = _compact(source=source, sink=sink, region=region,
|
|
1454
|
+
study=study, limit=limit, offset=offset)
|
|
1455
|
+
raw_cases = self._get("/cases/", params).get("results", [])
|
|
1456
|
+
|
|
1457
|
+
packs: list[dict] = []
|
|
1458
|
+
for case in raw_cases:
|
|
1459
|
+
case_id = case["id"]
|
|
1460
|
+
transport_name = case.get("transport_scenario")
|
|
1461
|
+
utilities_raw = case.get("utilities") or []
|
|
1462
|
+
if isinstance(utilities_raw, str):
|
|
1463
|
+
utilities_raw = [utilities_raw] if utilities_raw else []
|
|
1464
|
+
|
|
1465
|
+
case_spec: dict = {
|
|
1466
|
+
"case_name": case.get("name"),
|
|
1467
|
+
"source_name": case.get("source"),
|
|
1468
|
+
"sink_name": case.get("sink"),
|
|
1469
|
+
"region": case.get("region"),
|
|
1470
|
+
"root_case_path": None,
|
|
1471
|
+
"source": self._component_spec("source", case.get("source")),
|
|
1472
|
+
"sink": self._component_spec("sink", case.get("sink")),
|
|
1473
|
+
"transport": self._component_spec("transport", transport_name),
|
|
1474
|
+
"utilities": [
|
|
1475
|
+
self._component_spec("utility", u)
|
|
1476
|
+
for u in utilities_raw if u
|
|
1477
|
+
],
|
|
1478
|
+
"tea_general": None,
|
|
1479
|
+
"import_issues": [],
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
scenario_spec: dict | None = None
|
|
1483
|
+
if include_scenarios:
|
|
1484
|
+
raw = self._get("/scenarios/", {"case_id": case_id, "limit": 1})
|
|
1485
|
+
results = raw.get("results", [])
|
|
1486
|
+
if results:
|
|
1487
|
+
sid = results[0]["id"]
|
|
1488
|
+
sc = results[0]
|
|
1489
|
+
scenario_spec = {
|
|
1490
|
+
"scenario_name": sc.get("name"),
|
|
1491
|
+
"case_name": sc.get("case_study_name"),
|
|
1492
|
+
"source_name": None,
|
|
1493
|
+
"sink_name": None,
|
|
1494
|
+
"region": None,
|
|
1495
|
+
"process": None,
|
|
1496
|
+
"adsorption_scenario": None,
|
|
1497
|
+
"process_preview": None,
|
|
1498
|
+
"utilities": [],
|
|
1499
|
+
"tea_general": None,
|
|
1500
|
+
"import_issues": [],
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
packs.append({
|
|
1504
|
+
"pack_root": None,
|
|
1505
|
+
"case_spec": case_spec,
|
|
1506
|
+
"scenario_spec": scenario_spec,
|
|
1507
|
+
"available_documents": [],
|
|
1508
|
+
"import_issues": [],
|
|
1509
|
+
})
|
|
1510
|
+
|
|
1511
|
+
return packs
|
|
1512
|
+
|
|
1513
|
+
def get_screening_summaries(self, scenario_id: int | None = None,
|
|
1514
|
+
limit: int = 500, offset: int = 0) -> pd.DataFrame:
|
|
1515
|
+
"""
|
|
1516
|
+
GET /api/v2/screening-summaries/
|
|
1517
|
+
|
|
1518
|
+
Args:
|
|
1519
|
+
scenario_id: Exact scenario PK filter.
|
|
1520
|
+
"""
|
|
1521
|
+
params = _compact(scenario_id=scenario_id, limit=limit, offset=offset)
|
|
1522
|
+
return self._to_df(self._get("/screening-summaries/", params))
|
|
1523
|
+
|
|
1524
|
+
def get_screening_summary(self, summary_id: int) -> dict:
|
|
1525
|
+
"""GET /api/v2/screening-summaries/{summary_id}/"""
|
|
1526
|
+
return self._get(f"/screening-summaries/{summary_id}/")
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
# ── Module-level helper ───────────────────────────────────────────────────────
|
|
1530
|
+
|
|
1531
|
+
def _compact(**kwargs) -> dict:
|
|
1532
|
+
"""Return kwargs dict with None values removed."""
|
|
1533
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|