diff --git a/Cargo.lock b/Cargo.lock index ab2e799..0d2ab63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,7 +222,7 @@ dependencies = [ [[package]] name = "enok" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "approx", diff --git a/Cargo.toml b/Cargo.toml index 947e414..ab33338 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "enok" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" license = "MIT" diff --git a/README.md b/README.md index f28c23a..a7dc906 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ byggeaar = 1948 oppvarmet_bra = 140.0 [klima] -kommune = "Indre Østfold" +kommune = "Indre Østfold" # eller posisjon = [59.63, 11.01] for nærmeste klimasted [[flate]] navn = "Yttervegg sør" @@ -191,7 +191,12 @@ Ferdige eksempler ligger i `eksempler/`. | Uoppvarmet kjeller | En etasje under referansenivået blir satt opp som egen sone, med sine egne vegger og gulv, og dekket over som grense mot den oppvarmede delen | | Oppvarmet BRA | Rommene i etasjene over terreng. Rom som heter kott, bod, loft eller garasje blir flagget | | Vindus- og dørareal | `OverallHeight` og `OverallWidth`, og hvilken vegg de sitter i | -| Posisjon | `IfcSite` med breddegrad og lengdegrad | +| Posisjon | `IfcMapConversion` med `IfcProjectedCRS`, ellers `IfcSite`. Uten oppgitt kommune velger beregningen nærmeste klimasted. EUREF89 UTM og Web Mercator forstås | +| Grunnmur over terreng | `ElevationOfRefHeight` og `ElevationOfTerrain` på bygningen | +| Adresse | `BuildingAddress` på bygningen, eller `SiteAddress` på tomta. Adressen i forfatteropplysningene er firmaets og brukes ikke | +| Energirådgiver | Person og organisasjon i forfatteropplysningene | +| Byggets navn | Bygningens navn, eller prosjektets når bygningen heter «Default Building» | +| Vernet bygning | `IsLandmarked`. Vernede bygninger er unntatt plikten til energiattest | Himmelretningene og arealene bør likevel kontrolleres mot tegningen. Importen skriver merknader øverst i TOML-filen om hva den måtte anta. @@ -221,15 +226,24 @@ egenskapssett på `IfcBuilding`, `IfcSite` eller `IfcProject`: Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på modellen alene. -Fire ting i selve modellen er verdt å gjøre riktig: +Dette i selve modellen er verdt å gjøre riktig, omtrent i rekkefølge etter hva det betyr: -- **Sett prosjektstedet.** Da får `IfcSite` en posisjon, og klimadataene kan velges etter den. - Uten det står Vectorworks' standardverdi, som ligger i Washington. +- **Ta med designlagene i eksporten.** I «Export IFC Project» må lagene være knyttet til + etasjer under «Layer Mapping». Mangler de, blir filen bare prosjektstruktur uten vegger, + dekker, vinduer eller rom. enok stopper da med en feilmelding i stedet for å skrive en tom + bygning. +- **Georeferer tegningen, eller sett prosjektstedet.** Da får modellen koordinater, og + beregningen velger selv nærmeste av de 358 klimastedene. Uten det står Vectorworks' + standardverdi i Washington, og den blir oversett. - **Merk yttervegger med `IsExternal`.** I modellen for Nordhusstykket 5 er bare tolv av hundre og fem vegger merket, og kjellermurene er ikke blant dem. - **Fyll inn U-verdi for tak og vinduer.** Veggene har den, takflatene og vinduene ikke. - **Gi etasjene navn.** «Kjeller», «1. etg.» og «2. etg.» er nok til at enok finner den uoppvarmede kjelleren og regner oppvarmet areal riktig. +- **Sett referansehøyde og terrenghøyde på bygningen.** Da regner enok ut hvor mye av + grunnmuren som står over bakken. Uten dem regnes hele muren som nedgravd. +- **Legg byggets adresse på bygningen.** Adressen i forfatteropplysningene er firmaets, og + brukes ikke som byggets adresse. Regnearkformatet er bevisst enkelt, slik at det kan settes opp som en rapport i BIM-verktøyet: diff --git a/src/inndata/ifc.rs b/src/inndata/ifc.rs index 0873d8b..8c2396e 100644 --- a/src/inndata/ifc.rs +++ b/src/inndata/ifc.rs @@ -11,6 +11,7 @@ use crate::inndata::ifcgeom; use crate::inndata::step::{self, Arg, Entitet, Fil}; +use crate::klima::geo; use crate::modell::*; use crate::Result; use anyhow::Context; @@ -49,6 +50,35 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { let (vindu_vert, dor_vert) = samle_apninger(&fil); let (etasjer, etasje_for) = les_etasjer(&fil, skala); + // En eksport uten bygningsdeler gir ingen klimaskjerm å regne på. Det skjer typisk når + // designlagene ikke er knyttet til etasjer i eksportdialogen, og da er det bedre å stoppe + // enn å skrive en tom bygningsbeskrivelse. + let antall_deler: usize = [ + "IFCWALL", + "IFCWALLSTANDARDCASE", + "IFCCURTAINWALL", + "IFCSLAB", + "IFCROOF", + "IFCWINDOW", + "IFCDOOR", + "IFCSPACE", + ] + .iter() + .map(|t| fil.av_type(t).count()) + .sum(); + if antall_deler == 0 { + anyhow::bail!( + "IFC-filen har ingen bygningsdeler: ingen vegger, dekker, tak, vinduer, dører eller rom. \ + Den inneholder bare prosjektstrukturen{}. I Vectorworks skjer dette gjerne når designlagene \ + ikke er med under «Layer Mapping» i «Export IFC Project».", + if etasjer.is_empty() { + String::new() + } else { + format!(" med {} etasjer", etasjer.len()) + } + ); + } + // ── oppvarmet areal fra rommene ───────────────────────────────── // Rom i en etasje under terreng hører til kjelleren og ikke til oppvarmet BRA. let mut bra = 0.0; @@ -177,6 +207,14 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { .unwrap_or(0.0); // Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger. 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))) { + (Some((referanse, terrengnivaa)), Some(forste)) => { + over_terreng_for_kjeller(referanse, terrengnivaa, forste.hoyde, kjellerdybde) + } + _ => 0.0, + }; // ── klimaskjerm ───────────────────────────────────────────────── let mut kjellerflater: Vec = Vec::new(); @@ -245,8 +283,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { b_faktor: None, absorptans: None, omkrets: None, - dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde), - over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(0.0), + dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde - kjeller_over_terreng), + over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(kjeller_over_terreng), sone, kommentar: d .etasje @@ -407,13 +445,45 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { ); } + let ubrukelig = |s: &str| { + let s = s.trim(); + s.is_empty() || s == "Default Building" || s == "Vectorworks Project" + }; + // Bygningens navn, ellers prosjektets langnavn eller navn, ellers filnavnet. let navn = fil .av_type("IFCBUILDING") .next() .and_then(|(_, e)| e.navn().map(|s| s.to_string())) - .filter(|s| !s.is_empty() && s != "Default Building") + .filter(|s| !ubrukelig(s)) + .or_else(|| { + fil.av_type("IFCPROJECT").next().and_then(|(_, p)| { + [p.arg(5), p.arg(2)] + .into_iter() + .flatten() + .filter_map(|a| a.som_tekst()) + .find(|s| !ubrukelig(s)) + .map(|s| s.trim().to_string()) + }) + }) .unwrap_or_else(|| standardnavn.to_string()); + // Vernede bygninger er unntatt plikten til å ha energiattest. + let vernet = fil + .av_type("IFCBUILDING") + .next() + .and_then(|(r, _)| egenskaper.get(&r)) + .and_then(|e| e.get("IsLandmarked")) + .map(|a| matches!(a.som_tekst(), Some("T") | Some("TRUE") | Some("true"))) + .unwrap_or(false); + if vernet { + merknader.push( + "Bygningen er merket som vernet i modellen. Vernede bygninger etter kulturminneloven og \ + plan- og bygningsloven er unntatt plikten til å ha energiattest, men beregningen kan \ + fortsatt brukes til å vurdere tiltak." + .into(), + ); + } + // Kjellersonen settes sammen av flatene i etasjen under terreng. let mut soner: Vec = Vec::new(); if let Some(i) = kjelleretasje { @@ -442,11 +512,22 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { rom.join(", ") )), }); + let grunnmur = if terreng.is_some() { + format!( + "Etter terrenghøyden i modellen står grunnmuren {kjeller_over_terreng:.2} m over bakken og \ + {:.2} m under.", + kjellerdybde - kjeller_over_terreng + ) + } else { + format!( + "Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Står en del av \ + grunnmuren over bakken, sett `over_terreng` i meter på veggene, eller sett terrenghøyden \ + på bygningen i modellen. Utslaget er lite, om lag én prosent per meter." + ) + }; merknader.push(format!( "Etasjen «{}» ligger under terreng og er satt opp som en uoppvarmet sone med {:.0} m² gulv. \ - Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Står en del av \ - grunnmuren over bakken, sett `over_terreng` i meter på veggene; utslaget er lite, om lag \ - én prosent per meter.", + {grunnmur}", etasjer[i].navn, kjellerareal )); merknader.push("Er kjelleren oppvarmet likevel, slett [[sone]] og legg flatene inn i klimaskjermen. \ @@ -454,18 +535,41 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { } } - if let Some((lat, lon)) = posisjon(&fil) { - merknader.push(format!( - "Modellen oppgir posisjon {lat:.4}° nord, {lon:.4}° øst. Kontroller at den stemmer, og sett \ - `kommune` under [klima] deretter." - )); - } else { - merknader.push( - "Modellen oppgir ingen posisjon. Settes prosjektstedet i Vectorworks, kommer det med som \ - IfcSite og kan brukes til å velge klimadata." - .into(), - ); - } + let klimaposisjon = match posisjon(&fil) { + Some(p) if p.er_standardplassering() => { + merknader.push( + "Modellen står på Vectorworks' standardplassering i Washington, så posisjonen er ikke \ + brukt. Georefereres tegningen eller settes prosjektstedet, velger beregningen \ + klimasted ut fra den." + .into(), + ); + None + } + Some(p) if !p.i_norge() => { + merknader.push(format!( + "Modellen oppgir posisjon {:.4}° nord, {:.4}° øst, som ligger utenfor Norge. \ + Posisjonen er ikke brukt til å velge klimasted.", + p.bredde, p.lengde + )); + None + } + Some(p) => { + merknader.push(format!( + "Modellen oppgir posisjon {:.4}° nord, {:.4}° øst. Uten kommune velger beregningen \ + klimastedet nærmest denne.", + p.bredde, p.lengde + )); + Some([p.bredde, p.lengde]) + } + None => { + merknader.push( + "Modellen oppgir ingen posisjon. Georefereres tegningen eller settes prosjektstedet, \ + velger beregningen klimasted ut fra den." + .into(), + ); + None + } + }; let bygning = Bygning { bygning: BygningInfo { @@ -485,11 +589,12 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { adresse: adresse(&fil), gnr_bnr: None, beskrivelse: Some("Importert fra IFC".into()), - energiraadgiver: None, + energiraadgiver: forfatter(&fil), oppdragsgiver: None, }, klima: Klima { kommune: data.kommune.clone(), + posisjon: klimaposisjon, ..Klima::default() }, flate: flater, @@ -847,19 +952,66 @@ fn les_byggdata(fil: &Fil, egenskaper: &Egenskaper) -> (Byggdata, Vec) { (d, funnet) } -/// Byggets posisjon fra `IfcSite`, som grader nord og øst. -fn posisjon(fil: &Fil) -> Option<(f64, f64)> { - let (_, e) = fil.av_type("IFCSITE").next()?; - let grad = |a: Option<&Arg>| -> Option { - let l = a?.som_liste(); - if l.is_empty() { - return None; - } - let g = |i: usize| l.get(i).and_then(|x| x.som_tall()).unwrap_or(0.0); - let verdi = g(0).abs() + g(1).abs() / 60.0 + g(2).abs() / 3600.0 + g(3).abs() / 3.6e9; - Some(if g(0) < 0.0 { -verdi } else { verdi }) - }; - Some((grad(e.arg(9))?, grad(e.arg(10))?)) +/// Byggets posisjon. +/// +/// Georeferering gjennom `IfcMapConversion` og `IfcProjectedCRS` brukes først, siden den er +/// mest presis. Ellers brukes breddegrad og lengdegrad på `IfcSite`. Vectorworks fyller begge +/// med en standardplassering i Washington når ingenting er satt, og den velges bare når ingen +/// annen posisjon finnes, slik at den kan kjennes igjen og oversees. +fn posisjon(fil: &Fil) -> Option { + let fra_kart = fil.av_type("IFCMAPCONVERSION").next().and_then(|(_, m)| { + let crs = m + .arg(1) + .and_then(|a| a.som_ref()) + .and_then(|r| fil.hent(r))?; + let navn = crs.arg(0).and_then(|a| a.som_tekst())?; + let x = m.arg(2).and_then(|a| a.som_tall())?; + let y = m.arg(3).and_then(|a| a.som_tall())?; + geo::fra_koordinatsystem(navn, x, y) + }); + let fra_tomt = fil.av_type("IFCSITE").next().and_then(|(_, e)| { + let grad = |a: Option<&Arg>| -> Option { + let l = a?.som_liste(); + if l.is_empty() { + return None; + } + let g = |i: usize| l.get(i).and_then(|x| x.som_tall()).unwrap_or(0.0); + let verdi = g(0).abs() + g(1).abs() / 60.0 + g(2).abs() / 3600.0 + g(3).abs() / 3.6e9; + Some(if g(0) < 0.0 { -verdi } else { verdi }) + }; + Some(geo::Posisjon { + bredde: grad(e.arg(9))?, + lengde: grad(e.arg(10))?, + }) + }); + [fra_kart, fra_tomt] + .into_iter() + .flatten() + .find(|p| !p.er_standardplassering()) + .or(fra_kart) + .or(fra_tomt) +} + +/// Referansehøyden og terrenghøyden på bygningen, begge over havet, m. +fn terrenghoyder(fil: &Fil, skala: f64) -> Option<(f64, f64)> { + let (_, e) = fil.av_type("IFCBUILDING").next()?; + let referanse = e.arg(9).and_then(|a| a.som_tall())? * skala; + let terreng = e.arg(10).and_then(|a| a.som_tall())? * skala; + Some((referanse, terreng)) +} + +/// Hvor mye av grunnmuren som står over bakken, m. +/// +/// Toppen av grunnmuren ligger ved gulvet i første etasje over kjelleren. Den står over +/// terrenget med differansen mellom etasjens høyde over havet og terrengets, begrenset til +/// kjellerens høyde. +fn over_terreng_for_kjeller( + referanse: f64, + terreng: f64, + forste_etasje: f64, + kjellerdybde: f64, +) -> f64 { + ((referanse + forste_etasje) - terreng).clamp(0.0, kjellerdybde.max(0.0)) } /// En bygningsdel slik den er lest av IFC-filen. @@ -1097,23 +1249,93 @@ fn navn_av(e: &Entitet, r: u64, standard: &str) -> String { } } +/// Byggets adresse fra `IfcBuilding.BuildingAddress`, eller fra tomta. +/// +/// Adresser knyttet til personer og organisasjoner er forfatterens, ikke byggets, og brukes +/// ikke. Plassholdere uten gate eller poststed, som Vectorworks setter inn, hoppes over. fn adresse(fil: &Fil) -> Option { - let (_, e) = fil.av_type("IFCPOSTALADDRESS").next()?; + let bygning = fil + .av_type("IFCBUILDING") + .filter_map(|(_, e)| e.arg(11).and_then(|a| a.som_ref())); + let tomt = fil + .av_type("IFCSITE") + .filter_map(|(_, e)| e.arg(13).and_then(|a| a.som_ref())); + bygning + .chain(tomt) + .filter_map(|r| fil.hent(r)) + .filter(|e| e.typ == "IFCPOSTALADDRESS") + .find_map(formater_adresse) +} + +/// `IfcPostalAddress` har feltene Purpose, Description, UserDefinedPurpose, InternalLocation, +/// AddressLines, PostalBox, Town, Region, PostalCode og Country. +fn formater_adresse(e: &Entitet) -> Option { let linjer: Vec = e - .arg(4)? - .som_liste() + .arg(4) + .map(|a| a.som_liste()) + .unwrap_or(&[]) .iter() - .filter_map(|a| a.som_tekst().map(|s| s.to_string())) + .filter_map(|a| a.som_tekst()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) .collect(); - let by = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or(""); - let mut ut = linjer.join(", "); - if !by.is_empty() { - if !ut.is_empty() { - ut.push_str(", "); - } - ut.push_str(by); + let postnummer = e.arg(8).and_then(|a| a.som_tekst()).unwrap_or("").trim(); + let sted = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or("").trim(); + if linjer.is_empty() && sted.is_empty() { + return None; + } + let poststed: Vec<&str> = [postnummer, sted] + .into_iter() + .filter(|s| !s.is_empty()) + .collect(); + Some( + linjer + .into_iter() + .chain((!poststed.is_empty()).then(|| poststed.join(" "))) + .collect::>() + .join(", "), + ) +} + +/// Forfatteren av modellen, som person og organisasjon. +/// +/// I en modell laget for energirådgivning er det energirådgiveren. Vectorworks' egen +/// organisasjon, som står når ingenting er fylt inn, tas ikke med. +fn forfatter(fil: &Fil) -> Option { + let (_, po) = fil.av_type("IFCPERSONANDORGANIZATION").next()?; + let person = po + .arg(0) + .and_then(|a| a.som_ref()) + .and_then(|r| fil.hent(r)); + let organisasjon = po + .arg(1) + .and_then(|a| a.som_ref()) + .and_then(|r| fil.hent(r)); + // IfcPerson: Identification, FamilyName, GivenName, … + let personnavn = person + .map(|p| { + [p.arg(2), p.arg(1)] + .into_iter() + .flatten() + .filter_map(|a| a.som_tekst()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" ") + }) + .filter(|s| !s.is_empty()); + // IfcOrganization: Identification, Name, … + let organisasjonsnavn = organisasjon + .and_then(|o| o.arg(1).and_then(|a| a.som_tekst())) + .map(str::trim) + .filter(|s| !s.is_empty() && !s.to_lowercase().contains("vectorworks")) + .map(str::to_string); + match (personnavn, organisasjonsnavn) { + (Some(p), Some(o)) => Some(format!("{p}, {o}")), + (Some(p), None) => Some(p), + (None, Some(o)) => Some(o), + (None, None) => None, } - (!ut.is_empty()).then_some(ut) } /// Prosjektets dreining mot sant nord, i grader med klokka. @@ -1500,6 +1722,105 @@ mod tester { assert!(r.merknader.iter().any(|m| m.contains("tatt i bruk"))); } + #[test] + fn eksport_uten_bygningsdeler_gir_tydelig_feil() { + let tom = "ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n\ + #1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n\ + #2= IFCUNITASSIGNMENT((#1));\n\ + #3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,$,#2);\n\ + #10= IFCBUILDING('b',$,'Hus',$,$,$,$,$,.ELEMENT.,$,$,$);\n\ + #11= IFCBUILDINGSTOREY('k',$,'Kjeller',$,$,$,$,$,.ELEMENT.,-2350.);\n\ + ENDSEC;\nEND-ISO-10303-21;"; + let feil = fra_tekst(tom, "tom") + .err() + .expect("en tom eksport skal gi feil"); + let tekst = feil.to_string(); + assert!(tekst.contains("ingen bygningsdeler"), "{tekst}"); + assert!(tekst.contains("1 etasjer"), "{tekst}"); + } + + /// Adressen på firmaet som laget modellen skal ikke bli byggets adresse. + #[test] + fn adressen_hentes_fra_bygningen_og_ikke_fra_forfatteren() { + let t = prove().replace( + "#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);", + "#501= IFCPOSTALADDRESS($,$,$,$,('Hob\\X\\F8lveien 6'),$,'Tomter',$,'1825','Norge');\n\ + #502= IFCORGANIZATION('976015711','Klingenberg Bygg',$,$,(#501));\n\ + #503= IFCPERSON($,'Lynghaug','Fredrik',$,$,$,$,$);\n\ + #504= IFCPERSONANDORGANIZATION(#503,#502,$);\n\ + #500= IFCPOSTALADDRESS($,$,$,$,('Nordhusstykket 5'),$,'Tomter',$,'1825','Norge');\n\ + #10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,#500);", + ); + let r = fra_tekst(&t, "prove").unwrap(); + assert_eq!( + r.bygning.bygning.adresse.as_deref(), + Some("Nordhusstykket 5, 1825 Tomter") + ); + assert_eq!( + r.bygning.bygning.energiraadgiver.as_deref(), + Some("Fredrik Lynghaug, Klingenberg Bygg") + ); + } + + #[test] + fn plassholderadresse_og_standardnavn_brukes_ikke() { + let t = prove() + .replace( + "#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);", + "#500= IFCPOSTALADDRESS($,$,$,'Vectorworks Architect',$,$,$,$,$,$);\n\ + #10= IFCBUILDING('b',$,'Default Building',$,$,#20,$,$,.ELEMENT.,$,$,#500);", + ) + .replace( + "#3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);", + "#3= IFCPROJECT('p',$,'Nordhusstykket 5','Energianalyse',$,'Nordhusstykket 5','Eksisterende',(#40),#2);", + ); + let r = fra_tekst(&t, "prove").unwrap(); + assert_eq!(r.bygning.bygning.adresse, None); + assert_eq!(r.bygning.bygning.navn, "Nordhusstykket 5"); + } + + #[test] + fn georeferering_i_utm_gir_posisjon_for_klimavalg() { + let t = prove().replace( + "#4= IFCDIRECTION((0.,1.));", + "#4= IFCDIRECTION((0.,1.));\n\ + #600= IFCPROJECTEDCRS('EPSG:25832','ETRS89 / UTM zone 32N','ETRS89',$,$,$,$);\n\ + #601= IFCMAPCONVERSION(#40,#600,613351.291,6611921.980,0.,1.,0.,$);", + ); + let r = fra_tekst(&t, "prove").unwrap(); + let [b, l] = r + .bygning + .klima + .posisjon + .expect("posisjon fra georefereringen"); + assert!( + (b - 59.63).abs() < 1e-4 && (l - 11.01).abs() < 1e-4, + "{b}, {l}" + ); + } + + #[test] + fn vectorworks_standardplassering_brukes_ikke() { + let t = prove().replace( + "#4= IFCDIRECTION((0.,1.));", + "#4= IFCDIRECTION((0.,1.));\n\ + #600= IFCPROJECTEDCRS('EPSG:3857',$,'WGS 84',$,$,$,$);\n\ + #601= IFCMAPCONVERSION(#40,#600,-8575524.35228859,4705852.25746973,0.,1.,0.,$);", + ); + let r = fra_tekst(&t, "prove").unwrap(); + assert_eq!(r.bygning.klima.posisjon, None); + assert!(r.merknader.iter().any(|m| m.contains("standardplassering"))); + } + + #[test] + fn grunnmur_over_terreng_av_hoydene() { + // ±0 ligger 131,2 moh., terrenget 130,6 moh. og første etasje på ±0: 0,6 m over bakken. + assert!((over_terreng_for_kjeller(131.2, 130.6, 0.0, 2.35) - 0.6).abs() < 1e-9); + // Aldri mer enn kjellerhøyden, og aldri under null. + assert_eq!(over_terreng_for_kjeller(131.2, 125.0, 0.0, 2.35), 2.35); + assert_eq!(over_terreng_for_kjeller(130.0, 131.0, 0.0, 2.35), 0.0); + } + #[test] fn vegg_uten_geometri_utelates() { let uten = prove().replace("'Body','Tessellation'", "'Axis','Curve2D'"); diff --git a/src/kjor.rs b/src/kjor.rs index 6bdde0c..ab12de1 100644 --- a/src/kjor.rs +++ b/src/kjor.rs @@ -112,11 +112,14 @@ pub fn hent_klima(b: &Bygning) -> Result<(Vaerdata, String, bool)> { == crate::data::normaliser_stedsnavn(REFERANSEKLIMA); return Ok((v, navn, er_referanse)); } - let sted = b - .klima - .kommune - .clone() - .unwrap_or_else(|| REFERANSEKLIMA.to_string()); + // Uten kommune, men med posisjon, velges klimastedet nærmest posisjonen. + let sted = match (&b.klima.kommune, b.klima.posisjon) { + (Some(k), _) => k.clone(), + (None, Some([bredde, lengde])) => { + tmy::naermeste_kommune(&crate::klima::geo::Posisjon { bredde, lengde })?.0 + } + (None, None) => REFERANSEKLIMA.to_string(), + }; let er_referanse = crate::data::normaliser_stedsnavn(&sted) == crate::data::normaliser_stedsnavn(REFERANSEKLIMA); let v = tmy::for_kommune(&sted)?; @@ -140,6 +143,20 @@ pub fn kjor_med_klima( let varmetap = Varmetapsbudsjett::beregn(&lost); let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?; let mut merknader = systemer_oppsett.merknader.clone(); + if b.klima.kommune.is_none() && b.klima.epw.is_none() { + if let Some([bredde, lengde]) = b.klima.posisjon { + let bygg = crate::klima::geo::Posisjon { bredde, lengde }; + let stasjon = crate::klima::geo::Posisjon { + bredde: vaerdata.bredde, + lengde: vaerdata.lengde, + }; + merknader.push(format!( + "Klimastedet {klimanavn} er valgt ut fra posisjonen {bredde:.4}° nord, {lengde:.4}° øst, \ + {:.0} km unna. Sett `kommune` under [klima] for å velge et annet.", + bygg.avstand_km(&stasjon) + )); + } + } merknader.extend( crate::modell::kontroll::kontroller(b) .into_iter() diff --git a/src/klima/geo.rs b/src/klima/geo.rs new file mode 100644 index 0000000..132ba0a --- /dev/null +++ b/src/klima/geo.rs @@ -0,0 +1,224 @@ +//! Posisjon fra BIM-modellen, og valg av klimasted ut fra den. +//! +//! En IFC-modell kan oppgi hvor bygningen står på to måter: som breddegrad og lengdegrad på +//! `IfcSite`, eller som prosjiserte koordinater gjennom `IfcMapConversion` og +//! `IfcProjectedCRS`. I Norge er det siste normalt EUREF89 UTM sone 32, 33 eller 35. Denne +//! modulen gjør begge om til breddegrad og lengdegrad, slik at nærmeste klimasted kan velges +//! automatisk. + +use std::f64::consts::FRAC_PI_2; + +/// Store halvakse for GRS80, som EUREF89 bygger på, m. +const A: f64 = 6_378_137.0; +/// Flattrykning for GRS80. +const F: f64 = 1.0 / 298.257_222_101; +/// Målestokkfaktor langs sentralmeridianen i UTM. +const K0: f64 = 0.9996; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Posisjon { + /// Breddegrad, grader nord. + pub bredde: f64, + /// Lengdegrad, grader øst. + pub lengde: f64, +} + +impl Posisjon { + /// Avstand langs jordoverflaten, km. + pub fn avstand_km(&self, annen: &Posisjon) -> f64 { + let (b1, b2) = (self.bredde.to_radians(), annen.bredde.to_radians()); + let db = b2 - b1; + let dl = (annen.lengde - self.lengde).to_radians(); + let h = (db / 2.0).sin().powi(2) + b1.cos() * b2.cos() * (dl / 2.0).sin().powi(2); + 2.0 * 6371.0 * h.sqrt().asin() + } + + /// Sant for plasseringen Vectorworks setter når prosjektstedet ikke er angitt, og for + /// nullpunktet. Begge betyder at modellen i praksis ikke oppgir noen posisjon. + pub fn er_standardplassering(&self) -> bool { + let washington = + (self.bredde - 38.8895).abs() < 0.05 && (self.lengde + 77.0353).abs() < 0.05; + let null = self.bredde.abs() < 1e-6 && self.lengde.abs() < 1e-6; + washington || null + } + + /// Grovt: innenfor fastlands-Norge og Svalbard. + pub fn i_norge(&self) -> bool { + (57.5..=81.5).contains(&self.bredde) && (4.0..=32.0).contains(&self.lengde) + } +} + +/// Fra Web Mercator (EPSG:3857), som Vectorworks bruker når ingen annen projeksjon er valgt. +pub fn fra_web_mercator(x: f64, y: f64) -> Posisjon { + Posisjon { + bredde: (2.0 * (y / A).exp().atan() - FRAC_PI_2).to_degrees(), + lengde: (x / A).to_degrees(), + } +} + +/// Fra UTM på nordlige halvkule, med GRS80-ellipsoiden som EUREF89 bruker. +/// +/// Inversformlene er de vanlige seriene etter Snyder, «Map Projections – A Working Manual», +/// som gir centimeternøyaktighet innenfor en sone. +pub fn fra_utm(sone: u32, x: f64, y: f64) -> Posisjon { + let e2 = F * (2.0 - F); + let ep2 = e2 / (1.0 - e2); + let e1 = (1.0 - (1.0 - e2).sqrt()) / (1.0 + (1.0 - e2).sqrt()); + + let m = y / K0; + let mu = m / (A * (1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2.powi(3) / 256.0)); + let fi1 = mu + + (3.0 * e1 / 2.0 - 27.0 * e1.powi(3) / 32.0) * (2.0 * mu).sin() + + (21.0 * e1 * e1 / 16.0 - 55.0 * e1.powi(4) / 32.0) * (4.0 * mu).sin() + + (151.0 * e1.powi(3) / 96.0) * (6.0 * mu).sin() + + (1097.0 * e1.powi(4) / 512.0) * (8.0 * mu).sin(); + + let (s, c, t) = (fi1.sin(), fi1.cos(), fi1.tan()); + let n1 = A / (1.0 - e2 * s * s).sqrt(); + let t1 = t * t; + let c1 = ep2 * c * c; + let r1 = A * (1.0 - e2) / (1.0 - e2 * s * s).powf(1.5); + let d = (x - 500_000.0) / (n1 * K0); + + let bredde = fi1 + - (n1 * t / r1) + * (d * d / 2.0 + - (5.0 + 3.0 * t1 + 10.0 * c1 - 4.0 * c1 * c1 - 9.0 * ep2) * d.powi(4) / 24.0 + + (61.0 + 90.0 * t1 + 298.0 * c1 + 45.0 * t1 * t1 - 252.0 * ep2 - 3.0 * c1 * c1) + * d.powi(6) + / 720.0); + let sentralmeridian = (sone as f64 - 1.0) * 6.0 - 180.0 + 3.0; + let lengde = sentralmeridian + + ((d - (1.0 + 2.0 * t1 + c1) * d.powi(3) / 6.0 + + (5.0 - 2.0 * c1 + 28.0 * t1 - 3.0 * c1 * c1 + 8.0 * ep2 + 24.0 * t1 * t1) + * d.powi(5) + / 120.0) + / c) + .to_degrees(); + Posisjon { + bredde: bredde.to_degrees(), + lengde, + } +} + +/// Gjør koordinater i et navngitt koordinatsystem om til posisjon. +/// +/// `navn` er slik det står i `IfcProjectedCRS`, for eksempel «EPSG:25832». Koordinatsystemer +/// enok ikke kjenner gir `None`, og da brukes ikke posisjonen. +pub fn fra_koordinatsystem(navn: &str, x: f64, y: f64) -> Option { + let stor = navn.to_uppercase(); + let kode: Option = stor.find("EPSG").and_then(|i| { + let siffer: String = stor[i + 4..] + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + siffer.parse().ok() + }); + match kode { + Some(3857) | Some(900_913) => Some(fra_web_mercator(x, y)), + // Geografiske koordinater: østverdien er lengdegrad og nordverdien breddegrad. + Some(4326) | Some(4258) => Some(Posisjon { + bredde: y, + lengde: x, + }), + // ETRS89 / EUREF89 UTM sone 28–38. + Some(k @ 25828..=25838) => Some(fra_utm(k - 25800, x, y)), + // WGS 84 UTM nord, sone 28–38. + Some(k @ 32628..=32638) => Some(fra_utm(k - 32600, x, y)), + // ETRS89 / UTM med NN2000-høyder, som Kartverket bruker: 5972 er sone 32, 5973 sone 33, + // 5975 sone 35. + Some(k @ 5972..=5976) => Some(fra_utm(k - 5940, x, y)), + _ => { + // Uten EPSG-kode: kjenn igjen «UTM32», «UTM 33N» og lignende. + let i = stor.find("UTM")?; + let sone: String = stor[i + 3..] + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + let sone: u32 = sone.parse().ok()?; + (1..=60).contains(&sone).then(|| fra_utm(sone, x, y)) + } + } +} + +#[cfg(test)] +mod tester { + use super::*; + + #[test] + fn web_mercator_fra_vectorworks_gir_washington() { + // Verdiene i IfcMapConversion når prosjektstedet ikke er satt i Vectorworks. + let p = fra_web_mercator(-8_575_524.352_288_59, 4_705_852.257_469_73); + assert!((p.bredde - 38.8895).abs() < 1e-3, "bredde {}", p.bredde); + assert!((p.lengde + 77.0352).abs() < 1e-3, "lengde {}", p.lengde); + assert!(p.er_standardplassering()); + assert!(!p.i_norge()); + } + + /// Referanseverdier fra pyproj (PROJ), transformert fra EPSG:4326. + #[test] + fn utm_og_web_mercator_stemmer_med_proj() { + // (sone, øst, nord, breddegrad, lengdegrad, toleranse i grader) + // + // Innenfor sonen og et stykke utenfor er avviket under 1e-5 grader, om lag én meter. + // Tromsø i sone 32 ligger nesten 10 grader utenfor sonen, der rekkeutviklingen gir + // om lag 1,3 m avvik. Det er langt innenfor hva valg av klimasted trenger, men viser + // hvor grensen går. + let utm = [ + (32, 613_351.291, 6_611_921.980, 59.6300, 11.0100, 1e-5), + (32, 596_946.981, 6_642_824.393, 59.9115, 10.7336, 1e-5), + (33, 275_055.615, 6_616_968.071, 59.6300, 11.0100, 1e-5), + (33, 653_421.188, 7_731_721.083, 69.6492, 18.9553, 1e-5), + (35, 188_546.727, 7_747_295.774, 69.6492, 18.9553, 1e-5), + (32, 884_909.622, 7_758_204.184, 69.6492, 18.9553, 1e-4), + ]; + for (sone, x, y, b, l, toleranse) in utm { + let p = fra_utm(sone, x, y); + assert!( + (p.bredde - b).abs() < toleranse && (p.lengde - l).abs() < toleranse, + "sone {sone} ({x}, {y}) ga {p:?}, forventet {b}, {l}" + ); + } + let p = fra_web_mercator(1_225_627.594, 8_317_818.189); + assert!( + (p.bredde - 59.63).abs() < 1e-6 && (p.lengde - 11.01).abs() < 1e-6, + "{p:?}" + ); + } + + #[test] + fn sentralmeridianen_gir_eksakt_lengdegrad() { + // Østverdi 500 000 ligger på sonens sentralmeridian, 9° øst for sone 32. + let p = fra_utm(32, 500_000.0, 6_600_000.0); + assert!((p.lengde - 9.0).abs() < 1e-9, "lengde {}", p.lengde); + let q = fra_utm(33, 500_000.0, 6_600_000.0); + assert!((q.lengde - 15.0).abs() < 1e-9, "lengde {}", q.lengde); + } + + #[test] + fn ekvator_paa_sentralmeridianen() { + let p = fra_utm(32, 500_000.0, 0.0); + assert!(p.bredde.abs() < 1e-9 && (p.lengde - 9.0).abs() < 1e-9); + } + + #[test] + fn ukjent_koordinatsystem_gir_ingen_posisjon() { + assert!(fra_koordinatsystem("EPSG:2154", 650_000.0, 6_860_000.0).is_none()); + assert!(fra_koordinatsystem("lokalt", 1.0, 2.0).is_none()); + } + + #[test] + fn epsg_koder_tolkes() { + let a = fra_koordinatsystem("EPSG:25832", 500_000.0, 6_600_000.0).unwrap(); + let b = fra_koordinatsystem("ETRS89 / UTM zone 32N", 500_000.0, 6_600_000.0).unwrap(); + assert!((a.lengde - 9.0).abs() < 1e-9 && (b.lengde - 9.0).abs() < 1e-9); + let c = fra_koordinatsystem("EPSG:5973", 500_000.0, 6_600_000.0).unwrap(); + assert!( + (c.lengde - 15.0).abs() < 1e-9, + "5973 er sone 33, fikk {}", + c.lengde + ); + } +} diff --git a/src/klima/mod.rs b/src/klima/mod.rs index ef28973..6de0d19 100644 --- a/src/klima/mod.rs +++ b/src/klima/mod.rs @@ -1,6 +1,7 @@ //! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering. pub mod epw; +pub mod geo; pub mod korreksjon; pub mod sol; pub mod tmy; diff --git a/src/klima/tmy.rs b/src/klima/tmy.rs index a3aed02..356bb5f 100644 --- a/src/klima/tmy.rs +++ b/src/klima/tmy.rs @@ -76,6 +76,36 @@ fn kommune_av_filnavn(navn: &str) -> Option { Some(base.to_string()) } +/// Klimastedet nærmest en posisjon, og avstanden dit i km. +/// +/// Hver TMY-fil oppgir i første linje koordinatene for punktet dataene gjelder. Bare starten +/// av hver fil pakkes ut, så oppslaget går raskt selv om arkivet er stort. +pub fn naermeste_kommune(p: &crate::klima::geo::Posisjon) -> Result<(String, f64)> { + let sti = sikre_arkiv()?; + let fil = std::fs::File::open(&sti)?; + let mut zip = zip::ZipArchive::new(fil)?; + let mut best: Option<(String, f64)> = None; + for i in 0..zip.len() { + let oppf = zip.by_index(i)?; + let Some(kommune) = kommune_av_filnavn(oppf.name()) else { + continue; + }; + let mut hode = Vec::with_capacity(512); + oppf.take(512).read_to_end(&mut hode)?; + let linje = crate::klima::epw::avkod(&hode); + let felt: Vec<&str> = linje.lines().next().unwrap_or("").split(',').collect(); + let tall = |i: usize| felt.get(i).and_then(|s| s.trim().parse::().ok()); + let (Some(bredde), Some(lengde)) = (tall(6), tall(7)) else { + continue; + }; + let d = p.avstand_km(&crate::klima::geo::Posisjon { bredde, lengde }); + if best.as_ref().is_none_or(|(_, bd)| d < *bd) { + best = Some((kommune, d)); + } + } + best.ok_or_else(|| anyhow!("TMY-arkivet inneholder ingen klimafiler med koordinater")) +} + /// Henter værdata for en kommune. Navnet sammenlignes normalisert, slik at både /// «Indre Østfold» og «Indre Ostfold» treffer samme fil. pub fn for_kommune(kommune: &str) -> Result { diff --git a/src/modell/kontroll.rs b/src/modell/kontroll.rs index c687889..5c4aa30 100644 --- a/src/modell/kontroll.rs +++ b/src/modell/kontroll.rs @@ -58,7 +58,7 @@ pub fn kontroller(b: &Bygning) -> Vec { }); } - if b.klima.kommune.is_none() && b.klima.epw.is_none() { + if b.klima.kommune.is_none() && b.klima.epw.is_none() && b.klima.posisjon.is_none() { ut.push(Mangel { felt: "klima.kommune", melding: "Kommune er ikke oppgitt, så beregningen bruker referanseklimaet Oslo. \ diff --git a/src/modell/mod.rs b/src/modell/mod.rs index 8e44e9f..b501b96 100644 --- a/src/modell/mod.rs +++ b/src/modell/mod.rs @@ -117,6 +117,10 @@ fn en() -> u32 { #[derive(Deserialize, Serialize, Clone, Debug, Default)] pub struct Klima { + /// Byggets posisjon som [breddegrad, lengdegrad]. Er kommune ikke oppgitt, velges + /// klimastedet nærmest denne posisjonen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub posisjon: Option<[f64; 2]>, /// Kommune for lokalt klima (TMY-fil fra Zenodo 14779899). Utelatt: bare Oslo. #[serde(default, skip_serializing_if = "Option::is_none")] pub kommune: Option, diff --git a/tests/klima.rs b/tests/klima.rs new file mode 100644 index 0000000..c80cd27 --- /dev/null +++ b/tests/klima.rs @@ -0,0 +1,30 @@ +//! Kontroll av valg av klimasted. Testene bruker TMY-arkivet og laster det ned ved første kjøring. + +use enok::data::normaliser_stedsnavn; +use enok::klima::geo::Posisjon; +use enok::klima::tmy::naermeste_kommune; + +#[test] +fn naermeste_klimasted_for_tomter_er_indre_ostfold() { + let (kommune, km) = naermeste_kommune(&Posisjon { + bredde: 59.63, + lengde: 11.01, + }) + .unwrap(); + assert_eq!( + normaliser_stedsnavn(&kommune), + "indreostfold", + "fikk {kommune}" + ); + assert!(km < 25.0, "{km} km"); +} + +#[test] +fn naermeste_klimasted_for_oslo_raadhus_er_oslo() { + let (kommune, _) = naermeste_kommune(&Posisjon { + bredde: 59.9115, + lengde: 10.7336, + }) + .unwrap(); + assert_eq!(normaliser_stedsnavn(&kommune), "oslo", "fikk {kommune}"); +}