Skip to content

Commit 7bcfa36

Browse files
feat: add concentration range sweep across models
Add a range-sweep feature to the model page: select one concentration, define a linear range (2-25 values, default 10), and run the selected model chain across that range. Backend: - New Sweep table owning child Simulations (one per range value), reusing the existing simulation infrastructure so each point is itself a shareable simulation. - POST /sweeps validates the chain + swept substance up front and schedules a background run per point; GET /sweeps/{id}/result aggregates child simulations into per-point status/concentrations for single-URL polling. - Alembic migration for the sweeps table and simulation FK columns. Frontend: - Sweep range controls on the model page and a 'Run Sweep' action. - Shareable /sweeps/:sweepId route that reloads inputs and results. - SweepResults view (line chart + table + CSV export) and sweep overlay in Compare via ?sweeps=, selectable from the history sidebar. - History entries gain a 'kind' to distinguish simulations from sweeps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7560cce commit 7bcfa36

24 files changed

Lines changed: 1396 additions & 76 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""add sweeps
2+
3+
Revision ID: a7f3c9d21b84
4+
Revises: 7c2e1f4b8a90
5+
Create Date: 2026-06-12 00:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
15+
revision: str = "a7f3c9d21b84"
16+
down_revision: Union[str, Sequence[str], None] = "7c2e1f4b8a90"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"sweeps",
24+
sa.Column("owner_id", sa.Uuid(), nullable=True),
25+
sa.Column("swept_substance", sa.String(), nullable=False),
26+
sa.Column("values", sa.JSON(), nullable=False),
27+
sa.Column("id", sa.Uuid(), nullable=False),
28+
sa.Column("created_at", sa.DateTime(), nullable=False),
29+
sa.Column("updated_at", sa.DateTime(), nullable=False),
30+
sa.PrimaryKeyConstraint("id"),
31+
)
32+
op.add_column(
33+
"simulations",
34+
sa.Column("sweep_id", sa.Uuid(), nullable=True),
35+
)
36+
op.add_column(
37+
"simulations",
38+
sa.Column("sweep_value_index", sa.Integer(), nullable=True),
39+
)
40+
op.create_foreign_key(
41+
op.f("simulations_sweep_id_fkey"),
42+
"simulations",
43+
"sweeps",
44+
["sweep_id"],
45+
["id"],
46+
)
47+
48+
49+
def downgrade() -> None:
50+
op.drop_constraint(
51+
op.f("simulations_sweep_id_fkey"), "simulations", type_="foreignkey"
52+
)
53+
op.drop_column("simulations", "sweep_value_index")
54+
op.drop_column("simulations", "sweep_id")
55+
op.drop_table("sweeps")

backend/src/acidwatch_api/database.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,21 @@ class Simulation(Base):
4646
concentrations: Mapped[dict[str, float]] = mapped_column(JSON)
4747
conditions: Mapped[dict[str, float] | None] = mapped_column(JSON)
4848

49+
sweep_id: Mapped[UUID | None] = mapped_column(ForeignKey("sweeps.id"))
50+
sweep_value_index: Mapped[int | None] = mapped_column()
51+
4952
model_inputs: Mapped[list[ModelInput]] = relationship(back_populates="simulation")
53+
sweep: Mapped[Sweep | None] = relationship(back_populates="simulations")
54+
55+
56+
class Sweep(Base):
57+
__tablename__ = "sweeps"
58+
59+
owner_id: Mapped[UUID | None] = mapped_column(Uuid)
60+
swept_substance: Mapped[str] = mapped_column()
61+
values: Mapped[list[float]] = mapped_column(JSON)
62+
63+
simulations: Mapped[list[Simulation]] = relationship(back_populates="sweep")
5064

5165

5266
class ModelInput(Base):

backend/src/acidwatch_api/models/datamodel.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
from typing import Any, Literal, Optional, Dict, TypeAlias
4+
from uuid import UUID
45

5-
from pydantic import BaseModel, ConfigDict, Field
6+
from pydantic import BaseModel, ConfigDict, Field, model_validator
67
from pydantic.alias_generators import to_camel
78

89

@@ -46,6 +47,57 @@ class SimulationResult(_BaseModel):
4647
results: list[ModelResult]
4748

4849

