vpp_sim/
runner.rs

1use crate::devices::{BaseLoad, Battery, Device, DeviceContext, EvCharger, SolarPv};
2use crate::forecast::NaiveForecast;
3use crate::scenario::ScenarioConfig;
4use crate::sim::clock::Clock;
5use crate::sim::controller::NaiveRtController;
6use crate::sim::event::DemandResponseEvent;
7use crate::sim::feeder::Feeder;
8use crate::sim::schedule::DayAheadSchedule;
9use crate::telemetry::TelemetryRow;
10
11pub struct SimulationKpis {
12    pub rmse_tracking_kw: f32,
13    pub curtailment_pct: f32,
14    pub feeder_peak_load_kw: f32,
15}
16
17pub struct SimulationResult {
18    pub telemetry: Vec<TelemetryRow>,
19    pub kpis: SimulationKpis,
20}
21
22pub fn run_scenario(config: &ScenarioConfig, print_readable_log: bool) -> SimulationResult {
23    let houses = config.houses as f32;
24    let steps_per_day = config.steps_per_day;
25    let dt_hr = 24.0 / steps_per_day as f32;
26    let mut clock = Clock::new(steps_per_day); // Simulate 1 day
27
28    let mut load = BaseLoad::new(
29        0.8 * houses,  /* base_kw */
30        0.7 * houses,  /* amp_kw */
31        1.2,           /* phase_rad */
32        0.05,          /* noise_std */
33        steps_per_day, /* steps_per_day */
34        config.seed,   /* seed */
35    );
36
37    let baseload_device = load.device_type();
38    let mut baseline_load = BaseLoad::new(
39        0.8 * houses,
40        0.7 * houses,
41        1.2,
42        0.05,
43        steps_per_day,
44        config.seed,
45    );
46    let mut baseline = Vec::with_capacity(steps_per_day);
47    for t in 0..steps_per_day {
48        baseline.push(baseline_load.power_kw(&DeviceContext::new(t)));
49    }
50    let forecaster = NaiveForecast;
51    let load_forecast = forecaster.forecast(&baseline, steps_per_day);
52    let target_schedule = DayAheadSchedule::flat_target(&load_forecast);
53
54    let mut pv = SolarPv::new(
55        config.solar_kw_peak_per_house * houses, /* kw_peak */
56        steps_per_day,                           /* steps_per_day */
57        6,                                       /* sunrise_idx (6 AM) */
58        18,                                      /* sunset_idx (6 PM) */
59        0.05,                                    /* noise_std */
60        config.seed.wrapping_add(1),             /* seed */
61    );
62
63    let solar_device = pv.device_type();
64
65    let mut battery = Battery::new(
66        10.0 * houses, /* capacity_kwh */
67        0.5,           /* initial_soc */
68        5.0 * houses,  /* max_charge_kw */
69        5.0 * houses,  /* max_discharge_kw */
70        0.95,          /* eta_c */
71        0.95,          /* eta_d */
72        steps_per_day, /* steps_per_day */
73    );
74
75    let battery_device = battery.device_type();
76    let mut ev = EvCharger::new(
77        7.2 * houses,                /* max_charge_kw */
78        steps_per_day,               /* steps_per_day */
79        4.0 * houses,                /* demand_kwh_min */
80        14.0 * houses,               /* demand_kwh_max */
81        3,                           /* dwell_steps_min */
82        10,                          /* dwell_steps_max */
83        config.seed.wrapping_add(2), /* seed */
84    );
85    let ev_device = ev.device_type();
86
87    let mut feeder = Feeder::with_limits(
88        "MainFeeder",
89        config.feeder_kw,       /* max_import_kw */
90        config.feeder_kw * 0.8, /* max_export_kw */
91    );
92
93    let dr_event = DemandResponseEvent::new(
94        config.dr_start_step,
95        config.dr_end_step,
96        config.dr_reduction_kw_per_house * houses,
97    );
98
99    let controller = NaiveRtController;
100
101    let mut telemetry = Vec::with_capacity(steps_per_day);
102    let mut tracking_error_sq_sum = 0.0_f32;
103    let mut tracking_error_count = 0_usize;
104    let mut requested_curtailment_sum_kw = 0.0_f32;
105    let mut achieved_curtailment_sum_kw = 0.0_f32;
106    let mut feeder_peak_load_kw = 0.0_f32;
107
108    clock.run(|t| {
109        let context = DeviceContext::new(t);
110
111        let base_demand_kw_raw = load.power_kw(&context);
112        let forecast_kw = load_forecast[context.timestep];
113        let target_kw = target_schedule[context.timestep];
114        let solar_kw = pv.power_kw(&context);
115        let ev_requested_kw = ev.requested_power_kw(&context);
116
117        let dr_requested_kw = dr_event.requested_reduction_at_kw(t);
118        let (base_demand_kw, ev_after_dr_kw, dr_achieved_kw) = controller.apply_demand_response_kw(
119            base_demand_kw_raw,
120            ev_requested_kw,
121            dr_requested_kw,
122        );
123
124        let net_fixed_kw = base_demand_kw - solar_kw;
125        let ev_capped_kw = controller.capped_flexible_load_kw(
126            net_fixed_kw,
127            ev_after_dr_kw,
128            feeder.max_import_kw(),
129            battery.max_discharge_kw,
130        );
131        let ev_context = DeviceContext::with_setpoint(context.timestep, ev_capped_kw);
132        let ev_kw = ev.power_kw(&ev_context);
133
134        let net_without_battery = net_fixed_kw + ev_kw;
135        let battery_setpoint_kw = controller.constrained_battery_setpoint_kw(
136            net_without_battery,
137            target_kw,
138            feeder.max_import_kw(),
139            feeder.max_export_kw(),
140            battery.max_charge_kw,
141            battery.max_discharge_kw,
142        );
143        let battery_context = DeviceContext::with_setpoint(context.timestep, battery_setpoint_kw);
144
145        let battery_kw = battery.power_kw(&battery_context);
146        feeder.reset();
147        feeder.add_net_kw(base_demand_kw);
148        feeder.add_net_kw(ev_kw);
149        feeder.add_net_kw(-solar_kw);
150        feeder.add_net_kw(-battery_kw);
151        let feeder_kw = feeder.net_kw();
152        let tracking_error_kw = feeder_kw - target_kw;
153        let feeder_name = feeder.name();
154
155        tracking_error_sq_sum += tracking_error_kw * tracking_error_kw;
156        tracking_error_count += 1;
157        requested_curtailment_sum_kw += dr_requested_kw;
158        achieved_curtailment_sum_kw += dr_achieved_kw;
159        feeder_peak_load_kw = feeder_peak_load_kw.max(feeder_kw);
160
161        let row = TelemetryRow {
162            timestep: t,
163            time_hr: t as f32 * dt_hr,
164            target_kw,
165            feeder_kw,
166            tracking_error_kw,
167            baseload_kw: base_demand_kw,
168            solar_kw,
169            ev_requested_kw,
170            ev_dispatched_kw: ev_kw,
171            battery_kw,
172            battery_soc: battery.soc,
173            dr_requested_kw,
174            dr_achieved_kw,
175            limit_ok: feeder.within_limits(),
176        };
177        telemetry.push(row);
178
179        let soc = battery.soc * 100.0;
180        if print_readable_log {
181            println!(
182                "Time (Hr) {t}: {baseload_device}={base_demand_kw:.2} kW, \
183                RawBase={base_demand_kw_raw:.2} kW, \
184                Forecast={forecast_kw:.2} kW, \
185                Target={target_kw:.2} kW, \
186                {solar_device}={solar_kw:.2} kW, \
187                {ev_device}={ev_kw:.2} kW (Req={ev_requested_kw:.2}, DR={ev_after_dr_kw:.2}, Cap={ev_capped_kw:.2}), \
188                {battery_device}={battery_kw:.2} kW (SoC={soc:.1}%), \
189                {feeder_name}={feeder_kw:.2} kW, \
190                Error={tracking_error_kw:.2} kW, \
191                DR(req={dr_requested_kw:.2}, done={dr_achieved_kw:.2}), \
192                LimitOK={}",
193                feeder.within_limits()
194            );
195        }
196    });
197
198    let rmse_tracking_kw = if tracking_error_count > 0 {
199        (tracking_error_sq_sum / tracking_error_count as f32).sqrt()
200    } else {
201        0.0
202    };
203
204    let curtailment_pct = if requested_curtailment_sum_kw > 0.0 {
205        100.0 * achieved_curtailment_sum_kw / requested_curtailment_sum_kw
206    } else {
207        0.0
208    };
209
210    SimulationResult {
211        telemetry,
212        kpis: SimulationKpis {
213            rmse_tracking_kw,
214            curtailment_pct,
215            feeder_peak_load_kw,
216        },
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::run_scenario;
223    use crate::scenario::ScenarioConfig;
224    use crate::telemetry::write_telemetry_csv;
225
226    #[test]
227    fn same_scenario_and_seed_is_deterministic() {
228        let scenario = ScenarioConfig {
229            houses: 3,
230            feeder_kw: 40.0,
231            seed: 777,
232            steps_per_day: 24,
233            ..ScenarioConfig::default()
234        };
235
236        let run_a = run_scenario(&scenario, false);
237        let run_b = run_scenario(&scenario, false);
238
239        let mut out_a = Vec::new();
240        write_telemetry_csv(&mut out_a, &run_a.telemetry).expect("first export should succeed");
241
242        let mut out_b = Vec::new();
243        write_telemetry_csv(&mut out_b, &run_b.telemetry).expect("second export should succeed");
244
245        assert_eq!(out_a, out_b);
246    }
247}