5 Commits
Author SHA1 Message Date
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
blandClaude Opus 5 e7d77547ac Les høyden av geometrien, og tell dobbeltførte deler én gang
Release enok / bygg (release) Successful in 1m12s
test / test (push) Successful in 1m15s
Noen eksporter legger de samme veggene og dekkene i flere etasjer, og gir dem feil etasje.
Kopiene telles nå én gang, og den kopien som ligger i den etasjen høyden tilsier, er den som
regnes med. Vinduer i en kopi finner fortsatt veggen sin.

Klassifiseringen bruker høyden fra geometrien i stedet for etasjetilknytningen, som kan være
feil: et dekke mot grunnen kjennes på at det ligger nederst eller heter noe med «på grunn»,
og et dekke rett over kjelleren på høyden. Importen sier også hvilke deler som ikke er regnet
med i klimaskjermen, og en terrengmodell som ligger over gulvet i første etasje, forkastes.

Rapporten viser nå enok-versjonen og datoen den er laget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 20:21:58 +02:00
10 changed files with 395 additions and 43 deletions
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]]
name = "enok"
version = "0.4.0"
version = "0.4.3"
dependencies = [
"anyhow",
"approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "enok"
version = "0.4.0"
version = "0.4.3"
edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
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 }]
```
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
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*"`.
@@ -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
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å
modellen alene.
@@ -382,7 +405,7 @@ jobs:
"$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
- uses: https://gitea.com/actions/upload-artifact@v3
with:
name: bygningsmodell
path: |
@@ -396,7 +419,7 @@ jobs:
shell: fish {0}
steps:
- uses: https://gitea.com/actions/checkout@v6
- uses: https://gitea.com/actions/download-artifact@v4
- uses: https://gitea.com/actions/download-artifact@v3
with:
name: bygningsmodell
- name: Lag rapporten
+1
View File
@@ -596,6 +596,7 @@ fn bygg(
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
rapport: Rapport::default(),
};
Ok(Importrapport { bygning, merknader })
}
+195 -21
View File
@@ -26,6 +26,9 @@ pub struct Importrapport {
pub utelatt: Vec<(String, f64, Option<f64>)>,
}
/// Konstruksjonsnavn og avrundet plassering, som skiller én bygningsdel fra en annen.
type Dublettnokkel = (String, i64, i64, i64, i64);
const VEGGTYPER: [&str; 3] = ["IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL"];
const PLATETYPER: [&str; 3] = ["IFCSLAB", "IFCROOF", "IFCSLABSTANDARDCASE"];
@@ -182,9 +185,59 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
plass,
areal: flate.map(|f| f.areal).unwrap_or(0.0),
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),
});
}
// Noen eksporter legger den samme bygningsdelen i flere etasjer. To deler med samme
// konstruksjon, samme areal og samme plassering er den samme veggen eller det samme
// dekket. Kopiene blir stående, slik at vinduene i dem finner veggen sin, men de telles
// ikke med i klimaskjermen. Av kopiene beholdes den som ligger i den etasjen høyden
// tilsier, slik at en kjellervegg havner i kjelleren.
let konstruksjon = |navn: &str| navn.split(" #").next().unwrap_or(navn).trim().to_string();
let mut grupper: Vec<(Dublettnokkel, Vec<u64>)> = Vec::new();
for d in &deler {
let Some((x, y, _, _, _)) = d.plass else {
continue;
};
let cm = |v: f64| (v * 50.0).round() as i64;
let nokkel = (
konstruksjon(&d.navn),
cm(x),
cm(y),
cm(d.z),
(d.areal * 100.0).round() as i64,
);
match grupper.iter_mut().find(|(k, _)| *k == nokkel) {
Some((_, medlemmer)) => medlemmer.push(d.nr),
None => grupper.push((nokkel, vec![d.nr])),
}
}
let mut dublett_av: HashMap<u64, u64> = HashMap::new();
for (_, medlemmer) in grupper.iter().filter(|(_, m)| m.len() > 1) {
let avstand_til_etasjen = |nr: u64| {
let d = deler.iter().find(|d| d.nr == nr)?;
let hoyde = d.etasje.and_then(|i| etasjer.get(i)).map(|e| e.hoyde)?;
Some((d.z - hoyde).abs())
};
let beholdt = *medlemmer
.iter()
.min_by(|a, b| {
let (fa, fb) = (avstand_til_etasjen(**a), avstand_til_etasjen(**b));
match (fa, fb) {
(Some(x), Some(y)) => x.total_cmp(&y).then(a.cmp(b)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.cmp(b),
}
})
.unwrap_or(&medlemmer[0]);
for nr in medlemmer.iter().filter(|nr| **nr != beholdt) {
dublett_av.insert(*nr, beholdt);
}
}
let dubletter = dublett_av.len();
// Tyngdepunkt av de ytre veggene bestemmer hvilken vei normalen peker.
let ytre_deler: Vec<&Del> = deler
.iter()
@@ -234,8 +287,16 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
let forste_over = etasjer.iter().position(|e| !e.under_terreng);
// Terrenghøyden på IfcBuilding sier hvor mye av grunnmuren som står over bakken.
let terreng = terrenghoyder(&fil, skala);
let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) {
// 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.
let terreng_over_gulvet = match (terreng, forste_over.and_then(|i| etasjer.get(i))) {
(Some((referanse, terrengnivaa)), Some(forste)) => {
terrengnivaa > referanse + forste.hoyde + 0.5
}
_ => false,
};
let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) {
(Some((referanse, terrengnivaa)), Some(forste)) if !terreng_over_gulvet => {
over_terreng_for_kjeller(referanse, terrengnivaa, forste.hoyde, kjellerdybde)
}
_ => 0.0,
@@ -251,14 +312,14 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
// Laveste etasje og laveste gulvnivå skiller dekke mot grunn fra etasjeskillere.
let etasjekontekst = Etasjekontekst {
nederste: (!etasjer.is_empty()).then_some(0),
kjeller: kjelleretasje,
forste_over,
forste_over_hoyde: forste_over.and_then(|i| etasjer.get(i)).map(|e| e.hoyde),
};
let laveste = deler
.iter()
.filter(|d| PLATETYPER.contains(&d.typ.as_str()))
.filter_map(|d| d.plass.map(|p| p.2))
.map(|d| d.z)
.fold(f64::INFINITY, f64::min);
for d in &deler {
@@ -271,16 +332,25 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
uten_geometri += 1;
continue;
}
if dublett_av.contains_key(&d.nr) {
continue;
}
let i_kjeller = kjelleretasje.is_some() && d.etasje == kjelleretasje;
let Some((typ, mot)) = klassifiser(d, laveste, helning, &etasjekontekst) else {
utelatt.push((d.navn.clone(), d.areal, d.u_verdi));
continue;
};
// En etasjeskiller i den laveste etasjen over terreng er grensen mot kjelleren.
// En etasjeskiller like over kjelleren er grensen mot den uoppvarmede sonen. Høyden
// avgjør, ikke etasjetilknytningen, som kan være feil i eksporten.
let ved_kjellertaket = etasjekontekst
.forste_over_hoyde
.map(|h| (d.z - h).abs() < 0.6)
.unwrap_or(false);
let (mot, sone) = if kjelleretasje.is_some()
&& !i_kjeller
&& d.etasje == forste_over
&& ved_kjellertaket
&& typ == FlateType::Gulv
&& mot != Mot::Grunn
{
(Mot::Uoppvarmet, Some(kjellernavn.clone()))
} else {
@@ -324,6 +394,9 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
// ── vinduer og dører ────────────────────────────────────────────
let mut vinduer: Vec<Vindu> = Vec::new();
let mut sette_vinduer: std::collections::HashSet<(String, (i64, i64, i64), i64)> =
Default::default();
let mut vindusdubletter = 0usize;
for (r, e) in fil.av_type("IFCWINDOW").chain(fil.av_type("IFCDOOR")) {
let dor = e.typ == "IFCDOOR";
let hoyde = e.arg(8).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala;
@@ -340,7 +413,9 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
dor_vert.get(&r)
} else {
vindu_vert.get(&r)
};
}
.map(|v| dublett_av.get(v).copied().unwrap_or(*v));
let vert = vert.as_ref();
// Vinduer i innervegger hører ikke til klimaskjermen.
let i_ytre_vegg = vert
.and_then(|v| deler.iter().find(|d| d.nr == *v))
@@ -354,6 +429,25 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
));
continue;
}
// Det samme vinduet i to kopier av den samme veggen er ett vindu.
let sted = e
.arg(5)
.and_then(|a| a.som_ref())
.and_then(|p| global_plassering(&fil, p, skala))
.map(|(x, y, z, _, _)| {
let cm = |v: f64| (v * 50.0).round() as i64;
(cm(x), cm(y), cm(z))
})
.unwrap_or_default();
let nokkel = (
konstruksjon(&navn_av(e, r, if dor { "Dør" } else { "Vindu" })),
sted,
(areal * 100.0).round() as i64,
);
if !sette_vinduer.insert(nokkel) {
vindusdubletter += 1;
continue;
}
let asimut = vert
.and_then(|v| asimut_for_vegg.get(v))
.copied()
@@ -443,6 +537,44 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
"{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal."
));
}
if !utelatt.is_empty() {
// Gruppert per konstruksjon, slik at listen er til å lese.
let mut grupper: Vec<(String, usize, f64)> = Vec::new();
for (navn, areal, _) in &utelatt {
let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string();
match grupper.iter_mut().find(|(n, _, _)| *n == kort) {
Some((_, antall, sum)) => {
*antall += 1;
*sum += areal;
}
None => grupper.push((kort, 1, *areal)),
}
}
grupper.sort_by(|a, b| b.2.total_cmp(&a.2));
let liste: Vec<String> = grupper
.iter()
.take(10)
.map(|(navn, antall, sum)| {
if *antall > 1 {
format!("{navn} ×{antall} ({sum:.0} m²)")
} else {
format!("{navn} ({sum:.0} m²)")
}
})
.collect();
merknader.push(format!(
"{} bygningsdeler er ikke regnet med i klimaskjermen, som innervegger og dekker inne i bygningen: {}{}.",
utelatt.len(),
liste.join(", "),
if grupper.len() > liste.len() { "" } else { "" }
));
}
if dubletter + vindusdubletter > 0 {
merknader.push(format!(
"{} bygningsdeler ligger dobbelt i modellen, samme konstruksjon på samme sted, og er telt én gang. Det skjer når eksporten legger de samme delene i flere etasjer.",
dubletter + vindusdubletter
));
}
if !uten_uverdi.is_empty() {
// Navnene gjør det tydelig hva som må rettes i modellen. Flere elementer av samme
// konstruksjon nevnes én gang, med antallet.
@@ -561,7 +693,13 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
rom.join(", ")
)),
});
let grunnmur = if terreng.is_some() {
let grunnmur = if terreng_over_gulvet {
format!(
"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 \
terrengmodellen i bakkenivå, eller sett `over_terreng` i meter på veggene."
)
} else if terreng.is_some() {
format!(
"Etter terrenghøyden i modellen står grunnmuren {kjeller_over_terreng:.2} m over bakken og \
{:.2} m under.",
@@ -695,6 +833,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
rapport: Rapport::default(),
};
Ok(Importrapport {
bygning,
@@ -1110,6 +1249,8 @@ struct Del {
plass: Option<(f64, f64, f64, f64, f64)>,
areal: f64,
normal: Option<[f64; 3]>,
/// Delens høyde over byggets nullpunkt, av geometrien, m.
z: f64,
}
/// Oversetter en fritekstverdi fra modellen til en bygningskategori.
@@ -1257,12 +1398,13 @@ fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) {
/// Avgjør om en bygningsdel hører til klimaskjermen, og hva den vender mot.
/// Etasjeinndelingen som klassifiseringen trenger.
struct Etasjekontekst {
/// Nederste etasje i modellen.
nederste: Option<usize>,
/// Etasjen under terreng, dersom modellen har en.
kjeller: Option<usize>,
/// Laveste etasje over terreng, der etasjeskilleren mot kjelleren ligger.
forste_over: Option<usize>,
/// Høyden på den etasjen, m. Geometrien veier tyngre enn etasjetilknytningen, som kan
/// være feil i eksporten.
forste_over_hoyde: Option<f64>,
}
fn klassifiser(
@@ -1297,21 +1439,29 @@ fn klassifiser(
if d.ytre == Some(true) {
return Some((FlateType::Gulv, Mot::Ute));
}
// Mot grunnen bare i nederste etasje. Er etasjen ukjent, brukes høyden.
let nederst = match (d.etasje, e.nederste) {
(Some(a), Some(b)) => a == b,
_ => d
.plass
.map(|p| (p.2 - laveste).abs() < 0.5)
.unwrap_or(false),
};
if nederst || navn.contains("kjellergulv") {
// Mot grunnen i det nederste planet, eller når navnet sier det. Høyden av geometrien
// avgjør, siden etasjetilknytningen i en eksport kan være feil.
let paa_grunnen = [
"kjellergulv",
"on grade",
"på grunn",
"paa grunn",
"såle",
"sale",
];
if (d.z - laveste).abs() < 0.5 || paa_grunnen.iter().any(|o| navn.contains(o)) {
return Some((FlateType::Gulv, Mot::Grunn));
}
// Dekket i laveste etasje over en kjeller er grensen mot den uoppvarmede sonen.
if e.kjeller.is_some() && d.etasje.is_some() && d.etasje == e.forste_over {
if e.kjeller.is_some() {
let ved_forste_over = e
.forste_over_hoyde
.map(|h| (d.z - h).abs() < 0.6)
.unwrap_or(false);
if ved_forste_over || (d.etasje.is_some() && d.etasje == e.forste_over) {
return Some((FlateType::Gulv, Mot::Uoppvarmet));
}
}
// Dekker lenger opp er etasjeskillere inne i bygningen.
return None;
}
@@ -1678,6 +1828,11 @@ mod tester {
/// To yttervegger på 5 × 2,5 m med hver sin trekantgeometri, i millimeter.
/// Sørveggen ligger ved y = 5 m, nordveggen ved y = +5 m.
fn prove() -> String {
prove_med(&[(100, "Sorvegg", -5.0), (120, "Nordvegg", 5.0)])
}
/// Som `prove`, men med de veggene testen ber om: (entitetsnummer, navn, y i meter).
fn prove_med(vegger: &[(u64, &str, f64)]) -> String {
let mut s = String::from(
"ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n #1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n #2= IFCUNITASSIGNMENT((#1));\n #3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);\n #4= IFCDIRECTION((0.,1.));\n #40= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#50,#4);\n #5= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n #10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);\n #20= IFCLOCALPLACEMENT($,#21);\n #21= IFCAXIS2PLACEMENT3D(#22,$,$);\n #22= IFCCARTESIANPOINT((0.,0.,0.));\n #200= IFCPROPERTYSINGLEVALUE('ThermalTransmittance',$,IFCREAL(0.18),$);\n #201= IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$);\n #202= IFCPROPERTYSET('ps',$,'Pset_WallCommon',$,(#200,#201));\n #300= IFCSPACE('s',$,'Stue',$,$,#20,$,'Stue',.ELEMENT.,.INTERNAL.,$);\n #310= IFCQUANTITYAREA('NetFloorArea',$,$,100.0,$);\n #311= IFCQUANTITYVOLUME('NetVolume',$,$,250.0,$);\n #312= IFCELEMENTQUANTITY('q2',$,'BaseQuantities',$,$,(#310,#311));\n #313= IFCRELDEFINESBYPROPERTIES('r3',$,$,$,(#300),#312);\n",
);
@@ -1727,8 +1882,9 @@ mod tester {
p + 8
));
};
vegg(100, "Sorvegg", -5.0);
vegg(120, "Nordvegg", 5.0);
for (nr, navn, y) in vegger {
vegg(*nr, navn, *y);
}
s.push_str("ENDSEC;\nEND-ISO-10303-21;");
s
}
@@ -1774,6 +1930,24 @@ mod tester {
);
}
#[test]
fn samme_vegg_i_flere_etasjer_telles_en_gang() {
// Den samme veggen på nøyaktig samme sted to ganger, slik en eksport gjør når den
// legger de samme delene i flere etasjer.
let tekst = prove_med(&[
(100, "Sorvegg", -5.0),
(120, "Nordvegg", 5.0),
(140, "Sorvegg", -5.0),
]);
let r = fra_tekst(&tekst, "prove").unwrap();
assert_eq!(r.bygning.flate.len(), 2, "flater: {:?}", r.bygning.flate);
assert!(
r.merknader.iter().any(|m| m.contains("dobbelt i modellen")),
"{:?}",
r.merknader
);
}
#[test]
fn vegger_faar_himmelretning_i_navnet() {
let r = fra_tekst(&prove(), "prove").unwrap();
+17 -1
View File
@@ -27,6 +27,9 @@ pub struct Hovedflate {
pub normal: [f64; 3],
/// Utstrekning i flatens plan: (u_min, u_maks, v_min, v_maks), m.
plan: [f64; 4],
/// Midlere høyde over delens eget nullpunkt, m. Sier hvilken etasje delen ligger i når
/// plasseringen i filen ikke gjør det.
pub hoyde: f64,
}
impl Nett {
@@ -95,10 +98,16 @@ impl Nett {
let (normal, sum) = grupper
.into_iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
let hoyde = if self.punkter.is_empty() {
0.0
} else {
self.punkter.iter().map(|p| p[2]).sum::<f64>() / self.punkter.len() as f64 * skala
};
Some(Hovedflate {
areal: sum / 2.0,
normal,
plan: self.utstrekning(normal, skala),
hoyde,
})
}
@@ -173,9 +182,16 @@ pub fn samlet_hovedflate(deler: &[Nett], skala: f64) -> Option<Hovedflate> {
grupper.push(f);
}
}
let areal = grupper.iter().map(|g| g.areal).sum();
let areal: f64 = grupper.iter().map(|g| g.areal).sum();
// Arealvektet høyde, slik at et sammensatt dekke havner der mesteparten av det ligger.
let hoyde = if areal > 0.0 {
grupper.iter().map(|g| g.hoyde * g.areal).sum::<f64>() / areal
} else {
grupper[0].hoyde
};
Some(Hovedflate {
areal,
hoyde,
..grupper[0]
})
}
+1
View File
@@ -215,6 +215,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
rapport: Rapport::default(),
};
Ok(Importrapport { bygning, merknader })
}
+20
View File
@@ -82,6 +82,26 @@ pub struct Bygning {
pub drift: Drift,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
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)]
+88 -8
View File
@@ -66,6 +66,12 @@ fn rad(celler: &[String]) -> String {
pub fn skriv(r: &Resultat, katalog: &Path, scenarier: &[(String, Resultat)]) -> Result<()> {
std::fs::create_dir_all(katalog)?;
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-varighet.svg"), svg::varighetskurve(r))?;
std::fs::write(katalog.join("fig-karakter.svg"), svg::karakterstige(r))?;
@@ -100,15 +106,32 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
let mut s = String::new();
s.push_str("#import \"stil.typ\": *\n\n");
s.push_str(&format!(
"#show: oppsett.with(tittel: \"{} — energirapport\", bunntekst: \"{}\")\n\n",
e(&l.navn),
e(&bunntekst(r))
));
let rap = &r.bygning.rapport;
let mut valg = format!(
"tittel: \"{} — energirapport\", bunntekst: \"{}\"",
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 ─────────────────────────────────────────────────────
if let Some(navn) = logo_navn(r) {
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",
e(&l.navn)
));
@@ -473,7 +496,8 @@ 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 {
s.push_str("#tabell(\n kolonner: (auto, auto, 1fr),\n justering: (left, left, left),\n overskrift: (\"Felt\", \"Verdi\", \"Kilde\"),\n rader: (\n");
// 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(&format!(
" {}\n",
@@ -530,14 +554,33 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
}
s.push_str(&format!(
"\n#v(1em)\n#text(size: 8pt, fill: mid)[Beregnet med enok {} etter NS 3031:2025 (timesmetode, \
"\n#v(1em)\n#text(size: 8pt, fill: mid)[Beregnet {} med enok {} etter NS 3031:2025 (timesmetode, \
NS-EN ISO 52016-1). Karakterskala fastsatt av Enova SF, gjeldende fra 1. januar 2026. \
Klimadata: TMY for norske kommuner basert på CERRA 19912020 (CC-BY-4.0).]\n",
i_dag(),
env!("CARGO_PKG_VERSION")
));
s
}
/// 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 {
let b = &r.bygning.bygning;
let mut deler = Vec::new();
@@ -550,9 +593,34 @@ fn bunntekst(r: &Resultat) -> String {
if let Some(e) = &b.energiraadgiver {
deler.push(e.clone());
}
deler.push(format!("enok {} · {}", env!("CARGO_PKG_VERSION"), i_dag()));
deler.join(" · ")
}
/// Dagens dato som ISO-8601 i UTC.
fn i_dag() -> String {
let sekunder = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (aar, maaned, dag) = dato_av_dagnummer(sekunder.div_euclid(86_400));
format!("{aar:04}-{maaned:02}-{dag:02}")
}
/// Kalenderdato av antall døgn siden 1970-01-01, etter Howard Hinnants algoritme.
fn dato_av_dagnummer(dager: i64) -> (i64, i64, i64) {
let z = dager + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let dag = doy - (153 * mp + 2) / 5 + 1;
let maaned = if mp < 10 { mp + 3 } else { mp - 9 };
let aar = yoe + era * 400 + i64::from(maaned <= 2);
(aar, maaned, dag)
}
fn himmelretning(orientering: f64, helning: f64) -> String {
if helning < 20.0 {
return "horisontal".into();
@@ -566,3 +634,15 @@ fn himmelretning(orientering: f64, helning: f64) -> String {
let i = (((orientering.rem_euclid(360.0)) + 22.5) / 45.0).floor() as usize % 8;
retninger[i].into()
}
#[cfg(test)]
mod tester {
use super::dato_av_dagnummer;
#[test]
fn dagnummer_gir_riktig_dato() {
assert_eq!(dato_av_dagnummer(0), (1970, 1, 1));
assert_eq!(dato_av_dagnummer(19_723), (2024, 1, 1));
assert_eq!(dato_av_dagnummer(20_351), (2025, 9, 20));
}
}
+45 -8
View File
@@ -7,11 +7,32 @@
#let lys = rgb("#f4f1ee")
#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-body = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#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(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.2cm, right: 2.2cm),
@@ -20,32 +41,36 @@
grid(
columns: (1fr, auto),
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()],
)
line(length: 100%, stroke: 0.4pt + kantlinje)
}
},
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)
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]
}
},
)
set text(font: font-body, size: 10pt, fill: moerk, lang: "nb")
set par(justify: true, leading: 0.62em)
show heading: it => {
set text(font: font-heading, fill: aksent)
show heading: it => context {
set text(font: overskriftsskrift(), fill: aksent-valgt.get())
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")
block(above: 1.6em, below: 0.7em)[
#it.body
#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")
@@ -55,7 +80,8 @@
}
// 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 }
block(breakable: true, table(
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.
#let karakterrute(bokstav, verdi, farge) = {
box(
@@ -99,3 +135,4 @@
stroke: (left: 2pt + aksent-lys),
text(size: 9pt)[#innhold],
)