Velg klimasted ut fra georefereringen, og rett adresse fra IFC
Release enok / bygg (release) Successful in 1m20s
test / test (push) Successful in 1m26s

En ny eksport av Nordhusstykket 5 viste hva mer modellen kan bære, og avdekket feil.

Posisjon og klimasted
- IfcMapConversion med IfcProjectedCRS gjøres om til breddegrad og lengdegrad.
  EUREF89 og WGS 84 UTM, NN2000-varianter og Web Mercator forstås. Omregningen er
  kontrollert mot PROJ.
- Vectorworks' standardplassering i Washington, som står når ingenting er satt,
  kjennes igjen og brukes ikke.
- Ny `posisjon` under [klima]. Uten kommune velger beregningen nærmeste av de 358
  klimastedene i TMY-arkivet, og sier i rapporten hvor langt unna det ligger.

Opplysninger fra modellen
- Adressen hentes fra IfcBuilding eller IfcSite. Før ble den første postadressen i
  filen brukt, og det var firmaets adresse i forfatteropplysningene.
- Energirådgiver fra person og organisasjon i forfatteropplysningene.
- Byggets navn fra prosjektet når bygningen heter «Default Building».
- Referansehøyde og terrenghøyde på bygningen gir hvor mye av grunnmuren som står
  over bakken.
- IsLandmarked gir en merknad om at vernede bygninger er unntatt energiattestplikten.

