meterstore 0.8.0

Hot/cold tiered store for metering time series — PostgreSQL for the recent window, Apache Iceberg for history.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Which day a reading is balanced on, and how many intervals that day holds.
//!
//! # What this module decides
//!
//! MeterStore implements neither the calendar arithmetic (P5) nor the *choice* of
//! calendar. `metering::calendar` owns both, as [`DayBoundary`] — `Midnight` or
//! `Gastag` — carrying the whole period API: day start and end, the day an
//! instant falls in, day length, interval count, and the month and year
//! equivalents.
//!
//! That leaves one thing here, and it is a **storage** fact rather than a
//! calendar one: a stored row carries its `sparte`, so the store knows which
//! boundary applies to it. [`day_boundary`] is that mapping, written once — a
//! `match sparte` per function would be one rule in five places to drift.
//! Everything else here is a two-line delegation, for callers holding a `Sparte`
//! rather than a `DayBoundary`.
//!
//! # The Gastag
//!
//! Electricity, heat and water balance on the Berlin **calendar day**: 00:00 to
//! 00:00 local. Gas does not: the German gas market balances on the **Gastag**,
//! 06:00 to 06:00 local (GaBi Gas, following the EU-wide convention of Art. 3
//! Nr. 6 VO (EU) 312/2014, and the span over which the BDEW/VKU/GEODE Leitfaden
//! *SLP Gas* forms its daily mean temperatures).
//!
//! Grouping a gas Lastgang by the calendar day is the same class of error as
//! grouping an electricity one by the UTC day — it books the 00:00–06:00 draw
//! into the wrong Bilanzierungstag, six hours a day, every day, and the totals
//! still look plausible. It also misplaces the DST anomaly: the clocks change at
//! 02:00/03:00 local, *before* the 06:00 boundary, so the 23- and 25-hour gas
//! days are the ones named after the **Saturday**, not the transition Sunday. A
//! completeness check using calendar days would report the Sunday four intervals
//! short and the Saturday four in surplus, and both would be wrong.
//!
//! # And the Bilanzierungsmonat
//!
//! The boundary carries **up to the month**, which is the market's own rule
//! rather than an extrapolation — EDI@Energy *Allgemeine Festlegungen* v6.1c,
//! Kap. 3.1 spells the Bilanzierungsmonat Juni 2021 out as 01.06 00:00 to 01.07
//! 00:00 for Strom and 01.06 **06:00** to 01.07 **06:00** for Gas. That is what
//! [`balancing_month`] returns, and it is the month an MSCONS version scope is
//! keyed to — see [`VersionScope`](crate::version::VersionScope).
//! [`balancing_month_bounds`] gives it as a half-open UTC range, and
//! [`bilanzierungsmonat`] that range addressed by name — *"Juni 2026"*.
//!
//! [`DayBoundary`]: metering::calendar::DayBoundary

use metering::IntervalResolution;
use metering::calendar::DayBoundary;
use metering::interval::Sparte;
use time::{Date, Duration, Month, OffsetDateTime};

/// The daily boundary a commodity is balanced on.
///
/// **The one rule this module owns.** `metering` supplies both boundaries and
/// everything computed from them; which of the two applies to a given row is a
/// fact about the row's `sparte`, and a stored row carries one.
///
/// ```rust
/// use meterstore::planner::day_boundary;
/// use metering::calendar::DayBoundary;
/// use metering::interval::Sparte;
///
/// assert_eq!(day_boundary(Sparte::Gas), DayBoundary::Gastag);
/// for other in [Sparte::Strom, Sparte::Waerme, Sparte::Wasser] {
///     assert_eq!(day_boundary(other), DayBoundary::Midnight);
/// }
/// ```
#[must_use]
pub const fn day_boundary(sparte: Sparte) -> DayBoundary {
    match sparte {
        Sparte::Gas => DayBoundary::Gastag,
        _ => DayBoundary::Midnight,
    }
}

/// The day `instant` is balanced on, for a reading of commodity `sparte`.
///
/// The Gastag for [`Sparte::Gas`], the Berlin calendar day for everything else.
/// This is the grouping key every daily aggregate wants, the value the encoder
/// stores in `balancing_day`, and the one `meter_balancing_day` exposes to SQL.
///
/// ```rust
/// use meterstore::planner::balancing_day;
/// use metering::interval::Sparte;
/// use time::macros::{date, datetime};
///
/// // 03:00 UTC on 15 July is 05:00 local — before the 06:00 gas boundary.
/// let at = datetime!(2026-07-15 3:00 UTC);
/// assert_eq!(balancing_day(at, Sparte::Strom), date!(2026 - 07 - 15));
/// assert_eq!(balancing_day(at, Sparte::Gas), date!(2026 - 07 - 14));
/// ```
#[must_use]
pub fn balancing_day(instant: OffsetDateTime, sparte: Sparte) -> Date {
    day_boundary(sparte).local_day(instant)
}

