8 Commits
Author SHA1 Message Date
blandClaude Opus 5 1e5d4f05bf Kort ned tabellene i rapporten
Release enok / bygg (release) Successful in 1m14s
test / test (push) Successful in 1m17s
Forutsetningene gjentok den samme setningen for hver bygningsdel. Nå står like forutsetninger
på én rad med antallet de gjelder, og kilden er delt i kolonnene «Normert», «Byggeår»,
«Antatt» og «Standard» med avkryssing. Verdier som bare skiller seg litt, som U-verdien mot
grunnen for hver kjellervegg, vises som et intervall.

Bygningskroppen slår sammen like bygningsdeler med et antall, så fjorten like vinduer blir
fire rader i stedet for fjorten. Tall i tabellene bruker komma.

For Nordhusstykket 5 går rapporten fra fjorten til sju sider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-21 15:55:19 +02:00
blandClaude Opus 5 133152742f Mål grunnmuren over bakken fra overkant mur
Release enok / bygg (release) Successful in 1m13s
test / test (push) Successful in 1m20s
Høyden ble målt fra gulvet i etasjen over, så dekkets egen tykkelse ble regnet som mur over
bakken. Nå måles den fra overkant av selve muren, som geometrien gir den. For Nordhusstykket
5 går høyden fra 1,11–1,50 m til 0,76–1,24 m, i snitt 0,98 m.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-21 15:48:52 +02:00
blandClaude Opus 5 959db648a9 Skill dekket over kjelleren fra dekket på bakken
test / test (push) Successful in 1m17s
Release enok / bygg (release) Successful in 1m13s
Et dekke like under gulvet i første etasje er himlingen over kjelleren, også når det heter
noe med «kjellergulv»; det er et vanlig navn på dekket over kjelleren. Bare navn som sier
«på grunn» eller «on grade» gir dekke mot grunnen, ellers avgjør høyden: nederste dekke
ligger mot grunnen, resten er etasjeskillere inne i bygningen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-21 15:43:03 +02:00
blandClaude Opus 5 3dff2dc273 Les bakkenivået av terrengmodellen, vegg for vegg
Release enok / bygg (release) Successful in 1m13s
test / test (push) Successful in 1m19s
Har filen en terrengmodell, gir den hver kjellervegg sin egen høyde over bakken, hentet som
snittet av de nærmeste terrengpunktene der veggen står. En skrånende tomt gir dermed
forskjellig høyde på hver side av huset, og `over_terreng` trenger ikke settes for hånd.
Terrengmodellen går foran terrenghøyden på IfcBuilding, som ofte står lik gulvet i første
etasje og da ikke sier noe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-21 15:39:36 +02:00
blandClaude Opus 5 b19162d9a7 Sett forsidetittelen i overskriftsskriften og aksentfargen
Release enok / bygg (release) Successful in 1m13s
test / test (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 22:08:21 +02:00
blandClaude Opus 5 c89561b0f5 Gi rapporten plass til kildene, og la den følge egne dokumenter
Release enok / bygg (release) Successful in 1m12s
test / test (push) Successful in 1m16s
Kolonnen «Kilde» i forutsetningstabellen fikk for liten plass, fordi de to første kolonnene
tok det de ville. Kolonnene har nå faste bredder, og tabellen leses på én linje per rad.

En ny [rapport]-tabell i bygningsbeskrivelsen setter logo, aksentfarge, avsenderlinje og
skrift for overskrifter, slik at energirapporten kan se ut som firmaets øvrige rapporter.
Logoen kopieres til utdatakatalogen; mangler filen, lages rapporten uten den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 22:02:36 +02:00
blandClaude Opus 5 e3f37afe36 Rett artefakt-versjonen i eksempelet
test / test (push) Successful in 1m16s
Gitea melder seg som GitHub Enterprise, og artefakt-actionene i v4 nekter å kjøre der.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 20:27:13 +02:00
blandClaude Opus 5 cabd8500ea Beskriv hvordan importen tåler feil i eksporten
test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 20:22:55 +02:00
10 changed files with 501 additions and 43 deletions
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "enok" name = "enok"
version = "0.4.1" version = "0.4.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"approx", "approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "enok" name = "enok"
version = "0.4.1" version = "0.4.7"
edition = "2021" edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
license = "MIT" license = "MIT"
+25 -2
View File
@@ -180,6 +180,16 @@ navn = "Nye vinduer"
endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }] endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }]
``` ```
Rapporten kan følge husets eller firmaets egne dokumenter:
```toml
[rapport]
logo = "logo.svg" # vises øverst på forsiden
farge = "#880000" # aksentfarge i overskrifter og tabeller
avsender = "Firma · Adresse · Org.nr. 000 000 000"
skrift_overskrift = "Futura Now Headline"
```
Scenarier flettes inn i bygningsbeskrivelsen: tabeller flettes rekursivt, elementer i lister 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. 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*"`. Et navn med `*` treffer alle elementer det passer på, for eksempel `navn = "Yttervegg*"`.
@@ -310,6 +320,19 @@ Mangler en bygningsdel U-verdi i modellen, sier importen hvilke det gjelder, og
bruker en typisk verdi for byggeåret. Det gjelder også vinduer og dører, som i de fleste bruker en typisk verdi for byggeåret. Det gjelder også vinduer og dører, som i de fleste
eksportene ikke har U-verdi. eksportene ikke har U-verdi.
Importen tåler noen vanlige feil i eksporten:
- **Deler som ligger dobbelt.** Legger eksporten den samme veggen eller det samme dekket i
flere etasjer, telles den én gang. Av kopiene brukes den som hører til den etasjen høyden
tilsier, og vinduene i de andre finner fortsatt veggen sin.
- **Feil etasje.** Hva som er gulv mot grunnen, dekke over kjelleren eller etasjeskiller inne
i bygningen, avgjøres av høyden geometrien har, ikke av hvilken etasje delen er lagt i.
- **Terrengmodell utenfor bakkenivå.** Ligger terrenget over gulvet i første etasje, brukes
det ikke, og importen sier fra. Oppgi da `over_terreng` på kjellerveggene.
Importen sier også hvilke deler som ikke er regnet med i klimaskjermen, som innervegger og
etasjeskillere, slik at det er lett å se om noe mangler.
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.
@@ -382,7 +405,7 @@ jobs:
"$GITHUB_SERVER_URL/klingenbergbygg/enok/releases/download/$TAG/enok" "$GITHUB_SERVER_URL/klingenbergbygg/enok/releases/download/$TAG/enok"
chmod +x enok chmod +x enok
./enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml ./enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml
- uses: https://gitea.com/actions/upload-artifact@v4 - uses: https://gitea.com/actions/upload-artifact@v3
with: with:
name: bygningsmodell name: bygningsmodell
path: | path: |
@@ -396,7 +419,7 @@ jobs:
shell: fish {0} shell: fish {0}
steps: steps:
- uses: https://gitea.com/actions/checkout@v6 - uses: https://gitea.com/actions/checkout@v6
- uses: https://gitea.com/actions/download-artifact@v4 - uses: https://gitea.com/actions/download-artifact@v3
with: with:
name: bygningsmodell name: bygningsmodell
- name: Lag rapporten - name: Lag rapporten
+1
View File
@@ -596,6 +596,7 @@ fn bygg(
lufting: Lufting::default(), lufting: Lufting::default(),
drift: Drift::default(), drift: Drift::default(),
scenario: Vec::new(), scenario: Vec::new(),
rapport: Rapport::default(),
}; };
Ok(Importrapport { bygning, merknader }) Ok(Importrapport { bygning, merknader })
} }
+97 -15
View File
@@ -186,6 +186,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
areal: flate.map(|f| f.areal).unwrap_or(0.0), areal: flate.map(|f| f.areal).unwrap_or(0.0),
normal: flate.map(|f| f.normal), normal: flate.map(|f| f.normal),
z: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.hoyde).unwrap_or(0.0), z: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.hoyde).unwrap_or(0.0),
topp: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.topp).unwrap_or(0.0),
}); });
} }
@@ -286,6 +287,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
// Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger. // Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger.
let forste_over = etasjer.iter().position(|e| !e.under_terreng); let forste_over = etasjer.iter().position(|e| !e.under_terreng);
// Terrenghøyden på IfcBuilding sier hvor mye av grunnmuren som står over bakken. // Terrenghøyden på IfcBuilding sier hvor mye av grunnmuren som står over bakken.
let terrengmodell = terrengpunkter(&fil, skala);
let terreng = terrenghoyder(&fil, skala); let terreng = terrenghoyder(&fil, skala);
// Ligger terrengmodellen over gulvet i første etasje, er den ikke lagt i bakkenivå, og da // Ligger terrengmodellen over gulvet i første etasje, er den ikke lagt i bakkenivå, og da
// sier den ingenting om hvor mye av grunnmuren som er nedgravd. // sier den ingenting om hvor mye av grunnmuren som er nedgravd.
@@ -308,6 +310,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
let mut utelatt: Vec<(String, f64, Option<f64>)> = Vec::new(); let mut utelatt: Vec<(String, f64, Option<f64>)> = Vec::new();
let mut uten_uverdi: Vec<String> = Vec::new(); let mut uten_uverdi: Vec<String> = Vec::new();
let mut uten_geometri = 0usize; let mut uten_geometri = 0usize;
let mut grunnmurshoyder: Vec<f64> = Vec::new();
let mut asimut_for_vegg: HashMap<u64, f64> = HashMap::new(); let mut asimut_for_vegg: HashMap<u64, f64> = HashMap::new();
// Laveste etasje og laveste gulvnivå skiller dekke mot grunn fra etasjeskillere. // Laveste etasje og laveste gulvnivå skiller dekke mot grunn fra etasjeskillere.
@@ -359,6 +362,17 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
if d.u_verdi.is_none() { if d.u_verdi.is_none() {
uten_uverdi.push(d.navn.clone()); uten_uverdi.push(d.navn.clone());
} }
// Terrengmodellen sier hvor høyt bakken ligger akkurat der denne veggen står, så en
// skrånende tomt gir forskjellig høyde over bakken på hver side av huset.
// Høyden måles fra overkant mur, ikke fra gulvet over: dekket selv står ikke i bakken.
let over_bakken = d
.plass
.and_then(|(x, y, _, _, _)| terreng_ved(&terrengmodell, x, y))
.map(|z| (d.topp - z).clamp(0.0, kjellerdybde))
.unwrap_or(kjeller_over_terreng);
if i_kjeller && mot == Mot::Grunn && typ == FlateType::Vegg {
grunnmurshoyder.push(over_bakken);
}
let mal = if i_kjeller { let mal = if i_kjeller {
&mut kjellerflater &mut kjellerflater
} else { } else {
@@ -378,8 +392,8 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
b_faktor: None, b_faktor: None,
absorptans: None, absorptans: None,
omkrets: None, omkrets: None,
dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde - kjeller_over_terreng), dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde - over_bakken),
over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(kjeller_over_terreng), over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(over_bakken),
sone, sone,
kommentar: d kommentar: d
.etasje .etasje
@@ -693,7 +707,21 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
rom.join(", ") rom.join(", ")
)), )),
}); });
let grunnmur = if terreng_over_gulvet { let grunnmur = if !grunnmurshoyder.is_empty() && !terrengmodell.is_empty() {
let laveste = grunnmurshoyder
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let hoyeste = grunnmurshoyder
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let snitt = grunnmurshoyder.iter().sum::<f64>() / grunnmurshoyder.len() as f64;
format!(
"Terrengmodellen i filen gir hver kjellervegg sin egen høyde over bakken: \
{laveste:.2}{hoyeste:.2} m, i snitt {snitt:.2} m. Resten av veggen er regnet nedgravd."
)
} else if terreng_over_gulvet {
format!( format!(
"Terrengmodellen ligger over gulvet i første etasje, så den er ikke brukt. \ "Terrengmodellen ligger over gulvet i første etasje, så den er ikke brukt. \
Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Legg \ Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Legg \
@@ -833,6 +861,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
lufting: Lufting::default(), lufting: Lufting::default(),
drift: Drift::default(), drift: Drift::default(),
scenario: Vec::new(), scenario: Vec::new(),
rapport: Rapport::default(),
}; };
Ok(Importrapport { Ok(Importrapport {
bygning, bygning,
@@ -1223,6 +1252,45 @@ fn terrenghoyder(fil: &Fil, skala: f64) -> Option<(f64, f64)> {
Some((referanse, terreng)) Some((referanse, terreng))
} }
/// Punktene i terrengmodellen, i byggets koordinater, m.
///
/// Vectorworks legger terrenget som et `IfcGeographicElement` med typen TERRAIN, eller på
/// tomta. Punktene brukes til å finne bakkenivået ved hver enkelt kjellervegg.
fn terrengpunkter(fil: &Fil, skala: f64) -> Vec<[f64; 3]> {
let mut ut = Vec::new();
for (_, e) in fil
.av_type("IFCGEOGRAPHICELEMENT")
.chain(fil.av_type("IFCSITE"))
{
let (px, py, pz) = e
.arg(5)
.and_then(|a| a.som_ref())
.and_then(|p| global_plassering(fil, p, skala))
.map(|(x, y, z, _, _)| (x, y, z))
.unwrap_or((0.0, 0.0, 0.0));
for nett in ifcgeom::netter_for_produkt(fil, e.arg(6).and_then(|a| a.som_ref())) {
for p in &nett.punkter {
ut.push([p[0] * skala + px, p[1] * skala + py, p[2] * skala + pz]);
}
}
}
ut
}
/// Bakkenivået ved et punkt, som snittet av de nærmeste punktene i terrengmodellen, m.
fn terreng_ved(punkter: &[[f64; 3]], x: f64, y: f64) -> Option<f64> {
if punkter.is_empty() {
return None;
}
let mut avstand: Vec<(f64, f64)> = punkter
.iter()
.map(|p| ((p[0] - x).powi(2) + (p[1] - y).powi(2), p[2]))
.collect();
avstand.sort_by(|a, b| a.0.total_cmp(&b.0));
let antall = avstand.len().min(8);
Some(avstand[..antall].iter().map(|(_, z)| z).sum::<f64>() / antall as f64)
}
/// Hvor mye av grunnmuren som står over bakken, m. /// Hvor mye av grunnmuren som står over bakken, m.
/// ///
/// Toppen av grunnmuren ligger ved gulvet i første etasje over kjelleren. Den står over /// Toppen av grunnmuren ligger ved gulvet i første etasje over kjelleren. Den står over
@@ -1250,6 +1318,8 @@ struct Del {
normal: Option<[f64; 3]>, normal: Option<[f64; 3]>,
/// Delens høyde over byggets nullpunkt, av geometrien, m. /// Delens høyde over byggets nullpunkt, av geometrien, m.
z: f64, z: f64,
/// Overkanten av delen over byggets nullpunkt, m. For en kjellervegg er det overkant mur.
topp: f64,
} }
/// Oversetter en fritekstverdi fra modellen til en bygningskategori. /// Oversetter en fritekstverdi fra modellen til en bygningskategori.
@@ -1438,20 +1508,15 @@ fn klassifiser(
if d.ytre == Some(true) { if d.ytre == Some(true) {
return Some((FlateType::Gulv, Mot::Ute)); return Some((FlateType::Gulv, Mot::Ute));
} }
// Mot grunnen i det nederste planet, eller når navnet sier det. Høyden av geometrien // Høyden av geometrien avgjør, siden etasjetilknytningen i en eksport kan være feil.
// avgjør, siden etasjetilknytningen i en eksport kan være feil. // Et dekke som ligger på bakken sier det i navnet. Ellers er et dekke like under
let paa_grunnen = [ // gulvet i første etasje himlingen over kjelleren, og det nederste dekket ligger mot
"kjellergulv", // grunnen. «Kjellergulv» er ikke nok til å si at dekket ligger på bakken: dekket over
"on grade", // kjelleren bærer ofte det samme navnet.
"på grunn", let paa_grunnen = ["on grade", "på grunn", "paa grunn", "såle", "sale"];
"paa grunn", if paa_grunnen.iter().any(|o| navn.contains(o)) {
"såle",
"sale",
];
if (d.z - laveste).abs() < 0.5 || paa_grunnen.iter().any(|o| navn.contains(o)) {
return Some((FlateType::Gulv, Mot::Grunn)); return Some((FlateType::Gulv, Mot::Grunn));
} }
// Dekket i laveste etasje over en kjeller er grensen mot den uoppvarmede sonen.
if e.kjeller.is_some() { if e.kjeller.is_some() {
let ved_forste_over = e let ved_forste_over = e
.forste_over_hoyde .forste_over_hoyde
@@ -1461,6 +1526,9 @@ fn klassifiser(
return Some((FlateType::Gulv, Mot::Uoppvarmet)); return Some((FlateType::Gulv, Mot::Uoppvarmet));
} }
} }
if (d.z - laveste).abs() < 0.5 {
return Some((FlateType::Gulv, Mot::Grunn));
}
// Dekker lenger opp er etasjeskillere inne i bygningen. // Dekker lenger opp er etasjeskillere inne i bygningen.
return None; return None;
} }
@@ -2094,6 +2162,20 @@ mod tester {
assert!(r.merknader.iter().any(|m| m.contains("standardplassering"))); assert!(r.merknader.iter().any(|m| m.contains("standardplassering")));
} }
#[test]
fn terrenget_leses_av_de_naermeste_punktene() {
// Et terreng som faller mot øst: bakken er høyere ved vestveggen enn ved østveggen.
let punkter: Vec<[f64; 3]> = (0..10)
.flat_map(|i| (0..10).map(move |j| [i as f64, j as f64, -1.0 - 0.2 * i as f64]))
.collect();
let vest = terreng_ved(&punkter, 0.5, 5.0).unwrap();
let ost = terreng_ved(&punkter, 8.5, 5.0).unwrap();
assert!(vest > ost, "vest {vest}, øst {ost}");
assert!((vest + 1.1).abs() < 0.2, "vest {vest}");
assert!((ost + 2.7).abs() < 0.2, "øst {ost}");
assert_eq!(terreng_ved(&[], 0.0, 0.0), None);
}
#[test] #[test]
fn grunnmur_over_terreng_av_hoydene() { fn grunnmur_over_terreng_av_hoydene() {
// ±0 ligger 131,2 moh., terrenget 130,6 moh. og første etasje på ±0: 0,6 m over bakken. // ±0 ligger 131,2 moh., terrenget 130,6 moh. og første etasje på ±0: 0,6 m over bakken.
+17
View File
@@ -30,6 +30,8 @@ pub struct Hovedflate {
/// Midlere høyde over delens eget nullpunkt, m. Sier hvilken etasje delen ligger i når /// Midlere høyde over delens eget nullpunkt, m. Sier hvilken etasje delen ligger i når
/// plasseringen i filen ikke gjør det. /// plasseringen i filen ikke gjør det.
pub hoyde: f64, pub hoyde: f64,
/// Øverste punkt over delens eget nullpunkt, m. For en kjellervegg er det overkant mur.
pub topp: f64,
} }
impl Nett { impl Nett {
@@ -103,11 +105,18 @@ impl Nett {
} else { } else {
self.punkter.iter().map(|p| p[2]).sum::<f64>() / self.punkter.len() as f64 * skala self.punkter.iter().map(|p| p[2]).sum::<f64>() / self.punkter.len() as f64 * skala
}; };
let topp = self
.punkter
.iter()
.map(|p| p[2])
.fold(f64::NEG_INFINITY, f64::max)
* skala;
Some(Hovedflate { Some(Hovedflate {
areal: sum / 2.0, areal: sum / 2.0,
normal, normal,
plan: self.utstrekning(normal, skala), plan: self.utstrekning(normal, skala),
hoyde, hoyde,
topp: if topp.is_finite() { topp } else { 0.0 },
}) })
} }
@@ -189,9 +198,14 @@ pub fn samlet_hovedflate(deler: &[Nett], skala: f64) -> Option<Hovedflate> {
} else { } else {
grupper[0].hoyde grupper[0].hoyde
}; };
let topp = grupper
.iter()
.map(|g| g.topp)
.fold(f64::NEG_INFINITY, f64::max);
Some(Hovedflate { Some(Hovedflate {
areal, areal,
hoyde, hoyde,
topp,
..grupper[0] ..grupper[0]
}) })
} }
@@ -562,6 +576,9 @@ mod tester {
let h = n.hovedflate(1.0).unwrap(); let h = n.hovedflate(1.0).unwrap();
assert!((h.areal - 12.0).abs() < 1e-9, "areal ble {}", h.areal); assert!((h.areal - 12.0).abs() < 1e-9, "areal ble {}", h.areal);
assert!(h.normal[2].abs() > 0.99, "normal ble {:?}", h.normal); assert!(h.normal[2].abs() > 0.99, "normal ble {:?}", h.normal);
// Midlere høyde er halve tykkelsen, og overkanten er tykkelsen.
assert!((h.hoyde - z / 2.0).abs() < 1e-9, "høyde ble {}", h.hoyde);
assert!((h.topp - z).abs() < 1e-9, "topp ble {}", h.topp);
} }
/// Et hull i flaten skal trekkes fra, siden hullet mangler trekanter. /// Et hull i flaten skal trekkes fra, siden hullet mangler trekanter.
+1
View File
@@ -215,6 +215,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
lufting: Lufting::default(), lufting: Lufting::default(),
drift: Drift::default(), drift: Drift::default(),
scenario: Vec::new(), scenario: Vec::new(),
rapport: Rapport::default(),
}; };
Ok(Importrapport { bygning, merknader }) Ok(Importrapport { bygning, merknader })
} }
+20
View File
@@ -82,6 +82,26 @@ pub struct Bygning {
pub drift: Drift, pub drift: Drift,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scenario: Vec<Scenario>, pub scenario: Vec<Scenario>,
#[serde(default)]
pub rapport: Rapport,
}
/// Utseendet på rapporten, slik at den kan følge firmaets øvrige dokumenter.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct Rapport {
/// Logo på forsiden. Filen kopieres til utdatakatalogen ved siden av rapporten.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo: Option<PathBuf>,
/// Aksentfarge, for eksempel «#880000». Utelatt: enoks egen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub farge: Option<String>,
/// Avsenderlinje nederst på hver side, for eksempel firma og organisasjonsnummer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avsender: Option<String>,
/// Skrift for overskrifter. Utelatt: enoks egen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skrift_overskrift: Option<String>,
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
+293 -16
View File
@@ -2,6 +2,7 @@
use crate::kjor::Resultat; use crate::kjor::Resultat;
use crate::merking::skala::BOKSTAVER; use crate::merking::skala::BOKSTAVER;
use crate::modell::lost::Forutsetning;
use crate::modell::{FlateType, Mot}; use crate::modell::{FlateType, Mot};
use crate::rapport::svg; use crate::rapport::svg;
use crate::Result; use crate::Result;
@@ -66,6 +67,12 @@ fn rad(celler: &[String]) -> String {
pub fn skriv(r: &Resultat, katalog: &Path, scenarier: &[(String, Resultat)]) -> Result<()> { pub fn skriv(r: &Resultat, katalog: &Path, scenarier: &[(String, Resultat)]) -> Result<()> {
std::fs::create_dir_all(katalog)?; std::fs::create_dir_all(katalog)?;
std::fs::write(katalog.join("stil.typ"), STIL)?; std::fs::write(katalog.join("stil.typ"), STIL)?;
// Logoen følger med, slik at rapporten kan kompileres for seg selv senere. Mangler
// filen, lages rapporten uten den.
if let (Some(logo), Some(navn)) = (&r.bygning.rapport.logo, logo_navn(r)) {
std::fs::copy(logo, katalog.join(navn))
.with_context(|| format!("kopierer logoen {}", logo.display()))?;
}
std::fs::write(katalog.join("fig-maaned.svg"), svg::maanedsdiagram(r))?; std::fs::write(katalog.join("fig-maaned.svg"), svg::maanedsdiagram(r))?;
std::fs::write(katalog.join("fig-varighet.svg"), svg::varighetskurve(r))?; std::fs::write(katalog.join("fig-varighet.svg"), svg::varighetskurve(r))?;
std::fs::write(katalog.join("fig-karakter.svg"), svg::karakterstige(r))?; std::fs::write(katalog.join("fig-karakter.svg"), svg::karakterstige(r))?;
@@ -100,15 +107,32 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
let mut s = String::new(); let mut s = String::new();
s.push_str("#import \"stil.typ\": *\n\n"); s.push_str("#import \"stil.typ\": *\n\n");
s.push_str(&format!( let rap = &r.bygning.rapport;
"#show: oppsett.with(tittel: \"{} — energirapport\", bunntekst: \"{}\")\n\n", let mut valg = format!(
e(&l.navn), "tittel: \"{} — energirapport\", bunntekst: \"{}\"",
e(&bunntekst(r)) streng(&l.navn),
)); streng(&bunntekst(r))
);
if let Some(a) = &rap.avsender {
valg.push_str(&format!(", avsender: \"{}\"", streng(a)));
}
if let Some(f) = &rap.farge {
valg.push_str(&format!(", farge: rgb(\"{}\")", streng(f)));
}
if let Some(sk) = &rap.skrift_overskrift {
valg.push_str(&format!(", skrift: \"{}\"", streng(sk)));
}
s.push_str(&format!("#show: oppsett.with({valg})\n\n"));
// ── forside ───────────────────────────────────────────────────── // ── forside ─────────────────────────────────────────────────────
if let Some(navn) = logo_navn(r) {
s.push_str(&format!( s.push_str(&format!(
"#v(1.2cm)\n#text(size: 26pt, weight: \"bold\")[Energirapport]\n\n\ "#align(right, image(\"{}\", height: 1.7cm))\n\n#v(-0.9cm)\n",
streng(&navn)
));
}
s.push_str(&format!(
"#v(1.2cm)\n#forsidetittel[Energirapport]\n\n\
#v(-0.3em)\n#text(size: 15pt, fill: mid)[{}]\n\n", #v(-0.3em)\n#text(size: 15pt, fill: mid)[{}]\n\n",
e(&l.navn) e(&l.navn)
)); ));
@@ -414,7 +438,10 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
// ── inndata ───────────────────────────────────────────────────── // ── inndata ─────────────────────────────────────────────────────
s.push_str("#pagebreak()\n\n= Inndata\n\n== Bygningskropp\n\n"); 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 { for f in &l.flater {
let typ = if f.glass { let typ = if f.glass {
"Vindu/dør" "Vindu/dør"
@@ -430,15 +457,62 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
Mot::Grunn => "grunnen", Mot::Grunn => "grunnen",
Mot::Uoppvarmet => "uoppvarmet", 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!( s.push_str(&format!(
" {}\n", " {}\n",
rad(&[ rad(&[
e(&f.navn), e(navn),
typ.into(), typ.clone(),
tall(f.areal, 1), if *antall > 1 {
tall(f.u_verdi, 2), antall.to_string()
himmelretning(f.orientering, f.helning), } else {
mot.into() String::new()
},
tall(*areal, 1),
u.clone(),
orientering.clone(),
mot.into(),
]) ])
)); ));
} }
@@ -473,14 +547,28 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
if l.forutsetninger.is_empty() { if l.forutsetninger.is_empty() {
s.push_str("Alle inndata er oppgitt i bygningsbeskrivelsen.\n\n"); s.push_str("Alle inndata er oppgitt i bygningsbeskrivelsen.\n\n");
} else { } else {
s.push_str("#tabell(\n kolonner: (auto, auto, 1fr),\n justering: (left, left, left),\n overskrift: (\"Felt\", \"Verdi\", \"Kilde\"),\n rader: (\n"); 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 &l.forutsetninger { for f in grupper_forutsetninger(&l.forutsetninger) {
s.push_str(&format!( s.push_str(&format!(
" {}\n", " {}\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(" ),\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 { for m in &r.merknader {
s.push_str(&format!("#merknad[{}]\n\n", e(m))); s.push_str(&format!("#merknad[{}]\n\n", e(m)));
@@ -539,6 +627,195 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
s 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<f64> {
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,211,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<f64> = 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<char> = 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(: bool) -> String {
if {
"".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<Forutsetningsrad> {
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>, 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>, 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('"', "\\\"")
}
/// Filnavnet på logoen, når den er oppgitt og finnes.
fn logo_navn(r: &Resultat) -> Option<String> {
let logo = r.bygning.rapport.logo.as_ref()?;
if !logo.exists() {
eprintln!(
" ! rapport.logo: fant ikke {}; rapporten lages uten logo.",
logo.display()
);
return None;
}
Some(logo.file_name()?.to_str()?.to_string())
}
fn bunntekst(r: &Resultat) -> String { fn bunntekst(r: &Resultat) -> String {
let b = &r.bygning.bygning; let b = &r.bygning.bygning;
let mut deler = Vec::new(); let mut deler = Vec::new();
+45 -8
View File
@@ -7,11 +7,32 @@
#let lys = rgb("#f4f1ee") #let lys = rgb("#f4f1ee")
#let kantlinje = rgb("#ddd4cc") #let kantlinje = rgb("#ddd4cc")
// Aksentfarge og overskriftsskrift kan settes av rapporten, slik at den kan følge
// firmaets øvrige dokumenter. Verdiene under er enoks egne.
#let aksent-valgt = state("aksent-valgt", aksent)
#let skrift-valgt = state("skrift-valgt", none)
#let font-heading = ("Libertinus Serif", "DejaVu Serif", "Noto Serif") #let font-heading = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#let font-body = ("Libertinus Serif", "DejaVu Serif", "Noto Serif") #let font-body = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#let font-tall = ("DejaVu Sans Mono", "Liberation Mono") #let font-tall = ("DejaVu Sans Mono", "Liberation Mono")
#let oppsett(tittel: "", undertittel: "", bunntekst: "", doc) = { /// Skriften rapporten har bedt om, ellers enoks egen.
#let overskriftsskrift() = {
let valgt = skrift-valgt.get()
if valgt == none { font-heading } else { (valgt,) + font-heading }
}
#let oppsett(
tittel: "",
undertittel: "",
bunntekst: "",
avsender: "",
farge: none,
skrift: none,
doc,
) = {
aksent-valgt.update(if farge == none { aksent } else { farge })
skrift-valgt.update(skrift)
set page( set page(
paper: "a4", paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.2cm, right: 2.2cm), margin: (top: 2.4cm, bottom: 2.4cm, left: 2.2cm, right: 2.2cm),
@@ -20,32 +41,36 @@
grid( grid(
columns: (1fr, auto), columns: (1fr, auto),
align: (left + horizon, right + horizon), align: (left + horizon, right + horizon),
text(font: font-heading, size: 8pt, fill: mid)[#tittel], text(font: overskriftsskrift(), size: 8pt, fill: mid)[#tittel],
text(font: font-body, size: 8pt, fill: mid)[#counter(page).display()], text(font: font-body, size: 8pt, fill: mid)[#counter(page).display()],
) )
line(length: 100%, stroke: 0.4pt + kantlinje) line(length: 100%, stroke: 0.4pt + kantlinje)
} }
}, },
footer: context { footer: context {
if counter(page).get().first() > 1 and bunntekst != "" { if counter(page).get().first() > 1 and (bunntekst != "" or avsender != "") {
line(length: 100%, stroke: 0.4pt + kantlinje) line(length: 100%, stroke: 0.4pt + kantlinje)
v(3pt) v(3pt)
if avsender != "" {
text(font: font-body, size: 7.5pt, fill: mid)[#avsender]
linebreak()
}
text(font: font-body, size: 7.5pt, fill: mid)[#bunntekst] text(font: font-body, size: 7.5pt, fill: mid)[#bunntekst]
} }
}, },
) )
set text(font: font-body, size: 10pt, fill: moerk, lang: "nb") set text(font: font-body, size: 10pt, fill: moerk, lang: "nb")
set par(justify: true, leading: 0.62em) set par(justify: true, leading: 0.62em)
show heading: it => { show heading: it => context {
set text(font: font-heading, fill: aksent) set text(font: overskriftsskrift(), fill: aksent-valgt.get())
block(above: 1.2em, below: 0.6em, it) block(above: 1.2em, below: 0.6em, it)
} }
show heading.where(level: 1): it => { show heading.where(level: 1): it => context {
set text(size: 15pt, weight: "bold") set text(size: 15pt, weight: "bold")
block(above: 1.6em, below: 0.7em)[ block(above: 1.6em, below: 0.7em)[
#it.body #it.body
#v(-0.45em) #v(-0.45em)
#line(length: 100%, stroke: 1pt + aksent) #line(length: 100%, stroke: 1pt + aksent-valgt.get())
] ]
} }
show heading.where(level: 2): set text(size: 11.5pt, weight: "bold") show heading.where(level: 2): set text(size: 11.5pt, weight: "bold")
@@ -55,7 +80,8 @@
} }
// Tabell med stripete rader og linje under overskriften. // Tabell med stripete rader og linje under overskriften.
#let tabell(kolonner: (), overskrift: (), rader: (), justering: none, sum-rader: ()) = { #let tabell(kolonner: (), overskrift: (), rader: (), justering: none, sum-rader: ()) = context {
let aksent = aksent-valgt.get()
let j = if justering == none { kolonner.map(_ => right) } else { justering } let j = if justering == none { kolonner.map(_ => right) } else { justering }
block(breakable: true, table( block(breakable: true, table(
columns: kolonner, columns: kolonner,
@@ -71,6 +97,16 @@
)) ))
} }
// Tittelen på forsiden, i samme skrift og farge som overskriftene.
#let forsidetittel(tekst) = context {
text(
font: overskriftsskrift(),
size: 26pt,
weight: "bold",
fill: aksent-valgt.get(),
)[#tekst]
}
// Stor karakterrute på forsiden. // Stor karakterrute på forsiden.
#let karakterrute(bokstav, verdi, farge) = { #let karakterrute(bokstav, verdi, farge) = {
box( box(
@@ -99,3 +135,4 @@
stroke: (left: 2pt + aksent-lys), stroke: (left: 2pt + aksent-lys),
text(size: 9pt)[#innhold], text(size: 9pt)[#innhold],
) )