Files
PuGa/tools/network.py
T
dodoxandClaude Sonnet 5 58db08b921 Add multi-base chain modelling, fix price-band-aware demand/supply, tranche-split sell tool
- puga/network.py + tools/network.py: model a multi-base chain (e.g. mine LST at one base,
  ship it, consume it at another). Simulates each base independently, nets a transferred
  material's producer-surplus against consumer-need, charges real freight only on what's
  moved, cm_free for a founding covered by a Core Module Kit. Worked example in plans/chains/.
- puga/saturation.py: real price-band filtering. FIO's order_book NarrowPriceBandLow/High and
  WidePriceBandLow/High match APEX's own displayed Price Band exactly (verified live) - an
  order outside it is a stale artifact, not just uncompetitive. Added in_band() and
  effective_demand(); effective_supply() gained the same hard band filter alongside its
  existing soft vwap-proximity filter (renamed that param mult to free up `band`). Wired into
  tools/scan.py stage 2 in place of the raw, unfiltered demand figure. Was flagged as an
  unimplemented refinement in saturation-design.md since the original design review.
- tools/sell.py: self-contained tranche split (aggressive tranche capped at a volume quantile,
  median normally or 80th pct with --tight when a payment is imminent and stockout risk
  outweighs margin; patient tranche priced under the next competitor tier). Instant-bid
  comparison moved behind --show-bid (off by default).
- Docs and roadmap updated accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:32:09 +02:00

91 lines
4.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Model a multi-base chain: several plans, each on its own planet, with materials transferred
between bases instead of bought/sold twice at market. See puga/network.py for the method.
Spec (YAML): {name, bases: {key: plan-yaml-path}, transfers: [{material, src, dst, trip_cost, cargo}]}
puga network chains/nike_deimos_lst.yaml --basis vwap30
"""
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.network import BaseNode, Transfer, combine
sys.path.insert(0, str(Path(__file__).resolve().parent))
import plan_push
def main():
ap = argparse.ArgumentParser()
ap.add_argument("spec", help="chain spec YAML")
ap.add_argument("--cx", default=config.DEFAULT_CX)
ap.add_argument("--basis", default="uni30", choices=["real", "uni30", "vwap30", "vwap7", "ask", "bid", "mid"])
a = ap.parse_args()
net = yaml.safe_load(Path(a.spec).read_text())
recipes, blds = pp.recipes(), pp.buildings()
snap = market.snapshot()
def price(t, side="both"):
if a.basis == "real":
q = snap.get((t, a.cx))
return None if not q else (q.ask if side == "buy" else (q.vwap7 or q.vwap30 or q.bid))
if a.basis == "uni30":
return market.uni30(snap, t) or (snap.get((t, a.cx)) or market.Quote(t, a.cx)).ask
q = snap.get((t, a.cx))
if not q:
return None
return {"vwap30": q.vwap30 or q.vwap7 or q.ask, "vwap7": q.vwap7 or q.vwap30 or q.ask, "ask": q.ask,
"bid": q.bid, "mid": (q.ask + q.bid) / 2 if q.ask and q.bid else None}[a.basis]
st = yaml.safe_load(config.state_path().read_text())
faction = st.get("company", {}).get("faction")
perm = (st.get("permits", {}).get("used", 1), st.get("permits", {}).get("total", 2))
bases = {}
for key, entry in net["bases"].items():
entry = entry if isinstance(entry, dict) else {"plan": entry}
spec = yaml.safe_load((ROOT / entry["plan"]).read_text())
plan = plan_push.build_payload(spec, recipes, {b["building_ticker"] for b in blds})
if not st.get("hq"):
plan["plan_corphq"] = False
planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600)
built = next((dict(b.get("buildings", {})) for b in st.get("bases", []) if b.get("planet") == plan["planet_natural_id"]), {})
if entry.get("cm_free"): # a Core Module Kit (founding) covers the CM: don't price it
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", [])]
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)"
print(line)
for b in res["buildings"]:
print(f" {b['amount']:2} x {b['building']:4} eff {b['efficiency']*100:6.1f}%")
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 \
(f"surplus {t['surplus']:.1f}/d sold at market by {t['src']}" if t["surplus"] > 0.01 else "fully matched")
print(f" {t['material']:5} {t['src']} -> {t['dst']}: moved {t['moved']:7.1f}/d "
f"({t['weight']:.0f} t, {t['volume']:.0f} m3/d, {t['trips']:.2f} trips/d) freight {t['freight']:,.0f}/d [{note}]")
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 ""))
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.")
if __name__ == "__main__":
main()