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
316 lines
9.4 KiB
Rust
316 lines
9.4 KiB
Rust
//! 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(())
|
|
}
|