oxigrid 0.1.2

Pure Rust Energy Systems Simulation & Optimization Library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
//! Energy Community Optimization — peer-to-peer (P2P) local energy trading.
//!
//! Models a community of prosumers (with solar PV and optional batteries) that
//! can trade surplus electricity among themselves before resorting to grid
//! import/export. Supports four P2P price formation mechanisms:
//!
//! | Method | Description |
//! |--------|-------------|
//! | `MidMarket` | Midpoint of grid import and export tariff |
//! | `BilateralContract` | Fixed negotiated price |
//! | `AuctionClearing` | Simple uniform-price auction |
//! | `MeritOrder` | Sorted bid/ask merit-order matching |
//!
//! # Algorithm (per hour)
//!
//! 1. For each member compute net surplus (solar − load) or deficit.
//! 2. Integrate battery: charge on surplus, discharge on deficit (greedy SoC).
//! 3. Match surpluses to deficits via the chosen P2P price method.
//! 4. Any remaining deficit: grid import; any remaining surplus: grid export.
//!
//! # References
//! - Parag & Sovacool, "Electricity market design for the prosumer era",
//!   *Nature Energy* 1, 16032 (2016).
//! - Zhang et al., "Peer-to-peer energy trading in a microgrid",
//!   *Applied Energy* 220 (2018).

use serde::{Deserialize, Serialize};
use thiserror::Error;

// ── Error ─────────────────────────────────────────────────────────────────────

/// Errors produced by the energy community optimizer.
#[derive(Debug, Error)]
pub enum CommunityError {
    /// Community has no members.
    #[error("community has no members")]
    NoMembers,

    /// Member load/solar profile length does not match n_hours.
    #[error("member {id} profile length {got} does not match n_hours {expected}")]
    ProfileLengthMismatch {
        id: usize,
        got: usize,
        expected: usize,
    },

    /// Tariff vector length does not match n_hours.
    #[error("tariff vector length {got} does not match n_hours {expected}")]
    TariffLengthMismatch { got: usize, expected: usize },

    /// Invalid parameter.
    #[error("invalid parameter: {0}")]
    InvalidParameter(String),
}

// ── P2P price method ──────────────────────────────────────────────────────────

/// Method for determining the peer-to-peer transaction price.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum P2pPriceMethod {
    /// Midpoint of grid import and export tariff \[$/kWh\].
    MidMarket,
    /// Fixed bilateral contract price \[$/kWh\].
    BilateralContract {
        /// Agreed bilateral price \[$/kWh\].
        price: f64,
    },
    /// Uniform-price auction: clearing price = average of matched bid/ask.
    AuctionClearing,
    /// Merit-order matching: sellers sorted ascending, buyers descending by price.
    MeritOrder,
}

// ── Community configuration ───────────────────────────────────────────────────

/// Global configuration for the energy community optimizer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyCommunityConfig {
    /// Number of members in the community.
    pub n_members: usize,
    /// Length of each time interval \[h\] (typically 1.0 for hourly).
    pub dt_hours: f64,
    /// Number of hourly intervals in the planning horizon.
    pub n_hours: usize,
    /// Grid import tariff at each interval \[$/MWh\] (converted to $/kWh internally).
    pub grid_import_tariff: Vec<f64>,
    /// Grid export tariff at each interval \[$/MWh\] (typically < import).
    pub grid_export_tariff: Vec<f64>,
    /// P2P transaction price formation method.
    pub p2p_price_method: P2pPriceMethod,
    /// Distribution network charge applied to P2P energy \[$/MWh\].
    pub network_charge_usd_per_mwh: f64,
}

// ── Community member ──────────────────────────────────────────────────────────

/// A prosumer member of the energy community.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityMember {
    /// Unique member identifier.
    pub id: usize,
    /// Human-readable member name.
    pub name: String,
    /// Hourly electrical load profile \[kW\].
    pub load_kw: Vec<f64>,
    /// Hourly solar PV generation profile \[kW\] (zero if no solar).
    pub solar_kw: Vec<f64>,
    /// Battery energy capacity \[kWh\] (`None` if no battery).
    pub battery_kwh: Option<f64>,
    /// Battery power rating \[kW\] (`None` if no battery).
    pub battery_kw: Option<f64>,
    /// Initial battery state of charge \[kWh\].
    pub soc: f64,
    /// Maximum grid import power \[kW\].
    pub max_import_kw: f64,
    /// Maximum grid export power \[kW\].
    pub max_export_kw: f64,
}

