diff --git a/README.md b/README.md index 0c76f69..f0320f0 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,6 @@ Scenarios are defined in `data/input/SimulatorScenarios.csv`. Each row is one sc | `shock_onset_step` | Step at which shock starts ramping in | | `shock_ramp_steps` | Steps to ramp from baseline to full shock value | | `wholesaler_storage_capacity` | Max tonnes a wholesaler can hold per step (default: 20 000 t) | -| `usa_surplus_factor` | USA idle capacity multiplier — `1.5` = 50% reserve above base yield | | `period_num` | Number of simulation steps (default: 52) | --- diff --git a/src/provider_simenv/agents/__init__.py b/src/provider_simenv/agents/__init__.py index b1f0c4f..f9bbe10 100644 --- a/src/provider_simenv/agents/__init__.py +++ b/src/provider_simenv/agents/__init__.py @@ -13,7 +13,7 @@ """ from .base import SupplyChainAgent -from .farmer import Farmer, ROLE_BRA,ROLE_ARG, ROLE_USA, ROLE_EU +from .farmer import Farmer, ROLE_PRODUCER, ROLE_EU from .trader import Trader, ROLE_WHOLESALER, ROLE_FEED_TRADER from .transport import Transport, ROLE_SA_SANTOS, ROLE_SA_PARANAGUA, ROLE_SEA_SANTOS, ROLE_SEA_PARANAGUA, ROLE_SEA_ARG, ROLE_SEA_USA, ROLE_EU_RTM, ROLE_EU_HAM from .process import Process, ROLE_PROCESSOR, ROLE_FEED_MANUFACTURER @@ -27,9 +27,7 @@ "Transport", "Process", # Role constants — import these to avoid magic strings in model.py - "ROLE_BRA", - "ROLE_ARG", - "ROLE_USA", + "ROLE_PRODUCER", "ROLE_EU", "ROLE_WHOLESALER", "ROLE_FEED_TRADER", diff --git a/src/provider_simenv/agents/base.py b/src/provider_simenv/agents/base.py index d42e7ce..fe7b1a4 100644 --- a/src/provider_simenv/agents/base.py +++ b/src/provider_simenv/agents/base.py @@ -48,6 +48,10 @@ def setup(self): self.unit_price: float = 0.0 self.active: bool = True + # PDL entity id this agent represents. Set by model.setup() from the roster's entity_ids. + # "" for synthetic hubs (wholesaler/feed_traders) + self.origin: str = "" + # PDL bindings: semantic slot -> (entity, impact_field). # Applied by model.setup() from Archetype.params["bindings"] in # topology.py (cross-entity slots), plus the entity-driven "capacity" slot. diff --git a/src/provider_simenv/agents/farmer.py b/src/provider_simenv/agents/farmer.py index 32a17b9..aca5a18 100644 --- a/src/provider_simenv/agents/farmer.py +++ b/src/provider_simenv/agents/farmer.py @@ -1,16 +1,11 @@ """ - role="bra" Brazilian soja producer. First actor in the chain. - Produces soja; price = (fixed_costs / qty) * (1 + margin). - - role="arg" Argentine soja producer. Always-on baseline supplier. - Same price formula as BRA, not affected by BRA shock. - fixed_costs between BRA and USA -> price sits between them at baseline. - farm_capacity_arg allows independent ARG-specific shocks - - role="usa" US soja producer. Alternative supplier to BRA. - Same price formula as BRA but not affected by BRA/SA shock. - Higher fixed_costs -> higher baseline price than BRA and ARG. - base_yield acts as a hard capacity ceiling. + role bra/arg/usa Regional soja producers. All run one region-agnostic step: + output = base_yield * effective("capacity") + price = effective fixed_costs / delivered quantity * (1 + margin) + Producers differ only in their cost/margin parameters and which PDL shocks their archetype binds + (BRA binds fertilizer. Each binds its own entity's supply/capacity shock) + No producer is hardwired shock-immune or as a fixed surplus supplier. + (supply surges come from the PDL) role="eu" European livestock farmer. End consumer of the chain. Receives feed from feed traders; produces livestock output. @@ -25,9 +20,7 @@ from .base import SupplyChainAgent -ROLE_BRA = "bra" -ROLE_ARG = "arg" -ROLE_USA = "usa" +ROLE_PRODUCER = "producer" ROLE_EU = "eu" class Farmer(SupplyChainAgent): @@ -106,88 +99,35 @@ def post_setup(self): def step(self, drought_severity: float = 0.0): if not self.active: return - if self.role == ROLE_BRA: - self._step_bra() - elif self.role == ROLE_USA: - self._step_usa() - elif self.role == ROLE_ARG: - self._step_arg() elif self.role == ROLE_EU: self._step_eu() + else: + self._step_producer() # bra / arg / usa all run one regional-agnostic # ------------------------------------------------- - # SA farmer: produce soja, price from fixed costs + # Producer: produce soja, price from fixed costs. + # Region-agnostic every producer runs this same path + # regions differ only in params and bindings # ------------------------------------------------- - - def _step_bra(self): - """ - Produce soja this step. Drought reduces output; lower output - raises per-unit cost, which raises unit_price automatically. - """ - farm_capacity = self.effective("capacity") - self.quantity_available = self.base_yield * farm_capacity - - if self.quantity_available > 0: - # fertilizer price factor raises effective fixed costs this step - fertilizer_factor = self.effective("fertilizer") - effective_costs = self.fixed_costs * fertilizer_factor - self.unit_price = (effective_costs / self.quantity_available) * (1.0 + self.margin) - else: - # total crop failure - price undefined, agent cannot trade - self.unit_price = 0.0 - - # -------------------------------------------------- - # USA farmer: produce soja, price from fixed costs - no shock - # -------------------------------------------------- - - def _step_usa(self): - """ - Produce soja this step. - - USA farmers are not affected by the BRA shock - supply is stable. - base_yield acts as a hard capacity ceiling. - There is no mechanism to scale output above it. - - Higher fixed_costs than BRA -> higher baseline unit_price. - EU wholesalers prefer BRA under normal conditions and switch to USA - only when BRA prices rise above USA prices under shock. - """ - surplus_factor = self.scenario.usa_surplus_factor - self.quantity_available = self.base_yield * surplus_factor # now offers 150t - - if self.base_yield > 0: - self.unit_price = (self.fixed_costs / self.base_yield) * (1.0 + self.margin) # still priced at 100t - else: - self.unit_price = 0.0 - - # -------------------------------------------------- - # ARG farmer: produce soja, price from fixed costs - no BRA shock - # -------------------------------------------------- - - def _step_arg(self): + def _step_producer(self): """ Produce soja this step. - Argentina farmers are not affected by the BRA shock. - - Unlike USA(surplus_factor for emergency scaling), - ARG produces at base_yield every step - a permanent baseline supplier. - - farm_capacity_arg allows ARG-specific shocks to be modelled independently. - Defaults to 1.0 = always unshocked. + Output = base_yield * effective("capacity") + effective("capacity") is this producer's own supply-shock multiplier (1.0 when unbound) + so a drought or a supply event scales output directly. Lower output raises the per unit price. + effective("fertilizer") (1.0 when unbound) raises effective fixed costs this step for producers that bind it (BRA) """ - farm_capacity = self.effective("capacity") - self.quantity_available = self.base_yield * farm_capacity + capacity = self.effective("capacity") + self.quantity_available = self.base_yield * capacity if self.quantity_available > 0: - self.unit_price = ( - self.fixed_costs / self.quantity_available - ) * (1.0 + self.margin) + effective_costs = self.fixed_costs * self.effective("fertilizer") + self.unit_price = (effective_costs / self.quantity_available) * (1.0 + self.margin) else: self.unit_price = 0.0 - # -------------------------------------------------- # EU farmer: receive feed, compute livestock output # -------------------------------------------------- diff --git a/src/provider_simenv/agents/trader.py b/src/provider_simenv/agents/trader.py index ff535a1..401cbb6 100644 --- a/src/provider_simenv/agents/trader.py +++ b/src/provider_simenv/agents/trader.py @@ -39,6 +39,9 @@ def setup(self): self.margin: float = 0.0 self.stock: float = 0.0 + # volume sourced per PDL origin this step + self.volume_by_origin: dict[str, float] = {} + # volume sourced from each origin this step self.bra_volume: float = 0.0 self.arg_volume: float = 0.0 @@ -102,6 +105,7 @@ def _step_wholesaler(self): self.stock = 0.0 self.quantity_available = 0.0 self.unit_price = 0.0 + self.volume_by_origin = {} self.bra_volume = 0.0 self.arg_volume = 0.0 self.usa_volume = 0.0 @@ -120,9 +124,7 @@ def _step_wholesaler(self): sorted_farmers = sorted(all_farmers, key=lambda f: f.unit_price) remaining = my_demand total_cost = 0.0 - bra_taken = 0.0 - arg_taken = 0.0 - usa_taken = 0.0 + volume_by_origin: dict[str, float] = {} for farmer in sorted_farmers: if remaining <= 0.0: break @@ -130,17 +132,16 @@ def _step_wholesaler(self): taken = min(farmer_alloc, remaining) total_cost += taken * farmer.unit_price remaining -= taken - if farmer.role == "bra": - bra_taken += taken - elif farmer.role == "arg": - arg_taken += taken - else: - usa_taken += taken - - actual_taken = bra_taken + arg_taken + usa_taken - self.bra_volume = bra_taken - self.arg_volume = arg_taken - self.usa_volume = usa_taken + volume_by_origin[farmer.origin] = volume_by_origin.get(farmer.origin, 0.0) + taken + + actual_taken = sum(volume_by_origin.values()) + self.volume_by_origin = volume_by_origin + + # compatibility view for the current CSV schema + transport routing + # branch 19 removes these three attrs when it refactors transport routing and the output schema off per-region volumes. + self.bra_volume = volume_by_origin.get("brazil_farms", 0.0) + self.arg_volume = volume_by_origin.get("argentina_farms", 0.0) + self.usa_volume = volume_by_origin.get("us_farms", 0.0) self.stock = actual_taken self.quantity_available = actual_taken self.storage_utilization = (actual_taken / self.storage_capacity if self.storage_capacity > 0 else 0.0) diff --git a/src/provider_simenv/model.py b/src/provider_simenv/model.py index 1a7f243..40d8997 100644 --- a/src/provider_simenv/model.py +++ b/src/provider_simenv/model.py @@ -75,11 +75,13 @@ def setup(self): agent_list.setup_agents(getattr(self.scenario, arc.count_attr)) # entity-driven capacity binding: an agent reads its own entity's supply shock. capacity_key = (entry.entity_ids[0], "supply") if len(entry.entity_ids) == 1 else None + origin = entry.entity_ids[0] if len(entry.entity_ids) == 1 else "" bindings = arc.params.get("bindings", {}) attrs = arc.params.get("attrs", {}) scenario_attrs = arc.params.get("scenario_attrs", {}) for agent in agent_list.agents: agent.role = arc.role + agent.origin = origin agent.binding = dict(bindings) # per-agent copy of the declared slots for attr, value in attrs.items(): setattr(agent, attr, value) diff --git a/src/provider_simenv/scenario.py b/src/provider_simenv/scenario.py index 0f48168..6ac8b5e 100644 --- a/src/provider_simenv/scenario.py +++ b/src/provider_simenv/scenario.py @@ -83,7 +83,6 @@ class SupplyChainScenario(Scenario): n_usa_farmers: int = 8 fixed_costs_usa_farmer: float = 48000.0 margin_usa_farmer: float = 0.10 - usa_surplus_factor: float = 1.5 # --- Argentina farmer parameters --- diff --git a/src/provider_simenv/topology.py b/src/provider_simenv/topology.py index eeae880..e90007f 100644 --- a/src/provider_simenv/topology.py +++ b/src/provider_simenv/topology.py @@ -24,7 +24,7 @@ from .agents import ( Farmer, Trader, Transport, Process, - ROLE_BRA, ROLE_ARG, ROLE_USA, ROLE_EU, + ROLE_PRODUCER, ROLE_EU, ROLE_WHOLESALER, ROLE_FEED_TRADER, ROLE_SA_SANTOS, ROLE_SA_PARANAGUA, ROLE_EU_RTM, ROLE_EU_HAM, ROLE_SEA_SANTOS, ROLE_SEA_PARANAGUA, ROLE_SEA_ARG, ROLE_SEA_USA, @@ -121,7 +121,7 @@ class Archetype: # generic kind library: (type, sector) -> default archetype KIND_ARCHETYPES: dict[tuple[str, str], Archetype] = { - ("region", "agriculture"): Archetype("arg_farmers", Farmer, ROLE_ARG, "n_arg_farmers", _FARMER_ARG), + ("region", "agriculture"): Archetype("arg_farmers", Farmer, ROLE_PRODUCER, "n_arg_farmers", _FARMER_ARG), ("infrastructure", "logistics"): Archetype("transport_sa_santos", Transport, ROLE_SA_SANTOS, "n_transport_sa_santos", _TRANSPORT_SA), ("manufacturer", "processing"): Archetype("processors", Process, ROLE_PROCESSOR, "n_processors", _PROCESSOR), ("manufacturer", "agriculture"): Archetype("eu_farmers", Farmer, ROLE_EU, "n_eu_farmers", _FARMER_EU), @@ -129,8 +129,8 @@ class Archetype: # id overrides: tuned role/count, or type+sector collisions ID_OVERRIDES: dict[str, Archetype] = { - "brazil_farms": Archetype("bra_farmers", Farmer, ROLE_BRA, "n_bra_farmers", _FARMER_BRA), - "us_farms": Archetype("usa_farmers", Farmer, ROLE_USA, "n_usa_farmers", _FARMER_USA), + "brazil_farms": Archetype("bra_farmers", Farmer, ROLE_PRODUCER, "n_bra_farmers", _FARMER_BRA), + "us_farms": Archetype("usa_farmers", Farmer, ROLE_PRODUCER, "n_usa_farmers", _FARMER_USA), # argentina_farms -> region/agriculture default (arg_farmers) "paranagua_port": Archetype("transport_sa_paranagua", Transport, ROLE_SA_PARANAGUA, "n_transport_sa_paranagua", _TRANSPORT_SA), "rotterdam_port": Archetype("transport_eu_rtm", Transport, ROLE_EU_RTM, "n_transport_eu_rtm", _TRANSPORT_EU), @@ -240,7 +240,7 @@ class RosterEntry: entity_ids: tuple[str, ...] # PDL entities represented; () for synthetic / sea edges -PRODUCER_ROLES: frozenset[str] = frozenset({ROLE_BRA, ROLE_ARG, ROLE_USA}) +PRODUCER_ROLES: frozenset[str] = frozenset({ROLE_PRODUCER}) def build_roster(pdl_path: str | Path) -> list[RosterEntry]: @@ -377,7 +377,7 @@ def build_flow_adjacency(pdl_path: str | Path) -> dict[str, tuple[str, ...]]: producers = {e.archetype.name for e in roster if e.archetype.agent_class is Farmer - and e.archetype.role in (ROLE_BRA, ROLE_ARG, ROLE_USA)} + and e.archetype.role in (ROLE_PRODUCER)} consumers = {e.archetype.name for e in roster if e.archetype.agent_class is Farmer and e.archetype.role == ROLE_EU} producer_e = {eid for eid, nm in name_of.items() if nm in producers}