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>
This commit is contained in:
2026-08-15 19:59:45 -05:00
parent d1f14dbf14
commit 3ed3918964
2 changed files with 99 additions and 24 deletions

View File

@@ -197,6 +197,38 @@ 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)
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 # Ambient energy forms (sun, wind, gravity) aren't a depletable onboard
# store the way a fuel tank is -- "distance before running out" doesn't # 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 # apply (a sailboat doesn't run out of wind). Rather than degenerate to 0
@@ -228,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.
@@ -889,9 +935,31 @@ class Pipeline:
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
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 * actuator_mass) / floor_total if floor_total else 0.0 out["power_density"] = power_density_value
# 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:
@@ -899,7 +967,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:
@@ -920,7 +988,7 @@ 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

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"])],
) )