diff --git a/Cargo.lock b/Cargo.lock index bd96cde..bed5aae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,7 +222,7 @@ dependencies = [ [[package]] name = "enok" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "approx", diff --git a/Cargo.toml b/Cargo.toml index 86d6195..811becc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "enok" -version = "0.4.0" +version = "0.4.1" edition = "2021" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" license = "MIT" diff --git a/src/inndata/ifc.rs b/src/inndata/ifc.rs index f8d1acc..118f5af 100644 --- a/src/inndata/ifc.rs +++ b/src/inndata/ifc.rs @@ -26,6 +26,9 @@ pub struct Importrapport { pub utelatt: Vec<(String, f64, Option)>, } +/// Konstruksjonsnavn og avrundet plassering, som skiller én bygningsdel fra en annen. +type Dublettnokkel = (String, i64, i64, i64, i64); + const VEGGTYPER: [&str; 3] = ["IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL"]; const PLATETYPER: [&str; 3] = ["IFCSLAB", "IFCROOF", "IFCSLABSTANDARDCASE"]; @@ -182,9 +185,59 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu plass, areal: flate.map(|f| f.areal).unwrap_or(0.0), normal: flate.map(|f| f.normal), + z: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.hoyde).unwrap_or(0.0), }); } + // Noen eksporter legger den samme bygningsdelen i flere etasjer. To deler med samme + // konstruksjon, samme areal og samme plassering er den samme veggen eller det samme + // dekket. Kopiene blir stående, slik at vinduene i dem finner veggen sin, men de telles + // ikke med i klimaskjermen. Av kopiene beholdes den som ligger i den etasjen høyden + // tilsier, slik at en kjellervegg havner i kjelleren. + let konstruksjon = |navn: &str| navn.split(" #").next().unwrap_or(navn).trim().to_string(); + let mut grupper: Vec<(Dublettnokkel, Vec)> = Vec::new(); + for d in &deler { + let Some((x, y, _, _, _)) = d.plass else { + continue; + }; + let cm = |v: f64| (v * 50.0).round() as i64; + let nokkel = ( + konstruksjon(&d.navn), + cm(x), + cm(y), + cm(d.z), + (d.areal * 100.0).round() as i64, + ); + match grupper.iter_mut().find(|(k, _)| *k == nokkel) { + Some((_, medlemmer)) => medlemmer.push(d.nr), + None => grupper.push((nokkel, vec![d.nr])), + } + } + let mut dublett_av: HashMap = HashMap::new(); + for (_, medlemmer) in grupper.iter().filter(|(_, m)| m.len() > 1) { + let avstand_til_etasjen = |nr: u64| { + let d = deler.iter().find(|d| d.nr == nr)?; + let hoyde = d.etasje.and_then(|i| etasjer.get(i)).map(|e| e.hoyde)?; + Some((d.z - hoyde).abs()) + }; + let beholdt = *medlemmer + .iter() + .min_by(|a, b| { + let (fa, fb) = (avstand_til_etasjen(**a), avstand_til_etasjen(**b)); + match (fa, fb) { + (Some(x), Some(y)) => x.total_cmp(&y).then(a.cmp(b)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.cmp(b), + } + }) + .unwrap_or(&medlemmer[0]); + for nr in medlemmer.iter().filter(|nr| **nr != beholdt) { + dublett_av.insert(*nr, beholdt); + } + } + let dubletter = dublett_av.len(); + // Tyngdepunkt av de ytre veggene bestemmer hvilken vei normalen peker. let ytre_deler: Vec<&Del> = deler .iter() @@ -234,8 +287,16 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu let forste_over = etasjer.iter().position(|e| !e.under_terreng); // Terrenghøyden på IfcBuilding sier hvor mye av grunnmuren som står over bakken. let terreng = terrenghoyder(&fil, skala); - let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) { + // Ligger terrengmodellen over gulvet i første etasje, er den ikke lagt i bakkenivå, og da + // sier den ingenting om hvor mye av grunnmuren som er nedgravd. + let terreng_over_gulvet = match (terreng, forste_over.and_then(|i| etasjer.get(i))) { (Some((referanse, terrengnivaa)), Some(forste)) => { + terrengnivaa > referanse + forste.hoyde + 0.5 + } + _ => false, + }; + let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) { + (Some((referanse, terrengnivaa)), Some(forste)) if !terreng_over_gulvet => { over_terreng_for_kjeller(referanse, terrengnivaa, forste.hoyde, kjellerdybde) } _ => 0.0, @@ -251,14 +312,14 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu // Laveste etasje og laveste gulvnivå skiller dekke mot grunn fra etasjeskillere. let etasjekontekst = Etasjekontekst { - nederste: (!etasjer.is_empty()).then_some(0), kjeller: kjelleretasje, forste_over, + forste_over_hoyde: forste_over.and_then(|i| etasjer.get(i)).map(|e| e.hoyde), }; let laveste = deler .iter() .filter(|d| PLATETYPER.contains(&d.typ.as_str())) - .filter_map(|d| d.plass.map(|p| p.2)) + .map(|d| d.z) .fold(f64::INFINITY, f64::min); for d in &deler { @@ -271,16 +332,25 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu uten_geometri += 1; continue; } + if dublett_av.contains_key(&d.nr) { + continue; + } let i_kjeller = kjelleretasje.is_some() && d.etasje == kjelleretasje; let Some((typ, mot)) = klassifiser(d, laveste, helning, &etasjekontekst) else { utelatt.push((d.navn.clone(), d.areal, d.u_verdi)); continue; }; - // En etasjeskiller i den laveste etasjen over terreng er grensen mot kjelleren. + // En etasjeskiller like over kjelleren er grensen mot den uoppvarmede sonen. Høyden + // avgjør, ikke etasjetilknytningen, som kan være feil i eksporten. + let ved_kjellertaket = etasjekontekst + .forste_over_hoyde + .map(|h| (d.z - h).abs() < 0.6) + .unwrap_or(false); let (mot, sone) = if kjelleretasje.is_some() && !i_kjeller - && d.etasje == forste_over + && ved_kjellertaket && typ == FlateType::Gulv + && mot != Mot::Grunn { (Mot::Uoppvarmet, Some(kjellernavn.clone())) } else { @@ -324,6 +394,9 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu // ── vinduer og dører ──────────────────────────────────────────── let mut vinduer: Vec = Vec::new(); + let mut sette_vinduer: std::collections::HashSet<(String, (i64, i64, i64), i64)> = + Default::default(); + let mut vindusdubletter = 0usize; for (r, e) in fil.av_type("IFCWINDOW").chain(fil.av_type("IFCDOOR")) { let dor = e.typ == "IFCDOOR"; let hoyde = e.arg(8).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala; @@ -340,7 +413,9 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu dor_vert.get(&r) } else { vindu_vert.get(&r) - }; + } + .map(|v| dublett_av.get(v).copied().unwrap_or(*v)); + let vert = vert.as_ref(); // Vinduer i innervegger hører ikke til klimaskjermen. let i_ytre_vegg = vert .and_then(|v| deler.iter().find(|d| d.nr == *v)) @@ -354,6 +429,25 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu )); continue; } + // Det samme vinduet i to kopier av den samme veggen er ett vindu. + let sted = e + .arg(5) + .and_then(|a| a.som_ref()) + .and_then(|p| global_plassering(&fil, p, skala)) + .map(|(x, y, z, _, _)| { + let cm = |v: f64| (v * 50.0).round() as i64; + (cm(x), cm(y), cm(z)) + }) + .unwrap_or_default(); + let nokkel = ( + konstruksjon(&navn_av(e, r, if dor { "Dør" } else { "Vindu" })), + sted, + (areal * 100.0).round() as i64, + ); + if !sette_vinduer.insert(nokkel) { + vindusdubletter += 1; + continue; + } let asimut = vert .and_then(|v| asimut_for_vegg.get(v)) .copied() @@ -443,6 +537,44 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu "{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal." )); } + if !utelatt.is_empty() { + // Gruppert per konstruksjon, slik at listen er til å lese. + let mut grupper: Vec<(String, usize, f64)> = Vec::new(); + for (navn, areal, _) in &utelatt { + let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string(); + match grupper.iter_mut().find(|(n, _, _)| *n == kort) { + Some((_, antall, sum)) => { + *antall += 1; + *sum += areal; + } + None => grupper.push((kort, 1, *areal)), + } + } + grupper.sort_by(|a, b| b.2.total_cmp(&a.2)); + let liste: Vec = grupper + .iter() + .take(10) + .map(|(navn, antall, sum)| { + if *antall > 1 { + format!("{navn} ×{antall} ({sum:.0} m²)") + } else { + format!("{navn} ({sum:.0} m²)") + } + }) + .collect(); + merknader.push(format!( + "{} bygningsdeler er ikke regnet med i klimaskjermen, som innervegger og dekker inne i bygningen: {}{}.", + utelatt.len(), + liste.join(", "), + if grupper.len() > liste.len() { " …" } else { "" } + )); + } + if dubletter + vindusdubletter > 0 { + merknader.push(format!( + "{} bygningsdeler ligger dobbelt i modellen, samme konstruksjon på samme sted, og er telt én gang. Det skjer når eksporten legger de samme delene i flere etasjer.", + dubletter + vindusdubletter + )); + } 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. @@ -561,7 +693,13 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu rom.join(", ") )), }); - let grunnmur = if terreng.is_some() { + let grunnmur = if terreng_over_gulvet { + format!( + "Terrengmodellen ligger over gulvet i første etasje, så den er ikke brukt. \ + Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Legg \ + terrengmodellen i bakkenivå, eller sett `over_terreng` i meter på veggene." + ) + } else if terreng.is_some() { format!( "Etter terrenghøyden i modellen står grunnmuren {kjeller_over_terreng:.2} m over bakken og \ {:.2} m under.", @@ -1110,6 +1248,8 @@ struct Del { plass: Option<(f64, f64, f64, f64, f64)>, areal: f64, normal: Option<[f64; 3]>, + /// Delens høyde over byggets nullpunkt, av geometrien, m. + z: f64, } /// Oversetter en fritekstverdi fra modellen til en bygningskategori. @@ -1257,12 +1397,13 @@ fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) { /// Avgjør om en bygningsdel hører til klimaskjermen, og hva den vender mot. /// Etasjeinndelingen som klassifiseringen trenger. struct Etasjekontekst { - /// Nederste etasje i modellen. - nederste: Option, /// Etasjen under terreng, dersom modellen har en. kjeller: Option, /// Laveste etasje over terreng, der etasjeskilleren mot kjelleren ligger. forste_over: Option, + /// Høyden på den etasjen, m. Geometrien veier tyngre enn etasjetilknytningen, som kan + /// være feil i eksporten. + forste_over_hoyde: Option, } fn klassifiser( @@ -1297,20 +1438,28 @@ fn klassifiser( if d.ytre == Some(true) { return Some((FlateType::Gulv, Mot::Ute)); } - // Mot grunnen bare i nederste etasje. Er etasjen ukjent, brukes høyden. - let nederst = match (d.etasje, e.nederste) { - (Some(a), Some(b)) => a == b, - _ => d - .plass - .map(|p| (p.2 - laveste).abs() < 0.5) - .unwrap_or(false), - }; - if nederst || navn.contains("kjellergulv") { + // Mot grunnen i det nederste planet, eller når navnet sier det. Høyden av geometrien + // avgjør, siden etasjetilknytningen i en eksport kan være feil. + let paa_grunnen = [ + "kjellergulv", + "on grade", + "på grunn", + "paa grunn", + "såle", + "sale", + ]; + if (d.z - laveste).abs() < 0.5 || paa_grunnen.iter().any(|o| navn.contains(o)) { return Some((FlateType::Gulv, Mot::Grunn)); } // Dekket i laveste etasje over en kjeller er grensen mot den uoppvarmede sonen. - if e.kjeller.is_some() && d.etasje.is_some() && d.etasje == e.forste_over { - return Some((FlateType::Gulv, Mot::Uoppvarmet)); + if e.kjeller.is_some() { + let ved_forste_over = e + .forste_over_hoyde + .map(|h| (d.z - h).abs() < 0.6) + .unwrap_or(false); + if ved_forste_over || (d.etasje.is_some() && d.etasje == e.forste_over) { + return Some((FlateType::Gulv, Mot::Uoppvarmet)); + } } // Dekker lenger opp er etasjeskillere inne i bygningen. return None; @@ -1678,6 +1827,11 @@ mod tester { /// To yttervegger på 5 × 2,5 m med hver sin trekantgeometri, i millimeter. /// Sørveggen ligger ved y = −5 m, nordveggen ved y = +5 m. fn prove() -> String { + prove_med(&[(100, "Sorvegg", -5.0), (120, "Nordvegg", 5.0)]) + } + + /// Som `prove`, men med de veggene testen ber om: (entitetsnummer, navn, y i meter). + fn prove_med(vegger: &[(u64, &str, f64)]) -> String { let mut s = String::from( "ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n #1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n #2= IFCUNITASSIGNMENT((#1));\n #3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);\n #4= IFCDIRECTION((0.,1.));\n #40= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#50,#4);\n #5= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n #10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);\n #20= IFCLOCALPLACEMENT($,#21);\n #21= IFCAXIS2PLACEMENT3D(#22,$,$);\n #22= IFCCARTESIANPOINT((0.,0.,0.));\n #200= IFCPROPERTYSINGLEVALUE('ThermalTransmittance',$,IFCREAL(0.18),$);\n #201= IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$);\n #202= IFCPROPERTYSET('ps',$,'Pset_WallCommon',$,(#200,#201));\n #300= IFCSPACE('s',$,'Stue',$,$,#20,$,'Stue',.ELEMENT.,.INTERNAL.,$);\n #310= IFCQUANTITYAREA('NetFloorArea',$,$,100.0,$);\n #311= IFCQUANTITYVOLUME('NetVolume',$,$,250.0,$);\n #312= IFCELEMENTQUANTITY('q2',$,'BaseQuantities',$,$,(#310,#311));\n #313= IFCRELDEFINESBYPROPERTIES('r3',$,$,$,(#300),#312);\n", ); @@ -1727,8 +1881,9 @@ mod tester { p + 8 )); }; - vegg(100, "Sorvegg", -5.0); - vegg(120, "Nordvegg", 5.0); + for (nr, navn, y) in vegger { + vegg(*nr, navn, *y); + } s.push_str("ENDSEC;\nEND-ISO-10303-21;"); s } @@ -1774,6 +1929,24 @@ mod tester { ); } + #[test] + fn samme_vegg_i_flere_etasjer_telles_en_gang() { + // Den samme veggen på nøyaktig samme sted to ganger, slik en eksport gjør når den + // legger de samme delene i flere etasjer. + let tekst = prove_med(&[ + (100, "Sorvegg", -5.0), + (120, "Nordvegg", 5.0), + (140, "Sorvegg", -5.0), + ]); + let r = fra_tekst(&tekst, "prove").unwrap(); + assert_eq!(r.bygning.flate.len(), 2, "flater: {:?}", r.bygning.flate); + assert!( + r.merknader.iter().any(|m| m.contains("dobbelt i modellen")), + "{:?}", + r.merknader + ); + } + #[test] fn vegger_faar_himmelretning_i_navnet() { let r = fra_tekst(&prove(), "prove").unwrap(); diff --git a/src/inndata/ifcgeom.rs b/src/inndata/ifcgeom.rs index 8cc0473..021166f 100644 --- a/src/inndata/ifcgeom.rs +++ b/src/inndata/ifcgeom.rs @@ -27,6 +27,9 @@ pub struct Hovedflate { pub normal: [f64; 3], /// Utstrekning i flatens plan: (u_min, u_maks, v_min, v_maks), m. plan: [f64; 4], + /// Midlere høyde over delens eget nullpunkt, m. Sier hvilken etasje delen ligger i når + /// plasseringen i filen ikke gjør det. + pub hoyde: f64, } impl Nett { @@ -95,10 +98,16 @@ impl Nett { let (normal, sum) = grupper .into_iter() .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?; + let hoyde = if self.punkter.is_empty() { + 0.0 + } else { + self.punkter.iter().map(|p| p[2]).sum::() / self.punkter.len() as f64 * skala + }; Some(Hovedflate { areal: sum / 2.0, normal, plan: self.utstrekning(normal, skala), + hoyde, }) } @@ -173,9 +182,16 @@ pub fn samlet_hovedflate(deler: &[Nett], skala: f64) -> Option { grupper.push(f); } } - let areal = grupper.iter().map(|g| g.areal).sum(); + let areal: f64 = grupper.iter().map(|g| g.areal).sum(); + // Arealvektet høyde, slik at et sammensatt dekke havner der mesteparten av det ligger. + let hoyde = if areal > 0.0 { + grupper.iter().map(|g| g.hoyde * g.areal).sum::() / areal + } else { + grupper[0].hoyde + }; Some(Hovedflate { areal, + hoyde, ..grupper[0] }) } diff --git a/src/rapport/typst_ut.rs b/src/rapport/typst_ut.rs index 4e71a5a..fd51430 100644 --- a/src/rapport/typst_ut.rs +++ b/src/rapport/typst_ut.rs @@ -530,9 +530,10 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String { } s.push_str(&format!( - "\n#v(1em)\n#text(size: 8pt, fill: mid)[Beregnet med enok {} etter NS 3031:2025 (timesmetode, \ + "\n#v(1em)\n#text(size: 8pt, fill: mid)[Beregnet {} med enok {} etter NS 3031:2025 (timesmetode, \ NS-EN ISO 52016-1). Karakterskala fastsatt av Enova SF, gjeldende fra 1. januar 2026. \ Klimadata: TMY for norske kommuner basert på CERRA 1991–2020 (CC-BY-4.0).]\n", + i_dag(), env!("CARGO_PKG_VERSION") )); s @@ -550,9 +551,34 @@ fn bunntekst(r: &Resultat) -> String { if let Some(e) = &b.energiraadgiver { deler.push(e.clone()); } + deler.push(format!("enok {} · {}", env!("CARGO_PKG_VERSION"), i_dag())); deler.join(" · ") } +/// Dagens dato som ISO-8601 i UTC. +fn i_dag() -> String { + let sekunder = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let (aar, maaned, dag) = dato_av_dagnummer(sekunder.div_euclid(86_400)); + format!("{aar:04}-{maaned:02}-{dag:02}") +} + +/// Kalenderdato av antall døgn siden 1970-01-01, etter Howard Hinnants algoritme. +fn dato_av_dagnummer(dager: i64) -> (i64, i64, i64) { + let z = dager + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let dag = doy - (153 * mp + 2) / 5 + 1; + let maaned = if mp < 10 { mp + 3 } else { mp - 9 }; + let aar = yoe + era * 400 + i64::from(maaned <= 2); + (aar, maaned, dag) +} + fn himmelretning(orientering: f64, helning: f64) -> String { if helning < 20.0 { return "horisontal".into(); @@ -566,3 +592,15 @@ fn himmelretning(orientering: f64, helning: f64) -> String { let i = (((orientering.rem_euclid(360.0)) + 22.5) / 45.0).floor() as usize % 8; retninger[i].into() } + +#[cfg(test)] +mod tester { + use super::dato_av_dagnummer; + + #[test] + fn dagnummer_gir_riktig_dato() { + assert_eq!(dato_av_dagnummer(0), (1970, 1, 1)); + assert_eq!(dato_av_dagnummer(19_723), (2024, 1, 1)); + assert_eq!(dato_av_dagnummer(20_351), (2025, 9, 20)); + } +}