diff --git a/Inferno.Api/Services/FireMinder.cs b/Inferno.Api/Services/FireMinder.cs index cf07ab8..c89b34d 100644 --- a/Inferno.Api/Services/FireMinder.cs +++ b/Inferno.Api/Services/FireMinder.cs @@ -37,10 +37,25 @@ public class FireMinder : IDisposable double _ignitionHigh; bool _fireStarted; int _ignitionTemp; + /// + /// Grill temperature at which the fire was first declared started, captured + /// once on the initial ignition. Unlike — which the + /// recovery path raises to the fire-check temp when relighting a struggling + /// fire — this stays anchored to where the fire actually caught. That lets Sear + /// gate its full-feed transition on a margin above the catch temp instead of a + /// fixed absolute temperature that a cold or windy firepot may never reach. + /// + int _initialIgnitionTemp; bool _initialIgnition; public bool IsFireHealthy => !_fireCheck; public bool IsFireStarted => _fireStarted; + /// + /// Grill temp (F) at which the fire was first declared started. Anchored to the + /// initial catch — never raised by recovery relights — so callers can derive a + /// relative establish threshold from it. + /// + public int InitialIgnitionTemp => _initialIgnitionTemp; public bool IsReigniting => _fireCheck && _igniter.IsOn; // Recovery dominates: never report a lid-open while we're actively recovering, // so the Smoker stays on the aggressive RecoveryFeed instead of the floor. @@ -63,6 +78,9 @@ public void ResetFireStatus() _fireCheck = false; _initialIgnition = true; _ignitionTemp = 150; + // Conservative default so Sear's relative establish gate stays high until + // the fire actually catches (and overwrites this with the real catch temp). + _initialIgnitionTemp = 150; _belowCheckSince = null; _recoveryHigh = 0; _ignitionHigh = 0; @@ -177,6 +195,12 @@ internal void Tick() if(_smoker.Temps.GrillTemp >= _ignitionTemp) { // The fire has started, make sure the igniter is off + if (!_fireStarted) + { + // Capture where the fire caught, once. Recovery may later raise + // _ignitionTemp, but this anchor must stay at the initial catch. + _initialIgnitionTemp = _ignitionTemp; + } _fireStarted = true; _igniter.Off(); } diff --git a/Inferno.Api/Services/Smoker.cs b/Inferno.Api/Services/Smoker.cs index 657d8c5..939437e 100644 --- a/Inferno.Api/Services/Smoker.cs +++ b/Inferno.Api/Services/Smoker.cs @@ -27,6 +27,16 @@ public class Smoker : ISmoker, IDisposable int _maxGrillTemp = 425; + /// + /// In Sear, how far above the ignition temperature the grill must climb before + /// the auger switches from the gentle establishing feed to a continuous full + /// feed. A margin relative to where the fire actually caught — not a fixed + /// absolute temp — so a cold or windy start (where the firepot settles lower) + /// still reaches full feed instead of stalling under the old absolute 180F gate. + /// Lower this if the worst windy days still stall before full feed. + /// + int _searEstablishMargin = 25; + /// /// Timeout for the blower to run after shutdown. /// @@ -489,9 +499,10 @@ private async Task Sear() return; } - if (_rtdArray.GrillTemp < _minSetPoint) + int establishTemp = _fireMinder.InitialIgnitionTemp + _searEstablishMargin; + if (_rtdArray.GrillTemp < establishTemp) { - Debug.WriteLine($"Sear: Grill temp {_rtdArray.GrillTemp} below {_minSetPoint}. Diverting to SMOKE to establish fire."); + Debug.WriteLine($"Sear: Grill temp {_rtdArray.GrillTemp} below establish temp {establishTemp} (ignition {_fireMinder.InitialIgnitionTemp} + {_searEstablishMargin}). Diverting to SMOKE to establish fire."); await Smoke(); return; } diff --git a/Inferno.Tests/FireMinderTests.cs b/Inferno.Tests/FireMinderTests.cs index 868de78..41fa360 100644 --- a/Inferno.Tests/FireMinderTests.cs +++ b/Inferno.Tests/FireMinderTests.cs @@ -182,6 +182,60 @@ public void ResetFireStatus_ClearsState() Assert.False(fm.IsLidOpen); } + [Fact] + public void InitialIgnitionTemp_DefaultsToFloor_BeforeFireStarts() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Sear, SetPoint = 400 }; + var igniter = new FakeRelay(); + var fm = new FireMinder(smoker, igniter, autoStart: false); + fm.ResetFireStatus(); + + // Before the fire catches, the anchor sits at the 150F floor so Sear's + // relative establish gate stays conservative. + Assert.False(fm.IsFireStarted); + Assert.Equal(150, fm.InitialIgnitionTemp); + } + + [Fact] + public void ColdStart_CapturesInitialIgnitionTempAtCatch() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Sear, SetPoint = 400 }; + var igniter = new FakeRelay(); + var clock = new TestClock(); + var fm = new FireMinder(smoker, igniter, () => clock.Now, autoStart: false); + fm.ResetFireStatus(); + + SetGrill(smoker, 75); + fm.Tick(); // igniter lights; ignition temp floors at 150 + Assert.True(igniter.IsOn); + Assert.False(fm.IsFireStarted); + + SetGrill(smoker, 152); + fm.Tick(); // crosses 150 → fire started + + Assert.True(fm.IsFireStarted); + Assert.Equal(150, fm.InitialIgnitionTemp); + } + + [Fact] + public void InitialIgnitionTemp_UnchangedByRecoveryRelight() + { + // EstablishedFire catches cold (grill jumps to 200 over the 150 floor). + var (fm, smoker, igniter, clock) = EstablishedFire(); + Assert.Equal(150, fm.InitialIgnitionTemp); + + // Trip recovery: the relight raises the internal ignition threshold to the + // fire-check temp (195), but the initial-catch anchor must stay put so Sear's + // establish gate doesn't drift upward after a recovery. + SetGrill(smoker, 180); + fm.Tick(); + clock.Advance(TimeSpan.FromSeconds(46)); + fm.Tick(); + Assert.True(fm.IsReigniting); + + Assert.Equal(150, fm.InitialIgnitionTemp); + } + [Fact] public void BriefDip_RecoversBeforeDebounce_DoesNotTrip() {