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
+315
View File
@@ -0,0 +1,315 @@
//! enok — årlig energiberegning og energirapport for bygninger etter NS 3031.
use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use enok::data::Datakilde;
use enok::inndata;
use enok::kjor;
use enok::rapport;
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "enok",
version,
about = "Årlig energiberegning og energirapport for bygninger etter NS 3031:2025",
long_about = "Beregner timesvis energibehov for et helt år etter NS 3031:2025 (NS-EN ISO 52016-1), \
fastsetter energikarakter etter Enovas skala fra 1.1.2026, og skriver rapport som \
Typst/PDF med CSV- og JSON-vedlegg."
)]
struct Cli {
/// Katalog som overstyrer de innebygde datafilene (skala, normerte verdier, materialer).
#[arg(long, global = true, value_name = "KATALOG")]
data: Option<PathBuf>,
#[command(subcommand)]
kommando: Kommando,
}
#[derive(Subcommand)]
enum Kommando {
/// Beregner energibehov og skriver rapport.
Beregn(Beregn),
/// Importerer en bygningsmodell fra gbXML, IFC eller regneark og skriver bygning.toml.
Import(Import),
/// Viser tilgjengelige klimasteder og nøkkeltall for ett sted.
Klima(Klima),
/// Viser energikarakterskalaen for en bygningskategori.
Skala(Skala),
}
#[derive(Args)]
struct Beregn {
/// Bygningsbeskrivelse (bygning.toml).
fil: PathBuf,
/// Utdatakatalog. Standard: `ut/<bygningsnavn>`.
#[arg(short, long, value_name = "KATALOG")]
ut: Option<PathBuf>,
/// Hopper over PDF-kompilering (skriver bare rapport.typ).
#[arg(long)]
ingen_pdf: bool,
/// Skriver også XML for Enovas energimerkesystem.
#[arg(long)]
enova_xml: bool,
/// Hopper over timeserie.csv (8760 rader).
#[arg(long)]
ingen_timeserie: bool,
/// Beregner bare hovedalternativet, uten scenarioene i filen.
#[arg(long)]
uten_scenarier: bool,
}
#[derive(Args)]
struct Import {
/// gbXML-, IFC- eller CSV-fil eksportert fra BIM-verktøyet.
fil: PathBuf,
/// Utdatafil. Standard: samme navn med endelsen .toml.
#[arg(short, long, value_name = "FIL")]
ut: Option<PathBuf>,
}
#[derive(Args)]
struct Klima {
/// Lister alle kommuner med klimadata.
#[arg(long)]
liste: bool,
/// Viser nøkkeltall for én kommune.
#[arg(long, value_name = "KOMMUNE")]
kommune: Option<String>,
}
#[derive(Args)]
struct Skala {
/// Bygningskategori, for eksempel smaahus eller boligblokk.
#[arg(default_value = "smaahus")]
kategori: String,
/// Oppvarmet BRA for arealkorreksjonen, m².
#[arg(long, default_value_t = 160.0)]
areal: f64,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let kilde = Datakilde::ny(cli.data.clone());
match cli.kommando {
Kommando::Beregn(a) => beregn(a, &kilde),
Kommando::Import(a) => import(a),
Kommando::Klima(a) => klima(a),
Kommando::Skala(a) => skala(a, &kilde),
}
}
fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> {
let bygning = inndata::les_bygning(&a.fil)?;
let ut =
a.ut.unwrap_or_else(|| PathBuf::from("ut").join(filnavnvennlig(&bygning.bygning.navn)));
std::fs::create_dir_all(&ut)?;
eprintln!("Henter klimadata …");
let (vaerdata, klimanavn, er_referanse) = kjor::hent_klima(&bygning)?;
eprintln!("Beregner {}", bygning.bygning.navn);
let lost = enok::Lost::fra(&bygning, kilde)?;
let resultat = kjor::kjor_med_klima(
&bygning,
kilde,
lost,
vaerdata.clone(),
klimanavn.clone(),
er_referanse,
)?;
let mut scenarier = Vec::new();
if !a.uten_scenarier {
for s in &bygning.scenario {
eprintln!("Beregner scenario «{}» …", s.navn);
let endret = inndata::slaa_sammen_scenario(&bygning, s)?;
let lost = enok::Lost::fra(&endret, kilde)?;
let r = kjor::kjor_med_klima(
&endret,
kilde,
lost,
vaerdata.clone(),
klimanavn.clone(),
er_referanse,
)?;
scenarier.push((s.navn.clone(), r));
}
}
rapport::json_ut::skriv(&resultat, &ut.join("resultat.json"))?;
rapport::csv_ut::maanedsverdier(&resultat, &ut.join("maaned.csv"))?;
if !a.ingen_timeserie {
rapport::csv_ut::timeserie(&resultat, &ut.join("timeserie.csv"))?;
}
if a.enova_xml {
rapport::enova_xml::skriv(&resultat, &ut.join("enova-2014.xml"))?;
}
rapport::typst_ut::skriv(&resultat, &ut, &scenarier)?;
if !a.ingen_pdf {
let pdf = rapport::typst_ut::kompiler(&ut)?;
eprintln!("Rapport: {}", pdf.display());
}
skriv_sammendrag(&resultat, &scenarier);
eprintln!("Filer skrevet til {}", ut.display());
Ok(())
}
fn skriv_sammendrag(r: &kjor::Resultat, scenarier: &[(String, kjor::Resultat)]) {
let l = &r.lost;
println!();
println!(
" {} ({}, {} m², byggeår {})",
l.navn,
r.karakter.kategorinavn,
l.bra.round(),
l.byggeaar
);
println!(" Klimadata: {}", r.klimanavn);
println!();
println!(
" Varmetapstall {:>8.2} W/(m²K)",
r.varmetap.totalt
);
println!(
" Netto energibehov {:>8.1} kWh/(m²·år)",
r.budsjett.netto_spesifikk(l.bra)
);
println!(
" Levert energi {:>8.1} kWh/(m²·år)",
r.levert.levert_totalt / l.bra
);
println!(
" Vektet levert energi {:>8.1} kWh/(m²·år)",
r.levert.spesifikk_vektet
);
println!(" Energikarakter {}", r.karakter.bokstav);
if let Some(t) = r.karakter.til_neste {
println!(" ({:.0} kWh/(m²·år) fra neste karakter)", t);
}
if !scenarier.is_empty() {
println!();
println!(" Tiltak:");
for (navn, s) in scenarier {
println!(
" {:<34} {:>6.1} kWh/(m²·år) {}",
navn, s.levert.spesifikk_vektet, s.karakter.bokstav
);
}
}
println!();
}
fn import(a: Import) -> Result<()> {
let endelse = a
.fil
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let (bygning, merknader) = match endelse.as_str() {
"gbxml" | "xml" => {
let r = inndata::gbxml::les(&a.fil)?;
(r.bygning, r.merknader)
}
"ifc" => {
let r = inndata::ifc::les(&a.fil)?;
(r.bygning, r.merknader)
}
"csv" => {
let r = inndata::regneark::les(&a.fil)?;
(r.bygning, r.merknader)
}
annen => bail!(
"ukjent filtype «{annen}». enok importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
),
};
let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml"));
let tekst = toml::to_string_pretty(&bygning).context("serialiserer bygningen til TOML")?;
let mut innhold =
String::from("# Importert med enok. Fyll ut feltene som er merket nedenfor.\n");
for m in &merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&tekst);
std::fs::write(&ut, innhold)?;
println!("Skrev {}", ut.display());
for m in &merknader {
println!("{m}");
}
Ok(())
}
fn klima(a: Klima) -> Result<()> {
if a.liste {
for k in enok::klima::tmy::kommuner()? {
println!("{k}");
}
return Ok(());
}
let kommune = a
.kommune
.unwrap_or_else(|| enok::klima::REFERANSEKLIMA.to_string());
let v = enok::klima::tmy::for_kommune(&kommune)?;
let m = v.maanedsmiddeltemperatur();
println!(
"{} ({:.4}° N, {:.4}° Ø, {:.0} moh.)",
v.sted, v.bredde, v.lengde, v.hoyde
);
println!("Årsmiddeltemperatur {:.1} °C", v.aarsmiddeltemperatur());
println!("Graddager under 17 °C {:.0}", v.graddager(17.0));
println!(
"Global solinnstråling {:.0} kWh/(m²·år)",
v.ghi.iter().sum::<f64>() / 1000.0
);
println!();
println!("Månedsmiddeltemperatur, °C");
for (i, navn) in enok::MAANEDER.iter().enumerate() {
println!(" {navn} {:>6.1}", m[i]);
}
Ok(())
}
fn skala(a: Skala, kilde: &Datakilde) -> Result<()> {
let skala = kilde.skala()?;
let k = skala
.0
.get(a.kategori.as_str())
.with_context(|| format!("ukjent kategori «{}»", a.kategori))?;
let kar = enok::merking::karakter(k, f64::NAN, a.areal);
println!("{} (arealkorreksjon for {:.0} m²)", k.navn, a.areal);
println!();
for (i, bokstav) in enok::merking::skala::BOKSTAVER.iter().enumerate() {
if i < 6 {
println!(" {bokstav}{:>6.1} kWh/(m²·år)", kar.grenser[i]);
} else {
println!(" {bokstav} > {:>6.1} kWh/(m²·år)", kar.grenser[5]);
}
}
Ok(())
}
fn filnavnvennlig(s: &str) -> String {
s.chars()
.map(|c| match c {
'æ' => 'a',
'ø' => 'o',
'å' => 'a',
'Æ' => 'A',
'Ø' => 'O',
'Å' => 'A',
c if c.is_alphanumeric() => c.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
#[allow(dead_code)]
fn sjekk_sti(p: &Path) -> Result<()> {
if !p.exists() {
bail!("finner ikke {}", p.display());
}
Ok(())
}