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:
+401
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user