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:
@@ -0,0 +1,136 @@
|
||||
//! Leser EnergyPlus Weather (EPW) og gir 8760 timesverdier.
|
||||
//!
|
||||
//! Feltnumre følger EPW-spesifikasjonen (0-basert etter de åtte headerlinjene):
|
||||
//! 6 tørrkuletemperatur °C, 7 duggpunkt °C, 8 relativ fuktighet %, 9 lufttrykk Pa,
|
||||
//! 12 horisontal infrarød stråling W/m², 13 global horisontal W/m², 14 direkte normal W/m²,
|
||||
//! 15 diffus horisontal W/m², 20 vindretning °, 21 vindhastighet m/s.
|
||||
|
||||
use crate::{Result, TIMER};
|
||||
use anyhow::{bail, Context};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Vaerdata {
|
||||
pub sted: String,
|
||||
pub land: String,
|
||||
pub bredde: f64,
|
||||
pub lengde: f64,
|
||||
pub tidssone: f64,
|
||||
pub hoyde: f64,
|
||||
/// Utetemperatur, °C.
|
||||
pub t_ute: Vec<f64>,
|
||||
/// Relativ fuktighet, %.
|
||||
pub rf: Vec<f64>,
|
||||
/// Global horisontal solstråling, W/m².
|
||||
pub ghi: Vec<f64>,
|
||||
/// Direkte normal solstråling, W/m².
|
||||
pub dni: Vec<f64>,
|
||||
/// Diffus horisontal solstråling, W/m².
|
||||
pub dhi: Vec<f64>,
|
||||
/// Horisontal infrarød stråling fra himmelen, W/m².
|
||||
pub ir_himmel: Vec<f64>,
|
||||
/// Vindhastighet, m/s.
|
||||
pub vind: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Vaerdata {
|
||||
pub fn fra_fil(sti: &Path) -> Result<Self> {
|
||||
let innhold = std::fs::read(sti).with_context(|| format!("leser {}", sti.display()))?;
|
||||
let tekst = String::from_utf8_lossy(&innhold);
|
||||
Self::fra_tekst(&tekst).with_context(|| format!("tolker {}", sti.display()))
|
||||
}
|
||||
|
||||
pub fn fra_tekst(tekst: &str) -> Result<Self> {
|
||||
let mut linjer = tekst.lines();
|
||||
let location = linjer.next().context("EPW mangler LOCATION-linje")?;
|
||||
let f: Vec<&str> = location.split(',').collect();
|
||||
if f.len() < 10 || !f[0].eq_ignore_ascii_case("LOCATION") {
|
||||
bail!("første linje er ikke en gyldig LOCATION-linje");
|
||||
}
|
||||
let tall =
|
||||
|i: usize| -> f64 { f.get(i).and_then(|s| s.trim().parse().ok()).unwrap_or(0.0) };
|
||||
let mut v = Vaerdata {
|
||||
sted: f[1].trim().to_string(),
|
||||
land: f.get(3).unwrap_or(&"").trim().to_string(),
|
||||
bredde: tall(6),
|
||||
lengde: tall(7),
|
||||
tidssone: tall(8),
|
||||
hoyde: tall(9),
|
||||
t_ute: Vec::with_capacity(TIMER),
|
||||
rf: Vec::with_capacity(TIMER),
|
||||
ghi: Vec::with_capacity(TIMER),
|
||||
dni: Vec::with_capacity(TIMER),
|
||||
dhi: Vec::with_capacity(TIMER),
|
||||
ir_himmel: Vec::with_capacity(TIMER),
|
||||
vind: Vec::with_capacity(TIMER),
|
||||
};
|
||||
// Sju gjenstående headerlinjer.
|
||||
for _ in 0..7 {
|
||||
linjer.next();
|
||||
}
|
||||
for linje in linjer {
|
||||
if linje.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let d: Vec<&str> = linje.split(',').collect();
|
||||
if d.len() < 22 {
|
||||
continue;
|
||||
}
|
||||
let g = |i: usize| -> f64 { d[i].trim().parse::<f64>().unwrap_or(0.0) };
|
||||
// EPW bruker 9999/999999 som «mangler».
|
||||
let mangler = |x: f64, grense: f64| if x >= grense { 0.0 } else { x };
|
||||
v.t_ute.push(g(6));
|
||||
v.rf.push(g(8));
|
||||
v.ir_himmel.push(mangler(g(12), 9999.0));
|
||||
v.ghi.push(mangler(g(13), 9999.0).max(0.0));
|
||||
v.dni.push(mangler(g(14), 9999.0).max(0.0));
|
||||
v.dhi.push(mangler(g(15), 9999.0).max(0.0));
|
||||
v.vind.push(mangler(g(21), 999.0));
|
||||
if v.t_ute.len() == TIMER {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if v.t_ute.len() != TIMER {
|
||||
bail!(
|
||||
"EPW inneholder {} timer, forventet {}",
|
||||
v.t_ute.len(),
|
||||
TIMER
|
||||
);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn aarsmiddeltemperatur(&self) -> f64 {
|
||||
self.t_ute.iter().sum::<f64>() / self.t_ute.len() as f64
|
||||
}
|
||||
|
||||
pub fn maanedsmiddeltemperatur(&self) -> [f64; 12] {
|
||||
let mut sum = [0.0; 12];
|
||||
let mut antall = [0.0; 12];
|
||||
for (t, v) in self.t_ute.iter().enumerate() {
|
||||
let m = crate::maaned_for_time(t);
|
||||
sum[m] += v;
|
||||
antall[m] += 1.0;
|
||||
}
|
||||
let mut ut = [0.0; 12];
|
||||
for m in 0..12 {
|
||||
ut[m] = if antall[m] > 0.0 {
|
||||
sum[m] / antall[m]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
ut
|
||||
}
|
||||
|
||||
/// Graddager under 17 °C, til informasjon i rapporten.
|
||||
pub fn graddager(&self, basis: f64) -> f64 {
|
||||
self.t_ute
|
||||
.chunks(24)
|
||||
.map(|d| {
|
||||
let m = d.iter().sum::<f64>() / d.len() as f64;
|
||||
(basis - m).max(0.0)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Klimakorrigering av beregnet energiytelse til referanseklima (Oslo).
|
||||
//!
|
||||
//! NS 3031:2025 fjernet normert Oslo-klima fra selve beregningen og innførte i stedet
|
||||
//! klimakorrigeringsfaktorer: `EP_NORM = EP_LOK / KF`, der korrigeringen gjelder de
|
||||
//! klimaavhengige energipostene (romoppvarming, ventilasjonsvarme, romkjøling,
|
||||
//! ventilasjonskjøling). Øvrige poster er uendret.
|
||||
|
||||
use crate::data::Klimakorreksjon;
|
||||
use crate::modell::Kategori;
|
||||
|
||||
/// Resultat av klimakorrigering for én bygning.
|
||||
#[derive(serde::Serialize, Clone, Debug)]
|
||||
pub struct Korrigering {
|
||||
pub sted: String,
|
||||
pub faktor: f64,
|
||||
/// Klimaavhengige poster før korrigering, kWh/m².
|
||||
pub klimaavhengig_lokalt: f64,
|
||||
/// Klimaavhengige poster etter korrigering, kWh/m².
|
||||
pub klimaavhengig_normert: f64,
|
||||
}
|
||||
|
||||
impl Korrigering {
|
||||
/// Korrigerer en total energiytelse: trekker ut de klimaavhengige postene, deler på KF,
|
||||
/// og legger dem tilbake.
|
||||
pub fn korriger_total(&self, total_lokalt: f64) -> f64 {
|
||||
total_lokalt - self.klimaavhengig_lokalt + self.klimaavhengig_normert
|
||||
}
|
||||
}
|
||||
|
||||
pub fn korriger(
|
||||
tabell: &Klimakorreksjon,
|
||||
sted: &str,
|
||||
kategori: Kategori,
|
||||
klimaavhengig_lokalt: f64,
|
||||
) -> Option<Korrigering> {
|
||||
let faktor = tabell.faktor(sted, kategori)?;
|
||||
Some(Korrigering {
|
||||
sted: sted.to_string(),
|
||||
faktor,
|
||||
klimaavhengig_lokalt,
|
||||
klimaavhengig_normert: klimaavhengig_lokalt / faktor,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering.
|
||||
|
||||
pub mod epw;
|
||||
pub mod korreksjon;
|
||||
pub mod sol;
|
||||
pub mod tmy;
|
||||
|
||||
pub use epw::Vaerdata;
|
||||
|
||||
/// Referanseklima for energimerket.
|
||||
pub const REFERANSEKLIMA: &str = "Oslo";
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Solberegning etter NS-EN ISO 52010-1: solhøyde og asimut per time, og innstråling
|
||||
//! på en vilkårlig orientert og skrå flate (Perez-lignende anisotrop himmelmodell forenklet
|
||||
//! til Hay–Davies, som ISO 52010-1 tillater når sirkumsolar- og horisontbidrag ikke skilles).
|
||||
|
||||
use crate::klima::Vaerdata;
|
||||
use crate::TIMER;
|
||||
|
||||
const SOLKONSTANT: f64 = 1367.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Solposisjon {
|
||||
/// Solhøyde over horisonten, grader (negativ når sola er under horisonten).
|
||||
pub hoyde: f64,
|
||||
/// Asimut, grader fra nord med klokka (0 N, 90 Ø, 180 S, 270 V).
|
||||
pub asimut: f64,
|
||||
}
|
||||
|
||||
/// Solposisjon for time `t` (0–8759, lokal standardtid, midt i timen).
|
||||
pub fn solposisjon(v: &Vaerdata, t: usize) -> Solposisjon {
|
||||
let dag = (t / 24) as f64 + 1.0;
|
||||
let klokke = (t % 24) as f64 + 0.5;
|
||||
|
||||
// Tidsligning (ISO 52010-1 ligning 2–4).
|
||||
let rd = 360.0 / 365.0 * (dag - 81.0);
|
||||
let te = 0.1645 * (2.0 * rd.to_radians()).sin()
|
||||
- 0.1255 * rd.to_radians().cos()
|
||||
- 0.025 * rd.to_radians().sin();
|
||||
// Lokal soltid: korriger for lengdegradsavvik fra tidssonens meridian.
|
||||
let soltid = klokke + te + (v.lengde - 15.0 * v.tidssone) / 15.0;
|
||||
let timevinkel = 15.0 * (soltid - 12.0);
|
||||
|
||||
// Solens deklinasjon.
|
||||
let deklinasjon = 23.45 * (360.0 / 365.0 * (284.0 + dag)).to_radians().sin();
|
||||
|
||||
let (b, d, w) = (
|
||||
v.bredde.to_radians(),
|
||||
deklinasjon.to_radians(),
|
||||
timevinkel.to_radians(),
|
||||
);
|
||||
let sin_h = b.sin() * d.sin() + b.cos() * d.cos() * w.cos();
|
||||
let hoyde = sin_h.clamp(-1.0, 1.0).asin();
|
||||
|
||||
// Asimut regnes først fra sør (positiv mot vest) og gjøres så om til fra nord med klokka.
|
||||
let cos_h = hoyde.cos();
|
||||
let asimut = if cos_h.abs() < 1e-9 {
|
||||
180.0
|
||||
} else {
|
||||
let sin_a = w.sin() * d.cos() / cos_h;
|
||||
let cos_a = (sin_h * b.sin() - d.sin()) / (b.cos() * cos_h);
|
||||
let a = sin_a.atan2(cos_a.clamp(-1.0, 1.0)).to_degrees();
|
||||
(180.0 + a).rem_euclid(360.0)
|
||||
};
|
||||
Solposisjon {
|
||||
hoyde: hoyde.to_degrees(),
|
||||
asimut,
|
||||
}
|
||||
}
|
||||
|
||||
/// Innfallsvinkelens cosinus mot en flate med gitt helning og orientering.
|
||||
pub fn cos_innfallsvinkel(sol: Solposisjon, helning: f64, orientering: f64) -> f64 {
|
||||
let h = sol.hoyde.to_radians();
|
||||
let a = sol.asimut.to_radians();
|
||||
let t = helning.to_radians();
|
||||
let o = orientering.to_radians();
|
||||
(h.sin() * t.cos() + h.cos() * t.sin() * (a - o).cos()).max(0.0)
|
||||
}
|
||||
|
||||
/// Solinnstråling på en flate, W/m². `albedo` er bakkerefleksjonen (typisk 0,2; 0,7 med snø).
|
||||
pub fn innstraaling(v: &Vaerdata, t: usize, helning: f64, orientering: f64, albedo: f64) -> f64 {
|
||||
let sol = solposisjon(v, t);
|
||||
if sol.hoyde <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let cos_i = cos_innfallsvinkel(sol, helning, orientering);
|
||||
let sin_h = sol.hoyde.to_radians().sin().max(0.017); // begrenset for lave solhøyder
|
||||
|
||||
let dni = v.dni[t];
|
||||
let dhi = v.dhi[t];
|
||||
let ghi = v.ghi[t];
|
||||
|
||||
// Ekstraterrestriell normalstråling for anisotropi-indeksen.
|
||||
let dag = (t / 24) as f64 + 1.0;
|
||||
let e0 = SOLKONSTANT * (1.0 + 0.033 * (360.0 * dag / 365.0).to_radians().cos());
|
||||
let ai = (dni / e0).clamp(0.0, 1.0); // Hay–Davies anisotropiindeks
|
||||
|
||||
let direkte = dni * cos_i;
|
||||
let t_rad = helning.to_radians();
|
||||
let diffus = dhi * (ai * cos_i / sin_h + (1.0 - ai) * (1.0 + t_rad.cos()) / 2.0);
|
||||
let reflektert = ghi * albedo * (1.0 - t_rad.cos()) / 2.0;
|
||||
|
||||
(direkte + diffus.max(0.0) + reflektert).max(0.0)
|
||||
}
|
||||
|
||||
/// Årsserie med innstråling på én flate, W/m².
|
||||
pub fn innstraaling_aar(v: &Vaerdata, helning: f64, orientering: f64, albedo: f64) -> Vec<f64> {
|
||||
(0..TIMER)
|
||||
.map(|t| innstraaling(v, t, helning, orientering, albedo))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Himmelens ekvivalente temperatur for langbølget utstråling, °C.
|
||||
/// Brukes til å beregne ekstra varmetap fra utvendige flater mot klar himmel.
|
||||
pub fn himmeltemperatur(v: &Vaerdata, t: usize) -> f64 {
|
||||
let ir = v.ir_himmel[t];
|
||||
if ir > 1.0 {
|
||||
// Stefan–Boltzmann invertert.
|
||||
(ir / 5.67e-8).powf(0.25) - 273.15
|
||||
} else {
|
||||
// Uten måledata: forenklet sammenheng med utetemperatur (ISO 52016-1 tillegg).
|
||||
v.t_ute[t] - 11.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Henter og hurtiglagrer TMY-filer for norske kommuner.
|
||||
//!
|
||||
//! Datasettet «TMY files for Norway based on CERRA data from 1991-2020» (Zenodo,
|
||||
//! doi 10.5281/zenodo.14779899, CC-BY-4.0) er laget i tilknytning til NS 3031:2025 og dekker
|
||||
//! alle norske kommuner med timesverdier. Zip-filen lastes ned én gang til
|
||||
//! `$XDG_CACHE_HOME/enok` og pakkes ut derfra.
|
||||
|
||||
use crate::data::normaliser_stedsnavn;
|
||||
use crate::klima::Vaerdata;
|
||||
use crate::Result;
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const ZIP_URL: &str =
|
||||
"https://zenodo.org/api/records/19566253/files/EPW-TMYNO_CERRA_1991-2020.zip/content";
|
||||
const ZIP_NAVN: &str = "EPW-TMYNO_CERRA_1991-2020.zip";
|
||||
|
||||
pub fn hurtiglager() -> PathBuf {
|
||||
std::env::var_os("ENOK_CACHE")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("XDG_CACHE_HOME").map(|c| PathBuf::from(c).join("enok")))
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/enok")))
|
||||
.unwrap_or_else(|| PathBuf::from(".enok-cache"))
|
||||
}
|
||||
|
||||
fn zip_sti() -> PathBuf {
|
||||
hurtiglager().join(ZIP_NAVN)
|
||||
}
|
||||
|
||||
/// Laster ned TMY-arkivet dersom det ikke finnes lokalt.
|
||||
pub fn sikre_arkiv() -> Result<PathBuf> {
|
||||
let sti = zip_sti();
|
||||
if sti.exists() && std::fs::metadata(&sti)?.len() > 1_000_000 {
|
||||
return Ok(sti);
|
||||
}
|
||||
std::fs::create_dir_all(sti.parent().unwrap())?;
|
||||
eprintln!(
|
||||
"Laster ned TMY-klimadata (74 MB) fra Zenodo til {} …",
|
||||
sti.display()
|
||||
);
|
||||
let svar = ureq::get(ZIP_URL)
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.call()
|
||||
.context("nedlasting av TMY-arkivet feilet")?;
|
||||
let mut data = Vec::new();
|
||||
svar.into_reader().read_to_end(&mut data)?;
|
||||
if data.len() < 1_000_000 {
|
||||
bail!("nedlastet arkiv er uventet lite ({} byte)", data.len());
|
||||
}
|
||||
std::fs::write(&sti, &data)?;
|
||||
Ok(sti)
|
||||
}
|
||||
|
||||
/// Navnene på alle kommuner i arkivet.
|
||||
pub fn kommuner() -> Result<Vec<String>> {
|
||||
let sti = sikre_arkiv()?;
|
||||
let fil = std::fs::File::open(&sti)?;
|
||||
let mut zip = zip::ZipArchive::new(fil)?;
|
||||
let mut ut = Vec::new();
|
||||
for i in 0..zip.len() {
|
||||
let navn = zip.by_index(i)?.name().to_string();
|
||||
if let Some(k) = kommune_av_filnavn(&navn) {
|
||||
ut.push(k);
|
||||
}
|
||||
}
|
||||
ut.sort();
|
||||
Ok(ut)
|
||||
}
|
||||
|
||||
fn kommune_av_filnavn(navn: &str) -> Option<String> {
|
||||
let base = Path::new(navn).file_name()?.to_str()?;
|
||||
let base = base.strip_suffix(".epw")?;
|
||||
let base = base.strip_prefix("TMYNO_")?;
|
||||
let base = base.strip_suffix("_CERRA_1991-2020")?;
|
||||
Some(base.to_string())
|
||||
}
|
||||
|
||||
/// Henter værdata for en kommune. Navnet sammenlignes normalisert, slik at både
|
||||
/// «Indre Østfold» og «Indre Ostfold» treffer samme fil.
|
||||
pub fn for_kommune(kommune: &str) -> Result<Vaerdata> {
|
||||
let sti = sikre_arkiv()?;
|
||||
let fil = std::fs::File::open(&sti)?;
|
||||
let mut zip = zip::ZipArchive::new(fil)?;
|
||||
let mål = normaliser_stedsnavn(kommune);
|
||||
let mut treff = None;
|
||||
for i in 0..zip.len() {
|
||||
let navn = zip.by_index(i)?.name().to_string();
|
||||
if let Some(k) = kommune_av_filnavn(&navn) {
|
||||
if normaliser_stedsnavn(&k) == mål {
|
||||
treff = Some((i, k));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (indeks, navn) = treff.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"fant ingen TMY-fil for kommunen «{}» (bruk `enok klima --liste` for gyldige navn)",
|
||||
kommune
|
||||
)
|
||||
})?;
|
||||
let mut oppf = zip.by_index(indeks)?;
|
||||
let mut data = Vec::new();
|
||||
oppf.read_to_end(&mut data)?;
|
||||
let tekst = String::from_utf8_lossy(&data);
|
||||
let mut v = Vaerdata::fra_tekst(&tekst).with_context(|| format!("TMY-fil for {navn}"))?;
|
||||
v.sted = navn;
|
||||
Ok(v)
|
||||
}
|
||||
Reference in New Issue
Block a user