eeg-billing 0.20.0

Pure EEG/KWKG feed-in settlement for German energy markets — EEG 2017–2024 and § 7 KWKG 2023. Zero I/O, no float money.
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
412
413
414
415
//! [`ErzeugungsArt`] — typed EEG/KWKG plant technology category.
//!
//! Maps 1:1 to the `erzeugungsart` TEXT column in `einsd`'s `eeg_anlagen` table.
//! Used for technology-specific rule dispatch (e.g. §51 EEG 2017 wind exemption).

// ── ErzeugungsArt ─────────────────────────────────────────────────────────────

/// EEG/KWKG plant technology type.
///
/// ## §51 EEG 2017 relevance
///
/// EEG 2017 distinguishes wind turbines (<3 MW exempt) from all other types (<500 kW exempt).
/// Use [`ErzeugungsArt::is_wind`] to select the correct §51 threshold.
///
/// ## DB mapping
///
/// | `ErzeugungsArt` | DB `erzeugungsart` TEXT |
/// |---|---|
/// | `SolarAufdach` | `"SOLAR_AUFDACH"` |
/// | `SolarFreiflaeche` | `"SOLAR_FREIFLAECHE"` |
/// | `SolarAgriPv` | `"SOLAR_AGRIPV"` |
/// | `SolarMieterstrom` | `"SOLAR_MIETERSTROM"` |
/// | `SolarStecker` | `"SOLAR_STECKER"` |
/// | `WindOnshore` | `"WIND_ONSHORE"` |
/// | `WindOffshore` | `"WIND_OFFSHORE"` |
/// | `Biomasse` | `"BIOMASSE"` |
/// | `BiomassHolz` | `"BIOMASSE_HOLZ"` |
/// | `Biogas` | `"BIOGAS"` |
/// | `Biomethan` | `"BIOMETHAN"` |
/// | `Klaergas` | `"KLAERGAS"` |
/// | `Grubengas` | `"GRUBENGAS"` |
/// | `Deponiegas` | `"DEPONIEGAS"` |
/// | `Wasserkraft` | `"WASSERKRAFT"` |
/// | `Geothermie` | `"GEOTHERMIE"` |
/// | `Gezeiten` | `"GEZEITEN"` |
/// | `Kwk` | `"KWKG"` |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ErzeugungsArt {
    /// Rooftop PV — a Solaranlage „auf, an oder in einem Gebäude oder einer
    /// Lärmschutzwand", whose anzulegender Wert is § 48 Abs. 2 (plus the
    /// Abs. 2a Volleinspeisung uplift). § 3 Nr. 41b makes it a **Solaranlage des
    /// zweiten Segments**, so its Ausschreibungsgrenze is 750 kW
    /// (§ 22 Abs. 3 Satz 2 Nr. 1a).
    ///
    /// There is deliberately no generic `Solar` variant. The §48 rate depends on
    /// where the plant sits, so a plant recorded as "solar, unspecified" cannot
    /// be priced — it was the default, which meant an omitted Bauform silently
    /// became a rooftop rate on a Freiflächenanlage.
    #[default]
    SolarAufdach,
    /// Ground-mounted PV (Freiflächenanlage) — § 48 Abs. 1 sets which surfaces
    /// qualify and § 48 Abs. 1a its gesetzlich bestimmter Wert. § 3 Nr. 41a
    /// makes it a **Solaranlage des ersten Segments**: Ausschreibung above 1 MW
    /// (§ 22 Abs. 3 Satz 2 Nr. 1).
    SolarFreiflaeche,
    /// Agri-PV — a **besondere Solaranlage** under § 48 Abs. 1 Satz 1 Nr. 5
    /// Buchst. a (Ackerflächen mit gleichzeitigem Nutzpflanzenanbau), whose
    /// uplift is § 48 Abs. 1b. Erstes Segment, like every Freiflächenanlage.
    SolarAgriPv,
    /// Mieterstrom building solar — the Mieterstromzuschlag is § 21 Abs. 3, its
    /// anzulegender Wert § 48a.
    SolarMieterstrom,
    /// Balkonkraftwerk / Stecker-PV — § 8 Abs. 5a: up to 2 kW installed and
    /// 800 VA inverter power behind a Letztverbraucher's Entnahmestelle.
    SolarStecker,
    /// Wind onshore — anzulegender Wert § 46, Gebote § 36, Ausschreibungspflicht
    /// above 1 MW (§ 22 Abs. 2 Satz 2 Nr. 1).
    WindOnshore,
    /// Wind offshore — outside the EEG's own rate sections: the Zuschlag and the
    /// anzulegender Wert come from the **Windenergie-auf-See-Gesetz**, which
    /// § 22 Abs. 1 refers to.
    WindOffshore,
    /// Biomasse — § 42 sets 12,67 ct/kWh bis 150 kW Bemessungsleistung
    /// (Biomethan excluded by Satz 2); §§ 43/44 carry the Bioabfall- and
    /// Güllevergärung claims.
    Biomasse,
    /// Feste Biomasse (Holz). The EEG 2023 sets **no separate anzulegender Wert**
    /// for it and imposes **no fresh-wood restriction** — it is settled as
    /// Biomasse under § 42; the sustainability rules for solid biomass sit
    /// outside the EEG.
    BiomassHolz,
    /// Biogas (plant-based gas).
    Biogas,
    /// Biomethan (upgraded biomethane).
    Biomethan,
    /// Klärgas (sewage gas).
    Klaergas,
    /// Grubengas (mine gas).
    Grubengas,
    /// Deponiegas (landfill gas).
    Deponiegas,
    /// Wasserkraft (run-of-river and reservoir hydro).
    Wasserkraft,
    /// Geothermie.
    Geothermie,
    /// Gezeitenenergie (tidal).
    Gezeiten,
    /// Kraft-Wärme-Kopplungsanlage (KWKG, not EEG).
    Kwk,
}

