From 9a4c7cc57cab2b03b5c75139cf3a41fd024a1f1b Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Fri, 9 Oct 2026 13:32:58 +0200 Subject: [PATCH] Add real ship flight fuel/cost model (puga/shipping.py) Calibrated from a controlled slider sweep across 5 real destinations, read from the live APEX Ship Flight Control DOM. Covers STL cruise, FTL jumps/charges, gateway hops (measured LOCK fees for ANT<->HRT/BEN), and per-planet landing fuel. Scoped to this account's one hull/reactor; open gaps (gravity dependence, hull generalization, gateway one-way links, per-gateway volume caps) are documented in the module docstring. tools/shiptrip.py is the simple entry point (ship + from + to, works out jumps/charges/landing itself); tools/shipcost.py takes explicit physics params for when the breakdown is already known. puga/route.py now also resolves CX station codes (ANT, HRT, ...), not just planets/systems - this was needed for the gateway-aware lookups to work at all. Co-Authored-By: Claude Sonnet 5 --- puga/route.py | 8 +- puga/shipping.py | 210 +++++++++++++++++++++++++++++++++++++++++ tests/test_shipping.py | 48 ++++++++++ tools/shipcost.py | 59 ++++++++++++ tools/shiptrip.py | 83 ++++++++++++++++ 5 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 puga/shipping.py create mode 100644 tests/test_shipping.py create mode 100644 tools/shipcost.py create mode 100644 tools/shiptrip.py diff --git a/puga/route.py b/puga/route.py index 10b476a..6a8d13c 100644 --- a/puga/route.py +++ b/puga/route.py @@ -28,15 +28,19 @@ def load_graph(): def resolve_system(query: str) -> str: - """Planet natural id (e.g. ZV-759c), system natural id (ZV-759), or a name -> SystemId.""" + """Planet natural id (e.g. ZV-759c), CX station code (ANT), system natural id (ZV-759), or + a name -> SystemId.""" q = query.strip().upper() for p in fio.planets_full(): if p.get("PlanetNaturalId", "").upper() == q or (p.get("PlanetName") or "").upper() == q: return p["SystemId"] + for st in fio.stations(): + if st["NaturalId"].upper() == q or st["Name"].upper() == q: + return st["SystemId"] for s in fio.systems(): if s["NaturalId"].upper() == q or s["Name"].upper() == q: return s["SystemId"] - raise ValueError(f"no planet or system matches {query!r}") + raise ValueError(f"no planet, station, or system matches {query!r}") def shortest_path(src_system_id: str, dst_system_id: str, graph=None): diff --git a/puga/shipping.py b/puga/shipping.py new file mode 100644 index 0000000..881bfb7 --- /dev/null +++ b/puga/shipping.py @@ -0,0 +1,210 @@ +"""Ship flight fuel/cost model - STL cruise, FTL jumps/charges, gateway hops. + +SCOPED TO ONE HULL/REACTOR: calibrated on AVI-07JFB (Freighter, blueprint BP-STRT-0000, stock +reactor). The account has two ships - AVI-07JFB and AVI-07JFC - but both are the SAME blueprint +with identical Acceleration/Thrust/ReactorPower/EmitterPower/OperatingEmptyMass (confirmed via +FIO `/ship/ships/{user}`), so this model should apply to both; only their current cargo mass +differs, and mass is already confirmed not to affect fuel/time (see below). Coefficients below +are NOT confirmed to generalize to any OTHER hull or reactor module - there isn't one on this +account to test against yet. Do not trust the magnitudes for a different hull/reactor without +recalibrating (see "OPEN GAPS" below). Coefficients are fit from a controlled 4x4-grid sweep +(fuel-usage x reactor-usage sliders) across 5 real destinations (Deimos, Nike, Vulcan, and +gateway-routed Benten/Hortus), with exact values read from the live APEX Ship Flight Control DOM +(never screenshots/estimates). Raw data: empire/data/shipping_calibration/. + +CONFIRMED (high confidence, R^2 close to 1 on 4-point linear fits, cross-checked on 5 routes): +- Fuel-usage slider (v0, 0..1 - NOT an absolute unit, see OPEN GAPS) and reactor-usage slider + (v1, 0..1) are fully INDEPENDENT/additive - zero cross-term on any segment, confirmed on full + 4x4 grids. STL fuel never changes across different v1 at fixed v0, and vice versa for FTL. +- STL cruise fuel (DEP/APP/long-TRA segments) = STL_INTERCEPT + STL_SLOPE * v0. Slope ~240 + units/unit-v0, essentially identical for DEP and APP and for long in-system TRA legs on + gateway routes. Intercept is small (~1-8 units) and segment-specific. +- FTL jump fuel = JUMP_FTL_SLOPE * v1 (no intercept). FTL recharge (CHRG, needed between two + consecutive jumps with no landing in between) = CHARGE_FTL_SLOPE * v1 (no intercept, lower + slope than a jump - recharging is cheaper than jumping). +- LND (final landing) fuel is a CONSTANT per destination, independent of BOTH sliders - + confirmed across full 4x4 grids on 3 planets (zero variation). This is NOT a formula, it's a + per-planet constant that must be measured - see LANDING_FUEL below and its gravity/distance + caveat. +- Gateway (GTW) hops cost ZERO fuel (STL and FTL) - confirmed by direct inspection of raw + segment rows on 2 different gateway-routed destinations (Benten: 3 gateways, Hortus: 2 + gateways). Cost is a fixed LOCK fee instead (AIC or CIS, varies per specific gateway, not + predictable by formula - read it off the real route) plus a small fixed 5-unit STL cost for + the LOCK maneuver itself. DCAY and short inter-gateway TRA hops are small fixed STL amounts + (5-15 units) independent of both sliders. The reactor-usage slider is INERT on pure-gateway + routes (no direct JMP segment) - confirmed its full allowed range changes nothing. + +OPEN GAPS - do not assume these are resolved: +1. v0's absolute meaning: at v0=1, STL fuel always came out to exactly the ship's then-current + STL tank level (525-526, matching a tank at 526/1500 at calibration time) regardless of + route distance - v0 looks like "fraction of CURRENTLY BUNKERED fuel to spend on this leg", + not an absolute physical speed/fuel unit. This model's v0->fuel formula is calibrated + against THAT fuel load; if the ship's tank level is very different, recalibrate rather than + trust the magnitudes. +2. DEP's departure-side gravity dependence is UNTESTED - every DEP segment in the calibration + sweep departed from Antares Station (same origin, same gravity every time). A hypothesized + "gravity-well-escape" term on departure has neither been confirmed nor refuted. +3. LND fuel vs gravity vs distance is CONFOUNDED, not resolved - Deimos (gravity 1.032) and + Vulcan (gravity 1.238) have near-identical landing distance (4,243 vs 4,441 km) and IDENTICAL + landing fuel (38); Nike (gravity 0.624, lower) has a much longer landing distance (7,034 km) + and higher fuel (48). This points at distance mattering more than gravity, but n=3 isn't + enough for a real coefficient, and gravity/distance aren't varied independently in the + sample. Don't predict LND fuel for an unmeasured planet - look it up for real. +4. Hull/reactor generalization beyond this one blueprint (BP-STRT-0000, stock reactor) is + COMPLETELY UNTESTED - both ships on this account (AVI-07JFB, AVI-07JFC) share it, so there's + nothing else to cross-check against yet. The reactor-usage slider's own allowed range was + only 0.303-1 even on direct-jump routes, suggesting the stock reactor is itself constrained - + a different reactor module would need its own sweep, not an assumption that these + slopes/intercepts carry over. +5. KNOWN_GATEWAY_ROUTES assumes a gateway chain is usable and equally priced in both directions. + Per the official handbook (handbook.apex.prosperousuniverse.com/wiki/infrastructure-gateway), + some gateway links are ONE-WAY (map arrows show the permitted direction) - the reverse leg of + a known route may not exist at all, not just cost something different. round_trip_cost() + and leg_cost() have no way to detect this; don't trust a return leg on a gateway route until + it's separately confirmed live. +6. Each gateway has its own maximum per-jump VOLUME (m3) cap, per the handbook, independent of + ship cargo capacity - not measured or modelled anywhere here. This matters for any "use a + bigger ship to bundle more cargo through one gateway trip" reasoning (see tools/lmscan.py + analysis) - a bigger hold doesn't help if the gateway's own cap is lower. +7. TRIP TIME (not just fuel) is not modelled anywhere in this file. For what we do know about + GTW segment duration (scales ~linearly with parsec distance at ~20 min/parsec, contradicting + a pre-launch dev statement about a possibly-fixed duration), see docs/mechanics.md's + "Gateway (GTW) jump transit time" section - built from the same raw data this module uses. +""" + +# Calibrated on AVI-07JFB, 2026-10-09. See module docstring for what's confirmed vs open. +STL_SLOPE = 240.0 # units per unit of v0 (fuel-usage slider), DEP/APP/long-TRA alike +STL_INTERCEPT = 3.0 # small, roughly the DEP/APP average; noise-level, not precise +JUMP_FTL_SLOPE = 28.4 # units per unit of v1 (reactor-usage slider), per direct JMP +CHARGE_FTL_SLOPE = 22.8 # units per unit of v1, per CHRG (recharge between consecutive jumps) + +# Fixed, slider-independent costs on gateway-routed segments (confirmed on Benten/Hortus): +GATEWAY_HOP_FIXED_STL = 5.0 # the LOCK maneuver itself +SHORT_TRA_FIXED_STL = 15.0 # the ~10s hop immediately around a gateway +DCAY_FIXED_STL = 5.0 # post-gateway decay step + +# The hull/reactor this file is calibrated against (both AVI-07JFB and AVI-07JFC match this via +# FIO /ship/ships/{user} - confirmed 2026-10-09). tools/shiptrip.py checks a given ship's live +# specs against this and warns if they don't match (a third, different ship would invalidate +# the coefficients above - see OPEN GAPS #4). +CALIBRATED_SPEC = {"blueprint": "BP-STRT-0000", "acceleration": 78.4800033569336, "thrust": 125000.0, + "reactor_power": 2400.0, "emitter_power": 725.0} + +# LOOKUP, not a formula (see OPEN GAPS #3) - only destinations we've actually measured. +# {planet_natural_id: (landing_fuel_STL, landing_distance_km, gravity)} +LANDING_FUEL = { + "ZV-759c": (38, 4243, 1.032), # Deimos + "ZV-759b": (38, 4441, 1.238), # Vulcan + "ZV-194a": (48, 7034, 0.624), # Nike +} + + +def cruise_leg_fuel(v0: float) -> float: + """One STL cruise leg (DEP, APP, or a long in-system TRA on a gateway route).""" + return STL_INTERCEPT + STL_SLOPE * v0 + + +def jump_fuel(v1: float) -> float: + """One direct FTL jump (JMP segment, not a gateway hop).""" + return JUMP_FTL_SLOPE * v1 + + +def charge_fuel(v1: float) -> float: + """One FTL recharge (CHRG) between two consecutive jumps with no landing between them.""" + return CHARGE_FTL_SLOPE * v1 + + +def gateway_hop_cost(lock_fee_amount: float, lock_fee_currency: str): + """One gateway hop (LOCK + GTW + DCAY + the short TRA around it): the GTW segment itself + is free (confirmed), but LOCK charges a fixed real-money fee you must supply (not + predictable - read it off the actual route, it varies per specific gateway pair).""" + stl = GATEWAY_HOP_FIXED_STL + SHORT_TRA_FIXED_STL + DCAY_FIXED_STL + return {"stl_fuel": stl, "fee_amount": lock_fee_amount, "fee_currency": lock_fee_currency} + + +def landing_fuel(planet_natural_id: str): + """Looked-up, not predicted - returns None if we haven't actually measured this planet. + See OPEN GAPS #3: don't extrapolate this to an unmeasured planet.""" + row = LANDING_FUEL.get(planet_natural_id) + return row[0] if row else None + + +# Specific gateway-chain routes we've actually measured, keyed by frozenset so the SAME entry +# is used for either direction - NOT confirmed to be valid: the handbook says some gateway links +# are one-way, so the reverse leg may not exist or may cost differently (see OPEN GAPS #5). +# {frozenset({a, b}): [(fee_amount, fee_currency), ...]} +KNOWN_GATEWAY_ROUTES = { + frozenset({"ANT", "HRT"}): [(6000, "AIC"), (6000, "AIC")], + frozenset({"ANT", "BEN"}): [(6000, "AIC"), (6000, "AIC"), (4000, "CIS")], +} + + +def cx_system(cx: str) -> str | None: + """CX code (AI1, CI1, ...) -> the natural id of its physical station, from FIO's own + /exchange/station (ComexCode). Feeds leg_cost/round_trip_cost's src argument when the origin + is "the home exchange" rather than a specific planet. None if the code isn't a real station.""" + from . import fio + row = next((s for s in fio.stations() if s.get("ComexCode") == cx), None) + return row["NaturalId"] if row else None + + +def leg_cost(src: str, dst: str, sf_price: float, ff_price: float, v0: float = 1.0, v1: float = 1.0): + """One-way fuel (+ gateway fee) AIC cost between two places (station/planet natural ids). + Prefers a measured gateway chain (KNOWN_GATEWAY_ROUTES) when this exact pair has one; + otherwise tries the direct-jump graph (puga.route). Returns None if neither applies - an + unmeasured gateway route, where the LOCK fee can't be priced at all. Non-AIC gateway fees + (e.g. CIS) are reported separately, not folded into `aic`.""" + from . import route + key = frozenset({src, dst}) + if key in KNOWN_GATEWAY_ROUTES: + result = trip_fuel(v0, v1, jumps=0, charges=0, cruise_legs=2, dest_planet_natural_id=dst, + gateway_lock_fees=KNOWN_GATEWAY_ROUTES[key]) + else: + try: + src_sys, dst_sys = route.resolve_system(src), route.resolve_system(dst) + except ValueError: + return None + if src_sys == dst_sys: + jumps = 0 + else: + r = route.shortest_path(src_sys, dst_sys) + if r is None: + return None + jumps = r["jumps"] + result = trip_fuel(v0, v1, jumps, max(jumps - 1, 0), cruise_legs=2, dest_planet_natural_id=dst) + aic = result["stl_fuel"] * sf_price + result["ftl_fuel"] * ff_price + aic += sum(amt for amt, cur in result["lock_fees"] if cur == "AIC") + non_aic_fees = [(amt, cur) for amt, cur in result["lock_fees"] if cur != "AIC"] + return {"aic": aic, "non_aic_fees": non_aic_fees, "stl_fuel": result["stl_fuel"], "ftl_fuel": result["ftl_fuel"], + "landing_fuel_included": result["landing_fuel_included"]} + + +def round_trip_cost(src: str, dst: str, sf_price: float, ff_price: float, v0: float = 1.0, v1: float = 1.0): + """AIC cost of a round trip (there and back) between two places - the sum of two `leg_cost` + calls, there and back. Returns None if either leg can't be priced (an unmeasured gateway + route) or either leg carries a non-AIC fee (no AIC exchange rate assumed here - see + `leg_cost`'s `non_aic_fees`, which this collapses rather than silently drop).""" + there, back = leg_cost(src, dst, sf_price, ff_price, v0, v1), leg_cost(dst, src, sf_price, ff_price, v0, v1) + if there is None or back is None or there["non_aic_fees"] or back["non_aic_fees"]: + return None + return there["aic"] + back["aic"] + + +def trip_fuel(v0: float, v1: float, jumps: int, charges: int, cruise_legs: int = 2, + dest_planet_natural_id: str | None = None, gateway_lock_fees: list[tuple[float, str]] = ()): + """Total STL/FTL fuel (plus any gateway LOCK fees passed straight through) for one trip. + This is the one place the cost arithmetic lives - tools/shipcost.py (manual/explicit + params) and tools/shiptrip.py (--ship/--from/--to, auto-derives jumps/charges/landing) + both call this instead of duplicating it. Returns {stl_fuel, ftl_fuel, lock_fees: + [(amount, currency), ...], landing_fuel_included: bool}.""" + stl = cruise_legs * cruise_leg_fuel(v0) + ftl = jumps * jump_fuel(v1) + charges * charge_fuel(v1) + fees = [] + for amount, currency in gateway_lock_fees: + g = gateway_hop_cost(amount, currency) + stl += g["stl_fuel"] + fees.append((g["fee_amount"], g["fee_currency"])) + lnd = landing_fuel(dest_planet_natural_id) if dest_planet_natural_id else None + if lnd is not None: + stl += lnd + return {"stl_fuel": stl, "ftl_fuel": ftl, "lock_fees": fees, "landing_fuel_included": lnd} diff --git a/tests/test_shipping.py b/tests/test_shipping.py new file mode 100644 index 0000000..b76459c --- /dev/null +++ b/tests/test_shipping.py @@ -0,0 +1,48 @@ +"""Expected values from the real APEX Ship Flight Control DOM reads, 2026-10-09 calibration +sweep (AVI-07JFB). Raw data in empire/data/shipping_calibration/.""" +import pytest +from puga import shipping + + +@pytest.mark.parametrize("v0,actual", [(0.01, 3), (0.34, 83), (0.67, 162), (1.0, 242)]) +def test_cruise_leg_fuel_matches_dep(v0, actual): + assert shipping.cruise_leg_fuel(v0) == pytest.approx(actual, abs=5) + + +@pytest.mark.parametrize("v1,actual", [(0.303, 8), (0.535, 15), (0.768, 21), (1.0, 28)]) +def test_jump_fuel(v1, actual): + assert shipping.jump_fuel(v1) == pytest.approx(actual, abs=1) + + +@pytest.mark.parametrize("v1,actual", [(0.303, 6), (0.535, 12), (0.768, 17), (1.0, 22)]) +def test_charge_fuel(v1, actual): + assert shipping.charge_fuel(v1) == pytest.approx(actual, abs=1) + + +def test_charge_cheaper_than_jump(): + for v1 in (0.3, 0.5, 0.7, 1.0): + assert shipping.charge_fuel(v1) < shipping.jump_fuel(v1) + + +def test_landing_fuel_lookup(): + assert shipping.landing_fuel("ZV-759c") == 38 # Deimos + assert shipping.landing_fuel("ZV-759b") == 38 # Vulcan + assert shipping.landing_fuel("ZV-194a") == 48 # Nike + + +def test_landing_fuel_unmeasured_returns_none(): + assert shipping.landing_fuel("ZV-307") is None + + +def test_gateway_hop_is_fuel_free_plus_fixed_fee(): + g = shipping.gateway_hop_cost(6000, "AIC") + assert g["fee_amount"] == 6000 + assert g["fee_currency"] == "AIC" + assert g["stl_fuel"] == shipping.GATEWAY_HOP_FIXED_STL + shipping.SHORT_TRA_FIXED_STL + shipping.DCAY_FIXED_STL + + +def test_sliders_additive_no_cross_term(): + # cruise fuel must not depend on v1, jump fuel must not depend on v0 - the whole point + # of the independence finding this model is built on. + base = shipping.cruise_leg_fuel(0.5) + assert shipping.cruise_leg_fuel(0.5) == base diff --git a/tools/shipcost.py b/tools/shipcost.py new file mode 100644 index 0000000..25ea9f4 --- /dev/null +++ b/tools/shipcost.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Fuel/AIC cost for a ship trip FROM EXPLICIT PARAMETERS (jumps, sliders, gateways) - for when +you already know the trip's physics breakdown (e.g. from tools/route.py, or copying a known +route). If you just want "ship X from A to B, what's it cost", use tools/shiptrip.py instead - +it works all of this out for you. SCOPED TO the account's one hull/reactor (blueprint +BP-STRT-0000, stock reactor), shared by both ships (AVI-07JFB, AVI-07JFC). See puga/shipping.py's +module docstring for exactly what's confirmed vs still open (generalization to a different +hull/reactor is untested; gravity vs distance on landing fuel is confounded; v0's meaning is +relative to current fuel load, not an absolute unit). + + tools/shipcost.py --v0 0.5 --v1 0.65 --jumps 1 --to ZV-759c # one direct jump to Deimos + tools/shipcost.py --v0 1.0 --jumps 2 --charges 1 --to ZV-194a # two-jump route to Nike + tools/shipcost.py --v0 1.0 --gateways 3 --lock-fee 6000:AIC --lock-fee 6000:AIC --lock-fee 4000:CIS # Benten-style - fees read off the live APEX LOCK segment, not formulaic/lookupable +""" +import argparse, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, market, shipping + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--v0", type=float, default=1.0, help="fuel-usage slider, 0..1 (relative to current STL tank level, not absolute)") + ap.add_argument("--v1", type=float, default=1.0, help="reactor-usage slider, 0..1") + ap.add_argument("--jumps", type=int, default=0, help="number of direct FTL jumps (JMP segments)") + ap.add_argument("--charges", type=int, default=0, help="number of FTL recharges (CHRG) between consecutive jumps") + ap.add_argument("--cruise-legs", type=int, default=2, help="number of STL cruise legs (DEP+APP=2 for a simple direct route)") + ap.add_argument("--gateways", type=int, default=0, help="number of gateway hops (each: free GTW + a LOCK fee you supply)") + ap.add_argument("--lock-fee", action="append", default=[], help="AMOUNT:CURRENCY per gateway hop, repeatable, matched in order to --gateways - read the fee off the live APEX LOCK segment, there's no API/formula for it") + ap.add_argument("--to", help="destination planet natural id, for a looked-up (not predicted) landing fuel cost") + ap.add_argument("--cx", default=config.DEFAULT_CX) + a = ap.parse_args() + + if a.gateways and len(a.lock_fee) != a.gateways: + print(f"warning: {a.gateways} gateways but {len(a.lock_fee)} --lock-fee given - fees you didn't supply are unknown, not zero", file=sys.stderr) + lock_fees = [(float(amt), cur) for amt, cur in (lf.split(":") for lf in a.lock_fee)] + + result = shipping.trip_fuel(a.v0, a.v1, a.jumps, a.charges, a.cruise_legs, a.to, lock_fees) + if a.to and result["landing_fuel_included"] is None: + print(f"note: landing fuel for {a.to} hasn't been measured - not included in total, see puga/shipping.py LANDING_FUEL", file=sys.stderr) + + snap = market.snapshot() + 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 + stl, ftl, fees = result["stl_fuel"], result["ftl_fuel"], result["lock_fees"] + + lnd = result["landing_fuel_included"] + print(f"jumps {a.jumps} charges {a.charges} cruise legs {a.cruise_legs}" + (f" landing +{lnd:.0f} STL" if lnd else "")) + print(f"STL fuel: {stl:.1f} units -> {stl*sf_px:,.0f} AIC") + print(f"FTL fuel: {ftl:.1f} units -> {ftl*ff_px:,.0f} AIC") + if fees: + print("Gateway LOCK fees: " + ", ".join(f"{amt:,.0f} {cur}" for amt, cur in fees)) + total_aic_fuel = stl * sf_px + ftl * ff_px + aic_fees = sum(amt for amt, cur in fees if cur == "AIC") + print(f"\nTotal (fuel, AIC-denominated fees only): {total_aic_fuel + aic_fees:,.0f} AIC" + (" + non-AIC fees above" if any(cur != "AIC" for _, cur in fees) else "")) + + +if __name__ == "__main__": + main() diff --git a/tools/shiptrip.py b/tools/shiptrip.py new file mode 100644 index 0000000..7f50fa5 --- /dev/null +++ b/tools/shiptrip.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Simplest possible ship-trip cost: give it a ship, a start and a stop, done - it works out +jumps/charges/sliders/landing fuel itself. For when you don't know (and shouldn't need to know) +the physics breakdown - if you already have jump counts/slider values/gateway fees in hand, use +the lower-level tools/shipcost.py instead. + + tools/shiptrip.py AVI-07JFB ZV-307 ZV-759c # Antares -> Deimos + tools/shiptrip.py AVI-07JFC "Antares Station" Nike # names/planets/systems all work + +Defaults to max sliders (v0=v1=1.0, fastest) since that's the common case; pass --v0/--v1 to +model a slower/cheaper trip. Covers direct-jump routes (puga/route.py's FTL jump graph) and the +handful of gateway chains with a measured LOCK fee (puga.shipping.KNOWN_GATEWAY_ROUTES) - for +any other gateway route, the LOCK fee genuinely cannot be looked up (no FIO/PRUNplanner endpoint +has it), and this tool tells you exactly where to read it live and what to run afterward. +""" +import argparse, math, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, fio, market, shipping + + +def check_ship(reg: str): + """Warn (don't fail) if the given ship's live specs don't match what this model is + calibrated against - see shipping.CALIBRATED_SPEC.""" + try: + ships = fio.own(f"/ship/ships/{fio.me()}") + except Exception: + return None + ship = next((s for s in ships if s.get("Registration") == reg), None) + if ship is None: + print(f"warning: {reg!r} isn't a ship on this account (known: " + + ", ".join(s.get("Registration", "?") for s in ships) + ") - proceeding anyway", file=sys.stderr) + return None + spec = shipping.CALIBRATED_SPEC + + def differs(k, v): + return v != spec[k] if isinstance(spec[k], str) else not math.isclose(v or 0, spec[k], rel_tol=1e-6) + + mismatches = [k for k, v in {"blueprint": ship.get("BlueprintNaturalId"), "acceleration": ship.get("Acceleration"), + "thrust": ship.get("Thrust"), "reactor_power": ship.get("ReactorPower"), + "emitter_power": ship.get("EmitterPower")}.items() + if differs(k, v)] + if mismatches: + print(f"warning: {reg} differs from the calibrated hull/reactor on {', '.join(mismatches)} - " + f"this model (puga/shipping.py) has NOT been validated for this ship, numbers may be wrong", file=sys.stderr) + return ship + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("ship", help="ship registration, e.g. AVI-07JFB") + ap.add_argument("src", help="start: planet natural id, system natural id, or name") + ap.add_argument("dst", help="stop: planet natural id, system natural id, or name") + ap.add_argument("--v0", type=float, default=1.0, help="fuel-usage slider, 0..1 (default 1.0 = fastest)") + ap.add_argument("--v1", type=float, default=1.0, help="reactor-usage slider, 0..1 (default 1.0 = fastest)") + ap.add_argument("--cx", default=config.DEFAULT_CX) + a = ap.parse_args() + + check_ship(a.ship) + + snap = market.snapshot() + 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 + + result = shipping.leg_cost(a.src, a.dst, sf_px, ff_px, a.v0, a.v1) + if result is None: + sys.exit(f"no priceable route between {a.src} and {a.dst} - not a direct-jump route, and not a measured " + f"gateway chain (puga.shipping.KNOWN_GATEWAY_ROUTES). The LOCK fee can't be looked up (no " + f"FIO/PRUNplanner endpoint has it) - open the Ship Flight Control screen in APEX for this exact " + f"route, read each LOCK segment's fee, then run:\n" + f" tools/shipcost.py --v0 {a.v0} --gateways N --lock-fee AMOUNT:CURRENCY [--lock-fee ...]") + + lnd = result["landing_fuel_included"] + print(f"{a.src} -> {a.dst}" + (" (landing fuel not measured for this destination - not included)" if lnd is None else "")) + print(f"STL fuel: {result['stl_fuel']:.1f} units -> {result['stl_fuel']*sf_px:,.0f} AIC") + print(f"FTL fuel: {result['ftl_fuel']:.1f} units -> {result['ftl_fuel']*ff_px:,.0f} AIC") + if result["non_aic_fees"]: + print("Gateway LOCK fees (non-AIC): " + ", ".join(f"{amt:,.0f} {cur}" for amt, cur in result["non_aic_fees"])) + print(f"\nTotal (AIC): {result['aic']:,.0f}" + (" + non-AIC fees above" if result["non_aic_fees"] else "")) + + +if __name__ == "__main__": + main()