1mod v2x;
2
3use std::{
4 cmp::{max, min},
5 fmt,
6 ops::Range,
7};
8
9use chrono::{DateTime, Datelike as _, NaiveDateTime, NaiveTime, TimeDelta, Utc};
10use rust_decimal::{prelude::ToPrimitive, Decimal};
11use rust_decimal_macros::dec;
12
13use crate::{
14 country, currency,
15 duration::ToHoursDecimal,
16 energy::{Ampere, Kw, Kwh},
17 from_warning_all, into_caveat, into_caveat_all,
18 json::FromJson as _,
19 number::{FromDecimal as _, RoundDecimal},
20 price, tariff,
21 warning::{self, GatherWarnings as _, IntoCaveat, WithElement as _},
22 Price, Version, Versioned,
23};
24
25const MIN_CS_DURATION_SECS: i64 = 120;
27
28type DateTimeSpan = Range<DateTime<Utc>>;
29type Verdict<T> = crate::Verdict<T, Warning>;
30pub type Caveat<T> = warning::Caveat<T, Warning>;
31
32macro_rules! some_dec_or_bail {
34 ($elem:expr, $opt:expr, $warnings:expr, $msg:literal) => {
35 match $opt {
36 Some(v) => v,
37 None => {
38 return $warnings.bail(Warning::Decimal($msg), $elem.as_element());
39 }
40 }
41 };
42}
43
44#[derive(Debug)]
46pub struct Report {
47 pub tariff_id: String,
49
50 pub tariff_currency_code: currency::Code,
52
53 pub partial_cdr: PartialCdr,
60}
61
62#[derive(Debug)]
70pub struct PartialCdr {
71 pub cpo_country_code: Option<country::Code>,
73
74 pub cpo_currency_code: currency::Code,
76
77 pub party_id: Option<String>,
79
80 pub start_date_time: DateTime<Utc>,
82
83 pub end_date_time: DateTime<Utc>,
85
86 pub total_energy: Option<Kwh>,
88
89 pub total_charging_duration: Option<TimeDelta>,
91
92 pub total_parking_duration: Option<TimeDelta>,
94
95 pub total_cost: Option<Price>,
97
98 pub total_energy_cost: Option<Price>,
100
101 pub total_fixed_cost: Option<Price>,
103
104 pub total_parking_duration_cost: Option<Price>,
106
107 pub total_charging_duration_cost: Option<Price>,
109
110 pub charging_periods: Vec<ChargingPeriod>,
113}
114
115#[derive(Debug)]
119pub struct ChargingPeriod {
120 pub start_date_time: DateTime<Utc>,
123
124 pub dimensions: Vec<Dimension>,
126
127 pub tariff_id: Option<String>,
131}
132
133#[derive(Debug)]
137pub struct Dimension {
138 pub dimension_type: DimensionType,
139
140 pub volume: Decimal,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum DimensionType {
149 Energy,
151 MaxCurrent,
153 MinCurrent,
155 MaxPower,
157 MinPower,
159 ParkingTime,
161 ReservationTime,
163 Time,
165}
166
167into_caveat_all!(Report, Timeline);
168
169pub fn cdr_from_tariff(tariff_elem: &tariff::Versioned<'_>, config: Config) -> Verdict<Report> {
171 let mut warnings = warning::Set::new();
172 let (metrics, timezone) = metrics(tariff_elem, config)?.gather_warnings_into(&mut warnings);
180
181 let tariff = match tariff_elem.version() {
182 Version::V211 => {
183 let tariff = tariff::v211::Tariff::from_json(tariff_elem.as_element())?
184 .gather_warnings_into(&mut warnings);
185
186 tariff::v221::Tariff::from(tariff)
187 }
188 Version::V221 => tariff::v221::Tariff::from_json(tariff_elem.as_element())?
189 .gather_warnings_into(&mut warnings),
190 };
191
192 if !is_tariff_active(&metrics.start_date_time, &tariff) {
193 warnings.with_elem(tariff::Warning::NotActive.into(), tariff_elem.as_element());
194 }
195
196 let timeline = timeline(timezone, &metrics, &tariff);
197 let mut charging_periods = charge_periods(&metrics, timeline);
198
199 let report = price::periods(
200 metrics.end_date_time,
201 timezone,
202 &tariff,
203 &mut charging_periods,
204 )
205 .with_element(tariff_elem.as_element())?
206 .gather_warnings_into(&mut warnings);
207
208 let price::PeriodsReport {
209 billable: _,
210 periods,
211 totals,
212 total_costs,
213 } = report;
214
215 let charging_periods = periods
216 .into_iter()
217 .map(|period| {
218 let price::PeriodReport {
219 start_date_time,
220 end_date_time: _,
221 dimensions,
222 } = period;
223 let time = dimensions
224 .duration_charging
225 .volume
226 .as_ref()
227 .map(|dt| Dimension {
228 dimension_type: DimensionType::Time,
229 volume: ToHoursDecimal::to_hours_dec(dt),
230 });
231 let parking_time = dimensions
232 .duration_parking
233 .volume
234 .as_ref()
235 .map(|dt| Dimension {
236 dimension_type: DimensionType::ParkingTime,
237 volume: ToHoursDecimal::to_hours_dec(dt),
238 });
239 let energy = dimensions.energy.volume.as_ref().map(|kwh| Dimension {
240 dimension_type: DimensionType::Energy,
241 volume: (*kwh).into(),
242 });
243 let dimensions = vec![energy, parking_time, time]
244 .into_iter()
245 .flatten()
246 .collect();
247
248 ChargingPeriod {
249 start_date_time,
250 dimensions,
251 tariff_id: Some(tariff.id.to_string()),
252 }
253 })
254 .collect();
255
256 let mut total_cost = total_costs.total();
257
258 if let Some(total_cost) = total_cost.as_mut() {
259 if let Some(min_price) = tariff.min_price {
260 if *total_cost < min_price {
261 *total_cost = min_price;
262 warnings.with_elem(
263 tariff::Warning::TotalCostClampedToMin.into(),
264 tariff_elem.as_element(),
265 );
266 }
267 }
268
269 if let Some(max_price) = tariff.max_price {
270 if *total_cost > max_price {
271 *total_cost = max_price;
272 warnings.with_elem(
273 tariff::Warning::TotalCostClampedToMin.into(),
274 tariff_elem.as_element(),
275 );
276 }
277 }
278 }
279
280 let report = Report {
281 tariff_id: tariff.id.to_string(),
282 tariff_currency_code: tariff.currency,
283 partial_cdr: PartialCdr {
284 cpo_country_code: tariff.country_code,
285 party_id: tariff.party_id.as_ref().map(ToString::to_string),
286 start_date_time: metrics.start_date_time,
287 end_date_time: metrics.end_date_time,
288 cpo_currency_code: tariff.currency,
289 total_energy: totals.energy.round_to_ocpi_scale(),
290 total_charging_duration: totals.duration_charging,
291 total_parking_duration: totals.duration_parking,
292 total_cost: total_cost.round_to_ocpi_scale(),
293 total_energy_cost: total_costs.energy.round_to_ocpi_scale(),
294 total_fixed_cost: total_costs.fixed.round_to_ocpi_scale(),
295 total_parking_duration_cost: total_costs.duration_parking.round_to_ocpi_scale(),
296 total_charging_duration_cost: total_costs.duration_charging.round_to_ocpi_scale(),
297 charging_periods,
298 },
299 };
300
301 Ok(report.into_caveat(warnings))
302}
303
304fn timeline(
306 timezone: chrono_tz::Tz,
307 metrics: &Metrics,
308 tariff: &tariff::v221::Tariff<'_>,
309) -> Timeline {
310 let mut events = vec![];
311
312 let Metrics {
313 start_date_time: cdr_start,
314 end_date_time: cdr_end,
315 duration_charging,
316 duration_parking,
317 max_power_supply,
318 max_current_supply,
319
320 energy_supplied: _,
321 } = metrics;
322
323 events.push(Event {
324 duration_from_start: TimeDelta::seconds(0),
325 kind: EventKind::SessionStart,
326 });
327
328 events.push(Event {
329 duration_from_start: *duration_charging,
330 kind: EventKind::ChargingEnd,
331 });
332
333 if let Some(duration_parking) = duration_parking {
334 events.push(Event {
335 duration_from_start: *duration_parking,
336 kind: EventKind::ParkingEnd {
337 start: metrics.duration_charging,
338 },
339 });
340 }
341
342 let mut emit_current = false;
345
346 let mut emit_power = false;
349
350 for elem in &tariff.elements {
351 if let Some((time_restrictions, energy_restrictions)) = elem
352 .restrictions
353 .as_ref()
354 .map(tariff::v221::Restrictions::restrictions_by_category)
355 {
356 let mut time_events =
357 generate_time_events(timezone, *cdr_start..*cdr_end, time_restrictions);
358
359 let v2x::EnergyRestrictions {
360 min_kwh,
361 max_kwh,
362 min_current,
363 max_current,
364 min_power,
365 max_power,
366 } = energy_restrictions;
367
368 if !emit_current {
369 emit_current = (min_current..=max_current).contains(&Some(*max_current_supply));
374 }
375
376 if !emit_power {
377 emit_power = (min_power..=max_power).contains(&Some(*max_power_supply));
382 }
383
384 let mut energy_events = generate_energy_events(
385 metrics.duration_charging,
386 metrics.energy_supplied,
387 min_kwh,
388 max_kwh,
389 );
390
391 events.append(&mut time_events);
392 events.append(&mut energy_events);
393 }
394 }
395
396 Timeline {
397 events,
398 emit_current,
399 emit_power,
400 }
401}
402
403fn generate_time_events(
405 timezone: chrono_tz::Tz,
406 cdr_span: DateTimeSpan,
407 restrictions: v2x::TimeRestrictions,
408) -> Vec<Event> {
409 const MIDNIGHT: NaiveTime = NaiveTime::from_hms_opt(0, 0, 0)
410 .expect("The hour, minute and second values are correct and hardcoded");
411 const ONE_DAY: TimeDelta = TimeDelta::days(1);
412
413 let v2x::TimeRestrictions {
414 start_time,
415 end_time,
416 start_date,
417 end_date,
418 min_duration,
419 max_duration,
420 weekdays,
421 } = restrictions;
422 let mut events = vec![];
423
424 let cdr_duration = cdr_span.end - cdr_span.start;
425
426 if let Some(min_duration) = min_duration.filter(|dt| &cdr_duration < dt) {
428 events.push(Event {
429 duration_from_start: min_duration,
430 kind: EventKind::MinDuration,
431 });
432 }
433
434 if let Some(max_duration) = max_duration.filter(|dt| &cdr_duration < dt) {
436 events.push(Event {
437 duration_from_start: max_duration,
438 kind: EventKind::MaxDuration,
439 });
440 }
441
442 let (start_date_time, end_date_time) =
452 if let (Some(start_time), Some(end_time)) = (start_time, end_time) {
453 if end_time < start_time {
454 (
455 start_date.map(|d| d.and_time(start_time)),
456 end_date.map(|d| d.and_time(end_time + ONE_DAY)),
457 )
458 } else {
459 (
460 start_date.map(|d| d.and_time(start_time)),
461 end_date.map(|d| d.and_time(end_time)),
462 )
463 }
464 } else {
465 (
466 start_date.map(|d| d.and_time(start_time.unwrap_or(MIDNIGHT))),
467 end_date.map(|d| d.and_time(end_time.unwrap_or(MIDNIGHT))),
468 )
469 };
470
471 let event_span = clamp_date_time_span(
474 start_date_time.and_then(|d| local_to_utc(timezone, d)),
475 end_date_time.and_then(|d| local_to_utc(timezone, d)),
476 cdr_span,
477 );
478
479 if let Some(start_time) = start_time {
480 let mut start_events =
481 gen_naive_time_events(&event_span, start_time, &weekdays, EventKind::StartTime);
482 events.append(&mut start_events);
483 }
484
485 if let Some(end_time) = end_time {
486 let mut end_events =
487 gen_naive_time_events(&event_span, end_time, &weekdays, EventKind::EndTime);
488 events.append(&mut end_events);
489 }
490
491 events
492}
493
494fn local_to_utc(timezone: chrono_tz::Tz, date_time: NaiveDateTime) -> Option<DateTime<Utc>> {
500 use chrono::offset::LocalResult;
501
502 let result = date_time.and_local_timezone(timezone);
503
504 let local_date_time = match result {
505 LocalResult::Single(d) => d,
506 LocalResult::Ambiguous(earliest, _latest) => earliest,
507 LocalResult::None => return None,
508 };
509
510 Some(local_date_time.to_utc())
511}
512
513fn gen_naive_time_events(
515 event_span: &Range<DateTime<Utc>>,
516 time: NaiveTime,
517 weekdays: &v2x::WeekdaySet,
518 kind: EventKind,
519) -> Vec<Event> {
520 let mut events = vec![];
521 let time_delta = time - event_span.start.time();
522 let cdr_duration = event_span.end - event_span.start;
523
524 let time_delta = if time_delta.num_seconds().is_negative() {
527 let time_delta = time + TimeDelta::days(1);
528 time_delta - event_span.start.time()
529 } else {
530 time_delta
531 };
532
533 if time_delta.num_seconds().is_negative() {
535 return vec![];
536 }
537
538 let remainder = cdr_duration - time_delta;
540
541 if remainder.num_seconds().is_positive() {
542 let duration_from_start = time_delta;
543 let date = event_span.start + duration_from_start;
544
545 if weekdays.contains(date.weekday()) {
546 events.push(Event {
548 duration_from_start: time_delta,
549 kind,
550 });
551 }
552
553 for day in 1..=remainder.num_days() {
554 let duration_from_start = time_delta + TimeDelta::days(day);
555 let date = event_span.start + duration_from_start;
556
557 if weekdays.contains(date.weekday()) {
558 events.push(Event {
559 duration_from_start,
560 kind,
561 });
562 }
563 }
564 }
565
566 events
567}
568
569fn generate_energy_events(
571 duration_charging: TimeDelta,
572 energy_supplied: Kwh,
573 min_kwh: Option<Kwh>,
574 max_kwh: Option<Kwh>,
575) -> Vec<Event> {
576 let mut events = vec![];
577
578 if let Some(duration_from_start) =
579 min_kwh.and_then(|kwh| energy_factor(kwh, energy_supplied, duration_charging))
580 {
581 events.push(Event {
582 duration_from_start,
583 kind: EventKind::MinKwh,
584 });
585 }
586
587 if let Some(duration_from_start) =
588 max_kwh.and_then(|kwh| energy_factor(kwh, energy_supplied, duration_charging))
589 {
590 events.push(Event {
591 duration_from_start,
592 kind: EventKind::MaxKwh,
593 });
594 }
595
596 events
597}
598
599fn energy_factor(power: Kwh, power_total: Kwh, duration_total: TimeDelta) -> Option<TimeDelta> {
600 use rust_decimal::prelude::ToPrimitive;
601
602 let power = Decimal::from(power);
605 let power_total = Decimal::from(power_total);
607 let factor = power_total / power;
609
610 if factor.is_sign_negative() || factor > dec!(1.0) {
611 return None;
612 }
613
614 let duration_from_start = factor * Decimal::from(duration_total.num_seconds());
615 duration_from_start.to_i64().map(TimeDelta::seconds)
616}
617
618fn charge_periods(metrics: &Metrics, timeline: Timeline) -> Vec<price::Period> {
620 enum ChargingPhase {
622 Charging,
623 Parking,
624 }
625
626 let Metrics {
627 start_date_time: cdr_start,
628 max_power_supply,
629 max_current_supply,
630
631 end_date_time: _,
632 duration_charging: _,
633 duration_parking: _,
634 energy_supplied: _,
635 } = metrics;
636
637 let Timeline {
638 mut events,
639 emit_current,
640 emit_power,
641 } = timeline;
642
643 events.sort_unstable_by_key(|e| e.duration_from_start);
644
645 let mut periods = vec![];
646 let emit_current = emit_current.then_some(*max_current_supply);
647 let emit_power = emit_power.then_some(*max_power_supply);
648 let mut charging_phase = ChargingPhase::Charging;
650
651 for items in events.windows(2) {
652 let [event, event_next] = items else {
653 unreachable!("The window size is 2");
654 };
655
656 let Event {
657 duration_from_start,
658 kind,
659 } = event;
660
661 if let EventKind::ChargingEnd = kind {
662 charging_phase = ChargingPhase::Parking;
663 }
664
665 let duration = event_next.duration_from_start - *duration_from_start;
666 let start_date_time = *cdr_start + *duration_from_start;
667
668 let consumed = if let ChargingPhase::Charging = charging_phase {
669 let energy = Decimal::from(*max_power_supply) * duration.to_hours_dec();
670 price::Consumed {
671 duration_charging: Some(duration),
672 duration_parking: None,
673 energy: Some(Kwh::from_decimal(energy)),
674 current_max: emit_current,
675 current_min: emit_current,
676 power_max: emit_power,
677 power_min: emit_power,
678 }
679 } else {
680 price::Consumed {
681 duration_charging: None,
682 duration_parking: Some(duration),
683 energy: None,
684 current_max: None,
685 current_min: None,
686 power_max: None,
687 power_min: None,
688 }
689 };
690
691 let period = price::Period {
692 start_date_time,
693 consumed,
694 };
695
696 periods.push(period);
697 }
698
699 periods
700}
701
702fn clamp_date_time_span(
708 min_date: Option<DateTime<Utc>>,
709 max_date: Option<DateTime<Utc>>,
710 span: DateTimeSpan,
711) -> DateTimeSpan {
712 let (min_date, max_date) = (min(min_date, max_date), max(min_date, max_date));
714
715 let start = min_date.filter(|d| &span.start < d).unwrap_or(span.start);
716 let end = max_date.filter(|d| &span.end > d).unwrap_or(span.end);
717
718 DateTimeSpan { start, end }
719}
720
721struct Timeline {
723 events: Vec<Event>,
725
726 emit_current: bool,
728
729 emit_power: bool,
731}
732
733#[derive(Debug)]
735struct Event {
736 duration_from_start: TimeDelta,
738
739 kind: EventKind,
741}
742
743#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
745enum EventKind {
746 SessionStart,
752
753 ChargingEnd,
758
759 ParkingEnd {
764 start: TimeDelta,
766 },
767
768 StartTime,
769
770 EndTime,
771
772 MinDuration,
777
778 MaxDuration,
783
784 MinKwh,
786
787 MaxKwh,
789}
790
791#[derive(Debug)]
793struct Metrics {
794 end_date_time: DateTime<Utc>,
796
797 start_date_time: DateTime<Utc>,
799
800 duration_charging: TimeDelta,
805
806 duration_parking: Option<TimeDelta>,
810
811 energy_supplied: Kwh,
813
814 max_current_supply: Ampere,
816
817 max_power_supply: Kw,
819}
820
821into_caveat!(Metrics);
822
823#[expect(
825 clippy::needless_pass_by_value,
826 reason = "Clippy is complaining that `Config` is not consumed by the function when it clearly is"
827)]
828fn metrics(elem: &tariff::Versioned<'_>, config: Config) -> Verdict<(Metrics, chrono_tz::Tz)> {
829 const SECS_IN_HOUR: Decimal = dec!(3600);
830
831 let warnings = warning::Set::new();
832
833 let Config {
834 start_date_time,
835 end_date_time,
836 max_power_supply_kw,
837 max_energy_battery_kwh,
838 max_current_supply_amp,
839 timezone,
840 } = config;
841 let duration_session = end_date_time - start_date_time;
842
843 if duration_session.num_seconds().is_negative() {
845 return warnings.bail(Warning::StartDateTimeIsAfterEndDateTime, elem.as_element());
846 }
847
848 if duration_session.num_seconds() < MIN_CS_DURATION_SECS {
849 return warnings.bail(Warning::DurationBelowMinimum, elem.as_element());
850 }
851
852 let duration_full_charge_hours = some_dec_or_bail!(
854 elem,
855 max_energy_battery_kwh.checked_div(max_power_supply_kw),
856 warnings,
857 "Unable to calculate changing time"
858 );
859
860 let charge_duration_hours =
862 Decimal::min(duration_full_charge_hours, duration_session.to_hours_dec());
863
864 let power_supplied_kwh = some_dec_or_bail!(
865 elem,
866 max_energy_battery_kwh.checked_div(charge_duration_hours),
867 warnings,
868 "Unable to calculate the power supplied during the charging time"
869 );
870
871 let charging_duration_secs = some_dec_or_bail!(
873 elem,
874 charge_duration_hours.checked_mul(SECS_IN_HOUR),
875 warnings,
876 "Unable to convert charging time from hours to seconds"
877 );
878
879 let charging_duration_secs = some_dec_or_bail!(
880 elem,
881 charging_duration_secs.to_i64(),
882 warnings,
883 "Unable to convert charging duration Decimal to i64"
884 );
885 let duration_charging = TimeDelta::seconds(charging_duration_secs);
886
887 let duration_parking = some_dec_or_bail!(
888 elem,
889 duration_session.checked_sub(&duration_charging),
890 warnings,
891 "Unable to calculate `idle_duration`"
892 );
893
894 let metrics = Metrics {
895 end_date_time,
896 start_date_time,
897 duration_charging,
898 duration_parking: Some(duration_parking).filter(|dt| dt.num_seconds().is_positive()),
899 energy_supplied: Kwh::from_decimal(power_supplied_kwh),
900 max_current_supply: Ampere::from_decimal(max_current_supply_amp),
901 max_power_supply: Kw::from_decimal(max_power_supply_kw),
902 };
903
904 Ok((metrics, timezone).into_caveat(warnings))
905}
906
907fn is_tariff_active(cdr_start: &DateTime<Utc>, tariff: &tariff::v221::Tariff<'_>) -> bool {
908 match (tariff.start_date_time, tariff.end_date_time) {
909 (None, None) => true,
910 (None, Some(end)) => (..end).contains(cdr_start),
911 (Some(start), None) => (start..).contains(cdr_start),
912 (Some(start), Some(end)) => (start..end).contains(cdr_start),
913 }
914}
915
916#[derive(Debug)]
917pub enum Warning {
918 Decimal(&'static str),
920
921 DurationBelowMinimum,
923
924 Price(price::Warning),
925
926 StartDateTimeIsAfterEndDateTime,
928
929 Tariff(tariff::Warning),
930}
931
932impl crate::Warning for Warning {
933 fn id(&self) -> warning::Id {
934 match self {
935 Self::Decimal(_) => warning::Id::from_static("decimal_error"),
936 Self::DurationBelowMinimum => warning::Id::from_static("duration_below_minimum"),
937 Self::Price(kind) => kind.id(),
938 Self::StartDateTimeIsAfterEndDateTime => {
939 warning::Id::from_static("start_time_after_end_time")
940 }
941 Self::Tariff(kind) => kind.id(),
942 }
943 }
944}
945
946impl fmt::Display for Warning {
947 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948 match self {
949 Self::Decimal(msg) => f.write_str(msg),
950 Self::DurationBelowMinimum => write!(
951 f,
952 "The duration of the chargesession is below the minimum: {MIN_CS_DURATION_SECS}"
953 ),
954 Self::Price(warnings) => {
955 write!(f, "Price warnings: {warnings:?}")
956 }
957 Self::StartDateTimeIsAfterEndDateTime => {
958 write!(f, "The `start_date_time` is after the `end_date_time`")
959 }
960 Self::Tariff(warnings) => {
961 write!(f, "Tariff warnings: {warnings:?}")
962 }
963 }
964 }
965}
966
967from_warning_all!(
968 tariff::Warning => Warning::Tariff,
969 price::Warning => Warning::Price
970);
971
972#[derive(Clone)]
974pub struct Config {
975 pub timezone: chrono_tz::Tz,
977
978 pub end_date_time: DateTime<Utc>,
980
981 pub max_current_supply_amp: Decimal,
983
984 pub max_energy_battery_kwh: Decimal,
989
990 pub max_power_supply_kw: Decimal,
999
1000 pub start_date_time: DateTime<Utc>,
1002}
1003
1004#[cfg(test)]
1005mod test {
1006 use std::str::FromStr as _;
1007
1008 use chrono::{DateTime, NaiveDateTime, Utc};
1009
1010 use super::DateTimeSpan;
1011
1012 #[track_caller]
1013 pub(super) fn date_time_span(
1014 date_start: &str,
1015 time_start: &str,
1016 date_end: &str,
1017 time_end: &str,
1018 ) -> DateTimeSpan {
1019 DateTimeSpan {
1020 start: datetime_utc(date_start, time_start),
1021 end: datetime_utc(date_end, time_end),
1022 }
1023 }
1024
1025 #[track_caller]
1026 pub(super) fn datetime_utc(date: &str, time: &str) -> DateTime<Utc> {
1027 let s = format!("{date} {time}+00:00");
1028 DateTime::<Utc>::from_str(&s).unwrap()
1029 }
1030
1031 #[track_caller]
1032 pub(super) fn datetime_naive(date: &str, time: &str) -> NaiveDateTime {
1033 let s = format!("{date}T{time}");
1034 NaiveDateTime::from_str(&s).unwrap()
1035 }
1036}
1037
1038#[cfg(test)]
1039mod test_local_to_utc {
1040 use super::{
1041 local_to_utc,
1042 test::{datetime_naive, datetime_utc},
1043 };
1044
1045 #[test]
1046 fn should_convert_from_utc_plus_one() {
1047 let date_time_utc = local_to_utc(
1048 chrono_tz::Tz::Europe__Amsterdam,
1049 datetime_naive("2025-12-18", "11:00:00"),
1050 )
1051 .unwrap();
1052
1053 assert_eq!(date_time_utc, datetime_utc("2025-12-18", "10:00:00"));
1054 }
1055
1056 #[test]
1057 fn should_choose_earliest_date_from_dst_end_fold() {
1058 let date_time_utc = local_to_utc(
1060 chrono_tz::Tz::Europe__Amsterdam,
1061 datetime_naive("2025-10-26", "02:59:59"),
1062 )
1063 .unwrap();
1064
1065 assert_eq!(date_time_utc, datetime_utc("2025-10-26", "00:59:59"));
1066 }
1067
1068 #[test]
1069 fn should_return_none_on_dst_begin_gap() {
1070 let date_time_utc = local_to_utc(
1072 chrono_tz::Tz::Europe__Amsterdam,
1073 datetime_naive("2025-03-30", "02:00:00"),
1074 );
1075
1076 assert_eq!(date_time_utc, None);
1077 }
1078}
1079
1080#[cfg(test)]
1081mod test_periods {
1082 use chrono::TimeDelta;
1083 use rust_decimal::Decimal;
1084 use rust_decimal_macros::dec;
1085
1086 use crate::{
1087 assert_approx_eq, country, currency,
1088 duration::ToHoursDecimal as _,
1089 generate::{self, ChargingPeriod, Dimension, DimensionType, PartialCdr},
1090 json::FromJson as _,
1091 price, tariff, Ampere, Kw, Kwh, Money, Price,
1092 };
1093
1094 use super::test;
1095
1096 const DATE: &str = "2025-11-10";
1097
1098 fn generate_config() -> generate::Config {
1099 generate::Config {
1100 timezone: chrono_tz::Europe::Amsterdam,
1101 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1102 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1103 max_power_supply_kw: Decimal::from(24),
1104 max_energy_battery_kwh: Decimal::from(80),
1105 max_current_supply_amp: Decimal::from(4),
1106 }
1107 }
1108
1109 #[track_caller]
1110 fn periods(tariff_json: &str) -> Vec<price::Period> {
1111 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1112 let (metrics, _tz) = generate::metrics(&tariff, generate_config())
1113 .unwrap()
1114 .unwrap();
1115 let tariff = tariff::v221::Tariff::from_json(tariff.as_element())
1116 .unwrap()
1117 .unwrap();
1118 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1119 super::charge_periods(&metrics, timeline)
1120 }
1121
1122 #[test]
1123 fn should_generate_periods() {
1124 const TARIFF_JSON: &str = r#"{
1125 "country_code": "DE",
1126 "party_id": "ALL",
1127 "id": "1",
1128 "currency": "EUR",
1129 "type": "REGULAR",
1130 "elements": [
1131 {
1132 "price_components": [{
1133 "type": "ENERGY",
1134 "price": 0.50,
1135 "vat": 20.0,
1136 "step_size": 1
1137 }]
1138 }
1139 ],
1140 "last_updated": "2018-12-05T12:01:09Z"
1141}
1142"#;
1143
1144 let periods = periods(TARIFF_JSON);
1145 let [period] = periods
1146 .try_into()
1147 .expect("There are no restrictions so there should be one big period");
1148
1149 let price::Period {
1150 start_date_time,
1151 consumed,
1152 } = period;
1153
1154 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1155
1156 let price::Consumed {
1157 duration_charging,
1158 duration_parking,
1159 energy,
1160 current_max,
1161 current_min,
1162 power_max,
1163 power_min,
1164 } = consumed;
1165
1166 assert_eq!(
1167 duration_charging,
1168 Some(TimeDelta::minutes(10)),
1169 "The battery is charged for 10 mins and the plug is pulled"
1170 );
1171 assert_eq!(duration_parking, None, "The battery never fully charges");
1172 assert_approx_eq!(
1173 energy,
1174 Some(Kwh::from(4)),
1175 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1176 );
1177 assert_approx_eq!(
1178 current_max,
1179 None,
1180 "There is no `min_current` or `max_current` restriction defined"
1181 );
1182 assert_approx_eq!(
1183 current_min,
1184 None,
1185 "There is no `min_current` or `max_current` defined"
1186 );
1187 assert_approx_eq!(
1188 power_max,
1189 None,
1190 "There is no `min_power` or `max_power` defined"
1191 );
1192 assert_approx_eq!(
1193 power_min,
1194 None,
1195 "There is no `min_power` or `max_power` defined"
1196 );
1197 }
1198
1199 #[test]
1200 fn should_generate_power() {
1201 const TARIFF_JSON: &str = r#"{
1202 "country_code": "DE",
1203 "party_id": "ALL",
1204 "id": "1",
1205 "currency": "EUR",
1206 "type": "REGULAR",
1207 "elements": [
1208 {
1209 "price_components": [{
1210 "type": "ENERGY",
1211 "price": 0.60,
1212 "vat": 20.0,
1213 "step_size": 1
1214 }],
1215 "restrictions": {
1216 "max_power": 16.00
1217 }
1218 },
1219 {
1220 "price_components": [{
1221 "type": "ENERGY",
1222 "price": 0.70,
1223 "vat": 20.0,
1224 "step_size": 1
1225 }],
1226 "restrictions": {
1227 "max_power": 32.00
1228 }
1229 },
1230 {
1231 "price_components": [{
1232 "type": "ENERGY",
1233 "price": 0.50,
1234 "vat": 20.0,
1235 "step_size": 1
1236 }]
1237 }
1238 ],
1239 "last_updated": "2018-12-05T12:01:09Z"
1240}
1241"#;
1242
1243 let config = generate_config();
1244 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1245 let (metrics, _tz) = generate::metrics(&tariff_elem, config.clone())
1246 .unwrap()
1247 .unwrap();
1248 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1249 .unwrap()
1250 .unwrap();
1251 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1252 let periods = super::charge_periods(&metrics, timeline);
1253
1254 let [ref period] = periods
1256 .try_into()
1257 .expect("There are no restrictions so there should be one big period");
1258
1259 let price::Period {
1260 start_date_time,
1261 consumed,
1262 } = period;
1263
1264 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1265
1266 let price::Consumed {
1267 duration_charging,
1268 duration_parking,
1269 energy,
1270 current_max,
1271 current_min,
1272 power_max,
1273 power_min,
1274 } = consumed;
1275
1276 assert_eq!(
1277 *duration_charging,
1278 Some(TimeDelta::minutes(10)),
1279 "The battery is charged for 10 mins and the plug is pulled"
1280 );
1281 assert_eq!(*duration_parking, None, "The battery never fully charges");
1282 assert_approx_eq!(
1283 energy,
1284 Some(Kwh::from(4)),
1285 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1286 );
1287 assert_approx_eq!(
1288 current_max,
1289 None,
1290 "There is no `min_current` or `max_current` restriction defined"
1291 );
1292 assert_approx_eq!(
1293 current_min,
1294 None,
1295 "There is no `min_current` or `max_current` defined"
1296 );
1297 assert_approx_eq!(
1298 power_max,
1299 Some(Kw::from(24)),
1300 "There is a `max_power` defined"
1301 );
1302 assert_approx_eq!(
1303 power_min,
1304 Some(Kw::from(24)),
1305 "There is a `max_power` defined"
1306 );
1307 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1308 let (report, warnings) = report.into_parts();
1309 assert!(warnings.is_empty(), "{warnings:#?}");
1310
1311 let PartialCdr {
1312 cpo_country_code,
1313 party_id,
1314 start_date_time,
1315 end_date_time,
1316 cpo_currency_code,
1317 total_energy,
1318 total_charging_duration,
1319 total_parking_duration,
1320 total_cost,
1321 total_energy_cost,
1322 total_fixed_cost,
1323 total_parking_duration_cost,
1324 total_charging_duration_cost,
1325 charging_periods,
1326 } = report.partial_cdr;
1327
1328 assert_eq!(cpo_country_code, Some(country::Code::De));
1329 assert_eq!(party_id.as_deref(), Some("ALL"));
1330 assert_eq!(cpo_currency_code, currency::Code::Eur);
1331 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1332 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1333
1334 assert_approx_eq!(
1335 total_cost,
1336 Some(Price {
1337 excl_vat: Money::from(2.80),
1338 incl_vat: Some(Money::from(3.36))
1339 }),
1340 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1341 );
1342 assert_eq!(
1343 total_charging_duration,
1344 Some(TimeDelta::minutes(10)),
1345 "The charging session is 10 min and is stopped before the battery is fully charged."
1346 );
1347 assert_eq!(
1348 total_parking_duration, None,
1349 "There is no parking time since the battery never fully charged."
1350 );
1351 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1352 assert_approx_eq!(
1353 total_energy_cost,
1354 Some(Price {
1355 excl_vat: Money::from(2.80),
1356 incl_vat: Some(Money::from(3.36))
1357 }),
1358 "The cost per KwH is 70 cents and the VAT is 20%."
1359 );
1360 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1361 assert_eq!(
1362 total_parking_duration_cost, None,
1363 "There is no parking cost as there is no parking time."
1364 );
1365 assert_eq!(
1366 total_charging_duration_cost, None,
1367 "There are no time costs defined in the tariff."
1368 );
1369
1370 let [period] = charging_periods
1371 .try_into()
1372 .expect("There should be one period.");
1373
1374 let ChargingPeriod {
1375 start_date_time,
1376 dimensions,
1377 tariff_id,
1378 } = period;
1379
1380 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1381 assert_eq!(tariff_id.as_deref(), Some("1"));
1382
1383 let [energy, time] = dimensions
1384 .try_into()
1385 .expect("There should be an energy dimension");
1386
1387 let Dimension {
1388 dimension_type,
1389 volume,
1390 } = energy;
1391
1392 assert_eq!(dimension_type, DimensionType::Energy);
1393 assert_approx_eq!(volume, dec!(4.0));
1394
1395 let Dimension {
1396 dimension_type,
1397 volume,
1398 } = time;
1399
1400 assert_eq!(dimension_type, DimensionType::Time);
1401 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1402 }
1403
1404 #[test]
1405 fn should_generate_current() {
1406 const TARIFF_JSON: &str = r#"{
1407 "country_code": "DE",
1408 "party_id": "ALL",
1409 "id": "1",
1410 "currency": "EUR",
1411 "type": "REGULAR",
1412 "elements": [
1413 {
1414 "price_components": [{
1415 "type": "ENERGY",
1416 "price": 0.60,
1417 "vat": 20.0,
1418 "step_size": 1
1419 }],
1420 "restrictions": {
1421 "max_current": 2
1422 }
1423 },
1424 {
1425 "price_components": [{
1426 "type": "ENERGY",
1427 "price": 0.70,
1428 "vat": 20.0,
1429 "step_size": 1
1430 }],
1431 "restrictions": {
1432 "max_current": 4
1433 }
1434 },
1435 {
1436 "price_components": [{
1437 "type": "ENERGY",
1438 "price": 0.50,
1439 "vat": 20.0,
1440 "step_size": 1
1441 }]
1442 }
1443 ],
1444 "last_updated": "2018-12-05T12:01:09Z"
1445}
1446"#;
1447
1448 let config = generate_config();
1449 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1450 let (metrics, _tz) = generate::metrics(&tariff_elem, config.clone())
1451 .unwrap()
1452 .unwrap();
1453 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1454 .unwrap()
1455 .unwrap();
1456 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1457 let periods = super::charge_periods(&metrics, timeline);
1458
1459 let [ref period] = periods
1461 .try_into()
1462 .expect("There are no restrictions so there should be one big period");
1463
1464 let price::Period {
1465 start_date_time,
1466 consumed,
1467 } = period;
1468
1469 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1470
1471 let price::Consumed {
1472 duration_charging,
1473 duration_parking,
1474 current_max,
1475 current_min,
1476 energy,
1477 power_max,
1478 power_min,
1479 } = consumed;
1480
1481 assert_eq!(
1482 *duration_charging,
1483 Some(TimeDelta::minutes(10)),
1484 "The battery is charged for 10 mins and the plug is pulled"
1485 );
1486 assert_eq!(*duration_parking, None, "The battery never fully charges");
1487 assert_approx_eq!(
1488 energy,
1489 Some(Kwh::from(4)),
1490 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1491 );
1492 assert_approx_eq!(
1493 current_max,
1494 Some(Ampere::from(4)),
1495 "There is a `max_current` restriction defined"
1496 );
1497 assert_approx_eq!(
1498 current_min,
1499 Some(Ampere::from(4)),
1500 "There is a `max_current` restriction defined"
1501 );
1502 assert_approx_eq!(
1503 power_max,
1504 None,
1505 "There is no `min_power` or `max_power` defined"
1506 );
1507 assert_approx_eq!(
1508 power_min,
1509 None,
1510 "There is no `min_power` or `max_power` defined"
1511 );
1512 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1513 let (report, warnings) = report.into_parts();
1514 assert!(warnings.is_empty(), "{warnings:#?}");
1515
1516 let PartialCdr {
1517 cpo_country_code,
1518 party_id,
1519 start_date_time,
1520 end_date_time,
1521 cpo_currency_code,
1522 total_energy,
1523 total_charging_duration,
1524 total_parking_duration,
1525 total_cost,
1526 total_energy_cost,
1527 total_fixed_cost,
1528 total_parking_duration_cost,
1529 total_charging_duration_cost,
1530 charging_periods,
1531 } = report.partial_cdr;
1532
1533 assert_eq!(cpo_country_code, Some(country::Code::De));
1534 assert_eq!(party_id.as_deref(), Some("ALL"));
1535 assert_eq!(cpo_currency_code, currency::Code::Eur);
1536 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1537 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1538
1539 assert_approx_eq!(
1540 total_cost,
1541 Some(Price {
1542 excl_vat: Money::from(2.00),
1543 incl_vat: Some(Money::from(2.40))
1544 }),
1545 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1546 );
1547 assert_eq!(
1548 total_charging_duration,
1549 Some(TimeDelta::minutes(10)),
1550 "The charging session is 10 min and is stopped before the battery is fully charged."
1551 );
1552 assert_eq!(
1553 total_parking_duration, None,
1554 "There is no parking time since the battery never fully charged."
1555 );
1556 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1557 assert_approx_eq!(
1558 total_energy_cost,
1559 Some(Price {
1560 excl_vat: Money::from(2.00),
1561 incl_vat: Some(Money::from(2.40))
1562 }),
1563 "The cost per KwH is 70 cents and the VAT is 20%."
1564 );
1565 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1566 assert_eq!(
1567 total_parking_duration_cost, None,
1568 "There is no parking cost as there is no parking time."
1569 );
1570 assert_eq!(
1571 total_charging_duration_cost, None,
1572 "There are no time costs defined in the tariff."
1573 );
1574
1575 let [period] = charging_periods
1576 .try_into()
1577 .expect("There should be one period.");
1578
1579 let ChargingPeriod {
1580 start_date_time,
1581 dimensions,
1582 tariff_id,
1583 } = period;
1584
1585 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1586 assert_eq!(tariff_id.as_deref(), Some("1"));
1587
1588 let [energy, time] = dimensions
1589 .try_into()
1590 .expect("There should be an energy dimension");
1591
1592 let Dimension {
1593 dimension_type,
1594 volume,
1595 } = energy;
1596
1597 assert_eq!(dimension_type, DimensionType::Energy);
1598 assert_approx_eq!(volume, dec!(4.0));
1599
1600 let Dimension {
1601 dimension_type,
1602 volume,
1603 } = time;
1604
1605 assert_eq!(dimension_type, DimensionType::Time);
1606 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1607 }
1608}
1609
1610#[cfg(test)]
1611mod test_generate {
1612 use assert_matches::assert_matches;
1613
1614 use crate::{
1615 generate::{self},
1616 tariff,
1617 warning::test::VerdictTestExt,
1618 };
1619
1620 use super::test;
1621
1622 const DATE: &str = "2025-11-10";
1623
1624 #[test]
1625 fn should_warn_no_elements() {
1626 const TARIFF_JSON: &str = r#"{
1627 "country_code": "DE",
1628 "party_id": "ALL",
1629 "id": "1",
1630 "currency": "EUR",
1631 "type": "REGULAR",
1632 "elements": [],
1633 "last_updated": "2018-12-05T12:01:09Z"
1634}
1635"#;
1636
1637 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1638 let config = generate::Config {
1639 timezone: chrono_tz::Europe::Amsterdam,
1640 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1641 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1642 max_power_supply_kw: 12.into(),
1643 max_energy_battery_kwh: 80.into(),
1644 max_current_supply_amp: 2.into(),
1645 };
1646 let failure = generate::cdr_from_tariff(&tariff, config).unwrap_only_error();
1647 let warning = assert_matches!(failure.into_warning(), generate::Warning::Tariff(w) => w);
1648 assert_matches!(warning, tariff::Warning::NoElements);
1649 }
1650}
1651
1652#[cfg(test)]
1653mod test_generate_from_single_elem_tariff {
1654 use assert_matches::assert_matches;
1655 use chrono::TimeDelta;
1656
1657 use crate::{
1658 assert_approx_eq,
1659 generate::{self, PartialCdr},
1660 tariff,
1661 warning::test::VerdictTestExt as _,
1662 Kwh, Money, Price,
1663 };
1664
1665 use super::test;
1666
1667 const DATE: &str = "2025-11-10";
1668 const TARIFF_JSON: &str = r#"{
1669 "country_code": "DE",
1670 "party_id": "ALL",
1671 "id": "1",
1672 "currency": "EUR",
1673 "type": "REGULAR",
1674 "elements": [
1675 {
1676 "price_components": [{
1677 "type": "ENERGY",
1678 "price": 0.50,
1679 "vat": 20.0,
1680 "step_size": 1
1681 }]
1682 }
1683 ],
1684 "last_updated": "2018-12-05T12:01:09Z"
1685}
1686"#;
1687
1688 fn generate_config() -> generate::Config {
1689 generate::Config {
1690 timezone: chrono_tz::Europe::Amsterdam,
1691 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1692 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1693 max_power_supply_kw: 12.into(),
1694 max_energy_battery_kwh: 80.into(),
1695 max_current_supply_amp: 2.into(),
1696 }
1697 }
1698
1699 #[track_caller]
1700 fn generate(tariff_json: &str) -> generate::Caveat<generate::Report> {
1701 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1702 generate::cdr_from_tariff(&tariff, generate_config()).unwrap()
1703 }
1704
1705 #[test]
1706 fn should_warn_duration_below_min() {
1707 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1708 let config = generate::Config {
1709 timezone: chrono_tz::Europe::Amsterdam,
1710 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1711 end_date_time: test::datetime_utc(DATE, "15:03:12"),
1712 max_power_supply_kw: 12.into(),
1713 max_energy_battery_kwh: 80.into(),
1714 max_current_supply_amp: 2.into(),
1715 };
1716 let failure = generate::cdr_from_tariff(&tariff, config).unwrap_only_error();
1717 assert_matches!(
1718 failure.into_warning(),
1719 generate::Warning::DurationBelowMinimum
1720 );
1721 }
1722
1723 #[test]
1724 fn should_warn_end_before_start() {
1725 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1726 let config = generate::Config {
1727 timezone: chrono_tz::Europe::Amsterdam,
1728 start_date_time: test::datetime_utc(DATE, "15:12:12"),
1729 end_date_time: test::datetime_utc(DATE, "15:02:12"),
1730 max_power_supply_kw: 12.into(),
1731 max_energy_battery_kwh: 80.into(),
1732 max_current_supply_amp: 2.into(),
1733 };
1734 let failure = generate::cdr_from_tariff(&tariff, config).unwrap_only_error();
1735 assert_matches!(
1736 failure.into_warning(),
1737 generate::Warning::StartDateTimeIsAfterEndDateTime
1738 );
1739 }
1740
1741 #[test]
1742 fn should_generate_energy_for_ten_minutes() {
1743 let report = generate(TARIFF_JSON);
1744 let (report, warnings) = report.into_parts();
1745 assert!(warnings.is_empty(), "{warnings:#?}");
1746
1747 let PartialCdr {
1748 cpo_country_code: _,
1749 party_id: _,
1750 start_date_time: _,
1751 end_date_time: _,
1752 cpo_currency_code: _,
1753 total_energy,
1754 total_charging_duration,
1755 total_parking_duration,
1756 total_cost,
1757 total_energy_cost,
1758 total_fixed_cost,
1759 total_parking_duration_cost,
1760 total_charging_duration_cost,
1761 charging_periods: _,
1762 } = report.partial_cdr;
1763
1764 assert_approx_eq!(
1765 total_cost,
1766 Some(Price {
1767 excl_vat: Money::from(1),
1768 incl_vat: Some(Money::from(1.2))
1769 })
1770 );
1771 assert_eq!(
1772 total_charging_duration,
1773 Some(TimeDelta::minutes(10)),
1774 "The charging session is 10 min and is stopped before the battery is fully charged."
1775 );
1776 assert_eq!(
1777 total_parking_duration, None,
1778 "There is no parking time since the battery never fully charged."
1779 );
1780 assert_approx_eq!(total_energy, Some(Kwh::from(2)));
1781 assert_approx_eq!(
1782 total_energy_cost,
1783 Some(Price {
1784 excl_vat: Money::from(1),
1785 incl_vat: Some(Money::from(1.2))
1786 }),
1787 "The cost per KwH is 50 cents and the VAT is 20%."
1788 );
1789 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1790 assert_eq!(
1791 total_parking_duration_cost, None,
1792 "There is no parking cost as there is no parking time."
1793 );
1794 assert_eq!(
1795 total_charging_duration_cost, None,
1796 "There are no time costs defined in the tariff."
1797 );
1798 }
1799}
1800
1801#[cfg(test)]
1802mod test_clamp_date_time_span {
1803 use super::{clamp_date_time_span, DateTimeSpan};
1804
1805 use super::test::{date_time_span, datetime_utc};
1806
1807 #[test]
1808 fn should_not_clamp_if_start_and_end_are_none() {
1809 let in_span = date_time_span("2025-11-01", "12:02:00", "2025-11-10", "14:00:00");
1810
1811 let out_span = clamp_date_time_span(None, None, in_span.clone());
1812
1813 assert_eq!(in_span, out_span);
1814 }
1815
1816 #[test]
1817 fn should_not_clamp_if_start_and_end_are_contained() {
1818 let start = datetime_utc("2025-11-01", "12:02:00");
1819 let end = datetime_utc("2025-11-10", "14:00:00");
1820 let in_span = DateTimeSpan { start, end };
1821 let min_date = datetime_utc("2025-11-01", "11:00:00");
1822 let max_date = datetime_utc("2025-11-10", "15:00:00");
1823
1824 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span.clone());
1825
1826 assert_eq!(in_span, out_span);
1827 }
1828
1829 #[test]
1830 fn should_clamp_if_span_start_earlier() {
1831 let start = datetime_utc("2025-11-01", "12:02:00");
1832 let end = datetime_utc("2025-11-10", "14:00:00");
1833 let in_span = DateTimeSpan { start, end };
1834 let min_date = datetime_utc("2025-11-02", "00:00:00");
1835 let max_date = datetime_utc("2025-11-10", "23:00:00");
1836
1837 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1838
1839 assert_eq!(out_span.start, min_date);
1840 assert_eq!(out_span.end, end);
1841 }
1842
1843 #[test]
1844 fn should_clamp_if_end_later() {
1845 let start = datetime_utc("2025-11-01", "12:02:00");
1846 let end = datetime_utc("2025-11-10", "14:00:00");
1847 let in_span = DateTimeSpan { start, end };
1848 let min_date = datetime_utc("2025-11-01", "00:00:00");
1849 let max_date = datetime_utc("2025-11-09", "23:00:00");
1850
1851 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1852
1853 assert_eq!(out_span.start, start);
1854 assert_eq!(out_span.end, max_date);
1855 }
1856}
1857
1858#[cfg(test)]
1859mod test_gen_time_events {
1860 use assert_matches::assert_matches;
1861 use chrono::TimeDelta;
1862
1863 use super::{generate_time_events, v2x::TimeRestrictions};
1864
1865 use super::test::date_time_span;
1866
1867 #[test]
1868 fn should_emit_no_events_before_start_time() {
1869 let events = generate_time_events(
1871 chrono_tz::Tz::Europe__Amsterdam,
1872 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1873 TimeRestrictions {
1874 start_time: Some("15:00".parse().unwrap()),
1875 ..TimeRestrictions::default()
1876 },
1877 );
1878
1879 assert_matches!(events.as_slice(), []);
1880 }
1881
1882 #[test]
1883 fn should_emit_no_events_finishes_at_start_time_pricisely() {
1884 let events = generate_time_events(
1886 chrono_tz::Tz::Europe__Amsterdam,
1887 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1888 TimeRestrictions {
1889 start_time: Some("15:00".parse().unwrap()),
1890 ..TimeRestrictions::default()
1891 },
1892 );
1893
1894 assert_matches!(events.as_slice(), []);
1895 }
1896
1897 #[test]
1898 fn should_emit_one_event_precise_overlap_with_start_time() {
1899 let events = generate_time_events(
1901 chrono_tz::Tz::Europe__Amsterdam,
1902 date_time_span("2025-11-10", "15:00:00", "2025-11-10", "17:00:00"),
1903 TimeRestrictions {
1904 start_time: Some("15:00".parse().unwrap()),
1905 ..TimeRestrictions::default()
1906 },
1907 );
1908
1909 let [event] = events.try_into().unwrap();
1910 assert_eq!(event.duration_from_start, TimeDelta::zero());
1911 }
1912
1913 #[test]
1914 fn should_emit_one_event_hour_before_start_time() {
1915 let events = generate_time_events(
1917 chrono_tz::Tz::Europe__Amsterdam,
1918 date_time_span("2025-11-10", "14:00:00", "2025-11-10", "17:00:00"),
1919 TimeRestrictions {
1920 start_time: Some("15:00".parse().unwrap()),
1921 ..TimeRestrictions::default()
1922 },
1923 );
1924
1925 let [event] = events.try_into().unwrap();
1926 assert_eq!(event.duration_from_start, TimeDelta::hours(1));
1927 }
1928
1929 #[test]
1930 fn should_emit_one_event_almost_full_day() {
1931 let events = generate_time_events(
1934 chrono_tz::Tz::Europe__Amsterdam,
1935 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "14:59:00"),
1936 TimeRestrictions {
1937 start_time: Some("15:00".parse().unwrap()),
1938 ..TimeRestrictions::default()
1939 },
1940 );
1941
1942 let [event] = events.try_into().unwrap();
1943 assert_eq!(event.duration_from_start, TimeDelta::zero());
1944 }
1945
1946 #[test]
1947 fn should_emit_two_events_full_day_precisely() {
1948 let events = generate_time_events(
1949 chrono_tz::Tz::Europe__Amsterdam,
1950 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "15:00:00"),
1951 TimeRestrictions {
1952 start_time: Some("15:00".parse().unwrap()),
1953 ..TimeRestrictions::default()
1954 },
1955 );
1956
1957 let [event_0, event_1] = events.try_into().unwrap();
1958 assert_eq!(event_0.duration_from_start, TimeDelta::zero());
1959 assert_eq!(event_1.duration_from_start, TimeDelta::days(1));
1960 }
1961
1962 #[test]
1963 fn should_emit_two_events_full_day_with_hour_margin() {
1964 let events = generate_time_events(
1965 chrono_tz::Tz::Europe__Amsterdam,
1966 date_time_span("2025-11-10", "14:00:00", "2025-11-11", "16:00:00"),
1967 TimeRestrictions {
1968 start_time: Some("15:00".parse().unwrap()),
1969 ..TimeRestrictions::default()
1970 },
1971 );
1972
1973 let [event_0, event_1] = events.try_into().unwrap();
1974 assert_eq!(event_0.duration_from_start, TimeDelta::hours(1));
1975 assert_eq!(
1976 event_1.duration_from_start,
1977 TimeDelta::days(1) + TimeDelta::hours(1)
1978 );
1979 }
1980}