En eksport uten bygningsdeler stopper nå med en feilmelding som peker på
lagtilknytningen i eksportdialogen, i stedet for å skrive en tom bygning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
bl
2026-09-15 15:38:04 +02:00
co-authored by Claude Opus 5
parent be1514bd83
commit fba5a7936d
11 changed files with 698 additions and 57 deletions
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "enok" name = "enok"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"approx", "approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "enok" name = "enok"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
license = "MIT" license = "MIT"
+19 -5
View File
@@ -117,7 +117,7 @@ byggeaar = 1948
oppvarmet_bra = 140.0 oppvarmet_bra = 140.0
[klima] [klima]
kommune = "Indre Østfold" kommune = "Indre Østfold" # eller posisjon = [59.63, 11.01] for nærmeste klimasted
[[flate]] [[flate]]
navn = "Yttervegg sør" 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 | | 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 | | 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 | | 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 Himmelretningene og arealene bør likevel kontrolleres mot tegningen. Importen skriver merknader
øverst i TOML-filen om hva den måtte anta. ø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å Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på
modellen alene. 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. - **Ta med designlagene i eksporten.** I «Export IFC Project» må lagene være knyttet til
Uten det står Vectorworks' standardverdi, som ligger i Washington. 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 - **Merk yttervegger med `IsExternal`.** I modellen for Nordhusstykket 5 er bare tolv av
hundre og fem vegger merket, og kjellermurene er ikke blant dem. 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. - **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 - **Gi etasjene navn.** «Kjeller», «1. etg.» og «2. etg.» er nok til at enok finner den
uoppvarmede kjelleren og regner oppvarmet areal riktig. 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: Regnearkformatet er bevisst enkelt, slik at det kan settes opp som en rapport i BIM-verktøyet:
+351 -30
View File
@@ -11,6 +11,7 @@
use crate::inndata::ifcgeom; use crate::inndata::ifcgeom;
use crate::inndata::step::{self, Arg, Entitet, Fil}; use crate::inndata::step::{self, Arg, Entitet, Fil};
use crate::klima::geo;
use crate::modell::*; use crate::modell::*;
use crate::Result; use crate::Result;
use anyhow::Context; use anyhow::Context;
@@ -49,6 +50,35 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let (vindu_vert, dor_vert) = samle_apninger(&fil); let (vindu_vert, dor_vert) = samle_apninger(&fil);
let (etasjer, etasje_for) = les_etasjer(&fil, skala); 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 ───────────────────────────────── // ── oppvarmet areal fra rommene ─────────────────────────────────
// Rom i en etasje under terreng hører til kjelleren og ikke til oppvarmet BRA. // Rom i en etasje under terreng hører til kjelleren og ikke til oppvarmet BRA.
let mut bra = 0.0; let mut bra = 0.0;
@@ -177,6 +207,14 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
.unwrap_or(0.0); .unwrap_or(0.0);
// Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger. // Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger.
let forste_over = etasjer.iter().position(|e| !e.under_terreng); 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 ───────────────────────────────────────────────── // ── klimaskjerm ─────────────────────────────────────────────────
let mut kjellerflater: Vec<Flate> = Vec::new(); let mut kjellerflater: Vec<Flate> = Vec::new();
@@ -245,8 +283,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
b_faktor: None, b_faktor: None,
absorptans: None, absorptans: None,
omkrets: None, omkrets: None,
dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde), dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde - kjeller_over_terreng),
over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(0.0), over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(kjeller_over_terreng),
sone, sone,
kommentar: d kommentar: d
.etasje .etasje
@@ -407,13 +445,45 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
); );
} }
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 let navn = fil
.av_type("IFCBUILDING") .av_type("IFCBUILDING")
.next() .next()
.and_then(|(_, e)| e.navn().map(|s| s.to_string())) .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()); .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. // Kjellersonen settes sammen av flatene i etasjen under terreng.
let mut soner: Vec<Sone> = Vec::new(); let mut soner: Vec<Sone> = Vec::new();
if let Some(i) = kjelleretasje { if let Some(i) = kjelleretasje {
@@ -442,11 +512,22 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
rom.join(", ") 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!( merknader.push(format!(
"Etasjen «{}» ligger under terreng og er satt opp som en uoppvarmet sone med {:.0} m² gulv. \ "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 \ {grunnmur}",
grunnmuren over bakken, sett `over_terreng` i meter på veggene; utslaget er lite, om lag \
én prosent per meter.",
etasjer[i].navn, kjellerareal etasjer[i].navn, kjellerareal
)); ));
merknader.push("Er kjelleren oppvarmet likevel, slett [[sone]] og legg flatene inn i klimaskjermen. \ 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<Importrapport> {
} }
} }
if let Some((lat, lon)) = posisjon(&fil) { let klimaposisjon = match posisjon(&fil) {
merknader.push(format!( Some(p) if p.er_standardplassering() => {
"Modellen oppgir posisjon {lat:.4}° nord, {lon:.4}° øst. Kontroller at den stemmer, og sett \
`kommune` under [klima] deretter."
));
} else {
merknader.push( merknader.push(
"Modellen oppgir ingen posisjon. Settes prosjektstedet i Vectorworks, kommer det med som \ "Modellen står på Vectorworks' standardplassering i Washington, så posisjonen er ikke \
IfcSite og kan brukes til å velge klimadata." brukt. Georefereres tegningen eller settes prosjektstedet, velger beregningen \
klimasted ut fra den."
.into(), .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 { let bygning = Bygning {
bygning: BygningInfo { bygning: BygningInfo {
@@ -485,11 +589,12 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
adresse: adresse(&fil), adresse: adresse(&fil),
gnr_bnr: None, gnr_bnr: None,
beskrivelse: Some("Importert fra IFC".into()), beskrivelse: Some("Importert fra IFC".into()),
energiraadgiver: None, energiraadgiver: forfatter(&fil),
oppdragsgiver: None, oppdragsgiver: None,
}, },
klima: Klima { klima: Klima {
kommune: data.kommune.clone(), kommune: data.kommune.clone(),
posisjon: klimaposisjon,
..Klima::default() ..Klima::default()
}, },
flate: flater, flate: flater,
@@ -847,9 +952,24 @@ fn les_byggdata(fil: &Fil, egenskaper: &Egenskaper) -> (Byggdata, Vec<String>) {
(d, funnet) (d, funnet)
} }
/// Byggets posisjon fra `IfcSite`, som grader nord og øst. /// Byggets posisjon.
fn posisjon(fil: &Fil) -> Option<(f64, f64)> { ///
let (_, e) = fil.av_type("IFCSITE").next()?; /// 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<geo::Posisjon> {
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<f64> { let grad = |a: Option<&Arg>| -> Option<f64> {
let l = a?.som_liste(); let l = a?.som_liste();
if l.is_empty() { if l.is_empty() {
@@ -859,7 +979,39 @@ fn posisjon(fil: &Fil) -> Option<(f64, f64)> {
let verdi = g(0).abs() + g(1).abs() / 60.0 + g(2).abs() / 3600.0 + g(3).abs() / 3.6e9; 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(if g(0) < 0.0 { -verdi } else { verdi })
}; };
Some((grad(e.arg(9))?, grad(e.arg(10))?)) 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. /// 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<String> { fn adresse(fil: &Fil) -> Option<String> {
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<String> {
let linjer: Vec<String> = e let linjer: Vec<String> = e
.arg(4)? .arg(4)
.som_liste() .map(|a| a.som_liste())
.unwrap_or(&[])
.iter() .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(); .collect();
let by = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or(""); let postnummer = e.arg(8).and_then(|a| a.som_tekst()).unwrap_or("").trim();
let mut ut = linjer.join(", "); let sted = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or("").trim();
if !by.is_empty() { if linjer.is_empty() && sted.is_empty() {
if !ut.is_empty() { return None;
ut.push_str(", ");
} }
ut.push_str(by); 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::<Vec<_>>()
.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<String> {
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::<Vec<_>>()
.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. /// 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"))); 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] #[test]
fn vegg_uten_geometri_utelates() { fn vegg_uten_geometri_utelates() {
let uten = prove().replace("'Body','Tessellation'", "'Axis','Curve2D'"); let uten = prove().replace("'Body','Tessellation'", "'Axis','Curve2D'");
+22 -5
View File
@@ -112,11 +112,14 @@ pub fn hent_klima(b: &Bygning) -> Result<(Vaerdata, String, bool)> {
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA); == crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
return Ok((v, navn, er_referanse)); return Ok((v, navn, er_referanse));
} }
let sted = b // Uten kommune, men med posisjon, velges klimastedet nærmest posisjonen.
.klima let sted = match (&b.klima.kommune, b.klima.posisjon) {
.kommune (Some(k), _) => k.clone(),
.clone() (None, Some([bredde, lengde])) => {
.unwrap_or_else(|| REFERANSEKLIMA.to_string()); tmy::naermeste_kommune(&crate::klima::geo::Posisjon { bredde, lengde })?.0
}
(None, None) => REFERANSEKLIMA.to_string(),
};
let er_referanse = crate::data::normaliser_stedsnavn(&sted) let er_referanse = crate::data::normaliser_stedsnavn(&sted)
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA); == crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
let v = tmy::for_kommune(&sted)?; let v = tmy::for_kommune(&sted)?;
@@ -140,6 +143,20 @@ pub fn kjor_med_klima(
let varmetap = Varmetapsbudsjett::beregn(&lost); let varmetap = Varmetapsbudsjett::beregn(&lost);
let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?; let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?;
let mut merknader = systemer_oppsett.merknader.clone(); 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( merknader.extend(
crate::modell::kontroll::kontroller(b) crate::modell::kontroll::kontroller(b)
.into_iter() .into_iter()
+224
View File
@@ -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<Posisjon> {
let stor = navn.to_uppercase();
let kode: Option<u32> = 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 2838.
Some(k @ 25828..=25838) => Some(fra_utm(k - 25800, x, y)),
// WGS 84 UTM nord, sone 2838.
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
);
}
}
+1
View File
@@ -1,6 +1,7 @@
//! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering. //! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering.
pub mod epw; pub mod epw;
pub mod geo;
pub mod korreksjon; pub mod korreksjon;
pub mod sol; pub mod sol;
pub mod tmy; pub mod tmy;
+30
View File
@@ -76,6 +76,36 @@ fn kommune_av_filnavn(navn: &str) -> Option<String> {
Some(base.to_string()) 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::<f64>().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 /// Henter værdata for en kommune. Navnet sammenlignes normalisert, slik at både
/// «Indre Østfold» og «Indre Ostfold» treffer samme fil. /// «Indre Østfold» og «Indre Ostfold» treffer samme fil.
pub fn for_kommune(kommune: &str) -> Result<Vaerdata> { pub fn for_kommune(kommune: &str) -> Result<Vaerdata> {
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn kontroller(b: &Bygning) -> Vec<Mangel> {
}); });
} }
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 { ut.push(Mangel {
felt: "klima.kommune", felt: "klima.kommune",
melding: "Kommune er ikke oppgitt, så beregningen bruker referanseklimaet Oslo. \ melding: "Kommune er ikke oppgitt, så beregningen bruker referanseklimaet Oslo. \
+4
View File
@@ -117,6 +117,10 @@ fn en() -> u32 {
#[derive(Deserialize, Serialize, Clone, Debug, Default)] #[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Klima { 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. /// Kommune for lokalt klima (TMY-fil fra Zenodo 14779899). Utelatt: bare Oslo.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub kommune: Option<String>, pub kommune: Option<String>,
+30
View File
@@ -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}");
}