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 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:32:58 +02:00
co-authored by Claude Sonnet 5
parent 88dae754ca
commit 9a4c7cc57c
5 changed files with 406 additions and 2 deletions
+6 -2
View File
@@ -28,15 +28,19 @@ def load_graph():
def resolve_system(query: str) -> str: 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() q = query.strip().upper()
for p in fio.planets_full(): for p in fio.planets_full():
if p.get("PlanetNaturalId", "").upper() == q or (p.get("PlanetName") or "").upper() == q: if p.get("PlanetNaturalId", "").upper() == q or (p.get("PlanetName") or "").upper() == q:
return p["SystemId"] 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(): for s in fio.systems():
if s["NaturalId"].upper() == q or s["Name"].upper() == q: if s["NaturalId"].upper() == q or s["Name"].upper() == q:
return s["SystemId"] 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): def shortest_path(src_system_id: str, dst_system_id: str, graph=None):
+210
View File
@@ -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}
+48
View File
@@ -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
+59
View File
@@ -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()
+83
View File
@@ -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()