Beregn rett fra en BIM-modell, uten bygning.toml

«enok beregn modell.ifc» leser modellen, fletter inn en eventuell tilleggsfil og skriver
bygningsbeskrivelsen den kom fram til, i utdatakatalogen, slik at beregningen kan etterprøves.

Importen sier nå hvilke bygningsdeler som mangler U-verdi, ikke bare hvor mange. Vinduer og
dører uten U-verdi i modellen får en typisk verdi for byggeåret, slik flatene alltid har fått,
i stedet for faste 2,4 og 2,0 W/(m²K).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
bl
2026-09-16 14:39:58 +02:00
co-authored by Claude Opus 5
parent 6d87ef496f
commit 32ecb0b132
7 changed files with 180 additions and 91 deletions
+17 -6
View File
@@ -95,13 +95,14 @@ Typst må være installert for PDF-rapporten. Uten Typst kjører du med `--ingen
| Kommando | Hva den gjør | | Kommando | Hva den gjør |
| --- | --- | | --- | --- |
| `enok beregn FIL.toml` | Beregner og skriver rapport, CSV og JSON til `ut/<navn>/` | | `enok beregn FIL.toml` | Beregner og skriver rapport, CSV og JSON til `ut/<navn>/` |
| `enok beregn modell.ifc` | Beregner rett fra en modell, uten at `bygning.toml` finnes fra før |
| `enok import FIL` | Leser IFC, gbXML eller regneark og skriver `bygning.toml` | | `enok import FIL` | Leser IFC, gbXML eller regneark og skriver `bygning.toml` |
| `enok import FIL --tillegg prosjekt.toml` | Som over, med en tilleggsfil flettet inn (se nedenfor) | | `enok import FIL --tillegg prosjekt.toml` | Som over, med en tilleggsfil flettet inn (se nedenfor) |
| `enok klima --liste` | Lister alle kommuner med klimadata | | `enok klima --liste` | Lister alle kommuner med klimadata |
| `enok klima --kommune Oslo` | Viser nøkkeltall for ett klimasted | | `enok klima --kommune Oslo` | Viser nøkkeltall for ett klimasted |
| `enok skala smaahus --areal 140` | Viser karakterskalaen med arealkorreksjon | | `enok skala smaahus --areal 140` | Viser karakterskalaen med arealkorreksjon |
Nyttige valg til `beregn`: `--ingen-pdf`, `--ingen-timeserie`, `--enova-xml`, Nyttige valg til `beregn`: `--tillegg FIL`, `--ingen-pdf`, `--ingen-timeserie`, `--enova-xml`,
`--uten-scenarier`, `--ut KATALOG`. `--uten-scenarier`, `--ut KATALOG`.
### Utdata ### Utdata
@@ -194,15 +195,21 @@ Ferdige eksempler ligger i `eksempler/`.
### Modellen og en tilleggsfil ### Modellen og en tilleggsfil
Modellen bør være kilden til alt den kan beskrive. Det den ikke beskriver, som varmeanlegg, Modellen er kilden til alt den kan beskrive, og en modell alene gir en ferdig rapport: enok
lekkasjetall og tiltak, og rettelser der den mangler noe, legges i en tilleggsfil som flettes fyller hullene med normerte verdier og typiske verdier for byggeåret, og rapporten sier hva
inn ved hver import etter de samme reglene som scenarioene: som er antatt. Det modellen ikke kan beskrive, som varmeanlegg, lekkasjetall og tiltak, og
rettelser der den mangler noe, legges i en tilleggsfil som flettes inn etter de samme reglene
som scenarioene:
```sh ```sh
enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml enok beregn modell.ifc # bare modellen
enok beregn bygning.toml enok beregn modell.ifc --tillegg prosjekt.toml # modellen og tilleggsfilen
enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml # samme, som fil
``` ```
`beregn` skriver bygningsbeskrivelsen modellen ga, til `bygning.toml` i utdatakatalogen, slik
at beregningen kan etterprøves.
```toml ```toml
# prosjekt.toml # prosjekt.toml
[import] [import]
@@ -284,6 +291,10 @@ egenskapssett på `IfcBuilding`, `IfcSite` eller `IfcProject`:
| `Varmekilde` | `Oppvarming`, `HeatingSource` | `Bergvarmepumpe` | | `Varmekilde` | `Oppvarming`, `HeatingSource` | `Bergvarmepumpe` |
| `OppvarmetBRA` | `HeatedFloorArea` | `120` | | `OppvarmetBRA` | `HeatedFloorArea` | `120` |
Mangler en bygningsdel U-verdi i modellen, sier importen hvilke det gjelder, og beregningen
bruker en typisk verdi for byggeåret. Det gjelder også vinduer og dører, som i de fleste
eksportene ikke har U-verdi.
Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på
modellen alene. modellen alene.
+5 -5
View File
@@ -481,13 +481,13 @@ fn bygg(
let areal = areal_av(a.areal, a.bredde, a.hoyde, &a.polygon); let areal = areal_av(a.areal, a.bredde, a.hoyde, &a.polygon);
aapningsareal += areal; aapningsareal += areal;
let vt = vindustyper.get(&a.vindustype); let vt = vindustyper.get(&a.vindustype);
let u = vt.and_then(|v| v.u_verdi).unwrap_or_else(|| { let u = vt.and_then(|v| v.u_verdi);
if u.is_none() {
merknader.push(format!( merknader.push(format!(
"Åpningen «{}» mangler U-verdi i gbXML; satt til 2,4 W/(m²K).", "Åpningen «{}» mangler U-verdi i gbXML; verdien utledes av byggeåret.",
a.navn a.navn
)); ));
2.4 }
});
let dor = a.typ.to_lowercase().contains("door"); let dor = a.typ.to_lowercase().contains("door");
vinduer.push(Vindu { vinduer.push(Vindu {
navn: vt navn: vt
@@ -687,7 +687,7 @@ mod tester {
// Vinduet. // Vinduet.
assert_eq!(b.vindu.len(), 1); assert_eq!(b.vindu.len(), 1);
assert_eq!(b.vindu[0].areal, 3.0); assert_eq!(b.vindu[0].areal, 3.0);
assert_eq!(b.vindu[0].u_verdi, 1.2); assert_eq!(b.vindu[0].u_verdi, Some(1.2));
assert_eq!(b.vindu[0].g_verdi, Some(0.5)); assert_eq!(b.vindu[0].g_verdi, Some(0.5));
} }
+32 -12
View File
@@ -245,7 +245,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
let mut kjellerflater: Vec<Flate> = Vec::new(); let mut kjellerflater: Vec<Flate> = Vec::new();
let mut flater: Vec<Flate> = Vec::new(); let mut flater: Vec<Flate> = Vec::new();
let mut utelatt: Vec<(String, f64, Option<f64>)> = Vec::new(); let mut utelatt: Vec<(String, f64, Option<f64>)> = Vec::new();
let mut uten_uverdi = 0usize; let mut uten_uverdi: Vec<String> = Vec::new();
let mut uten_geometri = 0usize; let mut uten_geometri = 0usize;
let mut asimut_for_vegg: HashMap<u64, f64> = HashMap::new(); let mut asimut_for_vegg: HashMap<u64, f64> = HashMap::new();
@@ -287,7 +287,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
(mot, None) (mot, None)
}; };
if d.u_verdi.is_none() { if d.u_verdi.is_none() {
uten_uverdi += 1; uten_uverdi.push(d.navn.clone());
} }
let mal = if i_kjeller { let mal = if i_kjeller {
&mut kjellerflater &mut kjellerflater
@@ -358,14 +358,12 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
.and_then(|v| asimut_for_vegg.get(v)) .and_then(|v| asimut_for_vegg.get(v))
.copied() .copied()
.unwrap_or(0.0); .unwrap_or(0.0);
let u = u_verdi(&egenskaper, r).unwrap_or_else(|| { // Uten U-verdi i modellen lar vi feltet stå tomt, slik at beregningen bruker en
uten_uverdi += 1; // typisk verdi for byggeåret i stedet for en fast standardverdi.
if dor { let u = u_verdi(&egenskaper, r);
2.0 if u.is_none() {
} else { uten_uverdi.push(navn_av(e, r, if dor { "Dør" } else { "Vindu" }));
2.4 }
}
});
vinduer.push(Vindu { vinduer.push(Vindu {
navn: navn_av(e, r, if dor { "Dør" } else { "Vindu" }), navn: navn_av(e, r, if dor { "Dør" } else { "Vindu" }),
areal, areal,
@@ -445,9 +443,31 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
"{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal." "{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal."
)); ));
} }
if uten_uverdi > 0 { if !uten_uverdi.is_empty() {
// Navnene gjør det tydelig hva som må rettes i modellen. Flere elementer av samme
// konstruksjon nevnes én gang, med antallet.
let mut antall: Vec<(String, usize)> = Vec::new();
for navn in &uten_uverdi {
let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string();
match antall.iter_mut().find(|(n, _)| *n == kort) {
Some((_, a)) => *a += 1,
None => antall.push((kort, 1)),
}
}
let liste: Vec<String> = antall
.iter()
.map(|(n, a)| {
if *a > 1 {
format!("{n} ×{a}")
} else {
n.clone()
}
})
.collect();
merknader.push(format!( merknader.push(format!(
"{uten_uverdi} bygningsdeler mangler U-verdi i IFC-filen. Legg inn U-verdier, eller la enok bruke typiske verdier for byggeåret." "{} bygningsdeler mangler U-verdi i IFC-filen: {}. Legg inn U-verdier i modellen, eller la enok bruke typiske verdier for byggeåret.",
uten_uverdi.len(),
liste.join(", ")
)); ));
} }
merknader.push( merknader.push(
+1 -1
View File
@@ -153,7 +153,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
antall: tall(felt(8)).unwrap_or(1.0), antall: tall(felt(8)).unwrap_or(1.0),
orientering: tall(felt(4)).unwrap_or(0.0), orientering: tall(felt(4)).unwrap_or(0.0),
helning: tall(felt(5)).unwrap_or(90.0), helning: tall(felt(5)).unwrap_or(90.0),
u_verdi: u, u_verdi: Some(u),
g_verdi: tall(felt(7)), g_verdi: tall(felt(7)),
karmandel: None, karmandel: None,
solskjerming: None, solskjerming: None,
+99 -60
View File
@@ -56,6 +56,9 @@ struct Beregn {
/// Beregner bare hovedalternativet, uten scenarioene i filen. /// Beregner bare hovedalternativet, uten scenarioene i filen.
#[arg(long)] #[arg(long)]
uten_scenarier: bool, uten_scenarier: bool,
/// Tilleggsfil som flettes inn: det modellen ikke beskriver, og rettelser.
#[arg(short, long, value_name = "FIL")]
tillegg: Option<PathBuf>,
} }
#[derive(Args)] #[derive(Args)]
@@ -102,14 +105,77 @@ fn main() -> Result<()> {
} }
} }
/// Leser en bygningsbeskrivelse eller en BIM-modell, med en valgfri tilleggsfil flettet inn.
///
/// En `.toml`-fil er bygningsbeskrivelsen selv; andre filtyper importeres. Returnerer også
/// merknadene fra importen, og om filen var en modell.
fn les_modell(
fil: &Path,
tillegg: Option<&Path>,
) -> Result<(enok::modell::Bygning, Vec<String>, bool)> {
let endelse = fil
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let tillegg = tillegg.map(inndata::les_tillegg).transpose()?;
let valg = tillegg
.as_ref()
.map(|t| t.import.clone())
.unwrap_or_default();
let (bygning, mut merknader, modell) = match endelse.as_str() {
"toml" => (inndata::les_bygning(fil)?, Vec::new(), false),
"gbxml" | "xml" => {
let r = inndata::gbxml::les(fil)?;
(r.bygning, r.merknader, true)
}
"ifc" => {
let r = inndata::ifc::les_med(fil, &valg)?;
(r.bygning, r.merknader, true)
}
"csv" => {
let r = inndata::regneark::les(fil)?;
(r.bygning, r.merknader, true)
}
annen => bail!(
"ukjent filtype «{annen}». enok leser bygning.toml og importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
),
};
if endelse != "ifc" && !valg.uoppvarmede_rom.is_empty() {
merknader.push("uoppvarmede_rom i tilleggsfilen brukes bare ved import fra IFC.".into());
}
let Some(t) = tillegg else {
return Ok((bygning, merknader, modell));
};
let (bygning, bom) = inndata::flett_tillegg(&bygning, &t.endringer)?;
for m in bom {
merknader.push(format!(
"Navnemønsteret «{m}» i tilleggsfilen traff ingen oppføring i modellen."
));
}
Ok((bygning, merknader, modell))
}
fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> { fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> {
let bygning = inndata::les_bygning(&a.fil)?; let (bygning, merknader, modell) = les_modell(&a.fil, a.tillegg.as_deref())?;
for m in &merknader {
eprintln!(" · {m}");
}
for m in enok::modell::kontroll::kontroller(&bygning) { for m in enok::modell::kontroll::kontroller(&bygning) {
eprintln!(" ! {}: {}", m.felt, m.melding); eprintln!(" ! {}: {}", m.felt, m.melding);
} }
let ut = let ut =
a.ut.unwrap_or_else(|| PathBuf::from("ut").join(filnavnvennlig(&bygning.bygning.navn))); a.ut.unwrap_or_else(|| PathBuf::from("ut").join(filnavnvennlig(&bygning.bygning.navn)));
std::fs::create_dir_all(&ut)?; std::fs::create_dir_all(&ut)?;
if modell {
// Bygningsbeskrivelsen modellen ga, skrives med, slik at beregningen kan etterprøves.
let sti = ut.join("bygning.toml");
std::fs::write(
&sti,
skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?,
)?;
eprintln!("Bygningsbeskrivelsen fra modellen: {}", sti.display());
}
eprintln!("Henter klimadata …"); eprintln!("Henter klimadata …");
let (vaerdata, klimanavn, er_referanse) = kjor::hent_klima(&bygning)?; let (vaerdata, klimanavn, er_referanse) = kjor::hent_klima(&bygning)?;
@@ -215,66 +281,9 @@ fn skriv_sammendrag(r: &kjor::Resultat, scenarier: &[(String, kjor::Resultat)])
} }
fn import(a: Import) -> Result<()> { fn import(a: Import) -> Result<()> {
let endelse = a let (bygning, merknader, _) = les_modell(&a.fil, a.tillegg.as_deref())?;
.fil
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let tillegg = a.tillegg.as_deref().map(inndata::les_tillegg).transpose()?;
let valg = tillegg
.as_ref()
.map(|t| t.import.clone())
.unwrap_or_default();
let (bygning, mut merknader) = match endelse.as_str() {
"gbxml" | "xml" => {
let r = inndata::gbxml::les(&a.fil)?;
(r.bygning, r.merknader)
}
"ifc" => {
let r = inndata::ifc::les_med(&a.fil, &valg)?;
(r.bygning, r.merknader)
}
"csv" => {
let r = inndata::regneark::les(&a.fil)?;
(r.bygning, r.merknader)
}
annen => bail!(
"ukjent filtype «{annen}». enok importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
),
};
if endelse != "ifc" && !valg.uoppvarmede_rom.is_empty() {
merknader.push("uoppvarmede_rom i tilleggsfilen brukes bare ved import fra IFC.".into());
}
let (bygning, mut innhold) = match (&tillegg, &a.tillegg) {
(Some(t), Some(sti)) => {
let (b, bom) = inndata::flett_tillegg(&bygning, &t.endringer)?;
for m in bom {
merknader.push(format!(
"Navnemønsteret «{m}» i tilleggsfilen traff ingen oppføring i modellen."
));
}
let hode = format!(
"# Generert av enok fra «{}» og tilleggsfilen «{}».\n\
# Rett i modellen eller i tilleggsfilen; denne filen skrives på nytt ved hver import.\n\
# Merknader fra importen:\n",
a.fil.display(),
sti.display()
);
(b, hode)
}
_ => (
bygning,
String::from("# Importert med enok. Fyll ut feltene som er merket nedenfor.\n"),
),
};
let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml")); let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml"));
let tekst = toml::to_string_pretty(&bygning).context("serialiserer bygningen til TOML")?; let innhold = skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?;
for m in &merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&tekst);
std::fs::write(&ut, innhold)?; std::fs::write(&ut, innhold)?;
println!("Skrev {}", ut.display()); println!("Skrev {}", ut.display());
for m in &merknader { for m in &merknader {
@@ -283,6 +292,36 @@ fn import(a: Import) -> Result<()> {
Ok(()) Ok(())
} }
/// Bygningsbeskrivelsen som TOML, med et hode som sier hvor den kommer fra.
fn skriv_bygning(
bygning: &enok::modell::Bygning,
merknader: &[String],
fil: &Path,
tillegg: Option<&Path>,
) -> Result<String> {
let tekst = toml::to_string_pretty(bygning).context("serialiserer bygningen til TOML")?;
let mut innhold = match tillegg {
Some(t) => format!(
"# Generert av enok fra «{}» og tilleggsfilen «{}».\n\
# Rett i modellen eller i tilleggsfilen; denne filen skrives på nytt ved hver import.\n\
# Merknader fra importen:\n",
fil.display(),
t.display()
),
None => format!(
"# Generert av enok fra «{}».\n\
# Merknader fra importen:\n",
fil.display()
),
};
for m in merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&tekst);
Ok(innhold)
}
fn klima(a: Klima) -> Result<()> { fn klima(a: Klima) -> Result<()> {
if a.liste { if a.liste {
for k in enok::klima::tmy::kommuner()? { for k in enok::klima::tmy::kommuner()? {
+23 -6
View File
@@ -218,7 +218,7 @@ impl Lost {
)?; )?;
} }
for v in &b.vindu { for v in &b.vindu {
flater.push(los_vindu(v, &mut f)); flater.push(los_vindu(v, periode, &mut f));
} }
if flater.is_empty() { if flater.is_empty() {
bail!("bygningen har ingen flater; legg inn minst én [[flate]]"); bail!("bygningen har ingen flater; legg inn minst én [[flate]]");
@@ -646,8 +646,25 @@ fn los_flate(
}) })
} }
fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate { fn los_vindu(
v: &Vindu,
periode: Option<&crate::data::Periode>,
logg: &mut Vec<Forutsetning>,
) -> LostFlate {
let areal = v.areal * v.antall; let areal = v.areal * v.antall;
// U-verdi: oppgitt, eller typisk for byggeperioden. Et vindu uten oppgitt U-verdi er
// vanligvis originalvinduet, og da sier byggeåret mer enn en fast standardverdi.
let u = v.u_verdi.unwrap_or_else(|| {
let antatt = periode.map(|p| p.vindu).unwrap_or(2.8);
logg.push(Forutsetning {
felt: format!("vindu.{}.u_verdi", v.navn),
verdi: format!("{antatt:.2} W/(m²K)"),
kilde: periode
.map(|p| format!("typisk for {}", p.navn))
.unwrap_or_else(|| "antatt".into()),
});
antatt
});
let karm = v.karmandel.unwrap_or_else(|| { let karm = v.karmandel.unwrap_or_else(|| {
logg.push(Forutsetning { logg.push(Forutsetning {
felt: format!("vindu.{}.karmandel", v.navn), felt: format!("vindu.{}.karmandel", v.navn),
@@ -660,11 +677,11 @@ fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate {
0.0 0.0
} else { } else {
v.g_verdi.unwrap_or_else(|| { v.g_verdi.unwrap_or_else(|| {
let antatt = if v.u_verdi <= 1.2 { 0.50 } else { 0.75 }; let antatt = if u <= 1.2 { 0.50 } else { 0.75 };
logg.push(Forutsetning { logg.push(Forutsetning {
felt: format!("vindu.{}.g_verdi", v.navn), felt: format!("vindu.{}.g_verdi", v.navn),
verdi: format!("{antatt:.2}"), verdi: format!("{antatt:.2}"),
kilde: if v.u_verdi <= 1.2 { kilde: if u <= 1.2 {
"antatt 3-lags energiglass" "antatt 3-lags energiglass"
} else { } else {
"antatt 2-lags glass" "antatt 2-lags glass"
@@ -688,8 +705,8 @@ fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate {
navn: v.navn.clone(), navn: v.navn.clone(),
typ: FlateType::Vegg, typ: FlateType::Vegg,
areal, areal,
u_verdi: v.u_verdi, u_verdi: u,
u_konstruksjon: v.u_verdi, u_konstruksjon: u,
helning: v.helning, helning: v.helning,
orientering: v.orientering, orientering: v.orientering,
kappa: 0.0, kappa: 0.0,
+3 -1
View File
@@ -268,7 +268,9 @@ pub struct Vindu {
pub orientering: f64, pub orientering: f64,
#[serde(default = "nitti")] #[serde(default = "nitti")]
pub helning: f64, pub helning: f64,
pub u_verdi: f64, /// U-verdi inkl. karm, W/(m²K). Utelatt: typisk for byggeåret.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub u_verdi: Option<f64>,
/// Solfaktor for glasset (g-verdi). Utelatt: 0,75 for 2-lags, 0,5 for 3-lags energiglass. /// Solfaktor for glasset (g-verdi). Utelatt: 0,75 for 2-lags, 0,5 for 3-lags energiglass.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub g_verdi: Option<f64>, pub g_verdi: Option<f64>,