diff --git a/README.md b/README.md index b1e8400..b7750f4 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Typst må være installert for PDF-rapporten. Uten Typst kjører du med `--ingen | `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 --tillegg prosjekt.toml` | Som over, med en tilleggsfil flettet inn (se nedenfor) | +| `enok tillegg modell.ifc bygning.toml` | Skriver forskjellen mellom modellen og en håndredigert fil som tilleggsfil | | `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 | @@ -210,6 +211,20 @@ enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml # samme, som `beregn` skriver bygningsbeskrivelsen modellen ga, til `bygning.toml` i utdatakatalogen, slik at beregningen kan etterprøves. +En ny import skriver aldri over manuelle endringer, for de ligger ikke i den genererte filen. +Har du likevel en `bygning.toml` du har redigert for hånd, lager `enok tillegg` en tilleggsfil +av forskjellen mot modellen, så arbeidet følger med videre: + +```sh +enok tillegg modell.ifc bygning.toml --ut prosjekt.toml +``` + +Tillegget inneholder alt filen sier som modellen ikke sier: endrede U-verdier, drift, +varmeanlegg og scenarioer, og `slett = true` for oppføringer modellen har og filen ikke. +Kommandoen kontrollerer at tillegget gjenskaper filen det er laget av, og sier fra om noe +ikke lar seg uttrykke. `enok import` nekter dessuten å skrive over en fil enok ikke har laget +selv, med mindre `--overskriv` er oppgitt. + ```toml # prosjekt.toml [import] diff --git a/src/inndata/mod.rs b/src/inndata/mod.rs index 7869f96..639973b 100644 --- a/src/inndata/mod.rs +++ b/src/inndata/mod.rs @@ -8,5 +8,5 @@ pub mod step; pub mod toml_inn; pub use toml_inn::{ - flett_tillegg, les_bygning, les_tillegg, slaa_sammen_scenario, Importvalg, Tillegg, + differanse, flett_tillegg, les_bygning, les_tillegg, slaa_sammen_scenario, Importvalg, Tillegg, }; diff --git a/src/inndata/toml_inn.rs b/src/inndata/toml_inn.rs index c82a793..8bf1fc5 100644 --- a/src/inndata/toml_inn.rs +++ b/src/inndata/toml_inn.rs @@ -71,6 +71,87 @@ pub fn slaa_sammen_scenario(b: &Bygning, s: &Scenario) -> Result { .with_context(|| format!("scenarioet «{}» gir en ugyldig bygningsbeskrivelse", s.navn)) } +/// Forskjellen mellom to bygningsbeskrivelser, som en tilleggsfil. +/// +/// Resultatet er det minste som må flettes inn i `fra` for å få `til`: endrede og nye felter, +/// nye elementer i lister, og `slett = true` for elementer som bare finnes i `fra`. Fletting +/// kan ikke fjerne et enkeltfelt, så felter som bare finnes i `fra`, listes som merknader. +pub fn differanse(fra: &Value, til: &Value) -> (Value, Vec) { + let mut merknader = Vec::new(); + let v = diff_verdi(fra, til, "", &mut merknader).unwrap_or(Value::Table(Default::default())); + (v, merknader) +} + +fn diff_verdi(fra: &Value, til: &Value, sti: &str, merknader: &mut Vec) -> Option { + if fra == til { + return None; + } + match (fra, til) { + (Value::Table(f), Value::Table(t)) => { + let mut ut = toml::map::Map::new(); + for (k, v) in t { + let bane = if sti.is_empty() { + k.clone() + } else { + format!("{sti}.{k}") + }; + match f.get(k) { + Some(gammel) => { + if let Some(d) = diff_verdi(gammel, v, &bane, merknader) { + ut.insert(k.clone(), d); + } + } + None => { + ut.insert(k.clone(), v.clone()); + } + } + } + for k in f.keys() { + if !t.contains_key(k) { + let bane = if sti.is_empty() { + k.clone() + } else { + format!("{sti}.{k}") + }; + merknader.push(format!( + "«{bane}» finnes i modellen, men ikke i bygningsbeskrivelsen. En tilleggsfil kan ikke fjerne et felt." + )); + } + } + (!ut.is_empty()).then_some(Value::Table(ut)) + } + (Value::Array(f), Value::Array(t)) if t.iter().all(|e| navnenokkel(e).is_some()) => { + let mut ut: Vec = Vec::new(); + for element in t { + let navn = navnenokkel(element).unwrap_or_default(); + match f.iter().find(|e| navnenokkel(e).as_deref() == Some(&navn)) { + Some(gammel) => { + let bane = format!("{sti}.{navn}"); + if let Some(Value::Table(mut endring)) = + diff_verdi(gammel, element, &bane, merknader) + { + endring.insert("navn".into(), Value::String(navn)); + ut.push(Value::Table(endring)); + } + } + None => ut.push(element.clone()), + } + } + for gammel in f { + let navn = navnenokkel(gammel).unwrap_or_default(); + if !t.iter().any(|e| navnenokkel(e).as_deref() == Some(&navn)) { + let mut slett = toml::map::Map::new(); + slett.insert("navn".into(), Value::String(navn)); + slett.insert("slett".into(), Value::Boolean(true)); + ut.push(Value::Table(slett)); + } + } + (!ut.is_empty()).then_some(Value::Array(ut)) + } + _ => Some(til.clone()), + } +} + /// 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(); @@ -237,6 +318,52 @@ u_verdi = 0.8 assert_eq!(bom, vec!["Kjeller*".to_string()]); } + #[test] + fn differansen_gjenskaper_den_manuelle_filen() { + let importert: Bygning = toml::from_str(GRUNN).unwrap(); + let manuell: Bygning = toml::from_str( + r#" +[bygning] +navn = "Testhus" +kategori = "smaahus" +byggeaar = 1948 +oppvarmet_bra = 100.0 + +[[flate]] +navn = "Yttervegg" +type = "vegg" +areal = 80.0 +u_verdi = 0.22 + +[[flate]] +navn = "Kjellervegg" +type = "vegg" +areal = 30.0 +u_verdi = 3.13 + +[[scenario]] +navn = "Nye vinduer" +endringer.flate = [{ navn = "Yttervegg", u_verdi = 0.18 }] +"#, + ) + .unwrap(); + let (patch, merknader) = differanse( + &Value::try_from(&importert).unwrap(), + &Value::try_from(&manuell).unwrap(), + ); + // Taket finnes bare i importen, og skal slettes av tillegget. + let tekst = toml::to_string(&patch).unwrap(); + assert!(tekst.contains("slett"), "{tekst}"); + assert!(merknader.is_empty(), "{merknader:?}"); + + let (gjenskapt, bom) = flett_tillegg(&importert, &patch).unwrap(); + assert!(bom.is_empty()); + assert_eq!( + Value::try_from(&gjenskapt).unwrap(), + Value::try_from(&manuell).unwrap() + ); + } + #[test] fn moenster_passer() { assert!(passer("V-2*", "V-2 #3306")); diff --git a/src/main.rs b/src/main.rs index 522049e..d31daf3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,8 @@ enum Kommando { Beregn(Beregn), /// Importerer en bygningsmodell fra gbXML, IFC eller regneark og skriver bygning.toml. Import(Import), + /// Lager en tilleggsfil av forskjellen mellom en modell og en ferdig bygningsbeskrivelse. + Tillegg(TilleggAv), /// Viser tilgjengelige klimasteder og nøkkeltall for ett sted. Klima(Klima), /// Viser energikarakterskalaen for en bygningskategori. @@ -72,6 +74,20 @@ struct Import { /// Utdatafil. Standard: samme navn med endelsen .toml. #[arg(short, long, value_name = "FIL")] ut: Option, + /// Skriver over en utdatafil enok ikke har laget selv. + #[arg(long)] + overskriv: bool, +} + +#[derive(Args)] +struct TilleggAv { + /// BIM-modellen: .ifc, .gbxml eller .csv. + modell: PathBuf, + /// Bygningsbeskrivelsen med de manuelle endringene, som tillegget skal gjenskape. + bygning: PathBuf, + /// Utdatafil. Standard: prosjekt.toml ved siden av bygningsbeskrivelsen. + #[arg(short, long, value_name = "FIL")] + ut: Option, } #[derive(Args)] @@ -100,6 +116,7 @@ fn main() -> Result<()> { match cli.kommando { Kommando::Beregn(a) => beregn(a, &kilde), Kommando::Import(a) => import(a), + Kommando::Tillegg(a) => tillegg(a), Kommando::Klima(a) => klima(a), Kommando::Skala(a) => skala(a, &kilde), } @@ -280,9 +297,26 @@ fn skriv_sammendrag(r: &kjor::Resultat, scenarier: &[(String, kjor::Resultat)]) println!(); } +const GENERERT: &str = "# Generert av enok"; + fn import(a: Import) -> Result<()> { let (bygning, merknader, _) = les_modell(&a.fil, a.tillegg.as_deref())?; let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml")); + // En fil som ikke er skrevet av enok, kan være redigert for hånd. Da er det bedre å + // stoppe enn å skrive over arbeidet. + if !a.overskriv && ut.exists() { + let gammel = std::fs::read_to_string(&ut).unwrap_or_default(); + if !gammel.starts_with(GENERERT) { + bail!( + "{} er ikke laget av enok og kan være redigert for hånd. \n\ + Ta vare på endringene som en tilleggsfil med «enok tillegg {} {} --ut prosjekt.toml», \n\ + og importer med «--tillegg prosjekt.toml». Bruk --overskriv for å skrive over.", + ut.display(), + a.fil.display(), + ut.display() + ); + } + } let innhold = skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?; std::fs::write(&ut, innhold)?; println!("Skrev {}", ut.display()); @@ -322,6 +356,74 @@ fn skriv_bygning( Ok(innhold) } +/// Skriver forskjellen mellom modellen og en ferdig bygningsbeskrivelse som en tilleggsfil. +fn tillegg(a: TilleggAv) -> Result<()> { + let (importert, _, modell) = les_modell(&a.modell, None)?; + if !modell { + bail!("{} er ikke en BIM-modell", a.modell.display()); + } + let manuell = inndata::les_bygning(&a.bygning)?; + let fra = toml::Value::try_from(&importert).context("serialiserer den importerte bygningen")?; + let til = toml::Value::try_from(&manuell).context("serialiserer bygningsbeskrivelsen")?; + let (patch, detaljer) = inndata::differanse(&fra, &til); + + // Felter modellen har og filen ikke, kan et tillegg ikke fjerne. De er som regel + // standardverdier importen fyller ut, så de samles i én merknad i stedet for én hver. + let (ufjernbare, mut merknader): (Vec, Vec) = detaljer + .into_iter() + .partition(|m| m.contains("finnes i modellen")); + if !ufjernbare.is_empty() { + let felter: Vec<&str> = ufjernbare + .iter() + .filter_map(|m| m.split('«').nth(1)?.split('»').next()) + .collect(); + merknader.push(format!( + "{} felter finnes i modellen, men ikke i bygningsbeskrivelsen, og blir stående: {}{}. En tilleggsfil kan ikke fjerne et felt.", + felter.len(), + felter.iter().take(3).copied().collect::>().join(", "), + if felter.len() > 3 { " …" } else { "" } + )); + } + + // Kontroller at tillegget faktisk gjenskaper filen det er laget av. + let (gjenskapt, bom) = inndata::flett_tillegg(&importert, &patch)?; + merknader.extend( + bom.into_iter() + .map(|m| format!("Navnemønsteret «{m}» traff ingenting.")), + ); + if toml::Value::try_from(&gjenskapt).ok().as_ref() != Some(&til) { + merknader.push( + "Tillegget gjenskaper ikke bygningsbeskrivelsen helt. Gå gjennom filen før den brukes." + .into(), + ); + } + + let ut = + a.ut.unwrap_or_else(|| a.bygning.with_file_name("prosjekt.toml")); + let mut innhold = format!( + "# Tillegg til «{}», laget av forskjellen mot «{}».\n\ + # Alt som ikke står i modellen, ligger her, og flettes inn ved hver import:\n\ + # enok beregn \"{}\" --tillegg {}\n", + a.modell.display(), + a.bygning.display(), + a.modell.display(), + ut.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("prosjekt.toml") + ); + for m in &merknader { + innhold.push_str(&format!("# {m}\n")); + } + innhold.push('\n'); + innhold.push_str(&toml::to_string_pretty(&patch).context("serialiserer tillegget")?); + std::fs::write(&ut, innhold)?; + println!("Skrev {}", ut.display()); + for m in &merknader { + println!(" • {m}"); + } + Ok(()) +} + fn klima(a: Klima) -> Result<()> { if a.liste { for k in enok::klima::tmy::kommuner()? {