Compare commits

..

5 Commits

Author SHA1 Message Date
fb38093e6c fix cargo_capacity direction: storage competes with cargo, doesn't pad it
cargo_capacity was based on floor_total (platform+actuator+storage), so
more battery/fuel made cargo capacity go UP -- backwards. Real deadweight
tonnage is a fixed allowance sized off the vessel's own empty (lightship)
mass -- hull + machinery, not fuel -- and fuel and cargo then SHARE that
one allowance: more fuel bunkered means less room left for cargo. Cargo
capacity is now (platform+actuator)*ratio - storage_mass, floored at 0,
so reducing battery/fuel now correctly frees up cargo room instead of
shrinking it.

Confirmed on combo #876: at the battery's declared 9kg floor, cargo is
6.3kg; increasing to 15kg drops it to 0.3kg, to 30kg+ drops it to 0 --
storage size and cargo capacity now trade off in the right direction.

Re-ran all five domains: urban_commuting's insufficient_structure count
went from 1 to 6 passing combos, since cargo_capacity's changed
incentives shifted where the optimizer lands relative to the structural
cap -- checked each one, all are 0.007-0.033% over the cap, the same
grid-search rounding-noise magnitude already established as acceptable,
just now triggered on a few more combos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:35:48 -05:00
f786f3da79 derive cargo_capacity from the actual optimized build, not the declared-floor sum
cargo_capacity/cargo_capacity_kg were computed from the SUM of each
entity's own declared minimum mass (the same floor pass 1 checks for
legality) -- completely disconnected from the platform/actuator/storage
masses _decide_masses actually optimizes and every other metric
(power_density, speed, range_fuel, cost_efficiency) already uses. Two
builds of the same combo with wildly different actual masses scored
identically on cargo capacity, and the explore sliders had no effect on
it at all.

Moved the cargo_capacity/cargo_capacity_kg calculation into
_raw_physics_from_masses, deriving it from floor_total (the real
assembled mass) the same deadweight/lightship-ratio way as before, just
against the right mass. Removed the cargo_capacity_kg parameter that
threaded a precomputed constant through _decide_masses and
_raw_physics_from_masses -- it's now computed fresh at each point the
optimizer/explore sliders try, exactly like power_density/speed already
are. cost_efficiency's $/(kg·m) division now uses whichever cargo
convention the domain actually scores (cargo_capacity's 2.5x ratio or
cargo_capacity_kg's 0.3x ratio) instead of always assuming the former,
so the cost-per-cargo-kg number and the cargo capacity shown alongside
it always agree.

Confirmed on combo #876: cargo_capacity_kg moved from 5.7kg (declared
floor sum: 5+5+9=19kg * 0.3) to 18kg (actual 60kg build * 0.3), and now
responds to the explore sliders (9.4kg-35.2kg across actuator/storage
choices) the way every other metric already did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:20:45 -05:00
3ed3918964 add speed as a derived metric, fix aerodynamic drag gap it exposed, expand urban_commuting
target_velocity was only ever a platform-declared input used to size the
actuator -- an achieved-speed OUTPUT never existed anywhere, even though
trip time clearly matters for a domain like urban commuting. Added
"speed" as a genuine derived metric: achieved steady-state cruise speed
computed from the build's own power_density and the medium's resistance,
the same way power_density/range_fuel/cost_efficiency are already
outputs of a build rather than inputs to it.

