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
+29 -9
View File
@@ -9,7 +9,7 @@ import argparse, sys
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import ROOT, config, market, prunplanner as pp
from puga import ROOT, config, market, prunplanner as pp, shipping
from puga.network import BaseNode, Transfer, combine
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -56,18 +56,35 @@ def main():
built["CM"] = 1
bases[key] = BaseNode(key, plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built)
transfers = [Transfer(**t) for t in net.get("transfers", [])]
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
transfers, cost_notes = [], []
for raw in net.get("transfers", []):
t = dict(raw)
if "trip_cost" not in t and t["src"] in bases and t["dst"] in bases:
real = shipping.round_trip_cost(bases[t["src"]].plan["planet_natural_id"], bases[t["dst"]].plan["planet_natural_id"],
sf_px, ff_px)
if real is not None:
t["trip_cost"] = real
cost_notes.append(f" {t['material']} {t['src']}->{t['dst']}: real round-trip cost {real:,.0f} AIC (puga.shipping)")
else:
cost_notes.append(f" {t['material']} {t['src']}->{t['dst']}: no direct-jump route (gateway?) - using placeholder {Transfer.__dataclass_fields__['trip_cost'].default:,.0f} AIC, check SFC in-game")
transfers.append(Transfer(**t))
r = combine(bases, transfers)
print(f"{net.get('name', a.spec)} ({a.basis} prices)\n")
for key, res in r["results"].items():
line = f"[{key}] area {res['area']:.0f} profit/day {res['profit']:,.0f} new capex {res['new_capex']:,.0f}"
if res["new_capex"] > 0:
line += f" ({100*res['profit']/res['new_capex']:.1f}%/day)"
line += f" (payback {res['new_capex']/res['profit']:.1f} d)"
print(line)
for b in res["buildings"]:
print(f" {b['amount']:2} x {b['building']:4} eff {b['efficiency']*100:6.1f}%")
if cost_notes:
print("\nFREIGHT COST")
print("\n".join(cost_notes))
print("\nTRANSFERS")
for t in r["ledger"]:
note = f"SHORTFALL {t['shortfall']:.1f}/d bought at market by {t['dst']}" if t["shortfall"] > 0.01 else \
@@ -77,13 +94,16 @@ def main():
total_new_capex = sum(res["new_capex"] for res in r["results"].values())
print(f"\nCOMBINED profit/day {r['total_profit']:,.0f} new capex {total_new_capex:,.0f}"
+ (f" {100*r['total_profit']/total_new_capex:.1f}%/day" if total_new_capex > 0 else ""))
+ (f" payback {total_new_capex/r['total_profit']:.1f} d" if total_new_capex > 0 else ""))
print("CAUTION: a base's 'profit/day' here is its WHOLE plan (existing buildings included), not just the new "
"addition, so its %/day overstates the marginal return where new_capex is small relative to an existing "
"base. For the true marginal ROI, also simulate the base's plan WITHOUT the new building/transfer and "
"diff the profit; see docs/library.md.")
print("NOTE: trip_cost/cargo are placeholders (default 9250 AIC, 500 t/m3, same as the AI1 route) until a real "
"inter-base fuel model exists (docs/roadmap.md tools/route.py) -- check SFC in-game for the real cost.")
"addition, so its payback here understates the marginal payback where new_capex is small relative to an "
"existing base. For the true marginal payback, also simulate the base's plan WITHOUT the new "
"building/transfer and diff the profit; see docs/library.md.")
print("NOTE: trip_cost is computed from the real fuel model (puga.shipping, max sliders) for direct-jump routes "
"between two bases in this chain (see FREIGHT COST above); a gateway route, or a transfer with an "
"explicit trip_cost in the YAML, uses that value instead (9250 AIC if neither applies). cargo defaults "
"to 500 t/m3 (the account's ship hold size, confirmed via FIO) - override per-transfer if a different "
"ship is used.")
if __name__ == "__main__":