Files
enok/src/main.rs
T

500 lines
17 KiB
Rust
Raw Normal View History

//! 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),
/// Lager en tilleggsfil av forskjellen mellom en modell og en ferdig bygningsbeskrivelse.
Tillegg(TilleggAv),
/// 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,
/// Tilleggsfil som flettes inn: det modellen ikke beskriver, og rettelser.
#[arg(short, long, value_name = "FIL")]
tillegg: Option<PathBuf>,
}
#[derive(Args)]
struct Import {
/// gbXML-, IFC- eller CSV-fil eksportert fra BIM-verktøyet.
fil: PathBuf,
/// Tilleggsfil som flettes inn i den importerte bygningen: det modellen ikke beskriver
/// (drift, varmeanlegg, scenarioer) og rettelser til det den beskriver.
#[arg(short, long, value_name = "FIL")]
tillegg: Option<PathBuf>,
/// Utdatafil. Standard: samme navn med endelsen .toml.
#[arg(short, long, value_name = "FIL")]
ut: Option<PathBuf>,
/// Skriver over en utdatafil enok ikke har laget selv.
#[arg(long)]
overskriv: bool,
}
#[derive(Args)]
struct TilleggAv {
/// BIM-modellen: .ifc, .gbxml eller .csv.
modell: PathBuf,
/// Bygningsbeskrivelsen med de manuelle endringene, som tillegget skal gjenskape.
bygning: PathBuf,
/// Utdatafil. Standard: prosjekt.toml ved siden av bygningsbeskrivelsen.
#[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::Tillegg(a) => tillegg(a),
Kommando::Klima(a) => klima(a),
Kommando::Skala(a) => skala(a, &kilde),
}
}
/// Leser en bygningsbeskrivelse eller en BIM-modell, med en valgfri tilleggsfil flettet inn.
///
/// En `.toml`-fil er bygningsbeskrivelsen selv; andre filtyper importeres. Returnerer også
/// merknadene fra importen, og om filen var en modell.
fn les_modell(
fil: &Path,
tillegg: Option<&Path>,
) -> Result<(enok::modell::Bygning, Vec<String>, bool)> {
let endelse = fil
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let tillegg = tillegg.map(inndata::les_tillegg).transpose()?;
let valg = tillegg
.as_ref()
.map(|t| t.import.clone())
.unwrap_or_default();
let (bygning, mut merknader, modell) = match endelse.as_str() {
"toml" => (inndata::les_bygning(fil)?, Vec::new(), false),
"gbxml" | "xml" => {
let r = inndata::gbxml::les(fil)?;
(r.bygning, r.merknader, true)
}
"ifc" => {
let r = inndata::ifc::les_med(fil, &valg)?;
(r.bygning, r.merknader, true)
}
"csv" => {
let r = inndata::regneark::les(fil)?;
(r.bygning, r.merknader, true)
}
annen => bail!(
"ukjent filtype «{annen}». enok leser bygning.toml og importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
),
};
if endelse != "ifc" && !valg.uoppvarmede_rom.is_empty() {
merknader.push("uoppvarmede_rom i tilleggsfilen brukes bare ved import fra IFC.".into());
}
let Some(t) = tillegg else {
return Ok((bygning, merknader, modell));
};
let (bygning, bom) = inndata::flett_tillegg(&bygning, &t.endringer)?;
for m in bom {
merknader.push(format!(
"Navnemønsteret «{m}» i tilleggsfilen traff ingen oppføring i modellen."
));
}
Ok((bygning, merknader, modell))
}
fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> {
let (bygning, merknader, modell) = les_modell(&a.fil, a.tillegg.as_deref())?;
for m in &merknader {
eprintln!(" · {m}");
}
2026-09-14 21:20:34 +02:00
for m in enok::modell::kontroll::kontroller(&bygning) {
eprintln!(" ! {}: {}", m.felt, m.melding);
}
let ut =
a.ut.unwrap_or_else(|| PathBuf::from("ut").join(filnavnvennlig(&bygning.bygning.navn)));
std::fs::create_dir_all(&ut)?;
if modell {
// Bygningsbeskrivelsen modellen ga, skrives med, slik at beregningen kan etterprøves.
let sti = ut.join("bygning.toml");
std::fs::write(
&sti,
skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?,
)?;
eprintln!("Bygningsbeskrivelsen fra modellen: {}", sti.display());
}
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());
}
2026-09-14 22:36:54 +02:00
// Manglene er allerede skrevet ut før beregningen; her kommer bare det som kom i tillegg.
let alt_sagt: Vec<String> = enok::modell::kontroll::kontroller(&bygning)
.into_iter()
.map(|m| m.melding)
.collect();
for m in resultat.merknader.iter().filter(|m| !alt_sagt.contains(m)) {
eprintln!(" ! {m}");
}
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!();
}
const GENERERT: &str = "# Generert av enok";
fn import(a: Import) -> Result<()> {
let (bygning, merknader, _) = les_modell(&a.fil, a.tillegg.as_deref())?;
let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml"));
// En fil som ikke er skrevet av enok, kan være redigert for hånd. Da er det bedre å
// stoppe enn å skrive over arbeidet.
if !a.overskriv && ut.exists() {
let gammel = std::fs::read_to_string(&ut).unwrap_or_default();
if !gammel.starts_with(GENERERT) {
bail!(
"{} er ikke laget av enok og kan være redigert for hånd. \n\
Ta vare på endringene som en tilleggsfil med «enok tillegg {} {} --ut prosjekt.toml», \n\
og importer med «--tillegg prosjekt.toml». Bruk --overskriv for å skrive over.",
ut.display(),
a.fil.display(),
ut.display()
);
}
}
let innhold = skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?;
std::fs::write(&ut, innhold)?;
println!("Skrev {}", ut.display());
for m in &merknader {
println!(" • {m}");
}
Ok(())
}
/// Bygningsbeskrivelsen som TOML, med et hode som sier hvor den kommer fra.
fn skriv_bygning(
bygning: &enok::modell::Bygning,
merknader: &[String],
fil: &Path,
tillegg: Option<&Path>,
) -> Result<String> {
let tekst = toml::to_string_pretty(bygning).context("serialiserer bygningen til TOML")?;
let mut innhold = match tillegg {
Some(t) => format!(
"# Generert av enok fra «{}» og tilleggsfilen «{}».\n\
# Rett i modellen eller i tilleggsfilen; denne filen skrives på nytt ved hver import.\n\
# Merknader fra importen:\n",
fil.display(),
t.display()
),
None => format!(
"# Generert av enok fra «{}».\n\
# Merknader fra importen:\n",
fil.display()
),
};
for m in merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&tekst);
Ok(innhold)
}
/// Skriver forskjellen mellom modellen og en ferdig bygningsbeskrivelse som en tilleggsfil.
fn tillegg(a: TilleggAv) -> Result<()> {
let (importert, _, modell) = les_modell(&a.modell, None)?;
if !modell {
bail!("{} er ikke en BIM-modell", a.modell.display());
}
let manuell = inndata::les_bygning(&a.bygning)?;
let fra = toml::Value::try_from(&importert).context("serialiserer den importerte bygningen")?;
let til = toml::Value::try_from(&manuell).context("serialiserer bygningsbeskrivelsen")?;
let (patch, detaljer) = inndata::differanse(&fra, &til);
// Felter modellen har og filen ikke, kan et tillegg ikke fjerne. De er som regel
// standardverdier importen fyller ut, så de samles i én merknad i stedet for én hver.
let (ufjernbare, mut merknader): (Vec<String>, Vec<String>) = detaljer
.into_iter()
.partition(|m| m.contains("finnes i modellen"));
if !ufjernbare.is_empty() {
let felter: Vec<&str> = ufjernbare
.iter()
.filter_map(|m| m.split('«').nth(1)?.split('»').next())
.collect();
merknader.push(format!(
"{} felter finnes i modellen, men ikke i bygningsbeskrivelsen, og blir stående: {}{}. En tilleggsfil kan ikke fjerne et felt.",
felter.len(),
felter.iter().take(3).copied().collect::<Vec<_>>().join(", "),
if felter.len() > 3 { " …" } else { "" }
));
}
// Kontroller at tillegget faktisk gjenskaper filen det er laget av.
let (gjenskapt, bom) = inndata::flett_tillegg(&importert, &patch)?;
merknader.extend(
bom.into_iter()
.map(|m| format!("Navnemønsteret «{m}» traff ingenting.")),
);
if toml::Value::try_from(&gjenskapt).ok().as_ref() != Some(&til) {
merknader.push(
"Tillegget gjenskaper ikke bygningsbeskrivelsen helt. Gå gjennom filen før den brukes."
.into(),
);
}
let ut =
a.ut.unwrap_or_else(|| a.bygning.with_file_name("prosjekt.toml"));
let mut innhold = format!(
"# Tillegg til «{}», laget av forskjellen mot «{}».\n\
# Alt som ikke står i modellen, ligger her, og flettes inn ved hver import:\n\
# enok beregn \"{}\" --tillegg {}\n",
a.modell.display(),
a.bygning.display(),
a.modell.display(),
ut.file_name()
.and_then(|n| n.to_str())
.unwrap_or("prosjekt.toml")
);
for m in &merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&toml::to_string_pretty(&patch).context("serialiserer tillegget")?);
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(())
}