/// The **Bilanzierungsmonat** `instant` falls in, as its first day.
///
/// A gas month runs 06:00 on the first to 06:00 on the first of the next, so it
/// is a whole number of Gastage rather than a calendar month shifted. An
/// interval at 02:00 local on 1 March therefore belongs to *February* for gas
/// and to March for everything else.
///
/// This is the month an MSCONS correction version is scoped to, which is why it
/// is here rather than left to the caller: getting it wrong makes a
/// correctly-scoped gas delivery fail at the write.
///
/// ```rust
/// use meterstore::planner::balancing_month;
/// use metering::interval::Sparte;
/// use time::macros::{date, datetime};
///
/// // 01:00 UTC on 1 March is 02:00 local: already March, but still the
/// // February Gastag — and so the February Bilanzierungsmonat.
/// let at = datetime!(2026-03-01 1:00 UTC);
/// assert_eq!(balancing_month(at, Sparte::Strom), date!(2026 - 03 - 01));
/// assert_eq!(balancing_month(at, Sparte::Gas), date!(2026 - 02 - 01));
/// ```
#[must_use]
pub fn balancing_month(instant: OffsetDateTime, sparte: Sparte) -> Date {
    day_boundary(sparte).local_month(instant)
}

/// The half-open UTC bounds `[start, end)` of a balancing day.
///
/// Consecutive days tile the timeline with no gap and no overlap, for both
/// definitions and across both DST transitions — which is what lets a
/// completeness roll-up clip a partly covered day to the fraction inside the
/// queried range.
#[must_use]
pub fn balancing_day_bounds(day: Date, sparte: Sparte) -> (OffsetDateTime, OffsetDateTime) {
    let boundary = day_boundary(sparte);
    (boundary.day_start_utc(day), boundary.day_end_utc(day))
}

/// The half-open UTC bounds `[start, end)` of a **balancing month**.
///
/// [`balancing_day_bounds`] one period up: the month containing `day`, cut on
/// the boundary the commodity is balanced on. A gas month is a whole number of
/// Gastage — 01.06 06:00 to 01.07 06:00 — rather than a calendar month shifted,
/// and neither is a fixed number of hours, because a month may contain a DST
/// transition.
///
/// The range a settlement rerun or a completeness report over a settlement
/// period is stated in.
///
/// ```rust
/// use meterstore::planner::balancing_month_bounds;
/// use metering::interval::Sparte;
/// use time::macros::{date, datetime};
///
/// let (from, to) = balancing_month_bounds(date!(2026 - 06 - 15), Sparte::Strom);
/// assert_eq!(from, datetime!(2026-05-31 22:00 UTC));
/// assert_eq!(to, datetime!(2026-06-30 22:00 UTC));
///
/// // The same span, six hours later.
/// let (gas_from, gas_to) = balancing_month_bounds(date!(2026 - 06 - 15), Sparte::Gas);
/// assert_eq!(gas_from, datetime!(2026-06-01 4:00 UTC));
/// assert_eq!(gas_to, datetime!(2026-07-01 4:00 UTC));
/// ```
#[must_use]
pub fn balancing_month_bounds(day: Date, sparte: Sparte) -> (OffsetDateTime, OffsetDateTime) {
    day_boundary(sparte).month_range_utc(day)
}

/// The **Bilanzierungsmonat** the market names — *"Juni 2026"* — as a half-open
/// UTC range.
///
/// [`balancing_month_bounds`] addressed by name rather than by a date inside it.
/// Both boundaries are EDI@Energy *Allgemeine Festlegungen* v6.1c, Kap. 3.1:
/// Strom runs 00:00 to 00:00 and Gas 06:00 to 06:00.
///
/// ```rust
/// use meterstore::planner::bilanzierungsmonat;
/// use metering::interval::Sparte;
/// use time::Month;
/// use time::macros::datetime;
///
/// // March contains the spring-forward Sunday, so the month is an hour short
/// // of 31 days — for both commodities.
/// let (from, to) = bilanzierungsmonat(2026, Month::March, Sparte::Strom);
/// assert_eq!(from, datetime!(2026-02-28 23:00 UTC));
/// assert_eq!((to - from).whole_hours(), 31 * 24 - 1);
///
/// let (gas_from, gas_to) = bilanzierungsmonat(2026, Month::March, Sparte::Gas);
/// assert_eq!(gas_from, datetime!(2026-03-01 5:00 UTC));
/// assert_eq!((gas_to - gas_from).whole_hours(), 31 * 24 - 1);
/// ```
#[must_use]
pub fn bilanzierungsmonat(
    year: i32,
    month: Month,
    sparte: Sparte,
) -> (OffsetDateTime, OffsetDateTime) {
    day_boundary(sparte).bilanzierungsmonat(year, month)
}

