use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
use chrono_tz::Tz;
use serde::Deserialize;
use tracing::{debug, instrument, trace};
use crate::{json, price, Ampere, DateTime, HoursDecimal, Kw, Kwh, ParseError};
use super::v221::{
self,
cdr::{ChargingPeriod, Dimension, DimensionType},
};
#[derive(Debug)]
pub struct ChargePeriod {
pub period_data: PeriodData,
pub start_instant: InstantData,
pub end_instant: InstantData,
}
impl ChargePeriod {
fn new(
local_timezone: Tz,
period: &ChargingPeriod,
end_date_time: DateTime,
) -> Result<Self, price::Error> {
let charge_state = PeriodData::new(period)?;
let start_instant = InstantData::zero(period.start_date_time, local_timezone);
let end_instant = start_instant.next(&charge_state, end_date_time);
Ok(Self {
period_data: charge_state,
start_instant,
end_instant,
})
}
fn next(&self, period: &ChargingPeriod, end_date_time: DateTime) -> Result<Self, price::Error> {
let charge_state = PeriodData::new(period)?;
let start_instant = self.end_instant.clone();
let end_instant = start_instant.next(&charge_state, end_date_time);
Ok(Self {
period_data: charge_state,
start_instant,
end_instant,
})
}
}
#[derive(Debug)]
pub struct PeriodData {
pub max_current: Option<Ampere>,
pub min_current: Option<Ampere>,
pub max_power: Option<Kw>,
pub min_power: Option<Kw>,
pub charging_duration: Option<Duration>,
pub parking_duration: Option<Duration>,
pub reservation_duration: Option<Duration>,
pub energy: Option<Kwh>,
}
#[derive(Clone, Debug)]
pub struct InstantData {
pub local_timezone: Tz,
pub date_time: DateTime,
pub total_charging_duration: Duration,
pub total_duration: Duration,
pub total_energy: Kwh,
}
impl InstantData {
fn zero(date_time: DateTime, local_timezone: Tz) -> Self {
Self {
date_time,
local_timezone,
total_charging_duration: Duration::zero(),
total_duration: Duration::zero(),
total_energy: Kwh::zero(),
}
}
fn next(&self, state: &PeriodData, date_time: DateTime) -> Self {
let mut next = self.clone();
let duration = date_time.signed_duration_since(*next.date_time);
next.total_duration = next
.total_duration
.checked_add(&duration)
.unwrap_or(Duration::MAX);
next.date_time = date_time;
if let Some(duration) = state.charging_duration {
next.total_charging_duration = next
.total_charging_duration
.checked_add(&duration)
.unwrap_or(Duration::MAX);
}
if let Some(energy) = state.energy {
next.total_energy = next.total_energy.saturating_add(energy);
}
next
}
pub fn local_time(&self) -> NaiveTime {
self.date_time.with_timezone(&self.local_timezone).time()
}
pub fn local_date(&self) -> NaiveDate {
self.date_time
.with_timezone(&self.local_timezone)
.date_naive()
}
pub fn local_weekday(&self) -> Weekday {
self.date_time.with_timezone(&self.local_timezone).weekday()
}
}
impl PeriodData {
fn new(period: &ChargingPeriod) -> Result<Self, price::Error> {
fn convert_volume<'de, T>(
dimension_name: &'static str,
json: Option<&'de json::RawValue>,
) -> Result<T, price::Error>
where
T: Deserialize<'de>,
{
let Some(json) = json else {
return Err(price::Error::DimensionShouldHaveVolume { dimension_name });
};
Ok(serde_json::from_str(json.get()).map_err(ParseError::from_cdr_serde_err)?)
}
let mut inst = Self {
parking_duration: None,
reservation_duration: None,
max_current: None,
min_current: None,
max_power: None,
min_power: None,
charging_duration: None,
energy: None,
};
for dimension in &period.dimensions {
let Dimension {
dimension_type: kind,
volume,
} = dimension;
let dimension_type: DimensionType = match serde_json::from_str(kind.get()) {
Ok(v) => v,
Err(err) => {
debug!("{err}");
continue;
}
};
match dimension_type {
DimensionType::MinCurrent => {
inst.min_current = convert_volume("min_current", volume.as_deref())?;
}
DimensionType::MaxCurrent => {
inst.max_current = convert_volume("max_current", volume.as_deref())?;
}
DimensionType::MaxPower => {
inst.max_power = convert_volume("max_power", volume.as_deref())?;
}
DimensionType::MinPower => {
inst.min_power = convert_volume("min_power", volume.as_deref())?;
}
DimensionType::Energy => inst.energy = convert_volume("energy", volume.as_deref())?,
DimensionType::Time => {
inst.charging_duration = Some(
convert_volume::<HoursDecimal>("charging_duration", volume.as_deref())?
.into(),
);
}
DimensionType::ParkingTime => {
inst.parking_duration = Some(
convert_volume::<HoursDecimal>("parking_duration", volume.as_deref())?
.into(),
);
}
DimensionType::ReservationTime => {
inst.reservation_duration =
convert_volume("reservation_duration", volume.as_deref())?;
}
}
}
Ok(inst)
}
}
#[instrument(skip_all)]
pub(crate) fn extract_periods(
cdr: &v221::Cdr<'_>,
local_timezone: Tz,
) -> Result<Vec<ChargePeriod>, price::Error> {
let mut periods: Vec<ChargePeriod> = Vec::new();
for (index, period) in cdr.charging_periods.iter().enumerate() {
trace!(index, "processing\n{period:#?}");
let next_index = index + 1;
let end_date_time = if let Some(next_period) = cdr.charging_periods.get(next_index) {
next_period.start_date_time
} else {
trace!(next_index, "No period found");
cdr.end_date_time
};
let next = if let Some(last) = periods.last() {
let period = last.next(period, end_date_time)?;
trace!("Adding new period based on the last added\n{period:#?}\n{last:#?}");
period
} else {
let period = ChargePeriod::new(local_timezone, period, end_date_time)?;
trace!("Adding new period\n{period:#?}");
period
};
periods.push(next);
}
Ok(periods)
}
#[cfg(test)]
mod test_extract_periods {
use crate::{
de::obj_from_json_str,
price::{v211, v221},
test::{self, datetime_from_str},
};
use super::{extract_periods, ChargePeriod};
#[test]
fn should_extract_periods() {
const JSON: &str =
include_str!("../../test_data/v211/real_world/dates_without_timezone/cdr.json");
const TIMEZONE: chrono_tz::Tz = chrono_tz::Tz::Europe__Amsterdam;
test::setup();
let (cdr, _) = obj_from_json_str::<v211::Cdr<'_>>(JSON).unwrap();
let cdr: v221::Cdr<'_> = cdr.into();
let periods = extract_periods(&cdr, TIMEZONE).unwrap();
let [period_0, period_1] = periods.try_into().unwrap();
assert_eq!(period_0.start_instant.local_timezone, TIMEZONE);
assert_eq!(
period_0.end_instant.local_timezone,
period_1.start_instant.local_timezone
);
assert_eq!(
period_0.end_instant.date_time,
period_1.start_instant.date_time
);
{
let ChargePeriod { start_instant, .. } = period_0;
assert_eq!(
start_instant.date_time,
datetime_from_str("2025-04-07T15:48:21")
);
}
{
let ChargePeriod { end_instant, .. } = period_1;
assert_eq!(
end_instant.date_time,
datetime_from_str("2025-04-08T6:23:39")
);
}
}
}