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]]
name = "enok"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anyhow",
"approx",
+1 -1
View File
@@ -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"
+19 -5
View File
@@ -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:
+365 -44
View File
@@ -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<Importrapport> {
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<Importrapport> {
.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<Flate> = Vec::new();
@@ -245,8 +283,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
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<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
.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<Sone> = Vec::new();
if let Some(i) = kjelleretasje {
@@ -442,11 +512,22 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
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<Importrapport> {
}
}
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<Importrapport> {
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<String>) {
(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<f64> {
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<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 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<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
.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::<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.
@@ -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'");
+22 -5
View File
@@ -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()
+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.
pub mod epw;
pub mod geo;
pub mod korreksjon;
pub mod sol;
pub mod tmy;
+30
View File
@@ -76,6 +76,36 @@ fn kommune_av_filnavn(navn: &str) -> Option<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
/// «Indre Østfold» og «Indre Ostfold» treffer samme fil.
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 {
felt: "klima.kommune",
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)]
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<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}");
}