use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CommunityError {
#[error("community has no members")]
NoMembers,
#[error("member {id} profile length {got} does not match n_hours {expected}")]
ProfileLengthMismatch {
id: usize,
got: usize,
expected: usize,
},
#[error("tariff vector length {got} does not match n_hours {expected}")]
TariffLengthMismatch { got: usize, expected: usize },
#[error("invalid parameter: {0}")]
InvalidParameter(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum P2pPriceMethod {
MidMarket,
BilateralContract {
price: f64,
},
AuctionClearing,
MeritOrder,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyCommunityConfig {
pub n_members: usize,
pub dt_hours: f64,
pub n_hours: usize,
pub grid_import_tariff: Vec<f64>,
pub grid_export_tariff: Vec<f64>,
pub p2p_price_method: P2pPriceMethod,
pub network_charge_usd_per_mwh: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityMember {
pub id: usize,
pub name: String,
pub load_kw: Vec<f64>,
pub solar_kw: Vec<f64>,
pub battery_kwh: Option<f64>,
pub battery_kw: Option<f64>,
pub soc: f64,
pub max_import_kw: f64,
pub max_export_kw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct P2pTransaction {
pub hour: usize,
pub seller_id: usize,
pub buyer_id: usize,
pub energy_kwh: f64,
pub price_usd_per_kwh: f64,
pub total_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberResult {
pub member_id: usize,
pub energy_imported_kwh: f64,
pub energy_exported_kwh: f64,
pub p2p_bought_kwh: f64,
pub p2p_sold_kwh: f64,
pub grid_bill_usd: f64,
pub p2p_revenue_usd: f64,
pub net_bill_usd: f64,
pub self_sufficiency_pct: f64,
pub self_consumption_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyCommunityResult {
pub members: Vec<MemberResult>,
pub transactions: Vec<P2pTransaction>,
pub community_self_sufficiency_pct: f64,
pub total_p2p_volume_kwh: f64,
pub total_savings_vs_retail_usd: f64,
pub peak_grid_import_kw: f64,
}
pub struct EnergyCommunityOptimizer {
config: EnergyCommunityConfig,
members: Vec<CommunityMember>,
}
impl EnergyCommunityOptimizer {
pub fn new(config: EnergyCommunityConfig) -> Self {
Self {
config,
members: Vec::new(),
}
}
pub fn add_member(&mut self, member: CommunityMember) {
self.members.push(member);
}
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,
});
}
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();
let mut soc: Vec<f64> = self.members.iter().map(|m| m.soc).collect();
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;
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; let export_tariff = self.config.grid_export_tariff[t] / 1000.0;
let p2p_price = self.compute_p2p_price(import_tariff, export_tariff);
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;
if let (Some(batt_kwh), Some(batt_kw)) = (member.battery_kwh, member.battery_kw) {
if balance > 0.0 {
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 {
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;
}
}
let self_cons = (solar - balance.max(0.0)).clamp(0.0, solar);
total_self_consumed[i] += self_cons * dt;
net.push(balance);
}
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();
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));
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;
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;
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;
}
}
let mut community_import_kw = 0.0f64;
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;
}
}
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;
}
}
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);
}
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];
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
};
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();
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,
})
}
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, P2pPriceMethod::AuctionClearing => (import_tariff + export_tariff) / 2.0,
P2pPriceMethod::MeritOrder => import_tariff * 0.6 + export_tariff * 0.4,
}
}
}
#[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], grid_export_tariff: vec![80.0; n], 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,
}
}
#[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");
assert!(
result.total_p2p_volume_kwh < 1e-9,
"P2P volume should be zero without renewables; got {:.4}",
result.total_p2p_volume_kwh
);
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
);
}
}
#[test]
fn test_solar_surplus_triggers_p2p_trading() {
let n = 24;
let config = default_config(n);
let mut opt = EnergyCommunityOptimizer::new(config);
opt.add_member(make_solar_member(0, 2.0, 10.0, n)); opt.add_member(make_load_only_member(1, 5.0, n));
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"
);
}
#[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)); opt.add_member(make_load_only_member(1, 4.0, n));
let result = opt.optimize().expect("optimize");
assert!(
result.community_self_sufficiency_pct > 0.0,
"Community should have some self-sufficiency; got {:.1}%",
result.community_self_sufficiency_pct
);
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
);
}
#[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; let config_ref = config.clone();
let mut opt = EnergyCommunityOptimizer::new(config);
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");
if result.total_p2p_volume_kwh > 0.0 {
let buyer_mr = result
.members
.iter()
.find(|m| m.member_id == 1)
.expect("member 1");
let seller_mr = result
.members
.iter()
.find(|m| m.member_id == 0)
.expect("member 0");
let net_chg = config_ref.network_charge_usd_per_mwh / 1000.0;
let p2p_vol = result.total_p2p_volume_kwh;
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; }
}
#[test]
fn test_battery_reduces_grid_import() {
let n = 24;
let config1 = default_config(n);
let mut opt1 = EnergyCommunityOptimizer::new(config1);
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");
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}"
);
}
#[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"
);
}
}