diff --git a/src/physcom/engine/pipeline.py b/src/physcom/engine/pipeline.py index 77558be..e01b455 100644 --- a/src/physcom/engine/pipeline.py +++ b/src/physcom/engine/pipeline.py @@ -197,6 +197,38 @@ def _solve_two_requirement_masses( return a_min, 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 # 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 @@ -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 # 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. @@ -889,9 +935,31 @@ class Pipeline: 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 + 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 * 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 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 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: @@ -920,7 +988,7 @@ 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 diff --git a/src/physcom/seed/transport_example.py b/src/physcom/seed/transport_example.py index a256e80..df0f9cb 100644 --- a/src/physcom/seed/transport_example.py +++ b/src/physcom/seed/transport_example.py @@ -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"])], )