sim_restaurant/
sim.rs

1// Copyright (c) 2026 Graphcore Ltd. All rights reserved.
2
3use std::cell::{Cell, RefCell};
4use std::ops::RangeInclusive;
5use std::rc::Rc;
6
7use gwr_components::queue::Queue;
8use gwr_engine::engine::Engine;
9use gwr_engine::events::once::Once;
10use gwr_engine::events::repeated::Repeated;
11use gwr_engine::sim_error;
12use gwr_engine::time::clock::Clock;
13use gwr_engine::traits::Event;
14use gwr_engine::types::{SimError, SimResult};
15use gwr_track::entity::Entity;
16use gwr_track::tracker::dev_null_tracker;
17use gwr_track::{Tracker, info, warn};
18
19use crate::config::RestaurantConfig;
20use crate::customer::{Customer, CustomerOutcome, CustomerPlan, generate_demand, spawn_arrivals};
21use crate::menu::{order_name, order_value};
22use crate::recording::{
23    CustomerPhase, CustomerStateCounts, RecordedSimulation, SimulationSnapshot, TimelineEvent,
24    TimelinePoint,
25};
26use crate::staff::{Staffing, spawn_kitchen_worker, spawn_till_worker};
27use crate::time_of_day::TimeOfDay;
28
29#[derive(Default, Debug, Clone)]
30pub struct Metrics {
31    pub arrivals: usize,
32    pub balked: usize,
33    pub gave_up_queue: usize,
34    pub entered_queue: usize,
35    pub till_started: usize,
36    pub orders_started: usize,
37    pub orders_served: usize,
38    pub orders_abandoned: usize,
39    pub revenue: f64,
40    pub ingredient_cost: f64,
41    pub till_wait_ticks_total: u64,
42    pub kitchen_wait_ticks_total: u64,
43    pub visit_ticks_total: u64,
44    pub max_till_queue_len: usize,
45    pub max_kitchen_queue_len: usize,
46}
47
48struct Recorder {
49    timeline: RefCell<Vec<TimelinePoint>>,
50    events: RefCell<Vec<TimelineEvent>>,
51    customer_phases: RefCell<Vec<CustomerPhase>>,
52}
53
54impl Recorder {
55    fn new(num_customers: usize) -> Self {
56        Self {
57            timeline: RefCell::new(Vec::new()),
58            events: RefCell::new(Vec::new()),
59            customer_phases: RefCell::new(vec![CustomerPhase::Planned; num_customers]),
60        }
61    }
62
63    fn set_customer_phase(&self, customer_id: usize, phase: CustomerPhase) {
64        if let Some(slot) = self.customer_phases.borrow_mut().get_mut(customer_id) {
65            *slot = phase;
66        }
67    }
68
69    fn snapshot_counts(&self) -> CustomerStateCounts {
70        let mut counts = CustomerStateCounts::default();
71        for phase in self.customer_phases.borrow().iter().copied() {
72            match phase {
73                CustomerPhase::Planned => counts.planned += 1,
74                CustomerPhase::Balked => counts.balked += 1,
75                CustomerPhase::WaitingTill => counts.waiting_till += 1,
76                CustomerPhase::AtTill => counts.at_till += 1,
77                CustomerPhase::WaitingKitchen => counts.waiting_kitchen += 1,
78                CustomerPhase::PreparingFood => counts.preparing_food += 1,
79                CustomerPhase::CollectingFood => counts.collecting_food += 1,
80                CustomerPhase::Served => counts.served += 1,
81                CustomerPhase::GaveUpQueue => counts.gave_up_queue += 1,
82                CustomerPhase::Abandoned => counts.abandoned += 1,
83            }
84        }
85        counts
86    }
87
88    fn record_snapshot(&self, tick: u64, restaurant: &Restaurant, message: String) {
89        let snapshot = SimulationSnapshot {
90            metrics: restaurant.metrics.borrow().clone(),
91            till_queue: restaurant.till_queue.values(),
92            kitchen_queue: restaurant.kitchen_queue.values(),
93            active_till_workers: restaurant.active_till_workers.get(),
94            active_kitchen_workers: restaurant.active_kitchen_workers.get(),
95            arrivals_complete: restaurant.arrivals_complete.get(),
96            closed: restaurant.closed_seen.get(),
97            customer_counts: self.snapshot_counts(),
98        };
99        self.timeline
100            .borrow_mut()
101            .push(TimelinePoint { tick, snapshot });
102        self.events
103            .borrow_mut()
104            .push(TimelineEvent { tick, message });
105    }
106
107    fn finish(
108        &self,
109        staffing: Staffing,
110        opening_time: TimeOfDay,
111        day_ticks: u64,
112        summary: RunSummary,
113        demand_size: usize,
114    ) -> RecordedSimulation {
115        RecordedSimulation {
116            staffing,
117            opening_time,
118            day_ticks,
119            summary,
120            demand_size,
121            timeline: self.timeline.borrow().clone(),
122            events: self.events.borrow().clone(),
123        }
124    }
125}
126
127#[derive(Clone)]
128struct ScenarioEntities {
129    restaurant: Rc<Entity>,
130    till_workers: Vec<Rc<Entity>>,
131    kitchen_workers: Vec<Rc<Entity>>,
132}
133
134fn build_entities(engine: &Engine, staffing: Staffing) -> ScenarioEntities {
135    let restaurant = Rc::new(Entity::new(engine.top(), "restaurant"));
136    let staff = Rc::new(Entity::new(&restaurant, "staff"));
137    let till_workers = (0..staffing.till)
138        .map(|index| Rc::new(Entity::new(&staff, &format!("till_{index}"))))
139        .collect();
140    let kitchen_workers = (0..staffing.kitchen)
141        .map(|index| Rc::new(Entity::new(&staff, &format!("kitchen_{index}"))))
142        .collect();
143
144    ScenarioEntities {
145        restaurant,
146        till_workers,
147        kitchen_workers,
148    }
149}
150
151pub(crate) struct Restaurant {
152    clock: Clock,
153    pub(crate) till_queue: Queue<usize>,
154    pub(crate) kitchen_queue: Queue<usize>,
155    pub(crate) closed: Once<()>,
156    pub(crate) arrivals_complete: Cell<bool>,
157    pub(crate) closed_seen: Cell<bool>,
158    pub(crate) active_till_workers: Cell<usize>,
159    pub(crate) active_kitchen_workers: Cell<usize>,
160    pub(crate) metrics: RefCell<Metrics>,
161    entity: Rc<Entity>,
162    customers: RefCell<Vec<Option<Rc<Customer>>>>,
163    till_worker_entities: Vec<Rc<Entity>>,
164    kitchen_worker_entities: Vec<Rc<Entity>>,
165    recorder: Option<Rc<Recorder>>,
166}
167
168impl Restaurant {
169    #[must_use]
170    fn new(
171        clock: Clock,
172        entities: ScenarioEntities,
173        num_customers: usize,
174        kitchen_queue_capacity: usize,
175        recorder: Option<Rc<Recorder>>,
176    ) -> Self {
177        let entity = entities.restaurant;
178        let till_queue =
179            Queue::new(&entity, "till_queue", None).expect("queue config should be valid");
180        let kitchen_queue = Queue::new(&entity, "kitchen_queue", Some(kitchen_queue_capacity))
181            .expect("queue config should be valid");
182
183        Self {
184            clock,
185            till_queue,
186            kitchen_queue,
187            closed: Once::default(),
188            arrivals_complete: Cell::new(false),
189            closed_seen: Cell::new(false),
190            active_till_workers: Cell::new(0),
191            active_kitchen_workers: Cell::new(0),
192            metrics: RefCell::new(Metrics::default()),
193            entity,
194            customers: RefCell::new(vec![None; num_customers]),
195            till_worker_entities: entities.till_workers,
196            kitchen_worker_entities: entities.kitchen_workers,
197            recorder,
198        }
199    }
200
201    #[must_use]
202    pub(crate) fn till_queue_changed(&self) -> Repeated<()> {
203        self.till_queue.changed_event()
204    }
205
206    #[must_use]
207    pub(crate) fn kitchen_queue_changed(&self) -> Repeated<()> {
208        self.kitchen_queue.changed_event()
209    }
210
211    pub(crate) fn customer(&self, customer_id: usize) -> Rc<Customer> {
212        self.customers.borrow()[customer_id]
213            .clone()
214            .expect("customer should be registered before use")
215    }
216
217    fn entity(&self) -> &Rc<Entity> {
218        &self.entity
219    }
220
221    #[must_use]
222    pub(crate) fn tick_now(&self) -> u64 {
223        self.clock.tick_now().tick()
224    }
225
226    fn till_worker_entity(&self, worker_id: usize) -> &Rc<Entity> {
227        &self.till_worker_entities[worker_id]
228    }
229
230    fn kitchen_worker_entity(&self, worker_id: usize) -> &Rc<Entity> {
231        &self.kitchen_worker_entities[worker_id]
232    }
233
234    fn set_customer_phase(&self, customer_id: usize, phase: CustomerPhase) {
235        if let Some(recorder) = &self.recorder {
236            recorder.set_customer_phase(customer_id, phase);
237        }
238    }
239
240    pub(crate) fn record_snapshot(&self, message: String) {
241        if let Some(recorder) = &self.recorder {
242            recorder.record_snapshot(self.tick_now(), self, message);
243        }
244    }
245
246    pub(crate) fn customer_arrived(
247        &self,
248        customer: Rc<Customer>,
249        queue_len: usize,
250        join_probability: f64,
251    ) -> usize {
252        let customer_id = customer.id();
253        self.customers.borrow_mut()[customer_id] = Some(customer);
254        self.metrics.borrow_mut().arrivals += 1;
255
256        let message = format!(
257            "customer {customer_id} arrived, till queue len {queue_len}, join probability {join_probability:.2}",
258        );
259        self.record_snapshot(message.clone());
260        info!(self.entity; "{message}");
261        customer_id
262    }
263
264    pub(crate) fn customer_balked(&self, customer_id: usize) {
265        self.set_customer_phase(customer_id, CustomerPhase::Balked);
266        self.metrics.borrow_mut().balked += 1;
267
268        let message = format!("customer {customer_id} balked at the queue");
269        self.record_snapshot(message.clone());
270        info!(self.entity(); "{message}");
271    }
272
273    pub(crate) fn customer_gave_up_in_queue(&self, customer_id: usize) {
274        self.set_customer_phase(customer_id, CustomerPhase::GaveUpQueue);
275        self.metrics.borrow_mut().gave_up_queue += 1;
276
277        let message = format!("customer {customer_id} gave up waiting in the till queue");
278        self.record_snapshot(message.clone());
279        info!(self.entity(); "{message}");
280    }
281
282    pub(crate) fn customer_abandoned_after_order(&self, customer_id: usize) {
283        self.set_customer_phase(customer_id, CustomerPhase::Abandoned);
284        let customer = self.customer(customer_id);
285        let refund = customer
286            .payment_done_tick()
287            .map(|_| order_value(customer.order_index()).0);
288        let mut metrics = self.metrics.borrow_mut();
289        metrics.orders_abandoned += 1;
290        if let Some(refund) = refund {
291            metrics.revenue -= refund;
292        }
293        drop(metrics);
294
295        let message = if let Some(refund) = refund {
296            format!("customer {customer_id} abandoned after ordering and was refunded {refund:.2}")
297        } else {
298            format!("customer {customer_id} abandoned after ordering")
299        };
300        self.record_snapshot(message.clone());
301        warn!(self.entity(); "{message}");
302    }
303
304    pub(crate) fn customer_served(&self, customer_id: usize, joined_queue_tick: Option<u64>) {
305        self.set_customer_phase(customer_id, CustomerPhase::Served);
306        let tick = self.tick_now();
307        if let Some(joined_tick) = joined_queue_tick {
308            self.metrics.borrow_mut().visit_ticks_total += tick - joined_tick;
309        }
310
311        let message = format!("customer {customer_id} served");
312        self.record_snapshot(message.clone());
313        info!(self.entity(); "{message}");
314    }
315
316    #[must_use]
317    pub(crate) fn queue_len(&self) -> usize {
318        self.till_queue.len()
319    }
320
321    pub(crate) async fn enqueue_till(&self, customer_id: usize) -> SimResult {
322        self.set_customer_phase(customer_id, CustomerPhase::WaitingTill);
323        self.till_queue.push(customer_id).await?;
324        let len = self.till_queue.len();
325
326        let mut metrics = self.metrics.borrow_mut();
327        metrics.entered_queue += 1;
328        metrics.max_till_queue_len = metrics.max_till_queue_len.max(len);
329        drop(metrics);
330
331        let message = format!("customer {customer_id} joined the till queue");
332        self.record_snapshot(message.clone());
333        info!(self.entity(); "{message}");
334        Ok(())
335    }
336
337    #[must_use]
338    pub(crate) fn pop_till(&self) -> Option<usize> {
339        self.till_queue.pop_front()
340    }
341
342    #[must_use]
343    pub(crate) fn remove_till(&self, customer_id: usize) -> bool {
344        self.till_queue
345            .remove_where(|queued_id| *queued_id == customer_id)
346            .is_some()
347    }
348
349    pub(crate) async fn enqueue_kitchen(&self, customer_id: usize) -> SimResult {
350        self.set_customer_phase(customer_id, CustomerPhase::WaitingKitchen);
351        while self.kitchen_queue.is_full() {
352            self.kitchen_queue_changed().listen().await;
353        }
354
355        self.kitchen_queue.push(customer_id).await?;
356        let len = self.kitchen_queue.len();
357
358        let mut metrics = self.metrics.borrow_mut();
359        metrics.max_kitchen_queue_len = metrics.max_kitchen_queue_len.max(len);
360        drop(metrics);
361
362        let message = format!("customer {customer_id} entered the kitchen queue");
363        self.record_snapshot(message.clone());
364        info!(self.entity(); "{message}");
365        Ok(())
366    }
367
368    #[must_use]
369    pub(crate) fn pop_kitchen(&self) -> Option<usize> {
370        self.kitchen_queue.pop_front()
371    }
372
373    pub(crate) fn begin_till_service(
374        &self,
375        customer_id: usize,
376        joined_tick: Option<u64>,
377        worker_id: usize,
378        order_index: usize,
379    ) {
380        let tick = self.tick_now();
381        self.set_customer_phase(customer_id, CustomerPhase::AtTill);
382        self.active_till_workers
383            .set(self.active_till_workers.get() + 1);
384        let mut metrics = self.metrics.borrow_mut();
385        metrics.till_started += 1;
386        if let Some(joined_tick) = joined_tick {
387            metrics.till_wait_ticks_total += tick - joined_tick;
388        }
389        drop(metrics);
390
391        let customer_message = format!("customer {customer_id} reached the till");
392        info!(self.entity(); "{customer_message}");
393        self.record_snapshot(customer_message);
394        info!(
395            self.till_worker_entity(worker_id);
396            "started serving customer {customer_id} ({})",
397            order_name(order_index)
398        );
399    }
400
401    pub(crate) fn finish_till_service(&self, customer_id: usize, _worker_id: usize) {
402        self.active_till_workers
403            .set(self.active_till_workers.get().saturating_sub(1));
404        self.record_snapshot(format!("till work completed for customer {customer_id}"));
405    }
406
407    pub(crate) fn record_order_started(&self, customer_id: usize, worker_id: usize) {
408        let (revenue, _) = order_value(self.customer(customer_id).order_index());
409        let mut metrics = self.metrics.borrow_mut();
410        metrics.orders_started += 1;
411        metrics.revenue += revenue;
412        drop(metrics);
413
414        let message = format!("customer {customer_id} finished paying {revenue:.2}");
415        self.record_snapshot(message.clone());
416        info!(self.till_worker_entity(worker_id); "{message}");
417    }
418
419    pub(crate) fn begin_kitchen_service(
420        &self,
421        customer_id: usize,
422        worker_id: usize,
423        order_index: usize,
424    ) {
425        self.set_customer_phase(customer_id, CustomerPhase::PreparingFood);
426        self.active_kitchen_workers
427            .set(self.active_kitchen_workers.get() + 1);
428        let message = format!("kitchen started order for customer {customer_id}");
429        self.record_snapshot(message.clone());
430        info!(
431            self.kitchen_worker_entity(worker_id);
432            "started preparing customer {} ({})",
433            customer_id,
434            order_name(order_index)
435        );
436    }
437
438    pub(crate) fn finish_kitchen_service(&self, customer_id: usize, _worker_id: usize) {
439        self.active_kitchen_workers
440            .set(self.active_kitchen_workers.get().saturating_sub(1));
441        self.record_snapshot(format!(
442            "kitchen finished service work for customer {customer_id}"
443        ));
444    }
445
446    pub(crate) fn record_order_served(
447        &self,
448        customer_id: usize,
449        worker_id: usize,
450        payment_tick: Option<u64>,
451    ) {
452        let tick = self.tick_now();
453        let (_, ingredient_cost) = order_value(self.customer(customer_id).order_index());
454        let mut metrics = self.metrics.borrow_mut();
455        metrics.orders_served += 1;
456        metrics.ingredient_cost += ingredient_cost;
457        if let Some(payment_tick) = payment_tick {
458            metrics.kitchen_wait_ticks_total += tick - payment_tick;
459        }
460        drop(metrics);
461
462        self.set_customer_phase(customer_id, CustomerPhase::CollectingFood);
463        let message = format!("order ready for customer {customer_id}");
464        self.record_snapshot(message.clone());
465        info!(self.kitchen_worker_entity(worker_id); "{message}");
466    }
467
468    pub(crate) fn customer_collecting_food(&self, customer_id: usize) {
469        let message = format!("order ready for customer {customer_id}");
470        info!(self.entity(); "{message}");
471    }
472
473    pub(crate) fn mark_arrivals_complete(&self) -> SimResult {
474        self.arrivals_complete.set(true);
475        let message = "all arrivals processed".to_string();
476        self.record_snapshot(message.clone());
477        info!(self.entity; "{message}");
478        Ok(())
479    }
480
481    pub(crate) fn mark_closed(&self) -> SimResult {
482        self.closed_seen.set(true);
483        let message = "restaurant closed".to_string();
484        self.record_snapshot(message.clone());
485        warn!(self.entity; "{message}");
486        self.closed.notify()
487    }
488
489    fn record_removed_from_till_at_close(&self, customer_id: usize) {
490        self.set_customer_phase(customer_id, CustomerPhase::Abandoned);
491        let message = format!("customer {customer_id} removed from till queue at close");
492        self.record_snapshot(message.clone());
493        warn!(self.entity(); "{message}");
494    }
495
496    #[must_use]
497    pub(crate) fn can_kitchen_exit(&self) -> bool {
498        self.arrivals_complete.get()
499            && self.till_queue.is_empty()
500            && self.kitchen_queue.is_empty()
501            && self.active_till_workers.get() == 0
502    }
503}
504
505#[derive(Debug, Clone)]
506pub struct RunSummary {
507    pub staffing: Staffing,
508    pub arrivals: usize,
509    pub balked: usize,
510    pub gave_up_queue: usize,
511    pub served: usize,
512    pub abandoned: usize,
513    pub revenue: f64,
514    pub ingredient_cost: f64,
515    pub salary_cost: f64,
516    pub profit: f64,
517    pub avg_till_wait: f64,
518    pub avg_kitchen_wait: f64,
519    pub avg_visit: f64,
520    pub max_till_queue_len: usize,
521    pub max_kitchen_queue_len: usize,
522    pub finish_tick: u64,
523}
524
525impl RunSummary {
526    pub fn print_table_header() {
527        println!(
528            "{:>4} {:>7} {:>7} {:>8} {:>8} {:>10} {:>10} {:>9} {:>9} {:>10}",
529            "Till",
530            "Kitchen",
531            "Served",
532            "Balked",
533            "GaveUp",
534            "Revenue",
535            "Costs",
536            "Profit",
537            "Finish h",
538            "Max Queue"
539        );
540    }
541
542    pub fn print_table_row(&self) {
543        let costs = self.ingredient_cost + self.salary_cost;
544        println!(
545            "{:>4} {:>7} {:>7} {:>8} {:>8} {:>10.2} {:>10.2} {:>9.2} {:>9.2} {:>4}/{:<4}",
546            self.staffing.till,
547            self.staffing.kitchen,
548            self.served,
549            self.balked,
550            self.gave_up_queue,
551            self.revenue,
552            costs,
553            self.profit,
554            self.finish_tick as f64 / 3600.0,
555            self.max_till_queue_len,
556            self.max_kitchen_queue_len
557        );
558    }
559
560    pub fn print_best_summary(&self, day_ticks: u64) {
561        println!("Best configuration: {}", self.staffing);
562        println!(
563            "Served {} of {} arrivals, balked {}, gave up in queue {}, abandoned after ordering {}.",
564            self.served, self.arrivals, self.balked, self.gave_up_queue, self.abandoned
565        );
566        println!(
567            "Revenue {:.2}, ingredient cost {:.2}, salary cost {:.2}, profit {:.2}.",
568            self.revenue, self.ingredient_cost, self.salary_cost, self.profit
569        );
570        println!(
571            "Average waits: till {:.1}s, kitchen {:.1}s, full visit {:.1}s.",
572            self.avg_till_wait, self.avg_kitchen_wait, self.avg_visit
573        );
574        if self.finish_tick > day_ticks {
575            println!(
576                "Work completes {:.1} hours after opening, or {:.1} hours after close.",
577                self.finish_tick as f64 / 3600.0,
578                (self.finish_tick - day_ticks) as f64 / 3600.0
579            );
580        }
581    }
582}
583
584pub struct ScenarioResult {
585    pub summary: RunSummary,
586    pub recording: Option<RecordedSimulation>,
587}
588
589pub fn run_sweep(
590    config: &RestaurantConfig,
591    till_range: RangeInclusive<usize>,
592    kitchen_range: RangeInclusive<usize>,
593    tracker: &Tracker,
594) -> Result<(Vec<CustomerPlan>, Vec<RunSummary>), SimError> {
595    let demand = generate_demand(config);
596    let mut results = Vec::new();
597
598    for till in till_range {
599        for kitchen in kitchen_range.clone() {
600            let staffing = Staffing { till, kitchen };
601            results.push(run_configuration(config, &demand, staffing, false, tracker)?.summary);
602        }
603    }
604
605    results.sort_by(|a, b| b.profit.total_cmp(&a.profit));
606    Ok((demand, results))
607}
608
609pub fn run_recorded_scenario(
610    config: &RestaurantConfig,
611    staffing: Staffing,
612) -> Result<RecordedSimulation, SimError> {
613    let demand = generate_demand(config);
614    let tracker = dev_null_tracker();
615    let result = run_configuration(config, &demand, staffing, true, &tracker)?;
616    result
617        .recording
618        .ok_or_else(|| SimError("expected recorded simulation".to_string()))
619}
620
621pub fn run_configuration(
622    config: &RestaurantConfig,
623    demand: &[CustomerPlan],
624    staffing: Staffing,
625    record_timeline: bool,
626    tracker: &Tracker,
627) -> Result<ScenarioResult, SimError> {
628    let mut engine = Engine::new(tracker);
629
630    let entities = build_entities(&engine, staffing);
631    let clock = engine.clock_hz(1.0);
632    let recorder = record_timeline.then(|| Rc::new(Recorder::new(demand.len())));
633    let restaurant_entity = entities.restaurant.clone();
634    let till_workers = entities.till_workers.clone();
635    let kitchen_workers = entities.kitchen_workers.clone();
636    let restaurant = Rc::new(Restaurant::new(
637        clock.clone(),
638        entities,
639        demand.len(),
640        config.max_kitchen_queue_len,
641        recorder.clone(),
642    ));
643    let demand: Rc<Vec<CustomerPlan>> = Rc::new(demand.to_vec());
644
645    if staffing.kitchen == 0 || staffing.till == 0 {
646        return sim_error!("Invalid configuration with 0 staff on either till or in the kitchen");
647    }
648
649    restaurant.record_snapshot("simulation initialised".to_string());
650    info!(
651        restaurant_entity;
652        "starting scenario with {} customer plans, till staff {}, kitchen staff {}",
653        demand.len(),
654        staffing.till,
655        staffing.kitchen
656    );
657
658    spawn_arrivals(&engine, &clock, *config, restaurant.clone(), demand.clone());
659
660    for (worker_id, _) in till_workers.iter().enumerate() {
661        spawn_till_worker(&engine, &clock, *config, worker_id, restaurant.clone());
662    }
663    for (worker_id, _) in kitchen_workers.iter().enumerate() {
664        spawn_kitchen_worker(&engine, &clock, *config, worker_id, restaurant.clone());
665    }
666
667    spawn_close_kitchen(&engine, &clock, config.day_ticks, restaurant.clone());
668
669    engine.run()?;
670
671    let metrics = restaurant.metrics.borrow().clone();
672    let finish_tick = clock.tick_now().tick();
673    let paid_hours = config.paid_hours() as f64;
674    let salary_cost = paid_hours
675        * (staffing.till as f64 * config.till_salary_per_hour
676            + staffing.kitchen as f64 * config.kitchen_salary_per_hour);
677    let profit = metrics.revenue - metrics.ingredient_cost - salary_cost;
678    let served = metrics.orders_served.max(1) as f64;
679    let till_started = metrics.till_started.max(1) as f64;
680
681    let summary = RunSummary {
682        staffing,
683        arrivals: metrics.arrivals,
684        balked: metrics.balked,
685        gave_up_queue: metrics.gave_up_queue,
686        served: metrics.orders_served,
687        abandoned: metrics.orders_abandoned,
688        revenue: metrics.revenue,
689        ingredient_cost: metrics.ingredient_cost,
690        salary_cost,
691        profit,
692        avg_till_wait: metrics.till_wait_ticks_total as f64 / till_started,
693        avg_kitchen_wait: metrics.kitchen_wait_ticks_total as f64 / served,
694        avg_visit: metrics.visit_ticks_total as f64 / served,
695        max_till_queue_len: metrics.max_till_queue_len,
696        max_kitchen_queue_len: metrics.max_kitchen_queue_len,
697        finish_tick,
698    };
699
700    let recording = recorder.map(|recorder| {
701        recorder.finish(
702            staffing,
703            config.opening_time,
704            config.day_ticks,
705            summary.clone(),
706            demand.len(),
707        )
708    });
709    Ok(ScenarioResult { summary, recording })
710}
711
712fn spawn_close_kitchen(engine: &Engine, clock: &Clock, day_ticks: u64, restaurant: Rc<Restaurant>) {
713    let clock = clock.clone();
714    engine.spawn(async move {
715        clock.wait_ticks(day_ticks).await;
716        restaurant.mark_closed()?;
717        finish_till_queue_at_close(&restaurant);
718
719        Ok(())
720    });
721}
722
723fn finish_till_queue_at_close(restaurant: &Restaurant) {
724    while let Some(customer_id) = restaurant.pop_till() {
725        restaurant.record_removed_from_till_at_close(customer_id);
726        restaurant
727            .customer(customer_id)
728            .notify_outcome(CustomerOutcome::KitchenClosed);
729    }
730}