// ── Transactions ──────────────────────────────────────────────────────────────

/// A P2P energy transaction between two community members.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct P2pTransaction {
    /// Hour index of the transaction.
    pub hour: usize,
    /// Selling member identifier.
    pub seller_id: usize,
    /// Buying member identifier.
    pub buyer_id: usize,
    /// Energy transacted \[kWh\].
    pub energy_kwh: f64,
    /// Transaction price \[$/kWh\].
    pub price_usd_per_kwh: f64,
    /// Total transaction value \[USD\].
    pub total_usd: f64,
}

// ── Per-member result ─────────────────────────────────────────────────────────

/// Economic and energy outcome for a single community member.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberResult {
    /// Member identifier.
    pub member_id: usize,
    /// Total energy imported from the grid \[kWh\].
    pub energy_imported_kwh: f64,
    /// Total energy exported to the grid \[kWh\].
    pub energy_exported_kwh: f64,
    /// Total energy bought via P2P \[kWh\].
    pub p2p_bought_kwh: f64,
    /// Total energy sold via P2P \[kWh\].
    pub p2p_sold_kwh: f64,
    /// Net grid electricity bill \[USD\] (import cost − export revenue).
    pub grid_bill_usd: f64,
    /// Net P2P revenue (negative = expenditure) \[USD\].
    pub p2p_revenue_usd: f64,
    /// Net total bill (grid + P2P + network charges) \[USD\].
    pub net_bill_usd: f64,
    /// Self-sufficiency: fraction of load met without grid import \[%\].
    pub self_sufficiency_pct: f64,
    /// Self-consumption: fraction of solar consumed locally \[%\].
    pub self_consumption_pct: f64,
}

// ── Community result ──────────────────────────────────────────────────────────

/// Aggregate optimization result for the whole energy community.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyCommunityResult {
    /// Per-member results.
    pub members: Vec<MemberResult>,
    /// All P2P transactions over the horizon.
    pub transactions: Vec<P2pTransaction>,
    /// Community-wide self-sufficiency \[%\].
    pub community_self_sufficiency_pct: f64,
    /// Total P2P energy volume traded \[kWh\].
    pub total_p2p_volume_kwh: f64,
    /// Total savings versus full retail import \[USD\].
    pub total_savings_vs_retail_usd: f64,
    /// Peak community grid import in any single hour \[kW\].
    pub peak_grid_import_kw: f64,
}

// ── Optimizer ─────────────────────────────────────────────────────────────────

/// Energy community P2P optimizer.
pub struct EnergyCommunityOptimizer {
    config: EnergyCommunityConfig,
    members: Vec<CommunityMember>,
}

impl EnergyCommunityOptimizer {
    /// Create a new optimizer with the given configuration.
    pub fn new(config: EnergyCommunityConfig) -> Self {
        Self {
            config,
            members: Vec::new(),
        }
    }

    /// Register a community member.
    pub fn add_member(&mut self, member: CommunityMember) {
        self.members.push(member);
    }

