Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---
Expand Down
6 changes: 2 additions & 4 deletions src/provider_simenv/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/provider_simenv/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 22 additions & 82 deletions src/provider_simenv/agents/farmer.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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
# --------------------------------------------------
Expand Down
29 changes: 15 additions & 14 deletions src/provider_simenv/agents/trader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -120,27 +124,24 @@ 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
farmer_alloc = farmer.quantity_available / n_wholesalers
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)
Expand Down
2 changes: 2 additions & 0 deletions src/provider_simenv/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion src/provider_simenv/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
12 changes: 6 additions & 6 deletions src/provider_simenv/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -121,16 +121,16 @@ 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),
}

# 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),
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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}
Expand Down