50+
class SweepRange(_BaseModel):
51+
"""A linear, inclusive range that is sampled at ``steps`` points."""
52+
53+
min: float
54+
max: float
55+
steps: int = Field(default=10, ge=2, le=25)
56+
57+
@model_validator(mode="after")
58+
def _check_bounds(self) -> "SweepRange":
59+
if self.max <= self.min:
60+
raise ValueError("max must be greater than min")
61+
return self
62+
63+
def values(self) -> list[float]:
64+
step = (self.max - self.min) / (self.steps - 1)
65+
return [self.min + step * i for i in range(self.steps)]
66+
67+
68+
class CreateSweep(_BaseModel):
69+
"""Request body for starting a concentration sweep.
70+
71+
A sweep runs a single model configuration (the ``models`` chain) once for
72+
each value in ``range``, substituting ``swept_substance`` in
73+
``concentrations`` with that value.
74+
"""
75+
76+
swept_substance: str
77+
range: SweepRange
78+
concentrations: dict[str, int | float]
79+
conditions: Conditions = Field(default_factory=Conditions)
80+
models: list[ModelInput] = Field(min_length=1)
81+
82+
83+
class SweepPoint(_BaseModel):
84+
value: float
85+
simulation_id: UUID
86+
status: Literal["done", "pending", "error"]
87+
error: str | None = None
88+
concentrations: dict[str, int | float]
89+
90+
91+
class SweepResult(_BaseModel):
92+
status: Literal["done", "pending"]
93+
swept_substance: str
94+
values: list[float]
95+
concentrations: dict[str, int | float]
96+
conditions: Conditions
97+
models: list[ModelInput]
98+
points: list[SweepPoint]
99+
100+
49101
class JsonResult(BaseModel):
50102
type: Literal["json"] = "json"
51103
label: str | None = None

backend/src/acidwatch_api/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
from . import models
66
from . import oasis
7+
from . import sweeps
78

89
router = APIRouter()
910
router.include_router(models.router)
1011
router.include_router(oasis.router)
12+
router.include_router(sweeps.router)
1113

1214

