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>
This commit is contained in:
@@ -106,13 +106,18 @@ 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
|
# Human/animal actuators declare mass_min=0 (there's no minimum purchase
|
||||||
# purchasable vehicle-borne mass and must not compete for the platform's
|
# quantity for a rider the way there is for an engine), but treated as a
|
||||||
# mass budget), but that same 0 breaks power = power_density * mass. Fix:
|
# literal floor that lets the optimizer size a payload down toward 0kg of
|
||||||
# a fixed physiological reference mass used only in the power formula,
|
# operator -- nonsensical, and it also breaks power = power_density * mass.
|
||||||
# added to -- never substituted into -- the vehicle's own mass budget.
|
# 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
|
||||||
@@ -859,28 +864,21 @@ 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,
|
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 for an EXPLICIT mass
|
||||||
allocation. `power_mass` is separate from `actuator_mass` for the
|
allocation. `platform_mass` defaults to the platform's
|
||||||
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
|
representative mass (ctx.p_rep) -- pass an explicit value to
|
||||||
explore a specific weight class instead (see evaluate_allocation)."""
|
explore a specific weight class instead (see evaluate_allocation)."""
|
||||||
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
|
|
||||||
|
|
||||||
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"] = (ctx.k_act * actuator_mass) / floor_total if floor_total else 0.0
|
||||||
|
|
||||||
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:
|
||||||
@@ -927,34 +925,39 @@ class Pipeline:
|
|||||||
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,
|
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
|
"""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) get
|
||||||
mass that could structurally carry it (CARGO_KG_PER_STRUCTURAL_KG
|
the same treatment with one addition: BIOLOGICAL_OPERATOR_MASS_KG
|
||||||
again, applied to the platform carrying its own actuator+storage
|
sets a floor under the floor -- at least one real operator, even if
|
||||||
instead of cargo) -- below that, no actuator/storage choice is
|
the performance-derived requirement would otherwise ask for less
|
||||||
physically possible. Above that lower bound, platform mass is a
|
-- but above that, mass is a free variable exactly like a
|
||||||
real THIRD search variable, not fixed at p_rep: a bigger platform
|
mechanical actuator's; "bigger" here means more or bigger
|
||||||
also raises the structural cap on how much actuator+storage it can
|
operators, not a fixed physiological constant. That floor also
|
||||||
carry, so growing all three together can score higher than
|
sets the smallest platform mass that could structurally carry it
|
||||||
minimizing platform down to what's merely required. Searched
|
(CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform
|
||||||
jointly (outer coarse-to-fine scan over platform mass, inner
|
carrying its own actuator+storage instead of cargo) -- below that,
|
||||||
coarse-to-fine scan over actuator/storage at each candidate) for
|
no actuator/storage choice is physically possible. Above that
|
||||||
whatever allocation maximizes this domain's own weighted composite
|
lower bound, platform mass is a real THIRD search variable, not
|
||||||
score, using the same normalize()/composite_score() the real
|
fixed at p_rep: a bigger platform also raises the structural cap
|
||||||
scoring pass uses. Not "just enough to function" and not "best
|
on how much actuator+storage it can carry, so growing all three
|
||||||
score regardless of function" -- both, floor then optimize jointly.
|
together can score higher than minimizing platform down to what's
|
||||||
Returns (actuator_mass, storage_mass, power_mass, denom_offset,
|
merely required. Searched jointly (outer coarse-to-fine scan over
|
||||||
platform_mass, feasible); see _raw_physics_from_masses for what
|
platform mass, inner coarse-to-fine scan over actuator/storage at
|
||||||
power_mass and denom_offset mean. `feasible` is False only when no
|
each candidate) for whatever allocation maximizes this domain's
|
||||||
platform mass within its own declared ceiling could structurally
|
own weighted composite score, using the same normalize()/
|
||||||
carry the required floor -- power_density/range_fuel/cost_efficiency
|
composite_score() the real scoring pass uses. Not "just enough to
|
||||||
are all per-kg ratios, so they don't naturally penalize a build
|
function" and not "best score regardless of function" -- both,
|
||||||
whose absolute mass tramples its own platform's declared ceiling;
|
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,16 +970,15 @@ 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":
|
if ctx.actuator_energy_form == "radiation_pressure":
|
||||||
# thrust scales with sail area, not carried mass -- derive an
|
# thrust scales with sail area, not carried mass -- derive an
|
||||||
# effective mass from declared footprint and a thin-film areal
|
# effective mass from declared footprint and a thin-film areal
|
||||||
# density estimate rather than the (undeclared) mass attribute.
|
# density estimate rather than the (undeclared) mass attribute.
|
||||||
|
# No meaningful "bigger sail" mass slider here (area-driven,
|
||||||
|
# not budget-driven), so this stays its own case.
|
||||||
footprint = dep_value(ctx.actuator, "footprint", "range_min") or 0.0
|
footprint = dep_value(ctx.actuator, "footprint", "range_min") or 0.0
|
||||||
actuator_mass = footprint * 0.05 # kg/m^2, thin deployable sail film
|
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
|
return actuator_mass, ctx.s_min, 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).
|
||||||
@@ -1019,10 +1021,16 @@ 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])
|
||||||
|
|
||||||
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,11 +1056,11 @@ 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, cargo_capacity_kg,
|
||||||
platform_mass=platform_mass,
|
platform_mass=platform_mass,
|
||||||
)
|
)
|
||||||
@@ -1115,7 +1123,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(
|
||||||
@@ -1224,11 +1232,11 @@ class Pipeline:
|
|||||||
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, cargo_capacity_kg
|
||||||
)
|
)
|
||||||
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, cargo_capacity_kg,
|
||||||
platform_mass=platform_mass,
|
platform_mass=platform_mass,
|
||||||
))
|
))
|
||||||
@@ -1289,23 +1297,22 @@ class Pipeline:
|
|||||||
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 for combos with no free actuator mass to explore
|
||||||
(biological actuators, radiation-pressure sails -- see
|
(radiation-pressure sails -- thrust is area-driven, not a mass
|
||||||
_stub_estimate's module note) or with no declared platform mass
|
choice, see _decide_masses) or with no declared platform mass
|
||||||
ceiling to bound a weight-class slider.
|
ceiling to bound a weight-class slider. Biological actuators DO
|
||||||
|
get sliders: rider/operator mass is a real, budget-competing
|
||||||
|
variable like any other actuator (see BIOLOGICAL_OPERATOR_MASS_KG).
|
||||||
"""
|
"""
|
||||||
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 (
|
if ctx.actuator_energy_form == "radiation_pressure":
|
||||||
ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG
|
|
||||||
or ctx.actuator_energy_form == "radiation_pressure"
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cargo_capacity_kg = (ctx.p_min + ctx.a_min + ctx.s_min) * CARGO_KG_PER_STRUCTURAL_KG
|
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(
|
default_actuator, default_storage, default_platform, _feasible = self._decide_masses(
|
||||||
ctx, bounds_by_name, units_by_name, cargo_capacity_kg
|
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
|
||||||
@@ -1317,7 +1324,7 @@ 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, cargo_capacity_kg,
|
||||||
platform_mass=p_mass,
|
platform_mass=p_mass,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% 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
|
actuator's mass isn't a design choice (a footprint-derived quantity, like a
|
||||||
quantity), or the platform has no declared mass ceiling to bound the sliders.</p>
|
radiation-pressure sail), 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">
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user