«enok tillegg modell.ifc bygning.toml» skriver forskjellen mellom modellen og en håndredigert bygningsbeskrivelse som en tilleggsfil: endrede verdier, nye oppføringer, «slett = true» for det bare modellen har, og scenarioene. Kommandoen kontrollerer at tillegget gjenskaper filen det er laget av, og sier fra om noe ikke lar seg uttrykke, siden fletting ikke kan fjerne et enkeltfelt. «enok import» nekter å skrive over en fil enok ikke har laget selv, og viser til kommandoen over. --overskriv går forbi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
500 lines
17 KiB
Rust
500 lines
17 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),
|
|
/// 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}");
|
|
}
|
|
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<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(())
|
|
}
|