prisma-api 0.3.10__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.3.10
3
+ Version: 0.4.0
4
4
  Summary: A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk.
5
5
  Author-email: RCCS-CaptureTeam <team@prisma-platform.org>
6
6
  License: GNUv3
@@ -24,6 +24,7 @@ from urllib.parse import urlencode
24
24
  import warnings
25
25
  import json
26
26
  import re
27
+ import subprocess
27
28
  from datetime import datetime
28
29
 
29
30
 
@@ -966,6 +967,86 @@ class PrismaAPIv2:
966
967
  return result
967
968
  return []
968
969
 
970
+ def _payload_to_records(self, payload: pd.DataFrame | list[dict] | dict) -> list[dict]:
971
+ """Normalise write payloads to list[dict] while preserving all fields."""
972
+ if isinstance(payload, pd.DataFrame):
973
+ return payload.to_dict(orient="records")
974
+ if isinstance(payload, dict):
975
+ return [payload]
976
+ if isinstance(payload, list):
977
+ return payload
978
+ raise TypeError("payload must be a DataFrame, dict, or list[dict]")
979
+
980
+ def _autoprism_meta_provenance(self) -> dict[str, str | None]:
981
+ """Build meta_provenance from local repository and pyproject metadata."""
982
+
983
+ def _git(args: list[str]) -> str | None:
984
+ try:
985
+ cp = subprocess.run(
986
+ ["git", *args],
987
+ cwd=Path(__file__).resolve().parents[1],
988
+ check=False,
989
+ capture_output=True,
990
+ text=True,
991
+ )
992
+ except OSError:
993
+ return None
994
+ if cp.returncode != 0:
995
+ return None
996
+ value = cp.stdout.strip()
997
+ return value or None
998
+
999
+ repo_root = _git(["rev-parse", "--show-toplevel"])
1000
+ repo_name = Path(repo_root).name if repo_root else Path(__file__).resolve().parents[1].name
1001
+ branch = _git(["rev-parse", "--abbrev-ref", "HEAD"])
1002
+ source_repo = (
1003
+ repo_name
1004
+ if branch in (None, "main")
1005
+ else f"{repo_name} (branch: {branch})"
1006
+ )
1007
+
1008
+ pyproject_version: str | None = None
1009
+ pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
1010
+ if pyproject_path.exists():
1011
+ text = pyproject_path.read_text(encoding="utf-8")
1012
+ try:
1013
+ import tomllib # Python 3.11+
1014
+
1015
+ data = tomllib.loads(text)
1016
+ project = data.get("project", {}) if isinstance(data, dict) else {}
1017
+ value = project.get("version") if isinstance(project, dict) else None
1018
+ pyproject_version = str(value) if value else None
1019
+ except Exception:
1020
+ # Fallback for environments where tomllib is unavailable.
1021
+ m = re.search(r"(?ms)^\[project\].*?^version\s*=\s*\"([^\"]+)\"", text)
1022
+ pyproject_version = m.group(1).strip() if m else None
1023
+
1024
+ return {
1025
+ "source_repo": source_repo,
1026
+ "source_repo_semantic_version": pyproject_version,
1027
+ "source_repo_tag": _git(["describe", "--tags", "--abbrev=0"]),
1028
+ "source_commit_hash": _git(["rev-parse", "HEAD"]),
1029
+ }
1030
+
1031
+ def _autoprism_source_repo_url(self) -> str | None:
1032
+ """Return the repository remote URL used for source provenance, if available."""
1033
+ try:
1034
+ cp = subprocess.run(
1035
+ ["git", "remote", "get-url", "origin"],
1036
+ cwd=Path(__file__).resolve().parents[1],
1037
+ check=False,
1038
+ capture_output=True,
1039
+ text=True,
1040
+ )
1041
+ except OSError:
1042
+ return None
1043
+
1044
+ if cp.returncode != 0:
1045
+ return None
1046
+
1047
+ value = cp.stdout.strip()
1048
+ return value or None
1049
+
969
1050
  def _properties_for(self, object_id: int, limit: int = 2000) -> list[dict]:
