score-optimize actuator/storage/platform allocation, enforce structural feasibility

The saved composite score previously came from a requirement-solve that
only satisfied the platform's physical performance floor, not the
domain's actual weighted score -- a smaller/cheaper build could always
score higher by hand. _decide_masses now jointly searches platform,
actuator, and storage mass (coarse-to-fine grid, no external deps) to
maximize the domain's real weighted composite score, with the
requirement floor as a lower bound rather than the final answer.

Platform mass specifically was previously fixed at a geometric-mean
representative value, which could be too little structure to carry its
own required actuator+storage (reusing CARGO_KG_PER_STRUCTURAL_KG, the
existing structure-carries-N-times-its-mass ratio, applied to a
platform carrying its own powertrain instead of cargo). Growing
platform mass also raises that structural ceiling, so it has to be
searched jointly rather than fixed or bounded independently.

Because power_density/range_fuel/cost_efficiency are all per-kg
ratios, none of them naturally penalize a build whose absolute mass
exceeds its own platform's declared ceiling -- a Piston Engine sized
for a Hyperloop could still score well on a Light Personal Vehicle.
Pass 2 now detects genuine infeasibility (no platform mass within its
own declared ceiling can structurally carry the required floor) and
saves it as a per-domain block instead of a misleadingly good score.

Also adds an explore-panel warning (not a hard block, since exploration
is intentionally loose) when a manually-dragged slider build exceeds
the platform's mass ceiling or structural carrying capacity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 18:59:20 -05:00
parent 76f460499a
commit d871635779
6 changed files with 742 additions and 139 deletions

View File

@@ -4,11 +4,25 @@ from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from physcom.engine.constraint_resolver import ConstraintResolver
from physcom.engine.pipeline import Pipeline
from physcom.engine.scorer import Scorer
from physcom_web.app import get_repo
bp = Blueprint("results", __name__, url_prefix="/results")
def _run_evaluate(repo, domain, combo, platform_mass=None, actuator_mass=None, storage_mass=None):
"""Purely exploratory -- never writes to the DB. Returns None if this
combo has no free mass allocation to explore (see
Pipeline.evaluate_allocation's docstring)."""
pipeline = Pipeline(repo, ConstraintResolver(), Scorer(domain))
return pipeline.evaluate_allocation(
combo, domain,
platform_mass=platform_mass, actuator_mass=actuator_mass, storage_mass=storage_mass,
)
@bp.route("/")
def results_index():
repo = get_repo()
@@ -62,6 +76,7 @@ def result_detail(domain_name: str, combo_id: int):
flash("No results for this combination in this domain.", "error")
return redirect(url_for("results.results_domain", domain_name=domain_name))
scores = repo.get_combination_scores(combo_id, domain.id)
explore_result = _run_evaluate(repo, domain, combo)
return render_template(
"results/detail.html",
@@ -69,6 +84,40 @@ def result_detail(domain_name: str, combo_id: int):
combo=combo,
result=result,
scores=scores,
explore_result=explore_result,
)
@bp.route("/<domain_name>/<int:combo_id>/explore", methods=["POST"])
def explore(domain_name: str, combo_id: int):
"""Live, purely exploratory re-evaluation for an explicit platform/
actuator/storage mass choice -- never touches stored data. Returns an
HTMX partial."""
repo = get_repo()
domain = repo.get_domain(domain_name)
combo = repo.get_combination(combo_id) if domain else None
if not domain or not combo:
return "", 404
def _mass(field: str) -> float | None:
raw = request.form.get(field)
if raw is None:
return None
try:
return float(raw)
except ValueError:
return None
explore_result = _run_evaluate(
repo, domain, combo,
platform_mass=_mass("platform_mass"),
actuator_mass=_mass("actuator_mass"),
storage_mass=_mass("storage_mass"),
)
return render_template(
"results/_explore_result.html",
domain=domain,
explore_result=explore_result,
)

View File