impl ErzeugungsArt {
    /// Every variant, in declaration order. Lets callers (and the einsd
    /// schema↔enum guard test) enumerate the canonical `to_db_str` vocabulary
    /// exhaustively — adding a variant updates this array via the compiler's
    /// exhaustiveness check on the mapping functions.
    pub const ALL: [Self; 18] = [
        Self::SolarAufdach,
        Self::SolarFreiflaeche,
        Self::SolarAgriPv,
        Self::SolarMieterstrom,
        Self::SolarStecker,
        Self::WindOnshore,
        Self::WindOffshore,
        Self::Biomasse,
        Self::BiomassHolz,
        Self::Biogas,
        Self::Biomethan,
        Self::Klaergas,
        Self::Grubengas,
        Self::Deponiegas,
        Self::Wasserkraft,
        Self::Geothermie,
        Self::Gezeiten,
        Self::Kwk,
    ];

    /// Returns `true` for wind turbines (onshore or offshore).
    ///
    /// Used for §51 Abs. 3 Nr. 1 EEG 2017: wind turbines <3 MW are exempt
    /// (higher threshold than the 500 kW for "sonstige Anlagen").
    pub fn is_wind(self) -> bool {
        matches!(self, Self::WindOnshore | Self::WindOffshore)
    }

    /// Returns `true` for solar PV variants (all rooftop, ground-mounted, agri-PV).
    pub fn is_solar(self) -> bool {
        matches!(
            self,
            Self::SolarAufdach
                | Self::SolarFreiflaeche
                | Self::SolarAgriPv
                | Self::SolarMieterstrom
                | Self::SolarStecker
        )
    }

    /// Returns `true` for biomass/biogas/biomethan/gas variants.
    pub fn is_biomasse_or_gas(self) -> bool {
        matches!(
            self,
            Self::Biomasse
                | Self::BiomassHolz
                | Self::Biogas
                | Self::Biomethan
                | Self::Klaergas
                | Self::Grubengas
                | Self::Deponiegas
        )
    }

    /// Parse from the DB `erzeugungsart` TEXT column.
    ///
    /// Returns `Err` for unknown values — callers should fall back to `Solar`
    /// or log a warning for unexpected technology codes.
    pub fn from_db_str(s: &str) -> Result<Self, InvalidErzeugungsArt> {
        match s {
            "SOLAR_AUFDACH" => Ok(Self::SolarAufdach),
            "SOLAR_FREIFLAECHE" => Ok(Self::SolarFreiflaeche),
            "SOLAR_AGRIPV" => Ok(Self::SolarAgriPv),
            "SOLAR_MIETERSTROM" => Ok(Self::SolarMieterstrom),
            "SOLAR_STECKER" => Ok(Self::SolarStecker),
            "WIND_ONSHORE" => Ok(Self::WindOnshore),
            "WIND_OFFSHORE" => Ok(Self::WindOffshore),
            "BIOMASSE" => Ok(Self::Biomasse),
            "BIOMASSE_HOLZ" => Ok(Self::BiomassHolz),
            "BIOGAS" => Ok(Self::Biogas),
            "BIOMETHAN" => Ok(Self::Biomethan),
            "KLAERGAS" => Ok(Self::Klaergas),
            "GRUBENGAS" => Ok(Self::Grubengas),
            "DEPONIEGAS" => Ok(Self::Deponiegas),
            "WASSERKRAFT" => Ok(Self::Wasserkraft),
            "GEOTHERMIE" => Ok(Self::Geothermie),
            "GEZEITEN" => Ok(Self::Gezeiten),
            "KWKG" => Ok(Self::Kwk),
            _ => Err(InvalidErzeugungsArt(s.to_owned())),
        }
    }

