Add Trapezoidal suitability function and corresponding tests
CI / build-test (push) Successful in 1m10s

Introduced a new Trapezoid method in the Suitability class to model suitability with a hard tolerance band and a plateau. The method returns suitability values based on defined limits and optimal ranges. Added comprehensive unit tests to validate the functionality, including edge cases for flat plateaus, linear ramps, and hard limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-13 04:34:32 +03:00
co-authored by Claude Opus 4.8
parent d044cafad9
commit d360093be1
2 changed files with 78 additions and 0 deletions
@@ -23,4 +23,41 @@ public static class Suitability
var z = (value - optimum) / tolerance; var z = (value - optimum) / tolerance;
return MathF.Exp(-0.5f * z * z); return MathF.Exp(-0.5f * z * z);
} }
/// <summary>
/// Trapezoidal suitability in <c>[0, 1]</c>: <c>0</c> at or beyond the hard limits
/// <paramref name="min"/>/<paramref name="max"/>, ramping linearly up to <c>1</c> at
/// <paramref name="optimalLow"/>, flat <c>1</c> across the comfortable plateau to
/// <paramref name="optimalHigh"/>, then ramping back down to <c>0</c> at <paramref name="max"/>.
/// Models a hard tolerance band with a plateau (RimWorld-style plant growth vs. temperature:
/// dormant below <paramref name="min"/> or above <paramref name="max"/>). A degenerate edge
/// (<paramref name="optimalLow"/> ≤ <paramref name="min"/> or <paramref name="optimalHigh"/> ≥
/// <paramref name="max"/>) becomes a hard step on that side. The four bounds are expected
/// ordered (<c>min ≤ optimalLow ≤ optimalHigh ≤ max</c>); pass ordered values.
/// </summary>
public static float Trapezoid(
float value,
float min,
float optimalLow,
float optimalHigh,
float max
)
{
if (value <= min || value >= max)
{
return 0f;
}
if (value < optimalLow)
{
return optimalLow > min ? (value - min) / (optimalLow - min) : 1f;
}
if (value > optimalHigh)
{
return max > optimalHigh ? (max - value) / (max - optimalHigh) : 1f;
}
return 1f;
}
} }
@@ -48,4 +48,45 @@ public class SuitabilityTests
Assert.Equal(1f, Suitability.Gaussian(5f, 5f, 0f)); Assert.Equal(1f, Suitability.Gaussian(5f, 5f, 0f));
Assert.Equal(0f, Suitability.Gaussian(6f, 5f, 0f)); Assert.Equal(0f, Suitability.Gaussian(6f, 5f, 0f));
} }
[Fact]
public void Trapezoid_IsFlatAcrossThePlateau()
{
Assert.Equal(1f, Suitability.Trapezoid(10f, 0f, 10f, 42f, 58f), 5);
Assert.Equal(1f, Suitability.Trapezoid(25f, 0f, 10f, 42f, 58f), 5);
Assert.Equal(1f, Suitability.Trapezoid(42f, 0f, 10f, 42f, 58f), 5);
}
[Fact]
public void Trapezoid_RampsLinearlyOnEachShoulder()
{
Assert.Equal(0.5f, Suitability.Trapezoid(5f, 0f, 10f, 42f, 58f), 5); // halfway up the cold ramp
Assert.Equal(0.5f, Suitability.Trapezoid(50f, 0f, 10f, 42f, 58f), 5); // halfway down the heat ramp
}
[Fact]
public void Trapezoid_IsZeroAtAndBeyondTheHardLimits()
{
Assert.Equal(0f, Suitability.Trapezoid(0f, 0f, 10f, 42f, 58f));
Assert.Equal(0f, Suitability.Trapezoid(-5f, 0f, 10f, 42f, 58f));
Assert.Equal(0f, Suitability.Trapezoid(58f, 0f, 10f, 42f, 58f));
Assert.Equal(0f, Suitability.Trapezoid(70f, 0f, 10f, 42f, 58f));
}
[Fact]
public void Trapezoid_DegenerateShoulder_IsAHardStep()
{
// optimalLow == min: full suitability immediately above the lower limit, zero at/below it.
Assert.Equal(1f, Suitability.Trapezoid(5f, 0f, 0f, 10f, 20f));
Assert.Equal(0f, Suitability.Trapezoid(0f, 0f, 0f, 10f, 20f));
}
[Fact]
public void Trapezoid_StaysWithinUnitRange()
{
for (var v = -50f; v <= 80f; v += 1f)
{
Assert.InRange(Suitability.Trapezoid(v, 0f, 10f, 42f, 58f), 0f, 1f);
}
}
} }