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
+5
View File
@@ -16,3 +16,8 @@
4. Big-money recommendations (e.g. BHP go/no-go, roughly 100k AIC): independent second opinion on the numbers. 4. Big-money recommendations (e.g. BHP go/no-go, roughly 100k AIC): independent second opinion on the numbers.
- If a bigger-model spawn is refused, tell the player and ask them to run the review. - If a bigger-model spawn is refused, tell the player and ask them to run the review.
- Routine tool building is not reviewed by a bigger model. - Routine tool building is not reviewed by a bigger model.
## 2026-10-09 Drop "ROI/day %" terminology
- `profit/day ÷ new_capex` (what tools were calling "ROI/day X%") is just 1/payback_days rewritten as a percentage. It is NOT a compoundable rate of return - there's no reinvestment mechanism, and every opportunity here is capacity/market-limited (saturation), so it can't be extrapolated or annualized like an interest rate.
- Report **payback in days** as the primary number everywhere. A "%/day" figure, if shown at all, must be labelled as simple/non-compounding and never compared to a real yield/interest rate.
- Applies to `tools/whatif.py`, `tools/simulate.py`, `tools/scan.py`, `tools/persistence.py`, `tools/history.py`, and anything built after this that reports a return on a one-time building cost.
+3 -2
View File
@@ -1,8 +1,9 @@
# Multi-base chain: Nike mines LST, ships it to Deimos, Deimos turns it (+ market AL) into BSE. # Multi-base chain: Nike mines LST, ships it to Deimos, Deimos turns it (+ market AL) into BSE.
# trip_cost/cargo are placeholders (see tools/network.py) until a real fuel model exists. # trip_cost omitted below: tools/network.py computes it from the real fuel model (puga.shipping)
# since Nike<->Deimos is a direct-jump route. cargo is still a placeholder (ship hold size).
name: "Nike LST -> Deimos PP2 (BSE)" name: "Nike LST -> Deimos PP2 (BSE)"
bases: bases:
nike: {plan: plans/examples/nike_lst_ext.yaml, cm_free: true} # founded via the faction expansion contract's kit nike: {plan: plans/examples/nike_lst_ext.yaml, cm_free: true} # founded via the faction expansion contract's kit
deimos: plans/examples/deimos_pp2_bse.yaml deimos: plans/examples/deimos_pp2_bse.yaml
transfers: transfers:
- {material: LST, src: nike, dst: deimos, trip_cost: 9250, cargo: 500} - {material: LST, src: nike, dst: deimos, cargo: 500}
+12 -1
View File
@@ -8,6 +8,15 @@ the transferred quantity (base A "sells" it, base B "buys" it, at the same price
combined total needs only ONE correction: the real freight cost of physically moving it, which 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 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. 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 dataclasses import dataclass, field
from . import fio from . import fio
@@ -33,7 +42,9 @@ class Transfer:
material: str material: str
src: str # producing base's name (key in the `bases` dict) src: str # producing base's name (key in the `bases` dict)
dst: str # consuming base's name dst: str # consuming base's name
trip_cost: float = 9250.0 # AIC per round trip; PLACEHOLDER until a real route/fuel model exists 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 cargo: float = 500.0 # t or m3 per trip
+29 -9
View File
@@ -9,7 +9,7 @@ import argparse, sys
from pathlib import Path from pathlib import Path
import yaml import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 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 from puga.network import BaseNode, Transfer, combine
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -56,18 +56,35 @@ def main():
built["CM"] = 1 built["CM"] = 1
bases[key] = BaseNode(key, plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built) 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) r = combine(bases, transfers)
print(f"{net.get('name', a.spec)} ({a.basis} prices)\n") print(f"{net.get('name', a.spec)} ({a.basis} prices)\n")
for key, res in r["results"].items(): 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}" line = f"[{key}] area {res['area']:.0f} profit/day {res['profit']:,.0f} new capex {res['new_capex']:,.0f}"
if res["new_capex"] > 0: 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) print(line)
for b in res["buildings"]: for b in res["buildings"]:
print(f" {b['amount']:2} x {b['building']:4} eff {b['efficiency']*100:6.1f}%") 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") print("\nTRANSFERS")
for t in r["ledger"]: for t in r["ledger"]:
note = f"SHORTFALL {t['shortfall']:.1f}/d bought at market by {t['dst']}" if t["shortfall"] > 0.01 else \ 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()) 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}" 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 " 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 " "addition, so its payback here understates the marginal payback where new_capex is small relative to an "
"base. For the true marginal ROI, also simulate the base's plan WITHOUT the new building/transfer and " "existing base. For the true marginal payback, also simulate the base's plan WITHOUT the new "
"diff the profit; see docs/library.md.") "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 " print("NOTE: trip_cost is computed from the real fuel model (puga.shipping, max sliders) for direct-jump routes "
"inter-base fuel model exists (docs/roadmap.md tools/route.py) -- check SFC in-game for the real cost.") "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__": if __name__ == "__main__":
+9 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Depth-aware single-step recipe scan at one exchange (saturation model v1, see docs/saturation-design.md). """Depth-aware single-step recipe scan at one exchange (saturation model v1, see docs/saturation-design.md).
For each recipe: buildings the market can absorb (N*), and ROI/day at N=1 and N* using a patient sell price For each recipe: buildings the market can absorb (N*), and payback/yield at N=1 and N* using a patient sell
(not top-of-book) and ask-walked inputs. Excludes thin markets by default. price (not top-of-book) and ask-walked inputs. Excludes thin markets by default. yld/d is profit/day over
capex as a %, NOT a compoundable rate - it's 1/payback; see docs/decisions.md's "Drop ROI/day %" entry.
tools/scan.py # AI1, all tiers, top 30 by ROI at N* tools/scan.py # AI1, all tiers, top 30 by yield at N*
tools/scan.py --cogc METALLURGY --tier S # e.g. a Metallurgy-COGC base, up to settlers tools/scan.py --cogc METALLURGY --tier S # e.g. a Metallurgy-COGC base, up to settlers
tools/scan.py --sort total --min-n 3 # rank by absorbable profit/day tools/scan.py --sort total --min-n 3 # rank by absorbable profit/day
""" """
@@ -35,7 +36,7 @@ def main():
ap.add_argument("--deprec", type=float, default=0, help="demolish-later mode: building value decays linearly to 0 over this many days (game: ~60); subtracts capex/deprec per day") ap.add_argument("--deprec", type=float, default=0, help="demolish-later mode: building value decays linearly to 0 over this many days (game: ~60); subtracts capex/deprec per day")
ap.add_argument("--input", help="only recipes consuming this ticker as an input, e.g. --input AL for recipes built on top of your own AL chain") ap.add_argument("--input", help="only recipes consuming this ticker as an input, e.g. --input AL for recipes built on top of your own AL chain")
ap.add_argument("--planet", help="planet natural id: adds extraction (EXT/COL/RIG) from its resources, uses its fertility and active COGC; new base (not in state) => no HQ, permits+1") ap.add_argument("--planet", help="planet natural id: adds extraction (EXT/COL/RIG) from its resources, uses its fertility and active COGC; new base (not in state) => no HQ, permits+1")
ap.add_argument("--own", type=int, default=1, help="how many buildings WE would run; ROI is measured at this size (default 1). --min-n is only a market-size filter") ap.add_argument("--own", type=int, default=1, help="how many buildings WE would run; yield/payback is measured at this size (default 1). --min-n is only a market-size filter")
ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)") ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)")
ap.add_argument("--experts", default="", help="expert counts, e.g. METALLURGY=2 (bonus x1.0306..1.284 for 1..5)") ap.add_argument("--experts", default="", help="expert counts, e.g. METALLURGY=2 (bonus x1.0306..1.284 for 1..5)")
ap.add_argument("--hq", action="store_true", help="corp HQ bonus x1.1") ap.add_argument("--hq", action="store_true", help="corp HQ bonus x1.1")
@@ -256,10 +257,11 @@ def main():
import json import json
Path(a.json).write_text(json.dumps(rows)) Path(a.json).write_text(json.dumps(rows))
print(f"# {a.cx} tier<={a.tier or 'any'} cogc={a.cogc or '-'} faction={faction or '-'} ({pu}/{pt} permits) min-n>={a.min_n} sorted by {a.sort}") print(f"# {a.cx} tier<={a.tier or 'any'} cogc={a.cogc or '-'} faction={faction or '-'} ({pu}/{pt} permits) min-n>={a.min_n} sorted by {a.sort}")
print("# ROI/day is for OUR size (--own buildings, default 1) incl. our own price impact; market = buildings the market could absorb (--min-n filters on it, nothing else). Net of freight. Patient asks near VWAP, inputs ask-walked; all estimates") print("# yld/d is profit/day / capex as a %, for OUR size (--own buildings, default 1) incl. our own price impact; this is NOT a compoundable rate (1/payback, see docs/decisions.md) - pback is the number to actually reason about. market = buildings the market could absorb (--min-n filters on it, nothing else). Net of freight. Patient asks near VWAP, inputs ask-walked; all estimates")
print(f"{'ROI/d':>6} {'own':>3} {'market':>6} {'lim':4} {'profit/d':>11} {'capex':>9} {'eff':>4} {'bld':4} {'staff':5} {'t/d':>5} {'frt/d':>6} {'h':>5} recipe") print(f"{'yld/d':>6} {'pback':>6} {'own':>3} {'market':>6} {'lim':4} {'profit/d':>11} {'capex':>9} {'eff':>4} {'bld':4} {'staff':5} {'t/d':>5} {'frt/d':>6} {'h':>5} recipe")
for r in rows[:a.top]: for r in rows[:a.top]:
print(f"{r['roiN']:6.1f} {r['n']:3d} {r['n_lim']:6.1f} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['staff']:5} {r['tons']:5.0f} {r['frt']:6.0f} " pback = f"{100/r['roiN']:6.1f}" if r['roiN'] > 0 else " inf"
print(f"{r['roiN']:6.1f} {pback} {r['n']:3d} {r['n_lim']:6.1f} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['staff']:5} {r['tons']:5.0f} {r['frt']:6.0f} "
f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}") f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}")
+1 -1
View File
@@ -70,7 +70,7 @@ def main():
print(f"{plan['plan_name']} {plan['planet_natural_id']} COGC {plan.get('plan_cogc')} prices: {a.cx} {a.basis}") print(f"{plan['plan_name']} {plan['planet_natural_id']} COGC {plan.get('plan_cogc')} prices: {a.cx} {a.basis}")
print(f"Area {r['area']:.0f}/500 Profit/day {r['profit']:,.0f} gross {r['gross']:,.0f} degradation {r['degradation']:,.0f} plan cost {r['plan_cost']:,.0f} ROI {r['roi_days'] and round(r['roi_days'], 2)} d") print(f"Area {r['area']:.0f}/500 Profit/day {r['profit']:,.0f} gross {r['gross']:,.0f} degradation {r['degradation']:,.0f} plan cost {r['plan_cost']:,.0f} ROI {r['roi_days'] and round(r['roi_days'], 2)} d")
nc = r["new_capex"] nc = r["new_capex"]
print(f"NEW CAPEX = planned minus already built ({built or 'nothing built'}): {nc:,.0f} -> payback {nc / r['profit']:.2f} d = {100 * r['profit'] / nc:.1f}%/day" if r["profit"] > 0 and nc > 0 else ("NEW CAPEX: nothing to build" if nc <= 0 else "NEW CAPEX: profit <= 0")) print(f"NEW CAPEX = planned minus already built ({built or 'nothing built'}): {nc:,.0f} -> payback {nc / r['profit']:.2f} d" if r["profit"] > 0 and nc > 0 else ("NEW CAPEX: nothing to build" if nc <= 0 else "NEW CAPEX: profit <= 0"))
print("\nWORKFORCE need supply open eff%") print("\nWORKFORCE need supply open eff%")
for t, w in r["workforce"].items(): for t, w in r["workforce"].items():
if w["need"] or w["supply"]: if w["need"] or w["supply"]:
+44 -3
View File
@@ -14,7 +14,7 @@ from collections import Counter
from pathlib import Path from pathlib import Path
import yaml import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 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 from puga.simulate import simulate
sys.path.insert(0, str(Path(__file__).resolve().parent)) 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} 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): def run(spec, recipes, blds, price, faction, perm, built):
plan = plan_push.build_payload(spec, recipes, {b["building_ticker"] for b in blds}) 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) 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_profit = r_a["profit"] - r_b["profit"]
d_capex = r_a["new_capex"] - r_b["new_capex"] 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"])) 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"])) after_counts = Counter(b["building"] for b in after_spec.get("buildings", []) for _ in range(b["amount"]))
reclaim_total = 0.0 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"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"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: 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: for line in reclaim_lines:
print(line) print(line)
print(f" total: {reclaim_total:,.0f} AIC") 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 "") 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="") print(f"\nMARGINAL profit/day {d_profit:,.0f} new_capex {d_capex:,.0f}" + net_label, end="")
if net_capex > 0 and d_profit > 0: 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: elif net_capex <= 0 and d_profit > 0:
print(" (no net new capex after reclaim)" if reclaim_total else " (no new capex)") print(" (no net new capex after reclaim)" if reclaim_total else " (no new capex)")
else: else: