Separate Data Interface from Model Logic
Type: Refactor / Architecture
Priority: High
Labels: architecture, refactor, good-first-project, intern
Background
The ABSiCE ABM is a well-functioning simulation, but as a result of organic growth over time,
the model constructor (ABM_CE_PV.__init__) does too many things at once:
- It accepts 70+ raw parameters as function arguments
- It loads dozens of data files (CSV, Excel, YAML) inline
- It constructs file paths by hand with string concatenation
- It transforms data (unit conversions, timestep scaling) mixed in with business logic
- All of this lives in one 600-line
__init__ method
This makes the simulation very hard to:
- Configure for a new scenario without reading the source code
- Test in isolation (you can't run the model without all the data files present)
- Maintain (changing a file path means hunting through
__init__)
- Hand off to collaborators or students
The goal of this issue is to cleanly separate three concerns:
- Configuration – what parameters control the simulation (YAML files)
- Data – what external files feed the simulation (a typed data layer)
- Model logic – the actual ABM mechanics (unchanged)
⚠️ Scope note: A CLI runner is a planned follow-up task. This issue is only about the
internal architecture — do not add a CLI here.
Goals
Proposed Architecture
ABSiCE/
│
├── config/ ← NEW: user-facing YAML files (edit these to change a scenario)
│ ├── default_simulation.yaml ← simulation parameters
│ └── data_paths.yaml ← file paths for all data sources
│
├── absice/ ← NEW: importable Python package
│ ├── __init__.py
│ ├── schemas/ ← Pydantic classes that parse & validate the config/ YAML files
│ │ ├── __init__.py
│ │ ├── simulation_config.py
│ │ └── data_paths_config.py
│ ├── data/ ← all file I/O lives here
│ │ ├── __init__.py
│ │ └── data_loader.py
│ └── model/
│ ├── __init__.py
│ └── ABM_CE_PV_Model.py ← model receives schema objects, not raw params
│
├── ABM_CE_PV_ConsumerAgents.py ← unchanged for now
├── ABM_CE_PV_RecyclerAgents.py ← unchanged for now
│ ... (other agent files unchanged)
└── ABM_CE_PV_MultipleRun.py ← updated to load YAML and pass schema objects
The distinction in one sentence: config/ contains the YAML files users edit; absice/schemas/ contains the Python classes that read and validate those files.
Note: Moving files into absice/ is optional for this iteration. If it feels like too
much churn, you can keep everything at the top level and just add the new config/ and
data/ modules. Discuss with the senior dev before deciding.
Phase 1 — Extract Configuration into Pydantic Models and YAML
1a. Understand what a Pydantic model is
Pydantic lets you define data classes that validate
their fields automatically. If you pass a string where a float is expected, you get a clear
error immediately, not a confusing crash 200 lines later.
# Example: instead of passing w_sn_eol=0.23 as a raw float, you define:
from pydantic import BaseModel, Field
class TpbWeights(BaseModel):
w_sn_eol: float = Field(default=0.23, ge=0.0, le=1.0,
description="Weight of subjective norm for EoL decisions")
w_pbc_eol: float = Field(default=0.44, ge=0.0, le=1.0)
w_a_eol: float = Field(default=0.59, ge=0.0, le=1.0)
w_sn_reuse: float = Field(default=0.497, ge=0.0, le=1.0)
w_pbc_reuse: float = Field(default=0.382, ge=0.0, le=1.0)
w_a_reuse: float = Field(default=0.464, ge=0.0, le=1.0)
# Now if someone writes w_sn_eol=2.5 (a weight > 1, which is nonsensical),
# Pydantic raises a clear ValidationError before the model even starts.
1b. Create absice/schemas/simulation_config.py
Split the 70+ __init__ parameters into logical Pydantic sub-models, then compose them
into one top-level SimulationConfig. Each sub-model should cover one conceptual area.
Suggested groupings (derive from the existing __init__ signature):
# absice/schemas/simulation_config.py
from pydantic import BaseModel, Field, field_validator
# Import the enums that already exist in utils — do NOT redefine them here.
from utils import TIMESTEP, ConsumerAgentResolution
# ── helpers ───────────────────────────────────────────────────────────────────
def _parse_timestep(v: object) -> TIMESTEP:
"""
Coerce a YAML string like "annual" to the TIMESTEP enum.
TIMESTEP uses integer values (ANNUAL=1, MONTHLY=12, QUARTERLY=4), so
Pydantic cannot coerce the name automatically — we do it here.
"""
if isinstance(v, TIMESTEP):
return v
if isinstance(v, str):
try:
return TIMESTEP[v.upper()]
except KeyError:
raise ValueError(f"Invalid timestep '{v}'. Choose from: "
f"{[m.name.lower() for m in TIMESTEP]}")
raise TypeError(f"Expected str or TIMESTEP, got {type(v)}")
def _parse_resolution(v: object) -> ConsumerAgentResolution:
"""Coerce a YAML string like "site" to ConsumerAgentResolution."""
if isinstance(v, ConsumerAgentResolution):
return v
if isinstance(v, str):
try:
return ConsumerAgentResolution[v.upper()]
except KeyError:
raise ValueError(f"Invalid resolution '{v}'. Choose from: "
f"{[m.name.lower() for m in ConsumerAgentResolution]}")
raise TypeError(f"Expected str or ConsumerAgentResolution, got {type(v)}")
# ── sub-models (one per domain) ───────────────────────────────────────────────
class RunConfig(BaseModel):
"""Core simulation run settings."""
seed: int | None = None
last_step: int = Field(default=31, gt=0)
timestep: TIMESTEP = TIMESTEP.ANNUAL
@field_validator("timestep", mode="before")
@classmethod
def _coerce_timestep(cls, v: object) -> TIMESTEP:
return _parse_timestep(v)
class NetworkConfig(BaseModel):
"""Agent network topology."""
num_consumers: int = Field(default=1000, gt=0)
consumers_node_degree: int = Field(default=10, gt=0)
consumers_network_type: str = "small-world"
rewiring_prob: float = Field(default=0.1, ge=0.0, le=1.0)
num_producers: int = Field(default=60, gt=0)
num_recyclers: int = Field(default=16, gt=0)
num_refurbishers: int = Field(default=15, gt=0)
prod_n_recyc_node_degree: int = Field(default=5, gt=0)
prod_n_recyc_network_type: str = "small-world"
class ConsumerConfig(BaseModel):
"""Consumer agent settings."""
resolution: ConsumerAgentResolution = ConsumerAgentResolution.SITE
model_states: list[str] | None = None
consumers_distribution: dict[str, float] = Field(
default={"residential": 1.0, "commercial": 0.0, "utility": 0.0})
product_distribution: dict[str, float] = Field(
default={"residential": 1.0, "commercial": 0.0, "utility": 0.0})
@field_validator("resolution", mode="before")
@classmethod
def _coerce_resolution(cls, v: object) -> ConsumerAgentResolution:
return _parse_resolution(v)
class ProductConfig(BaseModel):
"""Product lifecycle parameters."""
product_lifetime: int = Field(default=30, gt=0)
product_growth: list[float] = Field(default=[0.166, 0.045])
growth_threshold: int = Field(default=10, gt=0)
failure_rate_alpha: list[float] = Field(default=[2.4928, 5.3759, 3.93495])
mass_to_function_reg_coeff: float = 0.03
max_storage: list[float] = Field(default=[1, 8, 4])
class EolConfig(BaseModel):
"""End-of-life pathway settings."""
init_eol_rate: dict[str, float] = Field(
default={"repair": 0.005, "sell": 0.01, "recycle": 0.1,
"landfill": 0.885, "hoard": 0.0})
all_eol_pathways: dict[str, bool] = Field(
default={"repair": False, "sell": False, "recycle": True,
"landfill": True, "hoard": False})
init_purchase_choice: dict[str, float] = Field(
default={"new": 0.9995, "used": 0.0005, "certified": 0.0})
purchase_choices: dict[str, bool] = Field(
default={"new": True, "used": True, "certified": False})
class TpbConfig(BaseModel):
"""Theory of Planned Behavior weights."""
theory_of_planned_behavior: dict[str, bool] = Field(
default={"residential": True, "commercial": True, "utility": True})
w_sn_eol: float = Field(default=0.23, ge=0.0, le=1.0)
w_pbc_eol: float = Field(default=0.44, ge=0.0, le=1.0)
w_a_eol: float = Field(default=0.59, ge=0.0, le=1.0)
w_sn_reuse: float = Field(default=0.497, ge=0.0, le=1.0)
w_pbc_reuse: float = Field(default=0.382, ge=0.0, le=1.0)
w_a_reuse: float = Field(default=0.464, ge=0.0, le=1.0)
att_distrib_param_eol: list[float] = Field(default=[0.515, 0.1])
att_distrib_param_reuse: list[float] = Field(default=[0.01, 0.185])
extended_tpb: dict = Field(default={
"Extended tpb": False, "w_convenience": 0.28,
"w_knowledge": -0.51, "knowledge_distrib": [0.5, 0.49]})
class CostConfig(BaseModel):
"""All cost-related parameters."""
landfill_cost: list[float] # 48 state-specific values $/ton
hoarding_cost: list[float] = Field(default=[0, 130, 65])
original_recycling_cost: list[float] = Field(default=[400-1e-6, 400+1e-6, 400])
original_repairing_cost: list[float] = Field(default=[12987, 58442, 29870])
hazardous_waste_management_cost: dict[str, float] = Field(
default={"repair": 0.0, "sell": 0.0, "recycle": 0.0,
"landfill": 300.0, "hoard": 0.0})
transportation_cost: float = 0.095
hazardous_transportation_cost: float = 0.095
fsthand_mkt_pric: float = 58442.0
fsthand_mkt_pric_reg_param: list[float] = Field(default=[1.0, 0.04])
scndhand_mkt_pric_rate: list[float] = Field(default=[0.4, 0.2])
refurbisher_margin: list[float] = Field(default=[0.4, 0.6, 0.5])
repairability: float = Field(default=0.55, ge=0.0, le=1.0)
used_product_substitution_rate: list[float] = Field(default=[0.6, 1, 0.8])
imperfect_substitution: float = 0.0
recycling_learning_shape_factor: float = -0.01
repairing_learning_shape_factor: float = -0.31
sa_landfill_costs: tuple[bool, float] = (False, 481.0)
class MaterialConfig(BaseModel):
"""Material composition and market parameters."""
product_mass_fractions: dict[str, float] = Field(default={
"Product": 1.0, "Aluminum": 0.08, "Glass": 0.76, "Copper": 0.01,
"Insulated cable": 0.012, "Silicon": 0.036, "Silver": 0.00032})
established_scd_mkt: dict[str, bool] = Field(default={
"Product": True, "Aluminum": True, "Glass": True, "Copper": True,
"Insulated cable": True, "Silicon": False, "Silver": False})
scd_mat_prices: dict[str, list] = ... # triangular dist params [min, max, mode]
virgin_mat_prices: dict[str, list] = ...
material_waste_ratio: dict[str, float] = ...
recovery_fractions: dict[str, float] = ...
class ScenarioConfig(BaseModel):
"""Optional/advanced scenario toggles."""
epr_business_model: bool = False
recycling_process: dict[str, bool] = Field(
default={"frelp": False, "asu": False, "hybrid": False})
dynamic_lifetime_model: dict = Field(default={
"Dynamic lifetime": False, "d_lifetime_intercept": 15.9,
"d_lifetime_reg_coeff": 0.87, "Seed": False,
"Year": 5, "avg_lifetime": 50})
seeding: dict = Field(default={
"Seeding": False, "Year": 10, "number_seed": 50})
seeding_recyc: dict = Field(default={
"Seeding": False, "Year": 10, "number_seed": 50, "discount": 0.35})
recycling_states: list[str] = Field(default=[
"Texas", "Arizona", "Oregon", "Oklahoma",
"Wisconsin", "Ohio", "Kentucky", "South Carolina"])
hazardous_waste_regulation_enabled: bool = False
landfill_solar_waste_acceptance_ratio: float = Field(
default=0.4, ge=0.0, le=1.0)
class TclpConfig(BaseModel):
"""TCLP leach-test parameters."""
bsf_mean: float = 3.35
bsf_std: float = 1.17
non_bsf_mean: float = 1.85
non_bsf_std: float = 0.97
hazard_cutoff: dict[str, float] = Field(default_factory=lambda: {
"federal": 5.0,
**{s: 5.0 for s in ["AL","AZ","AR","CA","CO","CT","DE","FL","GA",
"ID","IL","IN","IA","KS","KY","LA","ME","MD",
"MA","MI","MN","MS","MO","MT","NE","NV","NH",
"NJ","NM","NY","NC","ND","OH","OK","OR","PA",
"RI","SC","SD","TN","TX","UT","VT","VA","WA",
"WV","WI","WY"]}})
min_std: float = 0.05
class DataSourceConfig(BaseModel):
"""Feature flags that control which external data source is active."""
rtn: bool = False
solar_cycle: bool = False
use_pv_ice: bool = False # renamed from 'pv_ice' to avoid shadowing module
# ── top-level composed config ─────────────────────────────────────────────────
class SimulationConfig(BaseModel):
"""
Top-level configuration for a full ABSiCE simulation run.
Compose all sub-configs here.
"""
run: RunConfig = Field(default_factory=RunConfig)
network: NetworkConfig = Field(default_factory=NetworkConfig)
consumer: ConsumerConfig = Field(default_factory=ConsumerConfig)
product: ProductConfig = Field(default_factory=ProductConfig)
eol: EolConfig = Field(default_factory=EolConfig)
tpb: TpbConfig = Field(default_factory=TpbConfig)
cost: CostConfig = ... # required; no default (cost list is long)
material: MaterialConfig = ... # required
scenario: ScenarioConfig = Field(default_factory=ScenarioConfig)
tclp: TclpConfig = Field(default_factory=TclpConfig)
data_source: DataSourceConfig = Field(default_factory=DataSourceConfig)
@classmethod
def from_yaml(cls, path: str) -> "SimulationConfig":
"""Load configuration from a YAML file."""
import yaml
with open(path) as f:
raw = yaml.safe_load(f)
return cls(**raw)
def to_yaml(self, path: str) -> None:
"""Serialize configuration to a YAML file."""
import yaml
with open(path, "w") as f:
yaml.dump(self.model_dump(), f, default_flow_style=False, sort_keys=False)
1c. Create config/default_simulation.yaml
This file is what users edit to change parameters. It should be fully commented.
# config/default_simulation.yaml
# ─────────────────────────────────────────────────────────────────────────────
# ABSiCE Simulation Configuration
# Edit this file to change simulation parameters.
# All monetary values are in $/metric ton unless noted otherwise.
# ─────────────────────────────────────────────────────────────────────────────
run:
seed: null # set an integer for reproducible results, e.g. seed: 42
last_step: 31 # number of time steps to simulate
timestep: annual # options: annual | quarterly | monthly
network:
num_consumers: 1000
consumers_node_degree: 10
consumers_network_type: small-world # small-world | random | cycle | scale-free
rewiring_prob: 0.1
num_producers: 60
num_recyclers: 16
num_refurbishers: 15
prod_n_recyc_node_degree: 5
prod_n_recyc_network_type: small-world
consumer:
resolution: site # site | pca
model_states: null # null = all US states; or list, e.g. [California, Texas]
consumers_distribution:
residential: 1.0
commercial: 0.0
utility: 0.0
product_distribution:
residential: 1.0
commercial: 0.0
utility: 0.0
# ... (truncated for brevity — the full YAML mirrors all Pydantic sub-models)
Tip: Use SimulationConfig(...).to_yaml("config/default_simulation.yaml") to
generate the first version of this file programmatically from the Pydantic defaults.
That way you can't accidentally introduce a typo.
Phase 2 — Separate Data Paths Config
Data file paths should not be hard-coded strings inside __init__. They should live in
their own config model and YAML file so that switching from one dataset to another (e.g., RTN
vs. default landfill data) is a one-line YAML change.
2a. Create absice/schemas/data_paths_config.py
# absice/schemas/data_paths_config.py
from pathlib import Path
from pydantic import BaseModel, field_validator
class DataPathsConfig(BaseModel):
"""
Resolved absolute paths to all external data files used by the simulation.
Design rule: every field is a Path, not a str. Pydantic will coerce str → Path.
Existence of the file is NOT validated here (that is the DataLoader's job),
so that unit tests can construct a config without needing real files on disk.
"""
# ── PV ICE / ReEDS ────────────────────────────────────────────────────────
reeds_solar_futures: Path
reeds_std_scen24: Path
gis_centroids: Path
baseline_module_mass: Path
baseline_module_energy: Path
pvice_pca_merged_dir: Path # directory, not a file
# ── Distance matrices ─────────────────────────────────────────────────────
recycler_distances: Path
landfill_distances: Path
hazardous_landfill_distances: Path
uw_landfill_distances: Path
uw_recycler_distances: Path
# ── Facility data ─────────────────────────────────────────────────────────
recycler_data: Path
landfill_data: Path
hazardous_landfill_data: Path
uw_landfill_data: Path
uw_recycler_data: Path
# ── Market / auxiliary ────────────────────────────────────────────────────
correct_mat_factor: Path
states_adjacency_matrix: Path
uspvdb: Path # only used when resolution == SITE
# ── Policy ────────────────────────────────────────────────────────────────
policy_by_state: Path
policy_schedule: Path
tclp_market_share: Path
generator_threshold: Path
uw_generator_threshold: Path
@classmethod
def from_yaml(cls, path: str) -> "DataPathsConfig":
import yaml
with open(path) as f:
raw = yaml.safe_load(f)
return cls(**raw)
@classmethod
def make_default(cls, project_root: Path) -> "DataPathsConfig":
"""
Construct default paths relative to a known project root.
This replaces the ad-hoc os.path.join(...) calls in Model.__init__.
"""
temp = project_root / "TEMP"
pvice = project_root / "PV_ICE"
sup = pvice / "baselines" / "SupportingMaterial"
pol = project_root / "policy_regulation"
return cls(
reeds_solar_futures = sup / "December Core Scenarios ReEDS Outputs Solar Futures v3a.xlsx",
reeds_std_scen24 = project_root / "ReEDS" / "StdScen24_annual_balancingAreas_Mid_Case_CO2e_95by2035.xlsx",
gis_centroids = sup / "gis_centroid_n.csv",
baseline_module_mass = pvice / "baselines" / "baseline_modules_mass_US.csv",
baseline_module_energy = pvice / "baselines" / "baseline_modules_energy.csv",
pvice_pca_merged_dir = pvice / "TEMP" / "PCA_merged",
recycler_distances = temp / "site_recycler_distances.csv",
landfill_distances = temp / "site_landfill_distances.csv",
hazardous_landfill_distances = temp / "hazardous_site_landfill_distances.csv",
uw_landfill_distances = temp / "universal_waste_site_landfill_distances.csv",
uw_recycler_distances = temp / "universal_waste_site_recycler_distances.csv",
recycler_data = temp / "recycler_data.csv",
landfill_data = temp / "Landfills_data_2023.csv",
hazardous_landfill_data = temp / "Landfills_data_SA.csv",
uw_landfill_data = temp / "Universal_Waste_Landfills_data.csv",
uw_recycler_data = temp / "Universal_Waste_Recyclers_data.csv",
correct_mat_factor = temp / "correct_mat_factor.csv",
states_adjacency_matrix = project_root / "StatesAdjacencyMatrix.csv",
uspvdb = project_root / "USPVDB" / "uspvdb_v3_0_20250430_with_pca.xlsx",
policy_by_state = pol / "policy_by_state.csv",
policy_schedule = pol / "policy_schedule.yaml",
tclp_market_share = pol / "tclp_market_share_interpolated.csv",
generator_threshold = pol / "generator_threshold.csv",
uw_generator_threshold = pol / "universal_waste_generator_threshold.csv",
)
2b. Create config/data_paths.yaml
Users who clone the repo and put data files in a custom location can override just the paths
they need to change, without touching any Python.
# config/data_paths.yaml
# All paths are relative to the project root unless they start with /
reeds_solar_futures: PV_ICE/baselines/SupportingMaterial/December Core Scenarios ReEDS Outputs Solar Futures v3a.xlsx
reeds_std_scen24: ReEDS/StdScen24_annual_balancingAreas_Mid_Case_CO2e_95by2035.xlsx
gis_centroids: PV_ICE/baselines/SupportingMaterial/gis_centroid_n.csv
recycler_data: TEMP/recycler_data.csv
landfill_data: TEMP/Landfills_data_2023.csv
# ... etc.
Phase 3 — Create a DataLoader Class
All file I/O should live in one place. The DataLoader:
- Receives a
DataPathsConfig so it knows where to find files
- Exposes one method per logical dataset (easy to mock in tests)
- Returns
pandas.DataFrame or typed objects — never raw file handles
# absice/data/data_loader.py
from pathlib import Path
import pandas as pd
from pydantic import BaseModel, ConfigDict
from absice.schemas.data_paths_config import DataPathsConfig
class LoadedData(BaseModel):
"""
Typed container for all DataFrames the model needs at startup.
Passed as a single argument to ABM_CE_PV.__init__.
model_config sets arbitrary_types_allowed=True because pd.DataFrame
is not a type Pydantic knows how to validate by default.
frozen=True prevents *field reassignment* on this object
(e.g. ``data.recycler_data = other_df`` will raise an error).
It does NOT prevent mutating the DataFrames themselves — agents that
modify a DataFrame in-place (e.g. ``df.iloc[0] = value``) are
unaffected. In practice the model does
``self.recycler_data = data.recycler_data`` in __init__ and then
works exclusively through ``self.*``, so frozen=True is safe.
"""
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
reeds_raw: pd.DataFrame
gis_centroids: pd.DataFrame
recycler_data: pd.DataFrame
landfill_cost_df: pd.DataFrame
hazardous_landfill_cost_df: pd.DataFrame
uw_landfill_data: pd.DataFrame
uw_recycler_data: pd.DataFrame
recycler_distance_df: pd.DataFrame
landfill_distance_df: pd.DataFrame
hazardous_landfill_distance_df: pd.DataFrame
uw_landfill_distance_df: pd.DataFrame
uw_recycler_distance_df: pd.DataFrame
correct_mat_factor: pd.DataFrame
states_adjacency_matrix: pd.DataFrame
pvice_waste_eol_df: pd.DataFrame
tclp_market_share_df: pd.DataFrame
policy_schedule_by_state: dict
uspvdb: pd.DataFrame | None # None when resolution == PCA
reeds_data: pd.DataFrame | None # None when resolution == PCA
class DataLoader:
"""
Loads all external data files needed by the simulation.
By keeping all I/O here, the Model class itself becomes pure logic:
it never calls pd.read_csv() directly.
Parameters
----------
paths : DataPathsConfig
Resolved paths to every data file.
"""
def __init__(self, paths: DataPathsConfig) -> None:
self._paths = paths
def load_all(self, resolution: str, model_states: list[str] | None = None) -> LoadedData:
"""
Load every dataset and return a single LoadedData bundle.
Parameters
----------
resolution : str
"site" or "pca" — determines which distance matrices and USPVDB files to load.
model_states : list[str] | None
If provided, filter spatial data to these US states only.
"""
return LoadedData(
reeds_raw = self._load_reeds(),
gis_centroids = self._load_gis(),
recycler_data = self._load_recycler_data(),
landfill_cost_df = self._load_landfill_data(),
hazardous_landfill_cost_df = self._load_hazardous_landfill_data(),
uw_landfill_data = self._load_csv(self._paths.uw_landfill_data),
uw_recycler_data = self._load_csv(self._paths.uw_recycler_data),
recycler_distance_df = self._load_csv(self._paths.recycler_distances),
landfill_distance_df = self._load_csv(self._paths.landfill_distances),
hazardous_landfill_distance_df = self._load_csv(
self._paths.hazardous_landfill_distances),
uw_landfill_distance_df = self._load_csv(self._paths.uw_landfill_distances),
uw_recycler_distance_df = self._load_csv(self._paths.uw_recycler_distances),
correct_mat_factor = self._load_csv(self._paths.correct_mat_factor),
states_adjacency_matrix = self._load_csv(
self._paths.states_adjacency_matrix),
pvice_waste_eol_df = self._load_pvice_waste_eol(),
tclp_market_share_df = self._load_csv(self._paths.tclp_market_share),
policy_schedule_by_state = self._load_policy_schedule(),
uspvdb = self._load_uspvdb(model_states) if resolution == "site" else None,
reeds_data = self._load_reeds_balancing_areas(model_states)
if resolution == "site" else None,
)
# ── private helpers (one per file) ───────────────────────────────────────
def _load_csv(self, path: Path) -> pd.DataFrame:
self._assert_exists(path)
return pd.read_csv(path)
def _load_reeds(self) -> pd.DataFrame:
self._assert_exists(self._paths.reeds_solar_futures)
df = pd.read_excel(self._paths.reeds_solar_futures,
sheet_name="new installs PV")
df.drop(columns=["Tech"], inplace=True)
df.set_index(["Scenario", "Year", "PCA", "State"], inplace=True)
return df
def _load_gis(self) -> pd.DataFrame:
df = self._load_csv(self._paths.gis_centroids)
return df.set_index("id")
def _load_recycler_data(self) -> pd.DataFrame:
return self._load_csv(self._paths.recycler_data)
def _load_landfill_data(self) -> pd.DataFrame:
return self._load_csv(self._paths.landfill_data)
def _load_hazardous_landfill_data(self) -> pd.DataFrame:
return self._load_csv(self._paths.hazardous_landfill_data)
def _load_pvice_waste_eol(self) -> pd.DataFrame:
path = self._paths.pvice_pca_merged_dir / "PVICE_PCA_WasteEOL_by_Year_and_PCA.csv"
return self._load_csv(path)
def _load_uspvdb(self, model_states: list[str] | None) -> pd.DataFrame:
self._assert_exists(self._paths.uspvdb)
df = pd.read_excel(self._paths.uspvdb)
if model_states is not None:
df = df[df["p_state"].isin(model_states)]
return df
def _load_reeds_balancing_areas(self, model_states: list[str] | None) -> pd.DataFrame:
self._assert_exists(self._paths.reeds_std_scen24)
df = pd.read_excel(self._paths.reeds_std_scen24)
if model_states is not None:
df = df[df["state"].isin(model_states)]
df["utility_scale_pv_contribution_factor"] = (
df["upv_MW"] / (df["upv_MW"] + df["distpv_MW"])
)
return df
def _load_policy_schedule(self) -> dict:
"""Return the same structure that _load_policy_schedule_by_state() produces today."""
import yaml
path = self._paths.policy_schedule
if not path.exists():
return {}
with open(path) as f:
config = yaml.safe_load(f) or {}
raw_policies = config.get("policies") or {}
schedule_by_state: dict[str, dict] = {}
for policy_name, entries in raw_policies.items():
if not entries:
continue
for entry in entries:
states = entry.get("states", [])
entry_schedule = {k: v for k, v in entry.items() if k != "states"}
for state in states:
schedule_by_state.setdefault(state, {})[policy_name] = entry_schedule
return schedule_by_state
@staticmethod
def _assert_exists(path: Path) -> None:
if not path.exists():
raise FileNotFoundError(
f"Required data file not found: {path}\n"
f"Check your data_paths.yaml or DataPathsConfig."
)
Phase 4 — Update ABM_CE_PV.__init__ to Accept Config Objects
Once Phases 1–3 are done, the model signature becomes clean and simple:
# Before (current state):
model = ABM_CE_PV(
seed=42,
num_consumers=1000,
w_sn_eol=0.23,
w_pbc_eol=0.44,
# ... 65 more arguments ...
)
# After (target state):
from absice.schemas.simulation_config import SimulationConfig
from absice.schemas.data_paths_config import DataPathsConfig
from absice.data.data_loader import DataLoader
config = SimulationConfig.from_yaml("config/default_simulation.yaml")
paths = DataPathsConfig.make_default(project_root=Path("."))
data = DataLoader(paths).load_all(
resolution=config.consumer.resolution,
model_states=config.consumer.model_states,
)
model = ABM_CE_PV(config=config, data=data)
Change to ABM_CE_PV.__init__:
def __init__(self, config: SimulationConfig, data: LoadedData) -> None:
"""
Initialise the model.
Parameters
----------
config : SimulationConfig
All simulation parameters loaded from YAML.
data : LoadedData
All pre-loaded DataFrames (produced by DataLoader.load_all()).
"""
super().__init__(seed=config.run.seed)
self.config = config # keep a reference for agents that need it
self.data = data # keep a reference for data access
# replace 70+ local variable assignments with attribute access:
self.seed = config.run.seed
self.timestep = config.run.timestep # already a TIMESTEP enum
self.last_step = config.run.last_step
# ... etc.
# Data frames are already loaded — no pd.read_csv() here:
self.recycler_data = data.recycler_data
self.landfill_cost_df = data.landfill_cost_df
self.recycler_distance_df = data.recycler_distance_df
# ... etc.
Phase 5 — Update ABM_CE_PV_MultipleRun.py
The runner file becomes very small:
# ABM_CE_PV_MultipleRun.py (new version)
from pathlib import Path
from absice.schemas.simulation_config import SimulationConfig
from absice.schemas.data_paths_config import DataPathsConfig
from absice.data.data_loader import DataLoader
from ABM_CE_PV_Model import ABM_CE_PV
PROJECT_ROOT = Path(__file__).parent
def run(config_path: str = "config/default_simulation.yaml") -> None:
config = SimulationConfig.from_yaml(config_path)
paths = DataPathsConfig.make_default(PROJECT_ROOT)
data = DataLoader(paths).load_all(
resolution=config.consumer.resolution.value,
model_states=config.consumer.model_states,
)
model = ABM_CE_PV(config=config, data=data)
for step in range(config.run.last_step):
model.step()
if __name__ == "__main__":
run()
Phase 6 — Write Tests
For each new component write at least one test. Tests live in test/.
# test/test_simulation_config.py
from absice.schemas.simulation_config import SimulationConfig, CostConfig
def test_default_config_is_valid():
"""SimulationConfig can be constructed with all defaults."""
# CostConfig requires the cost list explicitly (no default), so we pass it.
cost = CostConfig(landfill_cost=[500.0] * 48, scd_mat_prices={...}, ...)
cfg = SimulationConfig(cost=cost)
assert cfg.run.last_step == 31
def test_round_trip_yaml(tmp_path):
"""Config serialised to YAML and loaded back is identical."""
cost = CostConfig(landfill_cost=[500.0] * 48, ...)
cfg = SimulationConfig(cost=cost)
yaml_path = str(tmp_path / "test_config.yaml")
cfg.to_yaml(yaml_path)
loaded = SimulationConfig.from_yaml(yaml_path)
assert loaded == cfg
def test_invalid_tpb_weight_raises():
"""Pydantic rejects a TPB weight outside [0, 1]."""
from pydantic import ValidationError
import pytest
with pytest.raises(ValidationError):
from absice.schemas.simulation_config import TpbConfig
TpbConfig(w_sn_eol=2.5) # > 1.0 should fail
# test/test_data_loader.py
from pathlib import Path
import pandas as pd
from unittest.mock import patch
from absice.data.data_loader import DataLoader
from absice.schemas.data_paths_config import DataPathsConfig
def test_load_all_calls_correct_files(tmp_path):
"""DataLoader.load_all uses the paths from DataPathsConfig."""
# This test uses mock files so it works without real data.
# Create minimal stand-in CSV files:
(tmp_path / "recycler_data.csv").write_text("Recycler Name,Latitude,Longitude\nFoo,30,-90\n")
# ... create other minimal CSVs ...
paths = DataPathsConfig(
recycler_data=tmp_path / "recycler_data.csv",
# ... other paths ...
)
loader = DataLoader(paths)
df = loader._load_recycler_data()
assert "Recycler Name" in df.columns
Step-by-Step Checklist
Work through this in order. Each step can be a separate commit.
Coding Standards to Follow
These apply to all new files you create.
-
Type-annotate everything — every function parameter and return value.
# Bad
def load(path):
return pd.read_csv(path)
# Good
def load(path: Path) -> pd.DataFrame:
return pd.read_csv(path)
-
One responsibility per class — DataLoader loads data, it does not transform it.
Transformations (timestep scaling, unit conversion) stay in the model or a dedicated
transforms.py.
-
Fail fast with clear messages — the _assert_exists helper in DataLoader is a
good pattern. Use it everywhere a missing file would otherwise produce a confusing
error 100 lines later.
-
No magic strings — file column names that appear in multiple places should be
constants:
absice/schemas/constants.py
RECYCLER_NAME_COL = "Recycler Name"
5. **Commit often with descriptive messages**:
feat(config): add RunConfig and NetworkConfig Pydantic models
feat(config): add default_simulation.yaml generated from defaults
feat(data): implement DataLoader._load_recycler_data
test(config): add round-trip YAML serialisation test
6. **Keep commits small and reviewable** — one logical change per commit, not
"refactor everything in one go".
---
## Out of Scope (Do NOT Do in This PR)
- Implementing a CLI (`argparse`, `click`, etc.) — that is a separate issue
- Changing agent logic in `ABM_CE_PV_ConsumerAgents.py` etc.
- Performance optimizations
- Adding new simulation features
- Moving files to a new directory structure (optional, discuss first)
---
## Definition of Done
- [ ] All new code passes `flake8` with no errors
- [ ] Simulation results with the new interface match a baseline run produced by the
old interface numerically (run before/after and diff with `compare_results.py`)
- [ ] At least five passing tests in `test/`
- [ ] `config/default_simulation.yaml` contains all parameters with inline comments
- [ ] `README.md` updated with a short "How to configure a run" section
- [ ] PR description explains the before/after for anyone reading the diff
---
## References
- [Pydantic v2 docs — Getting started](https://docs.pydantic.dev/latest/concepts/models/)
- [Pydantic v2 — `Field()` validators](https://docs.pydantic.dev/latest/concepts/fields/)
- [Python `dataclasses` module](https://docs.python.org/3/library/dataclasses.html)
(useful background reading; we use Pydantic throughout this project instead)
- [YAML in Python with `PyYAML`](https://pyyaml.org/wiki/PyYAMLDocumentation)
- [Separation of concerns (Wikipedia)](https://en.wikipedia.org/wiki/Separation_of_concerns)
- Existing example in this repo: `policy_regulation/policy_schedule.yaml` + the
`_load_policy_schedule_by_state()` method in `ABM_CE_PV_Model.py` — this is already
a good pattern you can extend.
Separate Data Interface from Model Logic
Type: Refactor / Architecture
Priority: High
Labels:
architecture,refactor,good-first-project,internBackground
The ABSiCE ABM is a well-functioning simulation, but as a result of organic growth over time,
the model constructor (
ABM_CE_PV.__init__) does too many things at once:__init__methodThis makes the simulation very hard to:
__init__)The goal of this issue is to cleanly separate three concerns:
Goals
DataLoaderclass__init__receives typed configuration objects, not 70 raw argumentsProposed Architecture
The distinction in one sentence:
config/contains the YAML files users edit;absice/schemas/contains the Python classes that read and validate those files.Phase 1 — Extract Configuration into Pydantic Models and YAML
1a. Understand what a Pydantic model is
Pydantic lets you define data classes that validate
their fields automatically. If you pass a string where a float is expected, you get a clear
error immediately, not a confusing crash 200 lines later.
1b. Create
absice/schemas/simulation_config.pySplit the 70+
__init__parameters into logical Pydantic sub-models, then compose theminto one top-level
SimulationConfig. Each sub-model should cover one conceptual area.Suggested groupings (derive from the existing
__init__signature):1c. Create
config/default_simulation.yamlThis file is what users edit to change parameters. It should be fully commented.
Phase 2 — Separate Data Paths Config
Data file paths should not be hard-coded strings inside
__init__. They should live intheir own config model and YAML file so that switching from one dataset to another (e.g., RTN
vs. default landfill data) is a one-line YAML change.
2a. Create
absice/schemas/data_paths_config.py2b. Create
config/data_paths.yamlUsers who clone the repo and put data files in a custom location can override just the paths
they need to change, without touching any Python.
Phase 3 — Create a
DataLoaderClassAll file I/O should live in one place. The
DataLoader:DataPathsConfigso it knows where to find filespandas.DataFrameor typed objects — never raw file handlesPhase 4 — Update
ABM_CE_PV.__init__to Accept Config ObjectsOnce Phases 1–3 are done, the model signature becomes clean and simple:
Change to
ABM_CE_PV.__init__:Phase 5 — Update
ABM_CE_PV_MultipleRun.pyThe runner file becomes very small:
Phase 6 — Write Tests
For each new component write at least one test. Tests live in
test/.Step-by-Step Checklist
Work through this in order. Each step can be a separate commit.
pip install pydantic>=2.0) and add it toenvironment.yml/pv_abm_env.ymlabsice/schemas/__init__.py(empty file to make it a package)SimulationConfiginabsice/schemas/simulation_config.py— start with just
RunConfigandNetworkConfig, get them to load from YAML, thenadd the remaining sub-models one at a time
config/default_simulation.yamlby callingSimulationConfig(...).to_yaml(...)in a throw-away scriptDataPathsConfiginabsice/schemas/data_paths_config.pyconfig/data_paths.yamlwith default pathsabsice/data/__init__.py(empty file to make it a package)DataLoader+LoadedDatainabsice/data/data_loader.py— start with two or three methods, make sure they work, then add the rest
ABM_CE_PV(config, data)constructor, replacing the old70-argument signature
ABM_CE_PV_MultipleRun.pyto use the new interfacetest/for config round-trip and DataLoaderoutput (e.g.
results/baseline_before_refactor.csv). After the refactor is complete,run again and confirm the two outputs match numerically (use
compare_results.pyora quick
pandasdiff)Coding Standards to Follow
These apply to all new files you create.
Type-annotate everything — every function parameter and return value.
One responsibility per class —
DataLoaderloads data, it does not transform it.Transformations (timestep scaling, unit conversion) stay in the model or a dedicated
transforms.py.Fail fast with clear messages — the
_assert_existshelper inDataLoaderis agood pattern. Use it everywhere a missing file would otherwise produce a confusing
error 100 lines later.
No magic strings — file column names that appear in multiple places should be
constants:
absice/schemas/constants.py
RECYCLER_NAME_COL = "Recycler Name"
feat(config): add RunConfig and NetworkConfig Pydantic models
feat(config): add default_simulation.yaml generated from defaults
feat(data): implement DataLoader._load_recycler_data
test(config): add round-trip YAML serialisation test