Energiberegning etter NS 3031:2025 med timesmetode og energirapport

enok leser en bygningsbeskrivelse i TOML, henter typisk klimaår for kommunen,
simulerer 8760 timer etter NS-EN ISO 52016-1 og skriver energirapport, CSV og JSON.

Motor
- Femnodemodell for opake bygningsdeler, tonodemodell for vinduer, luftnode og
  strålingsnode per sone, implisitt løsning per time med egen tett LU-løser.
- Varme- og kjølebehov ved eksakt interpolasjon mellom to løsninger, siden systemet
  er lineært. Vinduslufting for bygninger uten kjøleanlegg.
- Solberegning etter ISO 52010-1, kontrollert mot pvlib med avvik under 1 prosent.

Beregningsgang
- Beregningspunktene A til E: netto, brutto, tilført, levert per energibærer og
  vektet levert energi, med solceller, klimakorrigering og effektbehov.
- Energikarakter etter Enovas skala fra 1.1.2026, med arealkorreksjon.

Inndata
- TOML med scenarier for tiltak, og import fra IFC, gbXML og regneark.
- Normerte verdier, karakterskala, vektingsfaktorer og klimadata i data/,
  som kan overstyres med --data.

Kontroll
- Stasjonært varmetap mot U·A·ΔT innenfor 2 prosent, energibalansen innenfor
  2 prosent, varmetapsbudsjettet for TEK17-referansebygget identisk med publisert
  tabell. Kjent avvik i romoppvarming mot Simien 8 er dokumentert i README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
bl
2026-09-14 18:03:00 +02:00
co-authored by Claude Opus 5
commit 0964cacba3
51 changed files with 10309 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
//! Leser `bygning.toml` og fletter inn scenarioendringer.
use crate::modell::{Bygning, Scenario};
use crate::Result;
use anyhow::Context;
use std::path::Path;
use toml::Value;
pub fn les_bygning(sti: &Path) -> Result<Bygning> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))
}
/// Lager en ny bygning der scenarioets endringer er flettet inn.
///
/// Tabeller flettes rekursivt. Tabellarrayer (`flate`, `vindu`, `oppvarming.kilde` …) matches
/// på feltet `navn` når det finnes, ellers på `type`; elementer som ikke finnes fra før legges
/// til, og `slett = true` fjerner et element. Andre verdier erstattes.
pub fn slaa_sammen_scenario(b: &Bygning, s: &Scenario) -> Result<Bygning> {
let mut grunn = Value::try_from(b).context("serialiserer bygningen for scenario")?;
flett(&mut grunn, &s.endringer);
grunn
.try_into()
.with_context(|| format!("scenarioet «{}» gir en ugyldig bygningsbeskrivelse", s.navn))
}
fn navnenokkel(v: &Value) -> Option<String> {
let t = v.as_table()?;
t.get("navn")
.and_then(|x| x.as_str())
.or_else(|| t.get("type").and_then(|x| x.as_str()))
.map(|s| s.to_string())
}
fn skal_slettes(v: &Value) -> bool {
v.as_table()
.and_then(|t| t.get("slett"))
.and_then(|x| x.as_bool())
.unwrap_or(false)
}
fn flett(grunn: &mut Value, over: &Value) {
match (grunn, over) {
(Value::Table(g), Value::Table(o)) => {
for (k, v) in o {
if k == "slett" {
continue;
}
match g.get_mut(k) {
Some(eksisterende) => flett(eksisterende, v),
None => {
g.insert(k.clone(), v.clone());
}
}
}
}
(Value::Array(g), Value::Array(o)) => {
for element in o {
match navnenokkel(element) {
Some(navn) => {
let treff = g
.iter()
.position(|e| navnenokkel(e).as_deref() == Some(&navn));
match treff {
Some(i) if skal_slettes(element) => {
g.remove(i);
}
Some(i) => flett(&mut g[i], element),
None if skal_slettes(element) => {}
None => g.push(element.clone()),
}
}
None => g.push(element.clone()),
}
}
}
(g, o) => *g = o.clone(),
}
}
#[cfg(test)]
mod tester {
use super::*;
const GRUNN: &str = r#"
[bygning]
navn = "Testhus"
kategori = "smaahus"
byggeaar = 1948
oppvarmet_bra = 100.0
[[flate]]
navn = "Yttervegg"
type = "vegg"
areal = 80.0
u_verdi = 1.0
[[flate]]
navn = "Tak"
type = "tak"
areal = 100.0
u_verdi = 0.8
"#;
#[test]
fn scenario_endrer_u_verdi_for_navngitt_flate() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let s = Scenario {
navn: "Etterisolert".into(),
beskrivelse: None,
endringer: toml::from_str(r#"flate = [{ navn = "Yttervegg", u_verdi = 0.22 }]"#)
.unwrap(),
};
let ny = slaa_sammen_scenario(&b, &s).unwrap();
assert_eq!(ny.flate.len(), 2);
assert_eq!(ny.flate[0].u_verdi, Some(0.22));
assert_eq!(ny.flate[1].u_verdi, Some(0.8));
}
#[test]
fn scenario_legger_til_og_sletter() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let s = Scenario {
navn: "Ombygd".into(),
beskrivelse: None,
endringer: toml::from_str(
r#"flate = [{ navn = "Tak", slett = true }, { navn = "Nytt tak", type = "tak", areal = 100.0, u_verdi = 0.13 }]"#,
)
.unwrap(),
};
let ny = slaa_sammen_scenario(&b, &s).unwrap();
let navn: Vec<&str> = ny.flate.iter().map(|f| f.navn.as_str()).collect();
assert_eq!(navn, vec!["Yttervegg", "Nytt tak"]);
}
#[test]
fn scenario_endrer_skalarfelt() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let s = Scenario {
navn: "Varmepumpe".into(),
beskrivelse: None,
endringer: toml::from_str(
r#"
[infiltrasjon]
n50 = 3.0
[oppvarming]
kilde = [{ type = "luft_luft_vp", andel_rom = 0.6 }]
"#,
)
.unwrap(),
};
let ny = slaa_sammen_scenario(&b, &s).unwrap();
assert_eq!(ny.infiltrasjon.n50, Some(3.0));
assert_eq!(ny.oppvarming.kilde.len(), 1);
}
}