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