    /// The canonical DB column value for this variant.
    pub fn to_db_str(self) -> &'static str {
        match self {
            Self::SolarAufdach => "SOLAR_AUFDACH",
            Self::SolarFreiflaeche => "SOLAR_FREIFLAECHE",
            Self::SolarAgriPv => "SOLAR_AGRIPV",
            Self::SolarMieterstrom => "SOLAR_MIETERSTROM",
            Self::SolarStecker => "SOLAR_STECKER",
            Self::WindOnshore => "WIND_ONSHORE",
            Self::WindOffshore => "WIND_OFFSHORE",
            Self::Biomasse => "BIOMASSE",
            Self::BiomassHolz => "BIOMASSE_HOLZ",
            Self::Biogas => "BIOGAS",
            Self::Biomethan => "BIOMETHAN",
            Self::Klaergas => "KLAERGAS",
            Self::Grubengas => "GRUBENGAS",
            Self::Deponiegas => "DEPONIEGAS",
            Self::Wasserkraft => "WASSERKRAFT",
            Self::Geothermie => "GEOTHERMIE",
            Self::Gezeiten => "GEZEITEN",
            Self::Kwk => "KWKG",
        }
    }
}

// ── Error type ────────────────────────────────────────────────────────────────

/// Returned by [`ErzeugungsArt::from_db_str`] for unknown technology strings.
#[derive(Debug, thiserror::Error)]
#[error("unknown erzeugungsart: {0:?}")]
pub struct InvalidErzeugungsArt(pub String);

// ── InbetriebnahmeTyp ─────────────────────────────────────────────────────────

/// Type of commissioning event that started (or restarted) the EEG Förderdauer.
///
/// The commissioning type determines which regulatory rules apply and whether
/// the 20-year Förderdauer clock is reset or continues from the original date.
///
/// ## Legal basis
///
/// §3 Nr. 30 EEG 2023 defines "Inbetriebnahme" as the first feed-in of
/// electricity after all necessary installations are complete.
///
/// §22 EEG 2023 (Repowering): replacing components with higher capacity resets
/// the Förderdauer clock.
///
/// §24 EEG 2023 (Zusammenlegung): merging physically separate plants does NOT
/// reset the clock — the oldest plant's Förderdauer continues.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum InbetriebnahmeTyp {
    /// §3 Nr. 30 EEG 2023: first time the plant generates electricity.
    ///
    /// Starts the 20-year Förderdauer. All EEG rules apply from commissioning date.
    #[default]
    Erstinbetriebnahme,

    /// §3 Nr. 30 EEG 2023: temporary shutdown + restart (same plant, same capacity).
    ///
    /// Does NOT reset the Förderdauer. The original commissioning date continues
    /// to govern tariff and duration. Typical: plant moved, repaired, or temporarily
    /// decommissioned and returned to operation.
    Wiederinbetriebnahme,

    /// §3 Nr. 30a EEG 2023: technical modernization without capacity increase.
    ///
    /// Replaces equipment (inverter, cables) but not generators. Förderdauer continues.
    /// May affect technical compliance status (e.g. Fernsteuerbarkeit).
    Modernisierung,

    /// §22 EEG 2023: repowering — complete replacement of generating components.
    ///
    /// **Resets the Förderdauer clock** to the repowering date. The plant receives a
    /// new 20-year subsidy period at the tariff valid at the repowering commissioning.
    /// Use `foerderendedatum_repowering(repowering_datum)` to compute the new end date.
    Repowering,

    /// §24 EEG 2023: plant created by Zusammenlegung of multiple existing plants.
    ///
    /// Does NOT reset the Förderdauer. The oldest component plant's commissioning date
    /// governs the subsidy duration for the merged entity.
    /// Individual component plants continue under their original `foerderendedatum`.
    Zusammenlegung,

    /// §24 EEG 2023: capacity extension block (Erweiterung).
    ///
    /// The extension block starts its own 20-year Förderdauer at the extension date,
    /// at the tariff valid at that date (typically lower due to degression).
    /// Model via `CapacityBlock` in `SettleInput`.
    Erweiterung,
}

impl InbetriebnahmeTyp {
    /// Returns `true` when this commissioning type resets the 20-year Förderdauer.
    ///
    /// Only `Repowering` resets the clock. All other types continue from the
    /// original commissioning date (or start a new parallel block for `Erweiterung`).
    #[must_use]
    pub fn resets_foerderdauer(self) -> bool {
        self == Self::Repowering
    }

    /// Returns `true` for the initial commissioning (first electricity generation).
    #[must_use]
    pub fn is_erstinbetriebnahme(self) -> bool {
        self == Self::Erstinbetriebnahme
    }

