Files
enok/src/data.rs
T
blandClaude Opus 5 d02a3d9666 Legg til barnehage, kontor-, skole-, idretts- og kulturbygg
Driftstidene har nå dager per uke og uker per år, og lastene gjelder bare på driftsdager.
Normerte verdier for de nye kategoriene er hentet fra publiserte Simien-utskrifter og
DiBK-utredninger og kontrollert mot energibudsjettene i de samme kildene. Boligblokk har
fått egne verdier i stedet for en kopi av småhus.

Balanserte anlegg kan ha kjølebatteri, og varmegjenvinneren reguleres ned når den ellers
ville varmet tilluften over settpunktet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-15 20:05:20 +02:00

445 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
const NORMERTE_BARNEHAGE: &str = include_str!("../data/normerte/barnehage.toml");
const NORMERTE_KONTORBYGG: &str = include_str!("../data/normerte/kontorbygg.toml");
const NORMERTE_SKOLEBYGG: &str = include_str!("../data/normerte/skolebygg.toml");
const NORMERTE_IDRETTSBYGG: &str = include_str!("../data/normerte/idrettsbygg.toml");
const NORMERTE_KULTURBYGG: &str = include_str!("../data/normerte/kulturbygg.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),
Kategori::Barnehage => ("normerte/barnehage.toml", NORMERTE_BARNEHAGE),
Kategori::Kontorbygg => ("normerte/kontorbygg.toml", NORMERTE_KONTORBYGG),
Kategori::Skolebygg => ("normerte/skolebygg.toml", NORMERTE_SKOLEBYGG),
Kategori::Idrettsbygg => ("normerte/idrettsbygg.toml", NORMERTE_IDRETTSBYGG),
Kategori::Kulturbygg => ("normerte/kulturbygg.toml", NORMERTE_KULTURBYGG),
annen => {
// Kategorier uten offentlig tilgjengelige normerte verdier krever egen datafil.
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,
/// Tilluftstemperatur når anlegget har kjølebatteri. Utelatt: lik `ventilasjon_tilluft`.
#[serde(default)]
pub ventilasjon_tilluft_kjoling: Option<f64>,
}
#[derive(Deserialize, serde::Serialize, Clone, Debug)]
pub struct Driftstider {
pub oppvarming: Tidsrom,
pub belysning: Tidsrom,
pub utstyr: Tidsrom,
pub varmtvann: Tidsrom,
pub personer: Tidsrom,
pub ventilasjon: Tidsrom,
/// Driftsdager per uke, regnet fra mandag (NS 3031 Tabell A.3).
#[serde(default = "sju")]
pub dager_per_uke: u8,
/// Driftsuker per år (NS 3031 Tabell A.3). Ukene uten drift legges i ferier, se [`FERIEUKER`].
#[serde(default = "femtito")]
pub uker_per_aar: u8,
}
fn sju() -> u8 {
7
}
fn femtito() -> u8 {
52
}
/// Uker uten drift, i den rekkefølgen de tas ut når en kategori har færre enn 52 driftsuker:
/// fellesferien først, deretter jul, vinterferie, påske og høstferie, og så uke for uke
/// utover sommeren og nyttår.
pub const FERIEUKER: [u8; 16] = [28, 29, 30, 31, 27, 32, 52, 8, 14, 40, 26, 33, 1, 25, 34, 9];
impl Driftstider {
/// Om døgn nummer `dag` (0 = 1. januar) er en driftsdag.
///
/// Året regnes å begynne på en mandag. Døgn 364 hører til uke 52.
pub fn driftsdag(&self, dag: usize) -> bool {
if dag % 7 >= self.dager_per_uke.min(7) as usize {
return false;
}
let uke = (dag / 7 + 1).min(52) as u8;
let fri = (52u8.saturating_sub(self.uker_per_aar) as usize).min(FERIEUKER.len());
!FERIEUKER[..fri].contains(&uke)
}
pub fn driftsdager_per_aar(&self) -> usize {
(0..365).filter(|&d| self.driftsdag(d)).count()
}
}
#[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()
}