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 # validated it against the platform's ceiling), so it's used as the point
# estimate rather than an invented one. # estimate rather than an invented one.
# Human/animal actuators correctly declare mass_min=0 (a rider's body isn't # Radiation-pressure actuators (solar sails) don't declare a "mass" at
# purchasable vehicle-borne mass and must not compete for the platform's # all -- thrust scales with sail area, not carried mass -- so their
# mass budget), but that same 0 breaks power = power_density * mass. Fix: # effective mass is derived from declared footprint via a thin deployable
# a fixed physiological reference mass used only in the power formula, # sail film's areal density. Used the same way as BIOLOGICAL_OPERATOR_MASS_KG
# added to -- never substituted into -- the vehicle's own mass budget. # 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_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 # 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 a_min, s_min
return max(a, a_min), max(s, 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" def _solve_achievable_speed_mps(
# doesn't apply (a sailboat doesn't run out of wind). Rather than power_density: float, floor_total: float, k_med: float, drag_coeff: float,
# degenerate to 0 (mass_min=0, energy_density often undeclared entirely), ) -> float:
# range_fuel reports the domain's own declared ceiling for these: full """Invert specific_power = k_med*v + (drag_coeff/floor_total)*v^3 for v
# marks is the physically honest answer, not an error. -- the steady-state speed at which a build's actual power output exactly
AMBIENT_ENERGY_FORMS: set[str] = {"biological", "wind", "radiation_pressure", "gravitational"} 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 -- # Resistive energy cost of travel, J per kg of vehicle per meter --
# rolling resistance for ground vehicles, cruise-flight lift/drag for # 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 # 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. # rather than silently claiming a resistance-based number that isn't real.
# #
# KNOWN GAP: this whole table is mass-proportional resistance only (rolling # FORMERLY A KNOWN GAP, now fixed below: the table above is mass-proportional
# resistance, effectively) -- there's no aerodynamic drag term (force ~ # resistance only (rolling resistance, effectively) -- no aerodynamic drag
# frontal_area * velocity^2, independent of mass). That's a reasonable # term (force ~ frontal_area * velocity^2, independent of mass). That's a
# approximation for something car-scale, where rolling resistance genuinely # reasonable approximation for something car-scale, where rolling resistance
# dominates at typical speeds and this was validated against real car range. # genuinely dominates at typical speeds and this was validated against real
# It badly overestimates range for light/human-scale vehicles, where drag # car range. It badly overestimated range for light/human-scale vehicles,
# is the dominant resistance term and doesn't scale down with mass the way # where drag is the dominant resistance term and doesn't scale down with
# this formula assumes -- confirmed on a real combo (Light Personal Vehicle + # mass the way this formula assumes -- confirmed on a real combo (Light
# Electric Motor + Rechargeable Battery, #876): a sane 9kg battery on a # Personal Vehicle + Electric Motor + Rechargeable Battery, #876): a sane
# realistic 31kg vehicle came out to ~1,977km, a 6-9x overestimate against # 9kg battery on a realistic 31kg vehicle came out to ~1,977km, a 6-9x
# real e-bikes on comparable battery energy (~50-80km on ~500Wh). The mass # overestimate against real e-bikes on comparable battery energy (~50-80km
# allocation itself was fine (correctly floor-clamped, nothing oversized) -- # on ~500Wh). It also meant an achieved-speed metric derived from power
# this is a missing term in the resistance formula, not an allocation bug, # alone (see DRAG_POWER_COEFF_BY_MEDIUM / _solve_achievable_speed_mps below)
# so a mass-allocation optimizer wouldn't fix it either. Real fix needs a # had no ceiling at all -- without a v^2-scaling force to push back, more
# genuine drag term (frontal-area-ish figure -- `footprint` exists but is a # power always bought proportionally more speed, forever.
# ground-footprint number, not obviously the right proxy for cross-sectional #
# area facing the wind -- and a drag coefficient assumption), scoped # DRAG_POWER_COEFF_BY_MEDIUM below adds that missing term: a mass-INDEPENDENT
# separately from the resistance-constant tuning already done here. # 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 # Structural manufacturing cost, $ per kg of platform mass -- certification
# and materials overhead scale hugely by medium (aerospace-grade vs. # and materials overhead scale hugely by medium (aerospace-grade vs.
@@ -859,28 +923,75 @@ class Pipeline:
ctx: "_PhysicsContext", ctx: "_PhysicsContext",
actuator_mass: float, actuator_mass: float,
storage_mass: float, storage_mass: float,
power_mass: float,
denom_offset: float,
bounds_by_name: dict[str, MetricBound], bounds_by_name: dict[str, MetricBound],
units_by_name: dict[str, str], units_by_name: dict[str, str],
cargo_capacity_kg: float,
platform_mass: float | None = None, platform_mass: float | None = None,
) -> dict[str, float]: ) -> dict[str, float]:
"""power_density/range_fuel/cost_efficiency for an EXPLICIT mass """power_density/range_fuel/cost_efficiency/cargo_capacity for an
allocation. `power_mass` is separate from `actuator_mass` for the EXPLICIT mass allocation. `platform_mass` defaults to the
biological/radiation-pressure special cases (see _stub_estimate), platform's representative mass (ctx.p_rep) -- pass an explicit
where the numerator mass isn't the same as the build-budget mass; value to explore a specific weight class instead (see
for the normal (solved, optimized, or manually-explored) case evaluate_allocation). Cargo capacity is derived from THIS build's
they're the same value. `platform_mass` defaults to the platform's actual platform+actuator mass (not a separate declared-floor
representative mass (ctx.p_rep) -- pass an explicit value to constant), with storage_mass subtracted out of that allowance --
explore a specific weight class instead (see evaluate_allocation).""" 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 p_mass = ctx.p_rep if platform_mass is None else platform_mass
out: dict[str, float] = {} out: dict[str, float] = {}
floor_total = p_mass + actuator_mass + storage_mass 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: 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 "range_fuel" in bounds_by_name:
if ctx.storage_energy_form in AMBIENT_ENERGY_FORMS or ctx.k_med is None: 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 out["range_fuel"] = mb.norm_max if mb else 0.0
elif floor_total > 0: elif floor_total > 0:
out["range_fuel"] = min( 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: 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) fuel_price_per_mj = FUEL_PRICE_PER_MJ.get(ctx.storage_energy_form, 0.04)
energy_per_m_mj = ( 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 ) / 1e6
operating_per_m = energy_per_m_mj * fuel_price_per_mj operating_per_m = energy_per_m_mj * fuel_price_per_mj
cost_per_m = amortized_per_m + operating_per_m cost_per_m = amortized_per_m + operating_per_m
if units_by_name.get("cost_efficiency") == "$/(kg·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: else:
out["cost_efficiency"] = cost_per_m out["cost_efficiency"] = cost_per_m
@@ -926,35 +1043,42 @@ class Pipeline:
ctx: "_PhysicsContext", ctx: "_PhysicsContext",
bounds_by_name: dict[str, MetricBound], bounds_by_name: dict[str, MetricBound],
units_by_name: dict[str, str], units_by_name: dict[str, str],
cargo_capacity_kg: float, ) -> tuple[float, float, float, bool]:
) -> tuple[float, float, float, float, float, bool]:
"""Pick the platform/actuator/storage mass for the build this domain """Pick the platform/actuator/storage mass for the build this domain
actually scores. First, the platform's declared physical actually scores. First, the platform's declared physical
performance target (accel/thrust, or target_velocity/resistance) performance target (accel/thrust, or target_velocity/resistance)
sets a FLOOR -- a rotorcraft that can't produce enough thrust to sets a FLOOR -- a rotorcraft that can't produce enough thrust to
hover isn't a rotorcraft, regardless of how a smaller/cheaper hover isn't a rotorcraft, regardless of how a smaller/cheaper
engine might score. That floor also sets the smallest platform engine might score. Biological actuators (a rider's own body) and
mass that could structurally carry it (CARGO_KG_PER_STRUCTURAL_KG radiation-pressure actuators (a solar sail) get the same treatment
again, applied to the platform carrying its own actuator+storage with one addition: BIOLOGICAL_OPERATOR_MASS_KG / a footprint-derived
instead of cargo) -- below that, no actuator/storage choice is floor (see SAIL_AREAL_DENSITY_KG_PER_M2) sets a floor under the
physically possible. Above that lower bound, platform mass is a floor -- at least one real operator, or the sail's own declared
real THIRD search variable, not fixed at p_rep: a bigger platform minimum footprint, even if the performance-derived requirement
also raises the structural cap on how much actuator+storage it can would otherwise ask for less -- but above that, mass is a free
carry, so growing all three together can score higher than variable exactly like a mechanical actuator's; "bigger" means more
minimizing platform down to what's merely required. Searched or bigger operators, or a bigger sail, not a fixed constant. That
jointly (outer coarse-to-fine scan over platform mass, inner floor also
coarse-to-fine scan over actuator/storage at each candidate) for sets the smallest platform mass that could structurally carry it
whatever allocation maximizes this domain's own weighted composite (CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform
score, using the same normalize()/composite_score() the real carrying its own actuator+storage instead of cargo) -- below that,
scoring pass uses. Not "just enough to function" and not "best no actuator/storage choice is physically possible. Above that
score regardless of function" -- both, floor then optimize jointly. lower bound, platform mass is a real THIRD search variable, not
Returns (actuator_mass, storage_mass, power_mass, denom_offset, fixed at p_rep: a bigger platform also raises the structural cap
platform_mass, feasible); see _raw_physics_from_masses for what on how much actuator+storage it can carry, so growing all three
power_mass and denom_offset mean. `feasible` is False only when no together can score higher than minimizing platform down to what's
platform mass within its own declared ceiling could structurally merely required. Searched jointly (outer coarse-to-fine scan over
carry the required floor -- power_density/range_fuel/cost_efficiency platform mass, inner coarse-to-fine scan over actuator/storage at
are all per-kg ratios, so they don't naturally penalize a build each candidate) for whatever allocation maximizes this domain's
whose absolute mass tramples its own platform's declared ceiling; 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 callers must treat an infeasible build as a hard fail rather than
trusting the (still-computable, still ratio-plausible) score. Also trusting the (still-computable, still ratio-plausible) score. Also
used by evaluate_allocation to compute the slider's starting used by evaluate_allocation to compute the slider's starting
@@ -967,17 +1091,6 @@ class Pipeline:
return float(dep.value) return float(dep.value)
return None 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 # Step 1: the required floor (same solve as before -- now a floor
# for the search below, not the final answer). # for the search below, not the final answer).
min_accel = dep_value(ctx.platform, "min_effective_accel", "range_min") 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_actuator = ctx.a_min if ctx.a_min > 0.0 else 10.0
required_storage = ctx.s_min 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: if ctx.p_max is None:
# No declared mass ceiling (e.g. Spaceship) -- no bounded # No declared mass ceiling (e.g. Spaceship) -- no bounded
# budget to search within, use the requirement floor as-is. # 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) a_floor = max(ctx.a_min, required_actuator)
s_floor = max(ctx.s_min, required_storage) 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 # per-kg ratios, so they don't naturally penalize a build whose
# ABSOLUTE mass tramples its own platform's declared ceiling -- # ABSOLUTE mass tramples its own platform's declared ceiling --
# something else has to catch that). # 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: def objective(platform_mass: float, actuator_mass: float, storage_mass: float) -> float:
raw = self._raw_physics_from_masses( raw = self._raw_physics_from_masses(
ctx, actuator_mass, storage_mass, actuator_mass, 0.0, ctx, actuator_mass, storage_mass,
bounds_by_name, units_by_name, cargo_capacity_kg, bounds_by_name, units_by_name,
platform_mass=platform_mass, platform_mass=platform_mass,
) )
scores, weights = [], [] scores, weights = [], []
@@ -1115,7 +1241,7 @@ class Pipeline:
best_score, best_p = sc, p best_score, best_p = sc, p
actuator_mass, storage_mass, _score = best_at_platform(best_p, grid=12, rounds=6) 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 @staticmethod
def _search_best_allocation( def _search_best_allocation(
@@ -1196,7 +1322,6 @@ class Pipeline:
# drives the untouched blocks below). # drives the untouched blocks below).
power_density = 0.0 # W/kg power_density = 0.0 # W/kg
energy_density = 0.0 # J/kg energy_density = 0.0 # J/kg
mass_total = 0.0 # kg, extensive — components share one vehicle
thrust_profile: str | None = None thrust_profile: str | None = None
energy_form: str | None = None energy_form: str | None = None
infra_matches: list[float] = [] infra_matches: list[float] = []
@@ -1206,8 +1331,6 @@ class Pipeline:
power_density = max(power_density, float(dep.value)) power_density = max(power_density, float(dep.value))
if dep.key == "energy_density" and dep.constraint_type == "provides": if dep.key == "energy_density" and dep.constraint_type == "provides":
energy_density = max(energy_density, float(dep.value)) 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": if dep.key == "thrust_profile" and dep.constraint_type == "provides":
thrust_profile = dep.value thrust_profile = dep.value
if dep.key == "energy_form" and dep.constraint_type == "requires": if dep.key == "energy_form" and dep.constraint_type == "requires":
@@ -1216,20 +1339,19 @@ class Pipeline:
match = INFRASTRUCTURE_AVAILABILITY.get((dep.key, dep.value)) match = INFRASTRUCTURE_AVAILABILITY.get((dep.key, dep.value))
if match is not None: if match is not None:
infra_matches.append(match) 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 # ── 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) ctx = self._physics_context(combo, bounds_by_name)
feasible = True feasible = True
if ctx is not None: if ctx is not None:
actuator_mass, storage_mass, power_mass, denom_offset, platform_mass, feasible = self._decide_masses( actuator_mass, storage_mass, platform_mass, feasible = self._decide_masses(
ctx, bounds_by_name, units_by_name, cargo_capacity_kg ctx, bounds_by_name, units_by_name
) )
raw.update(self._raw_physics_from_masses( raw.update(self._raw_physics_from_masses(
ctx, actuator_mass, storage_mass, power_mass, denom_offset, ctx, actuator_mass, storage_mass,
bounds_by_name, units_by_name, cargo_capacity_kg, bounds_by_name, units_by_name,
platform_mass=platform_mass, platform_mass=platform_mass,
)) ))
@@ -1251,11 +1373,8 @@ class Pipeline:
if "range_degradation" in raw: if "range_degradation" in raw:
raw["range_degradation"] = 365 * 86400 raw["range_degradation"] = 365 * 86400
if "cargo_capacity" in raw: # cargo_capacity / cargo_capacity_kg are set by _raw_physics_from_masses
raw["cargo_capacity"] = cargo_capacity_kg # above, from the actual build mass -- not recomputed here.
if "cargo_capacity_kg" in raw:
raw["cargo_capacity_kg"] = mass * 0.3
if "environmental_impact" in raw: if "environmental_impact" in raw:
raw["environmental_impact"] = max(0.0, power_density * 2e-7) 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) -- minimum (platform is also ceiling-clamped to its declared max) --
never silently allowed below what pass 1 would have rejected. never silently allowed below what pass 1 would have rejected.
Returns None for combos with no free actuator mass to explore Returns None only for combos with no declared platform mass
(biological actuators, radiation-pressure sails -- see ceiling to bound a weight-class slider (e.g. Spaceship). Every
_stub_estimate's module note) or with no declared platform mass actuator type gets sliders, including biological (rider/operator
ceiling to bound a weight-class slider. 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} 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} units_by_name = {mb.metric_name: mb.unit for mb in domain.metric_bounds}
ctx = self._physics_context(combo, bounds_by_name) ctx = self._physics_context(combo, bounds_by_name)
if ctx is None or ctx.p_max is None: if ctx is None or ctx.p_max is None:
return 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, default_platform, _feasible = self._decide_masses(
default_actuator, default_storage, _power_mass, _denom_offset, default_platform, _feasible = self._decide_masses( ctx, bounds_by_name, units_by_name
ctx, bounds_by_name, units_by_name, cargo_capacity_kg
) )
p_mass = default_platform if platform_mass is None else platform_mass p_mass = default_platform if platform_mass is None else platform_mass
a_mass = default_actuator if actuator_mass is None else actuator_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) s_mass = max(ctx.s_min, s_mass)
raw = self._raw_physics_from_masses( raw = self._raw_physics_from_masses(
ctx, a_mass, s_mass, a_mass, 0.0, ctx, a_mass, s_mass,
bounds_by_name, units_by_name, cargo_capacity_kg, bounds_by_name, units_by_name,
platform_mass=p_mass, 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 # this project hasn't done -- not scored anywhere for now rather than
# pretend a quick formula or an equally uninformed LLM guess settles it. # pretend a quick formula or an equally uninformed LLM guess settles it.
# Weights renormalized to sum to 1.0 across the remaining metrics. # 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"), # speed and cargo_capacity_kg added -- a commute's actual travel
MetricBound("cost_efficiency", weight=0.4167, norm_min=1e-5, norm_max=2e-3, unit="$/m", lower_is_better=True), # time and whether the vehicle can carry groceries/passengers/gear
MetricBound("range_fuel", weight=0.1666, norm_min=5000, norm_max=500000, unit="m"), # 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"])], constraints=[DomainConstraint("medium", ["ground", "air"])],
) )

View File

@@ -1,7 +1,6 @@
{% if explore_result is none %} {% if explore_result is none %}
<p class="empty">No free mass allocation to explore for this combination — its <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 platform has no declared mass ceiling to bound the sliders.</p>
quantity), or the platform has no declared mass ceiling to bound the sliders.</p>
{% else %} {% else %}
{% set r = explore_result %} {% set r = explore_result %}
<div class="optimize-summary"> <div class="optimize-summary">

View File

@@ -129,7 +129,7 @@
</div> </div>
{% endif %} {% endif %}
{% if explore_result is not none %} {% if scores %}
<h2>Explore: Scale the Build</h2> <h2>Explore: Scale the Build</h2>
<p class="subtitle"> <p class="subtitle">
Purely exploratory — nothing here is saved. Drag a slider to pick a Purely exploratory — nothing here is saved. Drag a slider to pick a
@@ -141,6 +141,7 @@
or merely functional one. or merely functional one.
</p> </p>
<div class="card"> <div class="card">
{% if explore_result is not none %}
{% set r = explore_result %} {% set r = explore_result %}
<form id="explore-form" <form id="explore-form"
hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}" 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> <output id="out_platform_mass">{{ "%.1f"|format(r.platform_mass) }}kg</output>
</div> </div>
<div class="weight-slider-row"> <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" <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 }}" id="actuator_mass" name="actuator_mass" value="{{ r.actuator_mass }}"
oninput="document.getElementById('out_actuator_mass').textContent = (+this.value).toFixed(1) + 'kg'"> 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> <output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output>
</div> </div>
</form> </form>
{% endif %}
<div id="explore-result"> <div id="explore-result">
{% include "results/_explore_result.html" %} {% include "results/_explore_result.html" %}
</div> </div>