Skip to content

Commit 7322d34

Browse files
amannix-roKodrAus
authored andcommitted
Simulate the roasting process with correlated metrics, traces, and logs
Assisted-By: Claude:claude-fable-5
1 parent 08af6b8 commit 7322d34

67 files changed

Lines changed: 1857 additions & 329 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/Roastery/Agents/CatalogBatch.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using System.Threading;
45
using System.Threading.Tasks;
56
using Roastery.Model;
6-
using Roastery.Util;
77
using Roastery.Web;
88
using Serilog;
99
using Serilog.Context;
@@ -27,21 +27,27 @@ protected override IEnumerable<Behavior> GetBehaviors()
2727
yield return CheckStock;
2828
}
2929

30+
const double LowStockThresholdKilograms = 40;
31+
3032
async Task CheckStock(CancellationToken cancellationToken)
3133
{
3234
using var _ = LogContext.PushProperty("BatchId", Guid.NewGuid());
3335
try
3436
{
3537
_logger.Information("Checking stock levels");
36-
38+
39+
var inventory = await _httpClient.GetAsync<List<Inventory>>("api/inventory");
40+
var stockByBlend = inventory.ToDictionary(i => i.Blend, i => i.QuantityKilograms);
41+
3742
foreach (var product in await _httpClient.GetAsync<List<Product>>("api/products"))
3843
{
3944
_logger.Information("Checking product {ProductDescription} ({ProductId})", product.FormatDescription(), product.Id);
40-
41-
if (Distribution.OnceIn(30))
42-
_logger.Warning("Product {ProductId} is low on stock", product.Id);
43-
else if (Distribution.OnceIn(70))
45+
46+
var stock = stockByBlend.GetValueOrDefault(product.Blend);
47+
if (stock < product.SizeInGrams / 1000.0)
4448
_logger.Warning("Product {ProductId} is out of stock", product.Id);
49+
else if (stock < LowStockThresholdKilograms)
50+
_logger.Warning("Product {ProductId} is low on stock", product.Id);
4551
}
4652
}
4753
catch (Exception ex)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Roastery.Metrics;
6+
using Roastery.Util;
7+
using Serilog;
8+
9+
namespace Roastery.Agents;
10+
11+
class FacilitySensors : Agent
12+
{
13+
class AreaState
14+
{
15+
public AreaState(string area, double baseTemperature, double relativeHumidity)
16+
{
17+
Area = area;
18+
BaseTemperature = baseTemperature;
19+
RelativeHumidity = relativeHumidity;
20+
}
21+
22+
public string Area { get; }
23+
public double BaseTemperature { get; }
24+
public double RelativeHumidity { get; set; }
25+
}
26+
27+
readonly ILogger _logger;
28+
readonly RoasteryProductionMetrics _metrics;
29+
30+
readonly AreaState[] _areas =
31+
[
32+
new("Roasting Floor", baseTemperature: 27, relativeHumidity: 52),
33+
new("Green Bean Warehouse", baseTemperature: 19, relativeHumidity: 60)
34+
];
35+
36+
double _barometricPressure = 1015;
37+
38+
public FacilitySensors(ILogger logger, RoasteryProductionMetrics metrics)
39+
: base(6000)
40+
{
41+
_logger = logger.ForContext<FacilitySensors>();
42+
_metrics = metrics;
43+
}
44+
45+
protected override IEnumerable<Behavior> GetBehaviors()
46+
{
47+
yield return SampleEnvironment;
48+
}
49+
50+
Task SampleEnvironment(CancellationToken cancellationToken)
51+
{
52+
// Facility temperatures peak mid-afternoon and bottom out overnight
53+
var hour = DateTime.Now.TimeOfDay.TotalHours;
54+
var diurnalSwing = 3.5 * Math.Sin((hour - 9.0) / 24.0 * 2.0 * Math.PI);
55+
56+
_barometricPressure = Math.Clamp(_barometricPressure + Distribution.Uniform(0, 0.6) - 0.3, 990, 1035);
57+
58+
foreach (var area in _areas)
59+
{
60+
var temperature = area.BaseTemperature + diurnalSwing + Distribution.Uniform(0, 0.8) - 0.4;
61+
area.RelativeHumidity = Math.Clamp(
62+
area.RelativeHumidity + (58 - area.RelativeHumidity) * 0.02 + Distribution.Uniform(0, 2.4) - 1.2, 35, 85);
63+
64+
_metrics.RecordAmbientConditions(
65+
new RoasteryProductionMetrics.Sample.AmbientKey(area.Area),
66+
new RoasteryProductionMetrics.Sample.AmbientGauges(
67+
Math.Round(temperature, 1),
68+
Math.Round(area.RelativeHumidity, 1),
69+
Math.Round(_barometricPressure, 1)));
70+
71+
if (area.RelativeHumidity > 75 && Distribution.OnceIn(10))
72+
_logger.Warning("Relative humidity in the {Area} has reached {RelativeHumidity:F0}%; green coffee should be stored below 65% RH",
73+
area.Area, area.RelativeHumidity);
74+
}
75+
76+
return Task.CompletedTask;
77+
}
78+
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)