ThrustAllocator: zero intent returns exact zeros before the solve; the 1e-9 output flush is deleted (zero-intent-warm-nonzero, zero-intent-at-the-cap)
Nothing asked, nothing fired is a fact about the intent: when all three clamped intents are exactly zero Allocate returns the zeroed buffer before the solve, so a warm solve that stops at the iteration cap can no longer fire thrusters. The output flush is gone. The warm start is left at the last solved point. Tests: the two 40-thruster hulls Soul found, and a small intent still answered at any column scale. Builds untested locally; verified on Yggdrasil. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8fab3052e1
commit
8bfce1899e
2 changed files with 60 additions and 5 deletions
|
|
@ -23,9 +23,6 @@ public sealed class ThrustAllocator
|
|||
const float ErrorCostWeight = .3f;
|
||||
const float ErrorCostOffset = 10f;
|
||||
const float Ridge = .1f;
|
||||
// The solver stops within its tolerance of the optimum, so a throttle that should be zero can come back as 1e-34 after
|
||||
// a warm start. Below this a throttle is that residue, not an ask: nothing asked is nothing fired.
|
||||
const float ThrottleResidue = 1e-9f;
|
||||
const float ErrorMax = 2f;
|
||||
const double KktRelativeTolerance = 1e-12;
|
||||
const int ErrorVariables = 4;
|
||||
|
|
@ -40,6 +37,10 @@ public sealed class ThrustAllocator
|
|||
if (n != _columns) Resize(n);
|
||||
for (var i = 0; i < n; i++) throttle[i] = 0;
|
||||
|
||||
// Nothing asked, nothing fired: that is a fact about the intent, decided before the solve. The solver's warm
|
||||
// start is left as the last solved point, which is where the next non-zero call resumes.
|
||||
if (Intent(move, turn, 0) == 0 && Intent(move, turn, 1) == 0 && Intent(move, turn, 2) == 0) return;
|
||||
|
||||
var vars = n + ErrorVariables;
|
||||
var rows = FixedRows + n;
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ public sealed class ThrustAllocator
|
|||
else if (c < 0) minus -= c;
|
||||
}
|
||||
var extreme = Math.Max(plus, minus);
|
||||
var intent = Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f);
|
||||
var intent = Intent(move, turn, axis);
|
||||
var weight = axis == 2 ? YawWeight : TranslationWeight;
|
||||
// Intent is a fraction of the half-axis extreme it points into.
|
||||
var demand = intent >= 0 ? intent * plus : intent * minus;
|
||||
|
|
@ -80,9 +81,12 @@ public sealed class ThrustAllocator
|
|||
|
||||
var status = BoundedLeastSquares.Solve(rows, vars, _a, _b, _lo, _hi, _x, _workspace, out _, KktRelativeTolerance);
|
||||
if (status == BoundedLeastSquaresStatus.InvalidInput) return;
|
||||
for (var i = 0; i < n; i++) throttle[i] = _x[i] > ThrottleResidue ? _x[i] : 0;
|
||||
for (var i = 0; i < n; i++) throttle[i] = _x[i];
|
||||
}
|
||||
|
||||
static float Intent(float2 move, float turn, int axis) =>
|
||||
Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f);
|
||||
|
||||
static float Component(float3 c, int axis) => axis == 0 ? c.x : axis == 1 ? c.y : c.z;
|
||||
|
||||
void Resize(int n)
|
||||
|
|
|
|||
|
|
@ -346,6 +346,57 @@ public class ThrustAllocatorTests
|
|||
}
|
||||
}
|
||||
|
||||
// Forty thrusters of 20..60 m/s^2 on random half-axes with random yaw, the shape of a hull far larger than any demo
|
||||
// ship. Deterministic from the seed.
|
||||
static float3[] LargeHull(int n, int seed)
|
||||
{
|
||||
var rng = new Random(seed);
|
||||
var columns = new float3[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var a = 20f + 40f * (float)rng.NextDouble();
|
||||
var yaw = (float)(rng.NextDouble() * 2 - 1) * 120f;
|
||||
var dir = rng.Next(10);
|
||||
columns[i] = dir < 4 ? Col(0, a, yaw) : dir < 6 ? Col(0, -a, yaw) : dir < 8 ? Col(a, 0, yaw) : Col(-a, 0, yaw);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingAskedAfterAnIterationCapCallFiresNothing()
|
||||
{
|
||||
// On this hull the warm solve of a zero-intent call needs more than the solver's 100 iterations, and the iterate it
|
||||
// stopped at fired throttles up to 1.0 with nothing asked. Whether nothing is asked is decided from the intent, not
|
||||
// by how far the solver gets.
|
||||
var histories = new[]
|
||||
{
|
||||
(Seed: 9039, History: new[] { (0.0112729073f, -0.170811713f, 0.809294581f), (-0.474286854f, 0.703138232f, 0f), (0.294080615f, 0.934491992f, -0.776120305f) }),
|
||||
(Seed: 9079, History: new[] { (0.607828975f, 1f, 0.202640772f), (-0.620587587f, 0.0162956715f, -0.898228765f), (0.0739500523f, 1f, -0.903907537f) }),
|
||||
};
|
||||
foreach (var (seed, history) in histories)
|
||||
{
|
||||
var hull = LargeHull(40, seed);
|
||||
var allocator = new ThrustAllocator();
|
||||
foreach (var (x, y, turn) in history) Solve(allocator, hull, x, y, turn);
|
||||
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
||||
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASmallIntentIsStillAnsweredWhateverTheColumnScale()
|
||||
{
|
||||
// Zero is a fact about the intent alone: an intent of 1e-3 of the half-axis is served at any scale of thrust.
|
||||
foreach (var scale in new[] { 1e-3f, 1f, 1e3f })
|
||||
{
|
||||
var hull = Duel();
|
||||
for (var i = 0; i < hull.Length; i++) hull[i] *= scale;
|
||||
var extreme = Extremes(hull, 2).plus;
|
||||
var yaw = Net(hull, Solve(hull, 0, 0, 1e-3f)).z;
|
||||
Assert.True(yaw > .5e-3f * extreme, $"scale {scale}: yaw {yaw} of {extreme}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingAskedAfterACallFiresNothing()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue