15 Commits
Author SHA1 Message Date
blandClaude Opus 5 c89561b0f5 Gi rapporten plass til kildene, og la den følge egne dokumenter
Release enok / bygg (release) Successful in 1m12s
test / test (push) Successful in 1m16s
Kolonnen «Kilde» i forutsetningstabellen fikk for liten plass, fordi de to første kolonnene
tok det de ville. Kolonnene har nå faste bredder, og tabellen leses på én linje per rad.

En ny [rapport]-tabell i bygningsbeskrivelsen setter logo, aksentfarge, avsenderlinje og
skrift for overskrifter, slik at energirapporten kan se ut som firmaets øvrige rapporter.
Logoen kopieres til utdatakatalogen; mangler filen, lages rapporten uten den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 22:02:36 +02:00
blandClaude Opus 5 e3f37afe36 Rett artefakt-versjonen i eksempelet
test / test (push) Successful in 1m16s
Gitea melder seg som GitHub Enterprise, og artefakt-actionene i v4 nekter å kjøre der.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 20:27:13 +02:00
blandClaude Opus 5 cabd8500ea Beskriv hvordan importen tåler feil i eksporten
test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 20:22:55 +02:00
blandClaude Opus 5 e7d77547ac Les høyden av geometrien, og tell dobbeltførte deler én gang
Release enok / bygg (release) Successful in 1m12s
test / test (push) Successful in 1m15s
Noen eksporter legger de samme veggene og dekkene i flere etasjer, og gir dem feil etasje.
Kopiene telles nå én gang, og den kopien som ligger i den etasjen høyden tilsier, er den som
regnes med. Vinduer i en kopi finner fortsatt veggen sin.

Klassifiseringen bruker høyden fra geometrien i stedet for etasjetilknytningen, som kan være
feil: et dekke mot grunnen kjennes på at det ligger nederst eller heter noe med «på grunn»,
og et dekke rett over kjelleren på høyden. Importen sier også hvilke deler som ikke er regnet
med i klimaskjermen, og en terrengmodell som ligger over gulvet i første etasje, forkastes.

Rapporten viser nå enok-versjonen og datoen den er laget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 20:21:58 +02:00
blandClaude Opus 5 70b9f9c647 enok 0.4.0
test / test (push) Successful in 1m20s
Release enok / bygg (release) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-20 19:57:06 +02:00
blandClaude Opus 5 77c5d15042 Ta vare på manuelle endringer ved ny import
«enok tillegg modell.ifc bygning.toml» skriver forskjellen mellom modellen og en håndredigert
bygningsbeskrivelse som en tilleggsfil: endrede verdier, nye oppføringer, «slett = true» for
det bare modellen har, og scenarioene. Kommandoen kontrollerer at tillegget gjenskaper filen
det er laget av, og sier fra om noe ikke lar seg uttrykke, siden fletting ikke kan fjerne et
enkeltfelt.

«enok import» nekter å skrive over en fil enok ikke har laget selv, og viser til kommandoen
over. --overskriv går forbi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-16 17:51:19 +02:00
blandClaude Opus 5 32ecb0b132 Beregn rett fra en BIM-modell, uten bygning.toml
«enok beregn modell.ifc» leser modellen, fletter inn en eventuell tilleggsfil og skriver
bygningsbeskrivelsen den kom fram til, i utdatakatalogen, slik at beregningen kan etterprøves.

Importen sier nå hvilke bygningsdeler som mangler U-verdi, ikke bare hvor mange. Vinduer og
dører uten U-verdi i modellen får en typisk verdi for byggeåret, slik flatene alltid har fått,
i stedet for faste 2,4 og 2,0 W/(m²K).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-16 14:39:58 +02:00
blandClaude Opus 5 6d87ef496f Lag bygning.toml av IFC-modellen og en tilleggsfil
«enok import modell.ifc --tillegg prosjekt.toml» fletter inn det modellen ikke beskriver,
som varmeanlegg og tiltak, og rettelser der den mangler noe. Tilleggsfilen kan holde rom
utenfor oppvarmet BRA, og et navn med «*» treffer alle elementer det passer på, også i
scenarioer.

Importen er nå deterministisk: entitetene leses i fast rekkefølge, helningen arealvektes
når flater slås sammen, og skrå tak får alltid normalen oppover. Vegger får himmelretningen
i navnet, slik at tilleggsfiler kan vise til dem.

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
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
blandClaude Opus 5 cb64c91b19 Fjern kundeopplysninger fra eksempler, tester og dokumentasjon
test / test (push) Successful in 1m42s
enok er et offentlig lager. Eksempelfilen, testene og README inneholdt navn og
adresse til en privat kunde, og navnet på modellen fra kundeprosjektet. Eksemplet
heter nå «Eldre enebolig med kjeller» uten adresse eller oppdragsgiver, og testene
bruker oppdiktede adresser og navn.

Opplysningene finnes fortsatt i tidligere commits i historikken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-15 15:45:08 +02:00
blandClaude Opus 5 fba5a7936d Velg klimasted ut fra georefereringen, og rett adresse fra IFC
Release enok / bygg (release) Successful in 1m20s
test / test (push) Successful in 1m26s
En ny eksport av Nordhusstykket 5 viste hva mer modellen kan bære, og avdekket feil.

Posisjon og klimasted
- IfcMapConversion med IfcProjectedCRS gjøres om til breddegrad og lengdegrad.
  EUREF89 og WGS 84 UTM, NN2000-varianter og Web Mercator forstås. Omregningen er
  kontrollert mot PROJ.
- Vectorworks' standardplassering i Washington, som står når ingenting er satt,
  kjennes igjen og brukes ikke.
- Ny `posisjon` under [klima]. Uten kommune velger beregningen nærmeste av de 358
  klimastedene i TMY-arkivet, og sier i rapporten hvor langt unna det ligger.

Opplysninger fra modellen
- Adressen hentes fra IfcBuilding eller IfcSite. Før ble den første postadressen i
  filen brukt, og det var firmaets adresse i forfatteropplysningene.
- Energirådgiver fra person og organisasjon i forfatteropplysningene.
- Byggets navn fra prosjektet når bygningen heter «Default Building».
- Referansehøyde og terrenghøyde på bygningen gir hvor mye av grunnmuren som står
  over bakken.
- IsLandmarked gir en merknad om at vernede bygninger er unntatt energiattestplikten.

En eksport uten bygningsdeler stopper nå med en feilmelding som peker på
lagtilknytningen i eksportdialogen, i stedet for å skrive en tom bygning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-15 15:38:04 +02:00
blandClaude Opus 5 be1514bd83 Fjern rapportbyggingen fra enok
test / test (push) Successful in 1m26s
Energirapporter lages i hvert prosjektlager, ikke i verktøyets eget lager.
rapport.yml og bygninger/ er fjernet. Mappen inneholdt en kundes IFC-modell
og bygningsbeskrivelse, som hører hjemme i prosjektlageret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-15 15:26:20 +02:00
blandClaude Opus 5 81544d73e6 Hent mye mer ut av IFC-modellen
Energirapporter / rapporter (release) Successful in 57s
Release enok / bygg (release) Successful in 1m8s
test / test (push) Successful in 1m16s
Modellen for Nordhusstykket 5 viste seg å inneholde langt mer enn importen brukte.

Etasjer
- IfcBuildingStorey med høyde og navn, og tilknytningen av rom og bygningsdeler, sier
  hva som er hva. Etasjen «Kjeller» ligger 2,35 m under referansenivået.
- En etasje under terreng settes nå opp som en egen uoppvarmet sone med sine egne
  vegger og gulv, og dekket over blir grensen mot den oppvarmede delen.
- Oppvarmet BRA er rommene i etasjene over terreng. Rom som heter kott, bod, loft
  eller garasje blir flagget, siden de sjelden er oppvarmet.
- Et dekke kan bare ligge mot grunnen i nederste etasje. Før ble etasjeskillere høyere
  opp regnet som gulv mot grunn.

Egenskaper
- Byggeår, bygningskategori, kommune, lekkasjetall, ventilasjonstype, varmegjenvinning,
  varmekilde og oppvarmet BRA leses fra et hvilket som helst egenskapssett på bygningen,
  tomta eller prosjektet, på norsk eller engelsk. Da kan et postformat i BIM-verktøyet
  bære de opplysningene IFC ikke har noen fast plass til.
- Modellen viste seg allerede å ha YearOfConstruction 1950. Bare den ene verdien flytter
  den automatiske beregningen fra karakter E til G, siden lekkasjetallet følger byggeåret.
- Masseklasse utledes av materialnavnene, og posisjonen leses av IfcSite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-14 22:36:54 +02:00
blandClaude Opus 5 afb8ea3fc4 Les EPW-filer som Latin-1, og bruk oppgitt kommune i klimaoppslaget
Energirapporter / rapporter (release) Successful in 1m2s
Release enok / bygg (release) Successful in 1m9s
test / test (push) Successful in 1m16s
EPW-filer er ikke UTF-8. Stedsnavnet «Indre Østfold» kommer som Latin-1-byten 0xD8,
og tolket som UTF-8 ble navnet ødelagt. Da fant ikke oppslaget klimakorrigerings-
faktoren, og energiytelsen ble stående i lokalt klima uten korrigering til Oslo.
Utslaget var 79 kWh/(m²·år) for Nordhusstykket 5, uten annet varsel enn en merknad
inne i rapporten.

Filen leses nå som Latin-1 når den ikke er gyldig UTF-8. Er kommunen oppgitt, gjelder
den for oppslaget, slik at resultatet ikke avhenger av skrivemåten inne i klimafilen.
Merknader fra beregningen skrives også ut på skjermen, ikke bare i rapporten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-14 21:29:36 +02:00
blandClaude Opus 5 0404252fa0 Sett opp Rust-verktøykjeden i byggeserveren, og fiks nedlastingsstien
test / test (push) Successful in 2m2s
Kjørebrukeren gitea-runner har rustup installert, men ingen standardkjede satt.
Cargo svarte derfor at den ikke visste hvilken versjon den skulle bruke, og alle
tre arbeidsflytene feilet. Arbeidsflytene setter nå en standardkjede selv om det
mangler, i stedet for at kjøreren må ordnes for hånd.

Gitea har heller ingen «releases/latest/download»-sti slik GitHub har. Taggen
slås opp i API-et først.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-14 21:24:39 +02:00
42 changed files with 3102 additions and 686 deletions
-67
View File
@@ -1,67 +0,0 @@
name: Energirapporter
# Lager energirapport for hver bygning i `bygninger/` og legger PDF, JSON og CSV ved
# utgivelsen. Se `bygninger/LES-MEG.md` for hvilke filtyper som kan ligge der.
on:
release:
types: [published]
workflow_dispatch:
jobs:
rapporter:
runs-on: fish
defaults:
run:
shell: fish {0}
steps:
- name: Hent koden
uses: https://gitea.com/actions/checkout@v6
- name: Bygg enok
run: cargo build --release --locked
- name: Importer BIM-filer som ikke har en TOML fra før
run: |
for f in (find bygninger -type f \( -iname '*.ifc' -o -iname '*.gbxml' -o -iname '*.csv' \) 2>/dev/null)
set toml (string replace -r '\.[^.]+$' '.toml' $f)
if not test -f $toml
echo "Importerer $f"
./target/release/enok import $f --ut $toml
end
end
- name: Beregn og lag rapporter
run: |
mkdir -p dist
set feil 0
for t in (find bygninger -type f -name '*.toml' 2>/dev/null)
set navn (basename $t .toml)
echo "::group::$navn"
if ./target/release/enok beregn $t --ut ut/$navn
cp ut/$navn/rapport.pdf dist/$navn-energirapport.pdf
cp ut/$navn/resultat.json dist/$navn-resultat.json
cp ut/$navn/maaned.csv dist/$navn-maaned.csv
gzip -c ut/$navn/timeserie.csv > dist/$navn-timeserie.csv.gz
else
echo "Beregningen av $navn feilet"
set feil 1
end
echo "::endgroup::"
end
ls -la dist
exit $feil
- name: Legg ved utgivelsen
if: github.event_name == 'release'
env:
TOKEN: ${{ github.token }}
run: |
set REL_ID (curl --silent --fail --header "Authorization: token $TOKEN" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" | jq -r '.id')
for f in (find dist -type f)
echo "Laster opp "(basename $f)
curl --silent --show-error --fail --header "Authorization: token $TOKEN" \
--form "attachment=@$f" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$REL_ID/assets?name="(basename $f)
end
+9
View File
@@ -17,6 +17,15 @@ jobs:
- name: Hent koden
uses: https://gitea.com/actions/checkout@v6
- name: Sørg for en Rust-verktøykjede
run: |
# Kjørebrukeren har rustup, men ingen standardkjede er satt. Uten dette svarer
# cargo med at den ikke vet hvilken versjon den skal bruke.
if not cargo --version >/dev/null 2>&1
rustup default stable
end
cargo --version
- name: Bygg
run: cargo build --release --locked
+9
View File
@@ -16,6 +16,15 @@ jobs:
- name: Hent koden
uses: https://gitea.com/actions/checkout@v6
- name: Sørg for en Rust-verktøykjede
run: |
# Kjørebrukeren har rustup, men ingen standardkjede er satt. Uten dette svarer
# cargo med at den ikke vet hvilken versjon den skal bruke.
if not cargo --version >/dev/null 2>&1
rustup default stable
end
cargo --version
- name: Formatering
run: cargo fmt --check
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]]
name = "enok"
version = "0.1.0"
version = "0.4.2"
dependencies = [
"anyhow",
"approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "enok"
version = "0.1.0"
version = "0.4.2"
edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
license = "MIT"
+217 -25
View File
@@ -9,11 +9,11 @@ varmekilder og drift — og et typisk klimaår, og gir 8760 timesverdier, et ful
fra netto energibehov til vektet levert energi, en energikarakter og en ferdig rapport i PDF.
```
enok beregn bygninger/nordhusstykket-5.toml
enok beregn bygning.toml
```
```
Nordhusstykket 5 (Småhus, 140 m², byggeår 1948)
Eldre enebolig med kjeller (Småhus, 140 m², byggeår 1948)
Klimadata: Indre Østfold
Varmetapstall 3,00 W/(m²K)
@@ -68,6 +68,17 @@ bygning med store vindusflater fått urealistisk høye innetemperaturer om våre
lagret solvarme ville kunstig redusert oppvarmingsbehovet. Grensen settes til den normerte
settpunkttemperaturen for kjøling, og luftingen koster ingen energi.
**Ventilasjon.** Varmegjenvinneren reguleres ned når den ellers ville varmet tilluften over
settpunktet. Et anlegg med `kjolebatteri = true` holder tilluften på normert temperatur i
driftstiden, slik DiBKs modellbygg for energirammene er regnet, og energien føres som
ventilasjonskjøling.
**Bygningskategorier.** Normerte laster og driftstider er lagt inn for småhus, boligblokk,
barnehage, kontorbygg, skolebygg, idrettsbygg og kulturbygg. Driftstidene har timer per døgn,
dager per uke og uker per år; lastene gjelder bare på driftsdager, og ukene uten drift legges
i ferier. For sykehus, sykehjem, hotell, forretningsbygg, universitet og lett industri er det
ikke funnet offentlige kilder, og de krever en egen fil i `--data KATALOG/normerte/`.
**Tiltak.** Scenarier i samme fil gir før-og-etter-tall og karakterer i én tabell.
## Installasjon
@@ -84,12 +95,15 @@ Typst må være installert for PDF-rapporten. Uten Typst kjører du med `--ingen
| Kommando | Hva den gjør |
| --- | --- |
| `enok beregn FIL.toml` | Beregner og skriver rapport, CSV og JSON til `ut/<navn>/` |
| `enok beregn modell.ifc` | Beregner rett fra en modell, uten at `bygning.toml` finnes fra før |
| `enok import FIL` | Leser IFC, gbXML eller regneark og skriver `bygning.toml` |
| `enok import FIL --tillegg prosjekt.toml` | Som over, med en tilleggsfil flettet inn (se nedenfor) |
| `enok tillegg modell.ifc bygning.toml` | Skriver forskjellen mellom modellen og en håndredigert fil som tilleggsfil |
| `enok klima --liste` | Lister alle kommuner med klimadata |
| `enok klima --kommune Oslo` | Viser nøkkeltall for ett klimasted |
| `enok skala smaahus --areal 140` | Viser karakterskalaen med arealkorreksjon |
Nyttige valg til `beregn`: `--ingen-pdf`, `--ingen-timeserie`, `--enova-xml`,
Nyttige valg til `beregn`: `--tillegg FIL`, `--ingen-pdf`, `--ingen-timeserie`, `--enova-xml`,
`--uten-scenarier`, `--ut KATALOG`.
### Utdata
@@ -111,13 +125,13 @@ antatt og hvor det kommer fra.
```toml
[bygning]
navn = "Nordhusstykket 5"
navn = "Eldre enebolig med kjeller"
kategori = "smaahus"
byggeaar = 1948
oppvarmet_bra = 140.0
[klima]
kommune = "Indre Østfold"
kommune = "Indre Østfold" # eller posisjon = [59.63, 11.01] for nærmeste klimasted
[[flate]]
navn = "Yttervegg sør"
@@ -154,6 +168,7 @@ over_terreng = 0.6 # høyden som står over terreng
[ventilasjon]
type = "naturlig" # naturlig | avtrekk | balansert
# kjolebatteri = true # balansert anlegg med kjølebatteri
[[oppvarming.kilde]]
type = "el_kjel" # direkte_el, luft_luft_vp, vaeske_vann_vp, vedovn, fjernvarme, …
@@ -165,8 +180,19 @@ navn = "Nye vinduer"
endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }]
```
Rapporten kan følge husets eller firmaets egne dokumenter:
```toml
[rapport]
logo = "logo.svg" # vises øverst på forsiden
farge = "#880000" # aksentfarge i overskrifter og tabeller
avsender = "Firma · Adresse · Org.nr. 000 000 000"
skrift_overskrift = "Futura Now Headline"
```
Scenarier flettes inn i bygningsbeskrivelsen: tabeller flettes rekursivt, elementer i lister
matches på `navn` eller `type`, nye elementer legges til, og `slett = true` fjerner et element.
Et navn med `*` treffer alle elementer det passer på, for eksempel `navn = "Yttervegg*"`.
Ferdige eksempler ligger i `eksempler/`.
@@ -178,18 +204,162 @@ Ferdige eksempler ligger i `eksempler/`.
| gbXML | Verktøy som eksporterer gbXML | Rom, flater, åpninger, konstruksjoner med lag |
| Regneark | *Fil → Eksporter → Export Worksheet* | Enkel CSV med én rad per bygningsdel |
Himmelretningene i IFC utledes av elementenes plassering: veggens lokale akse gir planet, og
ytre normal velges som den som peker bort fra bygningens tyngdepunkt, korrigert for prosjektets
sanne nord. Kontroller dem mot tegningen etter import.
### Modellen og en tilleggsfil
Ingen av formatene beskriver ventilasjon, lekkasjetall eller varmekilder. Importen skriver
merknader øverst i TOML-filen om hva som må fylles ut.
Modellen er kilden til alt den kan beskrive, og en modell alene gir en ferdig rapport: enok
fyller hullene med normerte verdier og typiske verdier for byggeåret, og rapporten sier hva
som er antatt. Det modellen ikke kan beskrive, som varmeanlegg, lekkasjetall og tiltak, og
rettelser der den mangler noe, legges i en tilleggsfil som flettes inn etter de samme reglene
som scenarioene:
```sh
enok beregn modell.ifc # bare modellen
enok beregn modell.ifc --tillegg prosjekt.toml # modellen og tilleggsfilen
enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml # samme, som fil
```
`beregn` skriver bygningsbeskrivelsen modellen ga, til `bygning.toml` i utdatakatalogen, slik
at beregningen kan etterprøves.
En ny import skriver aldri over manuelle endringer, for de ligger ikke i den genererte filen.
Har du likevel en `bygning.toml` du har redigert for hånd, lager `enok tillegg` en tilleggsfil
av forskjellen mot modellen, så arbeidet følger med videre:
```sh
enok tillegg modell.ifc bygning.toml --ut prosjekt.toml
```
Tillegget inneholder alt filen sier som modellen ikke sier: endrede U-verdier, drift,
varmeanlegg og scenarioer, og `slett = true` for oppføringer modellen har og filen ikke.
Kommandoen kontrollerer at tillegget gjenskaper filen det er laget av, og sier fra om noe
ikke lar seg uttrykke. `enok import` nekter dessuten å skrive over en fil enok ikke har laget
selv, med mindre `--overskriv` er oppgitt.
```toml
# prosjekt.toml
[import]
uoppvarmede_rom = ["Kott", "Bod"] # holdes utenfor oppvarmet BRA
[[flate]]
navn = "Yttervegg*" # alle ytterveggene, uansett himmelretning
masseklasse = "lett"
[[flate]]
navn = "Roof Slab" # skråtaket mangler U-verdi i modellen
u_verdi = 0.55
[[vindu]]
navn = "V-*"
g_verdi = 0.76
[[sone]]
navn = "Kjeller"
andel_distribusjonstap = 1.0
[[sone.flate]]
navn = "Kjellermur*"
over_terreng = 0.6
[[oppvarming.kilde]]
type = "el_kjel"
andel_rom = 1.0
andel_tappevann = 1.0
```
Flatene får navn etter konstruksjonen i modellen, og vegger får himmelretningen etter navnet
(«Yttervegg, sør»), slik at tilleggsfilen kan vise til dem. Importen er deterministisk: samme
modell og tilleggsfil gir alltid samme `bygning.toml`. Et mønster som ikke treffer noe, nevnes
i merknadene, og et vanlig navn som ikke finnes i modellen, legges til som ny oppføring.
### Hva enok henter ut av en IFC-modell
| Opplysning | Hvor den kommer fra |
| --- | --- |
| Areal for vegger, tak og dekker | Geometrien. Trekantene grupperes etter retning, og den største gruppen er delens hovedflate. Vinduer som er skåret ut av veggen kommer riktig fram, siden hullene mangler trekanter. Materialsjikt som ligger oppå hverandre telles én gang |
| Himmelretning og helning | Flatenormalen, dreid med elementets plassering og prosjektets sanne nord |
| U-verdi | `ThermalTransmittance` i egenskapssettene, for både konstruksjoner og vinduer |
| Ytre eller innvendig | `IsExternal` |
| Masseklasse | Materialnavnene. Betong, tegl og mur gir tung konstruksjon, bindingsverk og isolasjon lett |
| Etasjer og hva som ligger i dem | `IfcBuildingStorey` med høyde, og tilknytningen av rom og bygningsdeler |
| Uoppvarmet kjeller | En etasje under referansenivået blir satt opp som egen sone, med sine egne vegger og gulv, og dekket over som grense mot den oppvarmede delen |
| Oppvarmet BRA | Rommene i etasjene over terreng. Rom som heter kott, bod, loft eller garasje blir flagget |
| Vindus- og dørareal | `OverallHeight` og `OverallWidth`, og hvilken vegg de sitter i |
| Posisjon | `IfcMapConversion` med `IfcProjectedCRS`, ellers `IfcSite`. Uten oppgitt kommune velger beregningen nærmeste klimasted. EUREF89 UTM og Web Mercator forstås |
| Grunnmur over terreng | `ElevationOfRefHeight` og `ElevationOfTerrain` på bygningen |
| Adresse | `BuildingAddress` på bygningen, eller `SiteAddress` på tomta. Adressen i forfatteropplysningene er firmaets og brukes ikke |
| Energirådgiver | Person og organisasjon i forfatteropplysningene |
| Byggets navn | Bygningens navn, eller prosjektets når bygningen heter «Default Building» |
| Vernet bygning | `IsLandmarked`. Vernede bygninger er unntatt plikten til energiattest |
Himmelretningene og arealene bør likevel kontrolleres mot tegningen. Importen skriver merknader
øverst i TOML-filen om hva den måtte anta.
### Egenskaper enok leser
IFC beskriver geometri og konstruksjon, men har ingen fast plass til byggeår, tetthet,
ventilasjon eller hvordan huset varmes opp. Det er nettopp de opplysningene som skiller en god
energiberegning fra en middelmådig: for et hus fra 1948 er forskjellen mellom lekkasjetall 0,6
og 8 langt større enn alt annet i modellen.
De kan likevel følge med. I Vectorworks lager du et postformat, fester det på bygningen og
knytter feltene til et IFC-egenskapssett. enok leser da disse navnene fra et hvilket som helst
egenskapssett på `IfcBuilding`, `IfcSite` eller `IfcProject`:
| Felt | Godtar også | Eksempel |
| --- | --- | --- |
| `Byggeaar` | `Byggeår`, `YearOfConstruction` | `1948` |
| `Bygningskategori` | `Kategori`, `BuildingCategory` | `Småhus` |
| `Kommune` | `Municipality`, `Klimasted` | `Indre Østfold` |
| `Lekkasjetall` | `n50`, `AirPermeability` | `8.0` |
| `Ventilasjonstype` | `Ventilasjon`, `VentilationType` | `Balansert` |
| `Varmegjenvinning` | `Gjenvinning`, `HeatRecoveryEfficiency` | `80` |
| `Varmekilde` | `Oppvarming`, `HeatingSource` | `Bergvarmepumpe` |
| `OppvarmetBRA` | `HeatedFloorArea` | `120` |
Mangler en bygningsdel U-verdi i modellen, sier importen hvilke det gjelder, og beregningen
bruker en typisk verdi for byggeåret. Det gjelder også vinduer og dører, som i de fleste
eksportene ikke har U-verdi.
Importen tåler noen vanlige feil i eksporten:
- **Deler som ligger dobbelt.** Legger eksporten den samme veggen eller det samme dekket i
flere etasjer, telles den én gang. Av kopiene brukes den som hører til den etasjen høyden
tilsier, og vinduene i de andre finner fortsatt veggen sin.
- **Feil etasje.** Hva som er gulv mot grunnen, dekke over kjelleren eller etasjeskiller inne
i bygningen, avgjøres av høyden geometrien har, ikke av hvilken etasje delen er lagt i.
- **Terrengmodell utenfor bakkenivå.** Ligger terrenget over gulvet i første etasje, brukes
det ikke, og importen sier fra. Oppgi da `over_terreng` på kjellerveggene.
Importen sier også hvilke deler som ikke er regnet med i klimaskjermen, som innervegger og
etasjeskillere, slik at det er lett å se om noe mangler.
Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på
modellen alene.
Dette i selve modellen er verdt å gjøre riktig, omtrent i rekkefølge etter hva det betyr:
- **Ta med designlagene i eksporten.** I «Export IFC Project» må lagene være knyttet til
etasjer under «Layer Mapping». Mangler de, blir filen bare prosjektstruktur uten vegger,
dekker, vinduer eller rom. enok stopper da med en feilmelding i stedet for å skrive en tom
bygning.
- **Georeferer tegningen, eller sett prosjektstedet.** Da får modellen koordinater, og
beregningen velger selv nærmeste av de 358 klimastedene. Uten det står Vectorworks'
standardverdi i Washington, og den blir oversett.
- **Merk yttervegger med `IsExternal`.** I en typisk eksport er bare en liten del av
veggene merket, og kjellermurene er sjelden blant dem.
- **Fyll inn U-verdi for tak og vinduer.** Veggene har den, takflatene og vinduene ikke.
- **Gi etasjene navn.** «Kjeller», «1. etg.» og «2. etg.» er nok til at enok finner den
uoppvarmede kjelleren og regner oppvarmet areal riktig.
- **Sett referansehøyde og terrenghøyde på bygningen.** Da regner enok ut hvor mye av
grunnmuren som står over bakken. Uten dem regnes hele muren som nedgravd.
- **Legg byggets adresse på bygningen.** Adressen i forfatteropplysningene er firmaets, og
brukes ikke som byggets adresse.
Regnearkformatet er bevisst enkelt, slik at det kan settes opp som en rapport i BIM-verktøyet:
```csv
type,navn,areal,u_verdi,orientering,helning,mot,g_verdi,antall
bygning,Nordhusstykket 5,140,,,,smaahus,1948,
bygning,Eldre enebolig,140,,,,smaahus,1948,
vegg,Yttervegg sør,26,1.0,180,90,ute,,
gulv,Mot kjeller,98,0.8,,180,uoppvarmet,,
vindu,Stue sør,3.0,2.8,180,90,,0.76,2
@@ -197,22 +367,19 @@ vindu,Stue sør,3.0,2.8,180,90,,0.76,2
## Rapporter fra byggeserveren
Arbeidsflytene i `.gitea/workflows/` kjører på vertskjøreren `fish` og utløses når en
utgivelse publiseres, på samme måte som de andre prosjektene.
Rapportene lages i hvert prosjektlager, ikke i enok. enoks egne arbeidsflyter kjører på
vertskjøreren `fish` på samme måte som de andre prosjektene:
| Fil | Hva den gjør |
| --- | --- |
| `test.yml` | Formatering, clippy og tester på hver push |
| `release.yml` | Bygger enok og legger binærfilen ved utgivelsen |
| `rapport.yml` | Lager energirapport for hver bygning i `bygninger/` og legger PDF, JSON og CSV ved utgivelsen |
IFC-, gbXML- og CSV-filer i `bygninger/` importeres automatisk til TOML dersom det ikke
allerede finnes en TOML-fil med samme navn. Se `bygninger/LES-MEG.md`.
### Energirapport fra et byggeprosjekt
Et prosjektlager med sin egen IFC-modell og `bygning.toml` trenger ikke Rust. Det henter
binærfilen fra enoks utgivelse:
Et prosjektlager med sin egen IFC-modell og tilleggsfil trenger ikke Rust. Det henter
binærfilen fra enoks siste utgivelse. Én jobb lager `bygning.toml` av modellen, og en annen
beregner rapporten av den:
```yaml
name: Energirapport
@@ -220,19 +387,43 @@ on:
release:
types: [published]
jobs:
rapport:
bygningsmodell:
runs-on: fish
defaults:
run:
shell: fish {0}
steps:
- uses: https://gitea.com/actions/checkout@v6
- name: Hent enok og lag rapporten
- name: Hent enok og lag bygning.toml
env:
TOKEN: ${{ github.token }}
run: |
curl -sL -o enok --header "Authorization: token $TOKEN" \
"$GITHUB_SERVER_URL/klingenbergbygg/enok/releases/latest/download/enok"
# Gitea har ingen «latest/download»-sti, så taggen slås opp først.
set TAG (curl -sS --fail --header "Authorization: token $TOKEN" \
"$GITHUB_SERVER_URL/api/v1/repos/klingenbergbygg/enok/releases/latest" | jq -r '.tag_name')
curl -sSL --fail -o enok --header "Authorization: token $TOKEN" \
"$GITHUB_SERVER_URL/klingenbergbygg/enok/releases/download/$TAG/enok"
chmod +x enok
./enok import modell.ifc --tillegg prosjekt.toml --ut bygning.toml
- uses: https://gitea.com/actions/upload-artifact@v3
with:
name: bygningsmodell
path: |
bygning.toml
enok
rapport:
needs: bygningsmodell
runs-on: fish
defaults:
run:
shell: fish {0}
steps:
- uses: https://gitea.com/actions/checkout@v6
- uses: https://gitea.com/actions/download-artifact@v3
with:
name: bygningsmodell
- name: Lag rapporten
run: |
chmod +x enok
./enok beregn bygning.toml --ut dist
```
@@ -251,12 +442,13 @@ Motoren er kontrollert mot uavhengige referanser:
| Varmebehov som funksjon av temperaturdifferanse | eksakt lineært |
| Varmetapsbudsjett for TEK17-referansebygget | identisk med publisert tabell, 0,69 W/(m²K) |
| Normerte energiposter for småhus | varmtvann 25,1, belysning 11,4, teknisk utstyr 17,5 kWh/(m²·år) |
| Faste energiposter for yrkesbyggene | belysning, utstyr og varmtvann treffer energibudsjettene i kildene |
### Kjent avvik
Standard Norges lanseringseksempel for NS 3031:2025 oppgir 46,2 kWh/(m²·år) romoppvarming og
106,4 kWh/(m²·år) netto energibehov for TEK17-referansebygget beregnet med Simien 8. enok
beregner 27,2 og 86,7 for den samme bygningen.
beregner 27,1 og 86,6 for den samme bygningen.
Varmetapsbudsjettet, de normerte energipostene, solinnstrålingen og energibalansen stemmer
hver for seg, så avviket ligger i hvor stor del av solvarmen som nyttiggjøres. NS 3031:2025
@@ -277,7 +469,7 @@ Alle normerte verdier ligger i `data/` og kan overstyres med `--data KATALOG`.
| --- | --- |
| `skala.toml` | Energikarakterskalaen fra 1.1.2026, Enova SF via Multiconsult-notatet «Grunnlag for ny energikarakterskala», 19.12.2025 |
| `vekting.toml` | Vektingsfaktorer etter energimerkeforskriften § 10a med NVEs veileder |
| `normerte/*.toml` | Normerte laster, driftstider og settpunkt. Utgangspunktet er NS 3031:2014 Tillegg A; varmtvann er oppdatert til NS 3031:2025-verdien for småhus. Timesprofiler fra NS 3031:2025 kan legges inn i `profil_*`-feltene |
| `normerte/*.toml` | Normerte laster, driftstider og settpunkt. Utgangspunktet er NS 3031:2014 Tillegg A; varmtvann for boliger er oppdatert til NS 3031:2025-verdien for småhus. Tillegg A er ikke offentlig, så yrkesbyggverdiene er hentet fra publiserte Simien-utskrifter og DiBK-utredninger. Hver fil oppgir kildene og hvilke verdier som er antatt. Timesprofiler fra NS 3031:2025 kan legges inn i `profil_*`-feltene |
| `varmepumpe_spf.toml` | Veiledende årsvarmefaktorer, NS 3031:2014 tabell B.9-3 |
| `materialer.toml` | Materialdata og typiske U-verdier per byggeperiode |
| `klimakorreksjon.csv` | Klimakorrigeringsfaktorer, Standard Norge SN/K 34, doi 10.5281/zenodo.15041587 |
-18
View File
@@ -1,18 +0,0 @@
# Bygninger
Legg én fil per bygning her. Byggeserveren regner ut energirapporten og legger PDF, JSON og
CSV ved utgivelsen når du setter en tagg som begynner med `v` eller `rapport-`.
| Filtype | Kommer fra |
| -------- | --------------------------------------------------- |
| `.toml` | Bygningsbeskrivelse skrevet for hånd, eller importert |
| `.ifc` | Vectorworks: *Fil → Eksporter → Export IFC Project* |
| `.gbxml` | BIM-verktøy som eksporterer gbXML |
| `.csv` | Vectorworks: *Fil → Eksporter → Export Worksheet* |
IFC-, gbXML- og CSV-filer blir importert til TOML automatisk dersom det ikke allerede finnes
en TOML-fil med samme navn. Importen får med geometri, U-verdier og himmelretninger, men ikke
ventilasjon, lekkasjetall eller varmekilder. Åpne den importerte TOML-filen, fyll ut det som
mangler, og sjekk den inn — da brukes den i stedet for importen neste gang.
Eksempler på ferdige bygningsbeskrivelser ligger i `../eksempler/`.
File diff suppressed because one or more lines are too long
-392
View File
@@ -1,392 +0,0 @@
# Nordhusstykket 5, 1825 Tomter — enebolig i tre fra 1948, 1,5 etasje på betong grunnmur.
#
# Geometri, U-verdier og himmelretninger er importert fra byggets IFC-modell
# (Vectorworks Architect 2026, «Nordhusstykket 5.ifc»). Arealene er regnet av geometrien,
# og oppføringene er slått sammen per konstruksjon og himmelretning.
# Vurderingene av isolasjon og lekkasjer er hentet fra termografirapporten av 10.3.2016.
#
# Verdier som ikke står i modellen og må kontrolleres på stedet er merket KONTROLLER.
[bygning]
navn = "Nordhusstykket 5"
kategori = "smaahus"
byggeaar = 1948
# Oppvarmet BRA er summen av rommene i 1. og 2. etasje i IFC-modellen:
# stue 39,6 + kjøkken 22,6 + entré 10,6 + vindfang 1,2 = 74,0 m² i 1. etasje,
# tre soverom 33,9 + gang/trapperom 11,2 + toalett 0,9 = 46,0 m² i 2. etasje.
# Kjeller (74,9 m²) og kott (19,6 m²) er uoppvarmede og regnes som egne soner.
oppvarmet_bra = 120.0
oppvarmet_volum = 288.0
adresse = "Nordhusstykket 5, 1825 Tomter"
oppdragsgiver = "Ellen Brødholt"
beskrivelse = "Enebolig i tre, 1,5 etasje, bebygget areal 99,6 m², uoppvarmet kjeller på betong grunnmur"
[klima]
kommune = "Indre Østfold"
[grunn]
ledningsevne = 2.0
# ── klimaskjerm for den oppvarmede delen ───────────────────────────────
# Ytterveggene er stenderverk med panel ute og porøs huntonitt inne. IFC-modellen oppgir
# U-verdi 0,93 W/(m²K), som stemmer med termografirapportens vurdering av at veggen har
# noe, men lite isolasjon.
[[flate]]
navn = "Yttervegg nord"
type = "vegg"
areal = 41.8
orientering = 0
u_verdi = 0.93
masseklasse = "lett"
[[flate]]
navn = "Yttervegg øst"
type = "vegg"
areal = 54.2
orientering = 90
u_verdi = 0.93
masseklasse = "lett"
[[flate]]
navn = "Yttervegg sør"
type = "vegg"
areal = 55.7
orientering = 180
u_verdi = 0.93
masseklasse = "lett"
[[flate]]
navn = "Yttervegg vest"
type = "vegg"
areal = 59.5
orientering = 270
u_verdi = 0.93
masseklasse = "lett"
# KONTROLLER: IFC-modellen oppgir ingen U-verdi for takflatene. Termografien viser
# luftlekkasje til loftet og ut gjennom pipen, og lite isolasjon i skråtaket.
[[flate]]
navn = "Skråtak"
type = "tak"
areal = 130.1
helning = 40
u_verdi = 0.55
masseklasse = "lett"
kommentar = "Areal fra IFC; U-verdi anslått etter termografirapporten"
[[flate]]
navn = "Himling mot kaldt loft"
type = "tak"
areal = 32.6
helning = 0
u_verdi = 0.45
mot = "uoppvarmet"
b_faktor = 0.9
masseklasse = "lett"
# Etasjeskilleren mot kjelleren. IFC-modellen har 150,4 m² «Etasjeskille», som dekker både
# gulvet over kjelleren og skillet mellom 1. og 2. etasje. Halvparten ligger over kjelleren,
# og stemmer med kjellergulvets 78,9 m².
[[flate]]
navn = "Etasjeskiller mot kjeller"
type = "gulv"
areal = 75.0
u_verdi = 0.31
mot = "uoppvarmet"
sone = "Kjeller"
masseklasse = "middels"
# ── vinduer og dører ───────────────────────────────────────────────────
# Areal og himmelretning fra IFC. KONTROLLER: modellen oppgir ingen U-verdi for vinduene.
# Termografirapporten beskriver dem som dårlig isolerte, og 2,8 W/(m²K) svarer til
# eldre tolagsglass. Med originale enkle glass ville verdien vært nærmere 5.
[[vindu]]
navn = "Vinduer nord"
areal = 5.58
orientering = 0
u_verdi = 2.80
g_verdi = 0.76
[[vindu]]
navn = "Vinduer øst"
areal = 2.86
orientering = 90
u_verdi = 2.80
g_verdi = 0.76
[[vindu]]
navn = "Vinduer sør"
areal = 3.96
orientering = 180
u_verdi = 2.80
g_verdi = 0.76
[[vindu]]
navn = "Vinduer vest"
areal = 1.05
orientering = 270
u_verdi = 2.80
g_verdi = 0.76
[[vindu]]
navn = "Inngangsdør med vindfang"
areal = 1.46
orientering = 270
u_verdi = 2.00
dor = true
[[vindu]]
navn = "Verandadør øst"
areal = 2.94
orientering = 90
u_verdi = 2.00
dor = true
[[vindu]]
navn = "Balkongdør sør"
areal = 1.91
orientering = 180
u_verdi = 2.00
dor = true
# Utkragede bjelker som bærer balkongen er en tydelig kuldebro i termografien.
[kuldebroer]
normalisert = 0.15
# KONTROLLER: lekkasjetallet er ikke målt. Termografien viser luftlekkasje til loft og
# gjennom pipen, som er typisk for et utett hus fra 1948.
[infiltrasjon]
n50 = 8.0
skjerming = "moderat"
[ventilasjon]
type = "naturlig"
luftmengde = 1.2
[tappevann]
system = "aapent"
# ── uoppvarmet kjeller ─────────────────────────────────────────────────
# Kjelleren brukes ikke til opphold, men varmes av uisolerte varmerør og av kjelen som står
# i det tekniske rommet. Temperaturen regnes ut time for time av kjellerens varmebalanse,
# slik at rørtapet både teller som tap fra varmeanlegget og som varme som holder
# etasjeskilleren varm. Det var dette termografirapporten pekte på som det største funnet.
[[sone]]
navn = "Kjeller"
volum = 165.0
luftveksling = 0.4
# Alt distribusjonstapet fra det vannbårne anlegget avgis her, og berederen står her.
andel_distribusjonstap = 1.0
andel_tappevannstap = 1.0
# KONTROLLER: anslått kappetap fra kjelen.
fast_effekt = 100.0
kommentar = "Rom 21,1 + gang 11,1 + vaskerom 9,9 + 11,1 + boder 10,4 + 9,3 + teknisk rom 2,2 = 74,9 m²"
# Grunnmuren. KONTROLLER: høyden over terreng er anslått til 0,6 m av fasadebildene i
# termografirapporten, og kjellerhøyden til 2,2 m. Disse to tallene avgjør hvor mye av
# muren som taper varme rett til uteluft, og er derfor verdt å måle på stedet.
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert, nord"
type = "vegg"
areal = 21.2
orientering = 0
u_verdi = 3.13
mot = "grunn"
dybde = 1.6
over_terreng = 0.6
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert, øst"
type = "vegg"
areal = 9.7
orientering = 90
u_verdi = 3.13
mot = "grunn"
dybde = 1.6
over_terreng = 0.6
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert, sør"
type = "vegg"
areal = 22.7
orientering = 180
u_verdi = 3.13
mot = "grunn"
dybde = 1.6
over_terreng = 0.6
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert, vest"
type = "vegg"
areal = 15.0
orientering = 270
u_verdi = 3.13
mot = "grunn"
dybde = 1.6
over_terreng = 0.6
[[sone.flate]]
navn = "Kjellermur 25 cm uisolert"
type = "vegg"
areal = 40.7
orientering = 90
u_verdi = 3.39
mot = "grunn"
dybde = 1.6
over_terreng = 0.6
[[sone.flate]]
navn = "Kjellermur med treullsement"
type = "vegg"
areal = 2.2
orientering = 90
u_verdi = 0.46
mot = "grunn"
dybde = 1.6
over_terreng = 0.6
[[sone.flate]]
navn = "Kjellergulv"
type = "gulv"
areal = 78.9
u_verdi = 3.85
mot = "grunn"
omkrets = 40.0
dybde = 2.2
# Kjellervinduene. Termografien viser stort varmetap gjennom kjellervinduet.
[[sone.flate]]
navn = "Kjellervinduer"
type = "vegg"
areal = 1.8
orientering = 180
u_verdi = 4.50
mot = "ute"
kommentar = "KONTROLLER: antall og størrelse; enkle glass i støpt karm"
# ── varmeforsyning ─────────────────────────────────────────────────────
# Vannbårent radiatoranlegg med elektrokjel i kjelleren, supplert med vedovn.
[[oppvarming.kilde]]
type = "vedovn"
andel_rom = 0.25
lukket = false
kommentar = "Åpent ildsted; energimerkeforskriften regner inntil 10 % av romoppvarmingen"
[[oppvarming.kilde]]
type = "el_kjel"
andel_rom = 0.75
andel_tappevann = 1.0
# Uisolerte varmerør gjennom den uoppvarmede kjelleren er hovedgrunnen til at kjelleren
# er varm. Termografirapporten anbefaler å isolere dem.
[distribusjon]
type = "radiator"
turtemperatur = 60
tap_andel = 0.25
roer_uoppvarmet = 1.0
# ── tiltak ─────────────────────────────────────────────────────────────
[[scenario]]
navn = "Isolerte varmerør"
beskrivelse = "Isolering av varmerørene gjennom kjelleren, som termografirapporten anbefaler. Rørtapet faller, men kjelleren blir kaldere, så noe av gevinsten spises opp av økt varmetap gjennom etasjeskilleren."
endringer.distribusjon = { tap_andel = 0.06 }
[[scenario]]
navn = "Isolerte kjellervegger"
beskrivelse = "Innvendig isolering av grunnmuren til U-verdi 0,35 W/(m²K)."
endringer.sone = [{ navn = "Kjeller", flate = [
{ navn = "Kjellermur 30 cm uisolert, nord", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, øst", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, sør", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, vest", u_verdi = 0.35 },
{ navn = "Kjellermur 25 cm uisolert", u_verdi = 0.35 },
] }]
[[scenario]]
navn = "Isolerte rør og kjellervegger"
beskrivelse = "Begge kjellertiltakene samlet, slik termografirapporten anbefaler dem."
endringer.distribusjon = { tap_andel = 0.06 }
endringer.sone = [{ navn = "Kjeller", flate = [
{ navn = "Kjellermur 30 cm uisolert, nord", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, øst", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, sør", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, vest", u_verdi = 0.35 },
{ navn = "Kjellermur 25 cm uisolert", u_verdi = 0.35 },
] }]
[[scenario]]
navn = "Nye vinduer"
beskrivelse = "Utskifting til trelags energiglass med U-verdi 0,8 W/(m²K)."
endringer.vindu = [
{ navn = "Vinduer nord", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Vinduer øst", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Vinduer vest", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Balkongdør sør", u_verdi = 1.20 },
{ navn = "Verandadør øst", u_verdi = 1.20 },
]
[[scenario]]
navn = "Etterisolert klimaskjerm"
beskrivelse = "Etterisolering av yttervegger til 20 cm og loft til 30 cm, med tetting av luftlekkasjer."
endringer.flate = [
{ navn = "Yttervegg nord", u_verdi = 0.22 },
{ navn = "Yttervegg øst", u_verdi = 0.22 },
{ navn = "Yttervegg sør", u_verdi = 0.22 },
{ navn = "Yttervegg vest", u_verdi = 0.22 },
{ navn = "Skråtak", u_verdi = 0.18 },
{ navn = "Himling mot kaldt loft", u_verdi = 0.15 },
]
endringer.infiltrasjon = { n50 = 4.0 }
endringer.kuldebroer = { normalisert = 0.10 }
[[scenario]]
navn = "Væske-vann-varmepumpe"
beskrivelse = "Bergvarmepumpe på det vannbårne anlegget, med elektrokjel som spisslast."
endringer.oppvarming.kilde = [
{ type = "vedovn", slett = true },
{ type = "vaeske_vann_vp", andel_rom = 0.9, andel_tappevann = 0.8, turtemperatur = 55 },
{ type = "el_kjel", andel_rom = 0.1, andel_tappevann = 0.2 },
]
[[scenario]]
navn = "Full pakke"
beskrivelse = "Alle tiltakene samlet: isolerte rør og kjellervegger, nye vinduer, etterisolert klimaskjerm, balansert ventilasjon med varmegjenvinning og bergvarmepumpe."
endringer.distribusjon = { tap_andel = 0.06, turtemperatur = 45 }
endringer.infiltrasjon = { n50 = 2.5 }
endringer.kuldebroer = { normalisert = 0.09 }
endringer.ventilasjon = { type = "balansert", gjenvinning = 0.80, sfp = 1.5 }
endringer.flate = [
{ navn = "Yttervegg nord", u_verdi = 0.22 },
{ navn = "Yttervegg øst", u_verdi = 0.22 },
{ navn = "Yttervegg sør", u_verdi = 0.22 },
{ navn = "Yttervegg vest", u_verdi = 0.22 },
{ navn = "Skråtak", u_verdi = 0.18 },
{ navn = "Himling mot kaldt loft", u_verdi = 0.15 },
{ navn = "Etasjeskiller mot kjeller", u_verdi = 0.18 },
]
endringer.vindu = [
{ navn = "Vinduer nord", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Vinduer øst", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Vinduer vest", u_verdi = 0.80, g_verdi = 0.50 },
{ navn = "Balkongdør sør", u_verdi = 1.20 },
{ navn = "Verandadør øst", u_verdi = 1.20 },
]
endringer.sone = [{ navn = "Kjeller", flate = [
{ navn = "Kjellermur 30 cm uisolert, nord", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, øst", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, sør", u_verdi = 0.35 },
{ navn = "Kjellermur 30 cm uisolert, vest", u_verdi = 0.35 },
{ navn = "Kjellermur 25 cm uisolert", u_verdi = 0.35 },
] }]
endringer.oppvarming.kilde = [
{ type = "vedovn", slett = true },
{ type = "vaeske_vann_vp", andel_rom = 0.9, andel_tappevann = 0.8, turtemperatur = 45 },
{ type = "el_kjel", andel_rom = 0.1, andel_tappevann = 0.2 },
]
+64
View File
@@ -0,0 +1,64 @@
# Normerte inndata for barnehage.
#
# NS 3031 Tillegg A er ikke fritt tilgjengelig. Verdiene er hentet fra offentlige
# energiberegninger som oppgir tabellverdiene for kategorien, og er kontrollert mot
# energibudsjettene i de samme rapportene:
# - Rambøll, «Kolstad barnehage energikonsept» (Mercell, 2019): driftstid 10/5/52,
# utstyr 2 W/m², varmtvann 1,6 W/m², personer 6 W/m², «iht. NS 3031 tillegg A,
# tab. A.1A.3, for bygningskategori barnehage». Budsjettet (utstyr 5,2 og varmtvann
# 10,0 kWh/m²) stemmer med 261 driftsdager.
# - Multiconsult for DiBK, «Grunnlag og forutsetninger for beregnede energirammer»
# (13.11.2015): personbelastning 6,0 W/m² i NS 3031.
# Belysning: rapporten over brukte passivhusverdien 5 W/m². NS 3031-verdien er ikke
# funnet i en offentlig kilde; 8 W/m² er antatt, som for kontor-, kultur- og idrettsbygg.
#
# Alle effekter i W per m² oppvarmet BRA. Varmtvann er effekt over hele døgnet på
# driftsdager, slik Simien oppgir den («effektbehov varmtvann på driftsdager»).
kategori = "barnehage"
kilde = "NS 3031:2014 Tillegg A slik gjengitt i offentlige energiberegninger (Rambøll 2019, Multiconsult/DiBK 2015); belysning antatt"
[settpunkt]
oppvarming = 21.0
oppvarming_natt = 19.0 # Rambøll oppgir årsmiddel 19,8 °C, som svarer til nattsenking
kjoling = 22.0
ventilasjon_tilluft = 19.0
[driftstid]
oppvarming = { start = 7, slutt = 17 } # 10 h
belysning = { start = 7, slutt = 17 }
utstyr = { start = 7, slutt = 17 }
varmtvann = { start = 0, slutt = 24 }
personer = { start = 7, slutt = 17 }
ventilasjon = { start = 7, slutt = 17 }
dager_per_uke = 5
uker_per_aar = 52 # 261 driftsdager
[belysning]
effekt = 8.0 # antatt → 20,9 kWh/(m²·år)
varmetilskudd = 8.0
[utstyr]
effekt = 2.0 # → 5,2 kWh/(m²·år)
varmetilskudd = 2.0
[varmtvann]
effekt = 1.6 # → 10,0 kWh/(m²·år)
varmetilskudd = 0.0
[personer]
varmetilskudd = 6.0
[ventilasjon]
luftmengde_driftstid = 10.0 # m³/(h·m²), prosjektverdi i Rambøll-rapporten
luftmengde_utenfor_driftstid = 2.0
sfp = 1.5
temperaturvirkningsgrad = 0.80
frostsikring_virkningsgrad = 0.80
[termisk]
varmekapasitet_normalisert = 72.6
kuldebroer_normalisert = 0.07
[pumper]
effekt_vannbaaren = 0.5
+9 -7
View File
@@ -1,11 +1,13 @@
# Normerte inndata for boligblokk.
# Standardverdier fra NS 3031:2014 Tillegg A (Tabell A.3/A.5) slik de også fremgår av Enovas
# XML-eksempel for energimerkesystemet. NS 3031:2025 Tillegg A gir timesprofiler over døgnet;
# eieren av standarden kan legge inn 24-verdiers profiler i feltene `profil_*` nedenfor, som da
# overstyrer driftstidene. Alle effekter i W per m² oppvarmet BRA.
# NS 3031:2014 Tillegg A gir de samme verdiene for boligblokk som for småhus. Det bekreftes
# av Simien-utskriften for en boligblokk (Multiconsult 2013, «NS3031, tabell A.1A.3»):
# belysning 1,95 W/m², utstyr 3,00 W/m² med varmetilskudd 1,80, varmtvann 3,40 W/m² over
# døgnet (29,8 kWh/m²), personer 1,5 W/m², driftstid 16 h og personer/ventilasjon 24 h.
# Varmtvann følger småhusverdien fra NS 3031:2025 (25,1 kWh/m²); verdien for boligblokk i
# 2025-utgaven er ikke funnet i en offentlig kilde. Alle effekter i W per m² oppvarmet BRA.
kategori = "boligblokk"
kilde = "NS 3031:2014 Tillegg A (standardverdier); NS 3031:2025-profiler kan legges inn"
kilde = "NS 3031:2014 Tillegg A slik gjengitt i Simien-utskrift (Multiconsult 2013); luftmengde fra Multiconsult/DiBK 2014"
[settpunkt]
oppvarming = 21.0 # °C i driftstiden
@@ -42,8 +44,8 @@ varmetilskudd = 0.0
varmetilskudd = 1.5 # W/m² (sensibel varme) i driftstiden
[ventilasjon]
luftmengde_driftstid = 1.2 # m³/(h·m²)
luftmengde_utenfor_driftstid = 1.2 # m³/(h·)
luftmengde_driftstid = 1.5 # m³/(h·m²); modellbygget for boligblokk (Multiconsult/DiBK 2014,
luftmengde_utenfor_driftstid = 1.5 # avsnitt 8.2), svarer til leiligheter på om lag 65
sfp = 1.5 # kW/(m³/s), gjelder mekanisk ventilasjon
temperaturvirkningsgrad = 0.80 # varmegjenvinner, gjelder balansert ventilasjon
frostsikring_virkningsgrad = 0.80
+61
View File
@@ -0,0 +1,61 @@
# Normerte inndata for idrettsbygg.
#
# NS 3031 Tillegg A er ikke fritt tilgjengelig. Verdiene er hentet fra to uavhengige,
# offentlige Simien-utskrifter for idrettshaller:
# - «Energiberegning og evaluering mot energikrav» (Mercell, 31.10.2018), sone
# Idrettshall: driftstid 12 h, belysning 8 W/m², utstyr 1 W/m², varmtvann 9,5 W/m²
# på driftsdager, personer 10 W/m², luftmengde 8 og 2 m³/(h·m²).
# - «Evaluering Energiregler 2016», Gimlehallen (BLOM Arkitekter, 2024): de samme
# verdiene for utstyr, varmtvann, personer og luftmengder (belysningen er prosjektverdi).
# Begge budsjettene (varmtvann 50,2 og utstyr 2,6 kWh/m²) svarer til 220 driftsdager,
# altså 5 dager i 44 uker. Begge utskriftene oppgir settpunkt 18,0 °C.
#
# Alle effekter i W per m² oppvarmet BRA. Varmtvann er effekt over hele døgnet på driftsdager.
kategori = "idrettsbygg"
kilde = "NS 3031:2014 Tillegg A slik gjengitt i to offentlige Simien-utskrifter (2018, 2024)"
[settpunkt]
oppvarming = 18.0
oppvarming_natt = 18.0 # utskriftene oppgir 18,0 °C som årsmiddel
kjoling = 22.0
ventilasjon_tilluft = 18.0
[driftstid]
oppvarming = { start = 8, slutt = 20 } # 12 h
belysning = { start = 8, slutt = 20 }
utstyr = { start = 8, slutt = 20 }
varmtvann = { start = 0, slutt = 24 }
personer = { start = 8, slutt = 20 }
ventilasjon = { start = 8, slutt = 20 }
dager_per_uke = 5
uker_per_aar = 44 # 220 driftsdager
[belysning]
effekt = 8.0 # → 21,1 kWh/(m²·år)
varmetilskudd = 8.0
[utstyr]
effekt = 1.0 # → 2,6 kWh/(m²·år)
varmetilskudd = 1.0
[varmtvann]
effekt = 9.5 # → 50,2 kWh/(m²·år)
varmetilskudd = 0.0
[personer]
varmetilskudd = 10.0
[ventilasjon]
luftmengde_driftstid = 8.0 # m³/(h·m²)
luftmengde_utenfor_driftstid = 2.0
sfp = 1.5
temperaturvirkningsgrad = 0.80
frostsikring_virkningsgrad = 0.80
[termisk]
varmekapasitet_normalisert = 72.6
kuldebroer_normalisert = 0.07
[pumper]
effekt_vannbaaren = 0.5
+63
View File
@@ -0,0 +1,63 @@
# Normerte inndata for kontorbygg.
#
# NS 3031 Tillegg A er ikke fritt tilgjengelig. Verdiene er hentet fra offentlige kilder:
# - Multiconsult for DiBK, «Konsekvensvurdering av Energiregler 2015» (11.12.2014),
# avsnitt 7.1 og 8.2: belysning 8 W/m² og veiledende luftmengde 10 m³/(h·m²) etter
# NS 3031 tabell A1 og B1.
# - Simien-utskrift «Passivhusevaluering», kontorbygg (Mercell): driftstid 12 h,
# varmtvann 0,80 W/m² på driftsdager, personer 4 W/m². Personbelastningen og
# driftstiden er de samme som i NS 3031; belysning og utstyr er passivhusverdier.
# - Utstyr 11 W/m²: Simien-evaluering for et kontorbygg (Asplan Viak 2019, FutureBuilt).
# Bare søkeutdraget er lest; fulltekstdokumentet var ikke tilgjengelig.
# - NVE, «Praktisk veileder for energimerking» (2013): kontorbygg har driftstid 5 dager
# i 52 uker.
#
# Alle effekter i W per m² oppvarmet BRA. Varmtvann er effekt over hele døgnet på driftsdager.
kategori = "kontorbygg"
kilde = "NS 3031:2014 Tillegg A slik gjengitt i offentlige kilder (Multiconsult/DiBK 2014, Simien-utskrifter); utstyr ikke verifisert i fulltekst"
[settpunkt]
oppvarming = 21.0
oppvarming_natt = 19.0
kjoling = 22.0
ventilasjon_tilluft = 19.0
[driftstid]
oppvarming = { start = 7, slutt = 19 } # 12 h
belysning = { start = 7, slutt = 19 }
utstyr = { start = 7, slutt = 19 }
varmtvann = { start = 0, slutt = 24 }
personer = { start = 7, slutt = 19 }
ventilasjon = { start = 7, slutt = 19 }
dager_per_uke = 5
uker_per_aar = 52 # 261 driftsdager
[belysning]
effekt = 8.0 # → 25,1 kWh/(m²·år)
varmetilskudd = 8.0
[utstyr]
effekt = 11.0 # → 34,5 kWh/(m²·år)
varmetilskudd = 11.0
[varmtvann]
effekt = 0.8 # → 5,0 kWh/(m²·år)
varmetilskudd = 0.0
[personer]
varmetilskudd = 4.0
[ventilasjon]
luftmengde_driftstid = 10.0 # m³/(h·m²), NS 3031 tabell B1 (veiledende)
luftmengde_utenfor_driftstid = 2.0 # som i Simien-utskriftene for yrkesbygg
sfp = 1.5
temperaturvirkningsgrad = 0.80
frostsikring_virkningsgrad = 0.80
[termisk]
varmekapasitet_normalisert = 72.6
kuldebroer_normalisert = 0.07
[pumper]
effekt_vannbaaren = 0.5
+59
View File
@@ -0,0 +1,59 @@
# Normerte inndata for kulturbygg.
#
# NS 3031 Tillegg A er ikke fritt tilgjengelig. Verdiene er hentet fra COWI,
# «Energikonsept Kystens Arv» (Mercell), tabell 7 «Standardverdier for internlaster etter
# NS 3031» og Simien-utskriften i samme rapport: belysning 8 W/m², utstyr 1 W/m²,
# varmtvann 1,6 W/m² på driftsdager og personer 3,2 W/m², luftmengde 10 og 2 m³/(h·m²).
# Rapporten skriver driftstid 12/5/52, men Simien-utskriften har 11 h, og energibehovene
# i tabell 7 (belysning 23, utstyr 2,9 og varmtvann 10 kWh/m²) stemmer med 11 h i
# 261 driftsdager.
#
# Alle effekter i W per m² oppvarmet BRA. Varmtvann er effekt over hele døgnet på driftsdager.
kategori = "kulturbygg"
kilde = "NS 3031:2014 Tillegg A slik gjengitt i COWI, Energikonsept Kystens Arv"
[settpunkt]
oppvarming = 21.0
oppvarming_natt = 19.0 # utskriften oppgir årsmiddel 19,9 °C
kjoling = 22.0
ventilasjon_tilluft = 19.0
[driftstid]
oppvarming = { start = 10, slutt = 21 } # 11 h
belysning = { start = 10, slutt = 21 }
utstyr = { start = 10, slutt = 21 }
varmtvann = { start = 0, slutt = 24 }
personer = { start = 10, slutt = 21 }
ventilasjon = { start = 10, slutt = 21 }
dager_per_uke = 5
uker_per_aar = 52 # 261 driftsdager
[belysning]
effekt = 8.0 # → 23,0 kWh/(m²·år)
varmetilskudd = 8.0
[utstyr]
effekt = 1.0 # → 2,9 kWh/(m²·år)
varmetilskudd = 1.0
[varmtvann]
effekt = 1.6 # → 10,0 kWh/(m²·år)
varmetilskudd = 0.0
[personer]
varmetilskudd = 3.2
[ventilasjon]
luftmengde_driftstid = 10.0 # m³/(h·m²)
luftmengde_utenfor_driftstid = 2.0
sfp = 1.5
temperaturvirkningsgrad = 0.80
frostsikring_virkningsgrad = 0.80
[termisk]
varmekapasitet_normalisert = 72.6
kuldebroer_normalisert = 0.07
[pumper]
effekt_vannbaaren = 0.5
+58
View File
@@ -0,0 +1,58 @@
# Normerte inndata for skolebygg.
#
# NS 3031 Tillegg A er ikke fritt tilgjengelig. Verdiene er hentet fra en offentlig
# Simien-utskrift, «Energiberegning og evaluering mot energikrav» for en skole
# (Mercell, 31.10.2018), sonene for arbeidsrom og klasserom: driftstid 10 h, belysning
# 10 W/m², utstyr 6 W/m², varmtvann 1,9 W/m² på driftsdager og personer 12 W/m².
# Budsjettet i rapporten (varmtvann 10,0 kWh/m²) svarer til 220 driftsdager, altså
# 5 dager i 44 uker.
#
# Alle effekter i W per m² oppvarmet BRA. Varmtvann er effekt over hele døgnet på driftsdager.
kategori = "skolebygg"
kilde = "NS 3031:2014 Tillegg A slik gjengitt i en offentlig Simien-utskrift (2018)"
[settpunkt]
oppvarming = 21.0
oppvarming_natt = 19.0 # utskriften oppgir årsmiddel 19,8 °C
kjoling = 22.0
ventilasjon_tilluft = 19.0
[driftstid]
oppvarming = { start = 8, slutt = 18 } # 10 h
belysning = { start = 8, slutt = 18 }
utstyr = { start = 8, slutt = 18 }
varmtvann = { start = 0, slutt = 24 }
personer = { start = 8, slutt = 18 }
ventilasjon = { start = 8, slutt = 18 }
dager_per_uke = 5
uker_per_aar = 44 # 220 driftsdager
[belysning]
effekt = 10.0 # → 22,0 kWh/(m²·år)
varmetilskudd = 10.0
[utstyr]
effekt = 6.0 # → 13,2 kWh/(m²·år)
varmetilskudd = 6.0
[varmtvann]
effekt = 1.9 # → 10,0 kWh/(m²·år)
varmetilskudd = 0.0
[personer]
varmetilskudd = 12.0
[ventilasjon]
luftmengde_driftstid = 10.0 # m³/(h·m²)
luftmengde_utenfor_driftstid = 2.0
sfp = 1.5
temperaturvirkningsgrad = 0.80
frostsikring_virkningsgrad = 0.80
[termisk]
varmekapasitet_normalisert = 72.6
kuldebroer_normalisert = 0.07
[pumper]
effekt_vannbaaren = 0.5
@@ -1,15 +1,13 @@
# Nordhusstykket 5, 1825 Tomter — enebolig i tre fra 1948, 1,5 etasje på betong grunnmur.
# Bygningsdata er hentet fra termografirapporten av 10.3.2016 og originale byggetegninger.
# Eksempel: enebolig i tre fra 1948, 1,5 etasje på betong grunnmur.
# Bygningsdataene er typiske for et utett hus fra perioden.
# Kjelleren er uoppvarmet, men varmes indirekte av uisolerte varmerør.
[bygning]
navn = "Nordhusstykket 5"
navn = "Eldre enebolig med kjeller"
kategori = "smaahus"
byggeaar = 1948
oppvarmet_bra = 140.0
oppvarmet_volum = 336.0
adresse = "Nordhusstykket 5, 1825 Tomter"
oppdragsgiver = "Ellen Brødholt"
beskrivelse = "Enebolig i tre, 1,5 etasje, bebygget areal 98 m², betong grunnmur"
[klima]
+45 -2
View File
@@ -14,6 +14,11 @@ 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)]
@@ -63,8 +68,13 @@ impl Datakilde {
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 => {
// Andre kategorier krever egen datafil i data-katalogen.
// Kategorier uten offentlig tilgjengelige normerte verdier krever egen datafil.
let fil = format!("normerte/{}.toml", annen.nokkel());
let p = self
.katalog
@@ -257,9 +267,12 @@ pub struct Settpunkt {
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, Clone, Debug)]
#[derive(Deserialize, serde::Serialize, Clone, Debug)]
pub struct Driftstider {
pub oppvarming: Tidsrom,
pub belysning: Tidsrom,
@@ -267,13 +280,43 @@ pub struct Driftstider {
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 {
+6 -5
View File
@@ -481,13 +481,13 @@ fn bygg(
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(|| {
let u = vt.and_then(|v| v.u_verdi);
if u.is_none() {
merknader.push(format!(
"Åpningen «{}» mangler U-verdi i gbXML; satt til 2,4 W/(m²K).",
"Åpningen «{}» mangler U-verdi i gbXML; verdien utledes av byggeåret.",
a.navn
));
2.4
});
}
let dor = a.typ.to_lowercase().contains("door");
vinduer.push(Vindu {
navn: vt
@@ -596,6 +596,7 @@ fn bygg(
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
rapport: Rapport::default(),
};
Ok(Importrapport { bygning, merknader })
}
@@ -687,7 +688,7 @@ mod tester {
// 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].u_verdi, Some(1.2));
assert_eq!(b.vindu[0].g_verdi, Some(0.5));
}
+1256 -64
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -27,6 +27,9 @@ pub struct Hovedflate {
pub normal: [f64; 3],
/// Utstrekning i flatens plan: (u_min, u_maks, v_min, v_maks), m.
plan: [f64; 4],
/// Midlere høyde over delens eget nullpunkt, m. Sier hvilken etasje delen ligger i når
/// plasseringen i filen ikke gjør det.
pub hoyde: f64,
}
impl Nett {
@@ -95,10 +98,16 @@ impl Nett {
let (normal, sum) = grupper
.into_iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
let hoyde = if self.punkter.is_empty() {
0.0
} else {
self.punkter.iter().map(|p| p[2]).sum::<f64>() / self.punkter.len() as f64 * skala
};
Some(Hovedflate {
areal: sum / 2.0,
normal,
plan: self.utstrekning(normal, skala),
hoyde,
})
}
@@ -173,9 +182,16 @@ pub fn samlet_hovedflate(deler: &[Nett], skala: f64) -> Option<Hovedflate> {
grupper.push(f);
}
}
let areal = grupper.iter().map(|g| g.areal).sum();
let areal: f64 = grupper.iter().map(|g| g.areal).sum();
// Arealvektet høyde, slik at et sammensatt dekke havner der mesteparten av det ligger.
let hoyde = if areal > 0.0 {
grupper.iter().map(|g| g.hoyde * g.areal).sum::<f64>() / areal
} else {
grupper[0].hoyde
};
Some(Hovedflate {
areal,
hoyde,
..grupper[0]
})
}
+3 -1
View File
@@ -7,4 +7,6 @@ pub mod regneark;
pub mod step;
pub mod toml_inn;
pub use toml_inn::{les_bygning, slaa_sammen_scenario};
pub use toml_inn::{
differanse, flett_tillegg, les_bygning, les_tillegg, slaa_sammen_scenario, Importvalg, Tillegg,
};
+5 -2
View File
@@ -6,7 +6,7 @@
//!
//! ```text
//! type,navn,areal,u_verdi,orientering,helning,mot,g_verdi,antall
//! bygning,Nordhusstykket 5,140,,,,smaahus,1948,
//! bygning,Eldre enebolig,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
//! ```
@@ -84,6 +84,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
"kontorbygg" | "kontor" => Kategori::Kontorbygg,
"skolebygg" | "skole" => Kategori::Skolebygg,
"barnehage" => Kategori::Barnehage,
"idrettsbygg" | "idrett" => Kategori::Idrettsbygg,
"kulturbygg" | "kultur" => Kategori::Kulturbygg,
annen => {
merknader
.push(format!("Ukjent bygningskategori «{annen}»; bruker småhus."));
@@ -151,7 +153,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
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,
u_verdi: Some(u),
g_verdi: tall(felt(7)),
karmandel: None,
solskjerming: None,
@@ -213,6 +215,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
lufting: Lufting::default(),
drift: Drift::default(),
scenario: Vec::new(),
rapport: Rapport::default(),
};
Ok(Importrapport { bygning, merknader })
}
+4 -3
View File
@@ -3,7 +3,7 @@
//! 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;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub enum Arg {
@@ -58,7 +58,8 @@ impl Entitet {
}
pub struct Fil {
pub entiteter: HashMap<u64, Entitet>,
/// Sortert på entitetsnummer, slik at gjennomløp alltid kommer i samme rekkefølge.
pub entiteter: BTreeMap<u64, Entitet>,
}
impl Fil {
@@ -83,7 +84,7 @@ impl Fil {
/// Deler en STEP-fil i entiteter.
pub fn les(tekst: &str) -> Fil {
let mut entiteter = HashMap::new();
let mut entiteter = BTreeMap::new();
let mut setning = String::new();
let mut i_streng = false;
let mut i_data = false;
+250 -6
View File
@@ -1,8 +1,9 @@
//! Leser `bygning.toml` og fletter inn scenarioendringer.
//! Leser `bygning.toml` og fletter inn scenarioendringer og tilleggsfiler.
use crate::modell::{Bygning, Scenario};
use crate::Result;
use anyhow::Context;
use serde::Deserialize;
use std::path::Path;
use toml::Value;
@@ -11,19 +12,165 @@ pub fn les_bygning(sti: &Path) -> Result<Bygning> {
toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))
}
/// Valg for selve importen, fra tabellen `[import]` i en tilleggsfil.
#[derive(Deserialize, Default, Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct Importvalg {
/// Rom som står uoppvarmet og holdes utenfor oppvarmet BRA. Et rom treffes når navnet
/// inneholder en av tekstene, uten hensyn til store og små bokstaver.
#[serde(default)]
pub uoppvarmede_rom: Vec<String>,
}
/// En tilleggsfil til en importert modell: det modellen ikke beskriver, som drift, varmeanlegg
/// og scenarioer, og rettelser til det den beskriver.
pub struct Tillegg {
pub endringer: Value,
pub import: Importvalg,
}
pub fn les_tillegg(sti: &Path) -> Result<Tillegg> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
let mut endringer: Value =
toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))?;
let import = match endringer.as_table_mut().and_then(|t| t.remove("import")) {
Some(v) => v
.try_into()
.with_context(|| format!("tolker [import] i {}", sti.display()))?,
None => Importvalg::default(),
};
Ok(Tillegg { endringer, import })
}
/// Fletter en tilleggsfil inn i en importert bygning, etter de samme reglene som scenarioene.
///
/// Returnerer også navnemønstrene med `*` som ikke traff noen oppføring, slik at en tilleggsfil
/// som har gått ut av takt med modellen blir synlig.
pub fn flett_tillegg(b: &Bygning, tillegg: &Value) -> Result<(Bygning, Vec<String>)> {
let mut grunn = Value::try_from(b).context("serialiserer den importerte bygningen")?;
let mut bom = Vec::new();
flett(&mut grunn, tillegg, &mut bom);
let bygning = grunn.try_into().context(
"tilleggsfilen gir en ugyldig bygningsbeskrivelse. Et navn som ikke finnes i modellen, \
legges til som ny oppføring og må da være komplett",
)?;
Ok((bygning, bom))
}
/// 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.
/// til, og `slett = true` fjerner et element. Et navn med `*` er et mønster som treffer alle
/// oppføringer det passer på, for eksempel `"Yttervegg*"`. 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);
flett(&mut grunn, &s.endringer, &mut Vec::new());
grunn
.try_into()
.with_context(|| format!("scenarioet «{}» gir en ugyldig bygningsbeskrivelse", s.navn))
}
/// Forskjellen mellom to bygningsbeskrivelser, som en tilleggsfil.
///
/// Resultatet er det minste som må flettes inn i `fra` for å få `til`: endrede og nye felter,
/// nye elementer i lister, og `slett = true` for elementer som bare finnes i `fra`. Fletting
/// kan ikke fjerne et enkeltfelt, så felter som bare finnes i `fra`, listes som merknader.
pub fn differanse(fra: &Value, til: &Value) -> (Value, Vec<String>) {
let mut merknader = Vec::new();
let v = diff_verdi(fra, til, "", &mut merknader).unwrap_or(Value::Table(Default::default()));
(v, merknader)
}
fn diff_verdi(fra: &Value, til: &Value, sti: &str, merknader: &mut Vec<String>) -> Option<Value> {
if fra == til {
return None;
}
match (fra, til) {
(Value::Table(f), Value::Table(t)) => {
let mut ut = toml::map::Map::new();
for (k, v) in t {
let bane = if sti.is_empty() {
k.clone()
} else {
format!("{sti}.{k}")
};
match f.get(k) {
Some(gammel) => {
if let Some(d) = diff_verdi(gammel, v, &bane, merknader) {
ut.insert(k.clone(), d);
}
}
None => {
ut.insert(k.clone(), v.clone());
}
}
}
for k in f.keys() {
if !t.contains_key(k) {
let bane = if sti.is_empty() {
k.clone()
} else {
format!("{sti}.{k}")
};
merknader.push(format!(
"«{bane}» finnes i modellen, men ikke i bygningsbeskrivelsen. En tilleggsfil kan ikke fjerne et felt."
));
}
}
(!ut.is_empty()).then_some(Value::Table(ut))
}
(Value::Array(f), Value::Array(t)) if t.iter().all(|e| navnenokkel(e).is_some()) => {
let mut ut: Vec<Value> = Vec::new();
for element in t {
let navn = navnenokkel(element).unwrap_or_default();
match f.iter().find(|e| navnenokkel(e).as_deref() == Some(&navn)) {
Some(gammel) => {
let bane = format!("{sti}.{navn}");
if let Some(Value::Table(mut endring)) =
diff_verdi(gammel, element, &bane, merknader)
{
endring.insert("navn".into(), Value::String(navn));
ut.push(Value::Table(endring));
}
}
None => ut.push(element.clone()),
}
}
for gammel in f {
let navn = navnenokkel(gammel).unwrap_or_default();
if !t.iter().any(|e| navnenokkel(e).as_deref() == Some(&navn)) {
let mut slett = toml::map::Map::new();
slett.insert("navn".into(), Value::String(navn));
slett.insert("slett".into(), Value::Boolean(true));
ut.push(Value::Table(slett));
}
}
(!ut.is_empty()).then_some(Value::Array(ut))
}
_ => Some(til.clone()),
}
}
/// Om `navn` passer på mønsteret, der `*` står for vilkårlig tekst.
fn passer(moenster: &str, navn: &str) -> bool {
let deler: Vec<&str> = moenster.split('*').collect();
let (forst, sist) = (deler[0], deler[deler.len() - 1]);
if deler.len() == 1 {
return moenster == navn;
}
let Some(mut rest) = navn.strip_prefix(forst) else {
return false;
};
for del in &deler[1..deler.len() - 1] {
match rest.find(del) {
Some(i) => rest = &rest[i + del.len()..],
None => return false,
}
}
rest.ends_with(sist)
}
fn navnenokkel(v: &Value) -> Option<String> {
let t = v.as_table()?;
t.get("navn")
@@ -39,7 +186,7 @@ fn skal_slettes(v: &Value) -> bool {
.unwrap_or(false)
}
fn flett(grunn: &mut Value, over: &Value) {
fn flett(grunn: &mut Value, over: &Value, bom: &mut Vec<String>) {
match (grunn, over) {
(Value::Table(g), Value::Table(o)) => {
for (k, v) in o {
@@ -47,7 +194,7 @@ fn flett(grunn: &mut Value, over: &Value) {
continue;
}
match g.get_mut(k) {
Some(eksisterende) => flett(eksisterende, v),
Some(eksisterende) => flett(eksisterende, v, bom),
None => {
g.insert(k.clone(), v.clone());
}
@@ -57,6 +204,25 @@ fn flett(grunn: &mut Value, over: &Value) {
(Value::Array(g), Value::Array(o)) => {
for element in o {
match navnenokkel(element) {
Some(moenster) if moenster.contains('*') => {
let treffer =
|e: &Value| navnenokkel(e).is_some_and(|n| passer(&moenster, &n));
let antall = g.iter().filter(|e| treffer(e)).count();
if antall == 0 {
bom.push(moenster.clone());
} else if skal_slettes(element) {
g.retain(|e| !treffer(e));
} else {
// Mønsteret skal ikke erstatte navnene det treffer.
let mut endring = element.clone();
if let Some(t) = endring.as_table_mut() {
t.remove("navn");
}
for e in g.iter_mut().filter(|e| treffer(e)) {
flett(e, &endring, bom);
}
}
}
Some(navn) => {
let treff = g
.iter()
@@ -65,7 +231,7 @@ fn flett(grunn: &mut Value, over: &Value) {
Some(i) if skal_slettes(element) => {
g.remove(i);
}
Some(i) => flett(&mut g[i], element),
Some(i) => flett(&mut g[i], element, bom),
None if skal_slettes(element) => {}
None => g.push(element.clone()),
}
@@ -133,6 +299,84 @@ u_verdi = 0.8
assert_eq!(navn, vec!["Yttervegg", "Nytt tak"]);
}
#[test]
fn moenster_i_navn_treffer_flere_oppforinger() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let mut grunn = Value::try_from(&b).unwrap();
let mut bom = Vec::new();
let over: Value = toml::from_str(
r#"flate = [{ navn = "*", masseklasse = "lett" }, { navn = "Yttervegg*", u_verdi = 0.3 }, { navn = "Kjeller*", u_verdi = 0.2 }]"#,
)
.unwrap();
flett(&mut grunn, &over, &mut bom);
let ny: Bygning = grunn.try_into().unwrap();
assert_eq!(ny.flate.len(), 2);
assert_eq!(ny.flate[0].navn, "Yttervegg");
assert_eq!(ny.flate[0].u_verdi, Some(0.3));
assert_eq!(ny.flate[1].u_verdi, Some(0.8));
assert!(ny.flate.iter().all(|f| f.masseklasse.is_some()));
assert_eq!(bom, vec!["Kjeller*".to_string()]);
}
#[test]
fn differansen_gjenskaper_den_manuelle_filen() {
let importert: Bygning = toml::from_str(GRUNN).unwrap();
let manuell: Bygning = toml::from_str(
r#"
[bygning]
navn = "Testhus"
kategori = "smaahus"
byggeaar = 1948
oppvarmet_bra = 100.0
[[flate]]
navn = "Yttervegg"
type = "vegg"
areal = 80.0
u_verdi = 0.22
[[flate]]
navn = "Kjellervegg"
type = "vegg"
areal = 30.0
u_verdi = 3.13
[[scenario]]
navn = "Nye vinduer"
endringer.flate = [{ navn = "Yttervegg", u_verdi = 0.18 }]
"#,
)
.unwrap();
let (patch, merknader) = differanse(
&Value::try_from(&importert).unwrap(),
&Value::try_from(&manuell).unwrap(),
);
// Taket finnes bare i importen, og skal slettes av tillegget.
let tekst = toml::to_string(&patch).unwrap();
assert!(tekst.contains("slett"), "{tekst}");
assert!(merknader.is_empty(), "{merknader:?}");
let (gjenskapt, bom) = flett_tillegg(&importert, &patch).unwrap();
assert!(bom.is_empty());
assert_eq!(
Value::try_from(&gjenskapt).unwrap(),
Value::try_from(&manuell).unwrap()
);
}
#[test]
fn moenster_passer() {
assert!(passer("V-2*", "V-2 #3306"));
assert!(passer("*sør", "Yttervegg, sør"));
assert!(passer(
"Kjellermur*uisolert*",
"Kjellermur 30 cm uisolert, øst"
));
assert!(!passer("V-2*", "VD-2 #11162"));
assert!(passer("Tak", "Tak"));
assert!(!passer("Tak", "Taket"));
}
#[test]
fn scenario_endrer_skalarfelt() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
+25 -6
View File
@@ -105,16 +105,21 @@ impl Resultat {
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();
// Er kommunen oppgitt, gjelder den for oppslaget av klimakorrigeringsfaktoren.
// Da er navnet uavhengig av hvordan stedsnavnet er skrevet inne i klimafilen.
let navn = b.klima.kommune.clone().unwrap_or_else(|| 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());
// Uten kommune, men med posisjon, velges klimastedet nærmest posisjonen.
let sted = match (&b.klima.kommune, b.klima.posisjon) {
(Some(k), _) => k.clone(),
(None, Some([bredde, lengde])) => {
tmy::naermeste_kommune(&crate::klima::geo::Posisjon { bredde, lengde })?.0
}
(None, None) => REFERANSEKLIMA.to_string(),
};
let er_referanse = crate::data::normaliser_stedsnavn(&sted)
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
let v = tmy::for_kommune(&sted)?;
@@ -138,6 +143,20 @@ pub fn kjor_med_klima(
let varmetap = Varmetapsbudsjett::beregn(&lost);
let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?;
let mut merknader = systemer_oppsett.merknader.clone();
if b.klima.kommune.is_none() && b.klima.epw.is_none() {
if let Some([bredde, lengde]) = b.klima.posisjon {
let bygg = crate::klima::geo::Posisjon { bredde, lengde };
let stasjon = crate::klima::geo::Posisjon {
bredde: vaerdata.bredde,
lengde: vaerdata.lengde,
};
merknader.push(format!(
"Klimastedet {klimanavn} er valgt ut fra posisjonen {bredde:.4}° nord, {lengde:.4}° øst, \
{:.0} km unna. Sett `kommune` under [klima] for å velge et annet.",
bygg.avstand_km(&stasjon)
));
}
}
merknader.extend(
crate::modell::kontroll::kontroller(b)
.into_iter()
+32
View File
@@ -9,6 +9,19 @@ use crate::{Result, TIMER};
use anyhow::{bail, Context};
use std::path::Path;
/// Gjør bytene i en EPW-fil om til tekst.
///
/// EPW-filer er ikke UTF-8. Stedsnavn skrives i Latin-1, så «Indre Østfold» kommer som
/// byten 0xD8 der Ø skal stå. Tolket som UTF-8 blir navnet ødelagt, og da slår ikke
/// klimakorrigeringsfaktoren til, siden den slås opp på stedsnavnet. Filen leses derfor
/// som UTF-8 når den er gyldig UTF-8, og ellers som Latin-1.
pub fn avkod(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes.iter().map(|b| *b as char).collect(),
}
}
#[derive(Clone, Debug)]
pub struct Vaerdata {
pub sted: String,
@@ -134,3 +147,22 @@ impl Vaerdata {
.sum()
}
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn latin1_stedsnavn_leses_riktig() {
// «Indre Østfold» der Ø er Latin-1-byten 0xD8.
let mut bytes = b"LOCATION,Indre ".to_vec();
bytes.push(0xD8);
bytes.extend_from_slice(b"stfold,None,NOR,CERRA,x,59.58,11.16,1, 130");
assert_eq!(avkod(&bytes).split(',').nth(1), Some("Indre Østfold"));
}
#[test]
fn gyldig_utf8_gaar_uendret_gjennom() {
assert_eq!(avkod("Indre Østfold".as_bytes()), "Indre Østfold");
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Posisjon fra BIM-modellen, og valg av klimasted ut fra den.
//!
//! En IFC-modell kan oppgi hvor bygningen står på to måter: som breddegrad og lengdegrad på
//! `IfcSite`, eller som prosjiserte koordinater gjennom `IfcMapConversion` og
//! `IfcProjectedCRS`. I Norge er det siste normalt EUREF89 UTM sone 32, 33 eller 35. Denne
//! modulen gjør begge om til breddegrad og lengdegrad, slik at nærmeste klimasted kan velges
//! automatisk.
use std::f64::consts::FRAC_PI_2;
/// Store halvakse for GRS80, som EUREF89 bygger på, m.
const A: f64 = 6_378_137.0;
/// Flattrykning for GRS80.
const F: f64 = 1.0 / 298.257_222_101;
/// Målestokkfaktor langs sentralmeridianen i UTM.
const K0: f64 = 0.9996;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Posisjon {
/// Breddegrad, grader nord.
pub bredde: f64,
/// Lengdegrad, grader øst.
pub lengde: f64,
}
impl Posisjon {
/// Avstand langs jordoverflaten, km.
pub fn avstand_km(&self, annen: &Posisjon) -> f64 {
let (b1, b2) = (self.bredde.to_radians(), annen.bredde.to_radians());
let db = b2 - b1;
let dl = (annen.lengde - self.lengde).to_radians();
let h = (db / 2.0).sin().powi(2) + b1.cos() * b2.cos() * (dl / 2.0).sin().powi(2);
2.0 * 6371.0 * h.sqrt().asin()
}
/// Sant for plasseringen Vectorworks setter når prosjektstedet ikke er angitt, og for
/// nullpunktet. Begge betyder at modellen i praksis ikke oppgir noen posisjon.
pub fn er_standardplassering(&self) -> bool {
let washington =
(self.bredde - 38.8895).abs() < 0.05 && (self.lengde + 77.0353).abs() < 0.05;
let null = self.bredde.abs() < 1e-6 && self.lengde.abs() < 1e-6;
washington || null
}
/// Grovt: innenfor fastlands-Norge og Svalbard.
pub fn i_norge(&self) -> bool {
(57.5..=81.5).contains(&self.bredde) && (4.0..=32.0).contains(&self.lengde)
}
}
/// Fra Web Mercator (EPSG:3857), som Vectorworks bruker når ingen annen projeksjon er valgt.
pub fn fra_web_mercator(x: f64, y: f64) -> Posisjon {
Posisjon {
bredde: (2.0 * (y / A).exp().atan() - FRAC_PI_2).to_degrees(),
lengde: (x / A).to_degrees(),
}
}
/// Fra UTM på nordlige halvkule, med GRS80-ellipsoiden som EUREF89 bruker.
///
/// Inversformlene er de vanlige seriene etter Snyder, «Map Projections A Working Manual»,
/// som gir centimeternøyaktighet innenfor en sone.
pub fn fra_utm(sone: u32, x: f64, y: f64) -> Posisjon {
let e2 = F * (2.0 - F);
let ep2 = e2 / (1.0 - e2);
let e1 = (1.0 - (1.0 - e2).sqrt()) / (1.0 + (1.0 - e2).sqrt());
let m = y / K0;
let mu = m / (A * (1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2.powi(3) / 256.0));
let fi1 = mu
+ (3.0 * e1 / 2.0 - 27.0 * e1.powi(3) / 32.0) * (2.0 * mu).sin()
+ (21.0 * e1 * e1 / 16.0 - 55.0 * e1.powi(4) / 32.0) * (4.0 * mu).sin()
+ (151.0 * e1.powi(3) / 96.0) * (6.0 * mu).sin()
+ (1097.0 * e1.powi(4) / 512.0) * (8.0 * mu).sin();
let (s, c, t) = (fi1.sin(), fi1.cos(), fi1.tan());
let n1 = A / (1.0 - e2 * s * s).sqrt();
let t1 = t * t;
let c1 = ep2 * c * c;
let r1 = A * (1.0 - e2) / (1.0 - e2 * s * s).powf(1.5);
let d = (x - 500_000.0) / (n1 * K0);
let bredde = fi1
- (n1 * t / r1)
* (d * d / 2.0
- (5.0 + 3.0 * t1 + 10.0 * c1 - 4.0 * c1 * c1 - 9.0 * ep2) * d.powi(4) / 24.0
+ (61.0 + 90.0 * t1 + 298.0 * c1 + 45.0 * t1 * t1 - 252.0 * ep2 - 3.0 * c1 * c1)
* d.powi(6)
/ 720.0);
let sentralmeridian = (sone as f64 - 1.0) * 6.0 - 180.0 + 3.0;
let lengde = sentralmeridian
+ ((d - (1.0 + 2.0 * t1 + c1) * d.powi(3) / 6.0
+ (5.0 - 2.0 * c1 + 28.0 * t1 - 3.0 * c1 * c1 + 8.0 * ep2 + 24.0 * t1 * t1)
* d.powi(5)
/ 120.0)
/ c)
.to_degrees();
Posisjon {
bredde: bredde.to_degrees(),
lengde,
}
}
/// Gjør koordinater i et navngitt koordinatsystem om til posisjon.
///
/// `navn` er slik det står i `IfcProjectedCRS`, for eksempel «EPSG:25832». Koordinatsystemer
/// enok ikke kjenner gir `None`, og da brukes ikke posisjonen.
pub fn fra_koordinatsystem(navn: &str, x: f64, y: f64) -> Option<Posisjon> {
let stor = navn.to_uppercase();
let kode: Option<u32> = stor.find("EPSG").and_then(|i| {
let siffer: String = stor[i + 4..]
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
siffer.parse().ok()
});
match kode {
Some(3857) | Some(900_913) => Some(fra_web_mercator(x, y)),
// Geografiske koordinater: østverdien er lengdegrad og nordverdien breddegrad.
Some(4326) | Some(4258) => Some(Posisjon {
bredde: y,
lengde: x,
}),
// ETRS89 / EUREF89 UTM sone 2838.
Some(k @ 25828..=25838) => Some(fra_utm(k - 25800, x, y)),
// WGS 84 UTM nord, sone 2838.
Some(k @ 32628..=32638) => Some(fra_utm(k - 32600, x, y)),
// ETRS89 / UTM med NN2000-høyder, som Kartverket bruker: 5972 er sone 32, 5973 sone 33,
// 5975 sone 35.
Some(k @ 5972..=5976) => Some(fra_utm(k - 5940, x, y)),
_ => {
// Uten EPSG-kode: kjenn igjen «UTM32», «UTM 33N» og lignende.
let i = stor.find("UTM")?;
let sone: String = stor[i + 3..]
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
let sone: u32 = sone.parse().ok()?;
(1..=60).contains(&sone).then(|| fra_utm(sone, x, y))
}
}
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn web_mercator_fra_vectorworks_gir_washington() {
// Verdiene i IfcMapConversion når prosjektstedet ikke er satt i Vectorworks.
let p = fra_web_mercator(-8_575_524.352_288_59, 4_705_852.257_469_73);
assert!((p.bredde - 38.8895).abs() < 1e-3, "bredde {}", p.bredde);
assert!((p.lengde + 77.0352).abs() < 1e-3, "lengde {}", p.lengde);
assert!(p.er_standardplassering());
assert!(!p.i_norge());
}
/// Referanseverdier fra pyproj (PROJ), transformert fra EPSG:4326.
#[test]
fn utm_og_web_mercator_stemmer_med_proj() {
// (sone, øst, nord, breddegrad, lengdegrad, toleranse i grader)
//
// Innenfor sonen og et stykke utenfor er avviket under 1e-5 grader, om lag én meter.
// Tromsø i sone 32 ligger nesten 10 grader utenfor sonen, der rekkeutviklingen gir
// om lag 1,3 m avvik. Det er langt innenfor hva valg av klimasted trenger, men viser
// hvor grensen går.
let utm = [
(32, 613_351.291, 6_611_921.980, 59.6300, 11.0100, 1e-5),
(32, 596_946.981, 6_642_824.393, 59.9115, 10.7336, 1e-5),
(33, 275_055.615, 6_616_968.071, 59.6300, 11.0100, 1e-5),
(33, 653_421.188, 7_731_721.083, 69.6492, 18.9553, 1e-5),
(35, 188_546.727, 7_747_295.774, 69.6492, 18.9553, 1e-5),
(32, 884_909.622, 7_758_204.184, 69.6492, 18.9553, 1e-4),
];
for (sone, x, y, b, l, toleranse) in utm {
let p = fra_utm(sone, x, y);
assert!(
(p.bredde - b).abs() < toleranse && (p.lengde - l).abs() < toleranse,
"sone {sone} ({x}, {y}) ga {p:?}, forventet {b}, {l}"
);
}
let p = fra_web_mercator(1_225_627.594, 8_317_818.189);
assert!(
(p.bredde - 59.63).abs() < 1e-6 && (p.lengde - 11.01).abs() < 1e-6,
"{p:?}"
);
}
#[test]
fn sentralmeridianen_gir_eksakt_lengdegrad() {
// Østverdi 500 000 ligger på sonens sentralmeridian, 9° øst for sone 32.
let p = fra_utm(32, 500_000.0, 6_600_000.0);
assert!((p.lengde - 9.0).abs() < 1e-9, "lengde {}", p.lengde);
let q = fra_utm(33, 500_000.0, 6_600_000.0);
assert!((q.lengde - 15.0).abs() < 1e-9, "lengde {}", q.lengde);
}
#[test]
fn ekvator_paa_sentralmeridianen() {
let p = fra_utm(32, 500_000.0, 0.0);
assert!(p.bredde.abs() < 1e-9 && (p.lengde - 9.0).abs() < 1e-9);
}
#[test]
fn ukjent_koordinatsystem_gir_ingen_posisjon() {
assert!(fra_koordinatsystem("EPSG:2154", 650_000.0, 6_860_000.0).is_none());
assert!(fra_koordinatsystem("lokalt", 1.0, 2.0).is_none());
}
#[test]
fn epsg_koder_tolkes() {
let a = fra_koordinatsystem("EPSG:25832", 500_000.0, 6_600_000.0).unwrap();
let b = fra_koordinatsystem("ETRS89 / UTM zone 32N", 500_000.0, 6_600_000.0).unwrap();
assert!((a.lengde - 9.0).abs() < 1e-9 && (b.lengde - 9.0).abs() < 1e-9);
let c = fra_koordinatsystem("EPSG:5973", 500_000.0, 6_600_000.0).unwrap();
assert!(
(c.lengde - 15.0).abs() < 1e-9,
"5973 er sone 33, fikk {}",
c.lengde
);
}
}
+1
View File
@@ -1,6 +1,7 @@
//! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering.
pub mod epw;
pub mod geo;
pub mod korreksjon;
pub mod sol;
pub mod tmy;
+30
View File
@@ -76,6 +76,36 @@ fn kommune_av_filnavn(navn: &str) -> Option<String> {
Some(base.to_string())
}
/// Klimastedet nærmest en posisjon, og avstanden dit i km.
///
/// Hver TMY-fil oppgir i første linje koordinatene for punktet dataene gjelder. Bare starten
/// av hver fil pakkes ut, så oppslaget går raskt selv om arkivet er stort.
pub fn naermeste_kommune(p: &crate::klima::geo::Posisjon) -> Result<(String, f64)> {
let sti = sikre_arkiv()?;
let fil = std::fs::File::open(&sti)?;
let mut zip = zip::ZipArchive::new(fil)?;
let mut best: Option<(String, f64)> = None;
for i in 0..zip.len() {
let oppf = zip.by_index(i)?;
let Some(kommune) = kommune_av_filnavn(oppf.name()) else {
continue;
};
let mut hode = Vec::with_capacity(512);
oppf.take(512).read_to_end(&mut hode)?;
let linje = crate::klima::epw::avkod(&hode);
let felt: Vec<&str> = linje.lines().next().unwrap_or("").split(',').collect();
let tall = |i: usize| felt.get(i).and_then(|s| s.trim().parse::<f64>().ok());
let (Some(bredde), Some(lengde)) = (tall(6), tall(7)) else {
continue;
};
let d = p.avstand_km(&crate::klima::geo::Posisjon { bredde, lengde });
if best.as_ref().is_none_or(|(_, bd)| d < *bd) {
best = Some((kommune, d));
}
}
best.ok_or_else(|| anyhow!("TMY-arkivet inneholder ingen klimafiler med koordinater"))
}
/// 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> {
+207 -26
View File
@@ -31,6 +31,8 @@ enum Kommando {
Beregn(Beregn),
/// Importerer en bygningsmodell fra gbXML, IFC eller regneark og skriver bygning.toml.
Import(Import),
/// Lager en tilleggsfil av forskjellen mellom en modell og en ferdig bygningsbeskrivelse.
Tillegg(TilleggAv),
/// Viser tilgjengelige klimasteder og nøkkeltall for ett sted.
Klima(Klima),
/// Viser energikarakterskalaen for en bygningskategori.
@@ -56,15 +58,36 @@ struct Beregn {
/// Beregner bare hovedalternativet, uten scenarioene i filen.
#[arg(long)]
uten_scenarier: bool,
/// Tilleggsfil som flettes inn: det modellen ikke beskriver, og rettelser.
#[arg(short, long, value_name = "FIL")]
tillegg: Option<PathBuf>,
}
#[derive(Args)]
struct Import {
/// gbXML-, IFC- eller CSV-fil eksportert fra BIM-verktøyet.
fil: PathBuf,
/// Tilleggsfil som flettes inn i den importerte bygningen: det modellen ikke beskriver
/// (drift, varmeanlegg, scenarioer) og rettelser til det den beskriver.
#[arg(short, long, value_name = "FIL")]
tillegg: Option<PathBuf>,
/// Utdatafil. Standard: samme navn med endelsen .toml.
#[arg(short, long, value_name = "FIL")]
ut: Option<PathBuf>,
/// Skriver over en utdatafil enok ikke har laget selv.
#[arg(long)]
overskriv: bool,
}
#[derive(Args)]
struct TilleggAv {
/// BIM-modellen: .ifc, .gbxml eller .csv.
modell: PathBuf,
/// Bygningsbeskrivelsen med de manuelle endringene, som tillegget skal gjenskape.
bygning: PathBuf,
/// Utdatafil. Standard: prosjekt.toml ved siden av bygningsbeskrivelsen.
#[arg(short, long, value_name = "FIL")]
ut: Option<PathBuf>,
}
#[derive(Args)]
@@ -93,19 +116,83 @@ fn main() -> Result<()> {
match cli.kommando {
Kommando::Beregn(a) => beregn(a, &kilde),
Kommando::Import(a) => import(a),
Kommando::Tillegg(a) => tillegg(a),
Kommando::Klima(a) => klima(a),
Kommando::Skala(a) => skala(a, &kilde),
}
}
/// Leser en bygningsbeskrivelse eller en BIM-modell, med en valgfri tilleggsfil flettet inn.
///
/// En `.toml`-fil er bygningsbeskrivelsen selv; andre filtyper importeres. Returnerer også
/// merknadene fra importen, og om filen var en modell.
fn les_modell(
fil: &Path,
tillegg: Option<&Path>,
) -> Result<(enok::modell::Bygning, Vec<String>, bool)> {
let endelse = fil
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let tillegg = tillegg.map(inndata::les_tillegg).transpose()?;
let valg = tillegg
.as_ref()
.map(|t| t.import.clone())
.unwrap_or_default();
let (bygning, mut merknader, modell) = match endelse.as_str() {
"toml" => (inndata::les_bygning(fil)?, Vec::new(), false),
"gbxml" | "xml" => {
let r = inndata::gbxml::les(fil)?;
(r.bygning, r.merknader, true)
}
"ifc" => {
let r = inndata::ifc::les_med(fil, &valg)?;
(r.bygning, r.merknader, true)
}
"csv" => {
let r = inndata::regneark::les(fil)?;
(r.bygning, r.merknader, true)
}
annen => bail!(
"ukjent filtype «{annen}». enok leser bygning.toml og importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
),
};
if endelse != "ifc" && !valg.uoppvarmede_rom.is_empty() {
merknader.push("uoppvarmede_rom i tilleggsfilen brukes bare ved import fra IFC.".into());
}
let Some(t) = tillegg else {
return Ok((bygning, merknader, modell));
};
let (bygning, bom) = inndata::flett_tillegg(&bygning, &t.endringer)?;
for m in bom {
merknader.push(format!(
"Navnemønsteret «{m}» i tilleggsfilen traff ingen oppføring i modellen."
));
}
Ok((bygning, merknader, modell))
}
fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> {
let bygning = inndata::les_bygning(&a.fil)?;
let (bygning, merknader, modell) = les_modell(&a.fil, a.tillegg.as_deref())?;
for m in &merknader {
eprintln!(" · {m}");
}
for m in enok::modell::kontroll::kontroller(&bygning) {
eprintln!(" ! {}: {}", m.felt, m.melding);
}
let ut =
a.ut.unwrap_or_else(|| PathBuf::from("ut").join(filnavnvennlig(&bygning.bygning.navn)));
std::fs::create_dir_all(&ut)?;
if modell {
// Bygningsbeskrivelsen modellen ga, skrives med, slik at beregningen kan etterprøves.
let sti = ut.join("bygning.toml");
std::fs::write(
&sti,
skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?,
)?;
eprintln!("Bygningsbeskrivelsen fra modellen: {}", sti.display());
}
eprintln!("Henter klimadata …");
let (vaerdata, klimanavn, er_referanse) = kjor::hent_klima(&bygning)?;
@@ -152,6 +239,14 @@ fn beregn(a: Beregn, kilde: &Datakilde) -> Result<()> {
eprintln!("Rapport: {}", pdf.display());
}
// Manglene er allerede skrevet ut før beregningen; her kommer bare det som kom i tillegg.
let alt_sagt: Vec<String> = enok::modell::kontroll::kontroller(&bygning)
.into_iter()
.map(|m| m.melding)
.collect();
for m in resultat.merknader.iter().filter(|m| !alt_sagt.contains(m)) {
eprintln!(" ! {m}");
}
skriv_sammendrag(&resultat, &scenarier);
eprintln!("Filer skrevet til {}", ut.display());
Ok(())
@@ -202,39 +297,125 @@ fn skriv_sammendrag(r: &kjor::Resultat, scenarier: &[(String, kjor::Resultat)])
println!();
}
const GENERERT: &str = "# Generert av enok";
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)
let (bygning, merknader, _) = les_modell(&a.fil, a.tillegg.as_deref())?;
let ut = a.ut.unwrap_or_else(|| a.fil.with_extension("toml"));
// En fil som ikke er skrevet av enok, kan være redigert for hånd. Da er det bedre å
// stoppe enn å skrive over arbeidet.
if !a.overskriv && ut.exists() {
let gammel = std::fs::read_to_string(&ut).unwrap_or_default();
if !gammel.starts_with(GENERERT) {
bail!(
"{} er ikke laget av enok og kan være redigert for hånd. \n\
Ta vare på endringene som en tilleggsfil med «enok tillegg {} {} --ut prosjekt.toml», \n\
og importer med «--tillegg prosjekt.toml». Bruk --overskriv for å skrive over.",
ut.display(),
a.fil.display(),
ut.display()
);
}
"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 innhold = skriv_bygning(&bygning, &merknader, &a.fil, a.tillegg.as_deref())?;
std::fs::write(&ut, innhold)?;
println!("Skrev {}", ut.display());
for m in &merknader {
println!("{m}");
}
Ok(())
}
/// Bygningsbeskrivelsen som TOML, med et hode som sier hvor den kommer fra.
fn skriv_bygning(
bygning: &enok::modell::Bygning,
merknader: &[String],
fil: &Path,
tillegg: Option<&Path>,
) -> Result<String> {
let tekst = toml::to_string_pretty(bygning).context("serialiserer bygningen til TOML")?;
let mut innhold = match tillegg {
Some(t) => format!(
"# Generert av enok fra «{}» og tilleggsfilen «{}».\n\
# Rett i modellen eller i tilleggsfilen; denne filen skrives på nytt ved hver import.\n\
# Merknader fra importen:\n",
fil.display(),
t.display()
),
None => format!(
"# Generert av enok fra «{}».\n\
# Merknader fra importen:\n",
fil.display()
),
};
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 {
for m in merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&tekst);
Ok(innhold)
}
/// Skriver forskjellen mellom modellen og en ferdig bygningsbeskrivelse som en tilleggsfil.
fn tillegg(a: TilleggAv) -> Result<()> {
let (importert, _, modell) = les_modell(&a.modell, None)?;
if !modell {
bail!("{} er ikke en BIM-modell", a.modell.display());
}
let manuell = inndata::les_bygning(&a.bygning)?;
let fra = toml::Value::try_from(&importert).context("serialiserer den importerte bygningen")?;
let til = toml::Value::try_from(&manuell).context("serialiserer bygningsbeskrivelsen")?;
let (patch, detaljer) = inndata::differanse(&fra, &til);
// Felter modellen har og filen ikke, kan et tillegg ikke fjerne. De er som regel
// standardverdier importen fyller ut, så de samles i én merknad i stedet for én hver.
let (ufjernbare, mut merknader): (Vec<String>, Vec<String>) = detaljer
.into_iter()
.partition(|m| m.contains("finnes i modellen"));
if !ufjernbare.is_empty() {
let felter: Vec<&str> = ufjernbare
.iter()
.filter_map(|m| m.split('«').nth(1)?.split('»').next())
.collect();
merknader.push(format!(
"{} felter finnes i modellen, men ikke i bygningsbeskrivelsen, og blir stående: {}{}. En tilleggsfil kan ikke fjerne et felt.",
felter.len(),
felter.iter().take(3).copied().collect::<Vec<_>>().join(", "),
if felter.len() > 3 { "" } else { "" }
));
}
// Kontroller at tillegget faktisk gjenskaper filen det er laget av.
let (gjenskapt, bom) = inndata::flett_tillegg(&importert, &patch)?;
merknader.extend(
bom.into_iter()
.map(|m| format!("Navnemønsteret «{m}» traff ingenting.")),
);
if toml::Value::try_from(&gjenskapt).ok().as_ref() != Some(&til) {
merknader.push(
"Tillegget gjenskaper ikke bygningsbeskrivelsen helt. Gå gjennom filen før den brukes."
.into(),
);
}
let ut =
a.ut.unwrap_or_else(|| a.bygning.with_file_name("prosjekt.toml"));
let mut innhold = format!(
"# Tillegg til «{}», laget av forskjellen mot «{}».\n\
# Alt som ikke står i modellen, ligger her, og flettes inn ved hver import:\n\
# enok beregn \"{}\" --tillegg {}\n",
a.modell.display(),
a.bygning.display(),
a.modell.display(),
ut.file_name()
.and_then(|n| n.to_str())
.unwrap_or("prosjekt.toml")
);
for m in &merknader {
innhold.push_str(&format!("# {m}\n"));
}
innhold.push('\n');
innhold.push_str(&toml::to_string_pretty(&patch).context("serialiserer tillegget")?);
std::fs::write(&ut, innhold)?;
println!("Skrev {}", ut.display());
for m in &merknader {
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn kontroller(b: &Bygning) -> Vec<Mangel> {
});
}
if b.klima.kommune.is_none() && b.klima.epw.is_none() {
if b.klima.kommune.is_none() && b.klima.epw.is_none() && b.klima.posisjon.is_none() {
ut.push(Mangel {
felt: "klima.kommune",
melding: "Kommune er ikke oppgitt, så beregningen bruker referanseklimaet Oslo. \
+64 -14
View File
@@ -130,6 +130,8 @@ pub struct LostVentilasjon {
pub sfp: f64,
pub varmebatteri: bool,
pub tilluft_settpunkt: f64,
/// Kjølebatteri: tilluften holdes på denne temperaturen i driftstiden.
pub tilluft_kjoling: Option<f64>,
pub driftstid: crate::data::Tidsrom,
}
@@ -142,7 +144,15 @@ pub struct LostLaster {
pub personer: [f64; 24],
pub varmtvann: [f64; 24],
pub pumper: [f64; 24],
pub oppvarming_driftstid: crate::data::Tidsrom,
/// Driftstider med driftsdager og driftsuker. Profilene over gjelder bare på driftsdager.
pub driftstider: crate::data::Driftstider,
}
impl LostLaster {
/// Om time `t` i året faller på en driftsdag.
pub fn driftsdag(&self, t: usize) -> bool {
self.driftstider.driftsdag(t / 24)
}
}
const R_SI: f64 = 0.13;
@@ -208,7 +218,7 @@ impl Lost {
)?;
}
for v in &b.vindu {
flater.push(los_vindu(v, &mut f));
flater.push(los_vindu(v, periode, &mut f));
}
if flater.is_empty() {
bail!("bygningen har ingen flater; legg inn minst én [[flate]]");
@@ -326,7 +336,10 @@ impl Lost {
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() {
let lufting = if b.lufting.ingen
|| settpunkt_kjoling.is_some()
|| ventilasjon.tilluft_kjoling.is_some()
{
None
} else {
// Uten oppgitt grense brukes den normerte settpunkttemperaturen for kjøling.
@@ -633,8 +646,25 @@ fn los_flate(
})
}
fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate {
fn los_vindu(
v: &Vindu,
periode: Option<&crate::data::Periode>,
logg: &mut Vec<Forutsetning>,
) -> LostFlate {
let areal = v.areal * v.antall;
// U-verdi: oppgitt, eller typisk for byggeperioden. Et vindu uten oppgitt U-verdi er
// vanligvis originalvinduet, og da sier byggeåret mer enn en fast standardverdi.
let u = v.u_verdi.unwrap_or_else(|| {
let antatt = periode.map(|p| p.vindu).unwrap_or(2.8);
logg.push(Forutsetning {
felt: format!("vindu.{}.u_verdi", v.navn),
verdi: format!("{antatt:.2} W/(m²K)"),
kilde: periode
.map(|p| format!("typisk for {}", p.navn))
.unwrap_or_else(|| "antatt".into()),
});
antatt
});
let karm = v.karmandel.unwrap_or_else(|| {
logg.push(Forutsetning {
felt: format!("vindu.{}.karmandel", v.navn),
@@ -647,11 +677,11 @@ fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate {
0.0
} else {
v.g_verdi.unwrap_or_else(|| {
let antatt = if v.u_verdi <= 1.2 { 0.50 } else { 0.75 };
let antatt = if u <= 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 {
kilde: if u <= 1.2 {
"antatt 3-lags energiglass"
} else {
"antatt 2-lags glass"
@@ -675,8 +705,8 @@ fn los_vindu(v: &Vindu, logg: &mut Vec<Forutsetning>) -> LostFlate {
navn: v.navn.clone(),
typ: FlateType::Vegg,
areal,
u_verdi: v.u_verdi,
u_konstruksjon: v.u_verdi,
u_verdi: u,
u_konstruksjon: u,
helning: v.helning,
orientering: v.orientering,
kappa: 0.0,
@@ -724,6 +754,22 @@ fn los_ventilasjon(v: &Ventilasjon, n: &Normerte, logg: &mut Vec<Forutsetning>)
let varmebatteri = v
.varmebatteri
.unwrap_or(matches!(v.typ, VentilasjonType::Balansert));
// Kjølebatteriet holder konstant tilluftstemperatur i driftstiden, slik DiBKs modellbygg
// for energirammene er regnet (Multiconsult 2014, avsnitt 7.3).
let tilluft_kjoling = (v.kjolebatteri == Some(true)).then(|| {
v.tilluft_kjoling.unwrap_or_else(|| {
let t = n
.settpunkt
.ventilasjon_tilluft_kjoling
.unwrap_or(n.settpunkt.ventilasjon_tilluft);
logg.push(Forutsetning {
felt: "ventilasjon.tilluft_kjoling".into(),
verdi: format!("{t:.0} °C"),
kilde: format!("normert tilluftstemperatur, {}", n.kilde),
});
t
})
});
LostVentilasjon {
typ: v.typ,
luftmengde,
@@ -732,6 +778,7 @@ fn los_ventilasjon(v: &Ventilasjon, n: &Normerte, logg: &mut Vec<Forutsetning>)
sfp,
varmebatteri,
tilluft_settpunkt: n.settpunkt.ventilasjon_tilluft,
tilluft_kjoling,
driftstid: n.driftstid.ventilasjon,
}
}
@@ -754,9 +801,11 @@ fn los_laster(b: &Bygning, n: &Normerte, logg: &mut Vec<Forutsetning>) -> LostLa
logg.push(Forutsetning {
felt: "belysning".into(),
verdi: format!(
"{:.2} W/m² i {:.0} h/døgn",
"{:.2} W/m² i {:.0} h/døgn, {} dager/uke, {} uker/år",
n.belysning.effekt,
n.driftstid.belysning.timer()
n.driftstid.belysning.timer(),
n.driftstid.dager_per_uke,
n.driftstid.uker_per_aar
),
kilde: format!("normert, {}", n.kilde),
});
@@ -772,16 +821,17 @@ fn los_laster(b: &Bygning, n: &Normerte, logg: &mut Vec<Forutsetning>) -> LostLa
.drift
.personer_varmetilskudd
.unwrap_or(n.personer.varmetilskudd);
let driftsdager = n.driftstid.driftsdager_per_aar() as f64;
let tappevann = b.tappevann.energibehov.map(|kwh| {
// kWh/(m²·år) → W/m² i driftstiden.
kwh * 1000.0 / (365.0 * n.driftstid.varmtvann.timer())
// kWh/(m²·år) → W/m² i driftstiden på driftsdager.
kwh * 1000.0 / (driftsdager * 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
n.varmtvann.effekt * n.driftstid.varmtvann.timer() * driftsdager / 1000.0
),
kilde: format!("normert, {}", n.kilde),
});
@@ -801,6 +851,6 @@ fn los_laster(b: &Bygning, n: &Normerte, logg: &mut Vec<Forutsetning>) -> LostLa
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,
driftstider: n.driftstid.clone(),
}
}
+33 -1
View File
@@ -82,6 +82,26 @@ pub struct Bygning {
pub drift: Drift,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scenario: Vec<Scenario>,
#[serde(default)]
pub rapport: Rapport,
}
/// Utseendet på rapporten, slik at den kan følge firmaets øvrige dokumenter.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct Rapport {
/// Logo på forsiden. Filen kopieres til utdatakatalogen ved siden av rapporten.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo: Option<PathBuf>,
/// Aksentfarge, for eksempel «#880000». Utelatt: enoks egen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub farge: Option<String>,
/// Avsenderlinje nederst på hver side, for eksempel firma og organisasjonsnummer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avsender: Option<String>,
/// Skrift for overskrifter. Utelatt: enoks egen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skrift_overskrift: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
@@ -117,6 +137,10 @@ fn en() -> u32 {
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Klima {
/// Byggets posisjon som [breddegrad, lengdegrad]. Er kommune ikke oppgitt, velges
/// klimastedet nærmest denne posisjonen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posisjon: Option<[f64; 2]>,
/// Kommune for lokalt klima (TMY-fil fra Zenodo 14779899). Utelatt: bare Oslo.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kommune: Option<String>,
@@ -264,7 +288,9 @@ pub struct Vindu {
pub orientering: f64,
#[serde(default = "nitti")]
pub helning: f64,
pub u_verdi: f64,
/// U-verdi inkl. karm, W/(m²K). Utelatt: typisk for byggeåret.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub u_verdi: Option<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>,
@@ -393,6 +419,12 @@ pub struct Ventilasjon {
/// Har anlegget varmebatteri som ettervarmer tilluften?
#[serde(default, skip_serializing_if = "Option::is_none")]
pub varmebatteri: Option<bool>,
/// Har anlegget kjølebatteri? Tilluften holdes da på `tilluft_kjoling` i driftstiden.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kjolebatteri: Option<bool>,
/// Tilluftstemperatur med kjølebatteri, °C. Utelatt: normert.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tilluft_kjoling: Option<f64>,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
+22 -11
View File
@@ -188,17 +188,20 @@ pub fn simuler(l: &Lost, v: &Vaerdata, tilskudd: Systemtilskudd) -> Result<Times
}
for t in timer {
let klokke = t % 24;
let driftsdag = l.laster.driftsdag(t);
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);
// Lastprofilene gjelder driftsdager; på andre dager er bygningen ute av bruk.
let skala = if driftsdag { l.bra } else { 0.0 };
let bel = l.laster.belysning[klokke] * skala;
let utstyr_el = l.laster.utstyr_effekt[klokke] * skala;
let utstyr_varme = l.laster.utstyr_varme[klokke] * skala;
let personer = l.laster.personer[klokke] * skala;
let vv = l.laster.varmtvann[klokke] * skala;
let pumper = l.laster.pumper[klokke] * skala;
let vifter = ventilasjon::vifteeffekt(l, t);
// Vifteeffekten ender som varme i lufta (ISO 52016-1: hele SFP-effekten).
let internlast = bel + utstyr_varme + personer + tilskudd.tappevann + vifter;
@@ -211,21 +214,28 @@ pub fn simuler(l: &Lost, v: &Vaerdata, tilskudd: Systemtilskudd) -> Result<Times
}
// ── ventilasjon ─────────────────────────────────────────
let q_vent = ventilasjon::luftmengde(l, klokke);
let q_vent = ventilasjon::luftmengde(l, t);
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) =
let kjol_tilluft = l
.ventilasjon
.tilluft_kjoling
.filter(|&k| ventilasjon::i_drift(l, t) && t_sup_hx > k);
let (t_sup, vent_varme, vent_kjol) =
if l.ventilasjon.varmebatteri && t_sup_hx < l.ventilasjon.tilluft_settpunkt {
(
l.ventilasjon.tilluft_settpunkt,
h_ve * (l.ventilasjon.tilluft_settpunkt - t_sup_hx),
0.0,
)
} else if let Some(k) = kjol_tilluft {
(k, 0.0, h_ve * (t_sup_hx - k))
} else {
(t_sup_hx, 0.0)
(t_sup_hx, 0.0, 0.0)
};
// ── settpunkt ───────────────────────────────────────────
let sett_varme = if l.laster.oppvarming_driftstid.aktiv(klokke) {
let sett_varme = if driftsdag && l.laster.driftstider.oppvarming.aktiv(klokke) {
l.settpunkt_oppvarming
} else {
l.settpunkt_natt
@@ -339,6 +349,7 @@ pub fn simuler(l: &Lost, v: &Vaerdata, tilskudd: Systemtilskudd) -> Result<Times
ts.romoppvarming[t] = fi.max(0.0);
ts.romkjoling[t] = (-fi).max(0.0);
ts.ventilasjonsvarme[t] = vent_varme;
ts.ventilasjonskjoling[t] = vent_kjol;
ts.varmtvann[t] = vv;
ts.vifter[t] = vifter;
ts.pumper[t] = pumper;
+23 -7
View File
@@ -33,9 +33,14 @@ pub fn infiltrasjonsluftmengde(l: &Lost) -> f64 {
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) {
/// Om ventilasjonen går med driftsluftmengde i time `t` av året.
pub fn i_drift(l: &Lost, t: usize) -> bool {
l.laster.driftsdag(t) && l.ventilasjon.driftstid.aktiv(t % 24)
}
/// Ventilasjonsluftmengde i time `t` av året, m³/h.
pub fn luftmengde(l: &Lost, t: usize) -> f64 {
let spesifikk = if i_drift(l, t) {
l.ventilasjon.luftmengde
} else {
l.ventilasjon.luftmengde_natt
@@ -44,18 +49,29 @@ pub fn luftmengde(l: &Lost, klokketime: usize) -> f64 {
}
/// Tilluftstemperatur etter varmegjenvinner (uten ettervarming), °C.
///
/// Gjenvinneren reguleres ned når den ellers ville varmet tilluften over settpunktet, slik
/// en roterende gjenvinner med turtallsregulering eller et omløpsspjeld gjør. Når det er
/// varmere ute enn inne, kjøler gjenvinneren tilluften og går for fullt.
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),
VentilasjonType::Balansert => {
let gjenvunnet = t_ute + l.ventilasjon.gjenvinning * (t_avtrekk - t_ute);
if gjenvunnet > t_ute {
gjenvunnet.min(l.ventilasjon.tilluft_settpunkt.max(t_ute))
} else {
gjenvunnet
}
}
_ => t_ute,
}
}
/// Vifteeffekt for en klokketime, W.
pub fn vifteeffekt(l: &Lost, klokketime: usize) -> f64 {
/// Vifteeffekt i time `t` av året, W.
pub fn vifteeffekt(l: &Lost, t: usize) -> f64 {
if matches!(l.ventilasjon.typ, VentilasjonType::Naturlig) {
return 0.0;
}
let q = luftmengde(l, klokketime) / 3600.0; // m³/s
let q = luftmengde(l, t) / 3600.0; // m³/s
l.ventilasjon.sfp * 1000.0 * q
}
+1 -1
View File
@@ -149,7 +149,7 @@ pub fn kilde(r: &Resultat) -> String {
l.settpunkt_kjoling.unwrap_or(0.0)
);
let _ = writeln!(s, " <Driftstider>");
let d = &l.laster.oppvarming_driftstid;
let d = &l.laster.driftstider.oppvarming;
let _ = writeln!(
s,
" <Ventilasjon>{:.0}</Ventilasjon>",
+87 -7
View File
@@ -66,6 +66,12 @@ fn rad(celler: &[String]) -> String {
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)?;
// Logoen følger med, slik at rapporten kan kompileres for seg selv senere. Mangler
// filen, lages rapporten uten den.
if let (Some(logo), Some(navn)) = (&r.bygning.rapport.logo, logo_navn(r)) {
std::fs::copy(logo, katalog.join(navn))
.with_context(|| format!("kopierer logoen {}", logo.display()))?;
}
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))?;
@@ -100,13 +106,30 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
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))
));
let rap = &r.bygning.rapport;
let mut valg = format!(
"tittel: \"{} — energirapport\", bunntekst: \"{}\"",
streng(&l.navn),
streng(&bunntekst(r))
);
if let Some(a) = &rap.avsender {
valg.push_str(&format!(", avsender: \"{}\"", streng(a)));
}
if let Some(f) = &rap.farge {
valg.push_str(&format!(", farge: rgb(\"{}\")", streng(f)));
}
if let Some(sk) = &rap.skrift_overskrift {
valg.push_str(&format!(", skrift: \"{}\"", streng(sk)));
}
s.push_str(&format!("#show: oppsett.with({valg})\n\n"));
// ── forside ─────────────────────────────────────────────────────
if let Some(navn) = logo_navn(r) {
s.push_str(&format!(
"#align(right, image(\"{}\", height: 1.7cm))\n\n#v(-0.9cm)\n",
streng(&navn)
));
}
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",
@@ -473,7 +496,8 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
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");
// Faste bredder, ellers spiser de to første kolonnene plassen kilden trenger.
s.push_str("#tabell(\n kolonner: (1.15fr, 0.85fr, 1.6fr),\n justering: (left, left, left),\n overskrift: (\"Felt\", \"Verdi\", \"Kilde\"),\n rader: (\n");
for f in &l.forutsetninger {
s.push_str(&format!(
" {}\n",
@@ -530,14 +554,33 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
}
s.push_str(&format!(
"\n#v(1em)\n#text(size: 8pt, fill: mid)[Beregnet med enok {} etter NS 3031:2025 (timesmetode, \
"\n#v(1em)\n#text(size: 8pt, fill: mid)[Beregnet {} med enok {} etter NS 3031:2025 (timesmetode, \
NS-EN ISO 52016-1). Karakterskala fastsatt av Enova SF, gjeldende fra 1. januar 2026. \
Klimadata: TMY for norske kommuner basert på CERRA 19912020 (CC-BY-4.0).]\n",
i_dag(),
env!("CARGO_PKG_VERSION")
));
s
}
/// Tekst i en Typst-strengliteral: bare omvendt skråstrek og anførselstegn skal ut.
fn streng(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Filnavnet på logoen, når den er oppgitt og finnes.
fn logo_navn(r: &Resultat) -> Option<String> {
let logo = r.bygning.rapport.logo.as_ref()?;
if !logo.exists() {
eprintln!(
" ! rapport.logo: fant ikke {}; rapporten lages uten logo.",
logo.display()
);
return None;
}
Some(logo.file_name()?.to_str()?.to_string())
}
fn bunntekst(r: &Resultat) -> String {
let b = &r.bygning.bygning;
let mut deler = Vec::new();
@@ -550,9 +593,34 @@ fn bunntekst(r: &Resultat) -> String {
if let Some(e) = &b.energiraadgiver {
deler.push(e.clone());
}
deler.push(format!("enok {} · {}", env!("CARGO_PKG_VERSION"), i_dag()));
deler.join(" · ")
}
/// Dagens dato som ISO-8601 i UTC.
fn i_dag() -> String {
let sekunder = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (aar, maaned, dag) = dato_av_dagnummer(sekunder.div_euclid(86_400));
format!("{aar:04}-{maaned:02}-{dag:02}")
}
/// Kalenderdato av antall døgn siden 1970-01-01, etter Howard Hinnants algoritme.
fn dato_av_dagnummer(dager: i64) -> (i64, i64, i64) {
let z = dager + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let dag = doy - (153 * mp + 2) / 5 + 1;
let maaned = if mp < 10 { mp + 3 } else { mp - 9 };
let aar = yoe + era * 400 + i64::from(maaned <= 2);
(aar, maaned, dag)
}
fn himmelretning(orientering: f64, helning: f64) -> String {
if helning < 20.0 {
return "horisontal".into();
@@ -566,3 +634,15 @@ fn himmelretning(orientering: f64, helning: f64) -> String {
let i = (((orientering.rem_euclid(360.0)) + 22.5) / 45.0).floor() as usize % 8;
retninger[i].into()
}
#[cfg(test)]
mod tester {
use super::dato_av_dagnummer;
#[test]
fn dagnummer_gir_riktig_dato() {
assert_eq!(dato_av_dagnummer(0), (1970, 1, 1));
assert_eq!(dato_av_dagnummer(19_723), (2024, 1, 1));
assert_eq!(dato_av_dagnummer(20_351), (2025, 9, 20));
}
}
+2 -1
View File
@@ -310,7 +310,8 @@ fn baerer_for(t: VarmekildeType, lukket: bool) -> Energibaerer {
/// Samlet tap fra tappevannsanlegget, W. Tapet varmer opp der anlegget står.
pub fn tappevannstilskudd_totalt(l: &Lost, s: &Systemoppsett) -> f64 {
let netto_w: f64 = l.laster.varmtvann.iter().sum::<f64>() / 24.0 * l.bra;
let driftsandel = l.laster.driftstider.driftsdager_per_aar() as f64 / 365.0;
let netto_w: f64 = l.laster.varmtvann.iter().sum::<f64>() / 24.0 * l.bra * driftsandel;
netto_w * s.tappevann_tap_andel
}
+143
View File
@@ -0,0 +1,143 @@
//! Kontroll av de normerte inndataene for yrkesbyggkategoriene: driftsdager, faste energiposter
//! og kjølebatteri.
use enok::data::Datakilde;
use enok::kjor;
use enok::modell::{Bygning, Kategori};
fn prøvebygg(kategori: &str, kjolebatteri: bool) -> Bygning {
let s = format!(
r#"
[bygning]
navn = "Prøvebygg"
kategori = "{kategori}"
byggeaar = 2020
oppvarmet_bra = 1200.0
oppvarmet_volum = 3840.0
[klima]
kommune = "Oslo"
[[flate]]
navn = "Vegger"
type = "vegg"
areal = 560.0
u_verdi = 0.18
[[flate]]
navn = "Tak"
type = "tak"
areal = 600.0
u_verdi = 0.13
[[flate]]
navn = "Gulv"
type = "gulv"
areal = 600.0
u_verdi = 0.10
[[vindu]]
navn = "Sør"
areal = 150.0
orientering = 180
u_verdi = 0.8
g_verdi = 0.5
[infiltrasjon]
n50 = 0.6
[ventilasjon]
type = "balansert"
gjenvinning = 0.85
kjolebatteri = {kjolebatteri}
[[oppvarming.kilde]]
type = "direkte_el"
andel_rom = 1.0
andel_tappevann = 1.0
"#
);
toml::from_str(&s).unwrap()
}
fn post(r: &kjor::Resultat, nr: &str) -> f64 {
let p = r.budsjett.poster.iter().find(|p| p.nr == nr).unwrap();
p.netto / r.lost.bra
}
fn naer(faktisk: f64, forventet: f64, hva: &str) {
assert!(
(faktisk - forventet).abs() <= 0.1,
"{hva}: beregnet {faktisk:.2}, forventet {forventet:.2}"
);
}
#[test]
fn driftsdager_per_aar() {
let kilde = Datakilde::default();
for (kategori, dager) in [
(Kategori::Smaahus, 365),
(Kategori::Boligblokk, 365),
(Kategori::Barnehage, 261),
(Kategori::Kontorbygg, 261),
(Kategori::Kulturbygg, 261),
(Kategori::Skolebygg, 220),
(Kategori::Idrettsbygg, 220),
] {
let n = kilde.normerte(kategori).unwrap();
assert_eq!(
n.driftstid.driftsdager_per_aar(),
dager,
"{}",
kategori.nokkel()
);
}
}
/// Fellesferien og helgene er uten drift i skolebygg.
#[test]
fn skolebygg_stengt_i_helg_og_ferie() {
let n = Datakilde::default().normerte(Kategori::Skolebygg).unwrap();
let d = &n.driftstid;
assert!(d.driftsdag(0), "1. januar er mandag i uke 1");
assert!(!d.driftsdag(5), "lørdag");
assert!(!d.driftsdag(6), "søndag");
assert!(!d.driftsdag(7 * 28), "mandag i uke 29");
assert!(d.driftsdag(7 * 34), "mandag i uke 35");
}
/// Belysning, utstyr og varmtvann skal gi energibehovene som følger av tabellverdiene.
#[test]
fn faste_poster_som_tabellverdiene() {
let kilde = Datakilde::default();
for (kategori, belysning, utstyr, varmtvann) in [
("barnehage", 20.9, 5.2, 10.0),
("kontorbygg", 25.1, 34.5, 5.0),
("skolebygg", 22.0, 13.2, 10.0),
("idrettsbygg", 21.1, 2.6, 50.2),
("kulturbygg", 23.0, 2.9, 10.0),
] {
let r = kjor::kjor(&prøvebygg(kategori, false), &kilde).unwrap();
naer(post(&r, "5"), belysning, &format!("{kategori} belysning"));
naer(post(&r, "6"), utstyr, &format!("{kategori} utstyr"));
naer(post(&r, "2"), varmtvann, &format!("{kategori} varmtvann"));
}
}
/// Kjølebatteriet gir ventilasjonskjøling; uten det er posten null.
#[test]
fn kjolebatteri_gir_ventilasjonskjoling() {
let kilde = Datakilde::default();
let uten = kjor::kjor(&prøvebygg("kontorbygg", false), &kilde).unwrap();
let med = kjor::kjor(&prøvebygg("kontorbygg", true), &kilde).unwrap();
assert_eq!(post(&uten, "3b"), 0.0);
assert!(
post(&med, "3b") > 0.5,
"ventilasjonskjøling {:.2}",
post(&med, "3b")
);
// Kjøling skjer bare i driftstiden på driftsdager.
let ts = &med.timeserie;
let lørdag_middag = 5 * 24 + 12 + 24 * 7 * 26;
assert_eq!(ts.ventilasjonskjoling[lørdag_middag], 0.0);
}
+30
View File
@@ -0,0 +1,30 @@
//! Kontroll av valg av klimasted. Testene bruker TMY-arkivet og laster det ned ved første kjøring.
use enok::data::normaliser_stedsnavn;
use enok::klima::geo::Posisjon;
use enok::klima::tmy::naermeste_kommune;
#[test]
fn naermeste_klimasted_i_indre_ostfold() {
let (kommune, km) = naermeste_kommune(&Posisjon {
bredde: 59.63,
lengde: 11.01,
})
.unwrap();
assert_eq!(
normaliser_stedsnavn(&kommune),
"indreostfold",
"fikk {kommune}"
);
assert!(km < 25.0, "{km} km");
}
#[test]
fn naermeste_klimasted_for_oslo_raadhus_er_oslo() {
let (kommune, _) = naermeste_kommune(&Posisjon {
bredde: 59.9115,
lengde: 10.7336,
})
.unwrap();
assert_eq!(normaliser_stedsnavn(&kommune), "oslo", "fikk {kommune}");
}
+2 -2
View File
@@ -86,13 +86,13 @@ fn regresjonsvakt_for_romoppvarming() {
let r = kjor_referansebygg();
naer(
post(&r, "1a"),
27.2,
27.1,
1.5,
"romoppvarming (regresjon, publisert verdi 46,2)",
);
naer(
r.budsjett.netto_spesifikk(r.lost.bra),
86.7,
86.6,
2.0,
"netto energibehov (regresjon, publisert verdi 106,4)",
);
+35 -8
View File
@@ -7,11 +7,32 @@
#let lys = rgb("#f4f1ee")
#let kantlinje = rgb("#ddd4cc")
// Aksentfarge og overskriftsskrift kan settes av rapporten, slik at den kan følge
// firmaets øvrige dokumenter. Verdiene under er enoks egne.
#let aksent-valgt = state("aksent-valgt", aksent)
#let skrift-valgt = state("skrift-valgt", none)
#let font-heading = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#let font-body = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#let font-tall = ("DejaVu Sans Mono", "Liberation Mono")
#let oppsett(tittel: "", undertittel: "", bunntekst: "", doc) = {
/// Skriften rapporten har bedt om, ellers enoks egen.
#let overskriftsskrift() = {
let valgt = skrift-valgt.get()
if valgt == none { font-heading } else { (valgt,) + font-heading }
}
#let oppsett(
tittel: "",
undertittel: "",
bunntekst: "",
avsender: "",
farge: none,
skrift: none,
doc,
) = {
aksent-valgt.update(if farge == none { aksent } else { farge })
skrift-valgt.update(skrift)
set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.2cm, right: 2.2cm),
@@ -20,32 +41,36 @@
grid(
columns: (1fr, auto),
align: (left + horizon, right + horizon),
text(font: font-heading, size: 8pt, fill: mid)[#tittel],
text(font: overskriftsskrift(), size: 8pt, fill: mid)[#tittel],
text(font: font-body, size: 8pt, fill: mid)[#counter(page).display()],
)
line(length: 100%, stroke: 0.4pt + kantlinje)
}
},
footer: context {
if counter(page).get().first() > 1 and bunntekst != "" {
if counter(page).get().first() > 1 and (bunntekst != "" or avsender != "") {
line(length: 100%, stroke: 0.4pt + kantlinje)
v(3pt)
if avsender != "" {
text(font: font-body, size: 7.5pt, fill: mid)[#avsender]
linebreak()
}
text(font: font-body, size: 7.5pt, fill: mid)[#bunntekst]
}
},
)
set text(font: font-body, size: 10pt, fill: moerk, lang: "nb")
set par(justify: true, leading: 0.62em)
show heading: it => {
set text(font: font-heading, fill: aksent)
show heading: it => context {
set text(font: overskriftsskrift(), fill: aksent-valgt.get())
block(above: 1.2em, below: 0.6em, it)
}
show heading.where(level: 1): it => {
show heading.where(level: 1): it => context {
set text(size: 15pt, weight: "bold")
block(above: 1.6em, below: 0.7em)[
#it.body
#v(-0.45em)
#line(length: 100%, stroke: 1pt + aksent)
#line(length: 100%, stroke: 1pt + aksent-valgt.get())
]
}
show heading.where(level: 2): set text(size: 11.5pt, weight: "bold")
@@ -55,7 +80,8 @@
}
// Tabell med stripete rader og linje under overskriften.
#let tabell(kolonner: (), overskrift: (), rader: (), justering: none, sum-rader: ()) = {
#let tabell(kolonner: (), overskrift: (), rader: (), justering: none, sum-rader: ()) = context {
let aksent = aksent-valgt.get()
let j = if justering == none { kolonner.map(_ => right) } else { justering }
block(breakable: true, table(
columns: kolonner,
@@ -99,3 +125,4 @@
stroke: (left: 2pt + aksent-lys),
text(size: 9pt)[#innhold],
)