|
|
|
|
@@ -106,13 +106,28 @@ ENERGY_FORM_RELIABILITY: dict[str, float] = {
|
|
|
|
|
# validated it against the platform's ceiling), so it's used as the point
|
|
|
|
|
# estimate rather than an invented one.
|
|
|
|
|
|
|
|
|
|
# Human/animal actuators correctly declare mass_min=0 (a rider's body isn't
|
|
|
|
|
# purchasable vehicle-borne mass and must not compete for the platform's
|
|
|
|
|
# mass budget), but that same 0 breaks power = power_density * mass. Fix:
|
|
|
|
|
# a fixed physiological reference mass used only in the power formula,
|
|
|
|
|
# added to -- never substituted into -- the vehicle's own mass budget.
|
|
|
|
|
# Radiation-pressure actuators (solar sails) don't declare a "mass" at
|
|
|
|
|
# all -- thrust scales with sail area, not carried mass -- so their
|
|
|
|
|
# effective mass is derived from declared footprint via a thin deployable
|
|
|
|
|
# sail film's areal density. Used the same way as BIOLOGICAL_OPERATOR_MASS_KG
|
|
|
|
|
# below: converts the entity's declared footprint FLOOR into a mass floor,
|
|
|
|
|
# not a fixed value -- above it, effective mass is a free, budget-competing
|
|
|
|
|
# variable like any other actuator (bigger sail = more collected power),
|
|
|
|
|
# sized by the same joint optimizer, not a one-off product spec.
|
|
|
|
|
SAIL_AREAL_DENSITY_KG_PER_M2: float = 0.05
|
|
|
|
|
|
|
|
|
|
# Human/animal actuators declare mass_min=0 (there's no minimum purchase
|
|
|
|
|
# quantity for a rider the way there is for an engine), but treated as a
|
|
|
|
|
# literal floor that lets the optimizer size a payload down toward 0kg of
|
|
|
|
|
# operator -- nonsensical, and it also breaks power = power_density * mass.
|
|
|
|
|
# Used as a FLOOR (not a fixed value) on top of the declared mass_min: at
|
|
|
|
|
# least one real operator must be present. Above that floor, actuator mass
|
|
|
|
|
# is a free, budget-competing, structurally-carried variable exactly like
|
|
|
|
|
# any mechanical actuator -- the "size" slider means more or bigger
|
|
|
|
|
# operators (a loaded cargo trike, a two-horse team), sized by the same
|
|
|
|
|
# joint optimizer everything else uses, not a fixed physiological constant.
|
|
|
|
|
BIOLOGICAL_OPERATOR_MASS_KG: dict[str, float] = {
|
|
|
|
|
"biological": 70.0, # human rider; Animal Traction shares this form too
|
|
|
|
|
"biological": 70.0, # one average human rider; Animal Traction shares this form too
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# A platform's declared mass range often spans a whole real-world class, not
|
|
|
|
|
@@ -182,13 +197,48 @@ def _solve_two_requirement_masses(
|
|
|
|
|
return a_min, s_min
|
|
|
|
|
return max(a, a_min), max(s, s_min)
|
|
|
|
|
|
|
|
|
|
# Ambient energy forms (sun, wind, gravity, food) aren't a depletable
|
|
|
|
|
# onboard store the way a fuel tank is -- "distance before running out"
|
|
|
|
|
# doesn't apply (a sailboat doesn't run out of wind). Rather than
|
|
|
|
|
# degenerate to 0 (mass_min=0, energy_density often undeclared entirely),
|
|
|
|
|
# range_fuel reports the domain's own declared ceiling for these: full
|
|
|
|
|
# marks is the physically honest answer, not an error.
|
|
|
|
|
AMBIENT_ENERGY_FORMS: set[str] = {"biological", "wind", "radiation_pressure", "gravitational"}
|
|
|
|
|
|
|
|
|
|
def _solve_achievable_speed_mps(
|
|
|
|
|
power_density: float, floor_total: float, k_med: float, drag_coeff: float,
|
|
|
|
|
) -> float:
|
|
|
|
|
"""Invert specific_power = k_med*v + (drag_coeff/floor_total)*v^3 for v
|
|
|
|
|
-- the steady-state speed at which a build's actual power output exactly
|
|
|
|
|
balances mass-proportional resistance plus mass-independent aerodynamic
|
|
|
|
|
drag. A depressed cubic (no v^2 term) with drag_coeff/floor_total > 0
|
|
|
|
|
and k_med >= 0: A*v^3 + B*v - C = 0 is strictly increasing for v >= 0
|
|
|
|
|
(derivative 3*A*v^2 + B > 0 everywhere), so it has exactly one
|
|
|
|
|
non-negative real root -- solved directly via Cardano's formula, no
|
|
|
|
|
iteration needed. Falls back to the plain linear model (v = power/k_med)
|
|
|
|
|
when there's no drag coefficient for this medium, so ungraded media
|
|
|
|
|
behave exactly as before."""
|
|
|
|
|
if power_density <= 0 or k_med is None:
|
|
|
|
|
return 0.0
|
|
|
|
|
if drag_coeff <= 0 or floor_total <= 0:
|
|
|
|
|
return power_density / k_med if k_med else 0.0
|
|
|
|
|
A = drag_coeff / floor_total
|
|
|
|
|
B = k_med
|
|
|
|
|
C = power_density
|
|
|
|
|
p, q = B / A, -C / A
|
|
|
|
|
|
|
|
|
|
def cbrt(x: float) -> float:
|
|
|
|
|
return math.copysign(abs(x) ** (1 / 3), x) if x else 0.0
|
|
|
|
|
|
|
|
|
|
discriminant = (q / 2) ** 2 + (p / 3) ** 3 # always >= 0 given p, C >= 0
|
|
|
|
|
sqrt_disc = math.sqrt(discriminant)
|
|
|
|
|
v = cbrt(-q / 2 + sqrt_disc) + cbrt(-q / 2 - sqrt_disc)
|
|
|
|
|
return max(v, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Ambient energy forms (sun, wind, gravity) aren't a depletable onboard
|
|
|
|
|
# store the way a fuel tank is -- "distance before running out" doesn't
|
|
|
|
|
# apply (a sailboat doesn't run out of wind). Rather than degenerate to 0
|
|
|
|
|
# (mass_min=0, energy_density often undeclared entirely), range_fuel
|
|
|
|
|
# reports the domain's own declared ceiling for these: full marks is the
|
|
|
|
|
# physically honest answer, not an error. Food is deliberately NOT here:
|
|
|
|
|
# stopping to eat is a resupply, the same category as refuelling a tank,
|
|
|
|
|
# not a genuinely external/inexhaustible power source -- Biological Feed
|
|
|
|
|
# uses the normal storage-mass-limited range_fuel formula.
|
|
|
|
|
AMBIENT_ENERGY_FORMS: set[str] = {"wind", "radiation_pressure", "gravitational"}
|
|
|
|
|
|
|
|
|
|
# Resistive energy cost of travel, J per kg of vehicle per meter --
|
|
|
|
|
# rolling resistance for ground vehicles, cruise-flight lift/drag for
|
|
|
|
|
@@ -210,24 +260,38 @@ SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M: dict[str, float] = {
|
|
|
|
|
# so it falls through to the old placeholder formula in the code below
|
|
|
|
|
# rather than silently claiming a resistance-based number that isn't real.
|
|
|
|
|
#
|
|
|
|
|
# KNOWN GAP: this whole table is mass-proportional resistance only (rolling
|
|
|
|
|
# resistance, effectively) -- there's no aerodynamic drag term (force ~
|
|
|
|
|
# frontal_area * velocity^2, independent of mass). That's a reasonable
|
|
|
|
|
# approximation for something car-scale, where rolling resistance genuinely
|
|
|
|
|
# dominates at typical speeds and this was validated against real car range.
|
|
|
|
|
# It badly overestimates range for light/human-scale vehicles, where drag
|
|
|
|
|
# is the dominant resistance term and doesn't scale down with mass the way
|
|
|
|
|
# this formula assumes -- confirmed on a real combo (Light Personal Vehicle +
|
|
|
|
|
# Electric Motor + Rechargeable Battery, #876): a sane 9kg battery on a
|
|
|
|
|
# realistic 31kg vehicle came out to ~1,977km, a 6-9x overestimate against
|
|
|
|
|
# real e-bikes on comparable battery energy (~50-80km on ~500Wh). The mass
|
|
|
|
|
# allocation itself was fine (correctly floor-clamped, nothing oversized) --
|
|
|
|
|
# this is a missing term in the resistance formula, not an allocation bug,
|
|
|
|
|
# so a mass-allocation optimizer wouldn't fix it either. Real fix needs a
|
|
|
|
|
# genuine drag term (frontal-area-ish figure -- `footprint` exists but is a
|
|
|
|
|
# ground-footprint number, not obviously the right proxy for cross-sectional
|
|
|
|
|
# area facing the wind -- and a drag coefficient assumption), scoped
|
|
|
|
|
# separately from the resistance-constant tuning already done here.
|
|
|
|
|
# FORMERLY A KNOWN GAP, now fixed below: the table above is mass-proportional
|
|
|
|
|
# resistance only (rolling resistance, effectively) -- no aerodynamic drag
|
|
|
|
|
# term (force ~ frontal_area * velocity^2, independent of mass). That's a
|
|
|
|
|
# reasonable approximation for something car-scale, where rolling resistance
|
|
|
|
|
# genuinely dominates at typical speeds and this was validated against real
|
|
|
|
|
# car range. It badly overestimated range for light/human-scale vehicles,
|
|
|
|
|
# where drag is the dominant resistance term and doesn't scale down with
|
|
|
|
|
# mass the way this formula assumes -- confirmed on a real combo (Light
|
|
|
|
|
# Personal Vehicle + Electric Motor + Rechargeable Battery, #876): a sane
|
|
|
|
|
# 9kg battery on a realistic 31kg vehicle came out to ~1,977km, a 6-9x
|
|
|
|
|
# overestimate against real e-bikes on comparable battery energy (~50-80km
|
|
|
|
|
# on ~500Wh). It also meant an achieved-speed metric derived from power
|
|
|
|
|
# alone (see DRAG_POWER_COEFF_BY_MEDIUM / _solve_achievable_speed_mps below)
|
|
|
|
|
# had no ceiling at all -- without a v^2-scaling force to push back, more
|
|
|
|
|
# power always bought proportionally more speed, forever.
|
|
|
|
|
#
|
|
|
|
|
# DRAG_POWER_COEFF_BY_MEDIUM below adds that missing term: a mass-INDEPENDENT
|
|
|
|
|
# drag power coefficient (0.5 * air_density * drag_coefficient * frontal_area,
|
|
|
|
|
# W per (m/s)^3) added on top of the existing mass-proportional term. Ground
|
|
|
|
|
# only for now (the diagnosed case, and where a car-like reference
|
|
|
|
|
# cross-section is a defensible categorical estimate the way the rest of
|
|
|
|
|
# this file's constants are); air's existing L/D-based model is already a
|
|
|
|
|
# reasonable velocity-roughly-linear cruise approximation and doesn't have
|
|
|
|
|
# this problem, and water hull drag would need its own (different) treatment
|
|
|
|
|
# rather than reusing a car's frontal area, so it's left as a known
|
|
|
|
|
# remaining gap rather than guessed at here.
|
|
|
|
|
DRAG_POWER_COEFF_BY_MEDIUM: dict[str, float] = {
|
|
|
|
|
# 0.5 * rho_air(1.225 kg/m^3) * Cd(~0.3) * frontal_area(~2.2 m^2, small
|
|
|
|
|
# car reference) -- sanity check: at 30 m/s (108 km/h) this alone costs
|
|
|
|
|
# ~11kW, in the right ballpark for real highway cruise power.
|
|
|
|
|
"ground": 0.5 * 1.225 * 0.3 * 2.2,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Structural manufacturing cost, $ per kg of platform mass -- certification
|
|
|
|
|
# and materials overhead scale hugely by medium (aerospace-grade vs.
|
|
|
|
|
@@ -859,28 +923,75 @@ class Pipeline:
|
|
|
|
|
ctx: "_PhysicsContext",
|
|
|
|
|
actuator_mass: float,
|
|
|
|
|
storage_mass: float,
|
|
|
|
|
power_mass: float,
|
|
|
|
|
denom_offset: float,
|
|
|
|
|
bounds_by_name: dict[str, MetricBound],
|
|
|
|
|
units_by_name: dict[str, str],
|
|
|
|
|
cargo_capacity_kg: float,
|
|
|
|
|
platform_mass: float | None = None,
|
|
|
|
|
) -> dict[str, float]:
|
|
|
|
|
"""power_density/range_fuel/cost_efficiency for an EXPLICIT mass
|
|
|
|
|
allocation. `power_mass` is separate from `actuator_mass` for the
|
|
|
|
|
biological/radiation-pressure special cases (see _stub_estimate),
|
|
|
|
|
where the numerator mass isn't the same as the build-budget mass;
|
|
|
|
|
for the normal (solved, optimized, or manually-explored) case
|
|
|
|
|
they're the same value. `platform_mass` defaults to the platform's
|
|
|
|
|
representative mass (ctx.p_rep) -- pass an explicit value to
|
|
|
|
|
explore a specific weight class instead (see evaluate_allocation)."""
|
|
|
|
|
"""power_density/range_fuel/cost_efficiency/cargo_capacity for an
|
|
|
|
|
EXPLICIT mass allocation. `platform_mass` defaults to the
|
|
|
|
|
platform's representative mass (ctx.p_rep) -- pass an explicit
|
|
|
|
|
value to explore a specific weight class instead (see
|
|
|
|
|
evaluate_allocation). Cargo capacity is derived from THIS build's
|
|
|
|
|
actual platform+actuator mass (not a separate declared-floor
|
|
|
|
|
constant), with storage_mass subtracted out of that allowance --
|
|
|
|
|
see the deadweight/lightship comment at its computation below for
|
|
|
|
|
why fuel/battery competes with cargo instead of padding it. It
|
|
|
|
|
responds to the same optimizer/explore-slider choices every other
|
|
|
|
|
metric here does."""
|
|
|
|
|
p_mass = ctx.p_rep if platform_mass is None else platform_mass
|
|
|
|
|
out: dict[str, float] = {}
|
|
|
|
|
floor_total = p_mass + actuator_mass + storage_mass
|
|
|
|
|
physics_denom = floor_total + denom_offset
|
|
|
|
|
power_density_value = (ctx.k_act * actuator_mass) / floor_total if floor_total else 0.0
|
|
|
|
|
|
|
|
|
|
if "power_density" in bounds_by_name:
|
|
|
|
|
out["power_density"] = (ctx.k_act * power_mass) / physics_denom if physics_denom else 0.0
|
|
|
|
|
out["power_density"] = power_density_value
|
|
|
|
|
|
|
|
|
|
# Deadweight/lightship cargo capacity. Real deadweight tonnage is a
|
|
|
|
|
# FIXED allowance sized off the vessel's own empty (lightship) mass
|
|
|
|
|
# -- hull + machinery, NOT fuel or cargo -- and fuel and cargo then
|
|
|
|
|
# SHARE that one allowance: a ship that bunkers more fuel has that
|
|
|
|
|
# much less room left for cargo, and vice versa. platform+actuator
|
|
|
|
|
# is the lightship analog here (the vehicle's own hardware);
|
|
|
|
|
# storage_mass is the fuel/battery competing with cargo for the
|
|
|
|
|
# same pool, not part of the base the pool is sized from -- get
|
|
|
|
|
# that backwards (basing the pool on platform+actuator+storage,
|
|
|
|
|
# as an earlier version of this did) and more battery looks like it
|
|
|
|
|
# BUYS more cargo room instead of using it up. Two ratio
|
|
|
|
|
# conventions coexist because heavy freight/maritime vehicles
|
|
|
|
|
# genuinely carry a much larger multiple of their own mass in
|
|
|
|
|
# cargo than light personal/delivery vehicles do (see
|
|
|
|
|
# CARGO_KG_PER_STRUCTURAL_KG's module comment); which one a domain
|
|
|
|
|
# scores is just which metric_name it declares. Floored at 0: a
|
|
|
|
|
# storage mass bigger than the whole allowance leaves no cargo
|
|
|
|
|
# room, not negative room.
|
|
|
|
|
lightship_mass = p_mass + actuator_mass
|
|
|
|
|
cargo_capacity_2_5x = max(0.0, lightship_mass * CARGO_KG_PER_STRUCTURAL_KG - storage_mass)
|
|
|
|
|
cargo_capacity_0_3x = max(0.0, lightship_mass * 0.3 - storage_mass)
|
|
|
|
|
if "cargo_capacity" in bounds_by_name:
|
|
|
|
|
out["cargo_capacity"] = cargo_capacity_2_5x
|
|
|
|
|
if "cargo_capacity_kg" in bounds_by_name:
|
|
|
|
|
out["cargo_capacity_kg"] = cargo_capacity_0_3x
|
|
|
|
|
|
|
|
|
|
# Achieved steady-state cruise speed, DERIVED from this specific
|
|
|
|
|
# build's actual power_density, the medium's mass-proportional
|
|
|
|
|
# resistance, and (ground only, see DRAG_POWER_COEFF_BY_MEDIUM) a
|
|
|
|
|
# mass-independent aerodynamic drag term -- not a platform-declared
|
|
|
|
|
# constant. A build with more power than the platform's bare
|
|
|
|
|
# target_velocity requires achieves a genuinely higher speed here;
|
|
|
|
|
# an underbuilt one achieves less -- speed is an output of the
|
|
|
|
|
# build, not an input to it. Computed unconditionally (not just
|
|
|
|
|
# when "speed" is a scored metric) because range_fuel/cost_efficiency
|
|
|
|
|
# below both need it too: the energy actually spent per meter
|
|
|
|
|
# depends on how fast this build is actually going, drag included.
|
|
|
|
|
drag_coeff = DRAG_POWER_COEFF_BY_MEDIUM.get(ctx.medium, 0.0)
|
|
|
|
|
achieved_speed = _solve_achievable_speed_mps(power_density_value, floor_total, ctx.k_med, drag_coeff)
|
|
|
|
|
effective_k_med = (
|
|
|
|
|
(ctx.k_med + drag_coeff * achieved_speed ** 2 / floor_total)
|
|
|
|
|
if ctx.k_med is not None and floor_total > 0 else ctx.k_med
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if "speed" in bounds_by_name:
|
|
|
|
|
out["speed"] = achieved_speed
|
|
|
|
|
|
|
|
|
|
if "range_fuel" in bounds_by_name:
|
|
|
|
|
if ctx.storage_energy_form in AMBIENT_ENERGY_FORMS or ctx.k_med is None:
|
|
|
|
|
@@ -888,7 +999,7 @@ class Pipeline:
|
|
|
|
|
out["range_fuel"] = mb.norm_max if mb else 0.0
|
|
|
|
|
elif floor_total > 0:
|
|
|
|
|
out["range_fuel"] = min(
|
|
|
|
|
(ctx.e_dens * storage_mass) / (ctx.k_med * floor_total), 1e13
|
|
|
|
|
(ctx.e_dens * storage_mass) / (effective_k_med * floor_total), 1e13
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if "cost_efficiency" in bounds_by_name:
|
|
|
|
|
@@ -909,13 +1020,19 @@ class Pipeline:
|
|
|
|
|
|
|
|
|
|
fuel_price_per_mj = FUEL_PRICE_PER_MJ.get(ctx.storage_energy_form, 0.04)
|
|
|
|
|
energy_per_m_mj = (
|
|
|
|
|
(ctx.k_med or SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M["ground"]) * floor_total
|
|
|
|
|
(effective_k_med or SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M["ground"]) * floor_total
|
|
|
|
|
) / 1e6
|
|
|
|
|
operating_per_m = energy_per_m_mj * fuel_price_per_mj
|
|
|
|
|
|
|
|
|
|
cost_per_m = amortized_per_m + operating_per_m
|
|
|
|
|
if units_by_name.get("cost_efficiency") == "$/(kg·m)":
|
|
|
|
|
out["cost_efficiency"] = cost_per_m / max(cargo_capacity_kg, 1.0)
|
|
|
|
|
# Divide by whichever cargo convention this domain actually
|
|
|
|
|
# scores, so cost-per-cargo-kg and the cargo_capacity number
|
|
|
|
|
# shown alongside it always agree; default to the heavy-
|
|
|
|
|
# vehicle ratio if a domain scores $/(kg·m) without scoring
|
|
|
|
|
# either cargo metric explicitly (matches prior behavior).
|
|
|
|
|
cargo_basis = out.get("cargo_capacity_kg", out.get("cargo_capacity", cargo_capacity_2_5x))
|
|
|
|
|
out["cost_efficiency"] = cost_per_m / max(cargo_basis, 1.0)
|
|
|
|
|
else:
|
|
|
|
|
out["cost_efficiency"] = cost_per_m
|
|
|
|
|
|
|
|
|
|
@@ -926,35 +1043,42 @@ class Pipeline:
|
|
|
|
|
ctx: "_PhysicsContext",
|
|
|
|
|
bounds_by_name: dict[str, MetricBound],
|
|
|
|
|
units_by_name: dict[str, str],
|
|
|
|
|
cargo_capacity_kg: float,
|
|
|
|
|
) -> tuple[float, float, float, float, float, bool]:
|
|
|
|
|
) -> tuple[float, float, float, bool]:
|
|
|
|
|
"""Pick the platform/actuator/storage mass for the build this domain
|
|
|
|
|
actually scores. First, the platform's declared physical
|
|
|
|
|
performance target (accel/thrust, or target_velocity/resistance)
|
|
|
|
|
sets a FLOOR -- a rotorcraft that can't produce enough thrust to
|
|
|
|
|
hover isn't a rotorcraft, regardless of how a smaller/cheaper
|
|
|
|
|
engine might score. That floor also sets the smallest platform
|
|
|
|
|
mass that could structurally carry it (CARGO_KG_PER_STRUCTURAL_KG
|
|
|
|
|
again, applied to the platform carrying its own actuator+storage
|
|
|
|
|
instead of cargo) -- below that, no actuator/storage choice is
|
|
|
|
|
physically possible. Above that lower bound, platform mass is a
|
|
|
|
|
real THIRD search variable, not fixed at p_rep: a bigger platform
|
|
|
|
|
also raises the structural cap on how much actuator+storage it can
|
|
|
|
|
carry, so growing all three together can score higher than
|
|
|
|
|
minimizing platform down to what's merely required. Searched
|
|
|
|
|
jointly (outer coarse-to-fine scan over platform mass, inner
|
|
|
|
|
coarse-to-fine scan over actuator/storage at each candidate) for
|
|
|
|
|
whatever allocation maximizes this domain's own weighted composite
|
|
|
|
|
score, using the same normalize()/composite_score() the real
|
|
|
|
|
scoring pass uses. Not "just enough to function" and not "best
|
|
|
|
|
score regardless of function" -- both, floor then optimize jointly.
|
|
|
|
|
Returns (actuator_mass, storage_mass, power_mass, denom_offset,
|
|
|
|
|
platform_mass, feasible); see _raw_physics_from_masses for what
|
|
|
|
|
power_mass and denom_offset mean. `feasible` is False only when no
|
|
|
|
|
platform mass within its own declared ceiling could structurally
|
|
|
|
|
carry the required floor -- power_density/range_fuel/cost_efficiency
|
|
|
|
|
are all per-kg ratios, so they don't naturally penalize a build
|
|
|
|
|
whose absolute mass tramples its own platform's declared ceiling;
|
|
|
|
|
engine might score. Biological actuators (a rider's own body) and
|
|
|
|
|
radiation-pressure actuators (a solar sail) get the same treatment
|
|
|
|
|
with one addition: BIOLOGICAL_OPERATOR_MASS_KG / a footprint-derived
|
|
|
|
|
floor (see SAIL_AREAL_DENSITY_KG_PER_M2) sets a floor under the
|
|
|
|
|
floor -- at least one real operator, or the sail's own declared
|
|
|
|
|
minimum footprint, even if the performance-derived requirement
|
|
|
|
|
would otherwise ask for less -- but above that, mass is a free
|
|
|
|
|
variable exactly like a mechanical actuator's; "bigger" means more
|
|
|
|
|
or bigger operators, or a bigger sail, not a fixed constant. That
|
|
|
|
|
floor also
|
|
|
|
|
sets the smallest platform mass that could structurally carry it
|
|
|
|
|
(CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform
|
|
|
|
|
carrying its own actuator+storage instead of cargo) -- below that,
|
|
|
|
|
no actuator/storage choice is physically possible. Above that
|
|
|
|
|
lower bound, platform mass is a real THIRD search variable, not
|
|
|
|
|
fixed at p_rep: a bigger platform also raises the structural cap
|
|
|
|
|
on how much actuator+storage it can carry, so growing all three
|
|
|
|
|
together can score higher than minimizing platform down to what's
|
|
|
|
|
merely required. Searched jointly (outer coarse-to-fine scan over
|
|
|
|
|
platform mass, inner coarse-to-fine scan over actuator/storage at
|
|
|
|
|
each candidate) for whatever allocation maximizes this domain's
|
|
|
|
|
own weighted composite score, using the same normalize()/
|
|
|
|
|
composite_score() the real scoring pass uses. Not "just enough to
|
|
|
|
|
function" and not "best score regardless of function" -- both,
|
|
|
|
|
floor then optimize jointly. Returns (actuator_mass, storage_mass,
|
|
|
|
|
platform_mass, feasible). `feasible` is False only when no platform
|
|
|
|
|
mass within its own declared ceiling could structurally carry the
|
|
|
|
|
required floor -- power_density/range_fuel/cost_efficiency are all
|
|
|
|
|
per-kg ratios, so they don't naturally penalize a build whose
|
|
|
|
|
absolute mass tramples its own platform's declared ceiling;
|
|
|
|
|
callers must treat an infeasible build as a hard fail rather than
|
|
|
|
|
trusting the (still-computable, still ratio-plausible) score. Also
|
|
|
|
|
used by evaluate_allocation to compute the slider's starting
|
|
|
|
|
@@ -967,17 +1091,6 @@ class Pipeline:
|
|
|
|
|
return float(dep.value)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG:
|
|
|
|
|
power_mass = BIOLOGICAL_OPERATOR_MASS_KG[ctx.actuator_energy_form]
|
|
|
|
|
return ctx.a_min, ctx.s_min, power_mass, power_mass, ctx.p_rep, True
|
|
|
|
|
if ctx.actuator_energy_form == "radiation_pressure":
|
|
|
|
|
# thrust scales with sail area, not carried mass -- derive an
|
|
|
|
|
# effective mass from declared footprint and a thin-film areal
|
|
|
|
|
# density estimate rather than the (undeclared) mass attribute.
|
|
|
|
|
footprint = dep_value(ctx.actuator, "footprint", "range_min") or 0.0
|
|
|
|
|
actuator_mass = footprint * 0.05 # kg/m^2, thin deployable sail film
|
|
|
|
|
return actuator_mass, ctx.s_min, actuator_mass, 0.0, ctx.p_rep, True
|
|
|
|
|
|
|
|
|
|
# Step 1: the required floor (same solve as before -- now a floor
|
|
|
|
|
# for the search below, not the final answer).
|
|
|
|
|
min_accel = dep_value(ctx.platform, "min_effective_accel", "range_min")
|
|
|
|
|
@@ -1019,10 +1132,23 @@ class Pipeline:
|
|
|
|
|
required_actuator = ctx.a_min if ctx.a_min > 0.0 else 10.0
|
|
|
|
|
required_storage = ctx.s_min
|
|
|
|
|
|
|
|
|
|
if ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG:
|
|
|
|
|
# At least one real operator, regardless of what the bare
|
|
|
|
|
# performance solve above would have asked for -- see the
|
|
|
|
|
# BIOLOGICAL_OPERATOR_MASS_KG module comment.
|
|
|
|
|
required_actuator = max(required_actuator, BIOLOGICAL_OPERATOR_MASS_KG[ctx.actuator_energy_form])
|
|
|
|
|
elif ctx.actuator_energy_form == "radiation_pressure":
|
|
|
|
|
# At least the entity's own declared minimum sail footprint,
|
|
|
|
|
# regardless of what the bare performance solve above would
|
|
|
|
|
# have asked for -- see the SAIL_AREAL_DENSITY_KG_PER_M2
|
|
|
|
|
# module comment.
|
|
|
|
|
footprint_floor = dep_value(ctx.actuator, "footprint", "range_min") or 0.0
|
|
|
|
|
required_actuator = max(required_actuator, footprint_floor * SAIL_AREAL_DENSITY_KG_PER_M2)
|
|
|
|
|
|
|
|
|
|
if ctx.p_max is None:
|
|
|
|
|
# No declared mass ceiling (e.g. Spaceship) -- no bounded
|
|
|
|
|
# budget to search within, use the requirement floor as-is.
|
|
|
|
|
return required_actuator, required_storage, required_actuator, 0.0, ctx.p_rep, True
|
|
|
|
|
return required_actuator, required_storage, ctx.p_rep, True
|
|
|
|
|
|
|
|
|
|
a_floor = max(ctx.a_min, required_actuator)
|
|
|
|
|
s_floor = max(ctx.s_min, required_storage)
|
|
|
|
|
@@ -1048,12 +1174,12 @@ class Pipeline:
|
|
|
|
|
# per-kg ratios, so they don't naturally penalize a build whose
|
|
|
|
|
# ABSOLUTE mass tramples its own platform's declared ceiling --
|
|
|
|
|
# something else has to catch that).
|
|
|
|
|
return a_floor, s_floor, a_floor, 0.0, p_lo, False
|
|
|
|
|
return a_floor, s_floor, p_lo, False
|
|
|
|
|
|
|
|
|
|
def objective(platform_mass: float, actuator_mass: float, storage_mass: float) -> float:
|
|
|
|
|
raw = self._raw_physics_from_masses(
|
|
|
|
|
ctx, actuator_mass, storage_mass, actuator_mass, 0.0,
|
|
|
|
|
bounds_by_name, units_by_name, cargo_capacity_kg,
|
|
|
|
|
ctx, actuator_mass, storage_mass,
|
|
|
|
|
bounds_by_name, units_by_name,
|
|
|
|
|
platform_mass=platform_mass,
|
|
|
|
|
)
|
|
|
|
|
scores, weights = [], []
|
|
|
|
|
@@ -1115,7 +1241,7 @@ class Pipeline:
|
|
|
|
|
best_score, best_p = sc, p
|
|
|
|
|
|
|
|
|
|
actuator_mass, storage_mass, _score = best_at_platform(best_p, grid=12, rounds=6)
|
|
|
|
|
return actuator_mass, storage_mass, actuator_mass, 0.0, best_p, True
|
|
|
|
|
return actuator_mass, storage_mass, best_p, True
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _search_best_allocation(
|
|
|
|
|
@@ -1196,7 +1322,6 @@ class Pipeline:
|
|
|
|
|
# drives the untouched blocks below).
|
|
|
|
|
power_density = 0.0 # W/kg
|
|
|
|
|
energy_density = 0.0 # J/kg
|
|
|
|
|
mass_total = 0.0 # kg, extensive — components share one vehicle
|
|
|
|
|
thrust_profile: str | None = None
|
|
|
|
|
energy_form: str | None = None
|
|
|
|
|
infra_matches: list[float] = []
|
|
|
|
|
@@ -1206,8 +1331,6 @@ class Pipeline:
|
|
|
|
|
power_density = max(power_density, float(dep.value))
|
|
|
|
|
if dep.key == "energy_density" and dep.constraint_type == "provides":
|
|
|
|
|
energy_density = max(energy_density, float(dep.value))
|
|
|
|
|
if dep.key == "mass" and dep.constraint_type == "range_min":
|
|
|
|
|
mass_total += float(dep.value)
|
|
|
|
|
if dep.key == "thrust_profile" and dep.constraint_type == "provides":
|
|
|
|
|
thrust_profile = dep.value
|
|
|
|
|
if dep.key == "energy_form" and dep.constraint_type == "requires":
|
|
|
|
|
@@ -1216,20 +1339,19 @@ class Pipeline:
|
|
|
|
|
match = INFRASTRUCTURE_AVAILABILITY.get((dep.key, dep.value))
|
|
|
|
|
if match is not None:
|
|
|
|
|
infra_matches.append(match)
|
|
|
|
|
mass = mass_total if mass_total > 0 else 100.0 # kg, default if undeclared
|
|
|
|
|
cargo_capacity_kg = mass * CARGO_KG_PER_STRUCTURAL_KG
|
|
|
|
|
|
|
|
|
|
# ── platform/actuator/storage-specific extraction, for
|
|
|
|
|
# power_density / range_fuel / cost_efficiency only ──────────────
|
|
|
|
|
# power_density / range_fuel / cost_efficiency / cargo_capacity
|
|
|
|
|
# only ─────────────────────────────────────────────────────────
|
|
|
|
|
ctx = self._physics_context(combo, bounds_by_name)
|
|
|
|
|
feasible = True
|
|
|
|
|
if ctx is not None:
|
|
|
|
|
actuator_mass, storage_mass, power_mass, denom_offset, platform_mass, feasible = self._decide_masses(
|
|
|
|
|
ctx, bounds_by_name, units_by_name, cargo_capacity_kg
|
|
|
|
|
actuator_mass, storage_mass, platform_mass, feasible = self._decide_masses(
|
|
|
|
|
ctx, bounds_by_name, units_by_name
|
|
|
|
|
)
|
|
|
|
|
raw.update(self._raw_physics_from_masses(
|
|
|
|
|
ctx, actuator_mass, storage_mass, power_mass, denom_offset,
|
|
|
|
|
bounds_by_name, units_by_name, cargo_capacity_kg,
|
|
|
|
|
ctx, actuator_mass, storage_mass,
|
|
|
|
|
bounds_by_name, units_by_name,
|
|
|
|
|
platform_mass=platform_mass,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
@@ -1251,11 +1373,8 @@ class Pipeline:
|
|
|
|
|
if "range_degradation" in raw:
|
|
|
|
|
raw["range_degradation"] = 365 * 86400
|
|
|
|
|
|
|
|
|
|
if "cargo_capacity" in raw:
|
|
|
|
|
raw["cargo_capacity"] = cargo_capacity_kg
|
|
|
|
|
|
|
|
|
|
if "cargo_capacity_kg" in raw:
|
|
|
|
|
raw["cargo_capacity_kg"] = mass * 0.3
|
|
|
|
|
# cargo_capacity / cargo_capacity_kg are set by _raw_physics_from_masses
|
|
|
|
|
# above, from the actual build mass -- not recomputed here.
|
|
|
|
|
|
|
|
|
|
if "environmental_impact" in raw:
|
|
|
|
|
raw["environmental_impact"] = max(0.0, power_density * 2e-7)
|
|
|
|
|
@@ -1288,25 +1407,22 @@ class Pipeline:
|
|
|
|
|
minimum (platform is also ceiling-clamped to its declared max) --
|
|
|
|
|
never silently allowed below what pass 1 would have rejected.
|
|
|
|
|
|
|
|
|
|
Returns None for combos with no free actuator mass to explore
|
|
|
|
|
(biological actuators, radiation-pressure sails -- see
|
|
|
|
|
_stub_estimate's module note) or with no declared platform mass
|
|
|
|
|
ceiling to bound a weight-class slider.
|
|
|
|
|
Returns None only for combos with no declared platform mass
|
|
|
|
|
ceiling to bound a weight-class slider (e.g. Spaceship). Every
|
|
|
|
|
actuator type gets sliders, including biological (rider/operator
|
|
|
|
|
mass, see BIOLOGICAL_OPERATOR_MASS_KG) and radiation-pressure
|
|
|
|
|
(effective sail mass derived from footprint, see
|
|
|
|
|
SAIL_AREAL_DENSITY_KG_PER_M2) -- both are real, budget-competing
|
|
|
|
|
variables like any mechanical actuator's mass.
|
|
|
|
|
"""
|
|
|
|
|
bounds_by_name = {mb.metric_name: mb for mb in domain.metric_bounds}
|
|
|
|
|
units_by_name = {mb.metric_name: mb.unit for mb in domain.metric_bounds}
|
|
|
|
|
ctx = self._physics_context(combo, bounds_by_name)
|
|
|
|
|
if ctx is None or ctx.p_max is None:
|
|
|
|
|
return None
|
|
|
|
|
if (
|
|
|
|
|
ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG
|
|
|
|
|
or ctx.actuator_energy_form == "radiation_pressure"
|
|
|
|
|
):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
cargo_capacity_kg = (ctx.p_min + ctx.a_min + ctx.s_min) * CARGO_KG_PER_STRUCTURAL_KG
|
|
|
|
|
default_actuator, default_storage, _power_mass, _denom_offset, default_platform, _feasible = self._decide_masses(
|
|
|
|
|
ctx, bounds_by_name, units_by_name, cargo_capacity_kg
|
|
|
|
|
default_actuator, default_storage, default_platform, _feasible = self._decide_masses(
|
|
|
|
|
ctx, bounds_by_name, units_by_name
|
|
|
|
|
)
|
|
|
|
|
p_mass = default_platform if platform_mass is None else platform_mass
|
|
|
|
|
a_mass = default_actuator if actuator_mass is None else actuator_mass
|
|
|
|
|
@@ -1317,8 +1433,8 @@ class Pipeline:
|
|
|
|
|
s_mass = max(ctx.s_min, s_mass)
|
|
|
|
|
|
|
|
|
|
raw = self._raw_physics_from_masses(
|
|
|
|
|
ctx, a_mass, s_mass, a_mass, 0.0,
|
|
|
|
|
bounds_by_name, units_by_name, cargo_capacity_kg,
|
|
|
|
|
ctx, a_mass, s_mass,
|
|
|
|
|
bounds_by_name, units_by_name,
|
|
|
|
|
platform_mass=p_mass,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|