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