use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq)]
pub enum MarketError {
AssetNotFound(u64),
AlreadyListed(u64),
NotForSale(u64),
}
impl fmt::Display for MarketError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AssetNotFound(id) => write!(f, "asset {id} not found"),
Self::AlreadyListed(id) => write!(f, "asset {id} is already listed"),
Self::NotForSale(id) => write!(f, "asset {id} is not for sale"),
}
}
}
impl std::error::Error for MarketError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BatteryChemistry {
NmcLithiumIon,
LfpLithiumIron,
LtoLithiumTitanate,
NcaLithiumNickel,
LeadAcid,
SodiumIon,
}
impl BatteryChemistry {
pub fn second_life_price_per_kwh(self) -> f64 {
match self {
Self::NmcLithiumIon => 150.0,
Self::LfpLithiumIron => 120.0,
Self::LtoLithiumTitanate => 200.0,
Self::NcaLithiumNickel => 160.0,
Self::LeadAcid => 60.0,
Self::SodiumIon => 100.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BatteryApplication {
ElectricVehicle,
GridStorage,
Ups,
Portable,
SecondLife,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LifecycleEventType {
Manufactured,
Sold,
FirstUseStart,
SwapEvent,
SohAssessment {
soh_pct: f64,
},
SecondLifeStart,
Recycled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatteryLifecycleEvent {
pub timestamp: (usize, usize, usize),
pub event_type: LifecycleEventType,
pub value_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatteryAsset {
pub id: u64,
pub chemistry: BatteryChemistry,
pub original_capacity_kwh: f64,
pub current_capacity_kwh: f64,
pub current_soh_pct: f64,
pub cycle_count: f64,
pub calendar_age_years: f64,
pub original_application: BatteryApplication,
pub current_owner: usize,
pub history: Vec<BatteryLifecycleEvent>,
}
impl BatteryAsset {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: u64,
chemistry: BatteryChemistry,
original_capacity_kwh: f64,
current_soh_pct: f64,
cycle_count: f64,
calendar_age_years: f64,
original_application: BatteryApplication,
owner: usize,
) -> Self {
let current_capacity_kwh = original_capacity_kwh * current_soh_pct / 100.0;
Self {
id,
chemistry,
original_capacity_kwh,
current_capacity_kwh,
current_soh_pct,
cycle_count,
calendar_age_years,
original_application,
current_owner: owner,
history: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceOffer {
pub asset_id: u64,
pub asking_price_usd: f64,
pub application_fit: Vec<BatteryApplication>,
pub remaining_useful_life_years: f64,
pub warranty_kwh: f64,
}
fn days_to_civil(days: i64) -> (usize, usize, usize) {
let z: i64 = days + 719_468;
let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe: u32 = (z - era * 146_097) as u32;
let yoe: u32 = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let mut y: i64 = yoe as i64 + era * 400;
let doy: u32 = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp: u32 = (5 * doy + 2) / 153;
let d: u32 = doy - (153 * mp + 2) / 5 + 1;
let m: u32 = if mp < 10 { mp + 3 } else { mp - 9 };
if m <= 2 {
y += 1;
}
(y as usize, m as usize, d as usize)
}
fn current_civil_date() -> (usize, usize, usize) {
let secs: i64 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let days = secs / 86_400;
days_to_civil(days)
}
#[derive(Debug, Default, Clone)]
pub struct BatteryMarketplace {
assets: Vec<BatteryAsset>,
offers: Vec<MarketplaceOffer>,
transaction_history: Vec<(u64, usize, f64, f64)>,
}
impl BatteryMarketplace {
pub fn new() -> Self {
Self::default()
}
pub fn register_asset(&mut self, mut asset: BatteryAsset) -> u64 {
if asset.id == 0 {
asset.id = self.assets.len() as u64 + 1;
}
let id = asset.id;
self.assets.push(asset);
id
}
pub fn list_for_sale(
&mut self,
asset_id: u64,
asking_price_usd: f64,
) -> Result<(), MarketError> {
let asset = self
.assets
.iter()
.find(|a| a.id == asset_id)
.ok_or(MarketError::AssetNotFound(asset_id))?;
if self.offers.iter().any(|o| o.asset_id == asset_id) {
return Err(MarketError::AlreadyListed(asset_id));
}
let soh = asset.current_soh_pct;
let mut fit = Vec::new();
if soh >= 80.0 {
fit.push(BatteryApplication::ElectricVehicle);
fit.push(BatteryApplication::GridStorage);
fit.push(BatteryApplication::Ups);
} else if soh >= 70.0 {
fit.push(BatteryApplication::GridStorage);
fit.push(BatteryApplication::Ups);
fit.push(BatteryApplication::SecondLife);
} else if soh >= 60.0 {
fit.push(BatteryApplication::Ups);
fit.push(BatteryApplication::SecondLife);
} else {
fit.push(BatteryApplication::SecondLife);
}
let rul_years = ((soh - 60.0).max(0.0) * 0.2).min(15.0);
let remaining_cycles = ((soh - 60.0).max(0.0) / 40.0 * 1000.0).max(0.0);
let warranty_kwh = asset.current_capacity_kwh * remaining_cycles;
self.offers.push(MarketplaceOffer {
asset_id,
asking_price_usd,
application_fit: fit,
remaining_useful_life_years: rul_years,
warranty_kwh,
});
Ok(())
}
pub fn buy_asset(&mut self, asset_id: u64, buyer_id: usize) -> Result<f64, MarketError> {
let offer_idx = self
.offers
.iter()
.position(|o| o.asset_id == asset_id)
.ok_or(MarketError::NotForSale(asset_id))?;
let offer = self.offers.remove(offer_idx);
let asset = self
.assets
.iter_mut()
.find(|a| a.id == asset_id)
.ok_or(MarketError::AssetNotFound(asset_id))?;
let soh_at_sale = asset.current_soh_pct;
asset.current_owner = buyer_id;
asset.history.push(BatteryLifecycleEvent {
timestamp: current_civil_date(),
event_type: LifecycleEventType::Sold,
value_usd: offer.asking_price_usd,
});
self.transaction_history
.push((asset_id, buyer_id, offer.asking_price_usd, soh_at_sale));
Ok(offer.asking_price_usd)
}
pub fn assess_second_life_value(&self, asset: &BatteryAsset) -> f64 {
let price_per_kwh = asset.chemistry.second_life_price_per_kwh();
let age_factor = (1.0 - asset.calendar_age_years * 0.05).max(0.1);
asset.current_capacity_kwh * (asset.current_soh_pct / 100.0) * price_per_kwh * age_factor
}
pub fn find_matching_offers(
&self,
requirement: &BatteryApplication,
min_soh_pct: f64,
) -> Vec<&MarketplaceOffer> {
self.offers
.iter()
.filter(|offer| {
let fits_app = offer.application_fit.contains(requirement);
let soh_ok = self
.assets
.iter()
.find(|a| a.id == offer.asset_id)
.map(|a| a.current_soh_pct >= min_soh_pct)
.unwrap_or(false);
fits_app && soh_ok
})
.collect()
}
pub fn total_market_volume_usd(&self) -> f64 {
self.transaction_history
.iter()
.map(|(_, _, price, _)| price)
.sum()
}
pub fn average_second_life_soh(&self) -> f64 {
if self.transaction_history.is_empty() {
return 0.0;
}
let total_soh: f64 = self
.transaction_history
.iter()
.map(|(_, _, _, soh)| soh)
.sum();
total_soh / self.transaction_history.len() as f64
}
pub fn n_assets(&self) -> usize {
self.assets.len()
}
pub fn n_listings(&self) -> usize {
self.offers.len()
}
pub fn n_transactions(&self) -> usize {
self.transaction_history.len()
}
pub fn asset(&self, id: u64) -> Option<&BatteryAsset> {
self.assets.iter().find(|a| a.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_nmc_asset(id: u64, soh_pct: f64, age_years: f64, owner: usize) -> BatteryAsset {
BatteryAsset::new(
id,
BatteryChemistry::NmcLithiumIon,
100.0, soh_pct,
500.0, age_years,
BatteryApplication::ElectricVehicle,
owner,
)
}
#[test]
fn test_register_and_list_asset() {
let mut mp = BatteryMarketplace::new();
let asset = make_nmc_asset(1, 82.0, 3.0, 0);
let id = mp.register_asset(asset);
assert_eq!(id, 1, "returned id should match asset id");
assert_eq!(mp.n_assets(), 1);
mp.list_for_sale(id, 5000.0)
.expect("list_for_sale should succeed");
assert_eq!(mp.n_listings(), 1, "one active listing after list_for_sale");
}
#[test]
fn test_buy_asset_transfers_ownership() {
let mut mp = BatteryMarketplace::new();
let asset = make_nmc_asset(42, 78.0, 4.0, 0); mp.register_asset(asset);
mp.list_for_sale(42, 4200.0).expect("list ok");
let price = mp.buy_asset(42, 7).expect("buy should succeed");
assert!(
(price - 4200.0).abs() < 1e-9,
"price should match asking price"
);
let a = mp.asset(42).expect("asset must still exist");
assert_eq!(a.current_owner, 7, "owner should be buyer 7");
assert_eq!(mp.n_transactions(), 1);
let (aid, buyer, tx_price, _soh) = mp.transaction_history[0];
assert_eq!(aid, 42);
assert_eq!(buyer, 7);
assert!((tx_price - 4200.0).abs() < 1e-9);
assert_eq!(mp.n_listings(), 0);
}
#[test]
fn test_second_life_value_higher_soh_higher_value() {
let mp = BatteryMarketplace::new();
let asset_high = make_nmc_asset(1, 90.0, 2.0, 0);
let asset_low = make_nmc_asset(2, 70.0, 2.0, 0);
let val_high = mp.assess_second_life_value(&asset_high);
let val_low = mp.assess_second_life_value(&asset_low);
assert!(
val_high > val_low,
"higher SoH asset ({:.2}) should have higher value than lower SoH ({:.2})",
val_high,
val_low
);
}
#[test]
fn test_find_matching_offers_filters_correctly() {
let mut mp = BatteryMarketplace::new();
let asset_good = make_nmc_asset(1, 85.0, 2.0, 0);
let asset_poor = make_nmc_asset(2, 55.0, 8.0, 0);
mp.register_asset(asset_good);
mp.register_asset(asset_poor);
mp.list_for_sale(1, 6000.0).expect("list good");
mp.list_for_sale(2, 1000.0).expect("list poor");
let matches = mp.find_matching_offers(&BatteryApplication::GridStorage, 80.0);
assert_eq!(
matches.len(),
1,
"only 1 asset meets GridStorage + SoH 80% requirement"
);
assert_eq!(matches[0].asset_id, 1);
let matches2 = mp.find_matching_offers(&BatteryApplication::SecondLife, 50.0);
assert!(
!matches2.is_empty(),
"at least asset_poor should match SecondLife"
);
}
#[test]
fn test_market_volume_equals_transaction_sum() {
let mut mp = BatteryMarketplace::new();
for i in 1u64..=3 {
let asset = make_nmc_asset(i, 75.0 + i as f64, 3.0, 0);
mp.register_asset(asset);
let price = 1000.0 * i as f64;
mp.list_for_sale(i, price).expect("list ok");
mp.buy_asset(i, i as usize + 10).expect("buy ok");
}
let expected_volume = 1000.0 + 2000.0 + 3000.0;
let actual_volume = mp.total_market_volume_usd();
assert!(
(actual_volume - expected_volume).abs() < 1e-6,
"market volume {actual_volume:.2} != expected {expected_volume:.2}"
);
}
#[test]
fn test_double_listing_returns_error() {
let mut mp = BatteryMarketplace::new();
let asset = make_nmc_asset(1, 80.0, 2.0, 0);
mp.register_asset(asset);
mp.list_for_sale(1, 5000.0).expect("first listing ok");
let result = mp.list_for_sale(1, 4500.0);
assert!(
matches!(result, Err(MarketError::AlreadyListed(1))),
"double listing should return AlreadyListed"
);
}
#[test]
fn test_buy_unlisted_returns_error() {
let mut mp = BatteryMarketplace::new();
let asset = make_nmc_asset(5, 80.0, 2.0, 0);
mp.register_asset(asset);
let result = mp.buy_asset(5, 99);
assert!(
matches!(result, Err(MarketError::NotForSale(5))),
"buying unlisted asset should return NotForSale"
);
}
#[test]
fn test_average_second_life_soh() {
let mut mp = BatteryMarketplace::new();
for (id, soh) in [(1u64, 80.0_f64), (2, 70.0)] {
let asset = make_nmc_asset(id, soh, 3.0, 0);
mp.register_asset(asset);
mp.list_for_sale(id, 1000.0).expect("list ok");
mp.buy_asset(id, 10).expect("buy ok");
}
let avg = mp.average_second_life_soh();
assert!(
(avg - 75.0).abs() < 1e-6,
"average SoH should be 75%, got {avg:.4}"
);
}
#[test]
fn civil_from_days_epoch() {
let (y, m, d) = days_to_civil(0);
assert_eq!((y, m, d), (1970, 1, 1), "epoch day 0 should be 1970-01-01");
}
#[test]
fn civil_from_days_known_offset() {
let (y, m, d) = days_to_civil(19723);
assert_eq!((y, m, d), (2024, 1, 1), "day 19723 should be 2024-01-01");
}
#[test]
fn current_civil_date_sane() {
let (y, m, d) = current_civil_date();
assert!(y >= 1970, "year must be >= 1970, got {y}");
assert!((1..=12).contains(&m), "month must be 1-12, got {m}");
assert!((1..=31).contains(&d), "day must be 1-31, got {d}");
}
}