Energiberegning etter NS 3031:2025 med timesmetode og energirapport

enok leser en bygningsbeskrivelse i TOML, henter typisk klimaår for kommunen,
simulerer 8760 timer etter NS-EN ISO 52016-1 og skriver energirapport, CSV og JSON.

Motor
- Femnodemodell for opake bygningsdeler, tonodemodell for vinduer, luftnode og
  strålingsnode per sone, implisitt løsning per time med egen tett LU-løser.
- Varme- og kjølebehov ved eksakt interpolasjon mellom to løsninger, siden systemet
  er lineært. Vinduslufting for bygninger uten kjøleanlegg.
- Solberegning etter ISO 52010-1, kontrollert mot pvlib med avvik under 1 prosent.

Beregningsgang
- Beregningspunktene A til E: netto, brutto, tilført, levert per energibærer og
  vektet levert energi, med solceller, klimakorrigering og effektbehov.
- Energikarakter etter Enovas skala fra 1.1.2026, med arealkorreksjon.

Inndata
- TOML med scenarier for tiltak, og import fra IFC, gbXML og regneark.
- Normerte verdier, karakterskala, vektingsfaktorer og klimadata i data/,
  som kan overstyres med --data.

Kontroll
- Stasjonært varmetap mot U·A·ΔT innenfor 2 prosent, energibalansen innenfor
  2 prosent, varmetapsbudsjettet for TEK17-referansebygget identisk med publisert
  tabell. Kjent avvik i romoppvarming mot Simien 8 er dokumentert i README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
