|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.Diagnostics; |
| 4 | +using System.Linq; |
| 5 | +using System.Threading; |
| 6 | +using System.Threading.Tasks; |
| 7 | +using Roastery.Metrics; |
| 8 | +using Roastery.Model; |
| 9 | +using Roastery.Util; |
| 10 | +using Serilog; |
| 11 | +using Serilog.Context; |
| 12 | +using Serilog.Events; |
| 13 | +using SerilogTracing; |
| 14 | + |
| 15 | +namespace Roastery.Agents; |
| 16 | + |
| 17 | +class RoastingMachine : Agent |
| 18 | +{ |
| 19 | + static readonly RoastProfile[] Profiles = |
| 20 | + [ |
| 21 | + new("1 AM Medium Roast", DropTemperatureCelsius: 210, FinalBurnerLevelPercent: 50, TypicalWeightLossPercent: 13.5), |
| 22 | + new("Rocket Ship Dark Roast", DropTemperatureCelsius: 224, FinalBurnerLevelPercent: 58, TypicalWeightLossPercent: 16.5) |
| 23 | + ]; |
| 24 | + |
| 25 | + // The bean probe reading rises toward the drum environment temperature at a rate |
| 26 | + // proportional to the difference between them; the operator steps the burner down |
| 27 | + // as the roast approaches its target, producing the characteristic declining |
| 28 | + // rate-of-rise curve. |
| 29 | + const double TurningPointCelsius = 95; |
| 30 | + const double InitialBurnerLevelPercent = 90; |
| 31 | + const double HeatTransferPerMinute = 0.2; |
| 32 | + const int TickMilliseconds = 4000; |
| 33 | + |
| 34 | + class RoastState |
| 35 | + { |
| 36 | + public double BeanTemperature; |
| 37 | + public double RateOfRise; |
| 38 | + public double BurnerLevel = InitialBurnerLevelPercent; |
| 39 | + public double DrumSpeed = 64; |
| 40 | + public bool PassedTurningPoint; |
| 41 | + public double? FaultAtTemperature; |
| 42 | + public int FaultTicksRemaining; |
| 43 | + public bool HadFault; |
| 44 | + } |
| 45 | + |
| 46 | + readonly ILogger _logger; |
| 47 | + readonly RoasteryProductionMetrics _metrics; |
| 48 | + readonly LoadingDock _loadingDock; |
| 49 | + readonly ProductionSchedule _productionSchedule; |
| 50 | + readonly MaintenanceSchedule _maintenanceSchedule; |
| 51 | + readonly string _machineId; |
| 52 | + bool _offlineForServicing; |
| 53 | + |
| 54 | + // Each machine's temperature calibration drifts a little; this shows up as a |
| 55 | + // per-machine skew in roast durations and weight loss |
| 56 | + readonly double _calibrationBiasCelsius = Distribution.Uniform(0, 6) - 3; |
| 57 | + |
| 58 | + public RoastingMachine(ILogger logger, RoasteryProductionMetrics metrics, LoadingDock loadingDock, |
| 59 | + ProductionSchedule productionSchedule, MaintenanceSchedule maintenanceSchedule, string machineId) |
| 60 | + : base(20000) |
| 61 | + { |
| 62 | + _logger = logger.ForContext<RoastingMachine>(); |
| 63 | + _metrics = metrics; |
| 64 | + _loadingDock = loadingDock; |
| 65 | + _productionSchedule = productionSchedule; |
| 66 | + _maintenanceSchedule = maintenanceSchedule; |
| 67 | + _machineId = machineId; |
| 68 | + } |
| 69 | + |
| 70 | + protected override IEnumerable<Behavior> GetBehaviors() |
| 71 | + { |
| 72 | + yield return RoastBatch; |
| 73 | + } |
| 74 | + |
| 75 | + async Task RoastBatch(CancellationToken cancellationToken) |
| 76 | + { |
| 77 | + if (_maintenanceSchedule.IsUnderMaintenance()) |
| 78 | + { |
| 79 | + if (!_offlineForServicing) |
| 80 | + { |
| 81 | + _offlineForServicing = true; |
| 82 | + _logger.Warning("Roasting machine {MachineId} is offline: the afterburner exhaust system requires servicing; roasting is suspended", |
| 83 | + _machineId); |
| 84 | + } |
| 85 | + |
| 86 | + return; |
| 87 | + } |
| 88 | + |
| 89 | + if (_offlineForServicing) |
| 90 | + { |
| 91 | + _offlineForServicing = false; |
| 92 | + _logger.Information("Servicing complete; roasting machine {MachineId} is back online", _machineId); |
| 93 | + } |
| 94 | + |
| 95 | + // The machine sits idle until the warehouse requests more stock of a blend |
| 96 | + var requestedBlend = _productionSchedule.TakeRequest(); |
| 97 | + if (requestedBlend == null) |
| 98 | + return; |
| 99 | + |
| 100 | + var profile = Profiles.FirstOrDefault(p => p.Name == requestedBlend); |
| 101 | + if (profile == null) |
| 102 | + return; |
| 103 | + |
| 104 | + var roastId = "roast-" + Guid.NewGuid().ToString("n")[..8]; |
| 105 | + var key = new RoasteryProductionMetrics.Sample.RoastKey(_machineId, roastId, profile.Name); |
| 106 | + |
| 107 | + using var _ = LogContext.PushProperty("MachineId", _machineId); |
| 108 | + using var __ = LogContext.PushProperty("RoastId", roastId); |
| 109 | + using var activity = _logger.StartActivity("Roast {RoastProfile} batch {RoastId} on machine {MachineId}", |
| 110 | + profile.Name, roastId, _machineId); |
| 111 | + |
| 112 | + var greenWeightKilograms = Math.Round(Distribution.Uniform(80, 110), 1); |
| 113 | + var firstCrackTemperature = 194 + Distribution.Uniform(0, 4); |
| 114 | + |
| 115 | + var state = new RoastState |
| 116 | + { |
| 117 | + BeanTemperature = 190 + Distribution.Uniform(0, 6) - 3 + _calibrationBiasCelsius, |
| 118 | + FaultAtTemperature = Distribution.OnceIn(8) ? Distribution.Uniform(120, 190) : null |
| 119 | + }; |
| 120 | + |
| 121 | + _metrics.RecordRoastBatchStarted(key); |
| 122 | + _logger.Information("Charged {GreenWeightKilograms}kg of green beans for {RoastProfile} at drum temperature {ChargeTemperature:F1}°C", |
| 123 | + greenWeightKilograms, profile.Name, state.BeanTemperature); |
| 124 | + |
| 125 | + var roastTiming = Stopwatch.StartNew(); |
| 126 | + |
| 127 | + await AdvancePhaseAsync("Drying", key, profile, state, 150, cancellationToken); |
| 128 | + await AdvancePhaseAsync("Browning", key, profile, state, firstCrackTemperature, cancellationToken); |
| 129 | + |
| 130 | + _logger.Information("First crack detected at {BeanTemperature:F1}°C, {ElapsedSeconds:F0}s into the roast", |
| 131 | + state.BeanTemperature, roastTiming.Elapsed.TotalSeconds); |
| 132 | + |
| 133 | + await AdvancePhaseAsync("Development", key, profile, state, profile.DropTemperatureCelsius, cancellationToken); |
| 134 | + |
| 135 | + roastTiming.Stop(); |
| 136 | + var dropTemperature = state.BeanTemperature; |
| 137 | + |
| 138 | + using (_logger.StartActivity("Cooling batch {RoastId}", roastId)) |
| 139 | + { |
| 140 | + state.BurnerLevel = 0; |
| 141 | + state.RateOfRise = 0; |
| 142 | + for (var i = 0; i < 4; ++i) |
| 143 | + { |
| 144 | + await Task.Delay(TickMilliseconds, cancellationToken); |
| 145 | + state.BeanTemperature += (45 - state.BeanTemperature) * 0.4; |
| 146 | + RecordTelemetry(key, state); |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + var durationSeconds = Math.Round(roastTiming.Elapsed.TotalSeconds, 1); |
| 151 | + activity.AddProperty("RoastDurationSeconds", durationSeconds); |
| 152 | + |
| 153 | + if (state.HadFault && Distribution.OnceIn(3) || Distribution.OnceIn(70)) |
| 154 | + { |
| 155 | + _metrics.RecordRoastBatchRejected(key); |
| 156 | + _logger.Error("Batch {RoastId} rejected by quality control: uneven development following an unstable roast curve", roastId); |
| 157 | + activity.Complete(LogEventLevel.Error); |
| 158 | + return; |
| 159 | + } |
| 160 | + |
| 161 | + var weightLossPercent = Math.Round(profile.TypicalWeightLossPercent + Distribution.Uniform(0, 2.5) - 1.25, 1); |
| 162 | + var roastedWeightKilograms = Math.Round(greenWeightKilograms * (1 - weightLossPercent / 100), 1); |
| 163 | + |
| 164 | + _metrics.RecordRoastBatchCompleted(key, durationSeconds, weightLossPercent); |
| 165 | + _loadingDock.Deliver(profile.Name, roastedWeightKilograms); |
| 166 | + _logger.Information("Dropped {GreenWeightKilograms}kg batch of {RoastProfile} at {DropTemperature:F1}°C after {RoastDurationSeconds}s with {WeightLossPercent}% weight loss; {RoastedWeightKilograms}kg sent to the loading dock", |
| 167 | + greenWeightKilograms, profile.Name, dropTemperature, durationSeconds, weightLossPercent, roastedWeightKilograms); |
| 168 | + } |
| 169 | + |
| 170 | + async Task AdvancePhaseAsync( |
| 171 | + string phaseName, |
| 172 | + RoasteryProductionMetrics.Sample.RoastKey key, |
| 173 | + RoastProfile profile, |
| 174 | + RoastState state, |
| 175 | + double targetTemperature, |
| 176 | + CancellationToken cancellationToken) |
| 177 | + { |
| 178 | + using var phase = _logger.StartActivity("{RoastPhase} phase of batch {RoastId}", phaseName, key.RoastId); |
| 179 | + |
| 180 | + while (state.BeanTemperature < targetTemperature || !state.PassedTurningPoint) |
| 181 | + { |
| 182 | + await Task.Delay((int)Distribution.Uniform(TickMilliseconds - 500, TickMilliseconds + 500), cancellationToken); |
| 183 | + |
| 184 | + if (state is { PassedTurningPoint: true, FaultAtTemperature: not null } && |
| 185 | + state.BeanTemperature >= state.FaultAtTemperature) |
| 186 | + { |
| 187 | + state.FaultAtTemperature = null; |
| 188 | + state.FaultTicksRemaining = (int)Distribution.Uniform(4, 8); |
| 189 | + state.HadFault = true; |
| 190 | + _logger.Warning("Burner flame-out detected on {MachineId} during {RoastPhase}; rate of rise is crashing", |
| 191 | + _machineId, phaseName); |
| 192 | + } |
| 193 | + |
| 194 | + var wasFaulted = state.FaultTicksRemaining > 0; |
| 195 | + Advance(state, profile); |
| 196 | + if (wasFaulted && state.FaultTicksRemaining == 0) |
| 197 | + _logger.Information("Burner reignited on {MachineId}; roast curve recovering", _machineId); |
| 198 | + |
| 199 | + RecordTelemetry(key, state); |
| 200 | + } |
| 201 | + |
| 202 | + phase.AddProperty("BeanTemperature", Math.Round(state.BeanTemperature, 1)); |
| 203 | + } |
| 204 | + |
| 205 | + void Advance(RoastState state, RoastProfile profile) |
| 206 | + { |
| 207 | + if (!state.PassedTurningPoint) |
| 208 | + { |
| 209 | + // The probe reading falls rapidly toward the turning point as the cold beans |
| 210 | + // absorb the drum's stored heat |
| 211 | + state.RateOfRise = -2.2 * (state.BeanTemperature - 88) + Distribution.Uniform(0, 8) - 4; |
| 212 | + if (state.BeanTemperature <= TurningPointCelsius + 2) |
| 213 | + { |
| 214 | + state.PassedTurningPoint = true; |
| 215 | + state.RateOfRise = 2; |
| 216 | + } |
| 217 | + } |
| 218 | + else |
| 219 | + { |
| 220 | + var progress = Math.Clamp( |
| 221 | + (state.BeanTemperature - TurningPointCelsius) / (profile.DropTemperatureCelsius - TurningPointCelsius), 0, 1); |
| 222 | + var targetBurnerLevel = state.FaultTicksRemaining > 0 |
| 223 | + ? 15 |
| 224 | + : InitialBurnerLevelPercent - (InitialBurnerLevelPercent - profile.FinalBurnerLevelPercent) * progress; |
| 225 | + |
| 226 | + state.BurnerLevel = Math.Clamp( |
| 227 | + state.BurnerLevel + (targetBurnerLevel - state.BurnerLevel) * 0.5 + Distribution.Uniform(0, 4) - 2, 10, 100); |
| 228 | + |
| 229 | + var drumEnvironmentTemperature = 175 + state.BurnerLevel * 1.45 + _calibrationBiasCelsius; |
| 230 | + state.RateOfRise = Math.Max( |
| 231 | + HeatTransferPerMinute * (drumEnvironmentTemperature - state.BeanTemperature) + Distribution.Uniform(0, 1.5) - 0.75, |
| 232 | + 0.3); |
| 233 | + |
| 234 | + if (state.FaultTicksRemaining > 0) |
| 235 | + state.FaultTicksRemaining -= 1; |
| 236 | + } |
| 237 | + |
| 238 | + state.BeanTemperature += state.RateOfRise * TickMilliseconds / 60000.0; |
| 239 | + state.DrumSpeed = 64 + Distribution.Uniform(0, 2) - 1; |
| 240 | + } |
| 241 | + |
| 242 | + void RecordTelemetry(RoasteryProductionMetrics.Sample.RoastKey key, RoastState state) |
| 243 | + { |
| 244 | + _metrics.RecordRoastTelemetry(key, new RoasteryProductionMetrics.Sample.RoastTelemetryGauges( |
| 245 | + Math.Round(state.BeanTemperature, 1), |
| 246 | + Math.Round(state.RateOfRise, 1), |
| 247 | + Math.Round(state.BurnerLevel, 1), |
| 248 | + Math.Round(state.DrumSpeed, 1))); |
| 249 | + } |
| 250 | +} |
0 commit comments