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>
This commit is contained in:
2026-10-09 13:33:13 +02:00
co-authored by Claude Sonnet 5
parent c660559f74
commit 74d05009fb
7 changed files with 103 additions and 23 deletions
+44 -3
View File
@@ -14,7 +14,7 @@ from collections import Counter
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, fio, market, prunplanner as pp
from puga import config, contracts, fio, market, prunplanner as pp, shipping
from puga.simulate import simulate
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -88,6 +88,40 @@ def reclaim_value(ticker: str, qty: int, built_count: int, cx: str):
return {"value": value, "items": items}
def trip_tonnage(flows: dict, mats: dict):
"""Total t/m3 the base needs to haul IN (net importers) and ship OUT (net exporters) per day -
same accounting scan.py already uses for its own trip-cost estimate (max of the four, since
imports and exports share the same round trips)."""
w_in = w_out = v_in = v_out = 0.0
for t, f in flows.items():
net = f["inp"] - f["out"]
m = mats.get(t)
if not m or abs(net) < 1e-6:
continue
if net > 0:
w_in += net * m["Weight"]; v_in += net * m["Volume"]
else:
w_out += -net * m["Weight"]; v_out += -net * m["Volume"]
return w_in, w_out, v_in, v_out
def marginal_freight(r_b, r_a, planet_id, cx, sf_price, ff_price, mats):
"""AIC/day freight for the EXTRA trips this addition requires - not the base's whole freight
bill. If the addition's materials fit in slack the base's existing traffic already has (the
common case for a small addition), this comes out to ~0: real freight cost only kicks in once
net t/m3 crosses into needing another round trip. Returns (freight_aic_per_day, note)."""
origin = shipping.cx_system(cx)
if origin is None:
return 0.0, f"no station found for CX {cx!r} - freight not modelled"
rt = shipping.round_trip_cost(origin, planet_id, sf_price, ff_price)
if rt is None:
return 0.0, "no priceable route (gateway without a measured LOCK fee?) - freight not modelled"
trips_b = max(trip_tonnage(r_b["flows"], mats)) / contracts.SHIP_CARGO
trips_a = max(trip_tonnage(r_a["flows"], mats)) / contracts.SHIP_CARGO
extra_trips = trips_a - trips_b
return extra_trips * rt, f"{extra_trips:+.3f} extra round trips/day x {rt:,.0f} AIC/trip"
def run(spec, recipes, blds, price, faction, perm, built):
plan = plan_push.build_payload(spec, recipes, {b["building_ticker"] for b in blds})
planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600)
@@ -151,6 +185,12 @@ def main():
d_profit = r_a["profit"] - r_b["profit"]
d_capex = r_a["new_capex"] - r_b["new_capex"]
mats = {m["Ticker"]: m for m in fio.materials()}
sf_px = (snap.get(("SF", a.cx)) or market.Quote("SF", a.cx)).ask or 0
ff_px = (snap.get(("FF", a.cx)) or market.Quote("FF", a.cx)).ask or 0
freight, freight_note = marginal_freight(r_b, r_a, planet_id, a.cx, sf_px, ff_px, mats)
d_profit -= freight
before_counts = Counter(b["building"] for b in before_spec.get("buildings", []) for _ in range(b["amount"]))
after_counts = Counter(b["building"] for b in after_spec.get("buildings", []) for _ in range(b["amount"]))
reclaim_total = 0.0
@@ -166,8 +206,9 @@ def main():
print(f"BEFORE profit/day {r_b['profit']:,.0f} new_capex {r_b['new_capex']:,.0f}")
print(f"AFTER profit/day {r_a['profit']:,.0f} new_capex {r_a['new_capex']:,.0f}")
print(f"FREIGHT {freight:+,.0f} AIC/day marginal ({freight_note})")
if reclaim_lines:
print(f"\nRECLAIMED MATERIALS (demolishing what's removed, sell value, real FIO data):")
print("\nRECLAIMED MATERIALS (demolishing what's removed, sell value, real FIO data):")
for line in reclaim_lines:
print(line)
print(f" total: {reclaim_total:,.0f} AIC")
@@ -175,7 +216,7 @@ def main():
net_label = f" net cash GAINED {-net_capex:,.0f}" if net_capex < 0 and reclaim_total else (f" net new capex {net_capex:,.0f}" if reclaim_total else "")
print(f"\nMARGINAL profit/day {d_profit:,.0f} new_capex {d_capex:,.0f}" + net_label, end="")
if net_capex > 0 and d_profit > 0:
print(f" ROI/day {100*d_profit/net_capex:.2f}% payback {net_capex/d_profit:.1f} d")
print(f" payback {net_capex/d_profit:.1f} d (simple, non-compounding - a one-time capex paying itself off; not a reinvestable rate, see docs/decisions.md)")
elif net_capex <= 0 and d_profit > 0:
print(" (no net new capex after reclaim)" if reclaim_total else " (no new capex)")
else: