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
+244
View File
@@ -0,0 +1,244 @@
//! Import fra regneark (CSV), for eksempel en Vectorworks-arbeidstegning eksportert med
//! «Export Worksheet». Formatet er bevisst enkelt, slik at det kan settes opp som en
//! rapport i BIM-verktøyet og eksporteres direkte.
//!
//! Hver rad begynner med en type:
//!
//! ```text
//! type,navn,areal,u_verdi,orientering,helning,mot,g_verdi,antall
//! bygning,Nordhusstykket 5,140,,,,smaahus,1948,
//! vegg,Yttervegg sør,32,1.0,180,90,ute,,
//! vindu,Stue sør,4.2,2.8,180,90,,0.75,2
//! ```
//!
//! For `bygning`-raden brukes kolonnene: navn, oppvarmet BRA, (tom), (tom), (tom),
//! kategori, byggeår.
use crate::modell::*;
use crate::Result;
use anyhow::{bail, Context};
use std::path::Path;
pub struct Importrapport {
pub bygning: Bygning,
pub merknader: Vec<String>,
}
pub fn les(sti: &Path) -> Result<Importrapport> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
fra_tekst(
&tekst,
sti.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Bygning"),
)
}
fn skilletegn(tekst: &str) -> u8 {
let forste = tekst.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
if forste.matches(';').count() > forste.matches(',').count() {
b';'
} else {
b','
}
}
fn tall(s: Option<&str>) -> Option<f64> {
let s = s?.trim().replace(',', ".");
if s.is_empty() {
return None;
}
s.parse().ok()
}
pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let mut leser = csv::ReaderBuilder::new()
.delimiter(skilletegn(tekst))
.flexible(true)
.has_headers(true)
.from_reader(tekst.as_bytes());
let mut merknader = Vec::new();
let mut flater = Vec::new();
let mut vinduer = Vec::new();
let mut navn = standardnavn.to_string();
let mut kategori = Kategori::Smaahus;
let mut byggeaar = 2020;
let mut bra = 0.0;
for (nr, rad) in leser.records().enumerate() {
let rad = rad.with_context(|| format!("rad {}", nr + 2))?;
let felt = |i: usize| rad.get(i).map(|s| s.trim()).filter(|s| !s.is_empty());
let Some(typ) = felt(0).map(|s| s.to_lowercase()) else {
continue;
};
let radnavn = felt(1).unwrap_or("uten navn").to_string();
match typ.as_str() {
"bygning" => {
navn = radnavn;
bra = tall(felt(2)).unwrap_or(0.0);
if let Some(k) = felt(6) {
kategori = match k.to_lowercase().as_str() {
"smaahus" | "småhus" | "enebolig" => Kategori::Smaahus,
"boligblokk" | "leilighet" => Kategori::Boligblokk,
"kontorbygg" | "kontor" => Kategori::Kontorbygg,
"skolebygg" | "skole" => Kategori::Skolebygg,
"barnehage" => Kategori::Barnehage,
annen => {
merknader
.push(format!("Ukjent bygningskategori «{annen}»; bruker småhus."));
Kategori::Smaahus
}
};
}
if let Some(a) = tall(felt(7)) {
byggeaar = a as i32;
}
}
"vegg" | "tak" | "gulv" => {
let ft = match typ.as_str() {
"vegg" => FlateType::Vegg,
"tak" => FlateType::Tak,
_ => FlateType::Gulv,
};
let mot = match felt(6).map(|s| s.to_lowercase()).as_deref() {
Some("grunn") | Some("grunnen") => Mot::Grunn,
Some("uoppvarmet") | Some("kjeller") | Some("loft") => Mot::Uoppvarmet,
_ => Mot::Ute,
};
let Some(areal) = tall(felt(2)) else {
merknader.push(format!(
"Raden «{radnavn}» mangler areal og er hoppet over."
));
continue;
};
flater.push(Flate {
navn: radnavn,
typ: ft,
areal,
orientering: tall(felt(4)).unwrap_or(0.0),
helning: tall(felt(5)),
u_verdi: tall(felt(3)),
lag: None,
masseklasse: None,
masseplassering: Masseplassering::Fordelt,
mot,
b_faktor: None,
absorptans: None,
omkrets: None,
dybde: None,
kommentar: None,
});
}
"vindu" | "dør" | "dor" => {
let Some(areal) = tall(felt(2)) else {
merknader.push(format!(
"Raden «{radnavn}» mangler areal og er hoppet over."
));
continue;
};
let Some(u) = tall(felt(3)) else {
merknader.push(format!(
"Raden «{radnavn}» mangler U-verdi og er hoppet over."
));
continue;
};
vinduer.push(Vindu {
navn: radnavn,
areal,
antall: tall(felt(8)).unwrap_or(1.0),
orientering: tall(felt(4)).unwrap_or(0.0),
helning: tall(felt(5)).unwrap_or(90.0),
u_verdi: u,
g_verdi: tall(felt(7)),
karmandel: None,
solskjerming: None,
horisont: None,
dor: typ != "vindu",
kommentar: None,
});
}
annen => merknader.push(format!("Ukjent radtype «{annen}» på rad {}.", nr + 2)),
}
}
if flater.is_empty() {
bail!("regnearket inneholder ingen flater (rader som begynner med vegg, tak eller gulv)");
}
if bra <= 0.0 {
bra = flater
.iter()
.filter(|f| f.typ == FlateType::Gulv)
.map(|f| f.areal)
.sum();
merknader.push(format!(
"Regnearket mangler en bygning-rad med oppvarmet BRA; satt til {bra:.0} m² av gulvarealet."
));
}
merknader.push(
"Fyll inn [ventilasjon], [infiltrasjon], [oppvarming], [tappevann] og [klima] før beregning."
.into(),
);
let bygning = Bygning {
bygning: BygningInfo {
navn,
kategori,
byggeaar,
oppvarmet_bra: bra,
oppvarmet_volum: None,
antall_boenheter: 1,
bygningens_oppvarmede_bra: None,
adresse: None,
gnr_bnr: None,
beskrivelse: Some("Importert fra regneark".into()),
energiraadgiver: None,
oppdragsgiver: None,
},
klima: Klima::default(),
flate: flater,
vindu: vinduer,
kuldebroer: Kuldebroer::default(),
infiltrasjon: Infiltrasjon::default(),
ventilasjon: Ventilasjon::default(),
tappevann: Tappevann::default(),
oppvarming: Oppvarming::default(),
distribusjon: Distribusjon::default(),
solcelle: None,
kjoling: Kjoling::default(),
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
};
Ok(Importrapport { bygning, merknader })
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn leser_enkelt_regneark() {
let csv = "type,navn,areal,u_verdi,orientering,helning,mot,g_verdi,antall\n\
bygning,Testhus,140,,,,smaahus,1948,\n\
vegg,Yttervegg sør,32,1.0,180,90,ute,,\n\
gulv,Kjellergulv,70,0.8,,180,grunn,,\n\
vindu,Stue sør,2.1,2.8,180,90,,0.75,2\n";
let r = fra_tekst(csv, "x").unwrap();
assert_eq!(r.bygning.bygning.navn, "Testhus");
assert_eq!(r.bygning.bygning.oppvarmet_bra, 140.0);
assert_eq!(r.bygning.bygning.byggeaar, 1948);
assert_eq!(r.bygning.flate.len(), 2);
assert_eq!(r.bygning.flate[1].mot, Mot::Grunn);
assert_eq!(r.bygning.vindu.len(), 1);
assert_eq!(r.bygning.vindu[0].antall, 2.0);
}
#[test]
fn godtar_semikolon_og_desimalkomma() {
let csv = "type;navn;areal;u_verdi\nvegg;Vegg;32,5;1,0\n";
let r = fra_tekst(csv, "x").unwrap();
assert_eq!(r.bygning.flate[0].areal, 32.5);
assert_eq!(r.bygning.flate[0].u_verdi, Some(1.0));
}
}