diff --git a/Cargo.lock b/Cargo.lock index ce876e2..2d83207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,7 +222,7 @@ dependencies = [ [[package]] name = "enok" -version = "0.4.6" +version = "0.4.7" dependencies = [ "anyhow", "approx", diff --git a/Cargo.toml b/Cargo.toml index a0543a1..e6d3e14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "enok" -version = "0.4.6" +version = "0.4.7" edition = "2021" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" license = "MIT" diff --git a/src/rapport/typst_ut.rs b/src/rapport/typst_ut.rs index a4b4690..cf2d093 100644 --- a/src/rapport/typst_ut.rs +++ b/src/rapport/typst_ut.rs @@ -2,6 +2,7 @@ use crate::kjor::Resultat; use crate::merking::skala::BOKSTAVER; +use crate::modell::lost::Forutsetning; use crate::modell::{FlateType, Mot}; use crate::rapport::svg; use crate::Result; @@ -437,7 +438,10 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String { // ── inndata ───────────────────────────────────────────────────── s.push_str("#pagebreak()\n\n= Inndata\n\n== Bygningskropp\n\n"); - s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto, auto, auto),\n justering: (left, left, right, right, right, left),\n overskrift: (\"Bygningsdel\", \"Type\", \"Areal, m²\", \"U-verdi\", \"Orientering\", \"Mot\"),\n rader: (\n"); + s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto, auto, auto, auto),\n justering: (left, left, right, right, right, right, left),\n overskrift: (\"Bygningsdel\", \"Type\", \"Antall\", \"Areal, m²\", \"U-verdi\", \"Orientering\", \"Mot\"),\n rader: (\n"); + // Like bygningsdeler står på én linje: modellen deler gjerne den samme konstruksjonen i + // mange like vinduer eller veggfelt, og da sier antallet mer enn én rad per stykk. + let mut samlet: Vec<(String, String, String, String, usize, f64)> = Vec::new(); for f in &l.flater { let typ = if f.glass { "Vindu/dør" @@ -453,15 +457,62 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String { Mot::Grunn => "grunnen", Mot::Uoppvarmet => "uoppvarmet", }; + let navn = f + .navn + .split(" #") + .next() + .unwrap_or(&f.navn) + .trim() + .to_string(); + let nokkel = ( + navn, + typ.to_string(), + tall(f.u_verdi, 2), + himmelretning(f.orientering, f.helning), + mot.to_string(), + ); + match samlet.iter_mut().find(|(n, t, u, o, _, _)| { + (n.as_str(), t.as_str(), u.as_str(), o.as_str()) + == ( + nokkel.0.as_str(), + nokkel.1.as_str(), + nokkel.2.as_str(), + nokkel.3.as_str(), + ) + }) { + Some((_, _, _, _, antall, areal)) => { + *antall += 1; + *areal += f.areal; + } + None => samlet.push((nokkel.0, nokkel.1, nokkel.2, nokkel.3, 1, f.areal)), + } + let _ = nokkel.4; + } + for (navn, typ, u, orientering, antall, areal) in &samlet { + let mot = l + .flater + .iter() + .find(|f| f.navn.split(" #").next().unwrap_or(&f.navn).trim() == navn) + .map(|f| match f.mot { + Mot::Ute => "det fri", + Mot::Grunn => "grunnen", + Mot::Uoppvarmet => "uoppvarmet", + }) + .unwrap_or("det fri"); s.push_str(&format!( " {}\n", rad(&[ - e(&f.navn), - typ.into(), - tall(f.areal, 1), - tall(f.u_verdi, 2), - himmelretning(f.orientering, f.helning), - mot.into() + e(navn), + typ.clone(), + if *antall > 1 { + antall.to_string() + } else { + String::new() + }, + tall(*areal, 1), + u.clone(), + orientering.clone(), + mot.into(), ]) )); } @@ -496,15 +547,28 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String { if l.forutsetninger.is_empty() { s.push_str("Alle inndata er oppgitt i bygningsbeskrivelsen.\n\n"); } else { - // Faste bredder, ellers spiser de to første kolonnene plassen kilden trenger. - s.push_str("#tabell(\n kolonner: (1.15fr, 0.85fr, 1.6fr),\n justering: (left, left, left),\n overskrift: (\"Felt\", \"Verdi\", \"Kilde\"),\n rader: (\n"); - for f in &l.forutsetninger { + s.push_str("#tabell(\n kolonner: (1.25fr, 1fr, 1.15fr, auto, auto, auto, auto),\n justering: (left, left, left, center, center, center, left),\n overskrift: (\"Felt\", \"Verdi\", \"Gjelder\", \"Normert\", \"Byggeår\", \"Antatt\", \"Standard\"),\n rader: (\n"); + for f in grupper_forutsetninger(&l.forutsetninger) { s.push_str(&format!( " {}\n", - rad(&[e(&f.felt), e(&f.verdi), e(&f.kilde)]) + rad(&[ + e(&f.felt), + e(&f.verdi), + e(&f.gjelder), + kryss(f.normert), + kryss(f.byggeaar), + kryss(f.antatt), + e(&f.standard), + ]) )); } s.push_str(" ),\n)\n\n"); + s.push_str( + "#merknad[*Normert* er standardverdier for bygningskategorien, som skal brukes i \ + energimerket. *Byggeår* er typiske verdier for byggeperioden, brukt der modellen ikke \ + oppgir noe. *Antatt* er satt av beregningen og bør kontrolleres. *Standard* viser \ + hvilken standard verdien er regnet etter.]\n\n", + ); } for m in &r.merknader { s.push_str(&format!("#merknad[{}]\n\n", e(m))); @@ -563,6 +627,177 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String { s } +/// Kilden uten tall, slik at «… av konstruksjonens 3,39 W/(m²K) og 1,29 m dybde» og den +/// samme setningen for en annen vegg regnes som samme slags kilde. +fn kildenokkel(kilde: &str) -> String { + let mut ut = String::with_capacity(kilde.len()); + let mut i_tall = false; + for c in kilde.chars() { + if c.is_ascii_digit() { + if !i_tall { + ut.push('#'); + } + i_tall = true; + } else { + i_tall = false; + ut.push(c); + } + } + ut +} + +/// Tallet fremst i en verdi som «1.353 W/(m²K)», når verdien begynner med et tall. +fn tallverdi(verdi: &str) -> Option { + let tall: String = verdi + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == ',' || *c == '-') + .collect(); + tall.replace(',', ".").parse().ok() +} + +/// Slår to verdier med samme enhet sammen til et intervall, «1,21–1,39 W/(m²K)». +fn slaa_sammen_intervall(a: &str, b: &str) -> String { + let enhet = a.trim_start_matches(|c: char| { + c.is_ascii_digit() || c == '.' || c == ',' || c == '-' || c == '–' + }); + let mut tall: Vec = Vec::new(); + for del in a.split('–').chain(b.split('–')) { + if let Some(t) = tallverdi(del.trim()) { + tall.push(t); + } + } + let minste = tall.iter().copied().fold(f64::INFINITY, f64::min); + let storste = tall.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if (storste - minste).abs() < 5e-3 { + format!("{minste:.2}{enhet}") + } else { + format!("{minste:.2}–{storste:.2}{enhet}") + } +} + +/// Norsk desimaltegn i tall, uten å røre tekst som «ISO 52016-1». +fn komma(verdi: &str) -> String { + let mut ut = String::with_capacity(verdi.len()); + let tegn: Vec = verdi.chars().collect(); + for (i, c) in tegn.iter().enumerate() { + let mellom_tall = i > 0 + && i + 1 < tegn.len() + && tegn[i - 1].is_ascii_digit() + && tegn[i + 1].is_ascii_digit(); + ut.push(if *c == '.' && mellom_tall { ',' } else { *c }); + } + ut +} + +fn kryss(på: bool) -> String { + if på { + "✓".into() + } else { + String::new() + } +} + +/// En rad i forutsetningstabellen: like forutsetninger for mange bygningsdeler slås sammen. +struct Forutsetningsrad { + felt: String, + verdi: String, + gjelder: String, + normert: bool, + byggeaar: bool, + antatt: bool, + standard: String, +} + +/// Slår sammen forutsetninger som gjelder flere bygningsdeler, og deler kilden i kolonner. +/// +/// «flate.Yttervegg nord.u_verdi» og «flate.Yttervegg sør.u_verdi» med samme verdi og kilde +/// blir til én rad «flate.u_verdi» som gjelder 2 flater. +fn grupper_forutsetninger(forutsetninger: &[Forutsetning]) -> Vec { + let flertall = |prefiks: &str, antall: usize| match prefiks { + "flate" => format!("{antall} flater"), + "vindu" => format!("{antall} vinduer"), + "sone" => format!("{antall} soner"), + annet => format!("{antall} × {annet}"), + }; + let mut rader: Vec<(String, String, String, Vec, String)> = Vec::new(); + for f in forutsetninger { + // «prefiks.navn.felt» deles i mønsteret «prefiks.felt» og navnet det gjelder. + let (prefiks, resten) = f.felt.split_once('.').unwrap_or(("", f.felt.as_str())); + let (navn, felt) = match resten.rsplit_once('.') { + Some((navn, felt)) => (navn.to_string(), felt.to_string()), + None => (String::new(), resten.to_string()), + }; + let monster = if prefiks.is_empty() { + felt.clone() + } else { + format!("{prefiks}.{felt}") + }; + match rader + .iter_mut() + .find(|(m, v, k, _, _)| *m == monster && *v == f.verdi && *k == f.kilde) + { + Some((_, _, _, navn_liste, _)) => navn_liste.push(navn), + None => rader.push(( + monster, + f.verdi.clone(), + f.kilde.clone(), + vec![navn], + prefiks.to_string(), + )), + } + } + // Verdier som bare skiller seg litt, som en U-verdi mot grunnen regnet for hver vegg, + // samles til ett intervall i stedet for én rad per vegg. + let mut samlet: Vec<(String, String, String, Vec, String)> = Vec::new(); + for (felt, verdi, kilde, navn, prefiks) in rader { + // Kilden bærer ofte tallene for den enkelte veggen; de maskeres, slik at samme + // slags kilde kjennes igjen. + let like = samlet + .iter_mut() + .find(|(f, _, k, _, _)| *f == felt && kildenokkel(k) == kildenokkel(&kilde)); + match like { + Some((_, v, _, n, _)) if tallverdi(v).is_some() && tallverdi(&verdi).is_some() => { + *v = slaa_sammen_intervall(v, &verdi); + n.extend(navn); + } + _ => samlet.push((felt, verdi, kilde, navn, prefiks)), + } + } + samlet + .into_iter() + .map(|(felt, verdi, kilde, navn, prefiks)| { + let gjelder = match navn.len() { + 0 => String::new(), + 1 if navn[0].is_empty() => String::new(), + 1 => navn[0].clone(), + n => flertall(&prefiks, n), + }; + let lav = kilde.to_lowercase(); + let standard = [ + "NS-EN ISO 13370", + "NS-EN ISO 13789", + "ISO 52016-1", + "NS 3031", + ] + .into_iter() + .find(|s| kilde.contains(s)) + .unwrap_or("") + .to_string(); + let normert = lav.contains("normert"); + let byggeaar = lav.contains("typisk for"); + Forutsetningsrad { + felt, + verdi: komma(&verdi), + gjelder, + normert, + byggeaar, + antatt: !normert && !byggeaar && standard.is_empty() || lav.contains("antatt"), + standard, + } + }) + .collect() +} + /// Tekst i en Typst-strengliteral: bare omvendt skråstrek og anførselstegn skal ut. fn streng(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"")