«enok tillegg modell.ifc bygning.toml» skriver forskjellen mellom modellen og en håndredigert bygningsbeskrivelse som en tilleggsfil: endrede verdier, nye oppføringer, «slett = true» for det bare modellen har, og scenarioene. Kommandoen kontrollerer at tillegget gjenskaper filen det er laget av, og sier fra om noe ikke lar seg uttrykke, siden fletting ikke kan fjerne et enkeltfelt. «enok import» nekter å skrive over en fil enok ikke har laget selv, og viser til kommandoen over. --overskriv går forbi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! 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;
|
|
|
|
pub fn les_bygning(sti: &Path) -> Result<Bygning> {
|
|
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<Tillegg> {
|
|
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<String>)> {
|
|
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. 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<Bygning> {
|
|
let mut grunn = Value::try_from(b).context("serialiserer bygningen for scenario")?;
|
|
flett(&mut grunn, &s.endringer, &mut Vec::new());
|
|
grunn
|
|
.try_into()
|
|
.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<String>) {
|
|
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<String>) -> Option<Value> {
|
|
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<Value> = 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();
|
|
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<String> {
|
|
let t = v.as_table()?;
|
|
t.get("navn")
|
|
.and_then(|x| x.as_str())
|
|
.or_else(|| t.get("type").and_then(|x| x.as_str()))
|
|
.map(|s| s.to_string())
|
|
}
|
|
|
|
fn skal_slettes(v: &Value) -> bool {
|
|
v.as_table()
|
|
.and_then(|t| t.get("slett"))
|
|
.and_then(|x| x.as_bool())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn flett(grunn: &mut Value, over: &Value, bom: &mut Vec<String>) {
|
|
match (grunn, over) {
|
|
(Value::Table(g), Value::Table(o)) => {
|
|
for (k, v) in o {
|
|
if k == "slett" {
|
|
continue;
|
|
}
|
|
match g.get_mut(k) {
|
|
Some(eksisterende) => flett(eksisterende, v, bom),
|
|
None => {
|
|
g.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(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()
|
|
.position(|e| navnenokkel(e).as_deref() == Some(&navn));
|
|
match treff {
|
|
Some(i) if skal_slettes(element) => {
|
|
g.remove(i);
|
|
}
|
|
Some(i) => flett(&mut g[i], element, bom),
|
|
None if skal_slettes(element) => {}
|
|
None => g.push(element.clone()),
|
|
}
|
|
}
|
|
None => g.push(element.clone()),
|
|
}
|
|
}
|
|
}
|
|
(g, o) => *g = o.clone(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tester {
|
|
use super::*;
|
|
|
|
const GRUNN: &str = r#"
|
|
[bygning]
|
|
navn = "Testhus"
|
|
kategori = "smaahus"
|
|
byggeaar = 1948
|
|
oppvarmet_bra = 100.0
|
|
|
|
[[flate]]
|
|
navn = "Yttervegg"
|
|
type = "vegg"
|
|
areal = 80.0
|
|
u_verdi = 1.0
|
|
|
|
[[flate]]
|
|
navn = "Tak"
|
|
type = "tak"
|
|
areal = 100.0
|
|
u_verdi = 0.8
|
|
"#;
|
|
|
|
#[test]
|
|
fn scenario_endrer_u_verdi_for_navngitt_flate() {
|
|
let b: Bygning = toml::from_str(GRUNN).unwrap();
|
|
let s = Scenario {
|
|
navn: "Etterisolert".into(),
|
|
beskrivelse: None,
|
|
endringer: toml::from_str(r#"flate = [{ navn = "Yttervegg", u_verdi = 0.22 }]"#)
|
|
.unwrap(),
|
|
};
|
|
let ny = slaa_sammen_scenario(&b, &s).unwrap();
|
|
assert_eq!(ny.flate.len(), 2);
|
|
assert_eq!(ny.flate[0].u_verdi, Some(0.22));
|
|
assert_eq!(ny.flate[1].u_verdi, Some(0.8));
|
|
}
|
|
|
|
#[test]
|
|
fn scenario_legger_til_og_sletter() {
|
|
let b: Bygning = toml::from_str(GRUNN).unwrap();
|
|
let s = Scenario {
|
|
navn: "Ombygd".into(),
|
|
beskrivelse: None,
|
|
endringer: toml::from_str(
|
|
r#"flate = [{ navn = "Tak", slett = true }, { navn = "Nytt tak", type = "tak", areal = 100.0, u_verdi = 0.13 }]"#,
|
|
)
|
|
.unwrap(),
|
|
};
|
|
let ny = slaa_sammen_scenario(&b, &s).unwrap();
|
|
let navn: Vec<&str> = ny.flate.iter().map(|f| f.navn.as_str()).collect();
|
|
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 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"));
|
|
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();
|
|
let s = Scenario {
|
|
navn: "Varmepumpe".into(),
|
|
beskrivelse: None,
|
|
endringer: toml::from_str(
|
|
r#"
|
|
[infiltrasjon]
|
|
n50 = 3.0
|
|
[oppvarming]
|
|
kilde = [{ type = "luft_luft_vp", andel_rom = 0.6 }]
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
};
|
|
let ny = slaa_sammen_scenario(&b, &s).unwrap();
|
|
assert_eq!(ny.infiltrasjon.n50, Some(3.0));
|
|
assert_eq!(ny.oppvarming.kilde.len(), 1);
|
|
}
|
|
}
|