bl
2026-09-14 18:03:00 +02:00
co-authored by Claude Opus 5
commit 0964cacba3
51 changed files with 10309 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
//! CSV-utskrift: timeserie for hele året og månedssummer.
use crate::kjor::Resultat;
use crate::Result;
use std::path::Path;
pub fn timeserie(r: &Resultat, sti: &Path) -> Result<()> {
let mut w = csv::Writer::from_path(sti)?;
w.write_record([
"time",
"maaned",
"t_ute_C",
"t_inne_C",
"t_operativ_C",
"sol_horisontal_W_per_m2",
"soltilskudd_W",
"internlast_W",
"romoppvarming_W",
"ventilasjonsvarme_W",
"varmtvann_W",
"romkjoling_W",
"ventilasjonskjoling_W",
"vifter_W",
"pumper_W",
"belysning_W",
"utstyr_W",
"solceller_W",
])?;
let sol = r.solceller.as_ref().map(|p| p.serie.as_slice());
let ts = &r.timeserie;
for t in 0..crate::TIMER {
let f = |v: f64| format!("{v:.1}");
w.write_record([
t.to_string(),
crate::MAANEDER[crate::maaned_for_time(t)].to_string(),
format!("{:.2}", ts.t_ute[t]),
format!("{:.2}", ts.t_inne[t]),
format!("{:.2}", ts.t_operativ[t]),
f(ts.sol_horisontal[t]),
f(ts.soltilskudd[t]),
f(ts.internlast[t]),
f(ts.romoppvarming[t]),
f(ts.ventilasjonsvarme[t]),
f(ts.varmtvann[t]),
f(ts.romkjoling[t]),
f(ts.ventilasjonskjoling[t]),
f(ts.vifter[t]),
f(ts.pumper[t]),
f(ts.belysning[t]),
f(ts.utstyr[t]),
f(sol.map(|s| s[t]).unwrap_or(0.0)),
])?;
}
w.flush()?;
Ok(())
}
pub fn maanedsverdier(r: &Resultat, sti: &Path) -> Result<()> {
let m = r.sammendrag().maanedsverdier;
let mut w = csv::Writer::from_path(sti)?;
w.write_record([
"maaned",
"utetemperatur_C",
"romoppvarming_kWh",
"ventilasjonsvarme_kWh",
"varmtvann_kWh",
"kjoling_kWh",
"vifter_pumper_kWh",
"belysning_kWh",
"utstyr_kWh",
"sum_kWh",
])?;
for i in 0..12 {
let sum = m.romoppvarming[i]
+ m.ventilasjonsvarme[i]
+ m.varmtvann[i]
+ m.kjoling[i]
+ m.vifter_pumper[i]
+ m.belysning[i]
+ m.utstyr[i];
w.write_record([
m.maaned[i].to_string(),
format!("{:.1}", m.utetemperatur[i]),
format!("{:.0}", m.romoppvarming[i]),
format!("{:.0}", m.ventilasjonsvarme[i]),
format!("{:.0}", m.varmtvann[i]),
format!("{:.0}", m.kjoling[i]),
format!("{:.0}", m.vifter_pumper[i]),
format!("{:.0}", m.belysning[i]),
format!("{:.0}", m.utstyr[i]),
format!("{sum:.0}"),
])?;
}
w.flush()?;
Ok(())
}
+282
View File
@@ -0,0 +1,282 @@
//! Eksport til XML-formatet energimerkesystemet (EMS) tar imot for NS 3031:2014-beregninger.
//!
//! Feltnavnene følger Enovas eksempelfil i veiledningen for opplasting av flere filer.
//! Formatet er knyttet til NS 3031:2014; Enova tar i tillegg imot JSON etter NS 3031:2025,
//! men den spesifikasjonen er bare tilgjengelig bak ekspertinnlogging. Bruk derfor denne
//! eksporten bevisst: tallene er beregnet etter NS 3031:2025 og skrives inn i 2014-strukturen.
use crate::kjor::Resultat;
use crate::modell::{FlateType, VentilasjonType};
use crate::systemer::Energibaerer;
use crate::Result;
use std::fmt::Write;
use std::path::Path;
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn areal(r: &Resultat, f: impl Fn(&crate::modell::lost::LostFlate) -> bool) -> f64 {
r.lost.flater.iter().filter(|x| f(x)).map(|x| x.areal).sum()
}
fn u_snitt(r: &Resultat, f: impl Fn(&crate::modell::lost::LostFlate) -> bool + Copy) -> f64 {
let a = areal(r, f);
if a <= 0.0 {
return 0.0;
}
r.lost
.flater
.iter()
.filter(|x| f(x))
.map(|x| x.areal * x.u_verdi)
.sum::<f64>()
/ a
}
pub fn skriv(r: &Resultat, sti: &Path) -> Result<()> {
std::fs::write(sti, kilde(r))?;
Ok(())
}
pub fn kilde(r: &Resultat) -> String {
let l = &r.lost;
let b = &r.bygning.bygning;
let ts = &r.timeserie;
let kwh = crate::motor::Timeserie::sum_kwh;
let mut s = String::new();
let _ = writeln!(s, r#"<?xml version="1.0" encoding="UTF-8"?>"#);
let _ = writeln!(
s,
r#"<Enheter xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">"#
);
let _ = writeln!(s, " <Enhet>");
// ── adresse ────────────────────────────────────────────────────
let _ = writeln!(
s,
" <Adresser>\n <BygningsAdresse>\n <AdresseInfo>"
);
if let Some(a) = &b.adresse {
let _ = writeln!(s, " <Gateadresse>{}</Gateadresse>", esc(a));
}
let _ = writeln!(
s,
" <BygningsbeskrivelseFritekst>{}</BygningsbeskrivelseFritekst>",
esc(&b.navn)
);
let _ = writeln!(
s,
" </AdresseInfo>\n </BygningsAdresse>\n </Adresser>"
);
// ── inngangsverdier ────────────────────────────────────────────
let _ = writeln!(s, " <Inngangsverdi>");
let _ = writeln!(
s,
" <Bygningskategori>{}</Bygningskategori>",
esc(&r.karakter.kategorinavn.to_uppercase())
);
let _ = writeln!(s, " <Byggeaar>{}</Byggeaar>", b.byggeaar);
let vegger = areal(r, |f| !f.glass && f.typ == FlateType::Vegg);
let tak = areal(r, |f| !f.glass && f.typ == FlateType::Tak);
let gulv = areal(r, |f| !f.glass && f.typ == FlateType::Gulv);
let glass = areal(r, |f| f.glass);
let _ = writeln!(s, " <M2Yttervegger>{vegger:.0}</M2Yttervegger>");
let _ = writeln!(s, " <M2Tak>{tak:.0}</M2Tak>");
let _ = writeln!(s, " <M2Gulv>{gulv:.0}</M2Gulv>");
let _ = writeln!(s, " <M2VinduDorGlass>{glass:.0}</M2VinduDorGlass>");
let _ = writeln!(s, " <BRAOppvarmet>{:.0}</BRAOppvarmet>", l.bra);
let _ = writeln!(s, " <BRA>{:.0}</BRA>", l.bra);
let _ = writeln!(s, " <M3OppvarmetLuft>{:.0}</M3OppvarmetLuft>", l.volum);
let _ = writeln!(
s,
" <UYttervegger>{:.2}</UYttervegger>",
u_snitt(r, |f| !f.glass && f.typ == FlateType::Vegg)
);
let _ = writeln!(
s,
" <UTak>{:.2}</UTak>",
u_snitt(r, |f| !f.glass && f.typ == FlateType::Tak)
);
let _ = writeln!(
s,
" <UGulv>{:.2}</UGulv>",
u_snitt(r, |f| !f.glass && f.typ == FlateType::Gulv)
);
let _ = writeln!(
s,
" <UVinduer>{:.2}</UVinduer>",
u_snitt(r, |f| f.glass)
);
let _ = writeln!(
s,
" <ArealAndelVinduDorGlass>{:.1}</ArealAndelVinduDorGlass>",
glass / l.bra * 100.0
);
let _ = writeln!(
s,
" <NormalisertKuldebro>{:.2}</NormalisertKuldebro>",
l.kuldebro_w_per_k / l.bra
);
let _ = writeln!(s, " <Lekkasjetall>{:.2}</Lekkasjetall>", l.n50);
let _ = writeln!(
s,
" <TempVirkningsgradVarmegjenvinner>{:.2}</TempVirkningsgradVarmegjenvinner>",
l.ventilasjon.gjenvinning * 100.0
);
let _ = writeln!(
s,
" <VifteeffektLuftmengderIDriftstiden>{:.2}</VifteeffektLuftmengderIDriftstiden>",
l.ventilasjon.sfp
);
let _ = writeln!(
s,
" <VentilasjonsluftmengdeIDriftstidenGjSn>{:.2}</VentilasjonsluftmengdeIDriftstidenGjSn>",
l.ventilasjon.luftmengde
);
let _ = writeln!(
s,
" <SettpunkttemperaturOppvarming>{:.1}</SettpunkttemperaturOppvarming>",
l.settpunkt_oppvarming
);
let _ = writeln!(
s,
" <SettpunkttemperaturKjoling>{:.1}</SettpunkttemperaturKjoling>",
l.settpunkt_kjoling.unwrap_or(0.0)
);
let _ = writeln!(s, " <Driftstider>");
let d = &l.laster.oppvarming_driftstid;
let _ = writeln!(
s,
" <Ventilasjon>{:.0}</Ventilasjon>",
l.ventilasjon.driftstid.timer()
);
let _ = writeln!(s, " <Oppvarming>{:.0}</Oppvarming>", d.timer());
let _ = writeln!(s, " </Driftstider>");
let _ = writeln!(
s,
" <Ventilasjonstype>{}</Ventilasjonstype>",
match l.ventilasjon.typ {
VentilasjonType::Balansert => "BALANSERT",
VentilasjonType::Avtrekk => "AVTREKK",
VentilasjonType::Naturlig => "NATURLIG",
}
);
let kilder: Vec<String> = r
.systemer
.kilder
.iter()
.filter(|k| k.andel_rom > 1e-6)
.map(|k| k.beskrivelse.clone())
.collect();
let _ = writeln!(
s,
" <Oppvarmingssystem>{}</Oppvarmingssystem>",
esc(&kilder.join("; "))
);
let _ = writeln!(
s,
" <KlimastasjonKilde>{} (TMY CERRA 1991-2020)</KlimastasjonKilde>",
esc(&r.klimanavn)
);
let _ = writeln!(s, " <Programvare>");
let _ = writeln!(s, " <Applikasjon>enok</Applikasjon>");
let _ = writeln!(
s,
" <Versjon>{}</Versjon>",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
s,
" <FriBeskrivelse>Dynamisk timesberegning etter NS 3031:2025 / NS-EN ISO 52016-1</FriBeskrivelse>"
);
let _ = writeln!(s, " </Programvare>");
if let Some(e) = &b.energiraadgiver {
let _ = writeln!(
s,
" <Energiradgiver>\n <Navn>{}</Navn>\n </Energiradgiver>",
esc(e)
);
}
let _ = writeln!(s, " </Inngangsverdi>");
// ── beregningsresultat ─────────────────────────────────────────
let _ = writeln!(s, " <Beregningsresultat>");
let _ = writeln!(s, " <NettoEnergibudsjettPrKvm>");
let p = |navn: &str, v: f64| format!(" <{navn}>{:.1}</{navn}>", v / l.bra);
let _ = writeln!(s, "{}", p("Romoppvarming", kwh(&ts.romoppvarming)));
let _ = writeln!(s, "{}", p("Ventilasjonsvarme", kwh(&ts.ventilasjonsvarme)));
let _ = writeln!(s, "{}", p("Varmtvann", kwh(&ts.varmtvann)));
let _ = writeln!(s, "{}", p("Vifter", kwh(&ts.vifter)));
let _ = writeln!(s, "{}", p("Pumper", kwh(&ts.pumper)));
let _ = writeln!(s, "{}", p("Belysning", kwh(&ts.belysning)));
let _ = writeln!(s, "{}", p("TekniskUtstyr", kwh(&ts.utstyr)));
let _ = writeln!(s, "{}", p("Romkjoling", kwh(&ts.romkjoling)));
let _ = writeln!(
s,
"{}",
p("Ventilasjonskjoling", kwh(&ts.ventilasjonskjoling))
);
let _ = writeln!(
s,
" <TotaltNettoEnergibehov>{:.1}</TotaltNettoEnergibehov>",
r.budsjett.netto_spesifikk(l.bra)
);
let _ = writeln!(s, " </NettoEnergibudsjettPrKvm>");
let _ = writeln!(
s,
" <LevertEnergiSpesifiktNormalisertKlima>{:.2}</LevertEnergiSpesifiktNormalisertKlima>",
r.levert.spesifikk_vektet
);
let _ = writeln!(
s,
" <LevertEnergiNormalisertKlima>{:.0}</LevertEnergiNormalisertKlima>",
r.levert.vektet_totalt
);
let _ = writeln!(s, " <ForventetLevertEnergi>\n <LevertEnergi>");
let hent = |b: Energibaerer| -> f64 {
r.levert
.per_baerer
.iter()
.find(|(x, _)| *x == b)
.map(|(_, v)| *v)
.unwrap_or(0.0)
};
let _ = writeln!(
s,
" <Elektrisitet>{:.0}</Elektrisitet>",
hent(Energibaerer::Elektrisitet)
);
let _ = writeln!(s, " <Olje>{:.0}</Olje>", hent(Energibaerer::Olje));
let _ = writeln!(s, " <Gass>{:.0}</Gass>", hent(Energibaerer::Gass));
let _ = writeln!(
s,
" <Fjernvarme>{:.0}</Fjernvarme>",
hent(Energibaerer::Fjernvarme)
);
let bio = hent(Energibaerer::BiobrenselLukket) + hent(Energibaerer::BiobrenselAapent);
let _ = writeln!(s, " <Biobrensel>{bio:.0}</Biobrensel>");
let _ = writeln!(
s,
" <AnnenEnergivare>{:.0}</AnnenEnergivare>",
hent(Energibaerer::Annet)
);
let _ = writeln!(
s,
" <Totalt>{:.0}</Totalt>",
r.levert.levert_totalt
);
let _ = writeln!(s, " </LevertEnergi>\n </ForventetLevertEnergi>");
let _ = writeln!(
s,
" <Energikarakter>{}</Energikarakter>",
r.karakter.bokstav
);
let _ = writeln!(s, " </Beregningsresultat>");
let _ = writeln!(s, " </Enhet>\n</Enheter>");
s
}
+15
View File
@@ -0,0 +1,15 @@
//! JSON-utskrift av beregningsresultatet.
use crate::kjor::Resultat;
use crate::Result;
use std::path::Path;
pub fn skriv(r: &Resultat, sti: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(&r.sammendrag())?;
std::fs::write(sti, json)?;
Ok(())
}
pub fn til_streng(r: &Resultat) -> Result<String> {
Ok(serde_json::to_string_pretty(&r.sammendrag())?)
}
+7
View File
@@ -0,0 +1,7 @@
//! Rapportering: CSV-timeserier, JSON-resultat, Typst-rapport med PDF, og Enova-XML.
pub mod csv_ut;
pub mod enova_xml;
pub mod json_ut;
pub mod svg;
pub mod typst_ut;
+260
View File
@@ -0,0 +1,260 @@
//! Enkle SVG-figurer som bygges inn i Typst-rapporten. Diagrammene tegnes direkte som SVG
//! slik at rapporten ikke er avhengig av eksterne Typst-pakker.
use crate::kjor::Resultat;
const BREDDE: f64 = 760.0;
const HOYDE: f64 = 300.0;
const VENSTRE: f64 = 58.0;
const HOYRE: f64 = 16.0;
const TOPP: f64 = 16.0;
const BUNN: f64 = 42.0;
/// Fargene i figurene. Holdt mørke nok til å leses i svart-hvitt-utskrift.
const FARGER: [&str; 7] = [
"#8c2f1f", // romoppvarming
"#c2703d", // ventilasjonsvarme
"#3f6f8f", // varmtvann
"#6f9ec4", // vifter og pumper
"#d8b44a", // belysning
"#9a9a4a", // utstyr
"#4f8f8f", // kjøling
];
const NAVN: [&str; 7] = [
"Romoppvarming",
"Ventilasjonsvarme",
"Varmtvann",
"Vifter og pumper",
"Belysning",
"Teknisk utstyr",
"Kjøling",
];
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn pent_maks(v: f64) -> f64 {
if v <= 0.0 {
return 1.0;
}
let eksponent = v.log10().floor();
let skala = 10f64.powf(eksponent);
let normalisert = v / skala;
let trinn = [1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.5, 10.0];
let valgt = trinn
.iter()
.find(|t| **t >= normalisert)
.copied()
.unwrap_or(10.0);
valgt * skala
}
/// Stablet søylediagram over månedlig energibruk, kWh.
pub fn maanedsdiagram(r: &Resultat) -> String {
let m = r.sammendrag().maanedsverdier;
let serier = [
m.romoppvarming,
m.ventilasjonsvarme,
m.varmtvann,
m.vifter_pumper,
m.belysning,
m.utstyr,
m.kjoling,
];
let mut totaler = [0.0f64; 12];
for s in &serier {
for i in 0..12 {
totaler[i] += s[i];
}
}
let maks = pent_maks(totaler.iter().cloned().fold(0.0, f64::max));
let plot_b = BREDDE - VENSTRE - HOYRE;
let plot_h = HOYDE - TOPP - BUNN;
let sb = plot_b / 12.0;
let bredde = sb * 0.68;
let mut s = String::new();
s.push_str(&format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {BREDDE} {HOYDE}" font-family="sans-serif" font-size="10">"##
));
// Rutenett og akse.
for i in 0..=4 {
let v = maks * i as f64 / 4.0;
let y = TOPP + plot_h - plot_h * i as f64 / 4.0;
s.push_str(&format!(
r##"<line x1="{VENSTRE}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="#d8d8d8" stroke-width="0.7"/>"##,
BREDDE - HOYRE
));
s.push_str(&format!(
r##"<text x="{:.1}" y="{:.1}" text-anchor="end" fill="#555">{v:.0}</text>"##,
VENSTRE - 6.0,
y + 3.5
));
}
s.push_str(&format!(
r##"<text x="4" y="12" fill="#555">kWh</text><line x1="{VENSTRE}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="#444" stroke-width="1"/>"##,
TOPP + plot_h,
BREDDE - HOYRE,
TOPP + plot_h
));
// Søyler.
for i in 0..12 {
let x = VENSTRE + sb * i as f64 + (sb - bredde) / 2.0;
let mut y = TOPP + plot_h;
for (k, serie) in serier.iter().enumerate() {
let h = serie[i] / maks * plot_h;
if h <= 0.05 {
continue;
}
y -= h;
s.push_str(&format!(
r##"<rect x="{x:.1}" y="{y:.1}" width="{bredde:.1}" height="{h:.1}" fill="{}"/>"##,
FARGER[k]
));
}
s.push_str(&format!(
r##"<text x="{:.1}" y="{:.1}" text-anchor="middle" fill="#444">{}</text>"##,
VENSTRE + sb * i as f64 + sb / 2.0,
TOPP + plot_h + 14.0,
m.maaned[i]
));
}
// Tegnforklaring.
let mut x = VENSTRE;
let y = HOYDE - 12.0;
for (k, navn) in NAVN.iter().enumerate() {
if serier[k].iter().sum::<f64>() <= 0.5 {
continue;
}
s.push_str(&format!(
r##"<rect x="{x:.1}" y="{:.1}" width="9" height="9" fill="{}"/><text x="{:.1}" y="{:.1}" fill="#333">{}</text>"##,
y - 8.0,
FARGER[k],
x + 13.0,
y,
esc(navn)
));
x += 16.0 + navn.chars().count() as f64 * 5.3;
}
s.push_str("</svg>");
s
}
/// Varighetskurve for romoppvarming: effekt sortert fallende over årets timer.
pub fn varighetskurve(r: &Resultat) -> String {
let mut v: Vec<f64> = r
.timeserie
.romoppvarming
.iter()
.zip(&r.timeserie.ventilasjonsvarme)
.map(|(a, b)| (a + b) / 1000.0)
.collect();
v.sort_by(|a, b| b.partial_cmp(a).unwrap());
let maks = pent_maks(v.first().copied().unwrap_or(1.0));
let plot_b = BREDDE - VENSTRE - HOYRE;
let plot_h = HOYDE - TOPP - BUNN;
let mut s = String::new();
s.push_str(&format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {BREDDE} {HOYDE}" font-family="sans-serif" font-size="10">"##
));
for i in 0..=4 {
let val = maks * i as f64 / 4.0;
let y = TOPP + plot_h - plot_h * i as f64 / 4.0;
s.push_str(&format!(
r##"<line x1="{VENSTRE}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="#d8d8d8" stroke-width="0.7"/><text x="{:.1}" y="{:.1}" text-anchor="end" fill="#555">{val:.1}</text>"##,
BREDDE - HOYRE,
VENSTRE - 6.0,
y + 3.5
));
}
s.push_str(r##"<text x="4" y="12" fill="#555">kW</text>"##);
let mut d = String::from("M");
for (i, verdi) in v.iter().enumerate().step_by(4) {
let x = VENSTRE + plot_b * i as f64 / v.len() as f64;
let y = TOPP + plot_h - verdi / maks * plot_h;
d.push_str(&format!(" {x:.1},{y:.1}"));
}
s.push_str(&format!(
r##"<path d="{d}" fill="none" stroke="{}" stroke-width="1.6"/>"##,
FARGER[0]
));
for i in 0..=4 {
let timer = 8760 * i / 4;
let x = VENSTRE + plot_b * i as f64 / 4.0;
s.push_str(&format!(
r##"<text x="{x:.1}" y="{:.1}" text-anchor="middle" fill="#444">{timer}</text>"##,
TOPP + plot_h + 14.0
));
}
s.push_str(&format!(
r##"<line x1="{VENSTRE}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="#444" stroke-width="1"/><text x="{:.1}" y="{:.1}" text-anchor="middle" fill="#555">timer per år</text></svg>"##,
TOPP + plot_h,
BREDDE - HOYRE,
TOPP + plot_h,
VENSTRE + plot_b / 2.0,
HOYDE - 10.0
));
s
}
/// Karakterskalaen AG med bygningens plassering markert.
pub fn karakterstige(r: &Resultat) -> String {
let k = &r.karakter;
let farger = [
"#1d7a35", "#4aab3a", "#a8c93a", "#f2d21e", "#f2a01e", "#e0661e", "#cf3030",
];
let rad_h = 30.0;
let h = rad_h * 7.0 + 30.0;
let b = 470.0;
let mut s = String::new();
s.push_str(&format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {b} {h}" font-family="sans-serif" font-size="11">"##
));
s.push_str(&format!(
r##"<text x="0" y="12" fill="#555">Spesifikk vektet levert energi, kWh/(m²·år) — {}</text>"##,
esc(&k.kategorinavn)
));
for (i, bokstav) in crate::merking::skala::BOKSTAVER.iter().enumerate() {
let y = 24.0 + rad_h * i as f64;
let bredde = 120.0 + 28.0 * i as f64;
let valgt = *bokstav == k.bokstav;
s.push_str(&format!(
r##"<rect x="0" y="{y:.1}" width="{bredde:.1}" height="{:.1}" rx="3" fill="{}"/>"##,
rad_h - 5.0,
farger[i]
));
s.push_str(&format!(
r##"<text x="12" y="{:.1}" font-size="15" font-weight="bold" fill="#ffffff">{bokstav}</text>"##,
y + 17.0
));
let tekst = if i < 6 {
format!("{:.0}", k.grenser[i])
} else {
format!("over {:.0}", k.grenser[5])
};
s.push_str(&format!(
r##"<text x="36" y="{:.1}" fill="#ffffff">{tekst}</text>"##,
y + 16.0
));
if valgt {
s.push_str(&format!(
r##"<rect x="-2.5" y="{:.1}" width="{:.1}" height="{:.1}" rx="4" fill="none" stroke="#111" stroke-width="2"/>"##,
y - 2.5,
bredde + 5.0,
rad_h
));
s.push_str(&format!(
r##"<text x="{:.1}" y="{:.1}" font-size="13" font-weight="bold" fill="#111">◀ {:.0} kWh/(m²·år)</text>"##,
bredde + 14.0,
y + 17.0,
k.spesifikk_vektet
));
}
}
s.push_str("</svg>");
s
}
+543
View File
@@ -0,0 +1,543 @@
//! Genererer energirapporten som Typst-kilde og kompilerer den til PDF.
use crate::kjor::Resultat;
use crate::merking::skala::BOKSTAVER;
use crate::modell::{FlateType, Mot};
use crate::rapport::svg;
use crate::Result;
use anyhow::{bail, Context};
use std::path::Path;
const STIL: &str = include_str!("../../typst/stil.typ");
const KARAKTERFARGER: [&str; 7] = [
"#1d7a35", "#4aab3a", "#a8c93a", "#f2d21e", "#f2a01e", "#e0661e", "#cf3030",
];
/// Escaper tekst slik at den kan settes inn i Typst-markup.
fn e(s: &str) -> String {
let mut ut = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@' | '[' | ']' | '"' => {
ut.push('\\');
ut.push(c);
}
_ => ut.push(c),
}
}
ut
}
fn tall(v: f64, desimaler: usize) -> String {
// Norsk desimalkomma og tusenskille med tynt mellomrom.
let s = format!("{v:.desimaler$}");
let (heltall, rest) = match s.split_once('.') {
Some((a, b)) => (a.to_string(), Some(b.to_string())),
None => (s.clone(), None),
};
let negativ = heltall.starts_with('-');
let siffer: Vec<char> = heltall.trim_start_matches('-').chars().collect();
let mut gruppert = String::new();
for (i, c) in siffer.iter().enumerate() {
if i > 0 && (siffer.len() - i).is_multiple_of(3) {
gruppert.push('\u{202f}');
}
gruppert.push(*c);
}
let mut ut = String::new();
if negativ {
ut.push('');
}
ut.push_str(&gruppert);
if let Some(r) = rest {
ut.push(',');
ut.push_str(&r);
}
ut
}
fn rad(celler: &[String]) -> String {
let inner: Vec<String> = celler.iter().map(|c| format!("[{c}]")).collect();
format!("({}),", inner.join(", "))
}
/// Skriver `rapport.typ`, `stil.typ` og figurene til katalogen.
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)?;
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))?;
std::fs::write(katalog.join("rapport.typ"), kilde(r, scenarier))?;
Ok(())
}
/// Kompilerer rapporten med `typst`. Returnerer stien til PDF-en.
pub fn kompiler(katalog: &Path) -> Result<std::path::PathBuf> {
let typ = katalog.join("rapport.typ");
let pdf = katalog.join("rapport.pdf");
let ut = std::process::Command::new("typst")
.arg("compile")
.arg(&typ)
.arg(&pdf)
.output()
.context("kunne ikke kjøre `typst`. Installer Typst, eller bruk --ingen-pdf")?;
if !ut.status.success() {
bail!(
"typst klarte ikke å kompilere rapporten:\n{}",
String::from_utf8_lossy(&ut.stderr)
);
}
Ok(pdf)
}
fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
let l = &r.lost;
let k = &r.karakter;
let b = &r.bygning.bygning;
let farge = KARAKTERFARGER[BOKSTAVER.iter().position(|x| *x == k.bokstav).unwrap_or(6)];
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))
));
// ── forside ─────────────────────────────────────────────────────
s.push_str(&format!(
"#v(1.2cm)\n#text(size: 26pt, weight: \"bold\")[Energirapport]\n\n\
#v(-0.3em)\n#text(size: 15pt, fill: mid)[{}]\n\n",
e(&l.navn)
));
if let Some(a) = &b.adresse {
s.push_str(&format!("#text(size: 11pt, fill: mid)[{}]\n\n", e(a)));
}
s.push_str("#v(0.8cm)\n");
s.push_str(&format!(
"#grid(columns: (auto, 1fr), gutter: 18pt,\n karakterrute(\"{}\", \"{}\", rgb(\"{}\")),\n faktaboks[\n",
k.bokstav,
tall(k.spesifikk_vektet, 0),
farge
));
s.push_str(&format!(
" *Energikarakter {}* for {}. \\\n",
k.bokstav,
e(&k.kategorinavn.to_lowercase())
));
s.push_str(
" Karakteren bygger på beregnet klimakorrigert normert energiytelse etter NS 3031:2025, \
oppgitt som vektet levert energi per kvadratmeter oppvarmet BRA. \\\n",
);
if let Some(t) = k.til_neste {
s.push_str(&format!(
" Energiytelsen må ned {} kWh/(m²·år) for å nå neste karakter. \\\n",
tall(t, 0)
));
} else {
s.push_str(" Dette er den beste karakteren på skalaen. \\\n");
}
s.push_str(" ]\n)\n\n#v(0.6cm)\n");
// Nøkkeltall.
let nøkkel = vec![
("Bygningskategori".to_string(), e(&k.kategorinavn)),
("Byggeår".to_string(), l.byggeaar.to_string()),
(
"Oppvarmet BRA".to_string(),
format!("{}", tall(l.bra, 0)),
),
(
"Netto energibehov".to_string(),
format!("{} kWh/(m²·år)", tall(r.budsjett.netto_spesifikk(l.bra), 1)),
),
(
"Levert energi".to_string(),
format!("{} kWh/(m²·år)", tall(r.levert.levert_totalt / l.bra, 1)),
),
(
"Vektet levert energi".to_string(),
format!("{} kWh/(m²·år)", tall(k.spesifikk_vektet, 1)),
),
("Klimadata".to_string(), e(&r.klimanavn)),
(
"Varmetapstall".to_string(),
format!("{} W/(m²K)", tall(r.varmetap.totalt, 2)),
),
];
s.push_str("#tabell(\n kolonner: (1fr, auto),\n justering: (left, right),\n overskrift: (\"Nøkkeltall\", \"\"),\n rader: (\n");
for (a, b2) in &nøkkel {
s.push_str(&format!(" {}\n", rad(&[a.clone(), b2.clone()])));
}
s.push_str(" ),\n)\n\n");
if let Some(kor) = &r.levert.klimakorrigering {
s.push_str(&format!(
"#merknad[Beregningen er gjort med klimadata for {}. De klimaavhengige energipostene er \
korrigert til referanseklima Oslo med klimakorrigeringsfaktor {} etter NS 3031:2025, \
slik energimerkeforskriften krever.]\n\n",
e(&kor.sted),
tall(kor.faktor, 3)
));
}
s.push_str("#pagebreak()\n\n");
// ── energikarakter ──────────────────────────────────────────────
s.push_str("= Energikarakter\n\n");
s.push_str("#image(\"fig-karakter.svg\", width: 100%)\n\n");
s.push_str("Skalaen følger Enovas fastsettelse fra 1. januar 2026, jf. energimerkeforskriften § 10b. \
Nedre grense for karakter A tilsvarer energikravene i TEK17 fratrukket 15 prosent, og et bygg \
som innfrir TEK17 får karakter B. De 15 prosent minst energieffektive bygningene i hver \
kategori får karakter G.\n\n");
if k.grenser[0] != r.karakter.grenser[0] || l.skala_areal != l.bra {
s.push_str(&format!(
"Grensene er arealkorrigert med {} m² oppvarmet BRA, slik at små og store bygninger med \
samme energikvaliteter får samme karakter.\n\n",
tall(l.skala_areal, 0)
));
}
// ── varmetapsbudsjett ───────────────────────────────────────────
let v = &r.varmetap;
s.push_str("= Varmetapsbudsjett\n\n");
s.push_str("#tabell(\n kolonner: (auto, 1fr, auto),\n justering: (center, left, right),\n overskrift: (\"\", \"Varmetapspost\", \"Varmetapstall, W/(m²K)\"),\n sum-rader: (5, 7, 9),\n rader: (\n");
let poster: Vec<(String, String, f64)> = vec![
("1".into(), "Vegger".into(), v.vegger),
("2".into(), "Tak".into(), v.tak),
("3".into(), "Gulv".into(), v.gulv),
("4".into(), "Vinduer og dører".into(), v.vinduer_dorer),
("5".into(), "Kuldebroer".into(), v.kuldebroer),
(
"".into(),
"Varmetapstall, transmisjon".into(),
v.transmisjon,
),
("6".into(), "Infiltrasjon".into(), v.infiltrasjon),
(
"".into(),
"Varmetapstall, klimaskjerm".into(),
v.klimaskjerm,
),
("7".into(), "Ventilasjon".into(), v.ventilasjon),
("".into(), "Varmetapstall, totalt".into(), v.totalt),
];
for (nr, navn, verdi) in &poster {
s.push_str(&format!(
" {}\n",
rad(&[nr.clone(), e(navn), tall(*verdi, 2)])
));
}
s.push_str(" ),\n)\n\n");
s.push_str(&format!(
"Klimaskjermarealet er {} m², som gir formfaktor {} m²/m². Vindus- og dørarealet utgjør \
{} prosent av oppvarmet BRA. Lekkasjetallet er satt til {} luftvekslinger per time ved 50 Pa.\n\n",
tall(l.klimaskjermareal(), 0),
tall(l.formfaktor(), 2),
tall(l.vindusareal() / l.bra * 100.0, 0),
tall(l.n50, 1)
));
// ── energibudsjett ──────────────────────────────────────────────
s.push_str("= Energibudsjett\n\n== Netto energibehov (beregningspunkt A)\n\n");
s.push_str("#tabell(\n kolonner: (auto, 1fr, auto, auto),\n justering: (center, left, right, right),\n overskrift: (\"\", \"Energipost\", \"kWh/år\", \"kWh/(m²·år)\"),\n rader: (\n");
for p in &r.budsjett.poster {
s.push_str(&format!(
" {}\n",
rad(&[
p.nr.to_string(),
e(p.navn),
tall(p.netto, 0),
tall(p.netto / l.bra, 1)
])
));
}
let sumrad = r.budsjett.poster.len();
s.push_str(&format!(
" {}\n ),\n sum-rader: ({sumrad},),\n)\n\n",
rad(&[
"".into(),
"Totalt netto energibehov".into(),
tall(r.budsjett.netto_totalt, 0),
tall(r.budsjett.netto_spesifikk(l.bra), 1)
])
));
s.push_str("== Fra netto til tilført energi (beregningspunkt AC)\n\n");
s.push_str("#tabell(\n kolonner: (auto, 1fr, auto, auto, auto, auto, auto),\n justering: (center, left, right, right, right, right, right),\n overskrift: (\"\", \"Energipost\", \"Netto\", \"Tap\", \"Brutto\", \"SPF\", \"Tilført\"),\n rader: (\n");
for p in &r.budsjett.poster {
s.push_str(&format!(
" {}\n",
rad(&[
p.nr.to_string(),
e(p.navn),
tall(p.netto / l.bra, 1),
tall(p.tap / l.bra, 1),
tall(p.brutto / l.bra, 1),
if p.spf > 0.0 {
tall(p.spf, 2)
} else {
"".into()
},
tall(p.tilfort / l.bra, 1),
])
));
}
s.push_str(&format!(
" {}\n ),\n sum-rader: ({sumrad},),\n)\n\n",
rad(&[
"".into(),
"Sum".into(),
tall(r.budsjett.netto_totalt / l.bra, 1),
tall(
(r.budsjett.brutto_totalt - r.budsjett.netto_totalt) / l.bra,
1
),
tall(r.budsjett.brutto_totalt / l.bra, 1),
"".into(),
tall(r.budsjett.tilfort_totalt / l.bra, 1),
])
));
s.push_str("Alle tall i kWh per kvadratmeter oppvarmet BRA og år. SPF er systemets sesongjusterte ytelsesfaktor: forholdet mellom avgitt energi og tilført energi.\n\n");
// ── levert og vektet ────────────────────────────────────────────
s.push_str("== Levert og vektet energi (beregningspunkt D og E)\n\n");
s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto),\n justering: (left, right, right, right),\n overskrift: (\"Energibærer\", \"kWh/år\", \"Vektingsfaktor\", \"Vektet, kWh/(m²·år)\"),\n rader: (\n");
for (baerer, levert, vektet) in &r.levert.vektet {
let faktor = if *levert > 1e-9 { vektet / levert } else { 0.0 };
s.push_str(&format!(
" {}\n",
rad(&[
e(baerer.navn()),
tall(*levert, 0),
tall(faktor, 2),
tall(vektet / l.bra, 1)
])
));
}
let mut n_rader = r.levert.vektet.len();
if r.levert.eksportert > 0.1 {
s.push_str(&format!(
" {}\n",
rad(&[
"Egenprodusert el til eksport".into(),
format!("{}", tall(r.levert.eksportert, 0)),
"0,00".into(),
"0,0".into()
])
));
n_rader += 1;
}
s.push_str(&format!(
" {}\n ),\n sum-rader: ({n_rader},),\n)\n\n",
rad(&[
"Totalt vektet levert energi".into(),
tall(r.levert.levert_totalt, 0),
"".into(),
tall(r.levert.spesifikk_vektet, 1)
])
));
if let Some(p) = &r.solceller {
s.push_str(&format!(
"Solcelleanlegget produserer {} kWh i året. Av dette dekker {} kWh byggets eget forbruk time \
for time, mens {} kWh eksporteres. Eksportert energi har vektingsfaktor 0 og teller derfor \
ikke med i energikarakteren.\n\n",
tall(p.produsert_kwh, 0),
tall(p.egenbruk_kwh, 0),
tall(p.eksportert_kwh, 0)
));
}
// ── figurer ─────────────────────────────────────────────────────
s.push_str("= Energibruk gjennom året\n\n#image(\"fig-maaned.svg\", width: 100%)\n\n");
s.push_str(&format!(
"Figuren viser netto energibehov per måned. Årsmiddeltemperaturen i klimadataene er {} °C, \
og det er {} graddager under 17 °C.\n\n",
tall(r.vaerdata.aarsmiddeltemperatur(), 1),
tall(r.vaerdata.graddager(17.0), 0)
));
s.push_str("#image(\"fig-varighet.svg\", width: 100%)\n\n");
s.push_str("Varighetskurven viser romoppvarming og ventilasjonsvarme sortert fallende over årets timer.\n\n");
let ef = &r.effektbehov;
s.push_str("== Effektbehov\n\n");
s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto),\n justering: (left, right, right, left),\n overskrift: (\"Effektpost\", \"kW\", \"W/m²\", \"Tidspunkt\"),\n rader: (\n");
s.push_str(&format!(
" {}\n {}\n {}\n ),\n sum-rader: (2,),\n)\n\n",
rad(&[
"Maksimalt samtidig brutto termisk effektbehov".into(),
tall(ef.termisk_w / 1000.0, 1),
tall(ef.termisk_w / l.bra, 1),
e(&ef.beskriv_termisk())
]),
rad(&[
"Maksimalt samtidig el-spesifikt effektbehov".into(),
tall(ef.elspesifikk_w / 1000.0, 1),
tall(ef.elspesifikk_w / l.bra, 1),
"".into()
]),
rad(&[
"Maksimalt samtidig brutto effektbehov".into(),
tall(ef.samlet_w / 1000.0, 1),
tall(ef.samlet_w / l.bra, 1),
e(&ef.beskriv_samlet())
])
));
// ── inndata ─────────────────────────────────────────────────────
s.push_str("#pagebreak()\n\n= Inndata\n\n== Bygningskropp\n\n");
s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto, auto, auto),\n justering: (left, left, right, right, right, left),\n overskrift: (\"Bygningsdel\", \"Type\", \"Areal, m²\", \"U-verdi\", \"Orientering\", \"Mot\"),\n rader: (\n");
for f in &l.flater {
let typ = if f.glass {
"Vindu/dør"
} else {
match f.typ {
FlateType::Vegg => "Vegg",
FlateType::Tak => "Tak",
FlateType::Gulv => "Gulv",
}
};
let mot = match f.mot {
Mot::Ute => "det fri",
Mot::Grunn => "grunnen",
Mot::Uoppvarmet => "uoppvarmet",
};
s.push_str(&format!(
" {}\n",
rad(&[
e(&f.navn),
typ.into(),
tall(f.areal, 1),
tall(f.u_verdi, 2),
himmelretning(f.orientering, f.helning),
mot.into()
])
));
}
s.push_str(" ),\n)\n\n");
s.push_str("== Varmeforsyning\n\n");
s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto, auto),\n justering: (left, right, right, right, left),\n overskrift: (\"Varmekilde\", \"Romvarme\", \"Vent.varme\", \"Tappevann\", \"SPF\"),\n rader: (\n");
for kilde in &r.systemer.kilder {
if kilde.andel_rom + kilde.andel_ventilasjon + kilde.andel_tappevann < 1e-6 {
continue;
}
s.push_str(&format!(
" {}\n",
rad(&[
e(&kilde.beskrivelse),
format!("{} %", tall(kilde.andel_rom * 100.0, 0)),
format!("{} %", tall(kilde.andel_ventilasjon * 100.0, 0)),
format!("{} %", tall(kilde.andel_tappevann * 100.0, 0)),
format!("{} ({})", tall(kilde.spf, 2), e(&kilde.spf_kilde)),
])
));
}
s.push_str(" ),\n)\n\n");
// ── forutsetninger ──────────────────────────────────────────────
s.push_str("= Forutsetninger og normerte verdier\n\n");
s.push_str(
"Energimerkeberegningen bruker normerte inndata for drift og interne laster, slik at bygninger \
kan sammenlignes på egenskaper og ikke på hvordan de brukes. Beregnet levert energi vil derfor \
normalt avvike fra målt energiforbruk.\n\n",
);
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");
for f in &l.forutsetninger {
s.push_str(&format!(
" {}\n",
rad(&[e(&f.felt), e(&f.verdi), e(&f.kilde)])
));
}
s.push_str(" ),\n)\n\n");
}
for m in &r.merknader {
s.push_str(&format!("#merknad[{}]\n\n", e(m)));
}
// ── scenarier ───────────────────────────────────────────────────
if !scenarier.is_empty() {
s.push_str("#pagebreak()\n\n= Tiltak\n\n");
s.push_str("#tabell(\n kolonner: (1fr, auto, auto, auto, auto),\n justering: (left, right, right, center, right),\n overskrift: (\"Alternativ\", \"Netto, kWh/(m²·år)\", \"Vektet levert, kWh/(m²·år)\", \"Karakter\", \"Endring\"),\n rader: (\n");
s.push_str(&format!(
" {}\n",
rad(&[
"Dagens situasjon".into(),
tall(r.budsjett.netto_spesifikk(l.bra), 1),
tall(r.levert.spesifikk_vektet, 1),
r.karakter.bokstav.into(),
"".into()
])
));
for (navn, sr) in scenarier {
let endring = sr.levert.spesifikk_vektet - r.levert.spesifikk_vektet;
let prosent = endring / r.levert.spesifikk_vektet * 100.0;
s.push_str(&format!(
" {}\n",
rad(&[
e(navn),
tall(sr.budsjett.netto_spesifikk(sr.lost.bra), 1),
tall(sr.levert.spesifikk_vektet, 1),
sr.karakter.bokstav.into(),
format!("{} %", tall(prosent, 1))
])
));
}
s.push_str(" ),\n)\n\n");
for (navn, sr) in scenarier {
if let Some(besk) = r
.bygning
.scenario
.iter()
.find(|x| &x.navn == navn)
.and_then(|x| x.beskrivelse.as_ref())
{
s.push_str(&format!("*{}.* {}\n\n", e(navn), e(besk)));
}
let _ = sr;
}
}
s.push_str(&format!(
"\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",
env!("CARGO_PKG_VERSION")
));
s
}
fn bunntekst(r: &Resultat) -> String {
let b = &r.bygning.bygning;
let mut deler = Vec::new();
if let Some(a) = &b.adresse {
deler.push(a.clone());
}
if let Some(g) = &b.gnr_bnr {
deler.push(g.clone());
}
if let Some(e) = &b.energiraadgiver {
deler.push(e.clone());
}
deler.join(" · ")
}
fn himmelretning(orientering: f64, helning: f64) -> String {
if helning < 20.0 {
return "horisontal".into();
}
if helning > 160.0 {
return "mot grunn".into();
}
let retninger = [
"nord", "nordøst", "øst", "sørøst", "sør", "sørvest", "vest", "nordvest",
];
let i = (((orientering.rem_euclid(360.0)) + 22.5) / 45.0).floor() as usize % 8;
retninger[i].into()
}