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
+401
View File
@@ -0,0 +1,401 @@
//! Innebygde datafiler (normerte verdier, karakterskala, vekting, SPF, materialer, klimakorreksjon).
//! Filene ligger i `data/` og bygges inn i binæren; de kan overstyres med `--data DIR`.
use crate::modell::Kategori;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const SKALA: &str = include_str!("../data/skala.toml");
const VEKTING: &str = include_str!("../data/vekting.toml");
const SPF: &str = include_str!("../data/varmepumpe_spf.toml");
const MATERIALER: &str = include_str!("../data/materialer.toml");
const KLIMAKORREKSJON: &str = include_str!("../data/klimakorreksjon.csv");
const NORMERTE_SMAAHUS: &str = include_str!("../data/normerte/smaahus.toml");
const NORMERTE_BOLIGBLOKK: &str = include_str!("../data/normerte/boligblokk.toml");
/// Katalog som kan overstyre de innebygde datafilene.
#[derive(Clone, Debug, Default)]
pub struct Datakilde {
pub katalog: Option<PathBuf>,
}
impl Datakilde {
pub fn ny(katalog: Option<PathBuf>) -> Self {
Self { katalog }
}
fn les(&self, relativ: &str, innebygd: &str) -> Result<String> {
if let Some(k) = &self.katalog {
let p = k.join(relativ);
if p.exists() {
return std::fs::read_to_string(&p)
.with_context(|| format!("leser {}", p.display()));
}
}
Ok(innebygd.to_string())
}
pub fn skala(&self) -> Result<Skala> {
let s = self.les("skala.toml", SKALA)?;
let m: HashMap<String, SkalaKategori> = toml::from_str(&s).context("skala.toml")?;
Ok(Skala(m))
}
pub fn vekting(&self) -> Result<Vekting> {
let s = self.les("vekting.toml", VEKTING)?;
toml::from_str(&s).context("vekting.toml")
}
pub fn varmepumper(&self) -> Result<Vec<VarmepumpeSpf>> {
let s = self.les("varmepumpe_spf.toml", SPF)?;
let t: SpfFil = toml::from_str(&s).context("varmepumpe_spf.toml")?;
Ok(t.varmepumpe)
}
pub fn materialer(&self) -> Result<Materialer> {
let s = self.les("materialer.toml", MATERIALER)?;
toml::from_str(&s).context("materialer.toml")
}
pub fn normerte(&self, kategori: Kategori) -> Result<Normerte> {
let (fil, innebygd) = match kategori {
Kategori::Smaahus => ("normerte/smaahus.toml", NORMERTE_SMAAHUS),
Kategori::Boligblokk => ("normerte/boligblokk.toml", NORMERTE_BOLIGBLOKK),
annen => {
// Andre kategorier krever egen datafil i data-katalogen.
let fil = format!("normerte/{}.toml", annen.nokkel());
let p = self
.katalog
.as_ref()
.map(|k| k.join(&fil))
.filter(|p| p.exists())
.ok_or_else(|| {
anyhow!(
"normerte inndata for kategori «{}» mangler: legg {} i data-katalogen",
annen.nokkel(),
fil
)
})?;
let s = std::fs::read_to_string(&p)?;
return toml::from_str(&s).with_context(|| p.display().to_string());
}
};
let s = self.les(fil, innebygd)?;
toml::from_str(&s).context(fil)
}
pub fn klimakorreksjon(&self) -> Result<Klimakorreksjon> {
let s = self.les("klimakorreksjon.csv", KLIMAKORREKSJON)?;
Klimakorreksjon::fra_csv(&s)
}
}
// ───────────────────────────── karakterskala ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct SkalaKategori {
pub navn: String,
/// Øvre grense for A, B, C, D, E, F (kWh/m²). G har ingen grense.
pub grenser: [f64; 6],
/// Arealkorreksjon k for hver grense: grense + k / A.
#[serde(default)]
pub arealkorreksjon: Option<[f64; 6]>,
}
pub struct Skala(pub HashMap<String, SkalaKategori>);
impl Skala {
pub fn for_kategori(&self, k: Kategori) -> Result<&SkalaKategori> {
self.0
.get(k.nokkel())
.ok_or_else(|| anyhow!("karakterskala mangler for {}", k.nokkel()))
}
}
// ───────────────────────────── vekting ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct Vekting {
pub faktor: Vektingsfaktorer,
pub regler: Vektingsregler,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Vektingsfaktorer {
pub elektrisitet: f64,
pub fjernvarme: f64,
pub fjernkjoling: f64,
pub biobrensel_lukket: f64,
pub biobrensel_aapen: f64,
pub olje: f64,
pub gass: f64,
pub annet: f64,
pub eksportert: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Vektingsregler {
pub vedovn_maks_andel_rom: f64,
}
// ───────────────────────────── varmepumper ─────────────────────────────
#[derive(Deserialize)]
struct SpfFil {
varmepumpe: Vec<VarmepumpeSpf>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct VarmepumpeSpf {
#[serde(rename = "type")]
pub typ: String,
#[serde(default)]
pub turtemperatur: Option<f64>,
pub beskrivelse: String,
pub smaahus: f64,
pub boligblokk: f64,
}
/// Slår opp veiledende SPF for en varmepumpetype. Ved turtemperatur velges nærmeste tabellverdi.
pub fn spf_oppslag(
tabell: &[VarmepumpeSpf],
typ: &str,
turtemp: Option<f64>,
kategori: Kategori,
) -> Option<f64> {
let mut best: Option<(f64, &VarmepumpeSpf)> = None;
for rad in tabell.iter().filter(|r| r.typ == typ) {
let avstand = match (rad.turtemperatur, turtemp) {
(Some(t), Some(v)) => (t - v).abs(),
(Some(t), None) => (t - 45.0).abs(), // uten oppgitt turtemperatur: radiatoranlegg 45/37
_ => 0.0,
};
if best.is_none_or(|(a, _)| avstand < a) {
best = Some((avstand, rad));
}
}
best.map(|(_, r)| match kategori {
Kategori::Smaahus => r.smaahus,
_ => r.boligblokk,
})
}
// ───────────────────────────── materialer ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct Materialer {
pub materiale: Vec<Materiale>,
pub periode: Vec<Periode>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Materiale {
pub navn: String,
pub lambda: f64,
pub rho: f64,
pub c: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Periode {
pub navn: String,
pub fra: i32,
pub til: i32,
pub vegg: f64,
pub tak: f64,
pub gulv: f64,
pub vindu: f64,
pub kuldebro: f64,
pub n50: f64,
}
impl Materialer {
pub fn materiale(&self, navn: &str) -> Option<&Materiale> {
self.materiale
.iter()
.find(|m| m.navn.eq_ignore_ascii_case(navn))
}
pub fn periode_for(&self, byggeaar: i32) -> Option<&Periode> {
self.periode
.iter()
.find(|p| byggeaar >= p.fra && byggeaar <= p.til)
}
}
// ───────────────────────────── normerte inndata ─────────────────────────────
#[derive(Deserialize, Clone, Debug)]
pub struct Normerte {
pub kategori: String,
#[serde(default)]
pub kilde: String,
pub settpunkt: Settpunkt,
pub driftstid: Driftstider,
pub belysning: EffektPost,
pub utstyr: EffektPost,
pub varmtvann: EffektPost,
pub personer: PersonPost,
pub ventilasjon: VentilasjonNormert,
pub termisk: TermiskNormert,
pub pumper: PumperNormert,
/// Valgfrie 24-verdiers profiler (W/m² per klokketime) som overstyrer driftstid × effekt.
#[serde(default)]
pub profil_belysning: Option<[f64; 24]>,
#[serde(default)]
pub profil_utstyr: Option<[f64; 24]>,
#[serde(default)]
pub profil_varmtvann: Option<[f64; 24]>,
#[serde(default)]
pub profil_personer: Option<[f64; 24]>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Settpunkt {
pub oppvarming: f64,
pub oppvarming_natt: f64,
pub kjoling: f64,
pub ventilasjon_tilluft: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Driftstider {
pub oppvarming: Tidsrom,
pub belysning: Tidsrom,
pub utstyr: Tidsrom,
pub varmtvann: Tidsrom,
pub personer: Tidsrom,
pub ventilasjon: Tidsrom,
#[serde(default = "sju")]
pub dager_per_uke: u8,
}
fn sju() -> u8 {
7
}
#[derive(Deserialize, serde::Serialize, Clone, Copy, Debug)]
pub struct Tidsrom {
pub start: u8,
pub slutt: u8,
}
impl Tidsrom {
pub fn aktiv(&self, klokketime: usize) -> bool {
let h = klokketime as u8;
if self.start <= self.slutt {
h >= self.start && h < self.slutt
} else {
h >= self.start || h < self.slutt
}
}
pub fn timer(&self) -> f64 {
if self.start <= self.slutt {
(self.slutt - self.start) as f64
} else {
(24 + self.slutt - self.start) as f64
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct EffektPost {
pub effekt: f64,
pub varmetilskudd: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct PersonPost {
pub varmetilskudd: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct VentilasjonNormert {
pub luftmengde_driftstid: f64,
pub luftmengde_utenfor_driftstid: f64,
pub sfp: f64,
pub temperaturvirkningsgrad: f64,
pub frostsikring_virkningsgrad: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct TermiskNormert {
pub varmekapasitet_normalisert: f64,
pub kuldebroer_normalisert: f64,
}
#[derive(Deserialize, Clone, Debug)]
pub struct PumperNormert {
pub effekt_vannbaaren: f64,
}
// ───────────────────────────── klimakorreksjon ─────────────────────────────
/// Klimakorrigeringsfaktorer KF per klimasted og bygningskategori (NS 3031:2025, tillegg).
pub struct Klimakorreksjon {
pub kategorier: Vec<String>,
pub steder: Vec<(String, String, Vec<f64>)>,
}
impl Klimakorreksjon {
fn fra_csv(s: &str) -> Result<Self> {
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(s.as_bytes());
let kategorier: Vec<String> = rdr
.headers()?
.iter()
.skip(2)
.map(|s| s.to_string())
.collect();
let mut steder = Vec::new();
for rad in rdr.records() {
let rad = rad?;
let sted = rad.get(0).unwrap_or("").to_string();
let fylke = rad.get(1).unwrap_or("").to_string();
let verdier: Result<Vec<f64>, _> =
rad.iter().skip(2).map(|v| v.parse::<f64>()).collect();
steder.push((
sted,
fylke,
verdier.context("klimakorreksjon.csv: tallverdi")?,
));
}
Ok(Self { kategorier, steder })
}
/// KF for et klimasted og en kategori. Navnet sammenlignes normalisert (æøå → ae/o/aa).
pub fn faktor(&self, sted: &str, kategori: Kategori) -> Option<f64> {
let kol = self
.kategorier
.iter()
.position(|k| k == kategori.nokkel())?;
let mål = normaliser_stedsnavn(sted);
self.steder
.iter()
.find(|(s, _, _)| normaliser_stedsnavn(s) == mål)
.map(|(_, _, v)| v[kol])
}
}
/// Normaliserer stedsnavn for sammenligning: små bokstaver, æ→ae, ø→o, å→aa, uten parentes-fylke.
pub fn normaliser_stedsnavn(s: &str) -> String {
let s = s.split('(').next().unwrap_or(s).trim();
s.chars()
.flat_map(|c| match c.to_ascii_lowercase() {
'æ' => "ae".chars().collect::<Vec<_>>(),
'ø' => "o".chars().collect(),
'å' => "aa".chars().collect(),
'Æ' => "ae".chars().collect(),
'Ø' => "o".chars().collect(),
'Å' => "aa".chars().collect(),
'-' | ' ' => vec![],
c => vec![c],
})
.collect()
}
pub fn finnes(p: &Path) -> bool {
p.exists()
}
+700
View File
@@ -0,0 +1,700 @@
//! Import av gbXML fra BIM-verktøy (Vectorworks, Archicad, Revit m.fl.).
//!
//! gbXML er formatet Vectorworks Architect eksporterer for energianalyse, og inneholder rom,
//! flater med himmelretning og helning, åpninger, konstruksjoner og vindustyper. Importen
//! oversetter dette til en `bygning.toml` som kan fylles ut med det gbXML ikke beskriver
//! (varmekilder, ventilasjon, lekkasjetall, kommune).
use crate::modell::*;
use crate::Result;
use anyhow::{anyhow, Context};
use quick_xml::events::Event;
use quick_xml::Reader;
use std::collections::HashMap;
use std::path::Path;
#[derive(Default, Debug, Clone)]
struct Material {
tykkelse: f64,
ledningsevne: f64,
r_verdi: f64,
densitet: f64,
varmekapasitet: f64,
}
#[derive(Default, Debug, Clone)]
struct Konstruksjon {
navn: String,
u_verdi: Option<f64>,
lag: Vec<String>,
}
#[derive(Default, Debug, Clone)]
struct Vindustype {
navn: String,
u_verdi: Option<f64>,
shgc: Option<f64>,
}
#[derive(Default, Debug, Clone)]
struct Aapning {
navn: String,
typ: String,
vindustype: String,
bredde: f64,
hoyde: f64,
areal: Option<f64>,
polygon: Vec<[f64; 3]>,
}
#[derive(Default, Debug, Clone)]
struct Overflate {
navn: String,
typ: String,
konstruksjon: String,
asimut: Option<f64>,
helning: Option<f64>,
bredde: f64,
hoyde: f64,
areal: Option<f64>,
polygon: Vec<[f64; 3]>,
naborom: Vec<String>,
aapninger: Vec<Aapning>,
}
#[derive(Default, Debug)]
struct Rom {
areal: f64,
volum: f64,
}
/// Rapport fra importen: hva som ble lest og hva som må fylles ut manuelt.
pub struct Importrapport {
pub bygning: Bygning,
pub merknader: Vec<String>,
}
pub fn les(sti: &Path) -> Result<Importrapport> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
fra_tekst(
&tekst,
sti.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Bygning"),
)
}
pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let mut leser = Reader::from_str(tekst);
leser.config_mut().trim_text(true);
let mut materialer: HashMap<String, Material> = HashMap::new();
let mut lag: HashMap<String, Vec<String>> = HashMap::new();
let mut konstruksjoner: HashMap<String, Konstruksjon> = HashMap::new();
let mut vindustyper: HashMap<String, Vindustype> = HashMap::new();
let mut overflater: Vec<Overflate> = Vec::new();
let mut rom: Vec<Rom> = Vec::new();
// Gjeldende kontekst under parsingen.
let mut sti_elementer: Vec<String> = Vec::new();
let mut gjeldende_material: Option<(String, Material)> = None;
let mut gjeldende_lag: Option<(String, Vec<String>)> = None;
let mut gjeldende_konstruksjon: Option<(String, Konstruksjon)> = None;
let mut gjeldende_vindustype: Option<(String, Vindustype)> = None;
let mut gjeldende_overflate: Option<Overflate> = None;
let mut gjeldende_aapning: Option<Aapning> = None;
let mut gjeldende_rom: Option<Rom> = None;
let mut gjeldende_punkt: Vec<f64> = Vec::new();
let mut i_planar = false;
let mut bygningsnavn: Option<String> = None;
let mut bygningstype: Option<String> = None;
let mut tekstbuffer = String::new();
let mut buf = Vec::new();
loop {
match leser.read_event_into(&mut buf) {
Err(e) => return Err(anyhow!("gbXML er ikke gyldig XML: {e}")),
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => {
let navn = lokalt_navn(e.name().as_ref());
let attr = |n: &str| -> Option<String> {
e.attributes().flatten().find_map(|a| {
if lokalt_navn(a.key.as_ref()) == n {
Some(String::from_utf8_lossy(&a.value).to_string())
} else {
None
}
})
};
match navn.as_str() {
"Material" => {
gjeldende_material =
Some((attr("id").unwrap_or_default(), Material::default()))
}
"Layer" => gjeldende_lag = Some((attr("id").unwrap_or_default(), Vec::new())),
"Construction" => {
gjeldende_konstruksjon =
Some((attr("id").unwrap_or_default(), Konstruksjon::default()))
}
"WindowType" => {
gjeldende_vindustype =
Some((attr("id").unwrap_or_default(), Vindustype::default()))
}
"Building" => {
bygningstype = attr("buildingType");
}
"Space" => gjeldende_rom = Some(Rom::default()),
"Surface" => {
gjeldende_overflate = Some(Overflate {
navn: attr("id").unwrap_or_default(),
typ: attr("surfaceType").unwrap_or_default(),
konstruksjon: attr("constructionIdRef").unwrap_or_default(),
..Default::default()
})
}
"Opening" => {
gjeldende_aapning = Some(Aapning {
navn: attr("id").unwrap_or_default(),
typ: attr("openingType").unwrap_or_default(),
vindustype: attr("windowTypeIdRef").unwrap_or_default(),
..Default::default()
})
}
"PlanarGeometry" => i_planar = true,
"CartesianPoint" => gjeldende_punkt.clear(),
_ => {}
}
sti_elementer.push(navn);
tekstbuffer.clear();
}
Ok(Event::Empty(e)) => {
let navn = lokalt_navn(e.name().as_ref());
let attr = |n: &str| -> Option<String> {
e.attributes().flatten().find_map(|a| {
if lokalt_navn(a.key.as_ref()) == n {
Some(String::from_utf8_lossy(&a.value).to_string())
} else {
None
}
})
};
match navn.as_str() {
"MaterialId" => {
if let (Some((_, l)), Some(r)) =
(gjeldende_lag.as_mut(), attr("materialIdRef"))
{
l.push(r);
}
}
"LayerId" => {
if let (Some((_, k)), Some(r)) =
(gjeldende_konstruksjon.as_mut(), attr("layerIdRef"))
{
k.lag.push(r);
}
}
"AdjacentSpaceId" => {
if let (Some(o), Some(r)) =
(gjeldende_overflate.as_mut(), attr("spaceIdRef"))
{
o.naborom.push(r);
}
}
_ => {}
}
}
Ok(Event::Text(t)) => {
tekstbuffer = t.unescape().unwrap_or_default().to_string();
}
Ok(Event::End(e)) => {
let navn = lokalt_navn(e.name().as_ref());
let tall = tekstbuffer.trim().parse::<f64>().ok();
let i_aapning = gjeldende_aapning.is_some();
match navn.as_str() {
"Thickness" => sett(&mut gjeldende_material, |m| {
m.tykkelse = tall.unwrap_or(0.0)
}),
"Conductivity" => sett(&mut gjeldende_material, |m| {
m.ledningsevne = tall.unwrap_or(0.0)
}),
"Density" => sett(&mut gjeldende_material, |m| {
m.densitet = tall.unwrap_or(0.0)
}),
"SpecificHeat" => sett(&mut gjeldende_material, |m| {
m.varmekapasitet = tall.unwrap_or(0.0)
}),
"R-value" => sett(&mut gjeldende_material, |m| m.r_verdi = tall.unwrap_or(0.0)),
"U-value" => {
if let Some((_, v)) = gjeldende_vindustype.as_mut() {
v.u_verdi = tall;
} else if let Some((_, k)) = gjeldende_konstruksjon.as_mut() {
k.u_verdi = tall;
}
}
"SolarHeatGainCoeff" => {
if let Some((_, v)) = gjeldende_vindustype.as_mut() {
v.shgc = tall;
}
}
"Name" => {
if let Some((_, v)) = gjeldende_vindustype.as_mut() {
v.navn = tekstbuffer.clone();
} else if let Some((_, k)) = gjeldende_konstruksjon.as_mut() {
k.navn = tekstbuffer.clone();
} else if let Some(o) = gjeldende_overflate.as_mut() {
if !tekstbuffer.is_empty() {
o.navn = tekstbuffer.clone();
}
} else if sti_elementer.iter().rev().nth(1).map(|s| s.as_str())
== Some("Building")
{
bygningsnavn = Some(tekstbuffer.clone());
}
}
"Azimuth" => {
if let Some(o) = gjeldende_overflate.as_mut() {
o.asimut = tall;
}
}
"Tilt" => {
if let Some(o) = gjeldende_overflate.as_mut() {
o.helning = tall;
}
}
"Width" => {
if let Some(a) = gjeldende_aapning.as_mut() {
a.bredde = tall.unwrap_or(0.0);
} else if let Some(o) = gjeldende_overflate.as_mut() {
o.bredde = tall.unwrap_or(0.0);
}
}
"Height" => {
if let Some(a) = gjeldende_aapning.as_mut() {
a.hoyde = tall.unwrap_or(0.0);
} else if let Some(o) = gjeldende_overflate.as_mut() {
o.hoyde = tall.unwrap_or(0.0);
}
}
"Area" => {
if let Some(a) = gjeldende_aapning.as_mut() {
a.areal = tall;
} else if let Some(o) = gjeldende_overflate.as_mut() {
o.areal = tall;
} else if let Some(r) = gjeldende_rom.as_mut() {
r.areal = tall.unwrap_or(0.0);
}
}
"Volume" => {
if let Some(r) = gjeldende_rom.as_mut() {
r.volum = tall.unwrap_or(0.0);
}
}
"Coordinate" => {
if let Some(v) = tall {
gjeldende_punkt.push(v);
}
}
"CartesianPoint" => {
if i_planar && gjeldende_punkt.len() >= 3 {
let p = [gjeldende_punkt[0], gjeldende_punkt[1], gjeldende_punkt[2]];
if i_aapning {
if let Some(a) = gjeldende_aapning.as_mut() {
a.polygon.push(p);
}
} else if let Some(o) = gjeldende_overflate.as_mut() {
o.polygon.push(p);
}
}
gjeldende_punkt.clear();
}
"PlanarGeometry" => i_planar = false,
"Material" => {
if let Some((id, m)) = gjeldende_material.take() {
materialer.insert(id, m);
}
}
"Layer" => {
if let Some((id, l)) = gjeldende_lag.take() {
lag.insert(id, l);
}
}
"Construction" => {
if let Some((id, k)) = gjeldende_konstruksjon.take() {
konstruksjoner.insert(id, k);
}
}
"WindowType" => {
if let Some((id, v)) = gjeldende_vindustype.take() {
vindustyper.insert(id, v);
}
}
"Opening" => {
if let (Some(a), Some(o)) =
(gjeldende_aapning.take(), gjeldende_overflate.as_mut())
{
o.aapninger.push(a);
}
}
"Surface" => {
if let Some(o) = gjeldende_overflate.take() {
overflater.push(o);
}
}
"Space" => {
if let Some(r) = gjeldende_rom.take() {
rom.push(r);
}
}
_ => {}
}
sti_elementer.pop();
tekstbuffer.clear();
}
_ => {}
}
buf.clear();
}
bygg(
standardnavn,
bygningsnavn,
bygningstype,
rom,
overflater,
konstruksjoner,
lag,
materialer,
vindustyper,
)
}
fn sett<T>(mål: &mut Option<(String, T)>, f: impl FnOnce(&mut T)) {
if let Some((_, v)) = mål.as_mut() {
f(v);
}
}
fn lokalt_navn(b: &[u8]) -> String {
let s = String::from_utf8_lossy(b);
s.rsplit(':').next().unwrap_or(&s).to_string()
}
/// Arealet av et plant polygon i rommet.
fn polygonareal(p: &[[f64; 3]]) -> f64 {
if p.len() < 3 {
return 0.0;
}
let mut n = [0.0f64; 3];
for i in 0..p.len() {
let a = p[i];
let b = p[(i + 1) % p.len()];
n[0] += a[1] * b[2] - a[2] * b[1];
n[1] += a[2] * b[0] - a[0] * b[2];
n[2] += a[0] * b[1] - a[1] * b[0];
}
0.5 * (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt()
}
fn areal_av(areal: Option<f64>, bredde: f64, hoyde: f64, polygon: &[[f64; 3]]) -> f64 {
if let Some(a) = areal.filter(|a| *a > 0.0) {
return a;
}
if bredde > 0.0 && hoyde > 0.0 {
return bredde * hoyde;
}
polygonareal(polygon)
}
#[allow(clippy::too_many_arguments)]
fn bygg(
standardnavn: &str,
bygningsnavn: Option<String>,
bygningstype: Option<String>,
rom: Vec<Rom>,
overflater: Vec<Overflate>,
konstruksjoner: HashMap<String, Konstruksjon>,
lag: HashMap<String, Vec<String>>,
materialer: HashMap<String, Material>,
vindustyper: HashMap<String, Vindustype>,
) -> Result<Importrapport> {
let mut merknader = Vec::new();
let bra: f64 = rom.iter().map(|r| r.areal).sum();
let volum: f64 = rom.iter().map(|r| r.volum).sum();
if bra <= 0.0 {
merknader.push(
"gbXML-filen oppgir ikke romareal. Oppvarmet BRA er satt til 0 og må fylles inn."
.into(),
);
}
let u_av_konstruksjon = |id: &str| -> Option<f64> {
let k = konstruksjoner.get(id)?;
if let Some(u) = k.u_verdi.filter(|u| *u > 0.0) {
return Some(u);
}
// Summer lagvis varmemotstand og legg til overgangsmotstander.
let mut r = 0.17;
let mut fant = false;
for lid in &k.lag {
for mid in lag.get(lid).into_iter().flatten() {
if let Some(m) = materialer.get(mid) {
if m.r_verdi > 0.0 {
r += m.r_verdi;
fant = true;
} else if m.ledningsevne > 0.0 && m.tykkelse > 0.0 {
r += m.tykkelse / m.ledningsevne;
fant = true;
}
}
}
}
fant.then(|| 1.0 / r)
};
let kappa_av_konstruksjon = |id: &str| -> Option<f64> {
let k = konstruksjoner.get(id)?;
let mut kappa = 0.0;
for lid in &k.lag {
for mid in lag.get(lid).into_iter().flatten() {
if let Some(m) = materialer.get(mid) {
if m.densitet > 0.0 && m.varmekapasitet > 0.0 && m.tykkelse > 0.0 {
kappa += m.tykkelse * m.densitet * m.varmekapasitet;
}
}
}
}
(kappa > 0.0).then_some(kappa)
};
let mut flater: Vec<Flate> = Vec::new();
let mut vinduer: Vec<Vindu> = Vec::new();
let mut hoppet_over = 0;
for o in &overflater {
let Some((typ, mot)) = klassifiser(&o.typ) else {
hoppet_over += 1;
continue;
};
let brutto = areal_av(o.areal, o.bredde, o.hoyde, &o.polygon);
let mut aapningsareal = 0.0;
for a in &o.aapninger {
let areal = areal_av(a.areal, a.bredde, a.hoyde, &a.polygon);
aapningsareal += areal;
let vt = vindustyper.get(&a.vindustype);
let u = vt.and_then(|v| v.u_verdi).unwrap_or_else(|| {
merknader.push(format!(
"Åpningen «{}» mangler U-verdi i gbXML; satt til 2,4 W/(m²K).",
a.navn
));
2.4
});
let dor = a.typ.to_lowercase().contains("door");
vinduer.push(Vindu {
navn: vt
.map(|v| v.navn.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| a.navn.clone()),
areal,
antall: 1.0,
orientering: o.asimut.unwrap_or(0.0),
helning: o.helning.unwrap_or(90.0),
u_verdi: u,
g_verdi: vt.and_then(|v| v.shgc),
karmandel: None,
solskjerming: None,
horisont: None,
dor,
kommentar: Some(format!("fra gbXML, flate {}", o.navn)),
});
}
let netto = (brutto - aapningsareal).max(0.0);
if netto <= 0.01 {
continue;
}
let u = u_av_konstruksjon(&o.konstruksjon);
if u.is_none() {
merknader.push(format!(
"Flaten «{}» mangler U-verdi og lagoppbygging i gbXML; verdien utledes av byggeår.",
o.navn
));
}
let helning = o.helning.unwrap_or(match typ {
FlateType::Vegg => 90.0,
FlateType::Tak => 0.0,
FlateType::Gulv => 180.0,
});
flater.push(Flate {
navn: o.navn.clone(),
typ,
areal: netto,
orientering: o.asimut.unwrap_or(0.0),
helning: Some(helning),
u_verdi: u,
lag: None,
masseklasse: kappa_av_konstruksjon(&o.konstruksjon).and_then(masseklasse_av_kappa),
masseplassering: Masseplassering::Fordelt,
mot,
b_faktor: None,
absorptans: None,
omkrets: None,
dybde: None,
kommentar: konstruksjoner
.get(&o.konstruksjon)
.map(|k| k.navn.clone())
.filter(|n| !n.is_empty()),
});
}
if hoppet_over > 0 {
merknader.push(format!(
"{hoppet_over} innvendige flater og skyggeflater er utelatt; bare klimaskjermen regnes med."
));
}
merknader.push(
"gbXML beskriver ikke varmekilder, ventilasjon, lekkasjetall eller kommune. \
Fyll inn [ventilasjon], [infiltrasjon], [oppvarming] og [klima] før beregning."
.into(),
);
let kategori = match bygningstype.as_deref() {
Some(t) if t.contains("MultiFamily") || t.contains("Apartment") => Kategori::Boligblokk,
Some(t) if t.contains("Office") => Kategori::Kontorbygg,
Some(t) if t.contains("School") => Kategori::Skolebygg,
_ => Kategori::Smaahus,
};
let bygning = Bygning {
bygning: BygningInfo {
navn: bygningsnavn.unwrap_or_else(|| standardnavn.to_string()),
kategori,
byggeaar: 2020,
oppvarmet_bra: bra,
oppvarmet_volum: (volum > 0.0).then_some(volum),
antall_boenheter: 1,
bygningens_oppvarmede_bra: None,
adresse: None,
gnr_bnr: None,
beskrivelse: Some("Importert fra gbXML".into()),
energiraadgiver: None,
oppdragsgiver: None,
},
klima: Klima::default(),
flate: flater,
vindu: vinduer,
kuldebroer: Kuldebroer::default(),
infiltrasjon: Infiltrasjon::default(),
ventilasjon: Ventilasjon::default(),
tappevann: Tappevann::default(),
oppvarming: Oppvarming::default(),
distribusjon: Distribusjon::default(),
solcelle: None,
kjoling: Kjoling::default(),
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
};
Ok(Importrapport { bygning, merknader })
}
fn klassifiser(typ: &str) -> Option<(FlateType, Mot)> {
match typ {
"ExteriorWall" => Some((FlateType::Vegg, Mot::Ute)),
"UndergroundWall" => Some((FlateType::Vegg, Mot::Grunn)),
"Roof" => Some((FlateType::Tak, Mot::Ute)),
"Ceiling" | "UndergroundCeiling" => Some((FlateType::Tak, Mot::Uoppvarmet)),
"SlabOnGrade" | "UndergroundSlab" => Some((FlateType::Gulv, Mot::Grunn)),
"RaisedFloor" => Some((FlateType::Gulv, Mot::Ute)),
"ExposedFloor" => Some((FlateType::Gulv, Mot::Ute)),
_ => None,
}
}
fn masseklasse_av_kappa(kappa: f64) -> Option<Masseklasse> {
Some(match kappa {
k if k < 62_500.0 => Masseklasse::SvaertLett,
k if k < 92_500.0 => Masseklasse::Lett,
k if k < 142_500.0 => Masseklasse::Middels,
k if k < 212_500.0 => Masseklasse::Tung,
_ => Masseklasse::SvaertTung,
})
}
#[cfg(test)]
mod tester {
use super::*;
const PROVE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<gbXML xmlns="http://www.gbxml.org/schema" lengthUnit="Meters" areaUnit="SquareMeters" temperatureUnit="C">
<Campus id="c1">
<Building id="b1" buildingType="SingleFamily">
<Name>Prøvehus</Name>
<Space id="s1"><Area>100.0</Area><Volume>250.0</Volume></Space>
</Building>
<Surface id="v-sor" surfaceType="ExteriorWall" constructionIdRef="k-vegg">
<Name>Yttervegg sør</Name>
<AdjacentSpaceId spaceIdRef="s1"/>
<RectangularGeometry><Azimuth>180</Azimuth><Tilt>90</Tilt><Width>10</Width><Height>2.5</Height></RectangularGeometry>
<Opening id="vindu-1" openingType="FixedWindow" windowTypeIdRef="w1">
<RectangularGeometry><Width>2.0</Width><Height>1.5</Height></RectangularGeometry>
</Opening>
</Surface>
<Surface id="tak" surfaceType="Roof" constructionIdRef="k-tak">
<AdjacentSpaceId spaceIdRef="s1"/>
<PlanarGeometry><PolyLoop>
<CartesianPoint><Coordinate>0</Coordinate><Coordinate>0</Coordinate><Coordinate>3</Coordinate></CartesianPoint>
<CartesianPoint><Coordinate>10</Coordinate><Coordinate>0</Coordinate><Coordinate>3</Coordinate></CartesianPoint>
<CartesianPoint><Coordinate>10</Coordinate><Coordinate>10</Coordinate><Coordinate>3</Coordinate></CartesianPoint>
<CartesianPoint><Coordinate>0</Coordinate><Coordinate>10</Coordinate><Coordinate>3</Coordinate></CartesianPoint>
</PolyLoop></PlanarGeometry>
</Surface>
<Surface id="innervegg" surfaceType="InteriorWall" constructionIdRef="k-vegg">
<RectangularGeometry><Width>5</Width><Height>2.5</Height></RectangularGeometry>
</Surface>
</Campus>
<Construction id="k-vegg"><Name>Bindingsverk 200</Name><U-value unit="WPerSquareMeterK">0.18</U-value></Construction>
<Construction id="k-tak">
<Name>Takkonstruksjon</Name>
<LayerId layerIdRef="l1"/>
</Construction>
<Layer id="l1"><MaterialId materialIdRef="m1"/></Layer>
<Material id="m1"><Thickness>0.3</Thickness><Conductivity>0.037</Conductivity><Density>30</Density><SpecificHeat>1030</SpecificHeat></Material>
<WindowType id="w1"><Name>2-lags</Name><U-value unit="WPerSquareMeterK">1.2</U-value><SolarHeatGainCoeff>0.5</SolarHeatGainCoeff></WindowType>
</gbXML>"#;
#[test]
fn leser_flater_vinduer_og_arealer() {
let r = fra_tekst(PROVE, "prove").unwrap();
let b = r.bygning;
assert_eq!(b.bygning.navn, "Prøvehus");
assert_eq!(b.bygning.oppvarmet_bra, 100.0);
assert_eq!(b.bygning.oppvarmet_volum, Some(250.0));
// Ytterveggen: 25 m² minus 3 m² vindu.
let vegg = b.flate.iter().find(|f| f.navn == "Yttervegg sør").unwrap();
assert!((vegg.areal - 22.0).abs() < 1e-9);
assert_eq!(vegg.u_verdi, Some(0.18));
assert_eq!(vegg.orientering, 180.0);
// Taket: polygonareal 100 m², U-verdi utledet av lag.
let tak = b.flate.iter().find(|f| f.navn == "tak").unwrap();
assert!((tak.areal - 100.0).abs() < 1e-6);
let u = tak.u_verdi.unwrap();
assert!((u - 1.0 / (0.17 + 0.3 / 0.037)).abs() < 1e-9, "u = {u}");
// Innvendig vegg skal ikke bli med.
assert!(b.flate.iter().all(|f| f.navn != "innervegg"));
// Vinduet.
assert_eq!(b.vindu.len(), 1);
assert_eq!(b.vindu[0].areal, 3.0);
assert_eq!(b.vindu[0].u_verdi, 1.2);
assert_eq!(b.vindu[0].g_verdi, Some(0.5));
}
#[test]
fn polygonareal_av_kvadrat() {
let p = [
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 3.0, 0.0],
[0.0, 3.0, 0.0],
];
assert!((polygonareal(&p) - 6.0).abs() < 1e-12);
}
}
+751
View File
@@ -0,0 +1,751 @@
//! Import fra IFC (IFC2x3 og IFC4), som er formatet Vectorworks eksporterer med
//! «Export IFC Project».
//!
//! Importen henter klimaskjermen: yttervegger, tak, gulv, vinduer og dører, med areal,
//! U-verdi og himmelretning. Areal og U-verdi hentes fra mengder (`IfcElementQuantity`) og
//! egenskapssett (`Pset_WallCommon.ThermalTransmittance` m.fl.) når modellen har dem, ellers
//! av geometriske attributter. Himmelretningen utledes av elementets plassering: veggens
//! lokale x-akse gir planet, og ytre normal velges som den som peker bort fra bygningens
//! tyngdepunkt. Prosjektets sanne nord (`IfcGeometricRepresentationContext.TrueNorth`)
//! legges til.
use crate::inndata::step::{self, Arg, Entitet, Fil};
use crate::modell::*;
use crate::Result;
use anyhow::Context;
use std::collections::HashMap;
use std::path::Path;
pub struct Importrapport {
pub bygning: Bygning,
pub merknader: Vec<String>,
}
const VEGGTYPER: [&str; 3] = ["IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL"];
const PLATETYPER: [&str; 3] = ["IFCSLAB", "IFCROOF", "IFCSLABSTANDARDCASE"];
pub fn les(sti: &Path) -> Result<Importrapport> {
let raa = std::fs::read(sti).with_context(|| format!("leser {}", sti.display()))?;
let tekst = String::from_utf8_lossy(&raa);
fra_tekst(
&tekst,
sti.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Bygning"),
)
}
pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let fil = step::les(tekst);
let mut merknader = Vec::new();
let lengdeskala = lengdeenhet(&fil);
let sant_nord = sant_nord(&fil);
let egenskaper = samle_egenskaper(&fil);
let mengder = samle_mengder(&fil);
let (vindu_vert, dor_vert) = samle_apninger(&fil);
// Areal og volum fra rom.
let mut bra = 0.0;
let mut volum = 0.0;
for (r, _) in fil.av_type("IFCSPACE") {
if let Some(m) = mengder.get(&r) {
bra += m
.get("NetFloorArea")
.or_else(|| m.get("GrossFloorArea"))
.copied()
.unwrap_or(0.0);
volum += m
.get("NetVolume")
.or_else(|| m.get("GrossVolume"))
.copied()
.unwrap_or(0.0);
}
}
// Plasseringer for alle bygningsdeler.
let mut plassering: HashMap<u64, (f64, f64, f64, f64, f64)> = HashMap::new();
for (r, e) in fil.av_typer(&VEGGTYPER).chain(fil.av_typer(&PLATETYPER)) {
if let Some(p) = e.arg(5).and_then(|a| a.som_ref()) {
if let Some(v) = global_plassering(&fil, p, lengdeskala) {
plassering.insert(r, v);
}
}
}
// Tyngdepunkt av ytterveggene, for å bestemme hvilken vei normalen peker.
let ytre: Vec<u64> = fil
.av_typer(&VEGGTYPER)
.filter(|(r, _)| er_ytre(&egenskaper, *r).unwrap_or(true))
.map(|(r, _)| r)
.collect();
let (mut cx, mut cy, mut n) = (0.0, 0.0, 0.0);
for r in &ytre {
if let Some((x, y, _, _, _)) = plassering.get(r) {
cx += x;
cy += y;
n += 1.0;
}
}
if n > 0.0 {
cx /= n;
cy /= n;
}
let mut flater: Vec<Flate> = Vec::new();
let mut vinduer: Vec<Vindu> = Vec::new();
let mut uten_uverdi = 0usize;
let mut asimut_for_vegg: HashMap<u64, f64> = HashMap::new();
// ── vegger ──────────────────────────────────────────────────────
for (r, e) in fil.av_typer(&VEGGTYPER) {
let ytre_vegg = er_ytre(&egenskaper, r).unwrap_or(true);
let asimut = plassering
.get(&r)
.map(|(x, y, _, dx, dy)| asimut_av(*x, *y, *dx, *dy, cx, cy, sant_nord))
.unwrap_or(0.0);
asimut_for_vegg.insert(r, asimut);
if !ytre_vegg {
continue;
}
let m = mengder.get(&r);
let brutto = m
.and_then(|m| {
m.get("NetSideArea")
.or_else(|| m.get("GrossSideArea"))
.copied()
})
.unwrap_or(0.0);
if brutto <= 0.01 {
continue;
}
let u = u_verdi(&egenskaper, r);
if u.is_none() {
uten_uverdi += 1;
}
flater.push(Flate {
navn: navn_av(e, r, "Vegg"),
typ: FlateType::Vegg,
areal: brutto,
orientering: asimut,
helning: Some(90.0),
u_verdi: u,
lag: None,
masseklasse: None,
masseplassering: Masseplassering::Fordelt,
mot: Mot::Ute,
b_faktor: None,
absorptans: None,
omkrets: None,
dybde: None,
kommentar: Some("importert fra IFC".into()),
});
}
// ── plater: tak, gulv, dekke mot grunn ──────────────────────────
for (r, e) in fil.av_typer(&PLATETYPER) {
if !er_ytre(&egenskaper, r).unwrap_or(true) {
continue;
}
let m = mengder.get(&r);
let a = m
.and_then(|m| m.get("NetArea").or_else(|| m.get("GrossArea")).copied())
.unwrap_or(0.0);
if a <= 0.01 {
continue;
}
let type_tekst = e
.args
.iter()
.filter_map(|x| x.som_tekst())
.collect::<Vec<_>>()
.join(" ")
.to_uppercase();
// Plater uten ROOF-merking regnes som dekke mot grunn; kontroller `mot` etter importen
// dersom modellen har dekker mot det fri eller mot uoppvarmet kjeller.
let (typ, mot, helning) = if e.typ == "IFCROOF" || type_tekst.contains("ROOF") {
(FlateType::Tak, Mot::Ute, 0.0)
} else {
(FlateType::Gulv, Mot::Grunn, 180.0)
};
let u = u_verdi(&egenskaper, r);
if u.is_none() {
uten_uverdi += 1;
}
flater.push(Flate {
navn: navn_av(e, r, if typ == FlateType::Tak { "Tak" } else { "Gulv" }),
typ,
areal: a,
orientering: 0.0,
helning: Some(helning),
u_verdi: u,
lag: None,
masseklasse: None,
masseplassering: Masseplassering::Fordelt,
mot,
b_faktor: None,
absorptans: None,
omkrets: None,
dybde: None,
kommentar: Some("importert fra IFC".into()),
});
}
// ── vinduer og dører ────────────────────────────────────────────
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) * lengdeskala;
let bredde = e.arg(9).and_then(|a| a.som_tall()).unwrap_or(0.0) * lengdeskala;
let areal = mengder
.get(&r)
.and_then(|m| m.get("Area").or_else(|| m.get("GrossArea")).copied())
.filter(|a| *a > 0.01)
.unwrap_or(hoyde * bredde);
if areal <= 0.01 {
continue;
}
let vert = if dor {
dor_vert.get(&r)
} else {
vindu_vert.get(&r)
};
let asimut = vert
.and_then(|v| asimut_for_vegg.get(v))
.copied()
.unwrap_or(0.0);
let u = u_verdi(&egenskaper, r).unwrap_or_else(|| {
uten_uverdi += 1;
if dor {
2.0
} else {
2.4
}
});
vinduer.push(Vindu {
navn: navn_av(e, r, if dor { "Dør" } else { "Vindu" }),
areal,
antall: 1.0,
orientering: asimut,
helning: 90.0,
u_verdi: u,
g_verdi: egenskap(&egenskaper, r, "SolarHeatGainTransmittance")
.or_else(|| egenskap(&egenskaper, r, "SolarTransmittance")),
karmandel: egenskap(&egenskaper, r, "GlazingAreaFraction").map(|g| 1.0 - g),
solskjerming: None,
horisont: None,
dor,
kommentar: Some("importert fra IFC".into()),
});
}
// Trekk vindus- og dørareal fra veggene de sitter i.
for (v, vegg) in vindu_vert.iter().chain(dor_vert.iter()) {
let areal = vinduer
.iter()
.find(|x| x.kommentar.is_some() && x.navn.ends_with(&format!("#{v}")))
.map(|x| x.areal);
let _ = (areal, vegg);
}
let apningsareal_per_vegg = {
let mut m: HashMap<u64, f64> = HashMap::new();
for (a, vegg) in vindu_vert.iter().chain(dor_vert.iter()) {
let e = fil.hent(*a);
let hoyde = e
.and_then(|e| e.arg(8))
.and_then(|x| x.som_tall())
.unwrap_or(0.0)
* lengdeskala;
let bredde = e
.and_then(|e| e.arg(9))
.and_then(|x| x.som_tall())
.unwrap_or(0.0)
* lengdeskala;
let areal = mengder
.get(a)
.and_then(|q| q.get("Area").or_else(|| q.get("GrossArea")).copied())
.filter(|v| *v > 0.01)
.unwrap_or(hoyde * bredde);
*m.entry(*vegg).or_default() += areal;
}
m
};
for (r, _) in fil.av_typer(&VEGGTYPER) {
if let Some(ap) = apningsareal_per_vegg.get(&r) {
let navn = fil.hent(r).map(|e| navn_av(e, r, "Vegg"));
if let Some(f) = navn.and_then(|n| flater.iter_mut().find(|f| f.navn == n)) {
f.areal = (f.areal - ap).max(0.0);
}
}
}
flater.retain(|f| f.areal > 0.01);
if bra <= 0.0 {
bra = flater
.iter()
.filter(|f| f.typ == FlateType::Gulv)
.map(|f| f.areal)
.sum::<f64>();
if bra > 0.0 {
merknader.push(format!(
"IFC-modellen har ingen romarealer; oppvarmet BRA er anslått til {bra:.0} m² av gulvarealet. Kontroller verdien."
));
} else {
merknader.push(
"IFC-modellen har verken romarealer eller gulvflater; fyll inn oppvarmet_bra."
.into(),
);
}
}
if uten_uverdi > 0 {
merknader.push(format!(
"{uten_uverdi} bygningsdeler mangler U-verdi i IFC-filen. Legg inn U-verdier, eller la enok \
bruke typiske verdier for byggeåret."
));
}
if n == 0.0 {
merknader.push(
"Fant ingen plasseringer for ytterveggene; alle himmelretninger er satt til nord."
.into(),
);
} else {
merknader.push(
"Himmelretningene er utledet av elementenes plassering og bygningens tyngdepunkt. Kontroller dem mot tegningen."
.into(),
);
}
merknader.push(
"IFC beskriver ikke drift, lekkasjetall eller energiforsyning. Fyll inn [ventilasjon], \
[infiltrasjon], [oppvarming], [tappevann] og [klima] før beregning."
.into(),
);
let navn = fil
.av_type("IFCBUILDING")
.next()
.and_then(|(_, e)| e.navn().map(|s| s.to_string()))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| standardnavn.to_string());
let bygning = Bygning {
bygning: BygningInfo {
navn,
kategori: Kategori::Smaahus,
byggeaar: 2020,
oppvarmet_bra: bra,
oppvarmet_volum: (volum > 0.0).then_some(volum),
antall_boenheter: 1,
bygningens_oppvarmede_bra: None,
adresse: adresse(&fil),
gnr_bnr: None,
beskrivelse: Some("Importert fra IFC".into()),
energiraadgiver: None,
oppdragsgiver: None,
},
klima: Klima::default(),
flate: flater,
vindu: vinduer,
kuldebroer: Kuldebroer::default(),
infiltrasjon: Infiltrasjon::default(),
ventilasjon: Ventilasjon::default(),
tappevann: Tappevann::default(),
oppvarming: Oppvarming::default(),
distribusjon: Distribusjon::default(),
solcelle: None,
kjoling: Kjoling::default(),
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
};
Ok(Importrapport { bygning, merknader })
}
fn navn_av(e: &Entitet, r: u64, standard: &str) -> String {
match e.navn() {
Some(n) if !n.trim().is_empty() => format!("{n} #{r}"),
_ => format!("{standard} #{r}"),
}
}
fn adresse(fil: &Fil) -> Option<String> {
let (_, e) = fil.av_type("IFCPOSTALADDRESS").next()?;
let linjer: Vec<String> = e
.arg(4)?
.som_liste()
.iter()
.filter_map(|a| a.som_tekst().map(|s| s.to_string()))
.collect();
let by = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or("");
let mut ut = linjer.join(", ");
if !by.is_empty() {
if !ut.is_empty() {
ut.push_str(", ");
}
ut.push_str(by);
}
(!ut.is_empty()).then_some(ut)
}
/// Faktor som gjør lengder om til meter.
fn lengdeenhet(fil: &Fil) -> f64 {
for (_, e) in fil.av_type("IFCSIUNIT") {
let er_lengde = e.arg(1).and_then(|a| a.som_tekst()) == Some("LENGTHUNIT");
if !er_lengde {
continue;
}
let prefiks = e.arg(2).and_then(|a| a.som_tekst()).unwrap_or("");
return match prefiks {
"MILLI" => 0.001,
"CENTI" => 0.01,
"DECI" => 0.1,
"KILO" => 1000.0,
_ => 1.0,
};
}
1.0
}
/// Prosjektets dreining mot sant nord, i grader med klokka.
fn sant_nord(fil: &Fil) -> f64 {
for (_, e) in fil.av_type("IFCGEOMETRICREPRESENTATIONCONTEXT") {
if let Some(r) = e.arg(5).and_then(|a| a.som_ref()) {
if let Some(d) = fil.hent(r) {
let v = d.arg(0).map(|a| a.som_liste()).unwrap_or(&[]);
if v.len() >= 2 {
let (x, y) = (
v[0].som_tall().unwrap_or(0.0),
v[1].som_tall().unwrap_or(1.0),
);
// TrueNorth peker mot nord i prosjektets koordinater.
return x.atan2(y).to_degrees();
}
}
}
}
0.0
}
/// Sammensatt global plassering: (x, y, z, dx, dy) der (dx, dy) er lokal x-akse i planet.
fn global_plassering(fil: &Fil, r: u64, skala: f64) -> Option<(f64, f64, f64, f64, f64)> {
let mut x = 0.0;
let mut y = 0.0;
let mut z = 0.0;
let mut dx = 1.0;
let mut dy = 0.0;
let mut kjede = Vec::new();
let mut gjeldende = Some(r);
let mut vakt = 0;
while let Some(p) = gjeldende {
vakt += 1;
if vakt > 64 {
break;
}
let e = fil.hent(p)?;
if e.typ != "IFCLOCALPLACEMENT" {
break;
}
kjede.push(p);
gjeldende = e.arg(0).and_then(|a| a.som_ref());
}
for p in kjede.into_iter().rev() {
let e = fil.hent(p)?;
let ax = e
.arg(1)
.and_then(|a| a.som_ref())
.and_then(|a| fil.hent(a))?;
let (lx, ly, lz) = punkt(fil, ax.arg(0).and_then(|a| a.som_ref()), skala);
let (rx, ry) = retning(fil, ax.arg(2).and_then(|a| a.som_ref())).unwrap_or((1.0, 0.0));
// Roter det lokale punktet med foreldrenes akse og legg det til.
let nx = x + lx * dx - ly * dy;
let ny = y + lx * dy + ly * dx;
x = nx;
y = ny;
z += lz;
let ndx = dx * rx - dy * ry;
let ndy = dy * rx + dx * ry;
let lengde = (ndx * ndx + ndy * ndy).sqrt().max(1e-12);
dx = ndx / lengde;
dy = ndy / lengde;
}
Some((x, y, z, dx, dy))
}
fn punkt(fil: &Fil, r: Option<u64>, skala: f64) -> (f64, f64, f64) {
let Some(e) = r.and_then(|r| fil.hent(r)) else {
return (0.0, 0.0, 0.0);
};
let v = e.arg(0).map(|a| a.som_liste()).unwrap_or(&[]);
let g = |i: usize| v.get(i).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala;
(g(0), g(1), g(2))
}
fn retning(fil: &Fil, r: Option<u64>) -> Option<(f64, f64)> {
let e = r.and_then(|r| fil.hent(r))?;
let v = e.arg(0).map(|a| a.som_liste()).unwrap_or(&[]);
if v.len() < 2 {
return None;
}
Some((
v[0].som_tall().unwrap_or(1.0),
v[1].som_tall().unwrap_or(0.0),
))
}
/// Himmelretning for en vegg: normalen står vinkelrett på veggens akse, og velges slik at
/// den peker bort fra bygningens tyngdepunkt.
fn asimut_av(x: f64, y: f64, dx: f64, dy: f64, cx: f64, cy: f64, sant_nord: f64) -> f64 {
let kandidater = [(dy, -dx), (-dy, dx)];
let ut = (x - cx, y - cy);
let valgt = if kandidater[0].0 * ut.0 + kandidater[0].1 * ut.1
>= kandidater[1].0 * ut.0 + kandidater[1].1 * ut.1
{
kandidater[0]
} else {
kandidater[1]
};
// Asimut fra nord med klokka; prosjektets y-akse peker mot nord etter TrueNorth-dreining.
(valgt.0.atan2(valgt.1).to_degrees() + sant_nord).rem_euclid(360.0)
}
type Egenskaper = HashMap<u64, HashMap<String, Arg>>;
/// Samler alle egenskaper fra `IfcRelDefinesByProperties` per objekt.
fn samle_egenskaper(fil: &Fil) -> Egenskaper {
let mut ut: Egenskaper = HashMap::new();
for (_, rel) in fil.av_type("IFCRELDEFINESBYPROPERTIES") {
let objekter = rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]);
let Some(def) = rel
.arg(5)
.and_then(|a| a.som_ref())
.and_then(|r| fil.hent(r))
else {
continue;
};
if def.typ != "IFCPROPERTYSET" {
continue;
}
let mut verdier = HashMap::new();
for p in def.arg(4).map(|a| a.som_liste()).unwrap_or(&[]) {
let Some(pe) = p.som_ref().and_then(|r| fil.hent(r)) else {
continue;
};
if pe.typ != "IFCPROPERTYSINGLEVALUE" {
continue;
}
let (Some(navn), Some(v)) = (pe.arg(0).and_then(|a| a.som_tekst()), pe.arg(2)) else {
continue;
};
verdier.insert(navn.to_string(), v.clone());
}
for o in objekter {
if let Some(r) = o.som_ref() {
ut.entry(r).or_default().extend(verdier.clone());
}
}
}
// Typeobjektenes egenskaper arves av forekomstene.
let mut arv: Vec<(u64, HashMap<String, Arg>)> = Vec::new();
for (_, rel) in fil.av_type("IFCRELDEFINESBYTYPE") {
let Some(typ) = rel.arg(5).and_then(|a| a.som_ref()) else {
continue;
};
let Some(verdier) = ut.get(&typ).cloned() else {
continue;
};
for o in rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]) {
if let Some(r) = o.som_ref() {
arv.push((r, verdier.clone()));
}
}
}
for (r, verdier) in arv {
let e = ut.entry(r).or_default();
for (k, v) in verdier {
e.entry(k).or_insert(v);
}
}
ut
}
/// Samler alle mengder fra `IfcElementQuantity` per objekt.
fn samle_mengder(fil: &Fil) -> HashMap<u64, HashMap<String, f64>> {
let mut ut: HashMap<u64, HashMap<String, f64>> = HashMap::new();
for (_, rel) in fil.av_type("IFCRELDEFINESBYPROPERTIES") {
let objekter = rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]);
let Some(def) = rel
.arg(5)
.and_then(|a| a.som_ref())
.and_then(|r| fil.hent(r))
else {
continue;
};
if def.typ != "IFCELEMENTQUANTITY" {
continue;
}
let mut verdier = HashMap::new();
for q in def.arg(5).map(|a| a.som_liste()).unwrap_or(&[]) {
let Some(qe) = q.som_ref().and_then(|r| fil.hent(r)) else {
continue;
};
let Some(navn) = qe.arg(0).and_then(|a| a.som_tekst()) else {
continue;
};
if let Some(v) = qe.arg(3).and_then(|a| a.som_tall()) {
verdier.insert(navn.to_string(), v);
}
}
for o in objekter {
if let Some(r) = o.som_ref() {
ut.entry(r).or_default().extend(verdier.clone());
}
}
}
ut
}
/// Kobler vinduer og dører til veggen de sitter i, via åpningene.
fn samle_apninger(fil: &Fil) -> (HashMap<u64, u64>, HashMap<u64, u64>) {
// åpning → vertselement
let mut vert_for_apning: HashMap<u64, u64> = HashMap::new();
for (_, rel) in fil.av_type("IFCRELVOIDSELEMENT") {
if let (Some(vert), Some(apning)) = (
rel.arg(4).and_then(|a| a.som_ref()),
rel.arg(5).and_then(|a| a.som_ref()),
) {
vert_for_apning.insert(apning, vert);
}
}
let mut vindu = HashMap::new();
let mut dor = HashMap::new();
for (_, rel) in fil.av_type("IFCRELFILLSELEMENT") {
let (Some(apning), Some(element)) = (
rel.arg(4).and_then(|a| a.som_ref()),
rel.arg(5).and_then(|a| a.som_ref()),
) else {
continue;
};
let Some(vert) = vert_for_apning.get(&apning) else {
continue;
};
match fil.hent(element).map(|e| e.typ.as_str()) {
Some("IFCDOOR") => {
dor.insert(element, *vert);
}
Some("IFCWINDOW") => {
vindu.insert(element, *vert);
}
_ => {}
}
}
(vindu, dor)
}
fn egenskap(e: &Egenskaper, r: u64, navn: &str) -> Option<f64> {
e.get(&r)?.get(navn)?.som_tall()
}
fn u_verdi(e: &Egenskaper, r: u64) -> Option<f64> {
[
"ThermalTransmittance",
"UValue",
"U-Value",
"Varmegjennomgangskoeffisient",
]
.iter()
.find_map(|n| egenskap(e, r, n))
.filter(|u| *u > 0.0 && *u < 20.0)
}
fn er_ytre(e: &Egenskaper, r: u64) -> Option<bool> {
let v = e.get(&r)?.get("IsExternal")?;
match v {
Arg::Enum(s) => Some(s == "T" || s.eq_ignore_ascii_case("true")),
Arg::Tall(n) => Some(*n != 0.0),
Arg::Tekst(s) => Some(s == "T" || s.eq_ignore_ascii_case("true")),
_ => None,
}
}
#[cfg(test)]
mod tester {
use super::*;
const PROVE: &str = r#"ISO-10303-21;
HEADER;ENDSEC;
DATA;
#1= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
#2= IFCDIRECTION((0.,1.));
#3= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#50,#2);
#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);
#20= IFCLOCALPLACEMENT($,#21);
#21= IFCAXIS2PLACEMENT3D(#22,$,$);
#22= IFCCARTESIANPOINT((0.,0.,0.));
#100= IFCWALLSTANDARDCASE('w1',$,'Sorvegg',$,$,#110,$,$);
#110= IFCLOCALPLACEMENT(#20,#111);
#111= IFCAXIS2PLACEMENT3D(#112,$,#113);
#112= IFCCARTESIANPOINT((0.,-5.,0.));
#113= IFCDIRECTION((1.,0.,0.));
#120= IFCWALLSTANDARDCASE('w2',$,'Nordvegg',$,$,#130,$,$);
#130= IFCLOCALPLACEMENT(#20,#131);
#131= IFCAXIS2PLACEMENT3D(#132,$,#133);
#132= IFCCARTESIANPOINT((0.,5.,0.));
#133= IFCDIRECTION((1.,0.,0.));
#200= IFCPROPERTYSINGLEVALUE('ThermalTransmittance',$,IFCREAL(0.18),$);
#201= IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$);
#202= IFCPROPERTYSET('ps',$,'Pset_WallCommon',$,(#200,#201));
#203= IFCRELDEFINESBYPROPERTIES('r',$,$,$,(#100,#120),#202);
#210= IFCQUANTITYAREA('NetSideArea',$,$,25.0,$);
#211= IFCELEMENTQUANTITY('q',$,'BaseQuantities',$,$,(#210));
#212= IFCRELDEFINESBYPROPERTIES('r2',$,$,$,(#100,#120),#211);
#300= IFCSPACE('s',$,'Stue',$,$,#20,$,$,.ELEMENT.,.INTERNAL.,$);
#310= IFCQUANTITYAREA('NetFloorArea',$,$,100.0,$);
#311= IFCQUANTITYVOLUME('NetVolume',$,$,250.0,$);
#312= IFCELEMENTQUANTITY('q2',$,'BaseQuantities',$,$,(#310,#311));
#313= IFCRELDEFINESBYPROPERTIES('r3',$,$,$,(#300),#312);
ENDSEC;
END-ISO-10303-21;"#;
#[test]
fn leser_vegger_areal_uverdi_og_himmelretning() {
let r = fra_tekst(PROVE, "prove").unwrap();
let b = &r.bygning;
assert_eq!(b.bygning.navn, "Testhus");
assert_eq!(b.bygning.oppvarmet_bra, 100.0);
assert_eq!(b.bygning.oppvarmet_volum, Some(250.0));
assert_eq!(b.flate.len(), 2);
for f in &b.flate {
assert_eq!(f.areal, 25.0);
assert_eq!(f.u_verdi, Some(0.18));
}
// Veggen ved y = 5 vender sør (180°), den ved y = +5 vender nord (0°).
let sor = b
.flate
.iter()
.find(|f| f.navn.starts_with("Sorvegg"))
.unwrap();
let nord = b
.flate
.iter()
.find(|f| f.navn.starts_with("Nordvegg"))
.unwrap();
assert!(
(sor.orientering - 180.0).abs() < 1.0,
"sør = {}",
sor.orientering
);
assert!(
nord.orientering < 1.0 || nord.orientering > 359.0,
"nord = {}",
nord.orientering
);
}
#[test]
fn millimeterenhet_skaleres() {
let t = PROVE.replace(
"#1= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);",
"#1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);",
);
let r = fra_tekst(&t, "prove").unwrap();
// Arealene kommer fra mengder og er alltid i m², så de endres ikke.
assert_eq!(r.bygning.flate[0].areal, 25.0);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Innlesing av bygningsbeskrivelser: `bygning.toml` og gbXML fra BIM-verktøy.
pub mod gbxml;
pub mod ifc;
pub mod regneark;
pub mod step;
pub mod toml_inn;
pub use toml_inn::{les_bygning, slaa_sammen_scenario};
+244
View File
@@ -0,0 +1,244 @@
//! Import fra regneark (CSV), for eksempel en Vectorworks-arbeidstegning eksportert med
//! «Export Worksheet». Formatet er bevisst enkelt, slik at det kan settes opp som en
//! rapport i BIM-verktøyet og eksporteres direkte.
//!
//! Hver rad begynner med en type:
//!
//! ```text
//! type,navn,areal,u_verdi,orientering,helning,mot,g_verdi,antall
//! bygning,Nordhusstykket 5,140,,,,smaahus,1948,
//! vegg,Yttervegg sør,32,1.0,180,90,ute,,
//! vindu,Stue sør,4.2,2.8,180,90,,0.75,2
//! ```
//!
//! For `bygning`-raden brukes kolonnene: navn, oppvarmet BRA, (tom), (tom), (tom),
//! kategori, byggeår.
use crate::modell::*;
use crate::Result;
use anyhow::{bail, Context};
use std::path::Path;
pub struct Importrapport {
pub bygning: Bygning,
pub merknader: Vec<String>,
}
pub fn les(sti: &Path) -> Result<Importrapport> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
fra_tekst(
&tekst,
sti.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Bygning"),
)
}
fn skilletegn(tekst: &str) -> u8 {
let forste = tekst.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
if forste.matches(';').count() > forste.matches(',').count() {
b';'
} else {
b','
}
}
fn tall(s: Option<&str>) -> Option<f64> {
let s = s?.trim().replace(',', ".");
if s.is_empty() {
return None;
}
s.parse().ok()
}
pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let mut leser = csv::ReaderBuilder::new()
.delimiter(skilletegn(tekst))
.flexible(true)
.has_headers(true)
.from_reader(tekst.as_bytes());
let mut merknader = Vec::new();
let mut flater = Vec::new();
let mut vinduer = Vec::new();
let mut navn = standardnavn.to_string();
let mut kategori = Kategori::Smaahus;
let mut byggeaar = 2020;
let mut bra = 0.0;
for (nr, rad) in leser.records().enumerate() {
let rad = rad.with_context(|| format!("rad {}", nr + 2))?;
let felt = |i: usize| rad.get(i).map(|s| s.trim()).filter(|s| !s.is_empty());
let Some(typ) = felt(0).map(|s| s.to_lowercase()) else {
continue;
};
let radnavn = felt(1).unwrap_or("uten navn").to_string();
match typ.as_str() {
"bygning" => {
navn = radnavn;
bra = tall(felt(2)).unwrap_or(0.0);
if let Some(k) = felt(6) {
kategori = match k.to_lowercase().as_str() {
"smaahus" | "småhus" | "enebolig" => Kategori::Smaahus,
"boligblokk" | "leilighet" => Kategori::Boligblokk,
"kontorbygg" | "kontor" => Kategori::Kontorbygg,
"skolebygg" | "skole" => Kategori::Skolebygg,
"barnehage" => Kategori::Barnehage,
annen => {
merknader
.push(format!("Ukjent bygningskategori «{annen}»; bruker småhus."));
Kategori::Smaahus
}
};
}
if let Some(a) = tall(felt(7)) {
byggeaar = a as i32;
}
}
"vegg" | "tak" | "gulv" => {
let ft = match typ.as_str() {
"vegg" => FlateType::Vegg,
"tak" => FlateType::Tak,
_ => FlateType::Gulv,
};
let mot = match felt(6).map(|s| s.to_lowercase()).as_deref() {
Some("grunn") | Some("grunnen") => Mot::Grunn,
Some("uoppvarmet") | Some("kjeller") | Some("loft") => Mot::Uoppvarmet,
_ => Mot::Ute,
};
let Some(areal) = tall(felt(2)) else {
merknader.push(format!(
"Raden «{radnavn}» mangler areal og er hoppet over."
));
continue;
};
flater.push(Flate {
navn: radnavn,
typ: ft,
areal,
orientering: tall(felt(4)).unwrap_or(0.0),
helning: tall(felt(5)),
u_verdi: tall(felt(3)),
lag: None,
masseklasse: None,
masseplassering: Masseplassering::Fordelt,
mot,
b_faktor: None,
absorptans: None,
omkrets: None,
dybde: None,
kommentar: None,
});
}
"vindu" | "dør" | "dor" => {
let Some(areal) = tall(felt(2)) else {
merknader.push(format!(
"Raden «{radnavn}» mangler areal og er hoppet over."
));
continue;
};
let Some(u) = tall(felt(3)) else {
merknader.push(format!(
"Raden «{radnavn}» mangler U-verdi og er hoppet over."
));
continue;
};
vinduer.push(Vindu {
navn: radnavn,
areal,
antall: tall(felt(8)).unwrap_or(1.0),
orientering: tall(felt(4)).unwrap_or(0.0),
helning: tall(felt(5)).unwrap_or(90.0),
u_verdi: u,
g_verdi: tall(felt(7)),
karmandel: None,
solskjerming: None,
horisont: None,
dor: typ != "vindu",
kommentar: None,
});
}
annen => merknader.push(format!("Ukjent radtype «{annen}» på rad {}.", nr + 2)),
}
}
if flater.is_empty() {
bail!("regnearket inneholder ingen flater (rader som begynner med vegg, tak eller gulv)");
}
if bra <= 0.0 {
bra = flater
.iter()
.filter(|f| f.typ == FlateType::Gulv)
.map(|f| f.areal)
.sum();
merknader.push(format!(
"Regnearket mangler en bygning-rad med oppvarmet BRA; satt til {bra:.0} m² av gulvarealet."
));
}
merknader.push(
"Fyll inn [ventilasjon], [infiltrasjon], [oppvarming], [tappevann] og [klima] før beregning."
.into(),
);
let bygning = Bygning {
bygning: BygningInfo {
navn,
kategori,
byggeaar,
oppvarmet_bra: bra,
oppvarmet_volum: None,
antall_boenheter: 1,
bygningens_oppvarmede_bra: None,
adresse: None,
gnr_bnr: None,
beskrivelse: Some("Importert fra regneark".into()),
energiraadgiver: None,
oppdragsgiver: None,
},
klima: Klima::default(),
flate: flater,
vindu: vinduer,
kuldebroer: Kuldebroer::default(),
infiltrasjon: Infiltrasjon::default(),
ventilasjon: Ventilasjon::default(),
tappevann: Tappevann::default(),
oppvarming: Oppvarming::default(),
distribusjon: Distribusjon::default(),
solcelle: None,
kjoling: Kjoling::default(),
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
};
Ok(Importrapport { bygning, merknader })
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn leser_enkelt_regneark() {
let csv = "type,navn,areal,u_verdi,orientering,helning,mot,g_verdi,antall\n\
bygning,Testhus,140,,,,smaahus,1948,\n\
vegg,Yttervegg sør,32,1.0,180,90,ute,,\n\
gulv,Kjellergulv,70,0.8,,180,grunn,,\n\
vindu,Stue sør,2.1,2.8,180,90,,0.75,2\n";
let r = fra_tekst(csv, "x").unwrap();
assert_eq!(r.bygning.bygning.navn, "Testhus");
assert_eq!(r.bygning.bygning.oppvarmet_bra, 140.0);
assert_eq!(r.bygning.bygning.byggeaar, 1948);
assert_eq!(r.bygning.flate.len(), 2);
assert_eq!(r.bygning.flate[1].mot, Mot::Grunn);
assert_eq!(r.bygning.vindu.len(), 1);
assert_eq!(r.bygning.vindu[0].antall, 2.0);
}
#[test]
fn godtar_semikolon_og_desimalkomma() {
let csv = "type;navn;areal;u_verdi\nvegg;Vegg;32,5;1,0\n";
let r = fra_tekst(csv, "x").unwrap();
assert_eq!(r.bygning.flate[0].areal, 32.5);
assert_eq!(r.bygning.flate[0].u_verdi, Some(1.0));
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Minimal leser for STEP-filer etter ISO 10303-21, som er formatet IFC bruker.
//!
//! Filen består av linjer på formen `#12=IFCWALL('guid',#3,'Navn',...);`. Denne modulen
//! deler filen i entiteter med type og argumenter, uten å tolke IFC-skjemaet.
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub enum Arg {
Ref(u64),
Tekst(String),
Tall(f64),
Enum(String),
Liste(Vec<Arg>),
Tom,
}
impl Arg {
pub fn som_ref(&self) -> Option<u64> {
match self {
Arg::Ref(r) => Some(*r),
_ => None,
}
}
pub fn som_tall(&self) -> Option<f64> {
match self {
Arg::Tall(v) => Some(*v),
_ => None,
}
}
pub fn som_tekst(&self) -> Option<&str> {
match self {
Arg::Tekst(s) | Arg::Enum(s) => Some(s),
_ => None,
}
}
pub fn som_liste(&self) -> &[Arg] {
match self {
Arg::Liste(l) => l,
_ => &[],
}
}
}
#[derive(Debug, Clone)]
pub struct Entitet {
pub typ: String,
pub args: Vec<Arg>,
}
impl Entitet {
pub fn arg(&self, i: usize) -> Option<&Arg> {
self.args.get(i)
}
pub fn navn(&self) -> Option<&str> {
self.arg(2).and_then(|a| a.som_tekst())
}
}
pub struct Fil {
pub entiteter: HashMap<u64, Entitet>,
}
impl Fil {
pub fn hent(&self, r: u64) -> Option<&Entitet> {
self.entiteter.get(&r)
}
/// Alle entiteter av en gitt type (uten hensyn til store/små bokstaver).
pub fn av_type<'a>(&'a self, typ: &'a str) -> impl Iterator<Item = (u64, &'a Entitet)> + 'a {
self.entiteter
.iter()
.filter_map(move |(k, e)| (e.typ == typ).then_some((*k, e)))
}
pub fn av_typer<'a>(
&'a self,
typer: &'a [&'a str],
) -> impl Iterator<Item = (u64, &'a Entitet)> + 'a {
self.entiteter
.iter()
.filter_map(move |(k, e)| typer.contains(&e.typ.as_str()).then_some((*k, e)))
}
}
/// Deler en STEP-fil i entiteter.
pub fn les(tekst: &str) -> Fil {
let mut entiteter = HashMap::new();
let mut setning = String::new();
let mut i_streng = false;
let mut i_data = false;
for ch in tekst.chars() {
if !i_data {
setning.push(ch);
if setning.len() > 4096 {
setning.drain(..2048);
}
if setning.to_uppercase().ends_with("DATA;") {
i_data = true;
setning.clear();
}
continue;
}
match ch {
'\'' => {
i_streng = !i_streng;
setning.push(ch);
}
';' if !i_streng => {
if let Some((nr, e)) = tolk_setning(&setning) {
entiteter.insert(nr, e);
}
setning.clear();
}
'\n' | '\r' if !i_streng => setning.push(' '),
_ => setning.push(ch),
}
}
Fil { entiteter }
}
fn tolk_setning(s: &str) -> Option<(u64, Entitet)> {
let s = s.trim();
let s = s.strip_prefix('#')?;
let (nr, rest) = s.split_once('=')?;
let nr: u64 = nr.trim().parse().ok()?;
let rest = rest.trim();
let paren = rest.find('(')?;
let typ = rest[..paren].trim().to_uppercase();
if typ.is_empty() {
return None;
}
let innhold = &rest[paren + 1..rest.rfind(')')?];
Some((
nr,
Entitet {
typ,
args: del_argumenter(innhold),
},
))
}
/// Deler argumentlisten på toppnivå-kommaer.
fn del_argumenter(s: &str) -> Vec<Arg> {
let mut ut = Vec::new();
let mut dybde = 0usize;
let mut i_streng = false;
let mut start = 0usize;
let b: Vec<char> = s.chars().collect();
let mut i = 0;
while i < b.len() {
let c = b[i];
match c {
'\'' => {
if i_streng && i + 1 < b.len() && b[i + 1] == '\'' {
i += 1;
} else {
i_streng = !i_streng;
}
}
'(' if !i_streng => dybde += 1,
')' if !i_streng => dybde = dybde.saturating_sub(1),
',' if !i_streng && dybde == 0 => {
ut.push(tolk_arg(&b[start..i].iter().collect::<String>()));
start = i + 1;
}
_ => {}
}
i += 1;
}
if start <= b.len() {
let siste: String = b[start..].iter().collect();
if !(siste.trim().is_empty() && ut.is_empty()) {
ut.push(tolk_arg(&siste));
}
}
ut
}
fn tolk_arg(s: &str) -> Arg {
let t = s.trim();
if t.is_empty() || t == "$" || t == "*" {
return Arg::Tom;
}
if let Some(r) = t.strip_prefix('#') {
if let Ok(n) = r.trim().parse::<u64>() {
return Arg::Ref(n);
}
}
if t.starts_with('\'') && t.ends_with('\'') && t.len() >= 2 {
return Arg::Tekst(avkod_tekst(&t[1..t.len() - 1]));
}
if t.starts_with('.') && t.ends_with('.') && t.len() >= 2 {
return Arg::Enum(t[1..t.len() - 1].to_string());
}
if t.starts_with('(') && t.ends_with(')') {
return Arg::Liste(del_argumenter(&t[1..t.len() - 1]));
}
if let Ok(v) = t.parse::<f64>() {
return Arg::Tall(v);
}
// Typede verdier som IFCREAL(3.0) eller IFCLABEL('x').
if let Some(p) = t.find('(') {
if t.ends_with(')') {
let indre = &t[p + 1..t.len() - 1];
return tolk_arg(indre);
}
}
Arg::Tekst(t.to_string())
}
/// Dekoder ISO 10303-21-strenger: `''` er apostrof, `\X2\…\X0\` er UTF-16, `\X\hh` er latin-1.
fn avkod_tekst(s: &str) -> String {
let mut ut = String::with_capacity(s.len());
let b: Vec<char> = s.chars().collect();
let mut i = 0;
while i < b.len() {
if b[i] == '\'' && i + 1 < b.len() && b[i + 1] == '\'' {
ut.push('\'');
i += 2;
continue;
}
if b[i] == '\\' && i + 3 < b.len() && b[i + 1] == 'X' && b[i + 2] == '2' && b[i + 3] == '\\'
{
let mut j = i + 4;
let mut enheter: Vec<u16> = Vec::new();
while j + 3 < b.len() {
if b[j] == '\\' {
break;
}
let hex: String = b[j..(j + 4).min(b.len())].iter().collect();
match u16::from_str_radix(&hex, 16) {
Ok(v) => enheter.push(v),
Err(_) => break,
}
j += 4;
}
ut.push_str(&String::from_utf16_lossy(&enheter));
// Hopp over avslutningen \X0\.
while j < b.len() && b[j] != '0' {
j += 1;
}
i = (j + 2).min(b.len());
continue;
}
if b[i] == '\\' && i + 3 < b.len() && b[i + 1] == 'X' && b[i + 2] == '\\' {
let hex: String = b[i + 3..(i + 5).min(b.len())].iter().collect();
if let Ok(v) = u8::from_str_radix(&hex, 16) {
ut.push(v as char);
i += 5;
continue;
}
}
ut.push(b[i]);
i += 1;
}
ut
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn leser_entiteter_og_argumenter() {
let f = les("ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n\
#1= IFCWALL('0xABC',#2,'Yttervegg s\\X2\\00F8\\X0\\r',$,$,#10,#20,$);\n\
#2= IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);\n\
#10= IFCLOCALPLACEMENT($,#11);\n\
ENDSEC;");
assert_eq!(f.entiteter.len(), 3);
let v = f.hent(1).unwrap();
assert_eq!(v.typ, "IFCWALL");
assert_eq!(v.navn(), Some("Yttervegg sør"));
assert_eq!(v.arg(5).unwrap().som_ref(), Some(10));
assert_eq!(
f.hent(2).unwrap().arg(3).unwrap().som_tekst(),
Some("ADDED")
);
assert_eq!(f.hent(10).unwrap().arg(0), Some(&Arg::Tom));
}
#[test]
fn haandterer_lister_og_semikolon_i_streng() {
let f = les("DATA;\n#5= IFCPOLYLOOP((#1,#2,#3));\n#6= IFCLABEL('a;b');\nENDSEC;");
assert_eq!(f.hent(5).unwrap().arg(0).unwrap().som_liste().len(), 3);
assert_eq!(f.hent(6).unwrap().arg(0).unwrap().som_tekst(), Some("a;b"));
}
#[test]
fn leser_tall_og_typede_verdier() {
let f = les("DATA;\n#7= IFCPROPERTYSINGLEVALUE('U',$,IFCREAL(0.18),$);\nENDSEC;");
assert_eq!(f.hent(7).unwrap().arg(2).unwrap().som_tall(), Some(0.18));
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Leser `bygning.toml` og fletter inn scenarioendringer.
use crate::modell::{Bygning, Scenario};
use crate::Result;
use anyhow::Context;
use std::path::Path;
use toml::Value;
pub fn les_bygning(sti: &Path) -> Result<Bygning> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))
}
/// Lager en ny bygning der scenarioets endringer er flettet inn.
///
/// Tabeller flettes rekursivt. Tabellarrayer (`flate`, `vindu`, `oppvarming.kilde` …) matches
/// på feltet `navn` når det finnes, ellers på `type`; elementer som ikke finnes fra før legges
/// til, og `slett = true` fjerner et element. Andre verdier erstattes.
pub fn slaa_sammen_scenario(b: &Bygning, s: &Scenario) -> Result<Bygning> {
let mut grunn = Value::try_from(b).context("serialiserer bygningen for scenario")?;
flett(&mut grunn, &s.endringer);
grunn
.try_into()
.with_context(|| format!("scenarioet «{}» gir en ugyldig bygningsbeskrivelse", s.navn))
}
fn navnenokkel(v: &Value) -> Option<String> {
let t = v.as_table()?;
t.get("navn")
.and_then(|x| x.as_str())
.or_else(|| t.get("type").and_then(|x| x.as_str()))
.map(|s| s.to_string())
}
fn skal_slettes(v: &Value) -> bool {
v.as_table()
.and_then(|t| t.get("slett"))
.and_then(|x| x.as_bool())
.unwrap_or(false)
}
fn flett(grunn: &mut Value, over: &Value) {
match (grunn, over) {
(Value::Table(g), Value::Table(o)) => {
for (k, v) in o {
if k == "slett" {
continue;
}
match g.get_mut(k) {
Some(eksisterende) => flett(eksisterende, v),
None => {
g.insert(k.clone(), v.clone());
}
}
}
}
(Value::Array(g), Value::Array(o)) => {
for element in o {
match navnenokkel(element) {
Some(navn) => {
let treff = g
.iter()
.position(|e| navnenokkel(e).as_deref() == Some(&navn));
match treff {
Some(i) if skal_slettes(element) => {
g.remove(i);
}
Some(i) => flett(&mut g[i], element),
None if skal_slettes(element) => {}
None => g.push(element.clone()),
}
}
None => g.push(element.clone()),
}
}
}
(g, o) => *g = o.clone(),
}
}
#[cfg(test)]
mod tester {
use super::*;
const GRUNN: &str = r#"
[bygning]
navn = "Testhus"
kategori = "smaahus"
byggeaar = 1948
oppvarmet_bra = 100.0
[[flate]]
navn = "Yttervegg"
type = "vegg"
areal = 80.0
u_verdi = 1.0
[[flate]]
navn = "Tak"
type = "tak"
areal = 100.0
u_verdi = 0.8
"#;
#[test]
fn scenario_endrer_u_verdi_for_navngitt_flate() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let s = Scenario {
navn: "Etterisolert".into(),
beskrivelse: None,
endringer: toml::from_str(r#"flate = [{ navn = "Yttervegg", u_verdi = 0.22 }]"#)
.unwrap(),
};
let ny = slaa_sammen_scenario(&b, &s).unwrap();
assert_eq!(ny.flate.len(), 2);
assert_eq!(ny.flate[0].u_verdi, Some(0.22));
assert_eq!(ny.flate[1].u_verdi, Some(0.8));
}
#[test]
fn scenario_legger_til_og_sletter() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let s = Scenario {
navn: "Ombygd".into(),
beskrivelse: None,
endringer: toml::from_str(
r#"flate = [{ navn = "Tak", slett = true }, { navn = "Nytt tak", type = "tak", areal = 100.0, u_verdi = 0.13 }]"#,
)
.unwrap(),
};
let ny = slaa_sammen_scenario(&b, &s).unwrap();
let navn: Vec<&str> = ny.flate.iter().map(|f| f.navn.as_str()).collect();
assert_eq!(navn, vec!["Yttervegg", "Nytt tak"]);
}
#[test]
fn scenario_endrer_skalarfelt() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let s = Scenario {
navn: "Varmepumpe".into(),
beskrivelse: None,
endringer: toml::from_str(
r#"
[infiltrasjon]
n50 = 3.0
[oppvarming]
kilde = [{ type = "luft_luft_vp", andel_rom = 0.6 }]
"#,
)
.unwrap(),
};
let ny = slaa_sammen_scenario(&b, &s).unwrap();
assert_eq!(ny.infiltrasjon.n50, Some(3.0));
assert_eq!(ny.oppvarming.kilde.len(), 1);
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Kjører en fullstendig energiberegning: fra bygningsbeskrivelse til energikarakter.
use crate::data::Datakilde;
use crate::klima::{korreksjon, tmy, Vaerdata, REFERANSEKLIMA};
use crate::merking::{karakter, Energikarakter};
use crate::modell::lost::{Forutsetning, Lost};
use crate::modell::Bygning;
use crate::motor::{iso52016::Systemtilskudd, simuler, Timeserie, Varmetapsbudsjett};
use crate::systemer::{self, Effektbehov, Energibudsjett, Levert, Systemoppsett};
use crate::Result;
use serde::Serialize;
/// Alt som kommer ut av én beregning.
pub struct Resultat {
pub bygning: Bygning,
pub lost: Lost,
pub vaerdata: Vaerdata,
pub klimanavn: String,
pub varmetap: Varmetapsbudsjett,
pub timeserie: Timeserie,
pub systemer: Systemoppsett,
pub budsjett: Energibudsjett,
pub levert: Levert,
pub karakter: Energikarakter,
pub effektbehov: Effektbehov,
pub solceller: Option<crate::systemer::solcelle::Produksjon>,
pub merknader: Vec<String>,
}
/// Kompakt, serialiserbart sammendrag for `resultat.json`.
#[derive(Serialize)]
pub struct Sammendrag<'a> {
pub navn: &'a str,
pub kategori: &'a str,
pub byggeaar: i32,
pub oppvarmet_bra: f64,
pub klimasted: &'a str,
pub referanseklima: &'a str,
pub varmetapsbudsjett: &'a Varmetapsbudsjett,
pub energibudsjett: &'a Energibudsjett,
pub netto_spesifikk: f64,
pub levert: &'a Levert,
pub energikarakter: &'a Energikarakter,
pub effektbehov: &'a Effektbehov,
pub forutsetninger: &'a [Forutsetning],
pub merknader: &'a [String],
pub maanedsverdier: Maanedsverdier,
}
#[derive(Serialize)]
pub struct Maanedsverdier {
pub maaned: [&'static str; 12],
pub romoppvarming: [f64; 12],
pub ventilasjonsvarme: [f64; 12],
pub varmtvann: [f64; 12],
pub vifter_pumper: [f64; 12],
pub belysning: [f64; 12],
pub utstyr: [f64; 12],
pub kjoling: [f64; 12],
pub utetemperatur: [f64; 12],
}
impl Resultat {
pub fn sammendrag(&self) -> Sammendrag<'_> {
let m = |s: &[f64]| Timeserie::maaned_kwh(s);
let mut vifter_pumper = m(&self.timeserie.vifter);
let pumper = m(&self.timeserie.pumper);
let mut kjoling = m(&self.timeserie.romkjoling);
let ventkjol = m(&self.timeserie.ventilasjonskjoling);
for i in 0..12 {
vifter_pumper[i] += pumper[i];
kjoling[i] += ventkjol[i];
}
Sammendrag {
navn: &self.lost.navn,
kategori: self.lost.kategori.nokkel(),
byggeaar: self.lost.byggeaar,
oppvarmet_bra: self.lost.bra,
klimasted: &self.klimanavn,
referanseklima: REFERANSEKLIMA,
varmetapsbudsjett: &self.varmetap,
energibudsjett: &self.budsjett,
netto_spesifikk: self.budsjett.netto_spesifikk(self.lost.bra),
levert: &self.levert,
energikarakter: &self.karakter,
effektbehov: &self.effektbehov,
forutsetninger: &self.lost.forutsetninger,
merknader: &self.merknader,
maanedsverdier: Maanedsverdier {
maaned: crate::MAANEDER,
romoppvarming: m(&self.timeserie.romoppvarming),
ventilasjonsvarme: m(&self.timeserie.ventilasjonsvarme),
varmtvann: m(&self.timeserie.varmtvann),
vifter_pumper,
belysning: m(&self.timeserie.belysning),
utstyr: m(&self.timeserie.utstyr),
kjoling,
utetemperatur: self.vaerdata.maanedsmiddeltemperatur(),
},
}
}
}
/// Henter værdata for bygningen: egen EPW-fil, oppgitt kommune, eller referanseklimaet.
pub fn hent_klima(b: &Bygning) -> Result<(Vaerdata, String, bool)> {
if let Some(sti) = &b.klima.epw {
let v = Vaerdata::fra_fil(sti)?;
let navn = v.sted.clone();
let er_referanse = crate::data::normaliser_stedsnavn(&navn)
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
return Ok((v, navn, er_referanse));
}
let sted = b
.klima
.kommune
.clone()
.unwrap_or_else(|| REFERANSEKLIMA.to_string());
let er_referanse = crate::data::normaliser_stedsnavn(&sted)
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
let v = tmy::for_kommune(&sted)?;
Ok((v, sted, er_referanse))
}
pub fn kjor(b: &Bygning, kilde: &Datakilde) -> Result<Resultat> {
let lost = Lost::fra(b, kilde)?;
let (vaerdata, klimanavn, er_referanse) = hent_klima(b)?;
kjor_med_klima(b, kilde, lost, vaerdata, klimanavn, er_referanse)
}
pub fn kjor_med_klima(
b: &Bygning,
kilde: &Datakilde,
lost: Lost,
vaerdata: Vaerdata,
klimanavn: String,
er_referanse: bool,
) -> Result<Resultat> {
let varmetap = Varmetapsbudsjett::beregn(&lost);
let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?;
let mut merknader = systemer_oppsett.merknader.clone();
let tilskudd = Systemtilskudd {
tappevann: systemer::tappevannstilskudd(&lost, &systemer_oppsett),
};
let timeserie = simuler(&lost, &vaerdata, tilskudd)?;
let budsjett = systemer::energibudsjett(&timeserie, &systemer_oppsett);
let solceller = b.solcelle.as_ref().map(|s| {
systemer::solcelle::beregn(s, &vaerdata, &timeserie, &systemer_oppsett, lost.albedo)
});
let (egenbruk, eksportert) = solceller
.as_ref()
.map(|p| (p.egenbruk_kwh, p.eksportert_kwh))
.unwrap_or((0.0, 0.0));
let korrigering = if er_referanse {
None
} else {
let tabell = kilde.klimakorreksjon()?;
let klimaavhengig = systemer::klimaavhengig_spesifikk(&budsjett, lost.bra);
let k = korreksjon::korriger(&tabell, &klimanavn, lost.kategori, klimaavhengig);
if k.is_none() {
merknader.push(format!(
"Fant ingen klimakorrigeringsfaktor for «{klimanavn}». Energiytelsen er oppgitt i lokalt \
klima og er ikke sammenlignbar med energimerkeskalaen."
));
}
k
};
let vekting = kilde.vekting()?;
let levert = systemer::levert(
&budsjett,
&vekting,
lost.bra,
korrigering,
egenbruk,
eksportert,
);
let skala = kilde.skala()?;
let karakter = karakter(
skala.for_kategori(lost.kategori)?,
levert.spesifikk_vektet,
lost.skala_areal,
);
let effektbehov = systemer::effektbehov(&timeserie, &systemer_oppsett);
Ok(Resultat {
bygning: b.clone(),
lost,
vaerdata,
klimanavn,
varmetap,
timeserie,
systemer: systemer_oppsett,
budsjett,
levert,
karakter,
effektbehov,
solceller,
merknader,
})
}
+136
View File
@@ -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()
}
}
+43
View File
@@ -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,
})
}
+11
View File
@@ -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";
+112
View File
@@ -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 HayDavies, 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` (08759, 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 24).
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); // HayDavies 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 {
// StefanBoltzmann 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
}
}
+109
View File
@@ -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)
}
+39
View File
@@ -0,0 +1,39 @@
//! enok årlig energiberegning for bygninger etter NS 3031 med timesmetode (ISO 52016-1).
//!
//! Modulene følger beregningsgangen i standarden:
//! `modell` (inndata) → `klima` (TMY + sol) → `motor` (netto energibehov, beregningspunkt A)
//! → `systemer` (brutto, tilført, levert og vektet energi, BE) → `merking` (energikarakter)
//! → `rapport` (CSV, JSON, Typst/PDF, Enova-XML).
pub mod data;
pub mod inndata;
pub mod kjor;
pub mod klima;
pub mod merking;
pub mod modell;
pub mod motor;
pub mod rapport;
pub mod systemer;
pub use anyhow::{Context, Result};
pub use modell::lost::Lost;
/// Antall timer i beregningsåret.
pub const TIMER: usize = 8760;
/// Måneder (0-basert) for hver time i året.
pub fn maaned_for_time(t: usize) -> usize {
const DAGER: [usize; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut dag = t / 24;
for (m, d) in DAGER.iter().enumerate() {
if dag < *d {
return m;
}
dag -= d;
}
11
}
pub const MAANEDER: [&str; 12] = [
"jan", "feb", "mar", "apr", "mai", "jun", "jul", "aug", "sep", "okt", "nov", "des",
];
+315
View File
@@ -0,0 +1,315 @@
//! enok — årlig energiberegning og energirapport for bygninger etter NS 3031.
use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use enok::data::Datakilde;
use enok::inndata;
use enok::kjor;
use enok::rapport;
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "enok",
version,
about = "Årlig energiberegning og energirapport for bygninger etter NS 3031:2025",
long_about = "Beregner timesvis energibehov for et helt år etter NS 3031:2025 (NS-EN ISO 52016-1), \
fastsetter energikarakter etter Enovas skala fra 1.1.2026, og skriver rapport som \
Typst/PDF med CSV- og JSON-vedlegg."
)]
struct Cli {
/// Katalog som overstyrer de innebygde datafilene (skala, normerte verdier, materialer).
#[arg(long, global = true, value_name = "KATALOG")]
data: Option<PathBuf>,
#[command(subcommand)]
kommando: Kommando,
}
#[derive(Subcommand)]
enum Kommando {
/// Beregner energibehov og skriver rapport.
Beregn(Beregn),
/// Importerer en bygningsmodell fra gbXML, IFC eller regneark og skriver bygning.toml.
Import(Import),
/// Viser tilgjengelige klimasteder og nøkkeltall for ett sted.
Klima(Klima),
/// Viser energikarakterskalaen for en bygningskategori.
Skala(Skala),
}
#[derive(Args)]
struct Beregn {
/// Bygningsbeskrivelse (bygning.toml).
fil: PathBuf,
/// Utdatakatalog. Standard: `ut/<bygningsnavn>`.
#[arg(short, long, value_name = "KATALOG")]
ut: Option<PathBuf>,
/// Hopper over PDF-kompilering (skriver bare rapport.typ).
#[arg(long)]
ingen_pdf: bool,
/// Skriver også XML for Enovas energimerkesystem.
#[arg(long)]
enova_xml: bool,
/// Hopper over timeserie.csv (8760 rader).
#[arg(long)]
ingen_timeserie: bool,
/// Beregner bare hovedalternativet, uten scenarioene i filen.
#[arg(long)]
uten_scenarier: bool,
}
#[derive(Args)]
struct Import {
/// gbXML-, IFC- eller CSV-fil eksportert fra BIM-verktøyet.
fil: PathBuf,
/// Utdatafil. Standard: samme navn med endelsen .toml.
#[arg(short, long, value_name = "FIL")]
ut: Option<PathBuf>,
}
#[derive(Args)]
struct Klima {
/// Lister alle kommuner med klimadata.
#[arg(long)]
liste: bool,
/// Viser nøkkeltall for én kommune.
#[arg(long, value_name = "KOMMUNE")]
kommune: Option<String>,
}
#[derive(Args)]
struct Skala {
/// Bygningskategori, for eksempel smaahus eller boligblokk.
#[arg(default_value = "smaahus")]
kategori: String,
/// Oppvarmet BRA for arealkorreksjonen, m².
#[arg(long, default_value_t = 160.0)]
areal: f64,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let kilde = Datakilde::ny(cli.data.clone());
match cli.kommando {
Kommando::Beregn(a) => beregn(a, &kilde),
Kommando::Import(a) => import(a),
Kommando::Klima(a) => klima(a),
Kommando::Skala(a) => skala(a, &kilde),
}
}
fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> {
let bygning = inndata::les_bygning(&a.fil)?;
let ut =
a.ut.unwrap_or_else(|| PathBuf::from("ut").join(filnavnvennlig(&bygning.bygning.navn)));
std::fs::create_dir_all(&ut)?;
eprintln!("Henter klimadata …");
let (vaerdata, klimanavn, er_referanse) = kjor::hent_klima(&bygning)?;
eprintln!("Beregner {}", bygning.bygning.navn);
let lost = enok::Lost::fra(&bygning, kilde)?;
let resultat = kjor::kjor_med_klima(
&bygning,
kilde,
lost,
vaerdata.clone(),
klimanavn.clone(),
er_referanse,
)?;
let mut scenarier = Vec::new();
if !a.uten_scenarier {
for s in &bygning.scenario {
eprintln!("Beregner scenario «{}» …", s.navn);
let endret = inndata::slaa_sammen_scenario(&bygning, s)?;
let lost = enok::Lost::fra(&endret, kilde)?;
let r = kjor::kjor_med_klima(
&endret,
kilde,
lost,
vaerdata.clone(),
klimanavn.clone(),
er_referanse,
)?;
scenarier.push((s.navn.clone(), r));
}
}
rapport::json_ut::skriv(&resultat, &ut.join("resultat.json"))?;
rapport::csv_ut::maanedsverdier(&resultat, &ut.join("maaned.csv"))?;
if !a.ingen_timeserie {
rapport::csv_ut::timeserie(&resultat, &ut.join("timeserie.csv"))?;
}
if a.enova_xml {
rapport::enova_xml::skriv(&resultat, &ut.join("enova-2014.xml"))?;
}
rapport::typst_ut::skriv(&resultat, &ut, &scenarier)?;
if !a.ingen_pdf {
let pdf = rapport::typst_ut::kompiler(&ut)?;
eprintln!("Rapport: {}", pdf.display());
}
skriv_sammendrag(&resultat, &scenarier);
eprintln!("Filer skrevet til {}", ut.display());
Ok(())
}
fn skriv_sammendrag(r: &kjor::Resultat, scenarier: &[(String, kjor::Resultat)]) {
let l = &r.lost;
println!();
println!(
" {} ({}, {} m², byggeår {})",
l.navn,
r.karakter.kategorinavn,
l.bra.round(),
l.byggeaar
);
println!(" Klimadata: {}", r.klimanavn);
println!();
println!(
" Varmetapstall {:>8.2} W/(m²K)",
r.varmetap.totalt
);
println!(
" Netto energibehov {:>8.1} kWh/(m²·år)",
r.budsjett.netto_spesifikk(l.bra)
);
println!(
" Levert energi {:>8.1} kWh/(m²·år)",
r.levert.levert_totalt / l.bra
);
println!(
" Vektet levert energi {:>8.1} kWh/(m²·år)",
r.levert.spesifikk_vektet
);
println!(" Energikarakter {}", r.karakter.bokstav);
if let Some(t) = r.karakter.til_neste {
println!(" ({:.0} kWh/(m²·år) fra neste karakter)", t);
}
if !scenarier.is_empty() {
println!();
println!(" Tiltak:");
for (navn, s) in scenarier {
println!(
" {:<34} {:>6.1} kWh/(m²·år) {}",
navn, s.levert.spesifikk_vektet, s.karakter.bokstav
);
}
}
println!();
}
fn import(a: Import) -> Result<()> {
let endelse = a
.fil
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let (bygning, merknader) = match endelse.as_str() {
"gbxml" | "xml" => {
let r = inndata::gbxml::les(&a.fil)?;
(r.bygning, r.merknader)
}
"ifc" => {
let r = inndata::ifc::les(&a.fil)?;
(r.bygning, r.merknader)
}
"csv" => {
let r = inndata::regneark::les(&a.fil)?;
(r.bygning, r.merknader)
}
annen => bail!(
"ukjent filtype «{annen}». enok importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
),
};
let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml"));
let tekst = toml::to_string_pretty(&bygning).context("serialiserer bygningen til TOML")?;
let mut innhold =
String::from("# Importert med enok. Fyll ut feltene som er merket nedenfor.\n");
for m in &merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&tekst);
std::fs::write(&ut, innhold)?;
println!("Skrev {}", ut.display());
for m in &merknader {
println!("{m}");
}
Ok(())
}
fn klima(a: Klima) -> Result<()> {
if a.liste {
for k in enok::klima::tmy::kommuner()? {
println!("{k}");
}
return Ok(());
}
let kommune = a
.kommune
.unwrap_or_else(|| enok::klima::REFERANSEKLIMA.to_string());
let v = enok::klima::tmy::for_kommune(&kommune)?;
let m = v.maanedsmiddeltemperatur();
println!(
"{} ({:.4}° N, {:.4}° Ø, {:.0} moh.)",
v.sted, v.bredde, v.lengde, v.hoyde
);
println!("Årsmiddeltemperatur {:.1} °C", v.aarsmiddeltemperatur());
println!("Graddager under 17 °C {:.0}", v.graddager(17.0));
println!(
"Global solinnstråling {:.0} kWh/(m²·år)",
v.ghi.iter().sum::<f64>() / 1000.0
);
println!();
println!("Månedsmiddeltemperatur, °C");
for (i, navn) in enok::MAANEDER.iter().enumerate() {
println!(" {navn} {:>6.1}", m[i]);
}
Ok(())
}
fn skala(a: Skala, kilde: &Datakilde) -> Result<()> {
let skala = kilde.skala()?;
let k = skala
.0
.get(a.kategori.as_str())
.with_context(|| format!("ukjent kategori «{}»", a.kategori))?;
let kar = enok::merking::karakter(k, f64::NAN, a.areal);
println!("{} (arealkorreksjon for {:.0} m²)", k.navn, a.areal);
println!();
for (i, bokstav) in enok::merking::skala::BOKSTAVER.iter().enumerate() {
if i < 6 {
println!(" {bokstav}{:>6.1} kWh/(m²·år)", kar.grenser[i]);
} else {
println!(" {bokstav} > {:>6.1} kWh/(m²·år)", kar.grenser[5]);
}
}
Ok(())
}
fn filnavnvennlig(s: &str) -> String {
s.chars()
.map(|c| match c {
'æ' => 'a',
'ø' => 'o',
'å' => 'a',
'Æ' => 'A',
'Ø' => 'O',
'Å' => 'A',
c if c.is_alphanumeric() => c.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
#[allow(dead_code)]
fn sjekk_sti(p: &Path) -> Result<()> {
if !p.exists() {
bail!("finner ikke {}", p.display());
}
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
//! Energikarakter etter energimerkeforskriften § 10b og Enovas skala fra 1.1.2026.
pub mod skala;
pub use skala::{karakter, Energikarakter};
+107
View File
@@ -0,0 +1,107 @@
//! Oppslag i energikarakterskalaen med arealkorreksjon.
//!
//! Skalaen gir øvre grense for hver karakter i kWh/m² spesifikk vektet levert energi.
//! For småhus og boligblokk legges en arealkorreksjon `k/A` til hver grense, der A er
//! oppvarmet BRA etter reglene i Enovas notat: for småhus hele bygningens oppvarmede BRA,
//! for boligblokk blokkens BRA delt på antall boenheter (eller boenhetens eget areal).
use crate::data::SkalaKategori;
use serde::Serialize;
pub const BOKSTAVER: [&str; 7] = ["A", "B", "C", "D", "E", "F", "G"];
#[derive(Serialize, Clone, Debug)]
pub struct Energikarakter {
pub bokstav: &'static str,
pub spesifikk_vektet: f64,
/// Grensene etter arealkorreksjon, kWh/m².
pub grenser: Vec<f64>,
pub kategorinavn: String,
/// Arealet som ble brukt i arealkorreksjonen.
pub korreksjonsareal: f64,
/// Hvor mye energiytelsen må ned for å nå neste karakter, kWh/m² (None for A).
pub til_neste: Option<f64>,
}
pub fn karakter(skala: &SkalaKategori, spesifikk_vektet: f64, areal: f64) -> Energikarakter {
let mut grenser = Vec::with_capacity(6);
for i in 0..6 {
let korreksjon = skala
.arealkorreksjon
.map(|k| if areal > 0.0 { k[i] / areal } else { 0.0 })
.unwrap_or(0.0);
grenser.push(skala.grenser[i] + korreksjon);
}
let indeks = grenser
.iter()
.position(|g| spesifikk_vektet <= *g + 1e-9)
.unwrap_or(6);
let til_neste = if indeks == 0 {
None
} else {
Some(spesifikk_vektet - grenser[indeks - 1])
};
Energikarakter {
bokstav: BOKSTAVER[indeks],
spesifikk_vektet,
grenser,
kategorinavn: skala.navn.clone(),
korreksjonsareal: areal,
til_neste,
}
}
#[cfg(test)]
mod tester {
use super::*;
fn smaahus() -> SkalaKategori {
SkalaKategori {
navn: "Småhus".into(),
grenser: [85.0, 95.0, 155.0, 210.0, 265.0, 315.0],
arealkorreksjon: Some([800.0, 1600.0, 2500.0, 4100.0, 5800.0, 8000.0]),
}
}
#[test]
fn arealkorreksjon_flytter_grensene() {
let s = smaahus();
// 160 m²: A-grensen blir 85 + 800/160 = 90.
let k = karakter(&s, 90.0, 160.0);
assert_eq!(k.bokstav, "A");
let k = karakter(&s, 90.1, 160.0);
assert_eq!(k.bokstav, "B");
}
#[test]
fn lite_hus_faar_romsligere_grenser() {
let s = smaahus();
// Et lite hus (80 m²) får A-grense 85 + 10 = 95.
let k = karakter(&s, 94.0, 80.0);
assert_eq!(k.bokstav, "A");
// Samme ytelse i et stort hus (400 m²) gir A-grense 87 → karakter B.
let k = karakter(&s, 94.0, 400.0);
assert_eq!(k.bokstav, "B");
}
#[test]
fn over_f_gir_g() {
let s = smaahus();
let k = karakter(&s, 500.0, 160.0);
assert_eq!(k.bokstav, "G");
assert!(k.til_neste.unwrap() > 0.0);
}
#[test]
fn uten_arealkorreksjon() {
let s = SkalaKategori {
navn: "Kontorbygning".into(),
grenser: [75.0, 90.0, 140.0, 190.0, 235.0, 285.0],
arealkorreksjon: None,
};
assert_eq!(karakter(&s, 75.0, 3600.0).bokstav, "A");
assert_eq!(karakter(&s, 75.01, 3600.0).bokstav, "B");
assert_eq!(karakter(&s, 285.0, 3600.0).bokstav, "F");
assert_eq!(karakter(&s, 285.1, 3600.0).bokstav, "G");
}
}
+539
View File
@@ -0,0 +1,539 @@
//! Oppløsning av inndata: fyller manglende felter med normerte verdier eller verdier utledet
//! av byggeår, og fører logg over hva som er antatt. Loggen gjengis i rapporten slik at leseren
//! ser hvilke tall som er dokumentert og hvilke som er normerte.
use crate::data::{Datakilde, Materialer, Normerte};
use crate::modell::*;
use crate::Result;
use anyhow::{bail, Context};
use serde::Serialize;
/// Én forutsetning som ikke kom fra inndatafilen.
#[derive(Serialize, Clone, Debug)]
pub struct Forutsetning {
pub felt: String,
pub verdi: String,
pub kilde: String,
}
#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Regulering {
/// Settpunktet gjelder operativ temperatur (middel av luft- og strålingstemperatur).
Operativ,
/// Settpunktet gjelder lufttemperatur.
Luft,
}
/// En ferdig oppløst flate klar for timesberegning.
#[derive(Serialize, Clone, Debug)]
pub struct LostFlate {
pub navn: String,
pub typ: FlateType,
pub areal: f64,
pub u_verdi: f64,
pub helning: f64,
pub orientering: f64,
pub kappa: f64,
pub masseplassering: Masseplassering,
pub mot: Mot,
pub b_faktor: f64,
pub absorptans: f64,
/// Sant for vinduer og dører (to noder, ingen masse, solinnstråling gjennom glasset).
pub glass: bool,
/// Effektiv solfaktor inkl. karmandel og skjerming (bare vinduer).
pub g_effektiv: f64,
/// Skjermingsfaktor når solskjerming er i bruk (1,0 = ingen skjerming).
pub skjermingsfaktor: f64,
pub automatisk_skjerming: bool,
pub horisont: f64,
}
#[derive(Serialize, Clone, Debug)]
pub struct Lost {
pub navn: String,
pub kategori: Kategori,
pub byggeaar: i32,
pub bra: f64,
pub volum: f64,
pub antall_boenheter: u32,
/// Areal som brukes i arealkorreksjonen på karakterskalaen.
pub skala_areal: f64,
pub flater: Vec<LostFlate>,
/// W/K, summert transmisjonstap uten kuldebroer.
pub kuldebro_w_per_k: f64,
pub n50: f64,
pub skjerming: Skjerming,
pub ventilasjon: LostVentilasjon,
pub laster: LostLaster,
pub settpunkt_oppvarming: f64,
pub settpunkt_natt: f64,
pub settpunkt_kjoling: Option<f64>,
/// Vinduslufting: (settpunkt °C, største luftmengde m³/(h·m²)).
pub lufting: Option<(f64, f64)>,
pub regulering: Regulering,
pub innvendig_varmekapasitet: f64,
pub maks_varmeeffekt: Option<f64>,
pub maks_kjoleeffekt: Option<f64>,
pub albedo: f64,
pub forutsetninger: Vec<Forutsetning>,
}
#[derive(Serialize, Clone, Debug)]
pub struct LostVentilasjon {
pub typ: VentilasjonType,
/// m³/(h·m²) i driftstiden.
pub luftmengde: f64,
pub luftmengde_natt: f64,
pub gjenvinning: f64,
pub sfp: f64,
pub varmebatteri: bool,
pub tilluft_settpunkt: f64,
pub driftstid: crate::data::Tidsrom,
}
/// Timesprofiler for interne laster, W/m² oppvarmet BRA.
#[derive(Serialize, Clone, Debug)]
pub struct LostLaster {
pub belysning: [f64; 24],
pub utstyr_effekt: [f64; 24],
pub utstyr_varme: [f64; 24],
pub personer: [f64; 24],
pub varmtvann: [f64; 24],
pub pumper: [f64; 24],
pub oppvarming_driftstid: crate::data::Tidsrom,
}
const R_SI: f64 = 0.13;
const R_SE: f64 = 0.04;
impl Lost {
pub fn fra(b: &Bygning, kilde: &Datakilde) -> Result<Self> {
let mut f = Vec::new();
let normerte = kilde.normerte(b.bygning.kategori)?;
let materialer = kilde.materialer()?;
let info = &b.bygning;
if info.oppvarmet_bra <= 0.0 {
bail!("oppvarmet_bra må være større enn null");
}
let volum = info.oppvarmet_volum.unwrap_or_else(|| {
f.push(Forutsetning {
felt: "bygning.oppvarmet_volum".into(),
verdi: format!("{:.0} m³", info.oppvarmet_bra * 2.4),
kilde: "antatt 2,4 m romhøyde".into(),
});
info.oppvarmet_bra * 2.4
});
let periode = materialer.periode_for(info.byggeaar);
let mut flater = Vec::new();
for fl in &b.flate {
flater.push(los_flate(fl, &materialer, periode, &mut f)?);
}
for v in &b.vindu {
flater.push(los_vindu(v, &mut f));
}
if flater.is_empty() {
bail!("bygningen har ingen flater; legg inn minst én [[flate]]");
}
// Kuldebroer.
let kuldebro_w_per_k = if !b.kuldebroer.liste.is_empty() {
b.kuldebroer.liste.iter().map(|k| k.psi * k.lengde).sum()
} else {
let n = b.kuldebroer.normalisert.unwrap_or_else(|| {
let v = periode
.map(|p| p.kuldebro)
.unwrap_or(normerte.termisk.kuldebroer_normalisert);
f.push(Forutsetning {
felt: "kuldebroer.normalisert".into(),
verdi: format!("{v:.2} W/(m²K)"),
kilde: periode
.map(|p| format!("typisk for {}", p.navn))
.unwrap_or_else(|| "normert verdi".into()),
});
v
});
n * info.oppvarmet_bra
};
// Lekkasjetall.
let n50 = b.infiltrasjon.n50.unwrap_or_else(|| {
let v = periode.map(|p| p.n50).unwrap_or(3.0);
f.push(Forutsetning {
felt: "infiltrasjon.n50".into(),
verdi: format!("{v:.1} h⁻¹"),
kilde: periode
.map(|p| format!("typisk for {}", p.navn))
.unwrap_or_else(|| "antatt".into()),
});
v
});
let ventilasjon = los_ventilasjon(&b.ventilasjon, &normerte, &mut f);
let laster = los_laster(b, &normerte, &mut f);
let settpunkt_oppvarming = b
.drift
.settpunkt_oppvarming
.unwrap_or(normerte.settpunkt.oppvarming);
let settpunkt_natt = b
.drift
.nattsenking
.unwrap_or(normerte.settpunkt.oppvarming_natt);
let settpunkt_kjoling = if b.kjoling.ingen {
None
} else {
Some(b.kjoling.settpunkt.unwrap_or(normerte.settpunkt.kjoling))
};
let innvendig_varmekapasitet = b.drift.innvendig_varmekapasitet.unwrap_or(10_000.0);
// Vinduslufting brukes når bygningen ikke har kjøleanlegg.
let lufting = if b.lufting.ingen || settpunkt_kjoling.is_some() {
None
} else {
let sett = b.lufting.settpunkt.unwrap_or_else(|| {
f.push(Forutsetning {
felt: "lufting.settpunkt".into(),
verdi: "24 °C".into(),
kilde: "antatt grense for vinduslufting i bygning uten kjøling".into(),
});
24.0
});
let maks = b.lufting.maks_luftmengde.unwrap_or_else(|| {
f.push(Forutsetning {
felt: "lufting.maks_luftmengde".into(),
verdi: "8 m³/(h·m²)".into(),
kilde: "antatt største luftmengde ved vinduslufting".into(),
});
8.0
});
Some((sett, maks))
};
let skala_areal = match info.kategori {
Kategori::Smaahus => info.bygningens_oppvarmede_bra.unwrap_or(info.oppvarmet_bra),
Kategori::Boligblokk => info
.bygningens_oppvarmede_bra
.map(|a| a / info.antall_boenheter.max(1) as f64)
.unwrap_or_else(|| {
if info.antall_boenheter > 1 {
info.oppvarmet_bra / info.antall_boenheter as f64
} else {
info.oppvarmet_bra
}
}),
_ => info.oppvarmet_bra,
};
Ok(Lost {
navn: info.navn.clone(),
kategori: info.kategori,
byggeaar: info.byggeaar,
bra: info.oppvarmet_bra,
volum,
antall_boenheter: info.antall_boenheter.max(1),
skala_areal,
flater,
kuldebro_w_per_k,
n50,
skjerming: b.infiltrasjon.skjerming,
ventilasjon,
laster,
settpunkt_oppvarming,
settpunkt_natt,
settpunkt_kjoling,
lufting,
regulering: Regulering::Operativ,
innvendig_varmekapasitet,
maks_varmeeffekt: b.oppvarming.maks_effekt,
maks_kjoleeffekt: b.kjoling.maks_effekt,
albedo: b.klima.albedo.unwrap_or(0.2),
forutsetninger: f,
})
}
/// Samlet areal mot det fri og mot grunn/uoppvarmet (klimaskjermareal), m².
pub fn klimaskjermareal(&self) -> f64 {
self.flater.iter().map(|f| f.areal).sum()
}
pub fn formfaktor(&self) -> f64 {
self.klimaskjermareal() / self.bra
}
pub fn vindusareal(&self) -> f64 {
self.flater
.iter()
.filter(|f| f.glass)
.map(|f| f.areal)
.sum()
}
}
fn los_flate(
fl: &Flate,
materialer: &Materialer,
periode: Option<&crate::data::Periode>,
logg: &mut Vec<Forutsetning>,
) -> Result<LostFlate> {
if fl.areal <= 0.0 {
bail!("flaten «{}» har areal {} m²", fl.navn, fl.areal);
}
// U-verdi: oppgitt, utledet av lag, eller typisk for byggeperioden.
let (u, kappa_lag) = if let Some(u) = fl.u_verdi {
(u, None)
} else if let Some(lag) = &fl.lag {
let mut r = R_SI + R_SE;
let mut kappa = 0.0;
for l in lag {
let m = materialer.materiale(&l.materiale).with_context(|| {
format!("ukjent materiale «{}» i flaten «{}»", l.materiale, fl.navn)
})?;
r += l.tykkelse / m.lambda;
kappa += l.tykkelse * m.rho * m.c;
}
(1.0 / r, Some(kappa))
} else {
let v = periode
.map(|p| match fl.typ {
FlateType::Vegg => p.vegg,
FlateType::Tak => p.tak,
FlateType::Gulv => p.gulv,
})
.with_context(|| format!("flaten «{}» mangler u_verdi og lag", fl.navn))?;
logg.push(Forutsetning {
felt: format!("flate.{}.u_verdi", fl.navn),
verdi: format!("{v:.2} W/(m²K)"),
kilde: periode
.map(|p| format!("typisk for {}", p.navn))
.unwrap_or_default(),
});
(v, None)
};
let helning = fl.helning.unwrap_or(match fl.typ {
FlateType::Vegg => 90.0,
FlateType::Tak => 0.0,
FlateType::Gulv => 180.0,
});
let kappa = match (kappa_lag, fl.masseklasse) {
(_, Some(k)) => k.kappa(),
(Some(k), None) => k,
(None, None) => {
let k = Masseklasse::Middels.kappa();
logg.push(Forutsetning {
felt: format!("flate.{}.masseklasse", fl.navn),
verdi: "middels".into(),
kilde: "ISO 52016-1 tabell B.14".into(),
});
k
}
};
let b_faktor = match fl.mot {
Mot::Ute => 1.0,
Mot::Grunn => 1.0, // håndteres med egen bakketemperatur i timesberegningen
Mot::Uoppvarmet => fl.b_faktor.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: format!("flate.{}.b_faktor", fl.navn),
verdi: "0,5".into(),
kilde: "NS-EN ISO 13789, uoppvarmet rom".into(),
});
0.5
}),
};
Ok(LostFlate {
navn: fl.navn.clone(),
typ: fl.typ,
areal: fl.areal,
u_verdi: u,
helning,
orientering: fl.orientering,
kappa,
masseplassering: fl.masseplassering,
mot: fl.mot,
b_faktor,
absorptans: fl.absorptans.unwrap_or(0.6),
glass: false,
g_effektiv: 0.0,
skjermingsfaktor: 1.0,
automatisk_skjerming: false,
horisont: 0.0,
})
}
fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate {
let areal = v.areal * v.antall;
let karm = v.karmandel.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: format!("vindu.{}.karmandel", v.navn),
verdi: "0,20".into(),
kilde: "normert karm- og rammeandel".into(),
});
0.20
});
let g = if v.dor {
0.0
} else {
v.g_verdi.unwrap_or_else(|| {
let antatt = if v.u_verdi <= 1.2 { 0.50 } else { 0.75 };
logg.push(Forutsetning {
felt: format!("vindu.{}.g_verdi", v.navn),
verdi: format!("{antatt:.2}"),
kilde: if v.u_verdi <= 1.2 {
"antatt 3-lags energiglass"
} else {
"antatt 2-lags glass"
}
.into(),
});
antatt
})
};
// ISO 52016-1: F_w = 0,9 korrigerer for ikke-vinkelrett innfall over året.
let g_effektiv = g * (1.0 - karm) * 0.9;
let (skjermingsfaktor, automatisk) = match &v.solskjerming {
None => (1.0, false),
Some(s) => match s.typ {
SolskjermingType::Ingen => (1.0, false),
SolskjermingType::Manuell => (s.faktor.unwrap_or(0.8), false),
SolskjermingType::Automatisk => (s.faktor.unwrap_or(0.4), true),
},
};
LostFlate {
navn: v.navn.clone(),
typ: FlateType::Vegg,
areal,
u_verdi: v.u_verdi,
helning: v.helning,
orientering: v.orientering,
kappa: 0.0,
masseplassering: Masseplassering::Fordelt,
mot: Mot::Ute,
b_faktor: 1.0,
absorptans: 0.0,
glass: true,
g_effektiv,
skjermingsfaktor,
automatisk_skjerming: automatisk,
horisont: v.horisont.unwrap_or(0.0),
}
}
fn los_ventilasjon(v: &Ventilasjon, n: &Normerte, logg: &mut Vec<Forutsetning>) -> LostVentilasjon {
let luftmengde = v.luftmengde.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: "ventilasjon.luftmengde".into(),
verdi: format!("{:.2} m³/(h·m²)", n.ventilasjon.luftmengde_driftstid),
kilde: format!("normert verdi, {}", n.kilde),
});
n.ventilasjon.luftmengde_driftstid
});
let luftmengde_natt = v
.luftmengde_natt
.unwrap_or(n.ventilasjon.luftmengde_utenfor_driftstid);
let gjenvinning = match v.typ {
VentilasjonType::Balansert => v.gjenvinning.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: "ventilasjon.gjenvinning".into(),
verdi: format!("{:.0} %", n.ventilasjon.temperaturvirkningsgrad * 100.0),
kilde: "normert temperaturvirkningsgrad".into(),
});
n.ventilasjon.temperaturvirkningsgrad
}),
_ => 0.0,
};
let sfp = v.sfp.unwrap_or(match v.typ {
VentilasjonType::Balansert => n.ventilasjon.sfp,
VentilasjonType::Avtrekk => 0.6,
VentilasjonType::Naturlig => 0.0,
});
let varmebatteri = v
.varmebatteri
.unwrap_or(matches!(v.typ, VentilasjonType::Balansert));
LostVentilasjon {
typ: v.typ,
luftmengde,
luftmengde_natt,
gjenvinning,
sfp,
varmebatteri,
tilluft_settpunkt: n.settpunkt.ventilasjon_tilluft,
driftstid: n.driftstid.ventilasjon,
}
}
fn profil(effekt: f64, tid: crate::data::Tidsrom, egen: Option<[f64; 24]>) -> [f64; 24] {
if let Some(p) = egen {
return p;
}
let mut ut = [0.0; 24];
for (h, v) in ut.iter_mut().enumerate() {
if tid.aktiv(h) {
*v = effekt;
}
}
ut
}
fn los_laster(b: &Bygning, n: &Normerte, logg: &mut Vec<Forutsetning>) -> LostLaster {
let bel = b.drift.belysning_effekt.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: "belysning".into(),
verdi: format!(
"{:.2} W/m² i {:.0} h/døgn",
n.belysning.effekt,
n.driftstid.belysning.timer()
),
kilde: format!("normert, {}", n.kilde),
});
n.belysning.effekt
});
let utstyr = b.drift.utstyr_effekt.unwrap_or(n.utstyr.effekt);
// Oppgitt utstyrseffekt skalerer varmetilskuddet i samme forhold som den normerte verdien.
let utstyr_varme = match b.drift.utstyr_effekt {
Some(e) => e * (n.utstyr.varmetilskudd / n.utstyr.effekt.max(1e-9)),
None => n.utstyr.varmetilskudd,
};
let personer = b
.drift
.personer_varmetilskudd
.unwrap_or(n.personer.varmetilskudd);
let tappevann = b.tappevann.energibehov.map(|kwh| {
// kWh/(m²·år) → W/m² i driftstiden.
kwh * 1000.0 / (365.0 * n.driftstid.varmtvann.timer())
});
let vv = tappevann.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: "tappevann.energibehov".into(),
verdi: format!(
"{:.1} kWh/(m²·år)",
n.varmtvann.effekt * n.driftstid.varmtvann.timer() * 365.0 / 1000.0
),
kilde: format!("normert, {}", n.kilde),
});
n.varmtvann.effekt
});
let pumpeeffekt = match b.distribusjon.typ {
DistribusjonType::Punkt => b.distribusjon.pumpe_effekt.unwrap_or(0.0),
_ => b
.distribusjon
.pumpe_effekt
.unwrap_or(n.pumper.effekt_vannbaaren),
};
LostLaster {
belysning: profil(bel, n.driftstid.belysning, n.profil_belysning),
utstyr_effekt: profil(utstyr, n.driftstid.utstyr, n.profil_utstyr),
utstyr_varme: profil(utstyr_varme, n.driftstid.utstyr, None),
personer: profil(personer, n.driftstid.personer, n.profil_personer),
varmtvann: profil(vv, n.driftstid.varmtvann, n.profil_varmtvann),
pumper: profil(pumpeeffekt, n.driftstid.oppvarming, None),
oppvarming_driftstid: n.driftstid.oppvarming,
}
}
+598
View File
@@ -0,0 +1,598 @@
//! Inndatamodell for en bygning (`bygning.toml`). Alt en energirådgiver kan variere ligger her;
//! det som utelates hentes fra normerte verdier for kategorien og merkes i rapporten.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Kategori {
Smaahus,
Boligblokk,
Barnehage,
Kontorbygg,
Skolebygg,
Universitet,
Sykehus,
Sykehjem,
Hotell,
Idrettsbygg,
Forretningsbygg,
Kulturbygg,
Industribygg,
}
impl Kategori {
pub fn nokkel(&self) -> &'static str {
match self {
Kategori::Smaahus => "smaahus",
Kategori::Boligblokk => "boligblokk",
Kategori::Barnehage => "barnehage",
Kategori::Kontorbygg => "kontorbygg",
Kategori::Skolebygg => "skolebygg",
Kategori::Universitet => "universitet",
Kategori::Sykehus => "sykehus",
Kategori::Sykehjem => "sykehjem",
Kategori::Hotell => "hotell",
Kategori::Idrettsbygg => "idrettsbygg",
Kategori::Forretningsbygg => "forretningsbygg",
Kategori::Kulturbygg => "kulturbygg",
Kategori::Industribygg => "industribygg",
}
}
pub fn er_bolig(&self) -> bool {
matches!(self, Kategori::Smaahus | Kategori::Boligblokk)
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct Bygning {
pub bygning: BygningInfo,
#[serde(default)]
pub klima: Klima,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flate: Vec<Flate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vindu: Vec<Vindu>,
#[serde(default)]
pub kuldebroer: Kuldebroer,
#[serde(default)]
pub infiltrasjon: Infiltrasjon,
#[serde(default)]
pub ventilasjon: Ventilasjon,
#[serde(default)]
pub tappevann: Tappevann,
#[serde(default)]
pub oppvarming: Oppvarming,
#[serde(default)]
pub distribusjon: Distribusjon,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solcelle: Option<Solcelle>,
#[serde(default)]
pub kjoling: Kjoling,
#[serde(default)]
pub lufting: Lufting,
#[serde(default)]
pub drift: Drift,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scenario: Vec<Scenario>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct BygningInfo {
pub navn: String,
pub kategori: Kategori,
pub byggeaar: i32,
/// Oppvarmet del av bruksareal, m².
pub oppvarmet_bra: f64,
/// Oppvarmet luftvolum, m³. Utelatt: 2,4 m × BRA.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oppvarmet_volum: Option<f64>,
#[serde(default = "en")]
pub antall_boenheter: u32,
/// Samlet oppvarmet BRA for hele bygningen når én boenhet i flerbolighus merkes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bygningens_oppvarmede_bra: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adresse: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gnr_bnr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beskrivelse: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energiraadgiver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oppdragsgiver: Option<String>,
}
fn en() -> u32 {
1
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Klima {
/// Kommune for lokalt klima (TMY-fil fra Zenodo 14779899). Utelatt: bare Oslo.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kommune: Option<String>,
/// Egen EPW-fil for lokalt klima (overstyrer kommune).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epw: Option<PathBuf>,
/// Referanseklima for energimerket. Standard «Oslo».
#[serde(default, skip_serializing_if = "Option::is_none")]
pub referanse: Option<String>,
/// Bakkerefleksjon (albedo) for solstråling. Standard 0,2.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub albedo: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FlateType {
Vegg,
Tak,
Gulv,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Mot {
#[default]
Ute,
Grunn,
Uoppvarmet,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Masseklasse {
SvaertLett,
Lett,
Middels,
Tung,
SvaertTung,
}
impl Masseklasse {
/// Arealspesifikk varmekapasitet κ_m, J/(m²K) (ISO 52016-1 Tabell B.14).
pub fn kappa(&self) -> f64 {
match self {
Masseklasse::SvaertLett => 50_000.0,
Masseklasse::Lett => 75_000.0,
Masseklasse::Middels => 110_000.0,
Masseklasse::Tung => 175_000.0,
Masseklasse::SvaertTung => 250_000.0,
}
}
}
/// Hvor massen sitter i konstruksjonen (ISO 52016-1 klasse I/E/IE/D/M).
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Masseplassering {
Innvendig,
Utvendig,
Begge,
#[default]
Fordelt,
Midt,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Lag {
pub materiale: String,
pub tykkelse: f64,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Flate {
pub navn: String,
#[serde(rename = "type")]
pub typ: FlateType,
pub areal: f64,
/// Himmelretning flatens utside vender mot, grader fra nord med klokka (0 N, 90 Ø, 180 S, 270 V).
#[serde(default)]
pub orientering: f64,
/// Helning fra horisontalplanet: 90 = vertikal vegg, 0 = flatt tak. Utelatt: 90 for vegg, 0 for tak/gulv.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub helning: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub u_verdi: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lag: Option<Vec<Lag>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub masseklasse: Option<Masseklasse>,
#[serde(default)]
pub masseplassering: Masseplassering,
#[serde(default)]
pub mot: Mot,
/// Temperaturfaktor b for uoppvarmet rom. Utelatt: 0,5 (loft/kjeller), jf. NS-EN ISO 13789.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub b_faktor: Option<f64>,
/// Solabsorptans for utvendig overflate. Utelatt: 0,6.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub absorptans: Option<f64>,
/// For gulv mot grunn / kjellervegg mot grunn: eksponert omkrets (m) og dybde under terreng (m).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub omkrets: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dybde: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kommentar: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SolskjermingType {
#[default]
Ingen,
Manuell,
Automatisk,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Solskjerming {
#[serde(rename = "type")]
pub typ: SolskjermingType,
/// Gjennomslipp når skjermingen er i bruk (01). Utelatt: 0,8 manuell, 0,4 automatisk.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub faktor: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Vindu {
pub navn: String,
/// Areal inkl. karm, m².
pub areal: f64,
#[serde(default = "en_f64")]
pub antall: f64,
#[serde(default)]
pub orientering: f64,
#[serde(default = "nitti")]
pub helning: f64,
pub u_verdi: f64,
/// Solfaktor for glasset (g-verdi). Utelatt: 0,75 for 2-lags, 0,5 for 3-lags energiglass.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub g_verdi: Option<f64>,
/// Karm- og rammeandel av arealet. Utelatt: 0,2.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub karmandel: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solskjerming: Option<Solskjerming>,
/// Horisontavskjerming i grader (terreng, nabobygg). Utelatt: 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub horisont: Option<f64>,
/// Er dette en dør (regnes uten solinnstråling)?
#[serde(default)]
pub dor: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kommentar: Option<String>,
}
fn en_f64() -> f64 {
1.0
}
fn nitti() -> f64 {
90.0
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Kuldebroer {
/// Normalisert kuldebroverdi, W/(m²K) av oppvarmet BRA. Utelatt: verdi etter byggeår.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalisert: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub liste: Vec<Kuldebro>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Kuldebro {
pub navn: String,
pub psi: f64,
pub lengde: f64,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Skjerming {
Skjermet,
#[default]
Moderat,
Utsatt,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Infiltrasjon {
/// Lekkasjetall ved 50 Pa, 1/h. Utelatt: verdi etter byggeår.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n50: Option<f64>,
#[serde(default)]
pub skjerming: Skjerming,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maalt_dato: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum VentilasjonType {
#[default]
Naturlig,
Avtrekk,
Balansert,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Ventilasjon {
#[serde(rename = "type", default)]
pub typ: VentilasjonType,
/// Gjennomsnittlig luftmengde i driftstiden, m³/(h·m²). Utelatt: normert.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub luftmengde: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub luftmengde_natt: Option<f64>,
/// Temperaturvirkningsgrad varmegjenvinner (01). Bare balansert. Utelatt: normert.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gjenvinning: Option<f64>,
/// Spesifikk vifteeffekt, kW/(m³/s). Utelatt: normert (balansert) / 0,6 (avtrekk).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sfp: Option<f64>,
/// Har anlegget varmebatteri som ettervarmer tilluften?
#[serde(default, skip_serializing_if = "Option::is_none")]
pub varmebatteri: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TappevannSystem {
#[default]
Aapent,
Sirkulasjon,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Tappevann {
#[serde(default)]
pub system: TappevannSystem,
/// Netto energibehov, kWh/(m²·år). Utelatt: normert.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energibehov: Option<f64>,
/// Tap i bereder og rør som andel av netto behov. Utelatt: 0,25 åpent, 0,45 sirkulasjon.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tap_andel: Option<f64>,
/// Andel av tapet som avgis i oppvarmet sone (uregulerbart varmetilskudd). Utelatt: 0,8 / 0,5.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tap_i_oppvarmet_sone: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum VarmekildeType {
DirekteEl,
ElKjel,
LuftLuftVp,
LuftVannVp,
VaeskeVannVp,
AvtrekkVp,
Vedovn,
Pellets,
Olje,
Gass,
Fjernvarme,
Solfanger,
}
impl VarmekildeType {
pub fn nokkel(&self) -> &'static str {
match self {
VarmekildeType::DirekteEl => "direkte_el",
VarmekildeType::ElKjel => "el_kjel",
VarmekildeType::LuftLuftVp => "luft_luft_vp",
VarmekildeType::LuftVannVp => "luft_vann_vp",
VarmekildeType::VaeskeVannVp => "vaeske_vann_vp",
VarmekildeType::AvtrekkVp => "avtrekk_vp",
VarmekildeType::Vedovn => "vedovn",
VarmekildeType::Pellets => "pellets",
VarmekildeType::Olje => "olje",
VarmekildeType::Gass => "gass",
VarmekildeType::Fjernvarme => "fjernvarme",
VarmekildeType::Solfanger => "solfanger",
}
}
pub fn beskrivelse(&self) -> &'static str {
match self {
VarmekildeType::DirekteEl => "Direkte elektrisk (panelovner, varmekabler)",
VarmekildeType::ElKjel => "Elektrokjel, vannbåren varme",
VarmekildeType::LuftLuftVp => "Varmepumpe luftluft",
VarmekildeType::LuftVannVp => "Varmepumpe luftvann",
VarmekildeType::VaeskeVannVp => "Varmepumpe væskevann (berg/jord/sjø)",
VarmekildeType::AvtrekkVp => "Avtrekksvarmepumpe",
VarmekildeType::Vedovn => "Vedovn",
VarmekildeType::Pellets => "Pelletskamin/-kjel",
VarmekildeType::Olje => "Oljekjel",
VarmekildeType::Gass => "Gasskjel",
VarmekildeType::Fjernvarme => "Fjernvarme",
VarmekildeType::Solfanger => "Solfanger",
}
}
pub fn er_varmepumpe(&self) -> bool {
matches!(
self,
VarmekildeType::LuftLuftVp
| VarmekildeType::LuftVannVp
| VarmekildeType::VaeskeVannVp
| VarmekildeType::AvtrekkVp
)
}
/// Kan kilden levere til vannbåren distribusjon (ventilasjonsvarme, tappevann)?
pub fn vannbaaren(&self) -> bool {
!matches!(
self,
VarmekildeType::DirekteEl | VarmekildeType::LuftLuftVp | VarmekildeType::Vedovn
)
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Varmekilde {
#[serde(rename = "type")]
pub typ: VarmekildeType,
/// Andel av romoppvarmingsbehovet kilden dekker (01).
#[serde(default)]
pub andel_rom: f64,
/// Andel av ventilasjonsvarme. Utelatt: som andel_rom for vannbårne kilder, ellers 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub andel_ventilasjon: Option<f64>,
#[serde(default)]
pub andel_tappevann: f64,
/// Årsvarmefaktor (SPF) for varmepumper eller virkningsgrad for kjeler. Utelatt: tabellverdi.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spf: Option<f64>,
/// Turtemperatur °C for vannbårne varmepumper (35/45/55).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turtemperatur: Option<f64>,
/// Vedovn: lukket ildsted med lavt partikkelutslipp (gir vektingsfaktor 0,45).
#[serde(default = "sann")]
pub lukket: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kommentar: Option<String>,
}
fn sann() -> bool {
true
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Oppvarming {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub kilde: Vec<Varmekilde>,
/// Maks varmeeffekt installert, W (begrenser innetemperatur ved underdekning). Utelatt: ubegrenset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maks_effekt: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DistribusjonType {
#[default]
Punkt,
Radiator,
Gulvvarme,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Distribusjon {
#[serde(rename = "type", default)]
pub typ: DistribusjonType,
/// Distribusjonstap som andel av avgitt romvarme. Utelatt: 0 punkt, 0,03 radiator, 0,05 gulvvarme.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tap_andel: Option<f64>,
/// Andel av tapet som går til uoppvarmet areal (rør i kjeller/loft). Utelatt: 0,2.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roer_uoppvarmet: Option<f64>,
/// Pumpeeffekt, W/m² i driftstid. Utelatt: normert for vannbåren, 0 for punkt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pumpe_effekt: Option<f64>,
/// Turtemperatur °C i vannbårent anlegg (60 radiator, 35 gulvvarme). Brukes for SPF-oppslag.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turtemperatur: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Solcelle {
/// Modulareal, m².
pub areal: f64,
#[serde(default = "tjue_prosent")]
pub virkningsgrad: f64,
#[serde(default = "sor")]
pub orientering: f64,
#[serde(default = "tretti")]
pub helning: f64,
/// Ytelsesfaktor (inverter, kabler, snø, smuss). Utelatt: 0,8.
#[serde(default = "aatti_prosent")]
pub ytelsesfaktor: f64,
}
fn tjue_prosent() -> f64 {
0.20
}
fn sor() -> f64 {
180.0
}
fn tretti() -> f64 {
30.0
}
fn aatti_prosent() -> f64 {
0.8
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Kjoling {
#[serde(default = "sann")]
pub ingen: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settpunkt: Option<f64>,
/// Årskuldefaktor (SEER). Utelatt: 3,0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spf: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maks_effekt: Option<f64>,
}
impl Default for Kjoling {
fn default() -> Self {
Self {
ingen: true,
settpunkt: None,
spf: None,
maks_effekt: None,
}
}
}
/// Vinduslufting: økt luftmengde når rommene blir for varme.
///
/// Bygninger uten kjøleanlegg luftes gjennom vinduer når det blir for varmt. Uten denne
/// modellen vil en tett bygning med store vindusflater få urealistisk høye innetemperaturer
/// om våren og høsten, og lagret solvarme vil kunstig redusere oppvarmingsbehovet.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Lufting {
/// Slår av vinduslufting. Standard er lufting for bygninger uten kjøleanlegg.
#[serde(default)]
pub ingen: bool,
/// Operativ temperatur der luftingen starter, °C. Utelatt: 24.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settpunkt: Option<f64>,
/// Største luftmengde ved vinduslufting, m³/(h·m²) oppvarmet BRA. Utelatt: 8.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maks_luftmengde: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Drift {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settpunkt_oppvarming: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nattsenking: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub belysning_effekt: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utstyr_effekt: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personer_varmetilskudd: Option<f64>,
/// Effektiv innvendig varmekapasitet for luft og inventar, J/(m²K). Utelatt: 10 000 (ISO 52016-1).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub innvendig_varmekapasitet: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Scenario {
pub navn: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beskrivelse: Option<String>,
/// Delvis bygningsbeskrivelse som flettes inn: tabeller flettes rekursivt, tabellarrayer
/// (flate, vindu, oppvarming.kilde …) matches på `navn`/`type`, skalarer erstattes.
/// `slett = true` fjerner et element.
pub endringer: toml::Value,
}
pub mod lost;
+518
View File
@@ -0,0 +1,518 @@
//! Dynamisk timesberegning av netto energibehov (beregningspunkt A) etter
//! NS-EN ISO 52016-1, slik NS 3031:2025 krever.
//!
//! Hver opake bygningsdel modelleres med fem noder der varmekapasiteten fordeles etter
//! masseplassering (ISO 52016-1 tabell B.13/B.14), og vinduer med to noder uten masse.
//! Sonen har én luftnode med varmekapasitet for luft og inventar, og én strålingsnode
//! («stjernenode») som fordeler langbølget strålingsutveksling mellom innvendige overflater.
//! Systemet løses implisitt (bakover-Euler) for hver time.
//!
//! Varme- og kjølebehovet bestemmes som i standarden: systemet løses først uten tilført
//! effekt og deretter med en prøveeffekt. Siden systemet er lineært, gir interpolasjon
//! mellom de to løsningene nøyaktig den effekten som treffer settpunktet.
use crate::klima::{sol, Vaerdata};
use crate::modell::lost::{Lost, Regulering};
use crate::modell::Mot;
use crate::motor::{lin, ventilasjon, RHO_C_LUFT};
use crate::{Result, TIMER};
use serde::Serialize;
/// Konvektiv varmeovergang innvendig overflate, W/(m²K). Gir sammen med h_ri R_si = 0,13.
const H_CI: f64 = 2.5;
/// Langbølget strålingsutveksling innvendig, W/(m²K).
const H_RI: f64 = 5.13;
/// Konvektiv varmeovergang utvendig, W/(m²K).
const H_CE: f64 = 20.0;
/// Langbølget stråling utvendig, W/(m²K). Gir sammen med h_ce R_se = 0,04.
const H_RE: f64 = 4.14;
/// Konvektiv andel av interne varmetilskudd (ISO 52016-1).
const KONVEKTIV_ANDEL: f64 = 0.4;
/// Innstråling der solskjerming trer i kraft, W/m².
const SKJERMING_TERSKEL: f64 = 300.0;
/// Utetemperatur der manuell solskjerming antas tatt i bruk, °C.
const SKJERMING_TEMP: f64 = 10.0;
const DT: f64 = 3600.0;
/// Prøveeffekt per m² ved bestemmelse av varme-/kjølebehov.
const PROVEEFFEKT: f64 = 10.0;
/// Nodeoppsett for én bygningsdel.
struct Del {
areal: f64,
/// Indeks til delens første node i tilstandsvektoren.
forste: usize,
/// Antall noder (5 for opake deler, 2 for vinduer og dører).
antall: usize,
/// Konduktans mellom nabonoder, W/K (allerede multiplisert med areal).
h_indre: f64,
/// Konduktans mot ytre miljø fra node 1, W/K.
h_ytre: f64,
/// Varmekapasitet per node, J/K.
kapasitet: Vec<f64>,
glass: bool,
mot: Mot,
b_faktor: f64,
absorptans: f64,
helning: f64,
/// Solinnstråling på flaten, W/m², for hele året.
innstraaling: Vec<f64>,
g_effektiv: f64,
skjermingsfaktor: f64,
automatisk_skjerming: bool,
horisontfaktor: f64,
}
/// Resultatet av en årssimulering: alle timesserier, W (energistrømmer) og °C.
#[derive(Serialize, Clone, Debug)]
pub struct Timeserie {
pub t_ute: Vec<f64>,
pub t_inne: Vec<f64>,
pub t_operativ: Vec<f64>,
/// Romoppvarming, post 1a, W.
pub romoppvarming: Vec<f64>,
/// Ventilasjonsvarme, post 1b, W.
pub ventilasjonsvarme: Vec<f64>,
/// Romkjøling, post 3a, W.
pub romkjoling: Vec<f64>,
/// Ventilasjonskjøling, post 3b, W.
pub ventilasjonskjoling: Vec<f64>,
/// Varmtvann, post 2, W.
pub varmtvann: Vec<f64>,
/// Vifter, post 4a, W.
pub vifter: Vec<f64>,
/// Pumper, post 4b, W.
pub pumper: Vec<f64>,
/// Belysning, post 5, W.
pub belysning: Vec<f64>,
/// Teknisk utstyr, post 6, W.
pub utstyr: Vec<f64>,
/// Sum solinnstråling gjennom vinduer, W.
pub soltilskudd: Vec<f64>,
/// Interne varmetilskudd, W.
pub internlast: Vec<f64>,
/// Solstråling på horisontal flate, W/m².
pub sol_horisontal: Vec<f64>,
/// Luftmengde ved vinduslufting, m³/h.
pub vinduslufting: Vec<f64>,
}
impl Timeserie {
fn ny() -> Self {
let n = || vec![0.0; TIMER];
Timeserie {
t_ute: n(),
t_inne: n(),
t_operativ: n(),
romoppvarming: n(),
ventilasjonsvarme: n(),
romkjoling: n(),
ventilasjonskjoling: n(),
varmtvann: n(),
vifter: n(),
pumper: n(),
belysning: n(),
utstyr: n(),
soltilskudd: n(),
internlast: n(),
sol_horisontal: n(),
vinduslufting: n(),
}
}
/// Årssum av en serie, kWh.
pub fn sum_kwh(serie: &[f64]) -> f64 {
serie.iter().sum::<f64>() / 1000.0
}
/// Månedssummer, kWh.
pub fn maaned_kwh(serie: &[f64]) -> [f64; 12] {
let mut ut = [0.0; 12];
for (t, v) in serie.iter().enumerate() {
ut[crate::maaned_for_time(t)] += v / 1000.0;
}
ut
}
}
/// Uregulerbart varmetilskudd i sonen fra systemtap, W.
///
/// Tap fra tappevannsbereder og varmtvannsrør i oppvarmet sone varmer rommene og reduserer
/// romoppvarmingsbehovet, samtidig som energien telles under tappevann. Distribusjonstap for
/// romvarme i oppvarmet sone regnes ikke med her: den varmen ville uansett blitt avgitt i sonen,
/// og påvirker derfor bare fordelingen mellom rør og varmegiver, ikke energibehovet.
#[derive(Clone, Copy, Debug, Default)]
pub struct Systemtilskudd {
/// Konstant tilskudd fra tappevannsanlegget, W.
pub tappevann: f64,
}
pub fn simuler(l: &Lost, v: &Vaerdata, tilskudd: Systemtilskudd) -> Result<Timeserie> {
let deler = bygg_deler(l, v);
let n_noder: usize = 2 + deler.iter().map(|d| d.antall).sum::<usize>();
let areal_innvendig: f64 = deler.iter().map(|d| d.areal).sum();
let c_luft = l.innvendig_varmekapasitet * l.bra;
let h_inf = ventilasjon::infiltrasjonsluftmengde(l) * RHO_C_LUFT;
let bakketemp = bakketemperatur(v);
let mut ts = Timeserie::ny();
let mut tilstand = vec![l.settpunkt_oppvarming; n_noder];
let mut t_a_forrige = l.settpunkt_oppvarming;
// Innkjøring: gjenta de første to ukene til starttilstanden har stabilisert seg.
for innkjoring in 0..3 {
let skriv = innkjoring == 2;
let start_tilstand = tilstand.clone();
let timer: Box<dyn Iterator<Item = usize>> = if skriv {
Box::new(0..TIMER)
} else {
Box::new(0..(24 * 14))
};
if !skriv {
tilstand = start_tilstand;
}
for t in timer {
let klokke = t % 24;
let t_ute = v.t_ute[t];
let t_himmel = sol::himmeltemperatur(v, t);
// ── laster ──────────────────────────────────────────────
let bel = l.laster.belysning[klokke] * l.bra;
let utstyr_el = l.laster.utstyr_effekt[klokke] * l.bra;
let utstyr_varme = l.laster.utstyr_varme[klokke] * l.bra;
let personer = l.laster.personer[klokke] * l.bra;
let vv = l.laster.varmtvann[klokke] * l.bra;
let pumper = l.laster.pumper[klokke] * l.bra;
let vifter = ventilasjon::vifteeffekt(l, klokke);
// Vifteeffekten ender som varme i lufta (ISO 52016-1: hele SFP-effekten).
let internlast = bel + utstyr_varme + personer + tilskudd.tappevann + vifter;
// ── sol ─────────────────────────────────────────────────
let mut sol_gjennom = 0.0;
for d in &deler {
if d.glass && d.g_effektiv > 0.0 {
sol_gjennom += soltransmisjon(d, t, t_ute);
}
}
// ── ventilasjon ─────────────────────────────────────────
let q_vent = ventilasjon::luftmengde(l, klokke);
let h_ve = q_vent * RHO_C_LUFT;
let t_sup_hx = ventilasjon::tilluftstemperatur(l, t_ute, t_a_forrige);
let (t_sup, vent_varme) =
if l.ventilasjon.varmebatteri && t_sup_hx < l.ventilasjon.tilluft_settpunkt {
(
l.ventilasjon.tilluft_settpunkt,
h_ve * (l.ventilasjon.tilluft_settpunkt - t_sup_hx),
)
} else {
(t_sup_hx, 0.0)
};
// ── settpunkt ───────────────────────────────────────────
let sett_varme = if l.laster.oppvarming_driftstid.aktiv(klokke) {
l.settpunkt_oppvarming
} else {
l.settpunkt_natt
};
// ── løsning ─────────────────────────────────────────────
let prove = PROVEEFFEKT * l.bra;
let ctrl = |s: &[f64]| -> f64 {
match l.regulering {
Regulering::Luft => s[0],
Regulering::Operativ => 0.5 * s[0] + 0.5 * s[1],
}
};
let lag_ctx = |h_luft: f64| Kontekst {
deler: &deler,
n_noder,
areal_innvendig,
c_luft,
h_inf,
h_ve: h_ve + h_luft,
// Lufteluften kommer rett utenfra, uten varmegjenvinning.
t_sup: if h_ve + h_luft > 1e-9 {
(h_ve * t_sup + h_luft * t_ute) / (h_ve + h_luft)
} else {
t_sup
},
t_ute,
t_himmel,
t_bakke: bakketemp[crate::maaned_for_time(t)],
t_a_forrige,
internlast,
sol_gjennom,
time: t,
};
// Vinduslufting: øk luftmengden til den operative temperaturen holder settpunktet.
let mut h_luft = 0.0;
let mut ctx = lag_ctx(0.0);
let mut los0 = ctx.los(&tilstand, 0.0)?;
if let Some((sett_luft, maks_luft)) = l.lufting {
if ctrl(&los0) > sett_luft {
let h_maks = maks_luft * l.bra * RHO_C_LUFT;
let ctx_maks = lag_ctx(h_maks);
let los_maks = ctx_maks.los(&tilstand, 0.0)?;
if ctrl(&los_maks) >= sett_luft {
h_luft = h_maks;
ctx = ctx_maks;
los0 = los_maks;
} else {
let (mut lav, mut hoy) = (0.0, h_maks);
for _ in 0..12 {
let midt = 0.5 * (lav + hoy);
let k = lag_ctx(midt);
let l0 = k.los(&tilstand, 0.0)?;
if ctrl(&l0) > sett_luft {
lav = midt;
} else {
hoy = midt;
}
h_luft = midt;
ctx = k;
los0 = l0;
}
}
}
}
let los1 = ctx.los(&tilstand, prove)?;
let c0 = ctrl(&los0);
let c1 = ctrl(&los1);
let stigning = (c1 - c0) / prove; // K per W
let mut fi = 0.0;
if c0 < sett_varme - 1e-9 && stigning > 1e-12 {
fi = (sett_varme - c0) / stigning;
if let Some(maks) = l.maks_varmeeffekt {
fi = fi.min(maks);
}
} else if let Some(sett_kjol) = l.settpunkt_kjoling {
if c0 > sett_kjol + 1e-9 && stigning > 1e-12 {
fi = (sett_kjol - c0) / stigning;
if let Some(maks) = l.maks_kjoleeffekt {
fi = fi.max(-maks);
}
}
}
// Eksakt tilstand ved interpolasjon (systemet er lineært).
let andel = fi / prove;
for i in 0..n_noder {
tilstand[i] = los0[i] + andel * (los1[i] - los0[i]);
}
t_a_forrige = tilstand[0];
if skriv {
ts.t_ute[t] = t_ute;
ts.t_inne[t] = tilstand[0];
ts.t_operativ[t] = 0.5 * tilstand[0] + 0.5 * tilstand[1];
ts.romoppvarming[t] = fi.max(0.0);
ts.romkjoling[t] = (-fi).max(0.0);
ts.ventilasjonsvarme[t] = vent_varme;
ts.varmtvann[t] = vv;
ts.vifter[t] = vifter;
ts.pumper[t] = pumper;
ts.belysning[t] = bel;
ts.utstyr[t] = utstyr_el;
ts.soltilskudd[t] = sol_gjennom;
ts.internlast[t] = internlast;
ts.sol_horisontal[t] = v.ghi[t];
ts.vinduslufting[t] = h_luft / RHO_C_LUFT;
}
}
}
Ok(ts)
}
struct Kontekst<'a> {
deler: &'a [Del],
n_noder: usize,
areal_innvendig: f64,
c_luft: f64,
h_inf: f64,
h_ve: f64,
t_sup: f64,
t_ute: f64,
t_himmel: f64,
t_bakke: f64,
t_a_forrige: f64,
internlast: f64,
sol_gjennom: f64,
time: usize,
}
impl Kontekst<'_> {
/// Setter opp og løser timesligningene med gitt tilført effekt til sonen.
fn los(&self, forrige: &[f64], fi_hc: f64) -> Result<Vec<f64>> {
let n = self.n_noder;
let mut a = vec![0.0; n * n];
let mut b = vec![0.0; n];
let idx = |i: usize, j: usize| i * n + j;
// ── rad 0: sonens luftnode ──────────────────────────────────
let c_dt = self.c_luft / DT;
a[idx(0, 0)] = c_dt + self.h_ve + self.h_inf;
b[0] = c_dt * forrige[0]
+ self.h_ve * self.t_sup
+ self.h_inf * self.t_ute
+ KONVEKTIV_ANDEL * self.internlast
+ fi_hc;
for d in self.deler {
let indre = d.forste + d.antall - 1;
let h = d.areal * H_CI;
a[idx(0, 0)] += h;
a[idx(0, indre)] -= h;
}
// ── rad 1: strålingsnoden (ingen masse, netto null) ─────────
for d in self.deler {
let indre = d.forste + d.antall - 1;
let h = d.areal * H_RI;
a[idx(1, 1)] += h;
a[idx(1, indre)] -= h;
}
b[1] = 0.0;
// ── bygningsdelene ──────────────────────────────────────────
let rad_last = (1.0 - KONVEKTIV_ANDEL) * self.internlast + self.sol_gjennom;
for d in self.deler {
let t_ytre = self.ytre_temperatur(d);
let sol_absorbert = if d.glass {
0.0
} else {
d.areal * d.absorptans * d.innstraaling[self.time]
};
for k in 0..d.antall {
let i = d.forste + k;
let c = d.kapasitet[k] / DT;
a[idx(i, i)] += c;
b[i] += c * forrige[i];
if k == 0 {
a[idx(i, i)] += d.h_ytre;
b[i] += d.h_ytre * t_ytre + sol_absorbert;
}
if k + 1 < d.antall {
a[idx(i, i)] += d.h_indre;
a[idx(i, i + 1)] -= d.h_indre;
}
if k > 0 {
a[idx(i, i)] += d.h_indre;
a[idx(i, i - 1)] -= d.h_indre;
}
if k == d.antall - 1 {
a[idx(i, i)] += d.areal * (H_CI + H_RI);
a[idx(i, 0)] -= d.areal * H_CI;
a[idx(i, 1)] -= d.areal * H_RI;
b[i] += rad_last * d.areal / self.areal_innvendig;
}
}
}
lin::los(&mut a, &mut b, n)?;
Ok(b)
}
fn ytre_temperatur(&self, d: &Del) -> f64 {
match d.mot {
Mot::Grunn => self.t_bakke,
Mot::Uoppvarmet => self.t_a_forrige - d.b_faktor * (self.t_a_forrige - self.t_ute),
Mot::Ute => {
// Himmelandel etter flatens helning; resten ser bakken med utelufttemperatur.
let f_himmel = (1.0 + d.helning.to_radians().cos()) / 2.0;
let t_str = f_himmel * self.t_himmel + (1.0 - f_himmel) * self.t_ute;
(H_CE * self.t_ute + H_RE * t_str) / (H_CE + H_RE)
}
}
}
}
fn soltransmisjon(d: &Del, t: usize, t_ute: f64) -> f64 {
let i_sol = d.innstraaling[t] * d.horisontfaktor;
let skjerm = if d.skjermingsfaktor < 1.0
&& i_sol > SKJERMING_TERSKEL
&& (d.automatisk_skjerming || t_ute > SKJERMING_TEMP)
{
d.skjermingsfaktor
} else {
1.0
};
d.areal * d.g_effektiv * skjerm * i_sol
}
fn bygg_deler(l: &Lost, v: &Vaerdata) -> Vec<Del> {
let mut deler = Vec::with_capacity(l.flater.len());
let mut neste = 2usize;
for f in &l.flater {
let antall = if f.glass { 2 } else { 5 };
let r_total = 1.0 / f.u_verdi.max(1e-6);
let r_konstruksjon = (r_total - 1.0 / (H_CI + H_RI) - 1.0 / (H_CE + H_RE)).max(1e-3);
let gap = (antall - 1) as f64;
let h_indre = f.areal * gap / r_konstruksjon;
let h_ytre = f.areal * (H_CE + H_RE);
let kapasitet = fordel_kapasitet(f, antall);
let innstraaling = if f.glass || f.mot == Mot::Ute {
sol::innstraaling_aar(v, f.helning, f.orientering, l.albedo)
} else {
vec![0.0; TIMER]
};
deler.push(Del {
areal: f.areal,
forste: neste,
antall,
h_indre,
h_ytre,
kapasitet,
glass: f.glass,
mot: f.mot,
b_faktor: f.b_faktor,
absorptans: f.absorptans,
helning: f.helning,
innstraaling,
g_effektiv: f.g_effektiv,
skjermingsfaktor: f.skjermingsfaktor,
automatisk_skjerming: f.automatisk_skjerming,
horisontfaktor: (1.0 - f.horisont / 90.0).clamp(0.0, 1.0),
});
neste += antall;
}
deler
}
/// Fordeler varmekapasiteten over nodene etter ISO 52016-1 tabell B.13.
fn fordel_kapasitet(f: &crate::modell::lost::LostFlate, antall: usize) -> Vec<f64> {
use crate::modell::Masseplassering::*;
if antall == 2 {
return vec![0.0, 0.0];
}
let k = f.kappa * f.areal;
let mut c = vec![0.0; 5];
match f.masseplassering {
Innvendig => c[4] = k,
Utvendig => c[0] = k,
Begge => {
c[0] = k / 2.0;
c[4] = k / 2.0;
}
Midt => c[2] = k,
Fordelt => {
c[0] = k / 8.0;
c[1] = k / 4.0;
c[2] = k / 4.0;
c[3] = k / 4.0;
c[4] = k / 8.0;
}
}
c
}
/// Dempet bakketemperatur per måned etter NS-EN ISO 13370 (forenklet): årsmiddel med
/// halvert amplitude og én måneds forsinkelse.
fn bakketemperatur(v: &Vaerdata) -> [f64; 12] {
let aar = v.aarsmiddeltemperatur();
let maaned = v.maanedsmiddeltemperatur();
let mut ut = [0.0; 12];
for m in 0..12 {
let forsinket = maaned[(m + 11) % 12];
ut[m] = aar + 0.5 * (forsinket - aar);
}
ut
}
+76
View File
@@ -0,0 +1,76 @@
//! Liten tett LU-løser med delvis pivotering. Systemene i timesberegningen har typisk
//! 3080 ukjente, så en tett løser er raskere enn oppsett av en glissen struktur.
use crate::Result;
use anyhow::bail;
/// Løser `a · x = b` in-place. `a` er radvis lagret n×n.
pub fn los(a: &mut [f64], b: &mut [f64], n: usize) -> Result<()> {
debug_assert_eq!(a.len(), n * n);
debug_assert_eq!(b.len(), n);
for k in 0..n {
// Delvis pivotering.
let mut piv = k;
let mut maks = a[k * n + k].abs();
for i in (k + 1)..n {
let v = a[i * n + k].abs();
if v > maks {
maks = v;
piv = i;
}
}
if maks < 1e-14 {
bail!("singulær matrise i timesberegningen (rad {k})");
}
if piv != k {
for j in 0..n {
a.swap(k * n + j, piv * n + j);
}
b.swap(k, piv);
}
let d = a[k * n + k];
for i in (k + 1)..n {
let f = a[i * n + k] / d;
if f == 0.0 {
continue;
}
a[i * n + k] = 0.0;
for j in (k + 1)..n {
a[i * n + j] -= f * a[k * n + j];
}
b[i] -= f * b[k];
}
}
for i in (0..n).rev() {
let mut s = b[i];
for j in (i + 1)..n {
s -= a[i * n + j] * b[j];
}
b[i] = s / a[i * n + i];
}
Ok(())
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn loser_lite_system() {
// 2x + y = 5 ; x + 3y = 10 → x = 1, y = 3
let mut a = vec![2.0, 1.0, 1.0, 3.0];
let mut b = vec![5.0, 10.0];
los(&mut a, &mut b, 2).unwrap();
assert!((b[0] - 1.0).abs() < 1e-12);
assert!((b[1] - 3.0).abs() < 1e-12);
}
#[test]
fn pivoterer_naar_noekkelelement_er_null() {
let mut a = vec![0.0, 1.0, 1.0, 0.0];
let mut b = vec![2.0, 3.0];
los(&mut a, &mut b, 2).unwrap();
assert!((b[0] - 3.0).abs() < 1e-12);
assert!((b[1] - 2.0).abs() < 1e-12);
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Beregningsmotoren: varmetapsbudsjett, laster, ventilasjon og den dynamiske timesberegningen.
pub mod iso52016;
pub mod lin;
pub mod varmetap;
pub mod ventilasjon;
pub use iso52016::{simuler, Timeserie};
pub use varmetap::Varmetapsbudsjett;
/// Varmekapasitet for luft ved konstant trykk, Wh/(m³·K).
pub const RHO_C_LUFT: f64 = 1200.0 / 3600.0;
+71
View File
@@ -0,0 +1,71 @@
//! Varmetapsbudsjett (NS 3031 Tabell 3): varmetapstall H_tr per post, W/(m²K) av oppvarmet BRA.
use crate::modell::lost::Lost;
use crate::modell::{FlateType, Mot};
use crate::motor::ventilasjon;
use serde::Serialize;
#[derive(Serialize, Clone, Debug, Default)]
pub struct Varmetapsbudsjett {
pub vegger: f64,
pub tak: f64,
pub gulv: f64,
pub vinduer_dorer: f64,
pub kuldebroer: f64,
pub transmisjon: f64,
pub infiltrasjon: f64,
pub klimaskjerm: f64,
pub ventilasjon: f64,
pub totalt: f64,
/// Absolutte verdier, W/K.
pub transmisjon_w_per_k: f64,
pub infiltrasjon_w_per_k: f64,
pub ventilasjon_w_per_k: f64,
}
impl Varmetapsbudsjett {
pub fn beregn(l: &Lost) -> Self {
let mut b = Varmetapsbudsjett::default();
let a = l.bra;
for f in &l.flater {
// Gulv mot grunn tas med sin U-verdi; den dempede bakketemperaturen håndteres
// i timesberegningen, mens varmetapstallet oppgis som for flate mot det fri.
let bidrag = f.areal
* f.u_verdi
* if f.mot == Mot::Uoppvarmet {
f.b_faktor
} else {
1.0
};
if f.glass {
b.vinduer_dorer += bidrag;
} else {
match f.typ {
FlateType::Vegg => b.vegger += bidrag,
FlateType::Tak => b.tak += bidrag,
FlateType::Gulv => b.gulv += bidrag,
}
}
}
b.transmisjon_w_per_k = b.vegger + b.tak + b.gulv + b.vinduer_dorer + l.kuldebro_w_per_k;
b.kuldebroer = l.kuldebro_w_per_k / a;
b.vegger /= a;
b.tak /= a;
b.gulv /= a;
b.vinduer_dorer /= a;
b.transmisjon = b.vegger + b.tak + b.gulv + b.vinduer_dorer + b.kuldebroer;
let q_inf = ventilasjon::infiltrasjonsluftmengde(l); // m³/h
b.infiltrasjon_w_per_k = q_inf * crate::motor::RHO_C_LUFT;
b.infiltrasjon = b.infiltrasjon_w_per_k / a;
b.klimaskjerm = b.transmisjon + b.infiltrasjon;
// Ventilasjonstapet oppgis som årsgjennomsnittlig, med varmegjenvinning.
let q_vent = l.ventilasjon.luftmengde * a; // m³/h i driftstiden
b.ventilasjon_w_per_k =
q_vent * crate::motor::RHO_C_LUFT * (1.0 - l.ventilasjon.gjenvinning);
b.ventilasjon = b.ventilasjon_w_per_k / a;
b.totalt = b.klimaskjerm + b.ventilasjon;
b
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Ventilasjon og infiltrasjon.
use crate::modell::lost::Lost;
use crate::modell::{Skjerming, VentilasjonType};
/// Infiltrasjonsluftmengde, m³/h, etter NS-EN 15242 slik NS 3031 anvender den:
///
/// `n_inf = n50 · e / (1 + (f/e) · ((q_til q_fra) / (V · n50))²)`
///
/// `e` er skjermingskoeffisienten og `f` en anleggskoeffisient. Ved balansert ventilasjon
/// faller ubalanseleddet bort, og infiltrasjonen blir `n50 · e`.
pub fn infiltrasjonsluftmengde(l: &Lost) -> f64 {
let e = match l.skjerming {
Skjerming::Skjermet => 0.03,
Skjerming::Moderat => 0.07,
Skjerming::Utsatt => 0.10,
};
let f = 15.0;
let v = l.volum;
let q_snitt = l.ventilasjon.luftmengde * l.bra;
let (q_til, q_fra) = match l.ventilasjon.typ {
VentilasjonType::Balansert => (q_snitt, q_snitt),
VentilasjonType::Avtrekk => (0.0, q_snitt),
VentilasjonType::Naturlig => (0.0, 0.0),
};
let ubalanse = (q_til - q_fra).abs();
let nevner = if l.n50 * v > 1e-9 {
1.0 + (f / e) * (ubalanse / (v * l.n50)).powi(2)
} else {
1.0
};
let n_inf = l.n50 * e / nevner;
n_inf * v
}
/// Ventilasjonsluftmengde for en klokketime, m³/h.
pub fn luftmengde(l: &Lost, klokketime: usize) -> f64 {
let spesifikk = if l.ventilasjon.driftstid.aktiv(klokketime) {
l.ventilasjon.luftmengde
} else {
l.ventilasjon.luftmengde_natt
};
spesifikk * l.bra
}
/// Tilluftstemperatur etter varmegjenvinner (uten ettervarming), °C.
pub fn tilluftstemperatur(l: &Lost, t_ute: f64, t_avtrekk: f64) -> f64 {
match l.ventilasjon.typ {
VentilasjonType::Balansert => t_ute + l.ventilasjon.gjenvinning * (t_avtrekk - t_ute),
_ => t_ute,
}
}
/// Vifteeffekt for en klokketime, W.
pub fn vifteeffekt(l: &Lost, klokketime: usize) -> f64 {
if matches!(l.ventilasjon.typ, VentilasjonType::Naturlig) {
return 0.0;
}
let q = luftmengde(l, klokketime) / 3600.0; // m³/s
l.ventilasjon.sfp * 1000.0 * q
}
+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()
}
+535
View File
@@ -0,0 +1,535 @@
//! Fra netto energibehov til vektet levert energi: beregningspunktene AE i NS 3031:2025.
//!
//! * **A** netto energibehov, justert for nyttiggjort uregulerbart varmetilskudd
//! * **B** brutto energibehov, etter tap i distribusjon og akkumulering
//! * **C** tilført energi til bygningen, etter systemvirkningsgrad/SPF for hver varmekilde
//! * **D** levert (kjøpt) energi per energibærer, med fradrag for egenprodusert og eksportert el
//! * **E** vektet levert energi
pub mod solcelle;
use crate::data::{spf_oppslag, Datakilde, Vekting};
use crate::klima::korreksjon::Korrigering;
use crate::modell::lost::Lost;
use crate::modell::*;
use crate::motor::Timeserie;
use crate::Result;
use serde::Serialize;
/// Energibærere i energiattesten.
#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Energibaerer {
Elektrisitet,
Olje,
Gass,
Fjernvarme,
Fjernkjoling,
BiobrenselLukket,
BiobrenselAapent,
Annet,
}
impl Energibaerer {
pub fn navn(&self) -> &'static str {
match self {
Energibaerer::Elektrisitet => "Elektrisitet",
Energibaerer::Olje => "Olje",
Energibaerer::Gass => "Gass",
Energibaerer::Fjernvarme => "Fjernvarme",
Energibaerer::Fjernkjoling => "Fjernkjøling",
Energibaerer::BiobrenselLukket => "Biobrensel (lukket ildsted)",
Energibaerer::BiobrenselAapent => "Biobrensel (åpent ildsted)",
Energibaerer::Annet => "Andre energibærere",
}
}
pub fn vekt(&self, v: &Vekting) -> f64 {
match self {
Energibaerer::Elektrisitet => v.faktor.elektrisitet,
Energibaerer::Olje => v.faktor.olje,
Energibaerer::Gass => v.faktor.gass,
Energibaerer::Fjernvarme => v.faktor.fjernvarme,
Energibaerer::Fjernkjoling => v.faktor.fjernkjoling,
Energibaerer::BiobrenselLukket => v.faktor.biobrensel_lukket,
Energibaerer::BiobrenselAapent => v.faktor.biobrensel_aapen,
Energibaerer::Annet => v.faktor.annet,
}
}
}
/// Én energipost i budsjettet, kWh/år for hele bygningen.
#[derive(Serialize, Clone, Debug)]
pub struct Energipost {
pub nr: &'static str,
pub navn: &'static str,
/// Netto energibehov (beregningspunkt A).
pub netto: f64,
/// Distribusjons- og akkumuleringstap.
pub tap: f64,
/// Brutto energibehov (beregningspunkt B).
pub brutto: f64,
/// Vektet systemvirkningsgrad / SPF for posten.
pub spf: f64,
/// Tilført energi (beregningspunkt C).
pub tilfort: f64,
/// Er posten klimaavhengig (1a, 1b, 3a, 3b)?
pub klimaavhengig: bool,
/// Fordeling av tilført energi på energibærere.
pub baerere: Vec<(Energibaerer, f64)>,
}
#[derive(Serialize, Clone, Debug)]
pub struct Energibudsjett {
pub poster: Vec<Energipost>,
pub netto_totalt: f64,
pub brutto_totalt: f64,
pub tilfort_totalt: f64,
}
impl Energibudsjett {
pub fn netto_spesifikk(&self, bra: f64) -> f64 {
self.netto_totalt / bra
}
}
/// Levert og eksportert energi (beregningspunkt D) og vektet levert energi (E).
#[derive(Serialize, Clone, Debug)]
pub struct Levert {
pub per_baerer: Vec<(Energibaerer, f64)>,
pub levert_totalt: f64,
/// Egenprodusert elektrisitet brukt i bygningen, kWh.
pub egenbruk: f64,
/// Egenprodusert elektrisitet eksportert, kWh.
pub eksportert: f64,
pub netto_levert: f64,
pub vektet: Vec<(Energibaerer, f64, f64)>,
pub vektet_totalt: f64,
/// Vektet levert energi per m² oppvarmet BRA, etter eventuell klimakorrigering.
pub spesifikk_vektet: f64,
pub klimakorrigering: Option<Korrigering>,
}
/// Maksimalt samtidig effektbehov (NS 3031 Tabell 8).
#[derive(Serialize, Clone, Debug)]
pub struct Effektbehov {
pub termisk_w: f64,
pub termisk_time: usize,
pub elspesifikk_w: f64,
pub elspesifikk_time: usize,
pub samlet_w: f64,
pub samlet_time: usize,
}
fn beskriv_time(t: usize) -> String {
let dag = t / 24;
let time = t % 24;
let mut d = dag as i64;
const DAGER: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut m = 0;
while m < 12 && d >= DAGER[m] {
d -= DAGER[m];
m += 1;
}
format!(
"{}. {} kl. {:02}:00",
d + 1,
crate::MAANEDER[m.min(11)],
time
)
}
impl Effektbehov {
pub fn beskriv_termisk(&self) -> String {
beskriv_time(self.termisk_time)
}
pub fn beskriv_samlet(&self) -> String {
beskriv_time(self.samlet_time)
}
}
/// Andelen av romoppvarming/ventilasjonsvarme/tappevann hver varmekilde dekker,
/// etter at vedovnens tak på 10 % og normalisering er anvendt.
#[derive(Serialize, Clone, Debug)]
pub struct Kildeandel {
pub typ: VarmekildeType,
pub beskrivelse: String,
pub andel_rom: f64,
pub andel_ventilasjon: f64,
pub andel_tappevann: f64,
pub spf: f64,
pub baerer: Energibaerer,
pub spf_kilde: String,
}
pub struct Systemoppsett {
pub kilder: Vec<Kildeandel>,
pub tappevann_tap_andel: f64,
pub tappevann_tap_i_sone: f64,
pub distribusjon_tap_ut: f64,
pub kjoling_spf: f64,
pub merknader: Vec<String>,
}
/// Bestemmer varmekildenes andeler og virkningsgrader.
pub fn sett_opp_systemer(b: &Bygning, l: &Lost, kilde: &Datakilde) -> Result<Systemoppsett> {
let tabell = kilde.varmepumper()?;
let mut merknader = Vec::new();
let mut kilder: Vec<Kildeandel> = Vec::new();
for k in &b.oppvarming.kilde {
let mut andel_rom = k.andel_rom.clamp(0.0, 1.0);
if k.typ == VarmekildeType::Vedovn {
let tak = kilde.vekting()?.regler.vedovn_maks_andel_rom;
if andel_rom > tak {
merknader.push(format!(
"Vedovnen er satt til å dekke {:.0} % av romoppvarmingen, men energimerkeforskriften \
regner med inntil {:.0} %. Resten dekkes av de øvrige varmekildene.",
andel_rom * 100.0,
tak * 100.0
));
andel_rom = tak;
}
}
let andel_vent =
k.andel_ventilasjon
.unwrap_or(if k.typ.vannbaaren() { andel_rom } else { 0.0 });
let turtemp = k.turtemperatur.or(b.distribusjon.turtemperatur);
let (spf, spf_kilde) = match k.spf {
Some(s) => (s, "oppgitt i inndata".to_string()),
None if k.typ.er_varmepumpe() => {
match spf_oppslag(&tabell, k.typ.nokkel(), turtemp, l.kategori) {
Some(s) => (s, "NS 3031:2014 tabell B.9-3 (veiledende)".to_string()),
None => (2.5, "antatt".to_string()),
}
}
None => (
standard_virkningsgrad(k.typ),
"veiledende systemvirkningsgrad".to_string(),
),
};
kilder.push(Kildeandel {
typ: k.typ,
beskrivelse: k.typ.beskrivelse().to_string(),
andel_rom,
andel_ventilasjon: andel_vent,
andel_tappevann: k.andel_tappevann.clamp(0.0, 1.0),
spf,
baerer: baerer_for(k.typ, k.lukket),
spf_kilde,
});
}
// Resten dekkes av direkte elektrisk oppvarming.
let rest = |f: fn(&Kildeandel) -> f64, kilder: &[Kildeandel]| -> f64 {
1.0 - kilder.iter().map(f).sum::<f64>()
};
let rest_rom = rest(|k| k.andel_rom, &kilder).max(0.0);
let rest_vent = rest(|k| k.andel_ventilasjon, &kilder).max(0.0);
let rest_vv = rest(|k| k.andel_tappevann, &kilder).max(0.0);
if rest_rom > 1e-6 || rest_vent > 1e-6 || rest_vv > 1e-6 {
if !b.oppvarming.kilde.is_empty() {
merknader.push(format!(
"Restbehovet dekkes av direkte elektrisk oppvarming: {:.0} % av romoppvarming, \
{:.0} % av ventilasjonsvarme og {:.0} % av tappevann.",
rest_rom * 100.0,
rest_vent * 100.0,
rest_vv * 100.0
));
}
kilder.push(Kildeandel {
typ: VarmekildeType::DirekteEl,
beskrivelse: VarmekildeType::DirekteEl.beskrivelse().to_string(),
andel_rom: rest_rom,
andel_ventilasjon: rest_vent,
andel_tappevann: rest_vv,
spf: 1.0,
baerer: Energibaerer::Elektrisitet,
spf_kilde: "elektrisk oppvarming, virkningsgrad 1,0".to_string(),
});
}
let sirkulasjon = b.tappevann.system == TappevannSystem::Sirkulasjon;
let tappevann_tap_andel =
b.tappevann
.tap_andel
.unwrap_or(if sirkulasjon { 0.45 } else { 0.25 });
let tappevann_tap_i_sone =
b.tappevann
.tap_i_oppvarmet_sone
.unwrap_or(if sirkulasjon { 0.5 } else { 0.8 });
let tap_andel = b
.distribusjon
.tap_andel
.unwrap_or(match b.distribusjon.typ {
DistribusjonType::Punkt => 0.0,
DistribusjonType::Radiator => 0.03,
DistribusjonType::Gulvvarme => 0.05,
});
let ut = b.distribusjon.roer_uoppvarmet.unwrap_or(0.2);
let distribusjon_tap_ut = tap_andel * ut;
Ok(Systemoppsett {
kilder,
tappevann_tap_andel,
tappevann_tap_i_sone,
distribusjon_tap_ut,
kjoling_spf: b.kjoling.spf.unwrap_or(3.0),
merknader,
})
}
fn standard_virkningsgrad(t: VarmekildeType) -> f64 {
match t {
VarmekildeType::DirekteEl | VarmekildeType::ElKjel => 1.0,
VarmekildeType::Vedovn | VarmekildeType::Pellets => 0.77,
VarmekildeType::Olje => 0.80,
VarmekildeType::Gass => 0.85,
VarmekildeType::Fjernvarme => 0.90,
VarmekildeType::Solfanger => 1e9,
_ => 2.5,
}
}
fn baerer_for(t: VarmekildeType, lukket: bool) -> Energibaerer {
match t {
VarmekildeType::Vedovn | VarmekildeType::Pellets => {
if lukket {
Energibaerer::BiobrenselLukket
} else {
Energibaerer::BiobrenselAapent
}
}
VarmekildeType::Olje => Energibaerer::Olje,
VarmekildeType::Gass => Energibaerer::Gass,
VarmekildeType::Fjernvarme => Energibaerer::Fjernvarme,
VarmekildeType::Solfanger => Energibaerer::Annet,
_ => Energibaerer::Elektrisitet,
}
}
/// Uregulerbart varmetilskudd fra tappevannsanlegget, W.
pub fn tappevannstilskudd(l: &Lost, s: &Systemoppsett) -> f64 {
let netto_w: f64 = l.laster.varmtvann.iter().sum::<f64>() / 24.0 * l.bra;
netto_w * s.tappevann_tap_andel * s.tappevann_tap_i_sone
}
/// Bygger energibudsjettet (punkt AC) av timeseriene.
pub fn energibudsjett(ts: &Timeserie, s: &Systemoppsett) -> Energibudsjett {
let kwh = Timeserie::sum_kwh;
let mut poster = Vec::new();
let andeler_rom: Vec<(Energibaerer, f64, f64)> = s
.kilder
.iter()
.map(|k| (k.baerer, k.andel_rom, k.spf))
.collect();
let andeler_vent: Vec<(Energibaerer, f64, f64)> = s
.kilder
.iter()
.map(|k| (k.baerer, k.andel_ventilasjon, k.spf))
.collect();
let andeler_vv: Vec<(Energibaerer, f64, f64)> = s
.kilder
.iter()
.map(|k| (k.baerer, k.andel_tappevann, k.spf))
.collect();
let el = vec![(Energibaerer::Elektrisitet, 1.0, 1.0)];
let kjol = vec![(Energibaerer::Elektrisitet, 1.0, s.kjoling_spf)];
let tap_rom =
|netto: f64| netto * s.distribusjon_tap_ut / (1.0 - s.distribusjon_tap_ut).max(1e-6);
let romvarme = kwh(&ts.romoppvarming);
poster.push(post(
"1a",
"Romoppvarming",
romvarme,
tap_rom(romvarme),
&andeler_rom,
true,
));
let ventvarme = kwh(&ts.ventilasjonsvarme);
poster.push(post(
"1b",
"Ventilasjonsvarme",
ventvarme,
tap_rom(ventvarme),
&andeler_vent,
true,
));
let vv = kwh(&ts.varmtvann);
poster.push(post(
"2",
"Varmtvann",
vv,
vv * s.tappevann_tap_andel,
&andeler_vv,
false,
));
let romkjol = kwh(&ts.romkjoling);
poster.push(post("3a", "Romkjøling", romkjol, 0.0, &kjol, true));
let ventkjol = kwh(&ts.ventilasjonskjoling);
poster.push(post(
"3b",
"Ventilasjonskjøling",
ventkjol,
0.0,
&kjol,
true,
));
poster.push(post("4a", "Vifter", kwh(&ts.vifter), 0.0, &el, false));
poster.push(post("4b", "Pumper", kwh(&ts.pumper), 0.0, &el, false));
poster.push(post("5", "Belysning", kwh(&ts.belysning), 0.0, &el, false));
poster.push(post(
"6",
"Teknisk utstyr",
kwh(&ts.utstyr),
0.0,
&el,
false,
));
let netto_totalt = poster.iter().map(|p| p.netto).sum();
let brutto_totalt = poster.iter().map(|p| p.brutto).sum();
let tilfort_totalt = poster.iter().map(|p| p.tilfort).sum();
Energibudsjett {
poster,
netto_totalt,
brutto_totalt,
tilfort_totalt,
}
}
fn post(
nr: &'static str,
navn: &'static str,
netto: f64,
tap: f64,
andeler: &[(Energibaerer, f64, f64)],
klimaavhengig: bool,
) -> Energipost {
let brutto = netto + tap;
let mut baerere: Vec<(Energibaerer, f64)> = Vec::new();
let mut tilfort = 0.0;
for (b, andel, spf) in andeler {
if *andel <= 1e-9 {
continue;
}
let e = brutto * andel / spf.max(1e-9);
tilfort += e;
match baerere.iter_mut().find(|(bb, _)| bb == b) {
Some((_, v)) => *v += e,
None => baerere.push((*b, e)),
}
}
let spf = if tilfort > 1e-9 {
brutto / tilfort
} else {
0.0
};
Energipost {
nr,
navn,
netto,
tap,
brutto,
spf,
tilfort,
klimaavhengig,
baerere,
}
}
/// Beregner levert og vektet energi (punkt D og E), med klimakorrigering og solceller.
pub fn levert(
budsjett: &Energibudsjett,
vekting: &Vekting,
bra: f64,
korrigering: Option<Korrigering>,
egenbruk: f64,
eksportert: f64,
) -> Levert {
let kf = korrigering.as_ref().map(|k| k.faktor).unwrap_or(1.0);
let mut per: Vec<(Energibaerer, f64)> = Vec::new();
for p in &budsjett.poster {
let skala = if p.klimaavhengig { 1.0 / kf } else { 1.0 };
for (b, v) in &p.baerere {
let v = v * skala;
match per.iter_mut().find(|(bb, _)| bb == b) {
Some((_, s)) => *s += v,
None => per.push((*b, v)),
}
}
}
// Egenprodusert elektrisitet trekkes fra levert elektrisitet.
if let Some((_, v)) = per
.iter_mut()
.find(|(b, _)| *b == Energibaerer::Elektrisitet)
{
*v = (*v - egenbruk).max(0.0);
}
per.sort_by_key(|(b, _)| *b);
let levert_totalt: f64 = per.iter().map(|(_, v)| v).sum();
let netto_levert = levert_totalt - eksportert;
let vektet: Vec<(Energibaerer, f64, f64)> = per
.iter()
.map(|(b, v)| (*b, *v, v * b.vekt(vekting)))
.collect();
let vektet_totalt: f64 =
vektet.iter().map(|(_, _, v)| v).sum::<f64>() + -(eksportert * vekting.faktor.eksportert);
Levert {
per_baerer: per,
levert_totalt,
egenbruk,
eksportert,
netto_levert,
vektet,
vektet_totalt,
spesifikk_vektet: vektet_totalt / bra,
klimakorrigering: korrigering,
}
}
/// Klimaavhengig tilført energi per m², som grunnlag for klimakorrigering.
pub fn klimaavhengig_spesifikk(budsjett: &Energibudsjett, bra: f64) -> f64 {
budsjett
.poster
.iter()
.filter(|p| p.klimaavhengig)
.map(|p| p.tilfort)
.sum::<f64>()
/ bra
}
/// Maksimalt samtidig effektbehov.
pub fn effektbehov(ts: &Timeserie, s: &Systemoppsett) -> Effektbehov {
let mut termisk = (0.0f64, 0usize);
let mut elspes = (0.0f64, 0usize);
let mut samlet = (0.0f64, 0usize);
let tap = 1.0 / (1.0 - s.distribusjon_tap_ut).max(1e-6);
for t in 0..crate::TIMER {
let term = (ts.romoppvarming[t] + ts.ventilasjonsvarme[t]) * tap
+ ts.varmtvann[t] * (1.0 + s.tappevann_tap_andel)
+ ts.romkjoling[t]
+ ts.ventilasjonskjoling[t];
let el = ts.vifter[t] + ts.pumper[t] + ts.belysning[t] + ts.utstyr[t];
if term > termisk.0 {
termisk = (term, t);
}
if el > elspes.0 {
elspes = (el, t);
}
if term + el > samlet.0 {
samlet = (term + el, t);
}
}
Effektbehov {
termisk_w: termisk.0,
termisk_time: termisk.1,
elspesifikk_w: elspes.0,
elspesifikk_time: elspes.1,
samlet_w: samlet.0,
samlet_time: samlet.1,
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Lokal elproduksjon fra solceller, med timesvis egenbruk og eksport.
use crate::klima::{sol, Vaerdata};
use crate::modell::Solcelle;
use crate::motor::Timeserie;
use crate::systemer::Systemoppsett;
use crate::TIMER;
pub struct Produksjon {
/// Timesserie for produksjon, W.
pub serie: Vec<f64>,
pub produsert_kwh: f64,
pub egenbruk_kwh: f64,
pub eksportert_kwh: f64,
}
/// Beregner produksjon og hvor mye som dekkes internt time for time.
///
/// Egenbruket måles mot byggets samlede elektriske effektbehov i timen: el-spesifikke poster
/// pluss elektrisitet til varme og kjøling etter systemvirkningsgrad.
pub fn beregn(
s: &Solcelle,
v: &Vaerdata,
ts: &Timeserie,
sys: &Systemoppsett,
albedo: f64,
) -> Produksjon {
let innstraaling = sol::innstraaling_aar(v, s.helning, s.orientering, albedo);
let mut serie = vec![0.0; TIMER];
let mut produsert = 0.0;
let mut egenbruk = 0.0;
let mut eksport = 0.0;
let el_andel_varme: f64 = sys
.kilder
.iter()
.filter(|k| k.baerer == crate::systemer::Energibaerer::Elektrisitet)
.map(|k| k.andel_rom / k.spf.max(1e-9))
.sum();
let el_andel_vv: f64 = sys
.kilder
.iter()
.filter(|k| k.baerer == crate::systemer::Energibaerer::Elektrisitet)
.map(|k| k.andel_tappevann / k.spf.max(1e-9))
.sum();
for t in 0..TIMER {
let p = s.areal * s.virkningsgrad * s.ytelsesfaktor * innstraaling[t];
serie[t] = p;
produsert += p;
let forbruk = ts.belysning[t]
+ ts.utstyr[t]
+ ts.vifter[t]
+ ts.pumper[t]
+ ts.romoppvarming[t] * el_andel_varme
+ ts.varmtvann[t] * el_andel_vv
+ (ts.romkjoling[t] + ts.ventilasjonskjoling[t]) / sys.kjoling_spf.max(1e-9);
let brukt = p.min(forbruk);
egenbruk += brukt;
eksport += p - brukt;
}
Produksjon {
serie,
produsert_kwh: produsert / 1000.0,
egenbruk_kwh: egenbruk / 1000.0,
eksportert_kwh: eksport / 1000.0,
}
}