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