/// How long a balancing day is — 23, 24 or 25 hours.
#[must_use]
pub fn balancing_day_length(day: Date, sparte: Sparte) -> Duration {
    day_boundary(sparte).day_length(day)
}

/// How many intervals of `resolution` a balancing day holds, for `sparte`.
///
/// [`metering::calendar::intervals_in_day`] under the boundary the commodity is
/// balanced on. `None` for a resolution coarser than a day (`P1M`, `P1Y`), which
/// has no fixed count within one — the honest answer, rather than an invented 96.
///
/// ```rust
/// use meterstore::planner::expected_intervals_in_balancing_day as intervals;
/// use metering::IntervalResolution;
/// use metering::interval::Sparte;
/// use time::macros::date;
///
/// let q = IntervalResolution::QuarterHour;
/// assert_eq!(intervals(date!(2026 - 07 - 15), q, Sparte::Strom), Some(96));
///
/// // The 25-hour day is the Sunday for the calendar and the **Saturday** for
/// // gas: the clocks move at 03:00, inside the Gastag that began the morning
/// // before.
/// assert_eq!(intervals(date!(2026 - 10 - 25), q, Sparte::Strom), Some(100));
/// assert_eq!(intervals(date!(2026 - 10 - 24), q, Sparte::Gas), Some(100));
/// assert_eq!(intervals(date!(2026 - 10 - 25), q, Sparte::Gas), Some(96));
///
/// // A daily series expects one interval per day, gas or not; a monthly one has
/// // no count within a day at all.
/// assert_eq!(intervals(date!(2026 - 07 - 15), IntervalResolution::Day, Sparte::Gas), Some(1));
/// assert_eq!(intervals(date!(2026 - 07 - 15), IntervalResolution::Month, Sparte::Gas), None);
/// ```
#[must_use]
pub fn expected_intervals_in_balancing_day(
    day: Date,
    resolution: IntervalResolution,
    sparte: Sparte,
) -> Option<u32> {
    day_boundary(sparte).intervals_in_day(day, resolution)
}

#[cfg(test)]
mod tests {
    use super::*;
    use time::macros::{date, datetime};

    #[test]
    fn the_only_rule_this_module_owns_is_the_sparte_mapping() {
        // Everything else delegates, so this is the one thing worth asserting
        // directly: gas is the exception and the other three are not.
        assert_eq!(day_boundary(Sparte::Gas), DayBoundary::Gastag);
        for other in [Sparte::Strom, Sparte::Waerme, Sparte::Wasser] {
            assert_eq!(day_boundary(other), DayBoundary::Midnight);
        }
    }

    #[test]
    fn the_gas_day_starts_six_hours_late() {
        // 05:00 local on 15 July is still the Gastag of the 14th; 07:00 is the
        // 15th. The calendar day never moves.
        let before = datetime!(2026-07-15 3:00 UTC); // 05:00 local
        let after = datetime!(2026-07-15 5:00 UTC); // 07:00 local

        assert_eq!(balancing_day(before, Sparte::Gas), date!(2026 - 07 - 14));
        assert_eq!(balancing_day(after, Sparte::Gas), date!(2026 - 07 - 15));
        for at in [before, after] {
            assert_eq!(balancing_day(at, Sparte::Strom), date!(2026 - 07 - 15));
        }
    }

    #[test]
    fn the_bilanzierungsmonat_carries_the_boundary_up() {
        // EDI@Energy Allgemeine Festlegungen v6.1c Kap. 3.1: the gas
        // Bilanzierungsmonat runs 06:00 on the first to 06:00 on the first of
        // the next, so the first six hours of a calendar month belong to the
        // previous one.
        let early = datetime!(2026-03-01 1:00 UTC); // 02:00 local, 1 March
        assert_eq!(balancing_month(early, Sparte::Strom), date!(2026 - 03 - 01));
        assert_eq!(balancing_month(early, Sparte::Gas), date!(2026 - 02 - 01));

        // And once past 06:00 local the two agree again.
        let later = datetime!(2026-03-01 6:00 UTC); // 07:00 local
        for sparte in [Sparte::Strom, Sparte::Gas] {
            assert_eq!(balancing_month(later, sparte), date!(2026 - 03 - 01));
        }
    }

