From 32ecb0b132088a7285015d01e5b2ba085d82eb3b Mon Sep 17 00:00:00 2001 From: Bendik Lynghaug Date: Wed, 16 Sep 2026 14:39:58 +0200 Subject: [PATCH] Beregn rett fra en BIM-modell, uten bygning.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «enok beregn modell.ifc» leser modellen, fletter inn en eventuell tilleggsfil og skriver bygningsbeskrivelsen den kom fram til, i utdatakatalogen, slik at beregningen kan etterprøves. Importen sier nå hvilke bygningsdeler som mangler U-verdi, ikke bare hvor mange. Vinduer og dører uten U-verdi i modellen får en typisk verdi for byggeåret, slik flatene alltid har fått, i stedet for faste 2,4 og 2,0 W/(m²K). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o --- README.md | 23 ++++-- src/inndata/gbxml.rs | 10 +-- src/inndata/ifc.rs | 44 ++++++++--- src/inndata/regneark.rs | 2 +- src/main.rs | 159 +++++++++++++++++++++++++--------------- src/modell/lost.rs | 29 ++++++-- src/modell/mod.rs | 4 +- 7 files changed, 180 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 6fe46df..b1e8400 100644 --- a/README.md +++ b/README.md @@ -95,13 +95,14 @@ Typst må være installert for PDF-rapporten. Uten Typst kjører du med `--ingen | Kommando | Hva den gjør | | --- | --- | | `enok beregn FIL.toml` | Beregner og skriver rapport, CSV og JSON til `ut//` | +| `enok beregn modell.ifc` | Beregner rett fra en modell, uten at `bygning.toml` finnes fra før | | `enok import FIL` | Leser IFC, gbXML eller regneark og skriver `bygning.toml` | | `enok import FIL --tillegg prosjekt.toml` | Som over, med en tilleggsfil flettet inn (se nedenfor) | | `enok klima --liste` | Lister alle kommuner med klimadata | | `enok klima --kommune Oslo` | Viser nøkkeltall for ett klimasted | | `enok skala smaahus --areal 140` | Viser karakterskalaen med arealkorreksjon | -Nyttige valg til `beregn`: `--ingen-pdf`, `--ingen-timeserie`, `--enova-xml`, +Nyttige valg til `beregn`: `--tillegg FIL`, `--ingen-pdf`, `--ingen-timeserie`, `--enova-xml`, `--uten-scenarier`, `--ut KATALOG`. ### Utdata @@ -194,15 +195,21 @@ Ferdige eksempler ligger i `eksempler/`. ### Modellen og en tilleggsfil -Modellen bør være kilden til alt den kan beskrive. Det den ikke beskriver, som varmeanlegg, -lekkasjetall og tiltak, og rettelser der den mangler noe, legges i en tilleggsfil som flettes -inn ved hver import etter de samme reglene som scenarioene: +Modellen er kilden til alt den kan beskrive, og en modell alene gir en ferdig rapport: enok +fyller hullene med normerte verdier og typiske verdier for byggeåret, og rapporten sier hva +som er antatt. Det modellen ikke kan beskrive, som varmeanlegg, lekkasjetall og tiltak, og +rettelser der den mangler noe, legges i en tilleggsfil som flettes inn etter de samme reglene +som scenarioene: ```sh -enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml -enok beregn bygning.toml +enok beregn modell.ifc # bare modellen +enok beregn modell.ifc --tillegg prosjekt.toml # modellen og tilleggsfilen +enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml # samme, som fil ``` +`beregn` skriver bygningsbeskrivelsen modellen ga, til `bygning.toml` i utdatakatalogen, slik +at beregningen kan etterprøves. + ```toml # prosjekt.toml [import] @@ -284,6 +291,10 @@ egenskapssett på `IfcBuilding`, `IfcSite` eller `IfcProject`: | `Varmekilde` | `Oppvarming`, `HeatingSource` | `Bergvarmepumpe` | | `OppvarmetBRA` | `HeatedFloorArea` | `120` | +Mangler en bygningsdel U-verdi i modellen, sier importen hvilke det gjelder, og beregningen +bruker en typisk verdi for byggeåret. Det gjelder også vinduer og dører, som i de fleste +eksportene ikke har U-verdi. + Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på modellen alene. diff --git a/src/inndata/gbxml.rs b/src/inndata/gbxml.rs index 84a6589..b96cc05 100644 --- a/src/inndata/gbxml.rs +++ b/src/inndata/gbxml.rs @@ -481,13 +481,13 @@ fn bygg( let areal = areal_av(a.areal, a.bredde, a.hoyde, &a.polygon); aapningsareal += areal; let vt = vindustyper.get(&a.vindustype); - let u = vt.and_then(|v| v.u_verdi).unwrap_or_else(|| { + let u = vt.and_then(|v| v.u_verdi); + if u.is_none() { merknader.push(format!( - "Åpningen «{}» mangler U-verdi i gbXML; satt til 2,4 W/(m²K).", + "Åpningen «{}» mangler U-verdi i gbXML; verdien utledes av byggeåret.", a.navn )); - 2.4 - }); + } let dor = a.typ.to_lowercase().contains("door"); vinduer.push(Vindu { navn: vt @@ -687,7 +687,7 @@ mod tester { // Vinduet. assert_eq!(b.vindu.len(), 1); assert_eq!(b.vindu[0].areal, 3.0); - assert_eq!(b.vindu[0].u_verdi, 1.2); + assert_eq!(b.vindu[0].u_verdi, Some(1.2)); assert_eq!(b.vindu[0].g_verdi, Some(0.5)); } diff --git a/src/inndata/ifc.rs b/src/inndata/ifc.rs index 82bca2d..f8d1acc 100644 --- a/src/inndata/ifc.rs +++ b/src/inndata/ifc.rs @@ -245,7 +245,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu let mut kjellerflater: Vec = Vec::new(); let mut flater: Vec = Vec::new(); let mut utelatt: Vec<(String, f64, Option)> = Vec::new(); - let mut uten_uverdi = 0usize; + let mut uten_uverdi: Vec = Vec::new(); let mut uten_geometri = 0usize; let mut asimut_for_vegg: HashMap = HashMap::new(); @@ -287,7 +287,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu (mot, None) }; if d.u_verdi.is_none() { - uten_uverdi += 1; + uten_uverdi.push(d.navn.clone()); } let mal = if i_kjeller { &mut kjellerflater @@ -358,14 +358,12 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu .and_then(|v| asimut_for_vegg.get(v)) .copied() .unwrap_or(0.0); - let u = u_verdi(&egenskaper, r).unwrap_or_else(|| { - uten_uverdi += 1; - if dor { - 2.0 - } else { - 2.4 - } - }); + // Uten U-verdi i modellen lar vi feltet stå tomt, slik at beregningen bruker en + // typisk verdi for byggeåret i stedet for en fast standardverdi. + let u = u_verdi(&egenskaper, r); + if u.is_none() { + uten_uverdi.push(navn_av(e, r, if dor { "Dør" } else { "Vindu" })); + } vinduer.push(Vindu { navn: navn_av(e, r, if dor { "Dør" } else { "Vindu" }), areal, @@ -445,9 +443,31 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu "{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal." )); } - if uten_uverdi > 0 { + if !uten_uverdi.is_empty() { + // Navnene gjør det tydelig hva som må rettes i modellen. Flere elementer av samme + // konstruksjon nevnes én gang, med antallet. + let mut antall: Vec<(String, usize)> = Vec::new(); + for navn in &uten_uverdi { + let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string(); + match antall.iter_mut().find(|(n, _)| *n == kort) { + Some((_, a)) => *a += 1, + None => antall.push((kort, 1)), + } + } + let liste: Vec = antall + .iter() + .map(|(n, a)| { + if *a > 1 { + format!("{n} ×{a}") + } else { + n.clone() + } + }) + .collect(); merknader.push(format!( - "{uten_uverdi} bygningsdeler mangler U-verdi i IFC-filen. Legg inn U-verdier, eller la enok bruke typiske verdier for byggeåret." + "{} bygningsdeler mangler U-verdi i IFC-filen: {}. Legg inn U-verdier i modellen, eller la enok bruke typiske verdier for byggeåret.", + uten_uverdi.len(), + liste.join(", ") )); } merknader.push( diff --git a/src/inndata/regneark.rs b/src/inndata/regneark.rs index 4ff0138..50a8ff0 100644 --- a/src/inndata/regneark.rs +++ b/src/inndata/regneark.rs @@ -153,7 +153,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { antall: tall(felt(8)).unwrap_or(1.0), orientering: tall(felt(4)).unwrap_or(0.0), helning: tall(felt(5)).unwrap_or(90.0), - u_verdi: u, + u_verdi: Some(u), g_verdi: tall(felt(7)), karmandel: None, solskjerming: None, diff --git a/src/main.rs b/src/main.rs index baf6026..522049e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,9 @@ struct Beregn { /// 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)] @@ -102,14 +105,77 @@ fn main() -> Result<()> { } } +/// 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 = inndata::les_bygning(&a.fil)?; + 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)?; @@ -215,66 +281,9 @@ fn skriv_sammendrag(r: &kjor::Resultat, scenarier: &[(String, kjor::Resultat)]) } fn import(a: Import) -> Result<()> { - let endelse = a - .fil - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let tillegg = a.tillegg.as_deref().map(inndata::les_tillegg).transpose()?; - let valg = tillegg - .as_ref() - .map(|t| t.import.clone()) - .unwrap_or_default(); - let (bygning, mut merknader) = match endelse.as_str() { - "gbxml" | "xml" => { - let r = inndata::gbxml::les(&a.fil)?; - (r.bygning, r.merknader) - } - "ifc" => { - let r = inndata::ifc::les_med(&a.fil, &valg)?; - (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)" - ), - }; - if endelse != "ifc" && !valg.uoppvarmede_rom.is_empty() { - merknader.push("uoppvarmede_rom i tilleggsfilen brukes bare ved import fra IFC.".into()); - } - let (bygning, mut innhold) = match (&tillegg, &a.tillegg) { - (Some(t), Some(sti)) => { - let (b, 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." - )); - } - let hode = 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", - a.fil.display(), - sti.display() - ); - (b, hode) - } - _ => ( - bygning, - String::from("# Importert med enok. Fyll ut feltene som er merket nedenfor.\n"), - ), - }; + let (bygning, merknader, _) = les_modell(&a.fil, a.tillegg.as_deref())?; let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml")); - let tekst = toml::to_string_pretty(&bygning).context("serialiserer bygningen til TOML")?; - for m in &merknader { - innhold.push_str(&format!("# {m}\n")); - } - innhold.push('\n'); - innhold.push_str(&tekst); + 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 { @@ -283,6 +292,36 @@ fn import(a: Import) -> Result<()> { 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) +} + fn klima(a: Klima) -> Result<()> { if a.liste { for k in enok::klima::tmy::kommuner()? { diff --git a/src/modell/lost.rs b/src/modell/lost.rs index fdcc59e..83b4f76 100644 --- a/src/modell/lost.rs +++ b/src/modell/lost.rs @@ -218,7 +218,7 @@ impl Lost { )?; } for v in &b.vindu { - flater.push(los_vindu(v, &mut f)); + flater.push(los_vindu(v, periode, &mut f)); } if flater.is_empty() { bail!("bygningen har ingen flater; legg inn minst én [[flate]]"); @@ -646,8 +646,25 @@ fn los_flate( }) } -fn los_vindu(v: &Vindu, logg: &mut Vec) -> LostFlate { +fn los_vindu( + v: &Vindu, + periode: Option<&crate::data::Periode>, + logg: &mut Vec, +) -> LostFlate { let areal = v.areal * v.antall; + // U-verdi: oppgitt, eller typisk for byggeperioden. Et vindu uten oppgitt U-verdi er + // vanligvis originalvinduet, og da sier byggeåret mer enn en fast standardverdi. + let u = v.u_verdi.unwrap_or_else(|| { + let antatt = periode.map(|p| p.vindu).unwrap_or(2.8); + logg.push(Forutsetning { + felt: format!("vindu.{}.u_verdi", v.navn), + verdi: format!("{antatt:.2} W/(m²K)"), + kilde: periode + .map(|p| format!("typisk for {}", p.navn)) + .unwrap_or_else(|| "antatt".into()), + }); + antatt + }); let karm = v.karmandel.unwrap_or_else(|| { logg.push(Forutsetning { felt: format!("vindu.{}.karmandel", v.navn), @@ -660,11 +677,11 @@ fn los_vindu(v: &Vindu, logg: &mut Vec) -> LostFlate { 0.0 } else { v.g_verdi.unwrap_or_else(|| { - let antatt = if v.u_verdi <= 1.2 { 0.50 } else { 0.75 }; + let antatt = if u <= 1.2 { 0.50 } else { 0.75 }; logg.push(Forutsetning { felt: format!("vindu.{}.g_verdi", v.navn), verdi: format!("{antatt:.2}"), - kilde: if v.u_verdi <= 1.2 { + kilde: if u <= 1.2 { "antatt 3-lags energiglass" } else { "antatt 2-lags glass" @@ -688,8 +705,8 @@ fn los_vindu(v: &Vindu, logg: &mut Vec) -> LostFlate { navn: v.navn.clone(), typ: FlateType::Vegg, areal, - u_verdi: v.u_verdi, - u_konstruksjon: v.u_verdi, + u_verdi: u, + u_konstruksjon: u, helning: v.helning, orientering: v.orientering, kappa: 0.0, diff --git a/src/modell/mod.rs b/src/modell/mod.rs index a27efd0..c657c74 100644 --- a/src/modell/mod.rs +++ b/src/modell/mod.rs @@ -268,7 +268,9 @@ pub struct Vindu { pub orientering: f64, #[serde(default = "nitti")] pub helning: f64, - pub u_verdi: f64, + /// U-verdi inkl. karm, W/(m²K). Utelatt: typisk for byggeåret. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub u_verdi: Option, /// Solfaktor for glasset (g-verdi). Utelatt: 0,75 for 2-lags, 0,5 for 3-lags energiglass. #[serde(default, skip_serializing_if = "Option::is_none")] pub g_verdi: Option,