    /// Parse from the DB `inbetriebnahme_typ` TEXT column.
    pub fn from_db_str(s: &str) -> Result<Self, InvalidInbetriebnahmeTyp> {
        match s {
            "ERSTINBETRIEBNAHME" => Ok(Self::Erstinbetriebnahme),
            "WIEDERINBETRIEBNAHME" => Ok(Self::Wiederinbetriebnahme),
            "MODERNISIERUNG" => Ok(Self::Modernisierung),
            "REPOWERING" => Ok(Self::Repowering),
            "ZUSAMMENLEGUNG" => Ok(Self::Zusammenlegung),
            "ERWEITERUNG" => Ok(Self::Erweiterung),
            _ => Err(InvalidInbetriebnahmeTyp(s.to_owned())),
        }
    }

    /// Canonical DB column value.
    #[must_use]
    pub fn to_db_str(self) -> &'static str {
        match self {
            Self::Erstinbetriebnahme => "ERSTINBETRIEBNAHME",
            Self::Wiederinbetriebnahme => "WIEDERINBETRIEBNAHME",
            Self::Modernisierung => "MODERNISIERUNG",
            Self::Repowering => "REPOWERING",
            Self::Zusammenlegung => "ZUSAMMENLEGUNG",
            Self::Erweiterung => "ERWEITERUNG",
        }
    }
}

/// Returned by [`InbetriebnahmeTyp::from_db_str`] for unknown values.
#[derive(Debug, thiserror::Error)]
#[error("unknown inbetriebnahme_typ: {0:?}")]
pub struct InvalidInbetriebnahmeTyp(pub String);

// ── RepoweringScope ───────────────────────────────────────────────────────────

/// Scope of a repowering event — determines whether the 20-year Förderdauer resets.
///
/// Repowering is one of the most legally complex topics in the EEG. Whether the
/// Förderdauer resets depends on what exactly was replaced.
///
/// ## §22 EEG 2023 — Key rule
///
/// The Förderdauer resets only for **Vollrepowering** (complete new plant at the
/// same site). Partial component replacements do NOT reset the clock — the original
/// commissioning date continues to govern.
///
/// ## Practical guidance
///
/// When in doubt, consult BNetzA guidance or a specialized EEG attorney.
/// The distinction between `RotorBlade`, `WholeNacelle`, and `TurbineUnit` is
/// fact-specific and the BNetzA has issued conflicting guidance in edge cases.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum RepoweringScope {
    /// **Vollrepowering**: Complete replacement of all generating components
    /// (generator, nacelle, rotor, tower, foundation).
    ///
    /// **Resets Förderdauer** to the new commissioning date.
    /// Equivalent to a new plant at the same grid connection point.
    /// Uses `foerderendedatum_repowering(new_commissioning_date)`.
    Full,

    /// **Teilrepowering — Rotor only**: rotor blades and hub replaced,
    /// nacelle and generator unchanged.
    ///
    /// **Does NOT reset Förderdauer.** Old commissioning date continues.
    /// Rotor replacement alone does not constitute "Inbetriebnahme" under §3 Nr. 30 EEG 2023.
    RotorOnly,

    /// **Teilrepowering — Nacelle and rotor replaced**, tower and foundation unchanged.
    ///
    /// **Legal status is contested** (BNetzA has not issued definitive guidance).
    /// Conservative interpretation: Förderdauer does NOT reset (original date governs).
    /// Aggressive interpretation: may reset if generator output increases substantially.
    NacelleAndRotor,

    /// **Teilrepowering — Complete turbine unit replaced** (generator + nacelle + rotor),
    /// but tower and foundation unchanged.
    ///
    /// **Legal status is contested.** Most EEG specialists consider this a
    /// Vollrepowering (Förderdauer resets) when capacity increases significantly.
    /// BNetzA position: resets if the turbine is "technisch and wirtschaftlich neu."
    TurbineUnit,

    /// **Repowering with capacity increase** — same classification as `Full` but
    /// explicitly tracks that the new plant has higher rated power than the original.
    ///
    /// Relevant for Ausschreibungspflicht threshold check (§22 EEG 2023):
    /// the new capacity may push the plant above the 750 kW wind tender threshold.
    FullWithCapacityIncrease,
}

impl RepoweringScope {
    /// Returns `true` when this repowering scope **definitely resets** the Förderdauer.
    ///
    /// Returns `false` for contested cases — the caller must resolve the legal question
    /// before computing the new Förderdauer.
    #[must_use]
    pub fn resets_foerderdauer_definitely(self) -> bool {
        matches!(self, Self::Full | Self::FullWithCapacityIncrease)
    }

    /// Returns `true` when this scope involves replacing the nacelle or generating unit.
    ///
    /// Rotor-only replacement never replaces the generating unit.
    #[must_use]
    pub fn replaces_generating_unit(self) -> bool {
        matches!(
            self,
            Self::Full | Self::FullWithCapacityIncrease | Self::NacelleAndRotor | Self::TurbineUnit
        )
    }
}