Files
PuGa/puga/shipping.py
dodoxandClaude Sonnet 5 9a4c7cc57c 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>
2026-10-09 13:32:58 +02:00

211 lines
13 KiB
Python

"""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}