//! 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, #[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/`. #[arg(short, long, value_name = "KATALOG")] ut: Option, /// 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, } #[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, /// Utdatafil. Standard: samme navn med endelsen .toml. #[arg(short, long, value_name = "FIL")] ut: Option, /// 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, } #[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, } #[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, 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}"); } 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()); } // Manglene er allerede skrevet ut før beregningen; her kommer bare det som kom i tillegg. let alt_sagt: Vec = 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 { 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, Vec) = 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::>().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::() / 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::() .trim_matches('-') .to_string() } #[allow(dead_code)] fn sjekk_sti(p: &Path) -> Result<()> { if !p.exists() { bail!("finner ikke {}", p.display()); } Ok(()) }