diff --git a/src/MrGameEng.Simulation/AI/Suitability.cs b/src/MrGameEng.Simulation/AI/Suitability.cs index 557a164..4e60e96 100644 --- a/src/MrGameEng.Simulation/AI/Suitability.cs +++ b/src/MrGameEng.Simulation/AI/Suitability.cs @@ -23,4 +23,41 @@ public static class Suitability var z = (value - optimum) / tolerance; return MathF.Exp(-0.5f * z * z); } + + /// + /// Trapezoidal suitability in [0, 1]: 0 at or beyond the hard limits + /// /, ramping linearly up to 1 at + /// , flat 1 across the comfortable plateau to + /// , then ramping back down to 0 at . + /// Models a hard tolerance band with a plateau (RimWorld-style plant growth vs. temperature: + /// dormant below or above ). A degenerate edge + /// ( or ≥ + /// ) becomes a hard step on that side. The four bounds are expected + /// ordered (min ≤ optimalLow ≤ optimalHigh ≤ max); pass ordered values. + /// + 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; + } } diff --git a/tests/MrGameEng.Simulation.Tests/AI/SuitabilityTests.cs b/tests/MrGameEng.Simulation.Tests/AI/SuitabilityTests.cs index d61fe99..cc50cbd 100644 --- a/tests/MrGameEng.Simulation.Tests/AI/SuitabilityTests.cs +++ b/tests/MrGameEng.Simulation.Tests/AI/SuitabilityTests.cs @@ -48,4 +48,45 @@ public class SuitabilityTests Assert.Equal(1f, Suitability.Gaussian(5f, 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); + } + } }