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) -> crate::SmartString {
934 match self {
935 Self::Decimal(_) => "decimal_error".into(),
936 Self::DurationBelowMinimum => "duration_below_minimum".into(),
937 Self::Price(kind) => kind.id(),
938 Self::StartDateTimeIsAfterEndDateTime => "start_time_after_end_time".into(),
939 Self::Tariff(kind) => kind.id(),
940 }
941 }
942}
943
944impl fmt::Display for Warning {
945 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
946 match self {
947 Self::Decimal(msg) => f.write_str(msg),
948 Self::DurationBelowMinimum => write!(
949 f,
950 "The duration of the chargesession is below the minimum: {MIN_CS_DURATION_SECS}"
951 ),
952 Self::Price(warnings) => {
953 write!(f, "Price warnings: {warnings:?}")
954 }
955 Self::StartDateTimeIsAfterEndDateTime => {
956 write!(f, "The `start_date_time` is after the `end_date_time`")
957 }
958 Self::Tariff(warnings) => {
959 write!(f, "Tariff warnings: {warnings:?}")
960 }
961 }
962 }
963}
964
965from_warning_all!(
966 tariff::Warning => Warning::Tariff,
967 price::Warning => Warning::Price
968);
969
970#[derive(Clone)]
972pub struct Config {
973 pub timezone: chrono_tz::Tz,
975
976 pub end_date_time: DateTime<Utc>,
978
979 pub max_current_supply_amp: Decimal,
981
982 pub max_energy_battery_kwh: Decimal,
987
988 pub max_power_supply_kw: Decimal,
997
998 pub start_date_time: DateTime<Utc>,
1000}
1001
1002#[cfg(test)]
1003mod test {
1004 use std::str::FromStr as _;
1005
1006 use chrono::{DateTime, NaiveDateTime, Utc};
1007
1008 use super::DateTimeSpan;
1009
1010 #[track_caller]
1011 pub(super) fn date_time_span(
1012 date_start: &str,
1013 time_start: &str,
1014 date_end: &str,
1015 time_end: &str,
1016 ) -> DateTimeSpan {
1017 DateTimeSpan {
1018 start: datetime_utc(date_start, time_start),
1019 end: datetime_utc(date_end, time_end),
1020 }
1021 }
1022
1023 #[track_caller]
1024 pub(super) fn datetime_utc(date: &str, time: &str) -> DateTime<Utc> {
1025 let s = format!("{date} {time}+00:00");
1026 DateTime::<Utc>::from_str(&s).unwrap()
1027 }
1028
1029 #[track_caller]
1030 pub(super) fn datetime_naive(date: &str, time: &str) -> NaiveDateTime {
1031 let s = format!("{date}T{time}");
1032 NaiveDateTime::from_str(&s).unwrap()
1033 }
1034}
1035
1036#[cfg(test)]
1037mod test_local_to_utc {
1038 use super::{
1039 local_to_utc,
1040 test::{datetime_naive, datetime_utc},
1041 };
1042
1043 #[test]
1044 fn should_convert_from_utc_plus_one() {
1045 let date_time_utc = local_to_utc(
1046 chrono_tz::Tz::Europe__Amsterdam,
1047 datetime_naive("2025-12-18", "11:00:00"),
1048 )
1049 .unwrap();
1050
1051 assert_eq!(date_time_utc, datetime_utc("2025-12-18", "10:00:00"));
1052 }
1053
1054 #[test]
1055 fn should_choose_earliest_date_from_dst_end_fold() {
1056 let date_time_utc = local_to_utc(
1058 chrono_tz::Tz::Europe__Amsterdam,
1059 datetime_naive("2025-10-26", "02:59:59"),
1060 )
1061 .unwrap();
1062
1063 assert_eq!(date_time_utc, datetime_utc("2025-10-26", "00:59:59"));
1064 }
1065
1066 #[test]
1067 fn should_return_none_on_dst_begin_gap() {
1068 let date_time_utc = local_to_utc(
1070 chrono_tz::Tz::Europe__Amsterdam,
1071 datetime_naive("2025-03-30", "02:00:00"),
1072 );
1073
1074 assert_eq!(date_time_utc, None);
1075 }
1076}
1077
1078#[cfg(test)]
1079mod test_periods {
1080 use chrono::TimeDelta;
1081 use rust_decimal::Decimal;
1082 use rust_decimal_macros::dec;
1083
1084 use crate::{
1085 assert_approx_eq, country, currency,
1086 duration::ToHoursDecimal as _,
1087 generate::{self, ChargingPeriod, Dimension, DimensionType, PartialCdr},
1088 json::FromJson as _,
1089 price, tariff, Ampere, Kw, Kwh, Money, Price,
1090 };
1091
1092 use super::test;
1093
1094 const DATE: &str = "2025-11-10";
1095
1096 fn generate_config() -> generate::Config {
1097 generate::Config {
1098 timezone: chrono_tz::Europe::Amsterdam,
1099 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1100 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1101 max_power_supply_kw: Decimal::from(24),
1102 max_energy_battery_kwh: Decimal::from(80),
1103 max_current_supply_amp: Decimal::from(4),
1104 }
1105 }
1106
1107 #[track_caller]
1108 fn periods(tariff_json: &str) -> Vec<price::Period> {
1109 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1110 let (metrics, _tz) = generate::metrics(&tariff, generate_config())
1111 .unwrap()
1112 .unwrap();
1113 let tariff = tariff::v221::Tariff::from_json(tariff.as_element())
1114 .unwrap()
1115 .unwrap();
1116 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1117 super::charge_periods(&metrics, timeline)
1118 }
1119
1120 #[test]
1121 fn should_generate_periods() {
1122 const TARIFF_JSON: &str = r#"{
1123 "country_code": "DE",
1124 "party_id": "ALL",
1125 "id": "1",
1126 "currency": "EUR",
1127 "type": "REGULAR",
1128 "elements": [
1129 {
1130 "price_components": [{
1131 "type": "ENERGY",
1132 "price": 0.50,
1133 "vat": 20.0,
1134 "step_size": 1
1135 }]
1136 }
1137 ],
1138 "last_updated": "2018-12-05T12:01:09Z"
1139}
1140"#;
1141
1142 let periods = periods(TARIFF_JSON);
1143 let [period] = periods
1144 .try_into()
1145 .expect("There are no restrictions so there should be one big period");
1146
1147 let price::Period {
1148 start_date_time,
1149 consumed,
1150 } = period;
1151
1152 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1153
1154 let price::Consumed {
1155 duration_charging,
1156 duration_parking,
1157 energy,
1158 current_max,
1159 current_min,
1160 power_max,
1161 power_min,
1162 } = consumed;
1163
1164 assert_eq!(
1165 duration_charging,
1166 Some(TimeDelta::minutes(10)),
1167 "The battery is charged for 10 mins and the plug is pulled"
1168 );
1169 assert_eq!(duration_parking, None, "The battery never fully charges");
1170 assert_approx_eq!(
1171 energy,
1172 Some(Kwh::from(4)),
1173 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1174 );
1175 assert_approx_eq!(
1176 current_max,
1177 None,
1178 "There is no `min_current` or `max_current` restriction defined"
1179 );
1180 assert_approx_eq!(
1181 current_min,
1182 None,
1183 "There is no `min_current` or `max_current` defined"
1184 );
1185 assert_approx_eq!(
1186 power_max,
1187 None,
1188 "There is no `min_power` or `max_power` defined"
1189 );
1190 assert_approx_eq!(
1191 power_min,
1192 None,
1193 "There is no `min_power` or `max_power` defined"
1194 );
1195 }
1196
1197 #[test]
1198 fn should_generate_power() {
1199 const TARIFF_JSON: &str = r#"{
1200 "country_code": "DE",
1201 "party_id": "ALL",
1202 "id": "1",
1203 "currency": "EUR",
1204 "type": "REGULAR",
1205 "elements": [
1206 {
1207 "price_components": [{
1208 "type": "ENERGY",
1209 "price": 0.60,
1210 "vat": 20.0,
1211 "step_size": 1
1212 }],
1213 "restrictions": {
1214 "max_power": 16.00
1215 }
1216 },
1217 {
1218 "price_components": [{
1219 "type": "ENERGY",
1220 "price": 0.70,
1221 "vat": 20.0,
1222 "step_size": 1
1223 }],
1224 "restrictions": {
1225 "max_power": 32.00
1226 }
1227 },
1228 {
1229 "price_components": [{
1230 "type": "ENERGY",
1231 "price": 0.50,
1232 "vat": 20.0,
1233 "step_size": 1
1234 }]
1235 }
1236 ],
1237 "last_updated": "2018-12-05T12:01:09Z"
1238}
1239"#;
1240
1241 let config = generate_config();
1242 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1243 let (metrics, _tz) = generate::metrics(&tariff_elem, config.clone())
1244 .unwrap()
1245 .unwrap();
1246 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1247 .unwrap()
1248 .unwrap();
1249 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1250 let periods = super::charge_periods(&metrics, timeline);
1251
1252 let [ref period] = periods
1254 .try_into()
1255 .expect("There are no restrictions so there should be one big period");
1256
1257 let price::Period {
1258 start_date_time,
1259 consumed,
1260 } = period;
1261
1262 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1263
1264 let price::Consumed {
1265 duration_charging,
1266 duration_parking,
1267 energy,
1268 current_max,
1269 current_min,
1270 power_max,
1271 power_min,
1272 } = consumed;
1273
1274 assert_eq!(
1275 *duration_charging,
1276 Some(TimeDelta::minutes(10)),
1277 "The battery is charged for 10 mins and the plug is pulled"
1278 );
1279 assert_eq!(*duration_parking, None, "The battery never fully charges");
1280 assert_approx_eq!(
1281 energy,
1282 Some(Kwh::from(4)),
1283 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1284 );
1285 assert_approx_eq!(
1286 current_max,
1287 None,
1288 "There is no `min_current` or `max_current` restriction defined"
1289 );
1290 assert_approx_eq!(
1291 current_min,
1292 None,
1293 "There is no `min_current` or `max_current` defined"
1294 );
1295 assert_approx_eq!(
1296 power_max,
1297 Some(Kw::from(24)),
1298 "There is a `max_power` defined"
1299 );
1300 assert_approx_eq!(
1301 power_min,
1302 Some(Kw::from(24)),
1303 "There is a `max_power` defined"
1304 );
1305 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1306 let (report, warnings) = report.into_parts();
1307 assert!(warnings.is_empty(), "{warnings:#?}");
1308
1309 let PartialCdr {
1310 cpo_country_code,
1311 party_id,
1312 start_date_time,
1313 end_date_time,
1314 cpo_currency_code,
1315 total_energy,
1316 total_charging_duration,
1317 total_parking_duration,
1318 total_cost,
1319 total_energy_cost,
1320 total_fixed_cost,
1321 total_parking_duration_cost,
1322 total_charging_duration_cost,
1323 charging_periods,
1324 } = report.partial_cdr;
1325
1326 assert_eq!(cpo_country_code, Some(country::Code::De));
1327 assert_eq!(party_id.as_deref(), Some("ALL"));
1328 assert_eq!(cpo_currency_code, currency::Code::Eur);
1329 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1330 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1331
1332 assert_approx_eq!(
1333 total_cost,
1334 Some(Price {
1335 excl_vat: Money::from(2.80),
1336 incl_vat: Some(Money::from(3.36))
1337 }),
1338 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1339 );
1340 assert_eq!(
1341 total_charging_duration,
1342 Some(TimeDelta::minutes(10)),
1343 "The charging session is 10 min and is stopped before the battery is fully charged."
1344 );
1345 assert_eq!(
1346 total_parking_duration, None,
1347 "There is no parking time since the battery never fully charged."
1348 );
1349 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1350 assert_approx_eq!(
1351 total_energy_cost,
1352 Some(Price {
1353 excl_vat: Money::from(2.80),
1354 incl_vat: Some(Money::from(3.36))
1355 }),
1356 "The cost per KwH is 70 cents and the VAT is 20%."
1357 );
1358 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1359 assert_eq!(
1360 total_parking_duration_cost, None,
1361 "There is no parking cost as there is no parking time."
1362 );
1363 assert_eq!(
1364 total_charging_duration_cost, None,
1365 "There are no time costs defined in the tariff."
1366 );
1367
1368 let [period] = charging_periods
1369 .try_into()
1370 .expect("There should be one period.");
1371
1372 let ChargingPeriod {
1373 start_date_time,
1374 dimensions,
1375 tariff_id,
1376 } = period;
1377
1378 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1379 assert_eq!(tariff_id.as_deref(), Some("1"));
1380
1381 let [energy, time] = dimensions
1382 .try_into()
1383 .expect("There should be an energy dimension");
1384
1385 let Dimension {
1386 dimension_type,
1387 volume,
1388 } = energy;
1389
1390 assert_eq!(dimension_type, DimensionType::Energy);
1391 assert_approx_eq!(volume, dec!(4.0));
1392
1393 let Dimension {
1394 dimension_type,
1395 volume,
1396 } = time;
1397
1398 assert_eq!(dimension_type, DimensionType::Time);
1399 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1400 }
1401
1402 #[test]
1403 fn should_generate_current() {
1404 const TARIFF_JSON: &str = r#"{
1405 "country_code": "DE",
1406 "party_id": "ALL",
1407 "id": "1",
1408 "currency": "EUR",
1409 "type": "REGULAR",
1410 "elements": [
1411 {
1412 "price_components": [{
1413 "type": "ENERGY",
1414 "price": 0.60,
1415 "vat": 20.0,
1416 "step_size": 1
1417 }],
1418 "restrictions": {
1419 "max_current": 2
1420 }
1421 },
1422 {
1423 "price_components": [{
1424 "type": "ENERGY",
1425 "price": 0.70,
1426 "vat": 20.0,
1427 "step_size": 1
1428 }],
1429 "restrictions": {
1430 "max_current": 4
1431 }
1432 },
1433 {
1434 "price_components": [{
1435 "type": "ENERGY",
1436 "price": 0.50,
1437 "vat": 20.0,
1438 "step_size": 1
1439 }]
1440 }
1441 ],
1442 "last_updated": "2018-12-05T12:01:09Z"
1443}
1444"#;
1445
1446 let config = generate_config();
1447 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1448 let (metrics, _tz) = generate::metrics(&tariff_elem, config.clone())
1449 .unwrap()
1450 .unwrap();
1451 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1452 .unwrap()
1453 .unwrap();
1454 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1455 let periods = super::charge_periods(&metrics, timeline);
1456
1457 let [ref period] = periods
1459 .try_into()
1460 .expect("There are no restrictions so there should be one big period");
1461
1462 let price::Period {
1463 start_date_time,
1464 consumed,
1465 } = period;
1466
1467 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1468
1469 let price::Consumed {
1470 duration_charging,
1471 duration_parking,
1472 current_max,
1473 current_min,
1474 energy,
1475 power_max,
1476 power_min,
1477 } = consumed;
1478
1479 assert_eq!(
1480 *duration_charging,
1481 Some(TimeDelta::minutes(10)),
1482 "The battery is charged for 10 mins and the plug is pulled"
1483 );
1484 assert_eq!(*duration_parking, None, "The battery never fully charges");
1485 assert_approx_eq!(
1486 energy,
1487 Some(Kwh::from(4)),
1488 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1489 );
1490 assert_approx_eq!(
1491 current_max,
1492 Some(Ampere::from(4)),
1493 "There is a `max_current` restriction defined"
1494 );
1495 assert_approx_eq!(
1496 current_min,
1497 Some(Ampere::from(4)),
1498 "There is a `max_current` restriction defined"
1499 );
1500 assert_approx_eq!(
1501 power_max,
1502 None,
1503 "There is no `min_power` or `max_power` defined"
1504 );
1505 assert_approx_eq!(
1506 power_min,
1507 None,
1508 "There is no `min_power` or `max_power` defined"
1509 );
1510 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1511 let (report, warnings) = report.into_parts();
1512 assert!(warnings.is_empty(), "{warnings:#?}");
1513
1514 let PartialCdr {
1515 cpo_country_code,
1516 party_id,
1517 start_date_time,
1518 end_date_time,
1519 cpo_currency_code,
1520 total_energy,
1521 total_charging_duration,
1522 total_parking_duration,
1523 total_cost,
1524 total_energy_cost,
1525 total_fixed_cost,
1526 total_parking_duration_cost,
1527 total_charging_duration_cost,
1528 charging_periods,
1529 } = report.partial_cdr;
1530
1531 assert_eq!(cpo_country_code, Some(country::Code::De));
1532 assert_eq!(party_id.as_deref(), Some("ALL"));
1533 assert_eq!(cpo_currency_code, currency::Code::Eur);
1534 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1535 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1536
1537 assert_approx_eq!(
1538 total_cost,
1539 Some(Price {
1540 excl_vat: Money::from(2.00),
1541 incl_vat: Some(Money::from(2.40))
1542 }),
1543 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1544 );
1545 assert_eq!(
1546 total_charging_duration,
1547 Some(TimeDelta::minutes(10)),
1548 "The charging session is 10 min and is stopped before the battery is fully charged."
1549 );
1550 assert_eq!(
1551 total_parking_duration, None,
1552 "There is no parking time since the battery never fully charged."
1553 );
1554 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1555 assert_approx_eq!(
1556 total_energy_cost,
1557 Some(Price {
1558 excl_vat: Money::from(2.00),
1559 incl_vat: Some(Money::from(2.40))
1560 }),
1561 "The cost per KwH is 70 cents and the VAT is 20%."
1562 );
1563 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1564 assert_eq!(
1565 total_parking_duration_cost, None,
1566 "There is no parking cost as there is no parking time."
1567 );
1568 assert_eq!(
1569 total_charging_duration_cost, None,
1570 "There are no time costs defined in the tariff."
1571 );
1572
1573 let [period] = charging_periods
1574 .try_into()
1575 .expect("There should be one period.");
1576
1577 let ChargingPeriod {
1578 start_date_time,
1579 dimensions,
1580 tariff_id,
1581 } = period;
1582
1583 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1584 assert_eq!(tariff_id.as_deref(), Some("1"));
1585
1586 let [energy, time] = dimensions
1587 .try_into()
1588 .expect("There should be an energy dimension");
1589
1590 let Dimension {
1591 dimension_type,
1592 volume,
1593 } = energy;
1594
1595 assert_eq!(dimension_type, DimensionType::Energy);
1596 assert_approx_eq!(volume, dec!(4.0));
1597
1598 let Dimension {
1599 dimension_type,
1600 volume,
1601 } = time;
1602
1603 assert_eq!(dimension_type, DimensionType::Time);
1604 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1605 }
1606}
1607
1608#[cfg(test)]
1609mod test_generate {
1610 use assert_matches::assert_matches;
1611
1612 use crate::{
1613 generate::{self},
1614 tariff,
1615 warning::test::VerdictTestExt,
1616 };
1617
1618 use super::test;
1619
1620 const DATE: &str = "2025-11-10";
1621
1622 #[test]
1623 fn should_warn_no_elements() {
1624 const TARIFF_JSON: &str = r#"{
1625 "country_code": "DE",
1626 "party_id": "ALL",
1627 "id": "1",
1628 "currency": "EUR",
1629 "type": "REGULAR",
1630 "elements": [],
1631 "last_updated": "2018-12-05T12:01:09Z"
1632}
1633"#;
1634
1635 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1636 let config = generate::Config {
1637 timezone: chrono_tz::Europe::Amsterdam,
1638 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1639 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1640 max_power_supply_kw: 12.into(),
1641 max_energy_battery_kwh: 80.into(),
1642 max_current_supply_amp: 2.into(),
1643 };
1644 let failure = generate::cdr_from_tariff(&tariff, config).unwrap_only_error();
1645 let warning = assert_matches!(failure.into_warning(), generate::Warning::Tariff(w) => w);
1646 assert_matches!(warning, tariff::Warning::NoElements);
1647 }
1648}
1649
1650#[cfg(test)]
1651mod test_generate_from_single_elem_tariff {
1652 use assert_matches::assert_matches;
1653 use chrono::TimeDelta;
1654
1655 use crate::{
1656 assert_approx_eq,
1657 generate::{self, PartialCdr},
1658 tariff,
1659 warning::test::VerdictTestExt as _,
1660 Kwh, Money, Price,
1661 };
1662
1663 use super::test;
1664
1665 const DATE: &str = "2025-11-10";
1666 const TARIFF_JSON: &str = r#"{
1667 "country_code": "DE",
1668 "party_id": "ALL",
1669 "id": "1",
1670 "currency": "EUR",
1671 "type": "REGULAR",
1672 "elements": [
1673 {
1674 "price_components": [{
1675 "type": "ENERGY",
1676 "price": 0.50,
1677 "vat": 20.0,
1678 "step_size": 1
1679 }]
1680 }
1681 ],
1682 "last_updated": "2018-12-05T12:01:09Z"
1683}
1684"#;
1685
1686 fn generate_config() -> generate::Config {
1687 generate::Config {
1688 timezone: chrono_tz::Europe::Amsterdam,
1689 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1690 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1691 max_power_supply_kw: 12.into(),
1692 max_energy_battery_kwh: 80.into(),
1693 max_current_supply_amp: 2.into(),
1694 }
1695 }
1696
1697 #[track_caller]
1698 fn generate(tariff_json: &str) -> generate::Caveat<generate::Report> {
1699 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1700 generate::cdr_from_tariff(&tariff, generate_config()).unwrap()
1701 }
1702
1703 #[test]
1704 fn should_warn_duration_below_min() {
1705 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1706 let config = generate::Config {
1707 timezone: chrono_tz::Europe::Amsterdam,
1708 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1709 end_date_time: test::datetime_utc(DATE, "15:03:12"),
1710 max_power_supply_kw: 12.into(),
1711 max_energy_battery_kwh: 80.into(),
1712 max_current_supply_amp: 2.into(),
1713 };
1714 let failure = generate::cdr_from_tariff(&tariff, config).unwrap_only_error();
1715 assert_matches!(
1716 failure.into_warning(),
1717 generate::Warning::DurationBelowMinimum
1718 );
1719 }
1720
1721 #[test]
1722 fn should_warn_end_before_start() {
1723 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1724 let config = generate::Config {
1725 timezone: chrono_tz::Europe::Amsterdam,
1726 start_date_time: test::datetime_utc(DATE, "15:12:12"),
1727 end_date_time: test::datetime_utc(DATE, "15:02:12"),
1728 max_power_supply_kw: 12.into(),
1729 max_energy_battery_kwh: 80.into(),
1730 max_current_supply_amp: 2.into(),
1731 };
1732 let failure = generate::cdr_from_tariff(&tariff, config).unwrap_only_error();
1733 assert_matches!(
1734 failure.into_warning(),
1735 generate::Warning::StartDateTimeIsAfterEndDateTime
1736 );
1737 }
1738
1739 #[test]
1740 fn should_generate_energy_for_ten_minutes() {
1741 let report = generate(TARIFF_JSON);
1742 let (report, warnings) = report.into_parts();
1743 assert!(warnings.is_empty(), "{warnings:#?}");
1744
1745 let PartialCdr {
1746 cpo_country_code: _,
1747 party_id: _,
1748 start_date_time: _,
1749 end_date_time: _,
1750 cpo_currency_code: _,
1751 total_energy,
1752 total_charging_duration,
1753 total_parking_duration,
1754 total_cost,
1755 total_energy_cost,
1756 total_fixed_cost,
1757 total_parking_duration_cost,
1758 total_charging_duration_cost,
1759 charging_periods: _,
1760 } = report.partial_cdr;
1761
1762 assert_approx_eq!(
1763 total_cost,
1764 Some(Price {
1765 excl_vat: Money::from(1),
1766 incl_vat: Some(Money::from(1.2))
1767 })
1768 );
1769 assert_eq!(
1770 total_charging_duration,
1771 Some(TimeDelta::minutes(10)),
1772 "The charging session is 10 min and is stopped before the battery is fully charged."
1773 );
1774 assert_eq!(
1775 total_parking_duration, None,
1776 "There is no parking time since the battery never fully charged."
1777 );
1778 assert_approx_eq!(total_energy, Some(Kwh::from(2)));
1779 assert_approx_eq!(
1780 total_energy_cost,
1781 Some(Price {
1782 excl_vat: Money::from(1),
1783 incl_vat: Some(Money::from(1.2))
1784 }),
1785 "The cost per KwH is 50 cents and the VAT is 20%."
1786 );
1787 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1788 assert_eq!(
1789 total_parking_duration_cost, None,
1790 "There is no parking cost as there is no parking time."
1791 );
1792 assert_eq!(
1793 total_charging_duration_cost, None,
1794 "There are no time costs defined in the tariff."
1795 );
1796 }
1797}
1798
1799#[cfg(test)]
1800mod test_clamp_date_time_span {
1801 use super::{clamp_date_time_span, DateTimeSpan};
1802
1803 use super::test::{date_time_span, datetime_utc};
1804
1805 #[test]
1806 fn should_not_clamp_if_start_and_end_are_none() {
1807 let in_span = date_time_span("2025-11-01", "12:02:00", "2025-11-10", "14:00:00");
1808
1809 let out_span = clamp_date_time_span(None, None, in_span.clone());
1810
1811 assert_eq!(in_span, out_span);
1812 }
1813
1814 #[test]
1815 fn should_not_clamp_if_start_and_end_are_contained() {
1816 let start = datetime_utc("2025-11-01", "12:02:00");
1817 let end = datetime_utc("2025-11-10", "14:00:00");
1818 let in_span = DateTimeSpan { start, end };
1819 let min_date = datetime_utc("2025-11-01", "11:00:00");
1820 let max_date = datetime_utc("2025-11-10", "15:00:00");
1821
1822 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span.clone());
1823
1824 assert_eq!(in_span, out_span);
1825 }
1826
1827 #[test]
1828 fn should_clamp_if_span_start_earlier() {
1829 let start = datetime_utc("2025-11-01", "12:02:00");
1830 let end = datetime_utc("2025-11-10", "14:00:00");
1831 let in_span = DateTimeSpan { start, end };
1832 let min_date = datetime_utc("2025-11-02", "00:00:00");
1833 let max_date = datetime_utc("2025-11-10", "23:00:00");
1834
1835 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1836
1837 assert_eq!(out_span.start, min_date);
1838 assert_eq!(out_span.end, end);
1839 }
1840
1841 #[test]
1842 fn should_clamp_if_end_later() {
1843 let start = datetime_utc("2025-11-01", "12:02:00");
1844 let end = datetime_utc("2025-11-10", "14:00:00");
1845 let in_span = DateTimeSpan { start, end };
1846 let min_date = datetime_utc("2025-11-01", "00:00:00");
1847 let max_date = datetime_utc("2025-11-09", "23:00:00");
1848
1849 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1850
1851 assert_eq!(out_span.start, start);
1852 assert_eq!(out_span.end, max_date);
1853 }
1854}
1855
1856#[cfg(test)]
1857mod test_gen_time_events {
1858 use assert_matches::assert_matches;
1859 use chrono::TimeDelta;
1860
1861 use super::{generate_time_events, v2x::TimeRestrictions};
1862
1863 use super::test::date_time_span;
1864
1865 #[test]
1866 fn should_emit_no_events_before_start_time() {
1867 let events = generate_time_events(
1869 chrono_tz::Tz::Europe__Amsterdam,
1870 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1871 TimeRestrictions {
1872 start_time: Some("15:00".parse().unwrap()),
1873 ..TimeRestrictions::default()
1874 },
1875 );
1876
1877 assert_matches!(events.as_slice(), []);
1878 }
1879
1880 #[test]
1881 fn should_emit_no_events_finishes_at_start_time_pricisely() {
1882 let events = generate_time_events(
1884 chrono_tz::Tz::Europe__Amsterdam,
1885 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1886 TimeRestrictions {
1887 start_time: Some("15:00".parse().unwrap()),
1888 ..TimeRestrictions::default()
1889 },
1890 );
1891
1892 assert_matches!(events.as_slice(), []);
1893 }
1894
1895 #[test]
1896 fn should_emit_one_event_precise_overlap_with_start_time() {
1897 let events = generate_time_events(
1899 chrono_tz::Tz::Europe__Amsterdam,
1900 date_time_span("2025-11-10", "15:00:00", "2025-11-10", "17:00:00"),
1901 TimeRestrictions {
1902 start_time: Some("15:00".parse().unwrap()),
1903 ..TimeRestrictions::default()
1904 },
1905 );
1906
1907 let [event] = events.try_into().unwrap();
1908 assert_eq!(event.duration_from_start, TimeDelta::zero());
1909 }
1910
1911 #[test]
1912 fn should_emit_one_event_hour_before_start_time() {
1913 let events = generate_time_events(
1915 chrono_tz::Tz::Europe__Amsterdam,
1916 date_time_span("2025-11-10", "14:00:00", "2025-11-10", "17:00:00"),
1917 TimeRestrictions {
1918 start_time: Some("15:00".parse().unwrap()),
1919 ..TimeRestrictions::default()
1920 },
1921 );
1922
1923 let [event] = events.try_into().unwrap();
1924 assert_eq!(event.duration_from_start, TimeDelta::hours(1));
1925 }
1926
1927 #[test]
1928 fn should_emit_one_event_almost_full_day() {
1929 let events = generate_time_events(
1932 chrono_tz::Tz::Europe__Amsterdam,
1933 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "14:59:00"),
1934 TimeRestrictions {
1935 start_time: Some("15:00".parse().unwrap()),
1936 ..TimeRestrictions::default()
1937 },
1938 );
1939
1940 let [event] = events.try_into().unwrap();
1941 assert_eq!(event.duration_from_start, TimeDelta::zero());
1942 }
1943
1944 #[test]
1945 fn should_emit_two_events_full_day_precisely() {
1946 let events = generate_time_events(
1947 chrono_tz::Tz::Europe__Amsterdam,
1948 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "15:00:00"),
1949 TimeRestrictions {
1950 start_time: Some("15:00".parse().unwrap()),
1951 ..TimeRestrictions::default()
1952 },
1953 );
1954
1955 let [event_0, event_1] = events.try_into().unwrap();
1956 assert_eq!(event_0.duration_from_start, TimeDelta::zero());
1957 assert_eq!(event_1.duration_from_start, TimeDelta::days(1));
1958 }
1959
1960 #[test]
1961 fn should_emit_two_events_full_day_with_hour_margin() {
1962 let events = generate_time_events(
1963 chrono_tz::Tz::Europe__Amsterdam,
1964 date_time_span("2025-11-10", "14:00:00", "2025-11-11", "16:00:00"),
1965 TimeRestrictions {
1966 start_time: Some("15:00".parse().unwrap()),
1967 ..TimeRestrictions::default()
1968 },
1969 );
1970
1971 let [event_0, event_1] = events.try_into().unwrap();
1972 assert_eq!(event_0.duration_from_start, TimeDelta::hours(1));
1973 assert_eq!(
1974 event_1.duration_from_start,
1975 TimeDelta::days(1) + TimeDelta::hours(1)
1976 );
1977 }
1978}