    /// Run the P2P energy community optimization.
    pub fn optimize(&self) -> Result<EnergyCommunityResult, CommunityError> {
        let n = self.config.n_hours;
        let dt = self.config.dt_hours;

        if self.members.is_empty() {
            return Err(CommunityError::NoMembers);
        }
        if self.config.grid_import_tariff.len() != n {
            return Err(CommunityError::TariffLengthMismatch {
                got: self.config.grid_import_tariff.len(),
                expected: n,
            });
        }
        if self.config.grid_export_tariff.len() != n {
            return Err(CommunityError::TariffLengthMismatch {
                got: self.config.grid_export_tariff.len(),
                expected: n,
            });
        }

        // Validate member profiles
        for m in &self.members {
            if m.load_kw.len() != n {
                return Err(CommunityError::ProfileLengthMismatch {
                    id: m.id,
                    got: m.load_kw.len(),
                    expected: n,
                });
            }
            if m.solar_kw.len() != n {
                return Err(CommunityError::ProfileLengthMismatch {
                    id: m.id,
                    got: m.solar_kw.len(),
                    expected: n,
                });
            }
        }

        let nm = self.members.len();

        // State: battery SoC per member [kWh]
        let mut soc: Vec<f64> = self.members.iter().map(|m| m.soc).collect();

        // Accumulators per member
        let mut energy_imported = vec![0.0f64; nm];
        let mut energy_exported = vec![0.0f64; nm];
        let mut p2p_bought = vec![0.0f64; nm];
        let mut p2p_sold = vec![0.0f64; nm];
        let mut grid_bill = vec![0.0f64; nm];
        let mut p2p_revenue = vec![0.0f64; nm];
        let mut total_load_kwh = vec![0.0f64; nm];
        let mut total_solar_kwh = vec![0.0f64; nm];
        let mut total_self_consumed = vec![0.0f64; nm];

        let mut transactions: Vec<P2pTransaction> = Vec::new();
        let mut peak_grid_import_kw = 0.0f64;
        let mut total_savings = 0.0f64;
        let mut total_p2p_volume = 0.0f64;

        // Network charge in $/kWh
        let net_charge_per_kwh = self.config.network_charge_usd_per_mwh / 1000.0;

        for t in 0..n {
            let import_tariff = self.config.grid_import_tariff[t] / 1000.0; // $/kWh
            let export_tariff = self.config.grid_export_tariff[t] / 1000.0; // $/kWh

            // P2P price for this hour
            let p2p_price = self.compute_p2p_price(import_tariff, export_tariff);

            // Step 1: compute net power after solar and battery for each member [kW]
            let mut net: Vec<f64> = Vec::with_capacity(nm);
            for (i, member) in self.members.iter().enumerate() {
                let load = member.load_kw[t];
                let solar = member.solar_kw[t];
                total_load_kwh[i] += load * dt;
                total_solar_kwh[i] += solar * dt;

                let mut balance = solar - load; // positive = surplus, negative = deficit

                // Step 2: greedy battery dispatch
                if let (Some(batt_kwh), Some(batt_kw)) = (member.battery_kwh, member.battery_kw) {
                    if balance > 0.0 {
                        // Charge battery with surplus
                        let can_charge = (batt_kwh - soc[i]).min(batt_kw * dt).max(0.0);
                        let charge = balance.min(can_charge / dt) * dt;
                        soc[i] = (soc[i] + charge).min(batt_kwh);
                        balance -= charge / dt;
                    } else {
                        // Discharge battery to cover deficit
                        let can_discharge = soc[i].min(batt_kw * dt).max(0.0);
                        let discharge = (-balance).min(can_discharge / dt) * dt;
                        soc[i] = (soc[i] - discharge).max(0.0);
                        balance += discharge / dt;
                    }
                }

                // Track self-consumption: solar used locally (load - deficit that went to grid)
                let self_cons = (solar - balance.max(0.0)).clamp(0.0, solar);
                total_self_consumed[i] += self_cons * dt;

                net.push(balance);
            }

            // Step 3: P2P matching
            // Separate into surpluses and deficits
            let mut surpluses: Vec<(usize, f64)> = net
                .iter()
                .enumerate()
                .filter(|(_, &b)| b > 0.0)
                .map(|(i, &b)| (i, b))
                .collect();
            let mut deficits: Vec<(usize, f64)> = net
                .iter()
                .enumerate()
                .filter(|(_, &b)| b < 0.0)
                .map(|(i, &b)| (i, -b))
                .collect();

            // Sort surpluses desc, deficits desc for merit-order matching
            surpluses.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
            deficits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

            // Match surplus to deficit greedily
            let mut s_ptr = 0;
            let mut d_ptr = 0;
            let mut s_rem: Vec<f64> = surpluses.iter().map(|(_, kw)| *kw).collect();
            let mut d_rem: Vec<f64> = deficits.iter().map(|(_, kw)| *kw).collect();

            while s_ptr < surpluses.len() && d_ptr < deficits.len() {
                let (s_id, _) = surpluses[s_ptr];
                let (d_id, _) = deficits[d_ptr];
                let traded_kw = s_rem[s_ptr].min(d_rem[d_ptr]);
                let energy_kwh = traded_kw * dt;

                if energy_kwh > 1e-6 {
                    let total_usd = energy_kwh * p2p_price;
                    transactions.push(P2pTransaction {
                        hour: t,
                        seller_id: self.members[s_id].id,
                        buyer_id: self.members[d_id].id,
                        energy_kwh,
                        price_usd_per_kwh: p2p_price,
                        total_usd,
                    });

                    p2p_sold[s_id] += energy_kwh;
                    p2p_bought[d_id] += energy_kwh;

                    // Revenue and cost for P2P
                    let seller_revenue = total_usd - energy_kwh * net_charge_per_kwh;
                    let buyer_cost = total_usd + energy_kwh * net_charge_per_kwh;
                    p2p_revenue[s_id] += seller_revenue;
                    p2p_revenue[d_id] -= buyer_cost;

                    total_p2p_volume += energy_kwh;

                    // Savings vs retail: buyer saves (import_tariff - p2p_price) per kWh
                    let savings = energy_kwh * (import_tariff - p2p_price - net_charge_per_kwh);
                    total_savings += savings.max(0.0);
                }

                s_rem[s_ptr] -= traded_kw;
                d_rem[d_ptr] -= traded_kw;

                if s_rem[s_ptr] < 1e-9 {
                    s_ptr += 1;
                }
                if d_rem[d_ptr] < 1e-9 {
                    d_ptr += 1;
                }
            }

            // Step 4: residual grid interaction
            let mut community_import_kw = 0.0f64;

            // Remaining surpluses → grid export
            for (si, (mid, _)) in surpluses.iter().enumerate() {
                let rem_kw = s_rem[si];
                if rem_kw > 1e-9 {
                    let export_kw = rem_kw.min(self.members[*mid].max_export_kw);
                    let export_kwh = export_kw * dt;
                    energy_exported[*mid] += export_kwh;
                    grid_bill[*mid] -= export_kwh * export_tariff;
                }
            }

            // Remaining deficits → grid import
            for (di, (mid, _)) in deficits.iter().enumerate() {
                let rem_kw = d_rem[di];
                if rem_kw > 1e-9 {
                    let import_kw = rem_kw.min(self.members[*mid].max_import_kw);
                    let import_kwh = import_kw * dt;
                    energy_imported[*mid] += import_kwh;
                    grid_bill[*mid] += import_kwh * import_tariff;
                    community_import_kw += import_kw;
                }
            }

            // Also import for pure-load members with no surplus/deficit matched
            for (i, &bal) in net.iter().enumerate() {
                if !surpluses.iter().any(|(si, _)| *si == i)
                    && !deficits.iter().any(|(di, _)| *di == i)
                {
                    if bal < -1e-9 {
                        let import_kw = (-bal).min(self.members[i].max_import_kw);
                        let import_kwh = import_kw * dt;
                        energy_imported[i] += import_kwh;
                        grid_bill[i] += import_kwh * import_tariff;
                        community_import_kw += import_kw;
                    } else if bal > 1e-9 {
                        let export_kw = bal.min(self.members[i].max_export_kw);
                        let export_kwh = export_kw * dt;
                        energy_exported[i] += export_kwh;
                        grid_bill[i] -= export_kwh * export_tariff;
                    }
                }
            }

            peak_grid_import_kw = peak_grid_import_kw.max(community_import_kw);
        }

        // Assemble member results
        let member_results: Vec<MemberResult> = (0..nm)
            .map(|i| {
                let member = &self.members[i];
                let net_bill = grid_bill[i] - p2p_revenue[i];
                let load_total = total_load_kwh[i];
                let solar_total = total_solar_kwh[i];

                // Self-sufficiency: how much of load was met without grid import
                let grid_imported = energy_imported[i];
                let self_sufficiency = if load_total > 0.0 {
                    ((load_total - grid_imported) / load_total * 100.0).clamp(0.0, 100.0)
                } else {
                    100.0
                };

                // Self-consumption: how much solar was used locally (not exported to grid)
                let self_cons_kwh = total_self_consumed[i];
                let self_consumption = if solar_total > 0.0 {
                    (self_cons_kwh / solar_total * 100.0).clamp(0.0, 100.0)
                } else {
                    100.0
                };

                MemberResult {
                    member_id: member.id,
                    energy_imported_kwh: energy_imported[i],
                    energy_exported_kwh: energy_exported[i],
                    p2p_bought_kwh: p2p_bought[i],
                    p2p_sold_kwh: p2p_sold[i],
                    grid_bill_usd: grid_bill[i],
                    p2p_revenue_usd: p2p_revenue[i],
                    net_bill_usd: net_bill,
                    self_sufficiency_pct: self_sufficiency,
                    self_consumption_pct: self_consumption,
                }
            })
            .collect();

        // Community self-sufficiency
        let total_community_load: f64 = total_load_kwh.iter().sum();
        let total_community_import: f64 = energy_imported.iter().sum();
        let community_self_sufficiency = if total_community_load > 0.0 {
            ((total_community_load - total_community_import) / total_community_load * 100.0)
                .clamp(0.0, 100.0)
        } else {
            100.0
        };

        Ok(EnergyCommunityResult {
            members: member_results,
            transactions,
            community_self_sufficiency_pct: community_self_sufficiency,
            total_p2p_volume_kwh: total_p2p_volume,
            total_savings_vs_retail_usd: total_savings,
            peak_grid_import_kw,
        })
    }