970
1051
  """Return all Property records linked to *object_id* via GenericForeignKey.
971
1052
 
@@ -1695,6 +1776,561 @@ class PrismaAPIv2:
1695
1776
  """GET /api/v2/carbon-zeopp-experimental/{item_id}/"""
1696
1777
  return self._get(f"/carbon-zeopp-experimental/{item_id}/")
1697
1778
 
1779
+ # ── AutoPrism tables ─────────────────────────────────────────────────────
1780
+
1781
+ def get_computation_runs(
1782
+ self,
1783
+ workflow_id: str | None = None,
1784
+ step: str | None = None,
1785
+ status: str | None = None,
1786
+ limit: int = 500,
1787
+ offset: int = 0,
1788
+ ) -> pd.DataFrame:
1789
+ """
1790
+ GET /api/v2/computation-runs/
1791
+
1792
+ Args:
1793
+ workflow_id: Workflow identifier filter.
1794
+ step: Step filter.
1795
+ status: Status filter.
1796
+ """
1797
+ params = _compact(
1798
+ workflow_id=workflow_id,
1799
+ step=step,
1800
+ status=status,
1801
+ limit=limit,
1802
+ offset=offset,
1803
+ )
1804
+ return self._to_df(self._get("/computation-runs/", params))
1805
+
1806
+ def get_computation_run(self, run_id: int) -> dict:
1807
+ """GET /api/v2/computation-runs/{run_id}/"""
1808
+ return self._get(f"/computation-runs/{run_id}/")
1809
+
1810
+ def upsert_computation_runs(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
1811
+ """
1812
+ PUT /api/v2/computation-runs/
1813
+
1814
+ Accepts one object or many objects and forwards all provided fields.
1815
+ """
1816
+ return self._put("/computation-runs/", self._payload_to_records(payload))
1817
+
1818
+ def get_adsorption_singlepoint(
1819
+ self,
1820
+ structure: str | None = None,
1821
+ md5: str | None = None,
1822
+ mixture_id: str | None = None,
1823
+ component: str | None = None,
1824
+ limit: int = 500,
1825
+ offset: int = 0,
1826
+ ) -> pd.DataFrame:
1827
+ """
1828
+ GET /api/v2/adsorption-singlepoint/
1829
+
1830
+ Args:
1831
+ structure: Structure name filter.
1832
+ md5: Exact md5 hash filter.
1833
+ mixture_id: Mixture identifier filter.
1834
+ component: Component filter.
1835
+ """
1836
+ params = _compact(
1837
+ structure=structure,
1838
+ md5=md5,
1839
+ mixture_id=mixture_id,
1840
+ component=component,
1841
+ limit=limit,
1842
+ offset=offset,
1843
+ )
1844
+ return self._to_df(self._get("/adsorption-singlepoint/", params))
1845
+
1846
+ def get_adsorption_singlepoint_item(self, row_id: int) -> dict:
1847
+ """GET /api/v2/adsorption-singlepoint/{row_id}/"""
1848
+ return self._get(f"/adsorption-singlepoint/{row_id}/")
1849
+
1850
+ def upsert_adsorption_singlepoint(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
1851
+ """
1852
+ PUT /api/v2/adsorption-singlepoint/
1853
+
1854
+ Accepts one object or many objects.
1855
+ Automatically normalises ``meta_provenance`` using local repo metadata.
1856
+ """
1857
+ if isinstance(payload, dict) and isinstance(payload.get("adsorption_singlepoints"), list):
1858
+ records = self._payload_to_records(payload["adsorption_singlepoints"])
1859
+ else:
1860
+ records = self._payload_to_records(payload)
1861
+ meta = {
1862
+ **self._autoprism_meta_provenance(),
1863
+ "source_repo_url": self._autoprism_source_repo_url(),
1864
+ }
1865
+ enriched = [
1866
+ {
1867
+ **record,
1868
+ "meta_provenance": meta,
1869
+ }
1870
+ if isinstance(record, dict)
1871
+ else record
1872
+ for record in records
1873
+ ]
1874
+ return self._put("/adsorption-singlepoint/", enriched)
1875
+
1876
+ def get_heat_capacity(
1877
+ self,
1878
+ structure: str | None = None,
1879
+ temperature_K: float | None = None,
1880
+ limit: int = 500,
1881
+ offset: int = 0,
1882
+ ) -> pd.DataFrame:
1883
+ """
1884
+ GET /api/v2/heat-capacity/
1885
+
1886
+ Args:
1887
+ structure: Structure name filter.
1888
+ temperature_K: Temperature filter [K].
1889
+ """
1890
+ params = _compact(
1891
+ structure=structure,
1892
+ temperature_K=temperature_K,
1893
+ limit=limit,
1894
+ offset=offset,
1895
+ )
1896
+ return self._to_df(self._get("/heat-capacity/", params))
1897
+
1898
+ def get_heat_capacity_item(self, row_id: int) -> dict:
1899
+ """GET /api/v2/heat-capacity/{row_id}/"""
1900
+ return self._get(f"/heat-capacity/{row_id}/")
1901
+
1902
+ def upsert_heat_capacity(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
1903
+ """
1904
+ PUT /api/v2/heat-capacity/
1905
+
1906
+ Accepts one object or many objects.
1907
+ Automatically normalises ``meta_provenance`` using local repo metadata.
1908
+ """
1909
+ if isinstance(payload, dict) and isinstance(payload.get("heat_capacities"), list):
1910
+ records = self._payload_to_records(payload["heat_capacities"])
1911
+ else:
1912
+ records = self._payload_to_records(payload)
1913
+
1914
+ meta = {
1915
+ **self._autoprism_meta_provenance(),
1916
+ "source_repo_url": self._autoprism_source_repo_url(),
1917
+ }
1918
+ enriched = [
1919
+ {
1920
+ **record,
1921
+ "meta_provenance": meta,
1922
+ }
1923
+ if isinstance(record, dict)
1924
+ else record
1925
+ for record in records
1926
+ ]
1927
+ return self._put("/heat-capacity/", enriched)
1928
+
1929
+ def get_isotherm_h2(
1930
+ self,
1931
+ structure: str | None = None,
1932
+ isotherm_id: str | None = None,
1933
+ component: str | None = None,
1934
+ temperature_K: float | None = None,
1935
+ pressure_bar: float | None = None,
1936
+ limit: int = 500,
1937
+ offset: int = 0,
1938
+ ) -> pd.DataFrame:
1939
+ """
1940
+ GET /api/v2/isotherm-h2/
1941
+
1942
+ Args:
1943
+ structure: Structure name filter.
1944
+ isotherm_id: Isotherm identifier filter.
1945
+ component: Component filter.
1946
+ temperature_K: Temperature filter [K].
1947
+ pressure_bar: Pressure filter [bar].
1948
+ """
1949
+ params = _compact(
1950
+ structure=structure,
1951
+ isotherm_id=isotherm_id,
1952
+ component=component,
1953
+ temperature_K=temperature_K,
1954
+ pressure_bar=pressure_bar,
1955
+ limit=limit,
1956
+ offset=offset,
1957
+ )
1958
+ return self._to_df(self._get("/isotherm-h2/", params))
1959
+
1960
+ def get_isotherm_h2_item(self, row_id: int) -> dict:
1961
+ """GET /api/v2/isotherm-h2/{row_id}/"""
1962
+ return self._get(f"/isotherm-h2/{row_id}/")
1963
+
1964
+ def upsert_isotherm_h2(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
1965
+ """
1966
+ PUT /api/v2/isotherm-h2/
1967
+
1968
+ Accepts one object or many objects.
1969
+ Automatically normalises ``meta_provenance`` using local repo metadata.
1970
+ """
1971
+ if isinstance(payload, dict) and isinstance(payload.get("isotherm_H2s"), list):
1972
+ records = self._payload_to_records(payload["isotherm_H2s"])
1973
+ else:
1974
+ records = self._payload_to_records(payload)
1975
+
1976
+ meta = self._autoprism_meta_provenance()
1977
+ enriched = [
1978
+ {
1979
+ **record,
1980
+ "meta_provenance": meta,
1981
+ }
1982
+ if isinstance(record, dict)
1983
+ else record
1984
+ for record in records
1985
+ ]
1986
+ return self._put("/isotherm-h2/", enriched)
1987
+
1988
+ def get_mofchecker(
1989
+ self,
1990
+ structure: str | None = None,
1991
+ md5: str | None = None,
1992
+ is_mof: bool | None = None,
1993
+ MOFQ: str | None = None,
1994
+ limit: int = 500,
1995
+ offset: int = 0,
1996
+ ) -> pd.DataFrame:
1997
+ """
1998
+ GET /api/v2/mofchecker/
1999
+
2000
+ Args:
2001
+ structure: Structure name filter.
2002
+ md5: Exact md5 hash filter.
2003
+ is_mof: Boolean mofchecker flag.
2004
+ MOFQ: MOFQ classifier filter.
2005
+ """
2006
+ params = _compact(
2007
+ structure=structure,
2008
+ md5=md5,
2009
+ is_mof=None if is_mof is None else str(is_mof).lower(),
2010
+ MOFQ=MOFQ,
2011
+ limit=limit,
2012
+ offset=offset,
2013
+ )
2014
+ return self._to_df(self._get("/mofchecker/", params))
2015
+
2016
+ def get_mofchecker_item(self, row_id: int) -> dict:
2017
+ """GET /api/v2/mofchecker/{row_id}/"""
2018
+ return self._get(f"/mofchecker/{row_id}/")
2019
+
2020
+ def upsert_mofchecker(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
2021
+ """
2022
+ PUT /api/v2/mofchecker/
2023
+
2024
+ Accepts one object or many objects.
2025
+ Automatically normalises ``meta_provenance`` using local repo metadata.
2026
+ """
2027
+ if isinstance(payload, dict) and isinstance(payload.get("mofchecker"), list):
2028
+ records = self._payload_to_records(payload["mofchecker"])
2029
+ else:
2030
+ records = self._payload_to_records(payload)
2031
+
2032
+ meta = self._autoprism_meta_provenance()
2033
+ enriched = [
2034
+ {
2035
+ **record,
2036
+ "meta_provenance": meta,
2037
+ }
2038
+ if isinstance(record, dict)
2039
+ else record
2040
+ for record in records
2041
+ ]
2042
+ return self._put("/mofchecker/", enriched)
2043
+
2044
+ def get_zeopp_metrics(
2045
+ self,
2046
+ mof: str | None = None,
2047
+ md5: str | None = None,
2048
+ probe: str | None = None,
2049
+ limit: int = 500,
2050
+ offset: int = 0,
2051
+ ) -> pd.DataFrame:
2052
+ """
2053
+ GET /api/v2/zeopp-metrics/
2054
+
2055
+ Args:
2056
+ mof: MOF name filter.
2057
+ md5: Exact md5 hash filter.
2058
+ probe: Probe name filter.
2059
+ """
2060
+ params = _compact(mof=mof, md5=md5, probe=probe, limit=limit, offset=offset)
2061
+ return self._to_df(self._get("/zeopp-metrics/", params))
2062
+
2063
+ def get_zeopp_metrics_item(self, row_id: int) -> dict:
2064
+ """GET /api/v2/zeopp-metrics/{row_id}/"""
2065
+ return self._get(f"/zeopp-metrics/{row_id}/")
2066
+
2067
+ def upsert_zeopp_metrics(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
2068
+ """
2069
+ PUT /api/v2/zeopp-metrics/
2070
+
2071
+ Accepts one object or many objects.
2072
+ Automatically normalises ``meta_provenance`` using local repo metadata.
2073
+ """
2074
+ if isinstance(payload, dict) and isinstance(payload.get("zeopp_metrics"), list):
2075
+ records = self._payload_to_records(payload["zeopp_metrics"])
2076
+ else:
2077
+ records = self._payload_to_records(payload)
2078
+
2079
+ meta = self._autoprism_meta_provenance()
2080
+ enriched = [
2081
+ {
2082
+ **record,
2083
+ "meta_provenance": meta,
2084
+ }
2085
+ if isinstance(record, dict)
2086
+ else record
2087
+ for record in records
2088
+ ]
2089
+ return self._put("/zeopp-metrics/", enriched)
2090
+
2091
+ def upsert_autoprism_collection(self, payload: dict[str, Any]) -> dict[str, Any]:
2092
+ """
2093
+ Upsert multiple AutoPrism sections from a single combined payload.
2094
+
2095
+ Expected payload shape mirrors the AutoPrism collection mock payload, with
2096
+ optional top-level keys:
2097
+ computation_runs, adsorption_singlepoints, heat_capacities,
2098
+ isotherm_H2s, mofchecker, zeopp_metrics, meta_provenance
2099
+
2100
+ Notes:
2101
+ - Top-level ``meta_provenance`` is ignored.
2102
+ - Each section is passed to its dedicated upsert wrapper so section-
2103
+ specific provenance normalization rules remain in one place.
2104
+
2105
+ Returns:
2106
+ dict with keys:
2107
+ sections: per-section status details
2108
+ totals: aggregate created/updated counters
2109
+ overall_status: "ok" if no section failed, else "partial_failure"
2110
+ """
2111
+ if not isinstance(payload, dict):
2112
+ raise TypeError("payload must be a dict matching the AutoPrism collection shape")
2113
+
2114
+ section_handlers: list[tuple[str, str, Any]] = [
2115
+ ("computation_runs", "upsert_computation_runs", payload.get("computation_runs")),
2116
+ ("adsorption_singlepoints", "upsert_adsorption_singlepoint", payload.get("adsorption_singlepoints")),
2117
+ ("heat_capacities", "upsert_heat_capacity", payload.get("heat_capacities")),
2118
+ ("isotherm_H2s", "upsert_isotherm_h2", payload.get("isotherm_H2s")),
2119
+ ("mofchecker", "upsert_mofchecker", payload.get("mofchecker")),
2120
+ ("zeopp_metrics", "upsert_zeopp_metrics", payload.get("zeopp_metrics")),
2121
+ ]
2122
+
2123
+ sections: dict[str, dict[str, Any]] = {}
2124
+ total_created = 0
2125
+ total_updated = 0
2126
+ failed = 0
2127
+
2128
+ for section_name, method_name, section_payload in section_handlers:
2129
+ if section_payload is None:
2130
+ sections[section_name] = {
2131
+ "status": "skipped",
2132
+ "reason": "missing section in payload",
2133
+ }
2134
+ continue
2135
+
2136
+ try:
2137
+ method = getattr(self, method_name)
2138
+ result = method(section_payload)
2139
+ created = int(result.get("created", 0)) if isinstance(result, dict) else 0
2140
+ updated = int(result.get("updated", 0)) if isinstance(result, dict) else 0
2141
+ total_created += created
2142
+ total_updated += updated
2143
+ sections[section_name] = {
2144
+ "status": "ok",
2145
+ "created": created,
2146
+ "updated": updated,
2147
+ "result": result,
2148
+ }
2149
+ except Exception as exc:
2150
+ failed += 1
2151
+ sections[section_name] = {
2152
+ "status": "error",
2153
+ "error": str(exc),
2154
+ }
2155
+
2156
+ return {
2157
+ "sections": sections,
2158
+ "totals": {
2159
+ "created": total_created,
2160
+ "updated": total_updated,
2161
+ "failed_sections": failed,
2162
+ },
2163
+ "overall_status": "ok" if failed == 0 else "partial_failure",
2164
+ }
2165
+
2166
+ def get_autoprism_collection(
2167
+ self,
2168
+ workflow_id: str | None = None,
2169
+ step: str | None = None,
2170
+ status: str | None = None,
2171
+ structure: str | None = None,
2172
+ mof: str | None = None,
2173
+ md5: str | None = None,
2174
+ mixture_id: str | None = None,
2175
+ component: str | None = None,
2176
+ isotherm_id: str | None = None,
2177
+ temperature_K: float | None = None,
2178
+ pressure_bar: float | None = None,
2179
+ probe: str | None = None,
2180
+ is_mof: bool | None = None,
2181
+ MOFQ: str | None = None,
2182
+ limit: int = 500,
2183
+ offset: int = 0,
2184
+ ) -> dict[str, list[dict] | dict[str, str | None]]:
2185
+ """
2186
+ Gather AutoPrism table records in one call flow.
2187
+
2188
+ Returns a dict with keys:
2189
+ computation_runs,
2190
+ adsorption_singlepoints, heat_capacities, isotherm_H2s,
2191
+ mofchecker, zeopp_metrics
2192
+ meta_provenance
2193
+
2194
+ Notes:
2195
+ - ``workflow_id``, ``step`` and ``status`` filter computation runs.
2196
+ - ``structure`` is used for adsorption_singlepoint, heat_capacity,
2197
+ isotherm_H2 and mofchecker.
2198
+ - ``mof`` is used for zeopp_metrics.
2199
+ - If ``structure`` is omitted and ``mof`` is provided, ``mof`` is
2200
+ also used as the structure filter for convenience.
2201
+
2202
+ Empty payload handling:
2203
+ - If any sub-call returns ``None`` or an unexpected scalar payload,
2204
+ this method normalises it to an empty table representation
2205
+ (``pd.DataFrame()`` in dataframe mode, ``[]`` in json mode).
2206
+ """
2207
+ structure_filter = structure or mof
2208
+
2209
+ def _empty_table() -> pd.DataFrame | list[dict]:
2210
+ return [] if self._return_format == "json" else pd.DataFrame()
2211
+
2212
+ def _normalise_table_payload(value: Any) -> pd.DataFrame | list[dict]:
2213
+ if value is None:
2214
+ return _empty_table()
2215
+ if isinstance(value, (pd.DataFrame, list)):
2216
+ return value
2217
+ if isinstance(value, dict):
2218
+ return self._to_df(value)
2219
+ return _empty_table()
2220
+
2221
+ def _record_count(value: Any) -> int:
2222
+ try:
2223
+ return len(value)
2224
+ except TypeError:
2225
+ return 0
2226
+
2227
+ def _as_record_list(value: Any) -> list[dict]:
2228
+ records = self._as_records(value)
2229
+ return [r for r in records if isinstance(r, dict)]
2230
+
2231
+ meta = {
2232
+ **self._autoprism_meta_provenance(),
2233
+ "source_repo_url": self._autoprism_source_repo_url(),
2234
+ }
2235
+
2236
+ def _safe_fetch(section: str, fetcher, **kwargs) -> pd.DataFrame | list[dict]:
2237
+ try:
2238
+ return _normalise_table_payload(fetcher(**kwargs))
2239
+ except requests.HTTPError as exc:
2240
+ warnings.warn(
2241
+ f"AutoPrism section '{section}' failed ({exc}); returning empty table.",
2242
+ UserWarning,
2243
+ stacklevel=2,
2244
+ )
2245
+ return _empty_table()
2246
+ except requests.RequestException as exc:
2247
+ warnings.warn(
2248
+ f"AutoPrism section '{section}' request error ({exc}); returning empty table.",
2249
+ UserWarning,
2250
+ stacklevel=2,
2251
+ )
2252
+ return _empty_table()
2253
+
2254
+ collection = {
2255
+ "computation_runs": _safe_fetch(
2256
+ "computation_runs",
2257
+ self.get_computation_runs,
2258
+ workflow_id=workflow_id,
2259
+ step=step,
2260
+ status=status,
2261
+ limit=limit,
2262
+ offset=offset,
2263
+ ),
2264
+ "adsorption_singlepoints": _safe_fetch(
2265
+ "adsorption_singlepoint",
2266
+ self.get_adsorption_singlepoint,
2267
+ structure=structure_filter,
2268
+ md5=md5,
2269
+ mixture_id=mixture_id,
2270
+ component=component,
2271
+ limit=limit,
2272
+ offset=offset,
2273
+ ),
2274
+ "heat_capacities": _safe_fetch(
2275
+ "heat_capacity",
2276
+ self.get_heat_capacity,
2277
+ structure=structure_filter,
2278
+ temperature_K=temperature_K,
2279
+ limit=limit,
2280
+ offset=offset,
2281
+ ),
2282
+ "isotherm_H2s": _safe_fetch(
2283
+ "isotherm_H2",
2284
+ self.get_isotherm_h2,
2285
+ structure=structure_filter,
2286
+ isotherm_id=isotherm_id,
2287
+ component=component,
2288
+ temperature_K=temperature_K,
2289
+ pressure_bar=pressure_bar,
2290
+ limit=limit,
2291
+ offset=offset,
2292
+ ),
2293
+ "mofchecker": _safe_fetch(
2294
+ "mofchecker",
2295
+ self.get_mofchecker,
2296
+ structure=structure_filter,
2297
+ md5=md5,
2298
+ is_mof=is_mof,
2299
+ MOFQ=MOFQ,
2300
+ limit=limit,
2301
+ offset=offset,
2302
+ ),
2303
+ "zeopp_metrics": _safe_fetch(
2304
+ "zeopp_metrics",
2305
+ self.get_zeopp_metrics,
2306
+ mof=mof,
2307
+ md5=md5,
2308
+ probe=probe,
2309
+ limit=limit,
2310
+ offset=offset,
2311
+ ),
2312
+ }
2313
+
2314
+ collection = {
2315
+ key: _as_record_list(value)
2316
+ for key, value in collection.items()
2317
+ }
2318
+ collection["meta_provenance"] = meta
2319
+
2320
+ label = structure_filter or mof or "all"
2321
+ print(f"AutoPrism collection for '{label}':")
2322
+ for key in (
2323
+ "computation_runs",
2324
+ "adsorption_singlepoints",
2325
+ "heat_capacities",
2326
+ "isotherm_H2s",
2327
+ "mofchecker",
2328
+ "zeopp_metrics",
2329
+ ):
2330
+ print(f" {key:22s}: {_record_count(collection.get(key, []))} records")
2331
+
2332
+ return collection
2333
+
1698
2334
  # ── TEA / LCA data ────────────────────────────────────────────────────────
1699
2335
 
1700
2336
  def get_output_kpis(self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.3.10
3
+ Version: 0.4.0
4
4
  Summary: A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk.
5
5
  Author-email: RCCS-CaptureTeam <team@prisma-platform.org>
6
6
  License: GNUv3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "prisma_api"
7
- version = "0.3.10"
7
+ version = "0.4.0"
8
8
  description = "A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk."
9
9
  authors = [
10
10
  {name = "RCCS-CaptureTeam", email = "team@prisma-platform.org"}
@@ -15,6 +15,7 @@ import pytest
15
15
  import responses as resp_lib
16
16
  from responses import matchers
17
17
  from datetime import datetime
18
+ from numbers import Integral
18
19
 
19
20
  import pandas as pd
20
21
 
@@ -908,9 +909,9 @@ def test_get_scopes_coerces_types(api):
908
909
  df = api.get_scopes()
909
910
  row = df.iloc[0]
910
911
 
911
- assert isinstance(row["id"], int)
912
+ assert isinstance(row["id"], Integral)
912
913
  assert isinstance(row["name"], str)
913
- assert isinstance(row["active"], bool)
914
+ assert pd.api.types.is_bool_dtype(df["active"])
914
915
  assert isinstance(row["capture_efficiency"], float)
915
916
  assert pd.api.types.is_datetime64_any_dtype(df["created_at"])
916
917
  assert isinstance(row["tags"], list)
@@ -1294,6 +1295,570 @@ def test_get_carbon_zeopp_experimental_item_detail(api):
1294
1295
  assert api.get_carbon_zeopp_experimental_item(1)["mof"] == "HKUST"
1295
1296
 
1296
1297
 
1298
+ # ── AutoPrism Tables ─────────────────────────────────────────────────────────
1299
+
1300
+ @resp_lib.activate
1301
+ def test_get_autoprism_collection_returns_all_records(api):
1302
+ resp_lib.add(
1303
+ resp_lib.GET,
1304
+ f"{PROD_BASE}/computation-runs/",
1305
+ match=[matchers.query_param_matcher({
1306
+ "workflow_id": "wf-1",
1307
+ "step": "adsorption",
1308
+ "status": "done",
1309
+ "limit": "50",
1310
+ "offset": "0",
1311
+ })],
1312
+ json=_envelope([{"id": 99, "workflow_id": "wf-1", "step": "adsorption", "status": "done"}]),
1313
+ status=200,
1314
+ )
1315
+ resp_lib.add(
1316
+ resp_lib.GET,
1317
+ f"{PROD_BASE}/adsorption-singlepoint/",
1318
+ match=[matchers.query_param_matcher({
1319
+ "structure": "ABEXEM",
1320
+ "md5": "abc123",
1321
+ "mixture_id": "mix-1",
1322
+ "component": "CO2",
1323
+ "limit": "50",
1324
+ "offset": "0",
1325
+ })],
1326
+ json=_envelope([{"id": 1, "structure": "ABEXEM"}]),
1327
+ status=200,
1328
+ )
1329
+ resp_lib.add(
1330
+ resp_lib.GET,
1331
+ f"{PROD_BASE}/heat-capacity/",
1332
+ match=[matchers.query_param_matcher({
1333
+ "structure": "ABEXEM",
1334
+ "temperature_K": "298.0",
1335
+ "limit": "50",
1336
+ "offset": "0",
1337
+ })],
1338
+ json=_envelope([{"id": 2, "structure": "ABEXEM", "temperature_K": 298.0}]),
1339
+ status=200,
1340
+ )
1341
+ resp_lib.add(
1342
+ resp_lib.GET,
1343
+ f"{PROD_BASE}/isotherm-h2/",
1344
+ match=[matchers.query_param_matcher({
1345
+ "structure": "ABEXEM",
1346
+ "isotherm_id": "iso-1",
1347
+ "component": "CO2",
1348
+ "temperature_K": "298.0",
1349
+ "pressure_bar": "1.0",
1350
+ "limit": "50",
1351
+ "offset": "0",
1352
+ })],
1353
+ json=_envelope([{"id": 3, "structure": "ABEXEM", "isotherm_id": "iso-1"}]),
1354
+ status=200,
1355
+ )
1356
+ resp_lib.add(
1357
+ resp_lib.GET,
1358
+ f"{PROD_BASE}/mofchecker/",
1359
+ match=[matchers.query_param_matcher({
1360
+ "structure": "ABEXEM",
1361
+ "md5": "abc123",
1362
+ "is_mof": "true",
1363
+ "MOFQ": "yes",
1364
+ "limit": "50",
1365
+ "offset": "0",
1366
+ })],
1367
+ json=_envelope([{"id": 4, "structure": "ABEXEM", "is_mof": True}]),
1368
+ status=200,
1369
+ )
1370
+ resp_lib.add(
1371
+ resp_lib.GET,
1372
+ f"{PROD_BASE}/zeopp-metrics/",
1373
+ match=[matchers.query_param_matcher({
1374
+ "mof": "ABEXEM",
1375
+ "md5": "abc123",
1376
+ "probe": "N2",
1377
+ "limit": "50",
1378
+ "offset": "0",
1379
+ })],
1380
+ json=_envelope([{"id": 5, "mof": "ABEXEM", "probe": "N2"}]),
1381
+ status=200,
1382
+ )
1383
+
1384
+ bundle = api.get_autoprism_collection(
1385
+ workflow_id="wf-1",
1386
+ step="adsorption",
1387
+ status="done",
1388
+ structure="ABEXEM",
1389
+ mof="ABEXEM",
1390
+ md5="abc123",
1391
+ mixture_id="mix-1",
1392
+ component="CO2",
1393
+ isotherm_id="iso-1",
1394
+ temperature_K=298.0,
1395
+ pressure_bar=1.0,
1396
+ probe="N2",
1397
+ is_mof=True,
1398
+ MOFQ="yes",
1399
+ limit=50,
1400
+ offset=0,
1401
+ )
1402
+
1403
+ assert set(bundle.keys()) == {
1404
+ "computation_runs",
1405
+ "adsorption_singlepoints",
1406
+ "heat_capacities",
1407
+ "isotherm_H2s",
1408
+ "mofchecker",
1409
+ "zeopp_metrics",
1410
+ "meta_provenance",
1411
+ }
1412
+ assert len(bundle["computation_runs"]) == 1
1413
+ assert len(bundle["adsorption_singlepoints"]) == 1
1414
+ assert len(bundle["heat_capacities"]) == 1
1415
+ assert len(bundle["isotherm_H2s"]) == 1
1416
+ assert len(bundle["mofchecker"]) == 1
1417
+ assert len(bundle["zeopp_metrics"]) == 1
1418
+
1419
+
1420
+ @resp_lib.activate
1421
+ def test_get_autoprism_collection_uses_mof_as_structure_fallback(api):
1422
+ resp_lib.add(
1423
+ resp_lib.GET,
1424
+ f"{PROD_BASE}/computation-runs/",
1425
+ match=[matchers.query_param_matcher({"limit": "500", "offset": "0"})],
1426
+ json=_envelope([]),
1427
+ status=200,
1428
+ )
1429
+ resp_lib.add(
1430
+ resp_lib.GET,
1431
+ f"{PROD_BASE}/adsorption-singlepoint/",
1432
+ match=[matchers.query_param_matcher({"structure": "HKUST", "limit": "500", "offset": "0"})],
1433
+ json=_envelope([]),
1434
+ status=200,
1435
+ )
1436
+ resp_lib.add(
1437
+ resp_lib.GET,
1438
+ f"{PROD_BASE}/heat-capacity/",
1439
+ match=[matchers.query_param_matcher({"structure": "HKUST", "limit": "500", "offset": "0"})],
1440
+ json=_envelope([]),
1441
+ status=200,
1442
+ )
1443
+ resp_lib.add(
1444
+ resp_lib.GET,
1445
+ f"{PROD_BASE}/isotherm-h2/",
1446
+ match=[matchers.query_param_matcher({"structure": "HKUST", "limit": "500", "offset": "0"})],
1447
+ json=_envelope([]),
1448
+ status=200,
1449
+ )
1450
+ resp_lib.add(
1451
+ resp_lib.GET,
1452
+ f"{PROD_BASE}/mofchecker/",
1453
+ match=[matchers.query_param_matcher({"structure": "HKUST", "limit": "500", "offset": "0"})],
1454
+ json=_envelope([]),
1455
+ status=200,
1456
+ )
1457
+ resp_lib.add(
1458
+ resp_lib.GET,
1459
+ f"{PROD_BASE}/zeopp-metrics/",
1460
+ match=[matchers.query_param_matcher({"mof": "HKUST", "limit": "500", "offset": "0"})],
1461
+ json=_envelope([]),
1462
+ status=200,
1463
+ )
1464
+
1465
+ api.get_autoprism_collection(mof="HKUST")
1466
+
1467
+
1468
+ @resp_lib.activate
1469
+ def test_get_computation_runs_filters(api):
1470
+ resp_lib.add(
1471
+ resp_lib.GET,
1472
+ f"{PROD_BASE}/computation-runs/",
1473
+ match=[matchers.query_param_matcher({
1474
+ "workflow_id": "wf-1",
1475
+ "step": "zeopp",
1476
+ "status": "running",
1477
+ "limit": "25",
1478
+ "offset": "5",
1479
+ })],
1480
+ json=_envelope([{"id": 1, "workflow_id": "wf-1", "step": "zeopp", "status": "running"}]),
1481
+ status=200,
1482
+ )
1483
+ df = api.get_computation_runs(workflow_id="wf-1", step="zeopp", status="running", limit=25, offset=5)
1484
+ assert_df_columns(df, "id", "workflow_id", "step", "status")
1485
+
1486
+
1487
+ @resp_lib.activate
1488
+ def test_get_computation_run_detail(api):
1489
+ resp_lib.add(
1490
+ resp_lib.GET,
1491
+ f"{PROD_BASE}/computation-runs/7/",
1492
+ json={"id": 7, "workflow_id": "wf-7", "step": "mofchecker", "status": "done"},
1493
+ status=200,
1494
+ )
1495
+ result = api.get_computation_run(7)
1496
+ assert result["id"] == 7
1497
+ assert result["status"] == "done"
1498
+
1499
+
1500
+ @resp_lib.activate
1501
+ def test_upsert_computation_runs_accepts_single_dict_and_new_fields(api):
1502
+ payload = {
1503
+ "id": 7,
1504
+ "workflow_id": "wf-7",
1505
+ "step": "adsorption",
1506
+ "status": "done",
1507
+ "new_field_from_server": "kept",
1508
+ }
1509
+ resp_lib.add(
1510
+ resp_lib.PUT,
1511
+ f"{PROD_BASE}/computation-runs/",
1512
+ match=[matchers.json_params_matcher([payload])],
1513
+ json={"created": 0, "updated": 1},
1514
+ status=200,
1515
+ )
1516
+ result = api.upsert_computation_runs(payload)
1517
+ assert result["updated"] == 1
1518
+
1519
+
1520
+ @resp_lib.activate
1521
+ def test_autoprism_detail_endpoints(api):
1522
+ detail_endpoints = [
1523
+ ("/adsorption-singlepoint/1/", "get_adsorption_singlepoint_item", {"id": 1, "structure": "ABEXEM"}),
1524
+ ("/heat-capacity/2/", "get_heat_capacity_item", {"id": 2, "structure": "ABEXEM"}),
1525
+ ("/isotherm-h2/3/", "get_isotherm_h2_item", {"id": 3, "structure": "ABEXEM"}),
1526
+ ("/mofchecker/4/", "get_mofchecker_item", {"id": 4, "structure": "ABEXEM"}),
1527
+ ("/zeopp-metrics/5/", "get_zeopp_metrics_item", {"id": 5, "mof": "ABEXEM"}),
1528
+ ]
1529
+
1530
+ for path, _, body in detail_endpoints:
1531
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}{path}", json=body, status=200)
1532
+
1533
+ assert api.get_adsorption_singlepoint_item(1)["id"] == 1
1534
+ assert api.get_heat_capacity_item(2)["id"] == 2
1535
+ assert api.get_isotherm_h2_item(3)["id"] == 3
1536
+ assert api.get_mofchecker_item(4)["id"] == 4
1537
+ assert api.get_zeopp_metrics_item(5)["id"] == 5
1538
+
1539
+
1540
+ @resp_lib.activate
1541
+ def test_upsert_autoprism_tables_accept_dataframes_and_pass_all_fields(api, monkeypatch):
1542
+ fake_meta = {
1543
+ "source_repo": "AutoPrism",
1544
+ "source_repo_semantic_version": "0.1.0",
1545
+ "source_repo_tag": "0.1.0",
1546
+ "source_commit_hash": "724b0306f6b21f953fba21e424cdda230525862f",
1547
+ }
1548
+ fake_repo_url = "https://github.com/AutoPrism/AutoPrism"
1549
+ monkeypatch.setattr(api, "_autoprism_meta_provenance", lambda: fake_meta)
1550
+ monkeypatch.setattr(api, "_autoprism_source_repo_url", lambda: fake_repo_url)
1551
+
1552
+ adsorption_df = pd.DataFrame([
1553
+ {
1554
+ "structure": "ABEXEM",
1555
+ "md5": "a1",
1556
+ "mixture_id": "mix-1",
1557
+ "component": "CO2",
1558
+ "temperature_K": 298.0,
1559
+ "pressure_bar": 1.0,
1560
+ "new_extra_field": "pass-through",
1561
+ }
1562
+ ])
1563
+ heat_capacity_df = pd.DataFrame([
1564
+ {"structure": "ABEXEM", "temperature_K": 298.0, "Cp": 123.4, "new_extra_field": "pass-through"}
1565
+ ])
1566
+ isotherm_h2_df = pd.DataFrame([
1567
+ {
1568
+ "structure": "ABEXEM",
1569
+ "md5": "a1",
1570
+ "isotherm_id": "iso-1",
1571
+ "component": "H2",
1572
+ "temperature_K": 298.0,
1573
+ "pressure_bar": 1.0,
1574
+ "uptake": 2.1,
1575
+ "new_extra_field": "pass-through",
1576
+ }
1577
+ ])
1578
+ mofchecker_df = pd.DataFrame([
1579
+ {"structure": "ABEXEM", "md5": "a1", "is_mof": True, "MOFQ": "yes", "new_extra_field": "pass-through"}
1580
+ ])
1581
+ zeopp_df = pd.DataFrame([
1582
+ {"mof": "ABEXEM", "md5": "a1", "probe": "N2", "pld": 3.4, "new_extra_field": "pass-through"}
1583
+ ])
1584
+
1585
+ adsorption_expected = [{
1586
+ **adsorption_df.to_dict(orient="records")[0],
1587
+ "meta_provenance": {**fake_meta, "source_repo_url": fake_repo_url},
1588
+ }]
1589
+ heat_capacity_expected = [{
1590
+ **heat_capacity_df.to_dict(orient="records")[0],
1591
+ "meta_provenance": {**fake_meta, "source_repo_url": fake_repo_url},
1592
+ }]
1593
+ isotherm_h2_expected = [{
1594
+ **isotherm_h2_df.to_dict(orient="records")[0],
1595
+ "meta_provenance": fake_meta,
1596
+ }]
1597
+ mofchecker_expected = [{
1598
+ **mofchecker_df.to_dict(orient="records")[0],
1599
+ "meta_provenance": fake_meta,
1600
+ }]
1601
+ zeopp_expected = [{
1602
+ **zeopp_df.to_dict(orient="records")[0],
1603
+ "meta_provenance": fake_meta,
1604
+ }]
1605
+
1606
+ resp_lib.add(
1607
+ resp_lib.PUT,
1608
+ f"{PROD_BASE}/adsorption-singlepoint/",
1609
+ match=[matchers.json_params_matcher(adsorption_expected)],
1610
+ json={"created": 1, "updated": 0},
1611
+ status=200,
1612
+ )
1613
+ resp_lib.add(
1614
+ resp_lib.PUT,
1615
+ f"{PROD_BASE}/heat-capacity/",
1616
+ match=[matchers.json_params_matcher(heat_capacity_expected)],
1617
+ json={"created": 1, "updated": 0},
1618
+ status=200,
1619
+ )
1620
+ resp_lib.add(
1621
+ resp_lib.PUT,
1622
+ f"{PROD_BASE}/isotherm-h2/",
1623
+ match=[matchers.json_params_matcher(isotherm_h2_expected)],
1624
+ json={"created": 1, "updated": 0},
1625
+ status=200,
1626
+ )
1627
+ resp_lib.add(
1628
+ resp_lib.PUT,
1629
+ f"{PROD_BASE}/mofchecker/",
1630
+ match=[matchers.json_params_matcher(mofchecker_expected)],
1631
+ json={"created": 1, "updated": 0},
1632
+ status=200,
1633
+ )
1634
+ resp_lib.add(
1635
+ resp_lib.PUT,
1636
+ f"{PROD_BASE}/zeopp-metrics/",
1637
+ match=[matchers.json_params_matcher(zeopp_expected)],
1638
+ json={"created": 1, "updated": 0},
1639
+ status=200,
1640
+ )
1641
+
1642
+ assert api.upsert_adsorption_singlepoint(adsorption_df)["created"] == 1
1643
+ assert api.upsert_heat_capacity(heat_capacity_df)["created"] == 1
1644
+ assert api.upsert_isotherm_h2(isotherm_h2_df)["created"] == 1
1645
+ assert api.upsert_mofchecker(mofchecker_df)["created"] == 1
1646
+ assert api.upsert_zeopp_metrics(zeopp_df)["created"] == 1
1647
+
1648
+
1649
+ @resp_lib.activate
1650
+ def test_upsert_zeopp_metrics_accepts_wrapped_payload_and_overwrites_meta(api, monkeypatch):
1651
+ fake_meta = {
1652
+ "source_repo": "AutoPrism",
1653
+ "source_repo_semantic_version": "0.1.0",
1654
+ "source_repo_tag": "0.1.0",
1655
+ "source_commit_hash": "724b0306f6b21f953fba21e424cdda230525862f",
1656
+ }
1657
+ monkeypatch.setattr(api, "_autoprism_meta_provenance", lambda: fake_meta)
1658
+
1659
+ payload = {
1660
+ "zeopp_metrics": [
1661
+ {
1662
+ "mof": "UiO-66",
1663
+ "md5": "aaaaaaaa",
1664
+ "probe": "N2",
1665
+ "Di": 6.4,
1666
+ "meta_provenance": {
1667
+ "source_repo": "stale",
1668
+ "source_repo_semantic_version": "stale",
1669
+ "source_repo_tag": "stale",
1670
+ "source_commit_hash": "stale",
1671
+ },
1672
+ }
1673
+ ],
1674
+ "meta_provenance": {
1675
+ "source_repo": "top-level-stale",
1676
+ "source_repo_semantic_version": "top-level-stale",
1677
+ "source_repo_tag": "top-level-stale",
1678
+ "source_commit_hash": "top-level-stale",
1679
+ },
1680
+ }
1681
+
1682
+ expected = [{
1683
+ "mof": "UiO-66",
1684
+ "md5": "aaaaaaaa",
1685
+ "probe": "N2",
1686
+ "Di": 6.4,
1687
+ "meta_provenance": fake_meta,
1688
+ }]
1689
+
1690
+ resp_lib.add(
1691
+ resp_lib.PUT,
1692
+ f"{PROD_BASE}/zeopp-metrics/",
1693
+ match=[matchers.json_params_matcher(expected)],
1694
+ json={"created": 1, "updated": 0},
1695
+ status=200,
1696
+ )
1697
+
1698
+ result = api.upsert_zeopp_metrics(payload)
1699
+ assert result["created"] == 1
1700
+
1701
+
1702
+ def test_upsert_autoprism_collection_dispatches_sections(api, monkeypatch):
1703
+ monkeypatch.setattr(api, "upsert_computation_runs", lambda payload: {"created": 1, "updated": 0})
1704
+ monkeypatch.setattr(api, "upsert_adsorption_singlepoint", lambda payload: {"created": 2, "updated": 0})
1705
+ monkeypatch.setattr(api, "upsert_heat_capacity", lambda payload: {"created": 0, "updated": 3})
1706
+ monkeypatch.setattr(api, "upsert_isotherm_h2", lambda payload: {"created": 4, "updated": 1})
1707
+ monkeypatch.setattr(api, "upsert_mofchecker", lambda payload: {"created": 5, "updated": 0})
1708
+ monkeypatch.setattr(api, "upsert_zeopp_metrics", lambda payload: {"created": 6, "updated": 2})
1709
+
1710
+ payload = {
1711
+ "computation_runs": [{"id": "run-1"}],
1712
+ "adsorption_singlepoints": [{"id": 1}],
1713
+ "heat_capacities": [{"id": 2}],
1714
+ "isotherm_H2s": [{"id": 3}],
1715
+ "mofchecker": [{"id": 4}],
1716
+ "zeopp_metrics": [{"id": 5}],
1717
+ "meta_provenance": {"source_repo": "ignored"},
1718
+ }
1719
+
1720
+ result = api.upsert_autoprism_collection(payload)
1721
+
1722
+ assert result["overall_status"] == "ok"
1723
+ assert result["totals"]["created"] == 18
1724
+ assert result["totals"]["updated"] == 6
1725
+ assert result["totals"]["failed_sections"] == 0
1726
+ assert result["sections"]["zeopp_metrics"]["status"] == "ok"
1727
+ assert result["sections"]["mofchecker"]["status"] == "ok"
1728
+
1729
+
1730
+ def test_upsert_autoprism_collection_reports_skips_and_errors(api, monkeypatch):
1731
+ monkeypatch.setattr(api, "upsert_computation_runs", lambda payload: {"created": 1, "updated": 0})
1732
+
1733
+ def _raise_error(payload):
1734
+ raise RuntimeError("boom")
1735
+
1736
+ monkeypatch.setattr(api, "upsert_zeopp_metrics", _raise_error)
1737
+
1738
+ payload = {
1739
+ "computation_runs": [{"id": "run-1"}],
1740
+ "zeopp_metrics": [{"id": 5}],
1741
+ }
1742
+
1743
+ result = api.upsert_autoprism_collection(payload)
1744
+
1745
+ assert result["overall_status"] == "partial_failure"
1746
+ assert result["totals"]["created"] == 1
1747
+ assert result["totals"]["updated"] == 0
1748
+ assert result["totals"]["failed_sections"] == 1
1749
+ assert result["sections"]["computation_runs"]["status"] == "ok"
1750
+ assert result["sections"]["adsorption_singlepoints"]["status"] == "skipped"
1751
+ assert result["sections"]["zeopp_metrics"]["status"] == "error"
1752
+
1753
+
1754
+ def test_get_autoprism_collection_allows_empty_payloads(api, monkeypatch):
1755
+ monkeypatch.setattr(api, "get_computation_runs", lambda **kwargs: None)
1756
+ monkeypatch.setattr(api, "get_adsorption_singlepoint", lambda **kwargs: None)
1757
+ monkeypatch.setattr(api, "get_heat_capacity", lambda **kwargs: {"results": []})
1758
+ monkeypatch.setattr(api, "get_isotherm_h2", lambda **kwargs: 0)
1759
+ monkeypatch.setattr(api, "get_mofchecker", lambda **kwargs: [])
1760
+ monkeypatch.setattr(api, "get_zeopp_metrics", lambda **kwargs: pd.DataFrame())
1761
+
1762
+ collection = api.get_autoprism_collection(mof="ABEXEM")
1763
+
1764
+ assert set(collection.keys()) == {
1765
+ "computation_runs",
1766
+ "adsorption_singlepoints",
1767
+ "heat_capacities",
1768
+ "isotherm_H2s",
1769
+ "mofchecker",
1770
+ "zeopp_metrics",
1771
+ "meta_provenance",
1772
+ }
1773
+ assert len(collection["computation_runs"]) == 0
1774
+ assert len(collection["adsorption_singlepoints"]) == 0
1775
+ assert len(collection["heat_capacities"]) == 0
1776
+ assert len(collection["isotherm_H2s"]) == 0
1777
+ assert len(collection["mofchecker"]) == 0
1778
+ assert len(collection["zeopp_metrics"]) == 0
1779
+
1780
+
1781
+ def test_get_autoprism_collection_tolerates_endpoint_http_error(api, monkeypatch):
1782
+ import requests
1783
+
1784
+ def _raise_http_error(**kwargs):
1785
+ raise requests.HTTPError("500 Server Error")
1786
+
1787
+ monkeypatch.setattr(api, "get_computation_runs", _raise_http_error)
1788
+ monkeypatch.setattr(api, "get_adsorption_singlepoint", lambda **kwargs: [{"id": 1}])
1789
+ monkeypatch.setattr(api, "get_heat_capacity", lambda **kwargs: [{"id": 2}])
1790
+ monkeypatch.setattr(api, "get_isotherm_h2", lambda **kwargs: [{"id": 3}])
1791
+ monkeypatch.setattr(api, "get_mofchecker", lambda **kwargs: [{"id": 4}])
1792
+ monkeypatch.setattr(api, "get_zeopp_metrics", lambda **kwargs: [{"id": 5}])
1793
+
1794
+ collection = api.get_autoprism_collection(mof="ABEXEM")
1795
+
1796
+ assert len(collection["computation_runs"]) == 0
1797
+ assert len(collection["adsorption_singlepoints"]) == 1
1798
+ assert len(collection["heat_capacities"]) == 1
1799
+ assert len(collection["isotherm_H2s"]) == 1
1800
+ assert len(collection["mofchecker"]) == 1
1801
+ assert len(collection["zeopp_metrics"]) == 1
1802
+
1803
+
1804
+ def test_get_autoprism_collection_includes_mofchecker_payload_shape(api, monkeypatch):
1805
+ monkeypatch.setattr(api, "get_computation_runs", lambda **kwargs: [])
1806
+ monkeypatch.setattr(api, "get_adsorption_singlepoint", lambda **kwargs: [])
1807
+ monkeypatch.setattr(api, "get_heat_capacity", lambda **kwargs: [])
1808
+ monkeypatch.setattr(api, "get_isotherm_h2", lambda **kwargs: [])
1809
+ monkeypatch.setattr(api, "get_zeopp_metrics", lambda **kwargs: [])
1810
+ monkeypatch.setattr(
1811
+ api,
1812
+ "get_mofchecker",
1813
+ lambda **kwargs: [{"id": 4, "structure": {"name": "ABEXEM"}, "is_mof": True}],
1814
+ )
1815
+
1816
+ collection = api.get_autoprism_collection(mof="ABEXEM")
1817
+
1818
+ assert "mofchecker" in collection
1819
+ assert "meta_provenance" in collection
1820
+ assert isinstance(collection["mofchecker"], list)
1821
+ assert len(collection["mofchecker"]) == 1
1822
+
1823
+ meta = collection["meta_provenance"]
1824
+ assert set(meta.keys()) >= {
1825
+ "source_repo",
1826
+ "source_repo_url",
1827
+ "source_repo_semantic_version",
1828
+ "source_repo_tag",
1829
+ "source_commit_hash",
1830
+ }
1831
+
1832
+
1833
+ def test_get_autoprism_collection_includes_zeopp_payload_shape(api, monkeypatch):
1834
+ monkeypatch.setattr(api, "get_computation_runs", lambda **kwargs: [])
1835
+ monkeypatch.setattr(api, "get_adsorption_singlepoint", lambda **kwargs: [])
1836
+ monkeypatch.setattr(api, "get_heat_capacity", lambda **kwargs: [])
1837
+ monkeypatch.setattr(api, "get_isotherm_h2", lambda **kwargs: [])
1838
+ monkeypatch.setattr(api, "get_mofchecker", lambda **kwargs: [])
1839
+ monkeypatch.setattr(
1840
+ api,
1841
+ "get_zeopp_metrics",
1842
+ lambda **kwargs: [{"id": 5, "mof": "UiO-66", "probe": "N2"}],
1843
+ )
1844
+
1845
+ collection = api.get_autoprism_collection(mof="UiO-66")
1846
+
1847
+ assert "zeopp_metrics" in collection
1848
+ assert "meta_provenance" in collection
1849
+ assert isinstance(collection["zeopp_metrics"], list)
1850
+ assert len(collection["zeopp_metrics"]) == 1
1851
+
1852
+ meta = collection["meta_provenance"]
1853
+ assert set(meta.keys()) >= {
1854
+ "source_repo",
1855
+ "source_repo_url",
1856
+ "source_repo_semantic_version",
1857
+ "source_repo_tag",
1858
+ "source_commit_hash",
1859
+ }
1860
+
1861
+
1297
1862
  # ── Output KPIs ───────────────────────────────────────────────────────────────
1298
1863
 
1299
1864
  @resp_lib.activate
File without changes
File without changes
File without changes