That immediately surfaced a known, previously-deferred gap: the
resistance model was mass-proportional only (rolling resistance), with
no velocity-squared aerodynamic drag term, so inverting power/resistance
for speed had no ceiling at all -- light vehicles were "achieving"
thousands of m/s. Added DRAG_POWER_COEFF_BY_MEDIUM (ground only, a
car-like reference cross-section) and a closed-form cubic solve
(_solve_achievable_speed_mps, via Cardano's formula, no iteration) for
the achieved speed where propulsive power balances resistance + drag.
Reused the same effective (drag-inclusive) resistance for range_fuel and
cost_efficiency's operating-cost term, since they're the same physical
quantity (energy spent per meter) evaluated at the build's actual speed.

This also closes the range-overestimation bug flagged much earlier
against combo #876 (a real e-bike): range dropped from ~1,032km to
~53km, right in the ~50-80km realistic e-bike range that was the
original target. Air and water media are unchanged (air's L/D-based
cruise model doesn't have this problem; water hull drag needs its own
treatment, not a car's frontal area -- left as a known remaining gap).

Also added cargo_capacity_kg to urban_commuting (whether a commute
vehicle can carry groceries/passengers/gear matters as much as the
metrics already scored there) and renormalized weights across the now
five metrics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 19:59:45 -05:00
d1f14dbf14 remove food from ambient energy forms, size solar sails to actual power needs
Biological Feed (food) was treated as an "ambient" energy source, so
range_fuel always reported the domain's ceiling regardless of how much
food was carried -- stopping to eat is a resupply, the same category as
refuelling a tank, not a genuinely external/inexhaustible source like
sun or wind. Removed "biological" from AMBIENT_ENERGY_FORMS; food now
uses the normal storage-mass-limited range formula like any fuel.

Solar Sail was still special-cased to a fixed footprint-derived mass
(100m^2 -> 5kg) regardless of what a domain's power/velocity target
actually needed -- the same "fixed reference instead of a requirement
floor" bug biological actuators had before last session's fix. Folded
it into the same general requirement-floor + joint-optimizer path:
declared footprint becomes a FLOOR (SAIL_AREAL_DENSITY_KG_PER_M2), not
a fixed value, so sail size scales with what's actually needed --
"enough panels to supply enough power for actuator impulse" is now
enforced the same way structural/mass-ceiling requirements already are,
instead of relying on a product-spec constant that happened to work or
not. No actuator type is special-cased for mass sizing anymore.

Confirmed the fix surfaces an honest result rather than hiding one: a
solar sail's declared 0.01 W/kg specific power can never reach
interplanetary_travel's 10 W/kg power_density floor at any sail size
(the ratio is capped by the sail's own power_density regardless of
scale), so it correctly still scores 0 there -- a real technology/domain
mismatch, not a sizing bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 19:37:38 -05:00
6cdd308583 treat biological actuators as normal budget-competing mass, fix explore-panel visibility
Biological actuators (Human Muscle, Animal Traction) were special-cased
out of the mass optimizer entirely: a fixed 70kg reference used only in
the power formula, excluded from the platform's mass budget and from
the explore-panel sliders. That made "bigger operator" or "more
operators" inexpressible, and required a power_mass/denom_offset
parameter pair throughout the physics code solely to keep this one
case's numerator mass separate from its budget mass.

Operator mass is now a normal, budget-competing, structurally-carried
variable sized by the same joint optimizer as any mechanical actuator,
with BIOLOGICAL_OPERATOR_MASS_KG reinterpreted as a floor (at least one
real operator) rather than a fixed value -- the explore slider now
reads as "how many/how large are the operators." Since every remaining
case set power_mass == actuator_mass and denom_offset == 0.0 anyway,
those parameters were entirely vestigial once biological's special
case was gone, so _raw_physics_from_masses drops them.

Also: the explore section was gated on `explore_result is not none`,
so combos with no free mass to explore (radiation-pressure sails, or
previously biological) showed nothing at all instead of the existing
explanatory message. Gated on `scores` instead, so the section always
renders and the message inside `_explore_result.html` is reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 19:17:59 -05:00
4 changed files with 247 additions and 123 deletions

View File

@@ -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,
)

View File

@@ -741,9 +741,16 @@ URBAN_COMMUTING = Domain(
# this project hasn't done -- not scored anywhere for now rather than
# pretend a quick formula or an equally uninformed LLM guess settles it.
# Weights renormalized to sum to 1.0 across the remaining metrics.
MetricBound("power_density", weight=0.4167, norm_min=1, norm_max=2000, unit="W/kg"),
MetricBound("cost_efficiency", weight=0.4167, norm_min=1e-5, norm_max=2e-3, unit="$/m", lower_is_better=True),
MetricBound("range_fuel", weight=0.1666, norm_min=5000, norm_max=500000, unit="m"),
# speed and cargo_capacity_kg added -- a commute's actual travel
# time and whether the vehicle can carry groceries/passengers/gear
# both matter as much as raw power_density did on their own; speed
# is a genuine build OUTPUT (see _raw_physics_from_masses), not a
# platform-declared constant.
MetricBound("power_density", weight=0.25, norm_min=1, norm_max=2000, unit="W/kg"),
MetricBound("cost_efficiency", weight=0.25, norm_min=1e-5, norm_max=2e-3, unit="$/m", lower_is_better=True),
MetricBound("speed", weight=0.25, norm_min=2, norm_max=30, unit="m/s"),
MetricBound("range_fuel", weight=0.10, norm_min=5000, norm_max=500000, unit="m"),
MetricBound("cargo_capacity_kg", weight=0.15, norm_min=1, norm_max=500, unit="kg"),
],
constraints=[DomainConstraint("medium", ["ground", "air"])],
)

View File

@@ -1,7 +1,6 @@
{% if explore_result is none %}
<p class="empty">No free mass allocation to explore for this combination — its
actuator's mass isn't a design choice (a physiological or footprint-derived
quantity), or the platform has no declared mass ceiling to bound the sliders.</p>
platform has no declared mass ceiling to bound the sliders.</p>
{% else %}
{% set r = explore_result %}
<div class="optimize-summary">

View File

@@ -129,7 +129,7 @@
</div>
{% endif %}
{% if explore_result is not none %}
{% if scores %}
<h2>Explore: Scale the Build</h2>
<p class="subtitle">
Purely exploratory — nothing here is saved. Drag a slider to pick a
@@ -141,6 +141,7 @@
or merely functional one.
</p>
<div class="card">
{% if explore_result is not none %}
{% set r = explore_result %}
<form id="explore-form"
hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}"
@@ -154,7 +155,7 @@
<output id="out_platform_mass">{{ "%.1f"|format(r.platform_mass) }}kg</output>
</div>
<div class="weight-slider-row">
<label for="actuator_mass">actuator (motor size)</label>
<label for="actuator_mass">actuator (motor/collector size, or operator count/size for muscle power)</label>
<input type="range" min="{{ r.actuator_min }}" max="{{ r.actuator_slider_max }}" step="0.1"
id="actuator_mass" name="actuator_mass" value="{{ r.actuator_mass }}"
oninput="document.getElementById('out_actuator_mass').textContent = (+this.value).toFixed(1) + 'kg'">
@@ -168,6 +169,7 @@
<output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output>
</div>
</form>
{% endif %}
<div id="explore-result">
{% include "results/_explore_result.html" %}
</div>