Skip to content

Commit e545439

Browse files
refactor(models): add get_population_ref() to ScenarioMeta, fix engine population resolution
Move duplicated _parse_base_population_ref() logic from 4 CLI commands into a single get_population_ref() method on ScenarioMeta. Fix engine.py to use the new method and add scenario-name-first fallback for agent and network loading (new CLI flow vs legacy IDs). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 94ad4ed commit e545439

6 files changed

Lines changed: 70 additions & 73 deletions

File tree

extropy/cli/commands/estimate.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,12 @@
11
"""Estimate command for predicting simulation costs before running."""
22

3-
import re
4-
53
import typer
64

75
from ..app import app, console, get_study_path
86
from ..study import StudyContext, detect_study_folder, parse_version_ref
97
from ..utils import Output, ExitCode
108

119

12-
def _parse_base_population_ref(ref: str) -> tuple[str, int | None]:
13-
"""Parse base_population reference like 'population.v2'."""
14-
match = re.match(r"^(.+)\.v(\d+)$", ref)
15-
if match:
16-
return match.group(1), int(match.group(2))
17-
return ref, None
18-
19-
2010
@app.command("estimate")
2111
def estimate_command(
2212
scenario: str = typer.Option(
@@ -96,12 +86,12 @@ def estimate_command(
9686
raise typer.Exit(1)
9787

9888
# Load population spec (resolve from base_population or population_spec)
99-
base_pop_ref = scenario_spec.meta.base_population or scenario_spec.meta.population_spec
100-
if not base_pop_ref:
101-
out.error("Scenario has no base_population or population_spec reference")
89+
try:
90+
pop_name, pop_version = scenario_spec.meta.get_population_ref()
91+
except ValueError as e:
92+
out.error(str(e))
10293
raise typer.Exit(1)
10394

104-
pop_name, pop_version = _parse_base_population_ref(base_pop_ref)
10595
pop_path = study_ctx.get_population_path(pop_name, pop_version)
10696
if not pop_path.exists():
10797
out.error(f"Population spec not found: {pop_path}")

extropy/cli/commands/network.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,12 @@ def network_command(
260260

261261
# Load base population spec (needed for config generation)
262262
merged_spec = None
263-
base_pop_ref = scenario_spec.meta.base_population
264-
if base_pop_ref:
265-
pop_name, pop_version = _parse_base_population_ref(base_pop_ref)
263+
try:
264+
pop_name, pop_version = scenario_spec.meta.get_population_ref()
265+
except ValueError:
266+
pop_name, pop_version = None, None
267+
268+
if pop_name is not None:
266269
pop_path = study_ctx.get_population_path(pop_name, pop_version)
267270
try:
268271
pop_spec = PopulationSpec.from_yaml(pop_path)
@@ -755,14 +758,6 @@ def _resolve_scenario(
755758
return name, version
756759

757760

758-
def _parse_base_population_ref(ref: str) -> tuple[str, int | None]:
759-
"""Parse base_population reference like 'population.v2'."""
760-
import re
761-
762-
match = re.match(r"^(.+)\.v(\d+)$", ref)
763-
if match:
764-
return match.group(1), int(match.group(2))
765-
return ref, None
766761

767762

768763
def _save_network(

extropy/cli/commands/persona.py

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,19 @@ def persona_command(
112112
raise typer.Exit(1)
113113

114114
# Load base population spec
115-
base_pop_ref = scenario_spec.meta.base_population
116-
if not base_pop_ref:
117-
# Legacy scenario - try population_spec path
118-
if scenario_spec.meta.population_spec:
119-
pop_path = Path(scenario_spec.meta.population_spec)
120-
if not pop_path.is_absolute():
121-
pop_path = study_ctx.root / pop_path
122-
else:
123-
out.error("Scenario has no base_population reference")
124-
raise typer.Exit(1)
125-
else:
126-
# Parse base_population reference (e.g., "population.v2")
127-
pop_name, pop_version = _parse_base_population_ref(base_pop_ref)
115+
try:
116+
pop_name, pop_version = scenario_spec.meta.get_population_ref()
117+
except ValueError as e:
118+
out.error(str(e))
119+
raise typer.Exit(1)
120+
121+
if pop_version is not None:
128122
pop_path = study_ctx.get_population_path(pop_name, pop_version)
123+
else:
124+
# Legacy scenario — population_spec is a file path
125+
pop_path = Path(pop_name)
126+
if not pop_path.is_absolute():
127+
pop_path = study_ctx.root / pop_path
129128

130129
try:
131130
pop_spec = PopulationSpec.from_yaml(pop_path)
@@ -301,21 +300,6 @@ def _resolve_scenario(
301300
return name, version
302301

303302

304-
def _parse_base_population_ref(ref: str) -> tuple[str, int]:
305-
"""Parse base_population reference like 'population.v2'.
306-
307-
Returns:
308-
Tuple of (name, version)
309-
"""
310-
import re
311-
312-
match = re.match(r"^(.+)\.v(\d+)$", ref)
313-
if match:
314-
return match.group(1), int(match.group(2))
315-
# Fallback: treat as name, get latest
316-
return ref, None
317-
318-
319303
def _handle_show_mode(
320304
study_ctx: StudyContext,
321305
scenario_name: str,

extropy/cli/commands/sample.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,12 @@ def sample_command(
110110
raise typer.Exit(1)
111111

112112
# Load base population spec
113-
base_pop_ref = scenario_spec.meta.base_population
114-
if not base_pop_ref:
115-
out.error("Scenario has no base_population reference")
113+
try:
114+
pop_name, pop_version = scenario_spec.meta.get_population_ref()
115+
except ValueError as e:
116+
out.error(str(e))
116117
raise typer.Exit(1)
117118

118-
pop_name, pop_version = _parse_base_population_ref(base_pop_ref)
119119
pop_path = study_ctx.get_population_path(pop_name, pop_version)
120120

121121
try:
@@ -334,14 +334,6 @@ def _resolve_scenario(
334334
return name, version
335335

336336

337-
def _parse_base_population_ref(ref: str) -> tuple[str, int | None]:
338-
"""Parse base_population reference like 'population.v2'."""
339-
import re
340-
341-
match = re.match(r"^(.+)\.v(\d+)$", ref)
342-
if match:
343-
return match.group(1), int(match.group(2))
344-
return ref, None
345337

346338

347339
def _save_to_db(

extropy/core/models/scenario.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Validation: ValidationError, ValidationWarning, ValidationResult
1515
"""
1616

17+
import re
1718
from datetime import datetime
1819
from enum import Enum
1920
from pathlib import Path
@@ -332,6 +333,28 @@ class ScenarioMeta(BaseModel):
332333
network_id: str = Field(default="default", description="Network ID in study DB")
333334
created_at: datetime = Field(default_factory=datetime.now)
334335

336+
def get_population_ref(self) -> tuple[str, int | None]:
337+
"""Parse the population reference from base_population or population_spec.
338+
339+
Handles versioned references like 'population.v2' → ('population', 2)
340+
and plain names like 'population' → ('population', None).
341+
342+
Returns:
343+
Tuple of (name, version) where version is None for unversioned refs.
344+
345+
Raises:
346+
ValueError: If neither base_population nor population_spec is set.
347+
"""
348+
ref = self.base_population or self.population_spec
349+
if ref is None:
350+
raise ValueError(
351+
"Scenario has no base_population or population_spec reference"
352+
)
353+
match = re.match(r"^(.+)\.v(\d+)$", ref)
354+
if match:
355+
return match.group(1), int(match.group(2))
356+
return ref, None
357+
335358

336359
class ScenarioSpec(BaseModel):
337360
"""Complete specification for a scenario simulation."""

extropy/simulation/engine.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,10 +2136,18 @@ def _reset_runtime_tables(path: Path, run_key: str) -> None:
21362136
# Load scenario
21372137
scenario = ScenarioSpec.from_yaml(scenario_path)
21382138

2139-
# Load population spec
2140-
pop_path = Path(scenario.meta.population_spec)
2141-
if not pop_path.is_absolute():
2142-
pop_path = scenario_path.parent / pop_path
2139+
# Load population spec (resolve from base_population or legacy population_spec)
2140+
pop_name, pop_version = scenario.meta.get_population_ref()
2141+
2142+
if pop_version is not None:
2143+
# Versioned ref (e.g. "population.v1") — resolve relative to study root
2144+
# Study structure: study_root/scenario/<name>/scenario.v1.yaml
2145+
study_root = scenario_path.parent.parent.parent
2146+
pop_path = study_root / f"{pop_name}.v{pop_version}.yaml"
2147+
else:
2148+
pop_path = Path(pop_name)
2149+
if not pop_path.is_absolute():
2150+
pop_path = scenario_path.parent / pop_path
21432151
population_spec = PopulationSpec.from_yaml(pop_path)
21442152

21452153
# Resolve canonical study DB
@@ -2163,15 +2171,20 @@ def _reset_runtime_tables(path: Path, run_key: str) -> None:
21632171
)
21642172

21652173
with open_study_db(study_db_resolved) as db:
2166-
agents = db.get_agents(scenario.meta.population_id)
2174+
# Try scenario-based lookup first (new CLI flow), fall back to legacy IDs
2175+
agents = db.get_agents_by_scenario(scenario.meta.name)
2176+
if not agents:
2177+
agents = db.get_agents(scenario.meta.population_id)
21672178
if not agents:
21682179
raise ValueError(
2169-
f"No agents for population_id '{scenario.meta.population_id}' in {study_db_resolved}"
2180+
f"No agents for scenario '{scenario.meta.name}' in {study_db_resolved}"
21702181
)
2171-
network = db.get_network(scenario.meta.network_id)
2182+
network = db.get_network(scenario.meta.name)
2183+
if not network.get("edges"):
2184+
network = db.get_network(scenario.meta.network_id)
21722185
if not network.get("edges"):
21732186
raise ValueError(
2174-
f"No network edges for network_id '{scenario.meta.network_id}' in {study_db_resolved}"
2187+
f"No network edges for scenario '{scenario.meta.name}' in {study_db_resolved}"
21752188
)
21762189
db.create_simulation_run(
21772190
run_id=resolved_run_id,

0 commit comments

Comments
 (0)