7 Commits
Author SHA1 Message Date
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
29 changed files with 1601 additions and 156 deletions
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]]
name = "enok"
version = "0.3.0"
version = "0.4.1"
dependencies = [
"anyhow",
"approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "enok"
version = "0.3.0"
version = "0.4.1"
edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
license = "MIT"
+122 -12
View File
@@ -13,7 +13,7 @@ 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,7 +125,7 @@ antatt og hvor det kommer fra.
```toml
[bygning]
navn = "Nordhusstykket 5"
navn = "Eldre enebolig med kjeller"
kategori = "smaahus"
byggeaar = 1948
oppvarmet_bra = 140.0
@@ -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, …
@@ -167,6 +182,7 @@ endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }]
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,6 +194,73 @@ 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 |
### Modellen og en tilleggsfil
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 |
@@ -223,6 +306,10 @@ egenskapssett på `IfcBuilding`, `IfcSite` eller `IfcProject`:
| `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.
Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på
modellen alene.
@@ -235,8 +322,8 @@ Dette i selve modellen er verdt å gjøre riktig, omtrent i rekkefølge etter hv
- **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 modellen for Nordhusstykket 5 er bare tolv av
hundre og fem vegger merket, og kjellermurene er ikke blant dem.
- **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.
@@ -249,7 +336,7 @@ Regnearkformatet er bevisst enkelt, slik at det kan settes opp som en rapport i
```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
@@ -267,8 +354,9 @@ vertskjøreren `fish` på samme måte som de andre prosjektene:
### Energirapport fra et byggeprosjekt
Et prosjektlager med sin egen IFC-modell og `bygning.toml` trenger ikke Rust. Det henter
binærfilen fra enoks siste 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
@@ -276,14 +364,14 @@ 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: |
@@ -293,6 +381,27 @@ jobs:
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@v4
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@v4
with:
name: bygningsmodell
- name: Lag rapporten
run: |
chmod +x enok
./enok beregn bygning.toml --ut dist
```
@@ -310,12 +419,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
@@ -336,7 +446,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 |
+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 {
+5 -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
@@ -687,7 +687,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));
}
+325 -43
View File
@@ -11,6 +11,7 @@
use crate::inndata::ifcgeom;
use crate::inndata::step::{self, Arg, Entitet, Fil};
use crate::inndata::toml_inn::Importvalg;
use crate::klima::geo;
use crate::modell::*;
use crate::Result;
@@ -25,21 +26,33 @@ pub struct Importrapport {
pub utelatt: Vec<(String, f64, Option<f64>)>,
}
/// Konstruksjonsnavn og avrundet plassering, som skiller én bygningsdel fra en annen.
type Dublettnokkel = (String, i64, i64, i64, i64);
const VEGGTYPER: [&str; 3] = ["IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL"];
const PLATETYPER: [&str; 3] = ["IFCSLAB", "IFCROOF", "IFCSLABSTANDARDCASE"];
pub fn les(sti: &Path) -> Result<Importrapport> {
les_med(sti, &Importvalg::default())
}
pub fn les_med(sti: &Path, valg: &Importvalg) -> Result<Importrapport> {
let raa = std::fs::read(sti).with_context(|| format!("leser {}", sti.display()))?;
let tekst = String::from_utf8_lossy(&raa);
fra_tekst(
fra_tekst_med(
&tekst,
sti.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Bygning"),
valg,
)
}
pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
fra_tekst_med(tekst, standardnavn, &Importvalg::default())
}
pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Result<Importrapport> {
let fil = step::les(tekst);
let mut merknader = Vec::new();
@@ -88,6 +101,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let mut rom: Vec<(String, f64)> = Vec::new();
let mut kjellerrom: Vec<(String, f64)> = Vec::new();
let mut mistenkelige: Vec<String> = Vec::new();
let mut uoppvarmede: Vec<String> = Vec::new();
for (r, e) in fil.av_type("IFCSPACE") {
let navn = romnavn(e);
let areal = mengder.get(&r).map(arealmengde).unwrap_or(0.0);
@@ -111,6 +125,14 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
kjellervolum += romvolum;
} else {
let lavt = navn.to_lowercase();
if valg
.uoppvarmede_rom
.iter()
.any(|u| lavt.contains(&u.to_lowercase()))
{
uoppvarmede.push(format!("{navn} {areal:.1}"));
continue;
}
if ["kott", "bod", "loft", "garasje", "carport", "uoppvarmet"]
.iter()
.any(|o| lavt.contains(o))
@@ -136,6 +158,12 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
mistenkelige.join(", ")
));
}
if !uoppvarmede.is_empty() {
merknader.push(format!(
"Disse rommene er uoppvarmet etter tilleggsfilen og holdt utenfor oppvarmet BRA: {}.",
uoppvarmede.join(", ")
));
}
// ── plassering og geometri for alle bygningsdeler ───────────────
let typer: Vec<&str> = VEGGTYPER.iter().chain(PLATETYPER.iter()).copied().collect();
@@ -157,9 +185,59 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
plass,
areal: flate.map(|f| f.areal).unwrap_or(0.0),
normal: flate.map(|f| f.normal),
z: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.hoyde).unwrap_or(0.0),
});
}
// Noen eksporter legger den samme bygningsdelen i flere etasjer. To deler med samme
// konstruksjon, samme areal og samme plassering er den samme veggen eller det samme
// dekket. Kopiene blir stående, slik at vinduene i dem finner veggen sin, men de telles
// ikke med i klimaskjermen. Av kopiene beholdes den som ligger i den etasjen høyden
// tilsier, slik at en kjellervegg havner i kjelleren.
let konstruksjon = |navn: &str| navn.split(" #").next().unwrap_or(navn).trim().to_string();
let mut grupper: Vec<(Dublettnokkel, Vec<u64>)> = Vec::new();
for d in &deler {
let Some((x, y, _, _, _)) = d.plass else {
continue;
};
let cm = |v: f64| (v * 50.0).round() as i64;
let nokkel = (
konstruksjon(&d.navn),
cm(x),
cm(y),
cm(d.z),
(d.areal * 100.0).round() as i64,
);
match grupper.iter_mut().find(|(k, _)| *k == nokkel) {
Some((_, medlemmer)) => medlemmer.push(d.nr),
None => grupper.push((nokkel, vec![d.nr])),
}
}
let mut dublett_av: HashMap<u64, u64> = HashMap::new();
for (_, medlemmer) in grupper.iter().filter(|(_, m)| m.len() > 1) {
let avstand_til_etasjen = |nr: u64| {
let d = deler.iter().find(|d| d.nr == nr)?;
let hoyde = d.etasje.and_then(|i| etasjer.get(i)).map(|e| e.hoyde)?;
Some((d.z - hoyde).abs())
};
let beholdt = *medlemmer
.iter()
.min_by(|a, b| {
let (fa, fb) = (avstand_til_etasjen(**a), avstand_til_etasjen(**b));
match (fa, fb) {
(Some(x), Some(y)) => x.total_cmp(&y).then(a.cmp(b)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.cmp(b),
}
})
.unwrap_or(&medlemmer[0]);
for nr in medlemmer.iter().filter(|nr| **nr != beholdt) {
dublett_av.insert(*nr, beholdt);
}
}
let dubletter = dublett_av.len();
// Tyngdepunkt av de ytre veggene bestemmer hvilken vei normalen peker.
let ytre_deler: Vec<&Del> = deler
.iter()
@@ -209,8 +287,16 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let forste_over = etasjer.iter().position(|e| !e.under_terreng);
// Terrenghøyden på IfcBuilding sier hvor mye av grunnmuren som står over bakken.
let terreng = terrenghoyder(&fil, skala);
let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) {
// Ligger terrengmodellen over gulvet i første etasje, er den ikke lagt i bakkenivå, og da
// sier den ingenting om hvor mye av grunnmuren som er nedgravd.
let terreng_over_gulvet = match (terreng, forste_over.and_then(|i| etasjer.get(i))) {
(Some((referanse, terrengnivaa)), Some(forste)) => {
terrengnivaa > referanse + forste.hoyde + 0.5
}
_ => false,
};
let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) {
(Some((referanse, terrengnivaa)), Some(forste)) if !terreng_over_gulvet => {
over_terreng_for_kjeller(referanse, terrengnivaa, forste.hoyde, kjellerdybde)
}
_ => 0.0,
@@ -220,20 +306,20 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let mut kjellerflater: Vec<Flate> = Vec::new();
let mut flater: Vec<Flate> = Vec::new();
let mut utelatt: Vec<(String, f64, Option<f64>)> = Vec::new();
let mut uten_uverdi = 0usize;
let mut uten_uverdi: Vec<String> = Vec::new();
let mut uten_geometri = 0usize;
let mut asimut_for_vegg: HashMap<u64, f64> = HashMap::new();
// Laveste etasje og laveste gulvnivå skiller dekke mot grunn fra etasjeskillere.
let etasjekontekst = Etasjekontekst {
nederste: (!etasjer.is_empty()).then_some(0),
kjeller: kjelleretasje,
forste_over,
forste_over_hoyde: forste_over.and_then(|i| etasjer.get(i)).map(|e| e.hoyde),
};
let laveste = deler
.iter()
.filter(|d| PLATETYPER.contains(&d.typ.as_str()))
.filter_map(|d| d.plass.map(|p| p.2))
.map(|d| d.z)
.fold(f64::INFINITY, f64::min);
for d in &deler {
@@ -246,23 +332,32 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
uten_geometri += 1;
continue;
}
if dublett_av.contains_key(&d.nr) {
continue;
}
let i_kjeller = kjelleretasje.is_some() && d.etasje == kjelleretasje;
let Some((typ, mot)) = klassifiser(d, laveste, helning, &etasjekontekst) else {
utelatt.push((d.navn.clone(), d.areal, d.u_verdi));
continue;
};
// En etasjeskiller i den laveste etasjen over terreng er grensen mot kjelleren.
// En etasjeskiller like over kjelleren er grensen mot den uoppvarmede sonen. Høyden
// avgjør, ikke etasjetilknytningen, som kan være feil i eksporten.
let ved_kjellertaket = etasjekontekst
.forste_over_hoyde
.map(|h| (d.z - h).abs() < 0.6)
.unwrap_or(false);
let (mot, sone) = if kjelleretasje.is_some()
&& !i_kjeller
&& d.etasje == forste_over
&& ved_kjellertaket
&& typ == FlateType::Gulv
&& mot != Mot::Grunn
{
(Mot::Uoppvarmet, Some(kjellernavn.clone()))
} else {
(mot, None)
};
if d.u_verdi.is_none() {
uten_uverdi += 1;
uten_uverdi.push(d.navn.clone());
}
let mal = if i_kjeller {
&mut kjellerflater
@@ -299,6 +394,9 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
// ── vinduer og dører ────────────────────────────────────────────
let mut vinduer: Vec<Vindu> = Vec::new();
let mut sette_vinduer: std::collections::HashSet<(String, (i64, i64, i64), i64)> =
Default::default();
let mut vindusdubletter = 0usize;
for (r, e) in fil.av_type("IFCWINDOW").chain(fil.av_type("IFCDOOR")) {
let dor = e.typ == "IFCDOOR";
let hoyde = e.arg(8).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala;
@@ -315,7 +413,9 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
dor_vert.get(&r)
} else {
vindu_vert.get(&r)
};
}
.map(|v| dublett_av.get(v).copied().unwrap_or(*v));
let vert = vert.as_ref();
// Vinduer i innervegger hører ikke til klimaskjermen.
let i_ytre_vegg = vert
.and_then(|v| deler.iter().find(|d| d.nr == *v))
@@ -329,18 +429,35 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
));
continue;
}
// Det samme vinduet i to kopier av den samme veggen er ett vindu.
let sted = e
.arg(5)
.and_then(|a| a.som_ref())
.and_then(|p| global_plassering(&fil, p, skala))
.map(|(x, y, z, _, _)| {
let cm = |v: f64| (v * 50.0).round() as i64;
(cm(x), cm(y), cm(z))
})
.unwrap_or_default();
let nokkel = (
konstruksjon(&navn_av(e, r, if dor { "Dør" } else { "Vindu" })),
sted,
(areal * 100.0).round() as i64,
);
if !sette_vinduer.insert(nokkel) {
vindusdubletter += 1;
continue;
}
let asimut = vert
.and_then(|v| asimut_for_vegg.get(v))
.copied()
.unwrap_or(0.0);
let u = u_verdi(&egenskaper, r).unwrap_or_else(|| {
uten_uverdi += 1;
if dor {
2.0
} else {
2.4
// Uten U-verdi i modellen lar vi feltet stå tomt, slik at beregningen bruker en
// typisk verdi for byggeåret i stedet for en fast standardverdi.
let u = u_verdi(&egenskaper, r);
if u.is_none() {
uten_uverdi.push(navn_av(e, r, if dor { "Dør" } else { "Vindu" }));
}
});
vinduer.push(Vindu {
navn: navn_av(e, r, if dor { "Dør" } else { "Vindu" }),
areal,
@@ -398,6 +515,10 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
));
}
// Tilleggsfiler og scenarioer viser til flatene med navn, så navnene må være entydige.
unike_navn(&mut flater);
unike_navn(&mut kjellerflater);
// ── merknader ───────────────────────────────────────────────────
if bra <= 0.0 {
bra = flater
@@ -416,9 +537,69 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
"{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal."
));
}
if uten_uverdi > 0 {
if !utelatt.is_empty() {
// Gruppert per konstruksjon, slik at listen er til å lese.
let mut grupper: Vec<(String, usize, f64)> = Vec::new();
for (navn, areal, _) in &utelatt {
let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string();
match grupper.iter_mut().find(|(n, _, _)| *n == kort) {
Some((_, antall, sum)) => {
*antall += 1;
*sum += areal;
}
None => grupper.push((kort, 1, *areal)),
}
}
grupper.sort_by(|a, b| b.2.total_cmp(&a.2));
let liste: Vec<String> = grupper
.iter()
.take(10)
.map(|(navn, antall, sum)| {
if *antall > 1 {
format!("{navn} ×{antall} ({sum:.0} m²)")
} else {
format!("{navn} ({sum:.0} m²)")
}
})
.collect();
merknader.push(format!(
"{uten_uverdi} bygningsdeler mangler U-verdi i IFC-filen. Legg inn U-verdier, eller la enok bruke typiske verdier for byggeåret."
"{} bygningsdeler er ikke regnet med i klimaskjermen, som innervegger og dekker inne i bygningen: {}{}.",
utelatt.len(),
liste.join(", "),
if grupper.len() > liste.len() { "" } else { "" }
));
}
if dubletter + vindusdubletter > 0 {
merknader.push(format!(
"{} bygningsdeler ligger dobbelt i modellen, samme konstruksjon på samme sted, og er telt én gang. Det skjer når eksporten legger de samme delene i flere etasjer.",
dubletter + vindusdubletter
));
}
if !uten_uverdi.is_empty() {
// Navnene gjør det tydelig hva som må rettes i modellen. Flere elementer av samme
// konstruksjon nevnes én gang, med antallet.
let mut antall: Vec<(String, usize)> = Vec::new();
for navn in &uten_uverdi {
let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string();
match antall.iter_mut().find(|(n, _)| *n == kort) {
Some((_, a)) => *a += 1,
None => antall.push((kort, 1)),
}
}
let liste: Vec<String> = antall
.iter()
.map(|(n, a)| {
if *a > 1 {
format!("{n} ×{a}")
} else {
n.clone()
}
})
.collect();
merknader.push(format!(
"{} bygningsdeler mangler U-verdi i IFC-filen: {}. Legg inn U-verdier i modellen, eller la enok bruke typiske verdier for byggeåret.",
uten_uverdi.len(),
liste.join(", ")
));
}
merknader.push(
@@ -512,7 +693,13 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
rom.join(", ")
)),
});
let grunnmur = if terreng.is_some() {
let grunnmur = if terreng_over_gulvet {
format!(
"Terrengmodellen ligger over gulvet i første etasje, så den er ikke brukt. \
Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Legg \
terrengmodellen i bakkenivå, eller sett `over_terreng` i meter på veggene."
)
} else if terreng.is_some() {
format!(
"Etter terrenghøyden i modellen står grunnmuren {kjeller_over_terreng:.2} m over bakken og \
{:.2} m under.",
@@ -571,6 +758,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
}
};
vinduer.sort_by(|a, b| a.navn.cmp(&b.navn));
let bygning = Bygning {
bygning: BygningInfo {
navn,
@@ -668,9 +857,13 @@ fn slaa_sammen(flater: &mut Vec<Flate>) {
for f in flater.drain(..) {
match samlet.iter_mut().find(|g| nokkel(g) == nokkel(&f)) {
Some(g) => {
// Arealvektet himmelretning, slik at summen peker riktig vei.
// Arealvektet himmelretning og helning, slik at summen peker riktig vei og ikke
// avhenger av rekkefølgen elementene kommer i.
let sum = g.areal + f.areal;
g.orientering = (g.orientering * g.areal + f.orientering * f.areal) / sum;
if let (Some(a), Some(b)) = (g.helning, f.helning) {
g.helning = Some((a * g.areal + b * f.areal) / sum);
}
g.areal = sum;
}
None => samlet.push(f),
@@ -689,6 +882,36 @@ fn slaa_sammen(flater: &mut Vec<Flate>) {
*flater = samlet;
}
/// Gjør flatenavnene entydige. Vegger får himmelretningen etter konstruksjonsnavnet, og andre
/// flater som fortsatt deler navn, nummereres.
fn unike_navn(flater: &mut [Flate]) {
// Fast rekkefølge, slik at samme modell alltid gir samme fil og samme nummerering.
flater.sort_by(|a, b| {
(a.typ as u8, &a.navn)
.cmp(&(b.typ as u8, &b.navn))
.then(a.orientering.total_cmp(&b.orientering))
.then(b.areal.total_cmp(&a.areal))
});
for f in flater.iter_mut().filter(|f| f.typ == FlateType::Vegg) {
f.navn = format!("{}, {}", f.navn, himmelretning(f.orientering));
}
let mut antall: HashMap<String, usize> = HashMap::new();
for f in flater.iter_mut() {
let n = antall.entry(f.navn.clone()).or_insert(0);
*n += 1;
if *n > 1 {
f.navn = format!("{} ({n})", f.navn);
}
}
}
fn himmelretning(grader: f64) -> &'static str {
const RETNINGER: [&str; 8] = [
"nord", "nordøst", "øst", "sørøst", "sør", "sørvest", "vest", "nordvest",
];
RETNINGER[((grader / 45.0).round() as i64).rem_euclid(8) as usize]
}
/// En etasje i modellen, med navn og høyde over byggets referansenivå.
#[derive(Clone, Debug)]
pub struct Etasje {
@@ -1025,6 +1248,8 @@ struct Del {
plass: Option<(f64, f64, f64, f64, f64)>,
areal: f64,
normal: Option<[f64; 3]>,
/// Delens høyde over byggets nullpunkt, av geometrien, m.
z: f64,
}
/// Oversetter en fritekstverdi fra modellen til en bygningskategori.
@@ -1150,7 +1375,10 @@ fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) {
let mut g = [n[0] * dx - n[1] * dy, n[0] * dy + n[1] * dx, n[2]];
let vannrett = (g[0] * g[0] + g[1] * g[1]).sqrt();
if vannrett > 0.2 {
// Et skrått tak har også en vannrett komponent, men skal alltid ha normalen oppover;
// ellers kan undersiden bli hovedflaten og gi helning 180° minus takvinkelen.
let skraa_plate = PLATETYPER.contains(&d.typ.as_str()) && g[2].abs() > 0.1;
if vannrett > 0.2 && !skraa_plate {
// Veggflate: velg den normalen som peker bort fra bygningens tyngdepunkt.
if let Some((x, y, _, _, _)) = d.plass {
if g[0] * (x - cx) + g[1] * (y - cy) < 0.0 {
@@ -1169,12 +1397,13 @@ fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) {
/// Avgjør om en bygningsdel hører til klimaskjermen, og hva den vender mot.
/// Etasjeinndelingen som klassifiseringen trenger.
struct Etasjekontekst {
/// Nederste etasje i modellen.
nederste: Option<usize>,
/// Etasjen under terreng, dersom modellen har en.
kjeller: Option<usize>,
/// Laveste etasje over terreng, der etasjeskilleren mot kjelleren ligger.
forste_over: Option<usize>,
/// Høyden på den etasjen, m. Geometrien veier tyngre enn etasjetilknytningen, som kan
/// være feil i eksporten.
forste_over_hoyde: Option<f64>,
}
fn klassifiser(
@@ -1209,21 +1438,29 @@ fn klassifiser(
if d.ytre == Some(true) {
return Some((FlateType::Gulv, Mot::Ute));
}
// Mot grunnen bare i nederste etasje. Er etasjen ukjent, brukes høyden.
let nederst = match (d.etasje, e.nederste) {
(Some(a), Some(b)) => a == b,
_ => d
.plass
.map(|p| (p.2 - laveste).abs() < 0.5)
.unwrap_or(false),
};
if nederst || navn.contains("kjellergulv") {
// Mot grunnen i det nederste planet, eller når navnet sier det. Høyden av geometrien
// avgjør, siden etasjetilknytningen i en eksport kan være feil.
let paa_grunnen = [
"kjellergulv",
"on grade",
"på grunn",
"paa grunn",
"såle",
"sale",
];
if (d.z - laveste).abs() < 0.5 || paa_grunnen.iter().any(|o| navn.contains(o)) {
return Some((FlateType::Gulv, Mot::Grunn));
}
// Dekket i laveste etasje over en kjeller er grensen mot den uoppvarmede sonen.
if e.kjeller.is_some() && d.etasje.is_some() && d.etasje == e.forste_over {
if e.kjeller.is_some() {
let ved_forste_over = e
.forste_over_hoyde
.map(|h| (d.z - h).abs() < 0.6)
.unwrap_or(false);
if ved_forste_over || (d.etasje.is_some() && d.etasje == e.forste_over) {
return Some((FlateType::Gulv, Mot::Uoppvarmet));
}
}
// Dekker lenger opp er etasjeskillere inne i bygningen.
return None;
}
@@ -1590,6 +1827,11 @@ mod tester {
/// To yttervegger på 5 × 2,5 m med hver sin trekantgeometri, i millimeter.
/// Sørveggen ligger ved y = 5 m, nordveggen ved y = +5 m.
fn prove() -> String {
prove_med(&[(100, "Sorvegg", -5.0), (120, "Nordvegg", 5.0)])
}
/// Som `prove`, men med de veggene testen ber om: (entitetsnummer, navn, y i meter).
fn prove_med(vegger: &[(u64, &str, f64)]) -> String {
let mut s = String::from(
"ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n #1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n #2= IFCUNITASSIGNMENT((#1));\n #3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);\n #4= IFCDIRECTION((0.,1.));\n #40= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#50,#4);\n #5= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n #10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);\n #20= IFCLOCALPLACEMENT($,#21);\n #21= IFCAXIS2PLACEMENT3D(#22,$,$);\n #22= IFCCARTESIANPOINT((0.,0.,0.));\n #200= IFCPROPERTYSINGLEVALUE('ThermalTransmittance',$,IFCREAL(0.18),$);\n #201= IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$);\n #202= IFCPROPERTYSET('ps',$,'Pset_WallCommon',$,(#200,#201));\n #300= IFCSPACE('s',$,'Stue',$,$,#20,$,'Stue',.ELEMENT.,.INTERNAL.,$);\n #310= IFCQUANTITYAREA('NetFloorArea',$,$,100.0,$);\n #311= IFCQUANTITYVOLUME('NetVolume',$,$,250.0,$);\n #312= IFCELEMENTQUANTITY('q2',$,'BaseQuantities',$,$,(#310,#311));\n #313= IFCRELDEFINESBYPROPERTIES('r3',$,$,$,(#300),#312);\n",
);
@@ -1639,8 +1881,9 @@ mod tester {
p + 8
));
};
vegg(100, "Sorvegg", -5.0);
vegg(120, "Nordvegg", 5.0);
for (nr, navn, y) in vegger {
vegg(*nr, navn, *y);
}
s.push_str("ENDSEC;\nEND-ISO-10303-21;");
s
}
@@ -1686,6 +1929,45 @@ mod tester {
);
}
#[test]
fn samme_vegg_i_flere_etasjer_telles_en_gang() {
// Den samme veggen på nøyaktig samme sted to ganger, slik en eksport gjør når den
// legger de samme delene i flere etasjer.
let tekst = prove_med(&[
(100, "Sorvegg", -5.0),
(120, "Nordvegg", 5.0),
(140, "Sorvegg", -5.0),
]);
let r = fra_tekst(&tekst, "prove").unwrap();
assert_eq!(r.bygning.flate.len(), 2, "flater: {:?}", r.bygning.flate);
assert!(
r.merknader.iter().any(|m| m.contains("dobbelt i modellen")),
"{:?}",
r.merknader
);
}
#[test]
fn vegger_faar_himmelretning_i_navnet() {
let r = fra_tekst(&prove(), "prove").unwrap();
let mut navn: Vec<&str> = r.bygning.flate.iter().map(|f| f.navn.as_str()).collect();
navn.sort();
assert_eq!(navn, vec!["Nordvegg, nord", "Sorvegg, sør"]);
}
#[test]
fn uoppvarmede_rom_fra_tilleggsfilen_holdes_utenfor_bra() {
let valg = Importvalg {
uoppvarmede_rom: vec!["STUE".into()],
};
let r = fra_tekst_med(&prove(), "prove", &valg).unwrap();
assert_ne!(r.bygning.bygning.oppvarmet_bra, 100.0);
assert!(r
.merknader
.iter()
.any(|m| m.contains("uoppvarmet etter tilleggsfilen")));
}
/// Lengdeenheten skal hentes gjennom prosjektets enhetsoppsett, ikke ved å lete
/// gjennom alle enheter i filen. Prøvefilen har både millimeter og meter.
#[test]
@@ -1744,21 +2026,21 @@ mod tester {
fn adressen_hentes_fra_bygningen_og_ikke_fra_forfatteren() {
let t = prove().replace(
"#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);",
"#501= IFCPOSTALADDRESS($,$,$,$,('Hob\\X\\F8lveien 6'),$,'Tomter',$,'1825','Norge');\n\
#502= IFCORGANIZATION('976015711','Klingenberg Bygg',$,$,(#501));\n\
#503= IFCPERSON($,'Lynghaug','Fredrik',$,$,$,$,$);\n\
"#501= IFCPOSTALADDRESS($,$,$,$,('Firmaveien 1'),$,'Firmaby',$,'9999','Norge');\n\
#502= IFCORGANIZATION('999999999','Eksempel AS',$,$,(#501));\n\
#503= IFCPERSON($,'Nordmann','Kari',$,$,$,$,$);\n\
#504= IFCPERSONANDORGANIZATION(#503,#502,$);\n\
#500= IFCPOSTALADDRESS($,$,$,$,('Nordhusstykket 5'),$,'Tomter',$,'1825','Norge');\n\
#500= IFCPOSTALADDRESS($,$,$,$,('Eksempelveien 12'),$,'Eksempelby',$,'1234','Norge');\n\
#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,#500);",
);
let r = fra_tekst(&t, "prove").unwrap();
assert_eq!(
r.bygning.bygning.adresse.as_deref(),
Some("Nordhusstykket 5, 1825 Tomter")
Some("Eksempelveien 12, 1234 Eksempelby")
);
assert_eq!(
r.bygning.bygning.energiraadgiver.as_deref(),
Some("Fredrik Lynghaug, Klingenberg Bygg")
Some("Kari Nordmann, Eksempel AS")
);
}
@@ -1772,11 +2054,11 @@ mod tester {
)
.replace(
"#3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);",
"#3= IFCPROJECT('p',$,'Nordhusstykket 5','Energianalyse',$,'Nordhusstykket 5','Eksisterende',(#40),#2);",
"#3= IFCPROJECT('p',$,'Eksempelhuset','Energianalyse',$,'Eksempelhuset','Eksisterende',(#40),#2);",
);
let r = fra_tekst(&t, "prove").unwrap();
assert_eq!(r.bygning.bygning.adresse, None);
assert_eq!(r.bygning.bygning.navn, "Nordhusstykket 5");
assert_eq!(r.bygning.bygning.navn, "Eksempelhuset");
}
#[test]
+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,
};
+4 -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,
+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();
+197 -24
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)?;
@@ -210,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)
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}");
}
annen => bail!(
"ukjent filtype «{annen}». enok importerer .gbxml, .ifc og .csv (regneark fra Vectorworks)"
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 {
+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(),
}
}
+9 -1
View File
@@ -268,7 +268,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>,
@@ -397,6 +399,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>",
+39 -1
View File
@@ -530,9 +530,10 @@ 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 CERRA 19912020 (CC-BY-4.0).]\n",
i_dag(),
env!("CARGO_PKG_VERSION")
));
s
@@ -550,9 +551,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 +592,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);
}
+1 -1
View File
@@ -5,7 +5,7 @@ use enok::klima::geo::Posisjon;
use enok::klima::tmy::naermeste_kommune;
#[test]
fn naermeste_klimasted_for_tomter_er_indre_ostfold() {
fn naermeste_klimasted_i_indre_ostfold() {
let (kommune, km) = naermeste_kommune(&Posisjon {
bredde: 59.63,
lengde: 11.01,
+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)",
);