Skip to content

Commit a941d38

Browse files
Restructure backend routes
The backend routes got entangled, especially the models.py had too much content for it's name. Split the simulation endpoints into a separate one and put function that both simulation and grid-simulation use into a _helpers file for reusing.
1 parent 4daf1a3 commit a941d38

14 files changed

Lines changed: 379 additions & 339 deletions

backend/src/acidwatch_api/routes/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
from fastapi import APIRouter
44

5+
from . import grid_simulations
56
from . import models
67
from . import oasis
7-
from . import grid_simulations
8+
from . import simulations
89

910
router = APIRouter()
1011
router.include_router(models.router)
11-
router.include_router(oasis.router)
12+
router.include_router(simulations.router)
1213
router.include_router(grid_simulations.router)
14+
router.include_router(oasis.router)
1315

1416

1517
__all__ = ["router"]
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections import defaultdict
5+
from datetime import datetime, timedelta
6+
from typing import cast
7+
from uuid import UUID, uuid4
8+
9+
from fastapi import HTTPException, Request
10+
from pydantic import TypeAdapter, ValidationError
11+
from sqlalchemy import select
12+
from sqlalchemy.exc import IntegrityError
13+
from sqlalchemy.orm import Session
14+
15+
import acidwatch_api.database as db
16+
from acidwatch_api.broker.heartbeat import HeartbeatRegistry
17+
from acidwatch_api.settings import SETTINGS
18+
from acidwatch_messaging import Transport
19+
from acidwatch_models import (
20+
AdapterSet,
21+
BaseAdapter,
22+
InputError,
23+
)
24+
from acidwatch_models.datamodel import (
25+
AnyPanel,
26+
Conditions,
27+
ModelInput,
28+
ModelResult,
29+
Phase,
30+
Simulation,
31+
SimulationResult,
32+
)
33+
34+
logger = logging.getLogger(__name__)
35+
36+
37+
def get_transport(request: Request) -> Transport:
38+
return cast(Transport, request.state.transport)
39+
40+
41+
def get_heartbeat_registry(request: Request) -> HeartbeatRegistry:
42+
return cast(HeartbeatRegistry, request.state.heartbeat_registry)
43+
44+
45+
def _now() -> datetime:
46+
return datetime.now()
47+
48+
49+
def build_adapters(
50+
models: list[ModelInput],
51+
conditions: Conditions,
52+
all_adapters: AdapterSet,
53+
) -> list[BaseAdapter]:
54+
"""Instantiate and validate the adapter chain for a set of model inputs.
55+
56+
Raises:
57+
HTTPException: 422 if a model is unknown or its parameters are invalid.
58+
"""
59+
adapters: list[BaseAdapter] = []
60+
for model in models:
61+
adapter_class = all_adapters.get(model.model_id)
62+
if adapter_class is None:
63+
raise HTTPException(
64+
status_code=422,
65+
detail=f"Unknown model '{model.model_id}'",
66+
)
67+
try:
68+
adapter = adapter_class(
69+
parameters=model.parameters,
70+
conditions=conditions,
71+
)
72+
adapters.append(adapter)
73+
except InputError as exc:
74+
raise HTTPException(status_code=422, detail=exc.detail)
75+
except ValidationError as exc:
76+
detail = defaultdict(list)
77+
for err in exc.errors():
78+
for loc in err["loc"]:
79+
detail[loc].append(err["msg"])
80+
81+
raise HTTPException(status_code=422, detail=dict(detail))
82+
except ValueError as exc:
83+
raise HTTPException(status_code=422, detail=exc.args)
84+
return adapters
85+
86+
87+
def build_model_input_rows(models: list[ModelInput]) -> list[db.ModelInput]:
88+
"""Build the chained ``db.ModelInput`` rows for a simulation."""
89+
rows: list[db.ModelInput] = []
90+
previous_model_input_id: UUID | None = None
91+
for model in models:
92+
model_input_id = uuid4()
93+
rows.append(
94+
db.ModelInput(
95+
id=model_input_id,
96+
previous_model_input_id=previous_model_input_id,
97+
model_id=model.model_id,
98+
parameters=model.parameters,
99+
)
100+
)
101+
previous_model_input_id = model_input_id
102+
return rows
103+
104+
105+
def order_chain(
106+
rows: list[tuple[db.ModelInput, db.ModelResult | None]],
107+
) -> list[tuple[db.ModelInput, db.ModelResult | None]]:
108+
"""Order ``(model_input, result)`` rows following the pipeline chain."""
109+
mapping: dict[UUID | None, UUID] = {}
110+
rows_by_id: dict[UUID, tuple[db.ModelInput, db.ModelResult | None]] = {}
111+
for model_input, result in rows:
112+
mapping[model_input.previous_model_input_id] = model_input.id
113+
rows_by_id[model_input.id] = (model_input, result)
114+
115+
ordered: list[tuple[db.ModelInput, db.ModelResult | None]] = []
116+
current_id: UUID | None = mapping.get(None)
117+
while current_id in rows_by_id:
118+
assert current_id is not None
119+
ordered.append(rows_by_id[current_id])
120+
current_id = mapping.get(current_id)
121+
return ordered
122+
123+
124+
def query_chain_rows(
125+
session: Session, simulation_id: UUID
126+
) -> list[tuple[db.ModelInput, db.ModelResult | None]]:
127+
q = (
128+
select(db.ModelInput, db.ModelResult)
129+
.where(db.ModelInput.simulation_id == simulation_id)
130+
.outerjoin(db.ModelResult)
131+
)
132+
return [(row[0], row[1]) for row in session.execute(q).fetchall()]
133+
134+
135+
def _phases_to_concentrations(phases: list[Phase]) -> dict[str, int | float]:
136+
merged: dict[str, int | float] = {}
137+
for phase in phases:
138+
if phase.kind == "co2-rich":
139+
merged.update(phase.concentrations)
140+
return merged
141+
142+
143+
def build_simulation_result(
144+
session: Session,
145+
simulation_id: UUID,
146+
registry: HeartbeatRegistry | None = None,
147+
) -> SimulationResult:
148+
db_simulation = session.get_one(db.Simulation, simulation_id)
149+
150+
model_inputs: list[ModelInput] = []
151+
results: list[ModelResult] = []
152+
pending = False
153+
processing = False
154+
now = _now()
155+
previous_result_created_at: datetime | None = None
156+
157+
for model_input, result in order_chain(query_chain_rows(session, simulation_id)):
158+
model_inputs.append(
159+
ModelInput(
160+
model_id=model_input.model_id,
161+
parameters=model_input.parameters,
162+
)
163+
)
164+
165+
if not result:
166+
if pending:
167+
continue
168+
pending = True
169+
if (
170+
registry is not None
171+
and registry.job_status(str(model_input.id), now=now) == "processing"
172+
):
173+
processing = True
174+
continue
175+
pending_since = previous_result_created_at or model_input.created_at
176+
if now - pending_since >= timedelta(
177+
minutes=SETTINGS.model_input_timeout_minutes
178+
):
179+
result = db.ModelResult(
180+
model_input_id=model_input.id,
181+
phases=[],
182+
panels=[],
183+
error=f"Model {model_input.model_id} timed out",
184+
)
185+
session.add(result)
186+
try:
187+
session.commit()
188+
except IntegrityError:
189+
session.rollback()
190+
result = session.scalar(
191+
select(db.ModelResult).where(
192+
db.ModelResult.model_input_id == model_input.id
193+
)
194+
)
195+
assert result is not None
196+
logger.error(
197+
"Simulation %s failed: %s",
198+
simulation_id,
199+
result.error,
200+
)
201+
return SimulationResult(
202+
status="error",
203+
input=Simulation(
204+
concentrations=_phases_to_concentrations(
205+
[Phase(**p) for p in db_simulation.phases]
206+
),
207+
conditions=Conditions(**(db_simulation.conditions or {})),
208+
models=model_inputs,
209+
),
210+
results=results,
211+
error=result.error,
212+
)
213+
continue
214+
215+
previous_result_created_at = result.created_at
216+
if result.error is not None:
217+
logger.error("Simulation %s failed: %s", simulation_id, result.error)
218+
return SimulationResult(
219+
status="error",
220+
input=Simulation(
221+
concentrations=_phases_to_concentrations(
222+
[Phase(**p) for p in db_simulation.phases]
223+
),
224+
conditions=Conditions(**(db_simulation.conditions or {})),
225+
models=model_inputs,
226+
),
227+
results=results,
228+
error=result.error,
229+
)
230+
231+
results.append(
232+
ModelResult(
233+
phases=[Phase(**p) for p in result.phases],
234+
panels=result.panels,
235+
)
236+
)
237+
238+
simulation_input = Simulation(
239+
concentrations=_phases_to_concentrations(
240+
[Phase(**p) for p in db_simulation.phases]
241+
),
242+
conditions=Conditions(**(db_simulation.conditions or {})),
243+
models=model_inputs,
244+
)
245+
246+
if pending:
247+
return SimulationResult(
248+
status="processing" if processing else "pending",
249+
input=simulation_input,
250+
results=results,
251+
)
252+
253+
return SimulationResult(
254+
status="done",
255+
input=simulation_input,
256+
results=[
257+
ModelResult(
258+
phases=result.phases,
259+
panels=[
260+
TypeAdapter(AnyPanel).validate_python(panel)
261+
for panel in result.panels
262+
],
263+
)
264+
for result in results
265+
if result is not None
266+
],
267+
)

backend/src/acidwatch_api/routes/grid_simulations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
GridSimulationResult,
2020
SimulationResult,
2121
)
22-
from acidwatch_api.routes.models import (
22+
from acidwatch_api.routes._helpers import (
2323
build_adapters,
2424
build_model_input_rows,
2525
build_simulation_result,

0 commit comments

Comments
 (0)