diff --git a/README.md b/README.md index cd82d58..6fe46df 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,17 @@ bygning med store vindusflater fått urealistisk høye innetemperaturer om våre lagret solvarme ville kunstig redusert oppvarmingsbehovet. Grensen settes til den normerte settpunkttemperaturen for kjøling, og luftingen koster ingen energi. +**Ventilasjon.** Varmegjenvinneren reguleres ned når den ellers ville varmet tilluften over +settpunktet. Et anlegg med `kjolebatteri = true` holder tilluften på normert temperatur i +driftstiden, slik DiBKs modellbygg for energirammene er regnet, og energien føres som +ventilasjonskjøling. + +**Bygningskategorier.** Normerte laster og driftstider er lagt inn for småhus, boligblokk, +barnehage, kontorbygg, skolebygg, idrettsbygg og kulturbygg. Driftstidene har timer per døgn, +dager per uke og uker per år; lastene gjelder bare på driftsdager, og ukene uten drift legges +i ferier. For sykehus, sykehjem, hotell, forretningsbygg, universitet og lett industri er det +ikke funnet offentlige kilder, og de krever en egen fil i `--data KATALOG/normerte/`. + **Tiltak.** Scenarier i samme fil gir før-og-etter-tall og karakterer i én tabell. ## Installasjon @@ -85,6 +96,7 @@ Typst må være installert for PDF-rapporten. Uten Typst kjører du med `--ingen | --- | --- | | `enok beregn FIL.toml` | Beregner og skriver rapport, CSV og JSON til `ut//` | | `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 klima --liste` | Lister alle kommuner med klimadata | | `enok klima --kommune Oslo` | Viser nøkkeltall for ett klimasted | | `enok skala smaahus --areal 140` | Viser karakterskalaen med arealkorreksjon | @@ -154,6 +166,7 @@ over_terreng = 0.6 # høyden som står over terreng [ventilasjon] type = "naturlig" # naturlig | avtrekk | balansert +# kjolebatteri = true # balansert anlegg med kjølebatteri [[oppvarming.kilde]] type = "el_kjel" # direkte_el, luft_luft_vp, vaeske_vann_vp, vedovn, fjernvarme, … @@ -167,6 +180,7 @@ endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }] Scenarier flettes inn i bygningsbeskrivelsen: tabeller flettes rekursivt, elementer i lister matches på `navn` eller `type`, nye elementer legges til, og `slett = true` fjerner et element. +Et navn med `*` treffer alle elementer det passer på, for eksempel `navn = "Yttervegg*"`. Ferdige eksempler ligger i `eksempler/`. @@ -178,6 +192,53 @@ Ferdige eksempler ligger i `eksempler/`. | gbXML | Verktøy som eksporterer gbXML | Rom, flater, åpninger, konstruksjoner med lag | | Regneark | *Fil → Eksporter → Export Worksheet* | Enkel CSV med én rad per bygningsdel | +### Modellen og en tilleggsfil + +Modellen bør være kilden til alt den kan beskrive. Det den ikke beskriver, som varmeanlegg, +lekkasjetall og tiltak, og rettelser der den mangler noe, legges i en tilleggsfil som flettes +inn ved hver import etter de samme reglene som scenarioene: + +```sh +enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml +enok beregn bygning.toml +``` + +```toml +# prosjekt.toml +[import] +uoppvarmede_rom = ["Kott", "Bod"] # holdes utenfor oppvarmet BRA + +[[flate]] +navn = "Yttervegg*" # alle ytterveggene, uansett himmelretning +masseklasse = "lett" + +[[flate]] +navn = "Roof Slab" # skråtaket mangler U-verdi i modellen +u_verdi = 0.55 + +[[vindu]] +navn = "V-*" +g_verdi = 0.76 + +[[sone]] +navn = "Kjeller" +andel_distribusjonstap = 1.0 + +[[sone.flate]] +navn = "Kjellermur*" +over_terreng = 0.6 + +[[oppvarming.kilde]] +type = "el_kjel" +andel_rom = 1.0 +andel_tappevann = 1.0 +``` + +Flatene får navn etter konstruksjonen i modellen, og vegger får himmelretningen etter navnet +(«Yttervegg, sør»), slik at tilleggsfilen kan vise til dem. Importen er deterministisk: samme +modell og tilleggsfil gir alltid samme `bygning.toml`. Et mønster som ikke treffer noe, nevnes +i merknadene, og et vanlig navn som ikke finnes i modellen, legges til som ny oppføring. + ### Hva enok henter ut av en IFC-modell | Opplysning | Hvor den kommer fra | @@ -267,8 +328,9 @@ vertskjøreren `fish` på samme måte som de andre prosjektene: ### Energirapport fra et byggeprosjekt -Et prosjektlager med sin egen IFC-modell og `bygning.toml` trenger ikke Rust. Det henter -binærfilen fra enoks siste utgivelse: +Et prosjektlager med sin egen IFC-modell og tilleggsfil trenger ikke Rust. Det henter +binærfilen fra enoks siste utgivelse. Én jobb lager `bygning.toml` av modellen, og en annen +beregner rapporten av den: ```yaml name: Energirapport @@ -276,14 +338,14 @@ on: release: types: [published] jobs: - rapport: + bygningsmodell: runs-on: fish defaults: run: shell: fish {0} steps: - uses: https://gitea.com/actions/checkout@v6 - - name: Hent enok og lag rapporten + - name: Hent enok og lag bygning.toml env: TOKEN: ${{ github.token }} run: | @@ -293,6 +355,27 @@ jobs: curl -sSL --fail -o enok --header "Authorization: token $TOKEN" \ "$GITHUB_SERVER_URL/klingenbergbygg/enok/releases/download/$TAG/enok" chmod +x enok + ./enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml + - uses: https://gitea.com/actions/upload-artifact@v4 + with: + name: bygningsmodell + path: | + bygning.toml + enok + rapport: + needs: bygningsmodell + runs-on: fish + defaults: + run: + shell: fish {0} + steps: + - uses: https://gitea.com/actions/checkout@v6 + - uses: https://gitea.com/actions/download-artifact@v4 + with: + name: bygningsmodell + - name: Lag rapporten + run: | + chmod +x enok ./enok beregn bygning.toml --ut dist ``` @@ -310,12 +393,13 @@ Motoren er kontrollert mot uavhengige referanser: | Varmebehov som funksjon av temperaturdifferanse | eksakt lineært | | Varmetapsbudsjett for TEK17-referansebygget | identisk med publisert tabell, 0,69 W/(m²K) | | Normerte energiposter for småhus | varmtvann 25,1, belysning 11,4, teknisk utstyr 17,5 kWh/(m²·år) | +| Faste energiposter for yrkesbyggene | belysning, utstyr og varmtvann treffer energibudsjettene i kildene | ### Kjent avvik Standard Norges lanseringseksempel for NS 3031:2025 oppgir 46,2 kWh/(m²·år) romoppvarming og 106,4 kWh/(m²·år) netto energibehov for TEK17-referansebygget beregnet med Simien 8. enok -beregner 27,2 og 86,7 for den samme bygningen. +beregner 27,1 og 86,6 for den samme bygningen. Varmetapsbudsjettet, de normerte energipostene, solinnstrålingen og energibalansen stemmer hver for seg, så avviket ligger i hvor stor del av solvarmen som nyttiggjøres. NS 3031:2025 @@ -336,7 +420,7 @@ Alle normerte verdier ligger i `data/` og kan overstyres med `--data KATALOG`. | --- | --- | | `skala.toml` | Energikarakterskalaen fra 1.1.2026, Enova SF via Multiconsult-notatet «Grunnlag for ny energikarakterskala», 19.12.2025 | | `vekting.toml` | Vektingsfaktorer etter energimerkeforskriften § 10a med NVEs veileder | -| `normerte/*.toml` | Normerte laster, driftstider og settpunkt. Utgangspunktet er NS 3031:2014 Tillegg A; varmtvann er oppdatert til NS 3031:2025-verdien for småhus. Timesprofiler fra NS 3031:2025 kan legges inn i `profil_*`-feltene | +| `normerte/*.toml` | Normerte laster, driftstider og settpunkt. Utgangspunktet er NS 3031:2014 Tillegg A; varmtvann for boliger er oppdatert til NS 3031:2025-verdien for småhus. Tillegg A er ikke offentlig, så yrkesbyggverdiene er hentet fra publiserte Simien-utskrifter og DiBK-utredninger. Hver fil oppgir kildene og hvilke verdier som er antatt. Timesprofiler fra NS 3031:2025 kan legges inn i `profil_*`-feltene | | `varmepumpe_spf.toml` | Veiledende årsvarmefaktorer, NS 3031:2014 tabell B.9-3 | | `materialer.toml` | Materialdata og typiske U-verdier per byggeperiode | | `klimakorreksjon.csv` | Klimakorrigeringsfaktorer, Standard Norge SN/K 34, doi 10.5281/zenodo.15041587 | diff --git a/src/inndata/ifc.rs b/src/inndata/ifc.rs index 5a2d452..82bca2d 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::inndata::toml_inn::Importvalg; use crate::klima::geo; use crate::modell::*; use crate::Result; @@ -29,17 +30,26 @@ const VEGGTYPER: [&str; 3] = ["IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL" const PLATETYPER: [&str; 3] = ["IFCSLAB", "IFCROOF", "IFCSLABSTANDARDCASE"]; pub fn les(sti: &Path) -> Result { + les_med(sti, &Importvalg::default()) +} + +pub fn les_med(sti: &Path, valg: &Importvalg) -> Result { let raa = std::fs::read(sti).with_context(|| format!("leser {}", sti.display()))?; let tekst = String::from_utf8_lossy(&raa); - fra_tekst( + fra_tekst_med( &tekst, sti.file_stem() .and_then(|s| s.to_str()) .unwrap_or("Bygning"), + valg, ) } pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { + fra_tekst_med(tekst, standardnavn, &Importvalg::default()) +} + +pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Result { let fil = step::les(tekst); let mut merknader = Vec::new(); @@ -88,6 +98,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { let mut rom: Vec<(String, f64)> = Vec::new(); let mut kjellerrom: Vec<(String, f64)> = Vec::new(); let mut mistenkelige: Vec = Vec::new(); + let mut uoppvarmede: Vec = Vec::new(); for (r, e) in fil.av_type("IFCSPACE") { let navn = romnavn(e); let areal = mengder.get(&r).map(arealmengde).unwrap_or(0.0); @@ -111,6 +122,14 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { kjellervolum += romvolum; } else { let lavt = navn.to_lowercase(); + if valg + .uoppvarmede_rom + .iter() + .any(|u| lavt.contains(&u.to_lowercase())) + { + uoppvarmede.push(format!("{navn} {areal:.1} m²")); + continue; + } if ["kott", "bod", "loft", "garasje", "carport", "uoppvarmet"] .iter() .any(|o| lavt.contains(o)) @@ -136,6 +155,12 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { mistenkelige.join(", ") )); } + if !uoppvarmede.is_empty() { + merknader.push(format!( + "Disse rommene er uoppvarmet etter tilleggsfilen og holdt utenfor oppvarmet BRA: {}.", + uoppvarmede.join(", ") + )); + } // ── plassering og geometri for alle bygningsdeler ─────────────── let typer: Vec<&str> = VEGGTYPER.iter().chain(PLATETYPER.iter()).copied().collect(); @@ -398,6 +423,10 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { )); } + // Tilleggsfiler og scenarioer viser til flatene med navn, så navnene må være entydige. + unike_navn(&mut flater); + unike_navn(&mut kjellerflater); + // ── merknader ─────────────────────────────────────────────────── if bra <= 0.0 { bra = flater @@ -571,6 +600,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { } }; + vinduer.sort_by(|a, b| a.navn.cmp(&b.navn)); + let bygning = Bygning { bygning: BygningInfo { navn, @@ -668,9 +699,13 @@ fn slaa_sammen(flater: &mut Vec) { for f in flater.drain(..) { match samlet.iter_mut().find(|g| nokkel(g) == nokkel(&f)) { Some(g) => { - // Arealvektet himmelretning, slik at summen peker riktig vei. + // Arealvektet himmelretning og helning, slik at summen peker riktig vei og ikke + // avhenger av rekkefølgen elementene kommer i. let sum = g.areal + f.areal; g.orientering = (g.orientering * g.areal + f.orientering * f.areal) / sum; + if let (Some(a), Some(b)) = (g.helning, f.helning) { + g.helning = Some((a * g.areal + b * f.areal) / sum); + } g.areal = sum; } None => samlet.push(f), @@ -689,6 +724,36 @@ fn slaa_sammen(flater: &mut Vec) { *flater = samlet; } +/// Gjør flatenavnene entydige. Vegger får himmelretningen etter konstruksjonsnavnet, og andre +/// flater som fortsatt deler navn, nummereres. +fn unike_navn(flater: &mut [Flate]) { + // Fast rekkefølge, slik at samme modell alltid gir samme fil og samme nummerering. + flater.sort_by(|a, b| { + (a.typ as u8, &a.navn) + .cmp(&(b.typ as u8, &b.navn)) + .then(a.orientering.total_cmp(&b.orientering)) + .then(b.areal.total_cmp(&a.areal)) + }); + for f in flater.iter_mut().filter(|f| f.typ == FlateType::Vegg) { + f.navn = format!("{}, {}", f.navn, himmelretning(f.orientering)); + } + let mut antall: HashMap = HashMap::new(); + for f in flater.iter_mut() { + let n = antall.entry(f.navn.clone()).or_insert(0); + *n += 1; + if *n > 1 { + f.navn = format!("{} ({n})", f.navn); + } + } +} + +fn himmelretning(grader: f64) -> &'static str { + const RETNINGER: [&str; 8] = [ + "nord", "nordøst", "øst", "sørøst", "sør", "sørvest", "vest", "nordvest", + ]; + RETNINGER[((grader / 45.0).round() as i64).rem_euclid(8) as usize] +} + /// En etasje i modellen, med navn og høyde over byggets referansenivå. #[derive(Clone, Debug)] pub struct Etasje { @@ -1150,7 +1215,10 @@ fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) { let mut g = [n[0] * dx - n[1] * dy, n[0] * dy + n[1] * dx, n[2]]; let vannrett = (g[0] * g[0] + g[1] * g[1]).sqrt(); - if vannrett > 0.2 { + // Et skrått tak har også en vannrett komponent, men skal alltid ha normalen oppover; + // ellers kan undersiden bli hovedflaten og gi helning 180° minus takvinkelen. + let skraa_plate = PLATETYPER.contains(&d.typ.as_str()) && g[2].abs() > 0.1; + if vannrett > 0.2 && !skraa_plate { // Veggflate: velg den normalen som peker bort fra bygningens tyngdepunkt. if let Some((x, y, _, _, _)) = d.plass { if g[0] * (x - cx) + g[1] * (y - cy) < 0.0 { @@ -1686,6 +1754,27 @@ mod tester { ); } + #[test] + fn vegger_faar_himmelretning_i_navnet() { + let r = fra_tekst(&prove(), "prove").unwrap(); + let mut navn: Vec<&str> = r.bygning.flate.iter().map(|f| f.navn.as_str()).collect(); + navn.sort(); + assert_eq!(navn, vec!["Nordvegg, nord", "Sorvegg, sør"]); + } + + #[test] + fn uoppvarmede_rom_fra_tilleggsfilen_holdes_utenfor_bra() { + let valg = Importvalg { + uoppvarmede_rom: vec!["STUE".into()], + }; + let r = fra_tekst_med(&prove(), "prove", &valg).unwrap(); + assert_ne!(r.bygning.bygning.oppvarmet_bra, 100.0); + assert!(r + .merknader + .iter() + .any(|m| m.contains("uoppvarmet etter tilleggsfilen"))); + } + /// Lengdeenheten skal hentes gjennom prosjektets enhetsoppsett, ikke ved å lete /// gjennom alle enheter i filen. Prøvefilen har både millimeter og meter. #[test] diff --git a/src/inndata/mod.rs b/src/inndata/mod.rs index 519b57e..7869f96 100644 --- a/src/inndata/mod.rs +++ b/src/inndata/mod.rs @@ -7,4 +7,6 @@ pub mod regneark; pub mod step; pub mod toml_inn; -pub use toml_inn::{les_bygning, slaa_sammen_scenario}; +pub use toml_inn::{ + flett_tillegg, les_bygning, les_tillegg, slaa_sammen_scenario, Importvalg, Tillegg, +}; diff --git a/src/inndata/step.rs b/src/inndata/step.rs index 4e6108a..5c87e77 100644 --- a/src/inndata/step.rs +++ b/src/inndata/step.rs @@ -3,7 +3,7 @@ //! Filen består av linjer på formen `#12=IFCWALL('guid',#3,'Navn',...);`. Denne modulen //! deler filen i entiteter med type og argumenter, uten å tolke IFC-skjemaet. -use std::collections::HashMap; +use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq)] pub enum Arg { @@ -58,7 +58,8 @@ impl Entitet { } pub struct Fil { - pub entiteter: HashMap, + /// Sortert på entitetsnummer, slik at gjennomløp alltid kommer i samme rekkefølge. + pub entiteter: BTreeMap, } impl Fil { @@ -83,7 +84,7 @@ impl Fil { /// Deler en STEP-fil i entiteter. pub fn les(tekst: &str) -> Fil { - let mut entiteter = HashMap::new(); + let mut entiteter = BTreeMap::new(); let mut setning = String::new(); let mut i_streng = false; let mut i_data = false; diff --git a/src/inndata/toml_inn.rs b/src/inndata/toml_inn.rs index 289b7bb..c82a793 100644 --- a/src/inndata/toml_inn.rs +++ b/src/inndata/toml_inn.rs @@ -1,8 +1,9 @@ -//! Leser `bygning.toml` og fletter inn scenarioendringer. +//! Leser `bygning.toml` og fletter inn scenarioendringer og tilleggsfiler. use crate::modell::{Bygning, Scenario}; use crate::Result; use anyhow::Context; +use serde::Deserialize; use std::path::Path; use toml::Value; @@ -11,19 +12,84 @@ pub fn les_bygning(sti: &Path) -> Result { toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display())) } +/// Valg for selve importen, fra tabellen `[import]` i en tilleggsfil. +#[derive(Deserialize, Default, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct Importvalg { + /// Rom som står uoppvarmet og holdes utenfor oppvarmet BRA. Et rom treffes når navnet + /// inneholder en av tekstene, uten hensyn til store og små bokstaver. + #[serde(default)] + pub uoppvarmede_rom: Vec, +} + +/// En tilleggsfil til en importert modell: det modellen ikke beskriver, som drift, varmeanlegg +/// og scenarioer, og rettelser til det den beskriver. +pub struct Tillegg { + pub endringer: Value, + pub import: Importvalg, +} + +pub fn les_tillegg(sti: &Path) -> Result { + let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?; + let mut endringer: Value = + toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))?; + let import = match endringer.as_table_mut().and_then(|t| t.remove("import")) { + Some(v) => v + .try_into() + .with_context(|| format!("tolker [import] i {}", sti.display()))?, + None => Importvalg::default(), + }; + Ok(Tillegg { endringer, import }) +} + +/// Fletter en tilleggsfil inn i en importert bygning, etter de samme reglene som scenarioene. +/// +/// Returnerer også navnemønstrene med `*` som ikke traff noen oppføring, slik at en tilleggsfil +/// som har gått ut av takt med modellen blir synlig. +pub fn flett_tillegg(b: &Bygning, tillegg: &Value) -> Result<(Bygning, Vec)> { + let mut grunn = Value::try_from(b).context("serialiserer den importerte bygningen")?; + let mut bom = Vec::new(); + flett(&mut grunn, tillegg, &mut bom); + let bygning = grunn.try_into().context( + "tilleggsfilen gir en ugyldig bygningsbeskrivelse. Et navn som ikke finnes i modellen, \ + legges til som ny oppføring og må da være komplett", + )?; + Ok((bygning, bom)) +} + /// Lager en ny bygning der scenarioets endringer er flettet inn. /// /// Tabeller flettes rekursivt. Tabellarrayer (`flate`, `vindu`, `oppvarming.kilde` …) matches /// på feltet `navn` når det finnes, ellers på `type`; elementer som ikke finnes fra før legges -/// til, og `slett = true` fjerner et element. Andre verdier erstattes. +/// til, og `slett = true` fjerner et element. Et navn med `*` er et mønster som treffer alle +/// oppføringer det passer på, for eksempel `"Yttervegg*"`. Andre verdier erstattes. pub fn slaa_sammen_scenario(b: &Bygning, s: &Scenario) -> Result { let mut grunn = Value::try_from(b).context("serialiserer bygningen for scenario")?; - flett(&mut grunn, &s.endringer); + flett(&mut grunn, &s.endringer, &mut Vec::new()); grunn .try_into() .with_context(|| format!("scenarioet «{}» gir en ugyldig bygningsbeskrivelse", s.navn)) } +/// Om `navn` passer på mønsteret, der `*` står for vilkårlig tekst. +fn passer(moenster: &str, navn: &str) -> bool { + let deler: Vec<&str> = moenster.split('*').collect(); + let (forst, sist) = (deler[0], deler[deler.len() - 1]); + if deler.len() == 1 { + return moenster == navn; + } + let Some(mut rest) = navn.strip_prefix(forst) else { + return false; + }; + for del in &deler[1..deler.len() - 1] { + match rest.find(del) { + Some(i) => rest = &rest[i + del.len()..], + None => return false, + } + } + rest.ends_with(sist) +} + fn navnenokkel(v: &Value) -> Option { let t = v.as_table()?; t.get("navn") @@ -39,7 +105,7 @@ fn skal_slettes(v: &Value) -> bool { .unwrap_or(false) } -fn flett(grunn: &mut Value, over: &Value) { +fn flett(grunn: &mut Value, over: &Value, bom: &mut Vec) { match (grunn, over) { (Value::Table(g), Value::Table(o)) => { for (k, v) in o { @@ -47,7 +113,7 @@ fn flett(grunn: &mut Value, over: &Value) { continue; } match g.get_mut(k) { - Some(eksisterende) => flett(eksisterende, v), + Some(eksisterende) => flett(eksisterende, v, bom), None => { g.insert(k.clone(), v.clone()); } @@ -57,6 +123,25 @@ fn flett(grunn: &mut Value, over: &Value) { (Value::Array(g), Value::Array(o)) => { for element in o { match navnenokkel(element) { + Some(moenster) if moenster.contains('*') => { + let treffer = + |e: &Value| navnenokkel(e).is_some_and(|n| passer(&moenster, &n)); + let antall = g.iter().filter(|e| treffer(e)).count(); + if antall == 0 { + bom.push(moenster.clone()); + } else if skal_slettes(element) { + g.retain(|e| !treffer(e)); + } else { + // Mønsteret skal ikke erstatte navnene det treffer. + let mut endring = element.clone(); + if let Some(t) = endring.as_table_mut() { + t.remove("navn"); + } + for e in g.iter_mut().filter(|e| treffer(e)) { + flett(e, &endring, bom); + } + } + } Some(navn) => { let treff = g .iter() @@ -65,7 +150,7 @@ fn flett(grunn: &mut Value, over: &Value) { Some(i) if skal_slettes(element) => { g.remove(i); } - Some(i) => flett(&mut g[i], element), + Some(i) => flett(&mut g[i], element, bom), None if skal_slettes(element) => {} None => g.push(element.clone()), } @@ -133,6 +218,38 @@ u_verdi = 0.8 assert_eq!(navn, vec!["Yttervegg", "Nytt tak"]); } + #[test] + fn moenster_i_navn_treffer_flere_oppforinger() { + let b: Bygning = toml::from_str(GRUNN).unwrap(); + let mut grunn = Value::try_from(&b).unwrap(); + let mut bom = Vec::new(); + let over: Value = toml::from_str( + r#"flate = [{ navn = "*", masseklasse = "lett" }, { navn = "Yttervegg*", u_verdi = 0.3 }, { navn = "Kjeller*", u_verdi = 0.2 }]"#, + ) + .unwrap(); + flett(&mut grunn, &over, &mut bom); + let ny: Bygning = grunn.try_into().unwrap(); + assert_eq!(ny.flate.len(), 2); + assert_eq!(ny.flate[0].navn, "Yttervegg"); + assert_eq!(ny.flate[0].u_verdi, Some(0.3)); + assert_eq!(ny.flate[1].u_verdi, Some(0.8)); + assert!(ny.flate.iter().all(|f| f.masseklasse.is_some())); + assert_eq!(bom, vec!["Kjeller*".to_string()]); + } + + #[test] + fn moenster_passer() { + assert!(passer("V-2*", "V-2 #3306")); + assert!(passer("*sør", "Yttervegg, sør")); + assert!(passer( + "Kjellermur*uisolert*", + "Kjellermur 30 cm uisolert, øst" + )); + assert!(!passer("V-2*", "VD-2 #11162")); + assert!(passer("Tak", "Tak")); + assert!(!passer("Tak", "Taket")); + } + #[test] fn scenario_endrer_skalarfelt() { let b: Bygning = toml::from_str(GRUNN).unwrap(); diff --git a/src/main.rs b/src/main.rs index ecb9712..baf6026 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,10 @@ struct Beregn { struct Import { /// gbXML-, IFC- eller CSV-fil eksportert fra BIM-verktøyet. fil: PathBuf, + /// Tilleggsfil som flettes inn i den importerte bygningen: det modellen ikke beskriver + /// (drift, varmeanlegg, scenarioer) og rettelser til det den beskriver. + #[arg(short, long, value_name = "FIL")] + tillegg: Option, /// Utdatafil. Standard: samme navn med endelsen .toml. #[arg(short, long, value_name = "FIL")] ut: Option, @@ -217,13 +221,18 @@ fn import(a: Import) -> Result<()> { .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); - let (bygning, merknader) = match endelse.as_str() { + 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(&a.fil)?; + let r = inndata::ifc::les_med(&a.fil, &valg)?; (r.bygning, r.merknader) } "csv" => { @@ -234,10 +243,33 @@ fn import(a: Import) -> Result<()> { "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 tekst = toml::to_string_pretty(&bygning).context("serialiserer bygningen til TOML")?; - let mut innhold = - String::from("# Importert med enok. Fyll ut feltene som er merket nedenfor.\n"); for m in &merknader { innhold.push_str(&format!("# {m}\n")); }