    /// Compute P2P price for one interval given grid tariffs \[$/kWh\].
    fn compute_p2p_price(&self, import_tariff: f64, export_tariff: f64) -> f64 {
        match &self.config.p2p_price_method {
            P2pPriceMethod::MidMarket => (import_tariff + export_tariff) / 2.0,
            P2pPriceMethod::BilateralContract { price } => *price / 1000.0, // $/MWh → $/kWh
            P2pPriceMethod::AuctionClearing => (import_tariff + export_tariff) / 2.0,
            P2pPriceMethod::MeritOrder => import_tariff * 0.6 + export_tariff * 0.4,
        }
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn default_config(n: usize) -> EnergyCommunityConfig {
        EnergyCommunityConfig {
            n_members: 0,
            dt_hours: 1.0,
            n_hours: n,
            grid_import_tariff: vec![200.0; n], // 200 $/MWh = 0.20 $/kWh
            grid_export_tariff: vec![80.0; n],  // 80 $/MWh = 0.08 $/kWh
            p2p_price_method: P2pPriceMethod::MidMarket,
            network_charge_usd_per_mwh: 20.0,
        }
    }

    fn make_load_only_member(id: usize, load_kw: f64, n: usize) -> CommunityMember {
        CommunityMember {
            id,
            name: format!("member_{id}"),
            load_kw: vec![load_kw; n],
            solar_kw: vec![0.0; n],
            battery_kwh: None,
            battery_kw: None,
            soc: 0.0,
            max_import_kw: 100.0,
            max_export_kw: 0.0,
        }
    }

    fn make_solar_member(id: usize, load_kw: f64, solar_kw: f64, n: usize) -> CommunityMember {
        CommunityMember {
            id,
            name: format!("solar_{id}"),
            load_kw: vec![load_kw; n],
            solar_kw: vec![solar_kw; n],
            battery_kwh: None,
            battery_kw: None,
            soc: 0.0,
            max_import_kw: 50.0,
            max_export_kw: 50.0,
        }
    }

    /// Without renewables, all load should be grid-imported.
    #[test]
    fn test_no_renewables_all_grid_import() {
        let n = 24;
        let config = default_config(n);
        let mut opt = EnergyCommunityOptimizer::new(config);
        opt.add_member(make_load_only_member(0, 5.0, n));
        opt.add_member(make_load_only_member(1, 3.0, n));

        let result = opt.optimize().expect("optimize");

        // No solar → no P2P
        assert!(
            result.total_p2p_volume_kwh < 1e-9,
            "P2P volume should be zero without renewables; got {:.4}",
            result.total_p2p_volume_kwh
        );

        // Both members fully import from grid
        for mr in &result.members {
            assert!(
                mr.energy_imported_kwh > 0.0,
                "Member {} should have imported from grid",
                mr.member_id
            );
            assert!(
                (mr.p2p_bought_kwh).abs() < 1e-9,
                "No P2P buying expected; member {} bought {:.4}",
                mr.member_id,
                mr.p2p_bought_kwh
            );
        }
    }

    /// Solar surplus should trigger P2P trading.
    #[test]
    fn test_solar_surplus_triggers_p2p_trading() {
        let n = 24;
        let config = default_config(n);
        let mut opt = EnergyCommunityOptimizer::new(config);
        // Solar member has large surplus
        opt.add_member(make_solar_member(0, 2.0, 10.0, n)); // 8 kW surplus
        opt.add_member(make_load_only_member(1, 5.0, n)); // deficit

        let result = opt.optimize().expect("optimize");

        assert!(
            result.total_p2p_volume_kwh > 0.0,
            "P2P trading should occur with solar surplus; volume={:.2}",
            result.total_p2p_volume_kwh
        );
        assert!(
            !result.transactions.is_empty(),
            "Transactions should be recorded"
        );
    }

    /// P2P should improve self-sufficiency vs no-P2P scenario.
    #[test]
    fn test_self_sufficiency_improves_with_p2p() {
        let n = 12;
        let config = default_config(n);
        let mut opt = EnergyCommunityOptimizer::new(config);
        opt.add_member(make_solar_member(0, 1.0, 8.0, n)); // solar prosumer
        opt.add_member(make_load_only_member(1, 4.0, n)); // pure consumer

        let result = opt.optimize().expect("optimize");

        // Community should be partially self-sufficient due to solar
        assert!(
            result.community_self_sufficiency_pct > 0.0,
            "Community should have some self-sufficiency; got {:.1}%",
            result.community_self_sufficiency_pct
        );

        // The buying member should have bought via P2P
        let buyer = result
            .members
            .iter()
            .find(|m| m.member_id == 1)
            .expect("member 1");
        assert!(
            buyer.p2p_bought_kwh > 0.0,
            "Consumer should have bought P2P energy; got {:.2}",
            buyer.p2p_bought_kwh
        );
    }

    /// Network charge must be included in buyer cost.
    #[test]
    fn test_network_charge_applied_to_p2p() {
        let n = 1;
        let mut config = default_config(n);
        config.network_charge_usd_per_mwh = 50.0; // significant charge
        let config_ref = config.clone();
        let mut opt = EnergyCommunityOptimizer::new(config);
        // Solar member: surplus 5 kW; load member: deficit 5 kW
        opt.add_member(make_solar_member(0, 0.0, 5.0, n));
        opt.add_member(make_load_only_member(1, 5.0, n));

        let result = opt.optimize().expect("optimize");

        // P2P should have occurred
        if result.total_p2p_volume_kwh > 0.0 {
            // Buyer's P2P cost should include the network charge
            let buyer_mr = result
                .members
                .iter()
                .find(|m| m.member_id == 1)
                .expect("member 1");
            // Seller revenue should be reduced by network charge
            let seller_mr = result
                .members
                .iter()
                .find(|m| m.member_id == 0)
                .expect("member 0");

            // Net charge $/kWh
            let net_chg = config_ref.network_charge_usd_per_mwh / 1000.0;
            let p2p_vol = result.total_p2p_volume_kwh;

            // Buyer paid at least net_charge * volume
            let buyer_net_payment = -buyer_mr.p2p_revenue_usd;
            assert!(
                buyer_net_payment >= net_chg * p2p_vol - 1e-9,
                "Buyer cost should include network charge; paid={buyer_net_payment:.4}, expected_nc_component={:.4}",
                net_chg * p2p_vol
            );
            let _ = seller_mr; // checked above implicitly
        }
    }

    /// Battery should reduce grid import compared to no battery.
    #[test]
    fn test_battery_reduces_grid_import() {
        let n = 24;
        // Without battery
        let config1 = default_config(n);
        let mut opt1 = EnergyCommunityOptimizer::new(config1);
        // Solar only during first 12 hours; load all 24 hours
        let mut solar = vec![0.0f64; n];
        for item in solar.iter_mut().take(12) {
            *item = 5.0;
        }
        opt1.add_member(CommunityMember {
            id: 0,
            name: "no_batt".into(),
            load_kw: vec![3.0; n],
            solar_kw: solar.clone(),
            battery_kwh: None,
            battery_kw: None,
            soc: 0.0,
            max_import_kw: 100.0,
            max_export_kw: 100.0,
        });
        let result1 = opt1.optimize().expect("optimize no_batt");

        // With battery
        let config2 = default_config(n);
        let mut opt2 = EnergyCommunityOptimizer::new(config2);
        opt2.add_member(CommunityMember {
            id: 0,
            name: "with_batt".into(),
            load_kw: vec![3.0; n],
            solar_kw: solar,
            battery_kwh: Some(20.0),
            battery_kw: Some(5.0),
            soc: 0.0,
            max_import_kw: 100.0,
            max_export_kw: 100.0,
        });
        let result2 = opt2.optimize().expect("optimize with_batt");

        let import_no_batt = result1.members[0].energy_imported_kwh;
        let import_with_batt = result2.members[0].energy_imported_kwh;

        assert!(
            import_with_batt <= import_no_batt + 1e-6,
            "Battery should not increase grid import; no_batt={import_no_batt:.2}, with_batt={import_with_batt:.2}"
        );
    }

    /// No-members error should be returned correctly.
    #[test]
    fn test_no_members_returns_error() {
        let config = default_config(24);
        let opt = EnergyCommunityOptimizer::new(config);
        let result = opt.optimize();
        assert!(
            matches!(result, Err(CommunityError::NoMembers)),
            "Expected NoMembers error"
        );
    }
}