    #[test]
    fn a_balancing_month_is_the_month_of_the_balancing_day() {
        // The property the delegation rests on, over a whole year of instants.
        let mut at = datetime!(2026-01-01 00:00 UTC);
        let end = datetime!(2027-01-01 00:00 UTC);
        while at < end {
            for sparte in [Sparte::Strom, Sparte::Gas] {
                let day = balancing_day(at, sparte);
                let month = balancing_month(at, sparte);
                assert_eq!(month.year(), day.year(), "{at} {sparte}");
                assert_eq!(month.month(), day.month(), "{at} {sparte}");
                assert_eq!(month.day(), 1, "{at} {sparte}");
            }
            at += Duration::hours(7);
        }
    }

    #[test]
    fn consecutive_balancing_days_tile_the_timeline() {
        // No gap and no overlap, across both transitions, for both boundaries —
        // which is what lets completeness clip a partly covered day.
        for sparte in [Sparte::Strom, Sparte::Gas] {
            for start in [date!(2026 - 03 - 28), date!(2026 - 10 - 23)] {
                let mut day = start;
                for _ in 0..4 {
                    let (_, end) = balancing_day_bounds(day, sparte);
                    let next = day.next_day().expect("in range");
                    let (next_start, _) = balancing_day_bounds(next, sparte);
                    assert_eq!(end, next_start, "{day} -> {next} ({sparte})");
                    day = next;
                }
            }
        }
    }

    #[test]
    fn the_dst_anomaly_sits_on_a_different_named_day_for_gas() {
        // The clocks change at 02:00/03:00 local, before the 06:00 boundary, so
        // the long and short Gastage are named after the Saturday while the
        // calendar ones are named after the Sunday. Getting this backwards would
        // report one day four short and its neighbour four in surplus.
        let q = IntervalResolution::QuarterHour;

        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 10 - 24), q, Sparte::Gas),
            Some(100)
        );
        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 10 - 25), q, Sparte::Gas),
            Some(96)
        );
        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 10 - 25), q, Sparte::Strom),
            Some(100)
        );

        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 03 - 28), q, Sparte::Gas),
            Some(92)
        );
        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 03 - 29), q, Sparte::Gas),
            Some(96)
        );
        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 03 - 29), q, Sparte::Strom),
            Some(92)
        );
    }

    #[test]
    fn a_daily_resolution_expects_one_interval_on_both_boundaries() {
        // A count derived from the resolution's fixed length instead would
        // report a daily gas series unmeasurable — `P1D` has no fixed length —
        // while a daily electricity one expected 1.
        for sparte in [Sparte::Strom, Sparte::Gas] {
            assert_eq!(
                expected_intervals_in_balancing_day(
                    date!(2026 - 07 - 15),
                    IntervalResolution::Day,
                    sparte
                ),
                Some(1),
                "{sparte}"
            );
            // A resolution coarser than a day still has no count within one.
            for coarse in [IntervalResolution::Month, IntervalResolution::Year] {
                assert_eq!(
                    expected_intervals_in_balancing_day(date!(2026 - 07 - 15), coarse, sparte),
                    None,
                    "{sparte} {coarse:?}"
                );
            }
        }
    }

    #[test]
    fn sub_quarter_hour_gas_resolutions_scale_with_the_day() {
        // Not only the quarter-hour: a one-minute gas profile expects 1 500
        // intervals on the long Gastag, and 1 440 on an ordinary one.
        let minute = IntervalResolution::from_seconds(60).expect("a minute is a resolution");
        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 07 - 15), minute, Sparte::Gas),
            Some(1_440)
        );
        assert_eq!(
            expected_intervals_in_balancing_day(date!(2026 - 10 - 24), minute, Sparte::Gas),
            Some(1_500)
        );
    }

    #[test]
    fn day_length_and_bounds_agree() {
        for sparte in [Sparte::Strom, Sparte::Gas] {
            for day in [
                date!(2026 - 07 - 15),
                date!(2026 - 03 - 28),
                date!(2026 - 10 - 24),
            ] {
                let (start, end) = balancing_day_bounds(day, sparte);
                assert_eq!(balancing_day_length(day, sparte), end - start);
            }
        }
    }
}