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.
@@ -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}