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}