Skip to main content

eeg_billing/
technology.rs

1//! [`ErzeugungsArt`] — typed EEG/KWKG plant technology category.
2//!
3//! Maps 1:1 to the `erzeugungsart` TEXT column in `einsd`'s `eeg_anlagen` table.
4//! Used for technology-specific rule dispatch (e.g. §51 EEG 2017 wind exemption).
5
6// ── ErzeugungsArt ─────────────────────────────────────────────────────────────
7
8/// EEG/KWKG plant technology type.
9///
10/// ## §51 EEG 2017 relevance
11///
12/// EEG 2017 distinguishes wind turbines (<3 MW exempt) from all other types (<500 kW exempt).
13/// Use [`ErzeugungsArt::is_wind`] to select the correct §51 threshold.
14///
15/// ## DB mapping
16///
17/// | `ErzeugungsArt` | DB `erzeugungsart` TEXT |
18/// |---|---|
19/// | `SolarAufdach` | `"SOLAR_AUFDACH"` |
20/// | `SolarFreiflaeche` | `"SOLAR_FREIFLAECHE"` |
21/// | `SolarAgriPv` | `"SOLAR_AGRIPV"` |
22/// | `SolarMieterstrom` | `"SOLAR_MIETERSTROM"` |
23/// | `SolarStecker` | `"SOLAR_STECKER"` |
24/// | `WindOnshore` | `"WIND_ONSHORE"` |
25/// | `WindOffshore` | `"WIND_OFFSHORE"` |
26/// | `Biomasse` | `"BIOMASSE"` |
27/// | `BiomassHolz` | `"BIOMASSE_HOLZ"` |
28/// | `Biogas` | `"BIOGAS"` |
29/// | `Biomethan` | `"BIOMETHAN"` |
30/// | `Klaergas` | `"KLAERGAS"` |
31/// | `Grubengas` | `"GRUBENGAS"` |
32/// | `Deponiegas` | `"DEPONIEGAS"` |
33/// | `Wasserkraft` | `"WASSERKRAFT"` |
34/// | `Geothermie` | `"GEOTHERMIE"` |
35/// | `Gezeiten` | `"GEZEITEN"` |
36/// | `Kwk` | `"KWKG"` |
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
38#[non_exhaustive]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
41pub enum ErzeugungsArt {
42    /// Rooftop PV — a Solaranlage „auf, an oder in einem Gebäude oder einer
43    /// Lärmschutzwand", whose anzulegender Wert is § 48 Abs. 2 (plus the
44    /// Abs. 2a Volleinspeisung uplift). § 3 Nr. 41b makes it a **Solaranlage des
45    /// zweiten Segments**, so its Ausschreibungsgrenze is 750 kW
46    /// (§ 22 Abs. 3 Satz 2 Nr. 1a).
47    ///
48    /// There is deliberately no generic `Solar` variant. The §48 rate depends on
49    /// where the plant sits, so a plant recorded as "solar, unspecified" cannot
50    /// be priced — it was the default, which meant an omitted Bauform silently
51    /// became a rooftop rate on a Freiflächenanlage.
52    #[default]
53    SolarAufdach,
54    /// Ground-mounted PV (Freiflächenanlage) — § 48 Abs. 1 sets which surfaces
55    /// qualify and § 48 Abs. 1a its gesetzlich bestimmter Wert. § 3 Nr. 41a
56    /// makes it a **Solaranlage des ersten Segments**: Ausschreibung above 1 MW
57    /// (§ 22 Abs. 3 Satz 2 Nr. 1).
58    SolarFreiflaeche,
59    /// Agri-PV — a **besondere Solaranlage** under § 48 Abs. 1 Satz 1 Nr. 5
60    /// Buchst. a (Ackerflächen mit gleichzeitigem Nutzpflanzenanbau), whose
61    /// uplift is § 48 Abs. 1b. Erstes Segment, like every Freiflächenanlage.
62    SolarAgriPv,
63    /// Mieterstrom building solar — the Mieterstromzuschlag is § 21 Abs. 3, its
64    /// anzulegender Wert § 48a.
65    SolarMieterstrom,
66    /// Balkonkraftwerk / Stecker-PV — § 8 Abs. 5a: up to 2 kW installed and
67    /// 800 VA inverter power behind a Letztverbraucher's Entnahmestelle.
68    SolarStecker,
69    /// Wind onshore — anzulegender Wert § 46, Gebote § 36, Ausschreibungspflicht
70    /// above 1 MW (§ 22 Abs. 2 Satz 2 Nr. 1).
71    WindOnshore,
72    /// Wind offshore — outside the EEG's own rate sections: the Zuschlag and the
73    /// anzulegender Wert come from the **Windenergie-auf-See-Gesetz**, which
74    /// § 22 Abs. 1 refers to.
75    WindOffshore,
76    /// Biomasse — § 42 sets 12,67 ct/kWh bis 150 kW Bemessungsleistung
77    /// (Biomethan excluded by Satz 2); §§ 43/44 carry the Bioabfall- and
78    /// Güllevergärung claims.
79    Biomasse,
80    /// Feste Biomasse (Holz). The EEG 2023 sets **no separate anzulegender Wert**
81    /// for it and imposes **no fresh-wood restriction** — it is settled as
82    /// Biomasse under § 42; the sustainability rules for solid biomass sit
83    /// outside the EEG.
84    BiomassHolz,
85    /// Biogas (plant-based gas).
86    Biogas,
87    /// Biomethan (upgraded biomethane).
88    Biomethan,
89    /// Klärgas (sewage gas).
90    Klaergas,
91    /// Grubengas (mine gas).
92    Grubengas,
93    /// Deponiegas (landfill gas).
94    Deponiegas,
95    /// Wasserkraft (run-of-river and reservoir hydro).
96    Wasserkraft,
97    /// Geothermie.
98    Geothermie,
99    /// Gezeitenenergie (tidal).
100    Gezeiten,
101    /// Kraft-Wärme-Kopplungsanlage (KWKG, not EEG).
102    Kwk,
103}
104
105impl ErzeugungsArt {
106    /// Every variant, in declaration order. Lets callers (and the einsd
107    /// schema↔enum guard test) enumerate the canonical `to_db_str` vocabulary
108    /// exhaustively — adding a variant updates this array via the compiler's
109    /// exhaustiveness check on the mapping functions.
110    pub const ALL: [Self; 18] = [
111        Self::SolarAufdach,
112        Self::SolarFreiflaeche,
113        Self::SolarAgriPv,
114        Self::SolarMieterstrom,
115        Self::SolarStecker,
116        Self::WindOnshore,
117        Self::WindOffshore,
118        Self::Biomasse,
119        Self::BiomassHolz,
120        Self::Biogas,
121        Self::Biomethan,
122        Self::Klaergas,
123        Self::Grubengas,
124        Self::Deponiegas,
125        Self::Wasserkraft,
126        Self::Geothermie,
127        Self::Gezeiten,
128        Self::Kwk,
129    ];
130
131    /// Returns `true` for wind turbines (onshore or offshore).
132    ///
133    /// Used for §51 Abs. 3 Nr. 1 EEG 2017: wind turbines <3 MW are exempt
134    /// (higher threshold than the 500 kW for "sonstige Anlagen").
135    pub fn is_wind(self) -> bool {
136        matches!(self, Self::WindOnshore | Self::WindOffshore)
137    }
138
139    /// Returns `true` for solar PV variants (all rooftop, ground-mounted, agri-PV).
140    pub fn is_solar(self) -> bool {
141        matches!(
142            self,
143            Self::SolarAufdach
144                | Self::SolarFreiflaeche
145                | Self::SolarAgriPv
146                | Self::SolarMieterstrom
147                | Self::SolarStecker
148        )
149    }
150
151    /// Returns `true` for biomass/biogas/biomethan/gas variants.
152    pub fn is_biomasse_or_gas(self) -> bool {
153        matches!(
154            self,
155            Self::Biomasse
156                | Self::BiomassHolz
157                | Self::Biogas
158                | Self::Biomethan
159                | Self::Klaergas
160                | Self::Grubengas
161                | Self::Deponiegas
162        )
163    }
164
165    /// Parse from the DB `erzeugungsart` TEXT column.
166    ///
167    /// Returns `Err` for unknown values — callers should fall back to `Solar`
168    /// or log a warning for unexpected technology codes.
169    pub fn from_db_str(s: &str) -> Result<Self, InvalidErzeugungsArt> {
170        match s {
171            "SOLAR_AUFDACH" => Ok(Self::SolarAufdach),
172            "SOLAR_FREIFLAECHE" => Ok(Self::SolarFreiflaeche),
173            "SOLAR_AGRIPV" => Ok(Self::SolarAgriPv),
174            "SOLAR_MIETERSTROM" => Ok(Self::SolarMieterstrom),
175            "SOLAR_STECKER" => Ok(Self::SolarStecker),
176            "WIND_ONSHORE" => Ok(Self::WindOnshore),
177            "WIND_OFFSHORE" => Ok(Self::WindOffshore),
178            "BIOMASSE" => Ok(Self::Biomasse),
179            "BIOMASSE_HOLZ" => Ok(Self::BiomassHolz),
180            "BIOGAS" => Ok(Self::Biogas),
181            "BIOMETHAN" => Ok(Self::Biomethan),
182            "KLAERGAS" => Ok(Self::Klaergas),
183            "GRUBENGAS" => Ok(Self::Grubengas),
184            "DEPONIEGAS" => Ok(Self::Deponiegas),
185            "WASSERKRAFT" => Ok(Self::Wasserkraft),
186            "GEOTHERMIE" => Ok(Self::Geothermie),
187            "GEZEITEN" => Ok(Self::Gezeiten),
188            "KWKG" => Ok(Self::Kwk),
189            _ => Err(InvalidErzeugungsArt(s.to_owned())),
190        }
191    }
192
193    /// The canonical DB column value for this variant.
194    pub fn to_db_str(self) -> &'static str {
195        match self {
196            Self::SolarAufdach => "SOLAR_AUFDACH",
197            Self::SolarFreiflaeche => "SOLAR_FREIFLAECHE",
198            Self::SolarAgriPv => "SOLAR_AGRIPV",
199            Self::SolarMieterstrom => "SOLAR_MIETERSTROM",
200            Self::SolarStecker => "SOLAR_STECKER",
201            Self::WindOnshore => "WIND_ONSHORE",
202            Self::WindOffshore => "WIND_OFFSHORE",
203            Self::Biomasse => "BIOMASSE",
204            Self::BiomassHolz => "BIOMASSE_HOLZ",
205            Self::Biogas => "BIOGAS",
206            Self::Biomethan => "BIOMETHAN",
207            Self::Klaergas => "KLAERGAS",
208            Self::Grubengas => "GRUBENGAS",
209            Self::Deponiegas => "DEPONIEGAS",
210            Self::Wasserkraft => "WASSERKRAFT",
211            Self::Geothermie => "GEOTHERMIE",
212            Self::Gezeiten => "GEZEITEN",
213            Self::Kwk => "KWKG",
214        }
215    }
216}
217
218// ── Error type ────────────────────────────────────────────────────────────────
219
220/// Returned by [`ErzeugungsArt::from_db_str`] for unknown technology strings.
221#[derive(Debug, thiserror::Error)]
222#[error("unknown erzeugungsart: {0:?}")]
223pub struct InvalidErzeugungsArt(pub String);
224
225// ── InbetriebnahmeTyp ─────────────────────────────────────────────────────────
226
227/// Type of commissioning event that started (or restarted) the EEG Förderdauer.
228///
229/// The commissioning type determines which regulatory rules apply and whether
230/// the 20-year Förderdauer clock is reset or continues from the original date.
231///
232/// ## Legal basis
233///
234/// §3 Nr. 30 EEG 2023 defines "Inbetriebnahme" as the first feed-in of
235/// electricity after all necessary installations are complete.
236///
237/// §22 EEG 2023 (Repowering): replacing components with higher capacity resets
238/// the Förderdauer clock.
239///
240/// §24 EEG 2023 (Zusammenlegung): merging physically separate plants does NOT
241/// reset the clock — the oldest plant's Förderdauer continues.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
243#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
244#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
245pub enum InbetriebnahmeTyp {
246    /// §3 Nr. 30 EEG 2023: first time the plant generates electricity.
247    ///
248    /// Starts the 20-year Förderdauer. All EEG rules apply from commissioning date.
249    #[default]
250    Erstinbetriebnahme,
251
252    /// §3 Nr. 30 EEG 2023: temporary shutdown + restart (same plant, same capacity).
253    ///
254    /// Does NOT reset the Förderdauer. The original commissioning date continues
255    /// to govern tariff and duration. Typical: plant moved, repaired, or temporarily
256    /// decommissioned and returned to operation.
257    Wiederinbetriebnahme,
258
259    /// §3 Nr. 30a EEG 2023: technical modernization without capacity increase.
260    ///
261    /// Replaces equipment (inverter, cables) but not generators. Förderdauer continues.
262    /// May affect technical compliance status (e.g. Fernsteuerbarkeit).
263    Modernisierung,
264
265    /// §22 EEG 2023: repowering — complete replacement of generating components.
266    ///
267    /// **Resets the Förderdauer clock** to the repowering date. The plant receives a
268    /// new 20-year subsidy period at the tariff valid at the repowering commissioning.
269    /// Use `foerderendedatum_repowering(repowering_datum)` to compute the new end date.
270    Repowering,
271
272    /// §24 EEG 2023: plant created by Zusammenlegung of multiple existing plants.
273    ///
274    /// Does NOT reset the Förderdauer. The oldest component plant's commissioning date
275    /// governs the subsidy duration for the merged entity.
276    /// Individual component plants continue under their original `foerderendedatum`.
277    Zusammenlegung,
278
279    /// §24 EEG 2023: capacity extension block (Erweiterung).
280    ///
281    /// The extension block starts its own 20-year Förderdauer at the extension date,
282    /// at the tariff valid at that date (typically lower due to degression).
283    /// Model via `CapacityBlock` in `SettleInput`.
284    Erweiterung,
285}
286
287impl InbetriebnahmeTyp {
288    /// Returns `true` when this commissioning type resets the 20-year Förderdauer.
289    ///
290    /// Only `Repowering` resets the clock. All other types continue from the
291    /// original commissioning date (or start a new parallel block for `Erweiterung`).
292    #[must_use]
293    pub fn resets_foerderdauer(self) -> bool {
294        self == Self::Repowering
295    }
296
297    /// Returns `true` for the initial commissioning (first electricity generation).
298    #[must_use]
299    pub fn is_erstinbetriebnahme(self) -> bool {
300        self == Self::Erstinbetriebnahme
301    }
302
303    /// Parse from the DB `inbetriebnahme_typ` TEXT column.
304    pub fn from_db_str(s: &str) -> Result<Self, InvalidInbetriebnahmeTyp> {
305        match s {
306            "ERSTINBETRIEBNAHME" => Ok(Self::Erstinbetriebnahme),
307            "WIEDERINBETRIEBNAHME" => Ok(Self::Wiederinbetriebnahme),
308            "MODERNISIERUNG" => Ok(Self::Modernisierung),
309            "REPOWERING" => Ok(Self::Repowering),
310            "ZUSAMMENLEGUNG" => Ok(Self::Zusammenlegung),
311            "ERWEITERUNG" => Ok(Self::Erweiterung),
312            _ => Err(InvalidInbetriebnahmeTyp(s.to_owned())),
313        }
314    }
315
316    /// Canonical DB column value.
317    #[must_use]
318    pub fn to_db_str(self) -> &'static str {
319        match self {
320            Self::Erstinbetriebnahme => "ERSTINBETRIEBNAHME",
321            Self::Wiederinbetriebnahme => "WIEDERINBETRIEBNAHME",
322            Self::Modernisierung => "MODERNISIERUNG",
323            Self::Repowering => "REPOWERING",
324            Self::Zusammenlegung => "ZUSAMMENLEGUNG",
325            Self::Erweiterung => "ERWEITERUNG",
326        }
327    }
328}
329
330/// Returned by [`InbetriebnahmeTyp::from_db_str`] for unknown values.
331#[derive(Debug, thiserror::Error)]
332#[error("unknown inbetriebnahme_typ: {0:?}")]
333pub struct InvalidInbetriebnahmeTyp(pub String);
334
335// ── RepoweringScope ───────────────────────────────────────────────────────────
336
337/// Scope of a repowering event — determines whether the 20-year Förderdauer resets.
338///
339/// Repowering is one of the most legally complex topics in the EEG. Whether the
340/// Förderdauer resets depends on what exactly was replaced.
341///
342/// ## §22 EEG 2023 — Key rule
343///
344/// The Förderdauer resets only for **Vollrepowering** (complete new plant at the
345/// same site). Partial component replacements do NOT reset the clock — the original
346/// commissioning date continues to govern.
347///
348/// ## Practical guidance
349///
350/// When in doubt, consult BNetzA guidance or a specialized EEG attorney.
351/// The distinction between `RotorBlade`, `WholeNacelle`, and `TurbineUnit` is
352/// fact-specific and the BNetzA has issued conflicting guidance in edge cases.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
354#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
355#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
356pub enum RepoweringScope {
357    /// **Vollrepowering**: Complete replacement of all generating components
358    /// (generator, nacelle, rotor, tower, foundation).
359    ///
360    /// **Resets Förderdauer** to the new commissioning date.
361    /// Equivalent to a new plant at the same grid connection point.
362    /// Uses `foerderendedatum_repowering(new_commissioning_date)`.
363    Full,
364
365    /// **Teilrepowering — Rotor only**: rotor blades and hub replaced,
366    /// nacelle and generator unchanged.
367    ///
368    /// **Does NOT reset Förderdauer.** Old commissioning date continues.
369    /// Rotor replacement alone does not constitute "Inbetriebnahme" under §3 Nr. 30 EEG 2023.
370    RotorOnly,
371
372    /// **Teilrepowering — Nacelle and rotor replaced**, tower and foundation unchanged.
373    ///
374    /// **Legal status is contested** (BNetzA has not issued definitive guidance).
375    /// Conservative interpretation: Förderdauer does NOT reset (original date governs).
376    /// Aggressive interpretation: may reset if generator output increases substantially.
377    NacelleAndRotor,
378
379    /// **Teilrepowering — Complete turbine unit replaced** (generator + nacelle + rotor),
380    /// but tower and foundation unchanged.
381    ///
382    /// **Legal status is contested.** Most EEG specialists consider this a
383    /// Vollrepowering (Förderdauer resets) when capacity increases significantly.
384    /// BNetzA position: resets if the turbine is "technisch and wirtschaftlich neu."
385    TurbineUnit,
386
387    /// **Repowering with capacity increase** — same classification as `Full` but
388    /// explicitly tracks that the new plant has higher rated power than the original.
389    ///
390    /// Relevant for Ausschreibungspflicht threshold check (§22 EEG 2023):
391    /// the new capacity may push the plant above the 750 kW wind tender threshold.
392    FullWithCapacityIncrease,
393}
394
395impl RepoweringScope {
396    /// Returns `true` when this repowering scope **definitely resets** the Förderdauer.
397    ///
398    /// Returns `false` for contested cases — the caller must resolve the legal question
399    /// before computing the new Förderdauer.
400    #[must_use]
401    pub fn resets_foerderdauer_definitely(self) -> bool {
402        matches!(self, Self::Full | Self::FullWithCapacityIncrease)
403    }
404
405    /// Returns `true` when this scope involves replacing the nacelle or generating unit.
406    ///
407    /// Rotor-only replacement never replaces the generating unit.
408    #[must_use]
409    pub fn replaces_generating_unit(self) -> bool {
410        matches!(
411            self,
412            Self::Full | Self::FullWithCapacityIncrease | Self::NacelleAndRotor | Self::TurbineUnit
413        )
414    }
415}