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
+401
View File
@@ -0,0 +1,401 @@
//! Innebygde datafiler (normerte verdier, karakterskala, vekting, SPF, materialer, klimakorreksjon).
//! Filene ligger i `data/` og bygges inn i binæren; de kan overstyres med `--data DIR`.
use crate::modell::Kategori;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const SKALA: &str = include_str!("../data/skala.toml");
const VEKTING: &str = include_str!("../data/vekting.toml");
const SPF: &str = include_str!("../data/varmepumpe_spf.toml");
const MATERIALER: &str = include_str!("../data/materialer.toml");
const KLIMAKORREKSJON: &str = include_str!("../data/klimakorreksjon.csv");
const NORMERTE_SMAAHUS: &str = include_str!("../data/normerte/smaahus.toml");
const NORMERTE_BOLIGBLOKK: &str = include_str!("../data/normerte/boligblokk.toml");
/// Katalog som kan overstyre de innebygde datafilene.
#[derive(Clone, Debug, Default)]
pub struct Datakilde {
pub katalog: Option<PathBuf>,
}
impl Datakilde {
pub fn ny(katalog: Option<PathBuf>) -> Self {
Self { katalog }
}
fn les(&self, relativ: &str, innebygd: &str) -> Result<String> {
if let Some(k) = &self.katalog {
let p = k.join(relativ);
if p.exists() {
return std::fs::read_to_string(&p)
.with_context(|| format!("leser {}", p.display()));
}
}
Ok(innebygd.to_string())
}
pub fn skala(&self) -> Result<Skala> {
let s = self.les("skala.toml", SKALA)?;
let m: HashMap<String, SkalaKategori> = toml::from_str(&s).context("skala.toml")?;
Ok(Skala(m))
}
pub fn vekting(&self) -> Result<Vekting> {
let s = self.les("vekting.toml", VEKTING)?;
toml::from_str(&s).context("vekting.toml")
}
pub fn varmepumper(&self) -> Result<Vec<VarmepumpeSpf>> {
let s = self.les("varmepumpe_spf.toml", SPF)?;
let t: SpfFil = toml::from_str(&s).context("varmepumpe_spf.toml")?;
Ok(t.varmepumpe)
}
pub fn materialer(&self) -> Result<Materialer> {
let s = self.les("materialer.toml", MATERIALER)?;
toml::from_str(&s).context("materialer.toml")
}
pub fn normerte(&self, kategori: Kategori) -> Result<Normerte> {
let (fil, innebygd) = match kategori {
Kategori::Smaahus => ("normerte/smaahus.toml", NORMERTE_SMAAHUS),
Kategori::Boligblokk => ("normerte/boligblokk.toml", NORMERTE_BOLIGBLOKK),
annen => {
// Andre kategorier krever egen datafil i data-katalogen.
let fil = format!("normerte/{}.toml", annen.nokkel());
let p = self
.katalog
.as_ref()
.map(|k| k.join(&fil))
.filter(|p| p.exists())
.ok_or_else(|| {
anyhow!(
"normerte inndata for kategori «{}» mangler: legg {} i data-katalogen",
annen.nokkel(),
fil
)
})?;
let s = std::fs::read_to_string(&p)?;
return toml::from_str(&s).with_context(|| p.display().to_string());
}
};
let s = self.les(fil, innebygd)?;
toml::from_str(&s).context(fil)
}
pub fn klimakorreksjon(&self) -> Result<Klimakorreksjon> {
let s = self.les("klimakorreksjon.csv", KLIMAKORREKSJON)?;
Klimakorreksjon::fra_csv(&s)
}
}
// ───────────────────────────── karakterskala ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct SkalaKategori {
pub navn: String,
/// Øvre grense for A, B, C, D, E, F (kWh/m²). G har ingen grense.
pub grenser: [f64; 6],
/// Arealkorreksjon k for hver grense: grense + k / A.
#[serde(default)]
pub arealkorreksjon: Option<[f64; 6]>,
}
pub struct Skala(pub HashMap<String, SkalaKategori>);
impl Skala {
pub fn for_kategori(&self, k: Kategori) -> Result<&SkalaKategori> {
self.0
.get(k.nokkel())
.ok_or_else(|| anyhow!("karakterskala mangler for {}", k.nokkel()))
}
}
// ───────────────────────────── vekting ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct Vekting {
pub faktor: Vektingsfaktorer,
pub regler: Vektingsregler,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Vektingsfaktorer {
pub elektrisitet: f64,
pub fjernvarme: f64,
pub fjernkjoling: f64,
pub biobrensel_lukket: f64,
pub biobrensel_aapen: f64,
pub olje: f64,
pub gass: f64,
pub annet: f64,
pub eksportert: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Vektingsregler {
pub vedovn_maks_andel_rom: f64,
}
// ───────────────────────────── varmepumper ─────────────────────────────
#[derive(Deserialize)]
struct SpfFil {
varmepumpe: Vec<VarmepumpeSpf>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct VarmepumpeSpf {
#[serde(rename = "type")]
pub typ: String,
#[serde(default)]
pub turtemperatur: Option<f64>,
pub beskrivelse: String,
pub smaahus: f64,
pub boligblokk: f64,
}
/// Slår opp veiledende SPF for en varmepumpetype. Ved turtemperatur velges nærmeste tabellverdi.
pub fn spf_oppslag(
tabell: &[VarmepumpeSpf],
typ: &str,
turtemp: Option<f64>,
kategori: Kategori,
) -> Option<f64> {
let mut best: Option<(f64, &VarmepumpeSpf)> = None;
for rad in tabell.iter().filter(|r| r.typ == typ) {
let avstand = match (rad.turtemperatur, turtemp) {
(Some(t), Some(v)) => (t - v).abs(),
(Some(t), None) => (t - 45.0).abs(), // uten oppgitt turtemperatur: radiatoranlegg 45/37
_ => 0.0,
};
if best.is_none_or(|(a, _)| avstand < a) {
best = Some((avstand, rad));
}
}
best.map(|(_, r)| match kategori {
Kategori::Smaahus => r.smaahus,
_ => r.boligblokk,
})
}
// ───────────────────────────── materialer ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct Materialer {
pub materiale: Vec<Materiale>,
pub periode: Vec<Periode>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Materiale {
pub navn: String,
pub lambda: f64,
pub rho: f64,
pub c: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Periode {
pub navn: String,
pub fra: i32,
pub til: i32,
pub vegg: f64,
pub tak: f64,
pub gulv: f64,
pub vindu: f64,
pub kuldebro: f64,
pub n50: f64,
}
impl Materialer {
pub fn materiale(&self, navn: &str) -> Option<&Materiale> {
self.materiale
.iter()
.find(|m| m.navn.eq_ignore_ascii_case(navn))
}
pub fn periode_for(&self, byggeaar: i32) -> Option<&Periode> {
self.periode
.iter()
.find(|p| byggeaar >= p.fra && byggeaar <= p.til)
}
}
// ───────────────────────────── normerte inndata ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct Normerte {
pub kategori: String,
#[serde(default)]
pub kilde: String,
pub settpunkt: Settpunkt,
pub driftstid: Driftstider,
pub belysning: EffektPost,
pub utstyr: EffektPost,
pub varmtvann: EffektPost,
pub personer: PersonPost,
pub ventilasjon: VentilasjonNormert,
pub termisk: TermiskNormert,
pub pumper: PumperNormert,
/// Valgfrie 24-verdiers profiler (W/m² per klokketime) som overstyrer driftstid × effekt.
#[serde(default)]
pub profil_belysning: Option<[f64; 24]>,
#[serde(default)]
pub profil_utstyr: Option<[f64; 24]>,
#[serde(default)]
pub profil_varmtvann: Option<[f64; 24]>,
#[serde(default)]
pub profil_personer: Option<[f64; 24]>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Settpunkt {
pub oppvarming: f64,
pub oppvarming_natt: f64,
pub kjoling: f64,
pub ventilasjon_tilluft: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Driftstider {
pub oppvarming: Tidsrom,
pub belysning: Tidsrom,
pub utstyr: Tidsrom,
pub varmtvann: Tidsrom,
pub personer: Tidsrom,
pub ventilasjon: Tidsrom,
#[serde(default = "sju")]
pub dager_per_uke: u8,
}
fn sju() -> u8 {
7
}
#[derive(Deserialize, serde::Serialize, Clone, Copy, Debug)]
pub struct Tidsrom {
pub start: u8,
pub slutt: u8,
}
impl Tidsrom {
pub fn aktiv(&self, klokketime: usize) -> bool {
let h = klokketime as u8;
if self.start <= self.slutt {
h >= self.start && h < self.slutt
} else {
h >= self.start || h < self.slutt
}
}
pub fn timer(&self) -> f64 {
if self.start <= self.slutt {
(self.slutt - self.start) as f64
} else {
(24 + self.slutt - self.start) as f64
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct EffektPost {
pub effekt: f64,
pub varmetilskudd: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct PersonPost {
pub varmetilskudd: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct VentilasjonNormert {
pub luftmengde_driftstid: f64,
pub luftmengde_utenfor_driftstid: f64,
pub sfp: f64,
pub temperaturvirkningsgrad: f64,
pub frostsikring_virkningsgrad: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct TermiskNormert {
pub varmekapasitet_normalisert: f64,
pub kuldebroer_normalisert: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct PumperNormert {
pub effekt_vannbaaren: f64,
}
// ───────────────────────────── klimakorreksjon ─────────────────────────────
/// Klimakorrigeringsfaktorer KF per klimasted og bygningskategori (NS 3031:2025, tillegg).
pub struct Klimakorreksjon {
pub kategorier: Vec<String>,
pub steder: Vec<(String, String, Vec<f64>)>,
}
impl Klimakorreksjon {
fn fra_csv(s: &str) -> Result<Self> {
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(s.as_bytes());
let kategorier: Vec<String> = rdr
.headers()?
.iter()
.skip(2)
.map(|s| s.to_string())
.collect();
let mut steder = Vec::new();
for rad in rdr.records() {
let rad = rad?;
let sted = rad.get(0).unwrap_or("").to_string();
let fylke = rad.get(1).unwrap_or("").to_string();
let verdier: Result<Vec<f64>, _> =
rad.iter().skip(2).map(|v| v.parse::<f64>()).collect();
steder.push((
sted,
fylke,
verdier.context("klimakorreksjon.csv: tallverdi")?,
));
}
Ok(Self { kategorier, steder })
}
/// KF for et klimasted og en kategori. Navnet sammenlignes normalisert (æøå → ae/o/aa).
pub fn faktor(&self, sted: &str, kategori: Kategori) -> Option<f64> {
let kol = self
.kategorier
.iter()
.position(|k| k == kategori.nokkel())?;
let mål = normaliser_stedsnavn(sted);
self.steder
.iter()
.find(|(s, _, _)| normaliser_stedsnavn(s) == mål)
.map(|(_, _, v)| v[kol])
}
}
/// Normaliserer stedsnavn for sammenligning: små bokstaver, æ→ae, ø→o, å→aa, uten parentes-fylke.
pub fn normaliser_stedsnavn(s: &str) -> String {
let s = s.split('(').next().unwrap_or(s).trim();
s.chars()
.flat_map(|c| match c.to_ascii_lowercase() {
'æ' => "ae".chars().collect::<Vec<_>>(),
'ø' => "o".chars().collect(),
'å' => "aa".chars().collect(),
'Æ' => "ae".chars().collect(),
'Ø' => "o".chars().collect(),
'Å' => "aa".chars().collect(),
'-' | ' ' => vec![],
c => vec![c],
})
.collect()
}
pub fn finnes(p: &Path) -> bool {
p.exists()
}