Files
dodoxandClaude Sonnet 5 74d05009fb Charge real freight in network.py/whatif.py; drop "ROI/day %" terminology
network.py: tools/network.py now computes freight cost from the real fuel
model (puga.shipping.round_trip_cost) for direct-jump transfers instead of
the flat 9250 AIC placeholder; gateway transfers or an explicit trip_cost
in the chain YAML still use that as a fallback. puga/network.py's combine()
docstring now flags that its "flows cancel, only freight matters" claim
only holds under a symmetric-price basis (uni30, the default) - under
--basis real it understates the benefit of physical transfer by the
bid-ask spread on the moved quantity.

whatif.py: now charges marginal freight too - only the EXTRA round trips
an addition needs beyond the base's existing traffic, so a small addition
riding along on already-necessary hauls costs ~0, while one that adds
meaningfully to shipped tonnage doesn't get a free pass.

Terminology: "profit/day ÷ capex" was being reported as "ROI/day X%",
which invites reading it as a compoundable rate. It's just 1/payback_days -
there's no reinvestment mechanism and every opportunity here is
capacity/market-limited, so it can't be annualized like a real yield.
Payback in days is now the primary number everywhere (scan.py, simulate.py,
network.py, whatif.py); see docs/decisions.md for the reasoning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 13:33:13 +02:00

72 lines
3.9 KiB
Python

"""Multi-base chains: combine several independently-simulated plans into one balance sheet.
Each base is simulated on its own (puga.simulate.simulate), which prices every flow at market —
that already gives each base's own correct profit whether or not a material actually leaves the
base. For a material transferred between two bases in the SAME chain (e.g. LST mined at Nike,
consumed at Deimos), the two bases' market-priced flows cancel out in the combined total to within
the transferred quantity (base A "sells" it, base B "buys" it, at the same price basis) — so the
combined total needs only ONE correction: the real freight cost of physically moving it, which
isn't in either base's isolated numbers. Leftover surplus (more produced than a partner needs) is
still validly sold at market by the producing base; leftover deficit is still validly bought.
CAVEAT: the "cancel out" claim only holds when the price basis prices buy and sell sides of a
ticker identically (true for `uni30`, the default in tools/network.py). Under `--basis real`
(or any basis with a genuine bid/ask spread), each base's own `simulate()` correctly books its
flows at the real sell (bid/vwap) or buy (ask) price - but for the `moved` quantity, that's not
what actually happens: it moves at no transaction price at all, not at both a real sale AND a
real purchase. Summing the two bases' independent numbers and subtracting only freight therefore
UNDERSTATES the true combined profit by the bid-ask spread on the moved quantity - a real gap,
not symmetric cancellation, under any basis with a spread. Not corrected here.
"""
from dataclasses import dataclass, field
from . import fio
from .simulate import simulate
@dataclass
class BaseNode:
name: str
plan: dict
recipes: list
buildings: list
resources: list
fertility: float
price: callable
faction: str | None = None
permits: tuple[float, float] = (1, 2)
built: dict = field(default_factory=dict)
@dataclass
class Transfer:
material: str
src: str # producing base's name (key in the `bases` dict)
dst: str # consuming base's name
trip_cost: float = 9250.0 # AIC per round trip; tools/network.py fills this from puga.shipping.round_trip_cost()
# when src/dst are a direct-jump route and no explicit value is given in the chain
# YAML - this default is the fallback for gateway routes/transfers without one
cargo: float = 500.0 # t or m3 per trip
def combine(bases: dict[str, BaseNode], transfers: list[Transfer]) -> dict:
mat = {m["Ticker"]: m for m in fio.materials()}
results = {name: simulate(b.plan, b.recipes, b.buildings, b.resources, b.fertility, b.price, b.faction, b.permits, built=b.built)
for name, b in bases.items()}
ledger = []
total_profit = sum(r["profit"] for r in results.values())
for t in transfers:
prod = results[t.src]["flows"].get(t.material, {"out": 0.0, "inp": 0.0})
cons = results[t.dst]["flows"].get(t.material, {"out": 0.0, "inp": 0.0})
avail = prod["out"] - prod["inp"] # net surplus at the source (0 if it's a net consumer)
need = cons["inp"] - cons["out"] # net requirement at the destination
moved = max(0.0, min(avail, need))
m = mat.get(t.material)
weight, volume = moved * (m["Weight"] if m else 0), moved * (m["Volume"] if m else 0)
trips = max(weight, volume) / t.cargo if t.cargo else 0.0
freight = trips * t.trip_cost
total_profit -= freight
ledger.append(dict(material=t.material, src=t.src, dst=t.dst, moved=moved, avail=avail, need=need,
shortfall=max(0.0, need - avail), surplus=max(0.0, avail - need),
weight=weight, volume=volume, trips=trips, freight=freight))
return dict(results=results, ledger=ledger, total_profit=total_profit)