Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19162d9a7
|
||
|
|
c89561b0f5
|
||
|
|
e3f37afe36
|
||
|
|
cabd8500ea
|
||
|
|
e7d77547ac
|
Generated
+1
-1
@@ -222,7 +222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "enok"
|
||||
version = "0.4.0"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -596,6 +596,7 @@ fn bygg(
|
||||
lufting: Lufting::default(),
|
||||
drift: Drift::default(),
|
||||
scenario: Vec::new(),
|
||||
rapport: Rapport::default(),
|
||||
};
|
||||
Ok(Importrapport { bygning, merknader })
|
||||
}
|
||||
|
||||
+196
-22
@@ -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,20 +1439,28 @@ 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 {
|
||||
return Some((FlateType::Gulv, Mot::Uoppvarmet));
|
||||
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
@@ -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]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
"#align(right, image(\"{}\", height: 1.7cm))\n\n#v(-0.9cm)\n",
|
||||
streng(&navn)
|
||||
));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"#v(1.2cm)\n#text(size: 26pt, weight: \"bold\")[Energirapport]\n\n\
|
||||
"#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 1991–2020 (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
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user