1315
__all__ = ["router"]
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections import defaultdict
5+
from typing import Annotated
6+
from uuid import UUID
7+
8+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
9+
from sqlalchemy import select
10+
11+
import acidwatch_api.database as db
12+
from acidwatch_api.authentication import OptionalCurrentUser
13+
from acidwatch_api.database import GetDB
14+
from acidwatch_api.models import InputError
15+
from acidwatch_api.models.datamodel import (
16+
Conditions,
17+
CreateSweep,
18+
ModelInput,
19+
SweepPoint,
20+
SweepResult,
21+
)
22+
from acidwatch_api.routes.models import (
23+
AdapterSet,
24+
build_adapters,
25+
build_model_input_rows,
26+
get_adapters,
27+
order_chain,
28+
run_adapters,
29+
)
30+
31+
router = APIRouter()
32+
33+
logger = logging.getLogger(__name__)
34+
35+
36+
def _summarize_point(
37+
ordered: list[tuple[db.ModelInput, db.ModelResult | None]],
38+
) -> tuple[str, dict[str, int | float], str | None]:
39+
"""Reduce a simulation's chain to a sweep point status and final output.
40+
41+
The "output" of a chain is the concentrations of its last model.
42+
"""
43+
if not ordered:
44+
return "pending", {}, None
45+
46+
for _, result in ordered:
47+
if result is not None and result.error is not None:
48+
return "error", {}, result.error
49+
50+
if any(result is None for _, result in ordered):
51+
return "pending", {}, None
52+
53+
final_result = ordered[-1][1]
54+
assert final_result is not None
55+
return "done", final_result.concentrations, None
56+
57+
58+
@router.post("/sweeps")
59+
async def run_sweep(
60+
create_sweep: CreateSweep,
61+
user: OptionalCurrentUser,
62+
request: Request,
63+
session: GetDB,
64+
background_tasks: BackgroundTasks,
65+
all_adapters: Annotated[AdapterSet, Depends(get_adapters)],
66+
) -> UUID:
67+
jwt_token = user.jwt_token if user else None
68+
69+
# Validate the model chain and concentrations once up front so the caller
70+
# gets a synchronous 422 instead of a sweep full of failed points.
71+
adapters = build_adapters(
72+
create_sweep.models, create_sweep.conditions, all_adapters, jwt_token
73+
)
74+
75+
if create_sweep.swept_substance not in adapters[0].valid_substances:
76+
raise HTTPException(
77+
status_code=422,
78+
detail={
79+
"sweptSubstance": [
80+
f"'{create_sweep.swept_substance}' is not supported by "
81+
f"the selected model"
82+
]
83+
},
84+
)
85+
86+
try:
87+
adapters[0].validate_concentrations(
88+
{**create_sweep.concentrations, create_sweep.swept_substance: 0}
89+
)
90+
except InputError as exc:
91+
raise HTTPException(status_code=422, detail=exc.detail)
92+
93+
values = create_sweep.range.values()
94+
95+
sweep = db.Sweep(
96+
owner_id=UUID(user.id) if user else None,
97+
swept_substance=create_sweep.swept_substance,
98+
values=values,
99+
)
100+
session.add(sweep)
101+
102+
scheduled: list[tuple[dict[str, int | float], list, list[UUID]]] = []
103+
for index, value in enumerate(values):
104+
point_concentrations = {
105+
**create_sweep.concentrations,
106+
create_sweep.swept_substance: value,
107+
}
108+
model_input_rows = build_model_input_rows(create_sweep.models)
109+
session.add(
110+
db.Simulation(
111+
owner_id=UUID(user.id) if user else None,
112+
concentrations=point_concentrations,
113+
conditions=create_sweep.conditions.model_dump(),
114+
sweep=sweep,
115+
sweep_value_index=index,
116+
model_inputs=model_input_rows,
117+
)
118+
)
119+
point_adapters = build_adapters(
120+
create_sweep.models, create_sweep.conditions, all_adapters, jwt_token
121+
)
122+
scheduled.append(
123+
(
124+
point_concentrations,
125+
point_adapters,
126+
[row.id for row in model_input_rows],
127+
)
128+
)
129+
130+
session.commit()
131+
132+
for point_concentrations, point_adapters, model_input_ids in scheduled:
133+
background_tasks.add_task(
134+
run_adapters,
135+
request.state.session,
136+
point_concentrations,
137+
point_adapters,
138+
model_input_ids,
139+
)
140+
141+
return sweep.id
142+
143+
144+
@router.get("/sweeps/{sweep_id}/result")
145+
def get_sweep_result(
146+
sweep_id: UUID,
147+
session: GetDB,
148+
) -> SweepResult:
149+
sweep = session.get_one(db.Sweep, sweep_id)
150+
151+
simulations = (
152+
session.execute(
153+
select(db.Simulation).where(db.Simulation.sweep_id == sweep_id)
154+
)
155+
.scalars()
156+
.all()
157+
)
158+
simulation_by_index = {sim.sweep_value_index: sim for sim in simulations}
159+
160+
rows_by_simulation: dict[
161+
UUID, list[tuple[db.ModelInput, db.ModelResult | None]]
162+
] = defaultdict(list)
163+
simulation_ids = [sim.id for sim in simulations]
164+
if simulation_ids:
165+
q = (
166+
select(db.ModelInput, db.ModelResult)
167+
.where(db.ModelInput.simulation_id.in_(simulation_ids))
168+
.outerjoin(db.ModelResult)
169+
)
170+
for model_input, result in session.execute(q):
171+
rows_by_simulation[model_input.simulation_id].append(
172+
(model_input, result)
173+
)
174+
175+
points: list[SweepPoint] = []
176+
overall_pending = False
177+
for index, value in enumerate(sweep.values):
178+
simulation = simulation_by_index.get(index)
179+
if simulation is None:
180+
overall_pending = True
181+
continue
182+
183+
ordered = order_chain(rows_by_simulation.get(simulation.id, []))
184+
status, concentrations, error = _summarize_point(ordered)
185+
if status == "pending":
186+
overall_pending = True
187+
188+
points.append(
189+
SweepPoint(
190+
value=value,
191+
simulation_id=simulation.id,
192+
status=status, # type: ignore[arg-type]
193+
error=error,
194+
concentrations=concentrations,
195+
)
196+
)
197+
198+
first_simulation = simulation_by_index.get(0)
199+
if first_simulation is not None:
200+
models = [
201+
ModelInput(model_id=mi.model_id, parameters=mi.parameters)
202+
for mi, _ in order_chain(rows_by_simulation.get(first_simulation.id, []))
203+
]
204+
base_concentrations = dict(first_simulation.concentrations or {})
205+
conditions = Conditions(**(first_simulation.conditions or {}))
206+
else:
207+
models = []
208+
base_concentrations = {}
209+
conditions = Conditions()
210+
211+
return SweepResult(
212+
status="pending" if overall_pending else "done",
213+
swept_substance=sweep.swept_substance,
214+
values=list(sweep.values),
215+
concentrations=base_concentrations,
216+
conditions=conditions,
217+
models=models,
218+
points=points,
219+
)

0 commit comments

Comments
 (0)