@@ -464,6 +464,52 @@ dd { font-size: 0.9rem; color: var(--text-primary); }
margin-left: 0.3rem;
}
/* ── Mass allocation bar (optimizer) ───────────────────────── */
.mass-bar-container {
display: flex;
width: 100%;
height: 18px;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-subtle);
margin-top: 0.5rem;
}
.mass-bar-seg { height: 100%; }
.mass-bar-platform { background: var(--accent-blue); }
.mass-bar-actuator { background: var(--accent-gold); }
.mass-bar-storage { background: var(--accent-teal); }
.mass-bar-legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.4rem;
align-items: center;
}
.mass-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 0.35rem;
vertical-align: middle;
}
.optimize-summary { margin-bottom: 0.25rem; }
.optimize-score { display: flex; flex-direction: column; gap: 0.1rem; }
/* ── Importance sliders (optimizer) ────────────────────────── */
.weight-slider-row {
display: grid;
grid-template-columns: 140px 1fr 48px;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.weight-slider-row label { font-size: 0.85rem; color: var(--text-muted); }
.weight-slider-row output { font-size: 0.85rem; text-align: right; font-variant-numeric: tabular-nums; }
.weight-slider-row input[type="range"] { width: 100%; }
/* ── Select dropdown dark styling ────────────────────────── */
select option {
background: var(--bg-surface);

View File

@@ -0,0 +1,55 @@
{% 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>
{% else %}
{% set r = explore_result %}
<div class="optimize-summary">
<div class="optimize-score">
<span class="score-cell" style="font-size:1.4rem">{{ "%.4f"|format(r.composite_score) }}</span>
<span class="subtitle">composite score at this build</span>
</div>
</div>
{% if r.exceeds_platform_envelope %}
<p class="badge badge-p1_fail" style="display:inline-block;margin-bottom:0.75rem">
⚠ total mass {{ "%.1f"|format(r.total_mass) }}kg exceeds this platform's declared ceiling
({{ "%.1f"|format(r.platform_max) }}kg) — not a build this platform category could carry
</p>
{% endif %}
{% if r.insufficient_structure %}
<p class="badge badge-p1_fail" style="display:inline-block;margin-bottom:0.75rem">
⚠ platform mass {{ "%.1f"|format(r.platform_mass) }}kg is too little structure to carry
{{ "%.1f"|format(r.actuator_mass + r.storage_mass) }}kg of actuator+storage
</p>
{% endif %}
<div class="mass-bar-container" title="platform {{ '%.1f'|format(r.platform_mass) }}kg / actuator {{ '%.1f'|format(r.actuator_mass) }}kg / storage {{ '%.1f'|format(r.storage_mass) }}kg">
{% set total = r.total_mass %}
<div class="mass-bar-seg mass-bar-platform" style="width: {{ (r.platform_mass / total * 100)|round(1) }}%"></div>
<div class="mass-bar-seg mass-bar-actuator" style="width: {{ (r.actuator_mass / total * 100)|round(1) }}%"></div>
<div class="mass-bar-seg mass-bar-storage" style="width: {{ (r.storage_mass / total * 100)|round(1) }}%"></div>
</div>
<div class="mass-bar-legend">
<span><span class="mass-swatch mass-bar-platform"></span>platform {{ "%.1f"|format(r.platform_mass) }}kg</span>
<span><span class="mass-swatch mass-bar-actuator"></span>actuator {{ "%.1f"|format(r.actuator_mass) }}kg</span>
<span><span class="mass-swatch mass-bar-storage"></span>storage {{ "%.1f"|format(r.storage_mass) }}kg</span>
<span class="subtitle">{{ "%.1f"|format(r.total_mass) }}kg total</span>
</div>
<table class="compact" style="margin-top:0.75rem">
<thead><tr><th>Metric</th><th>Raw Value</th><th>Normalized</th><th>Weight</th></tr></thead>
<tbody>
{% for mb in domain.metric_bounds %}
{% set val = r.raw_metrics.get(mb.metric_name) %}
{% set n = r.normalized_scores.get(mb.metric_name) %}
<tr>
<td>{{ mb.metric_name }}</td>
<td class="score-cell">{{ val|qty(mb.unit) if val is not none else '—' }}</td>
<td class="score-cell">{{ "%.4f"|format(n) if n is not none else '—' }}</td>
<td>{{ "%.0f%%"|format(mb.weight * 100) }}{{ ' ↓' if mb.lower_is_better else '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -129,6 +129,51 @@
</div>
{% endif %}
{% if explore_result is not none %}
<h2>Explore: Scale the Build</h2>
<p class="subtitle">
Purely exploratory — nothing here is saved. Drag a slider to pick a
platform weight class, motor size, or battery size directly, and see how
power density, range, and the resulting score respond. Sliders open on
the saved build above, which is already the score-optimized allocation
for this domain (subject to the platform's physical performance floor),
so the starting point is the best build already found, not an arbitrary
or merely functional one.
</p>
<div class="card">
{% set r = explore_result %}
<form id="explore-form"
hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}"
hx-trigger="input changed delay:200ms"
hx-target="#explore-result" hx-swap="innerHTML">
<div class="weight-slider-row">
<label for="platform_mass">platform (weight class)</label>
<input type="range" min="{{ r.platform_min }}" max="{{ r.platform_max }}" step="0.1"
id="platform_mass" name="platform_mass" value="{{ r.platform_mass }}"
oninput="document.getElementById('out_platform_mass').textContent = (+this.value).toFixed(1) + 'kg'">
<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>
<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'">
<output id="out_actuator_mass">{{ "%.1f"|format(r.actuator_mass) }}kg</output>
</div>
<div class="weight-slider-row">
<label for="storage_mass">storage (battery/tank size)</label>
<input type="range" min="{{ r.storage_min }}" max="{{ r.storage_slider_max }}" step="0.1"
id="storage_mass" name="storage_mass" value="{{ r.storage_mass }}"
oninput="document.getElementById('out_storage_mass').textContent = (+this.value).toFixed(1) + 'kg'">
<output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output>
</div>
</form>
<div id="explore-result">
{% include "results/_explore_result.html" %}
</div>
</div>
{% endif %}
<h2>Human Review</h2>
<div id="review-section">
{% include "results/_review_form.html" %}