2 Commits
Author SHA1 Message Date
blandClaude Opus 5 fba5a7936d Velg klimasted ut fra georefereringen, og rett adresse fra IFC
Release enok / bygg (release) Successful in 1m20s
test / test (push) Successful in 1m26s
En ny eksport av Nordhusstykket 5 viste hva mer modellen kan bære, og avdekket feil.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-15 15:26:20 +02:00
15 changed files with 702 additions and 617 deletions
-76
View File
@@ -1,76 +0,0 @@
name: Energirapporter
# Lager energirapport for hver bygning i `bygninger/` og legger PDF, JSON og CSV ved
# utgivelsen. Se `bygninger/LES-MEG.md` for hvilke filtyper som kan ligge der.
on:
release:
types: [published]
workflow_dispatch:
jobs:
rapporter:
runs-on: fish
defaults:
run:
shell: fish {0}
steps:
- name: Hent koden
uses: https://gitea.com/actions/checkout@v6
- name: Sørg for en Rust-verktøykjede
run: |
# Kjørebrukeren har rustup, men ingen standardkjede er satt. Uten dette svarer
# cargo med at den ikke vet hvilken versjon den skal bruke.
if not cargo --version >/dev/null 2>&1
rustup default stable
end
cargo --version
- name: Bygg enok
run: cargo build --release --locked
- name: Importer BIM-filer som ikke har en TOML fra før
run: |
for f in (find bygninger -type f \( -iname '*.ifc' -o -iname '*.gbxml' -o -iname '*.csv' \) 2>/dev/null)
set toml (string replace -r '\.[^.]+$' '.toml' $f)
if not test -f $toml
echo "Importerer $f"
./target/release/enok import $f --ut $toml
end
end
- name: Beregn og lag rapporter
run: |
mkdir -p dist
set feil 0
for t in (find bygninger -type f -name '*.toml' 2>/dev/null)
set navn (basename $t .toml)
echo "::group::$navn"
if ./target/release/enok beregn $t --ut ut/$navn
cp ut/$navn/rapport.pdf dist/$navn-energirapport.pdf
cp ut/$navn/resultat.json dist/$navn-resultat.json
cp ut/$navn/maaned.csv dist/$navn-maaned.csv
gzip -c ut/$navn/timeserie.csv > dist/$navn-timeserie.csv.gz
else
echo "Beregningen av $navn feilet"
set feil 1
end
echo "::endgroup::"
end
ls -la dist
exit $feil
- name: Legg ved utgivelsen
if: github.event_name == 'release'
env:
TOKEN: ${{ github.token }}
run: |
set REL_ID (curl --silent --fail --header "Authorization: token $TOKEN" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" | jq -r '.id')
for f in (find dist -type f)
echo "Laster opp "(basename $f)
curl --silent --show-error --fail --header "Authorization: token $TOKEN" \
--form "attachment=@$f" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$REL_ID/assets?name="(basename $f)
end
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "enok" name = "enok"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"approx", "approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "enok" name = "enok"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
license = "MIT" license = "MIT"
+23 -13
View File
@@ -9,7 +9,7 @@ varmekilder og drift — og et typisk klimaår, og gir 8760 timesverdier, et ful
fra netto energibehov til vektet levert energi, en energikarakter og en ferdig rapport i PDF. fra netto energibehov til vektet levert energi, en energikarakter og en ferdig rapport i PDF.
``` ```
enok beregn bygninger/nordhusstykket-5.toml enok beregn bygning.toml
``` ```
``` ```
@@ -117,7 +117,7 @@ byggeaar = 1948
oppvarmet_bra = 140.0 oppvarmet_bra = 140.0
[klima] [klima]
kommune = "Indre Østfold" kommune = "Indre Østfold" # eller posisjon = [59.63, 11.01] for nærmeste klimasted
[[flate]] [[flate]]
navn = "Yttervegg sør" navn = "Yttervegg sør"
@@ -191,7 +191,12 @@ Ferdige eksempler ligger i `eksempler/`.
| Uoppvarmet kjeller | En etasje under referansenivået blir satt opp som egen sone, med sine egne vegger og gulv, og dekket over som grense mot den oppvarmede delen | | Uoppvarmet kjeller | En etasje under referansenivået blir satt opp som egen sone, med sine egne vegger og gulv, og dekket over som grense mot den oppvarmede delen |
| Oppvarmet BRA | Rommene i etasjene over terreng. Rom som heter kott, bod, loft eller garasje blir flagget | | Oppvarmet BRA | Rommene i etasjene over terreng. Rom som heter kott, bod, loft eller garasje blir flagget |
| Vindus- og dørareal | `OverallHeight` og `OverallWidth`, og hvilken vegg de sitter i | | Vindus- og dørareal | `OverallHeight` og `OverallWidth`, og hvilken vegg de sitter i |
| Posisjon | `IfcSite` med breddegrad og lengdegrad | | Posisjon | `IfcMapConversion` med `IfcProjectedCRS`, ellers `IfcSite`. Uten oppgitt kommune velger beregningen nærmeste klimasted. EUREF89 UTM og Web Mercator forstås |
| Grunnmur over terreng | `ElevationOfRefHeight` og `ElevationOfTerrain` på bygningen |
| Adresse | `BuildingAddress` på bygningen, eller `SiteAddress` på tomta. Adressen i forfatteropplysningene er firmaets og brukes ikke |
| Energirådgiver | Person og organisasjon i forfatteropplysningene |
| Byggets navn | Bygningens navn, eller prosjektets når bygningen heter «Default Building» |
| Vernet bygning | `IsLandmarked`. Vernede bygninger er unntatt plikten til energiattest |
Himmelretningene og arealene bør likevel kontrolleres mot tegningen. Importen skriver merknader Himmelretningene og arealene bør likevel kontrolleres mot tegningen. Importen skriver merknader
øverst i TOML-filen om hva den måtte anta. øverst i TOML-filen om hva den måtte anta.
@@ -221,15 +226,24 @@ egenskapssett på `IfcBuilding`, `IfcSite` eller `IfcProject`:
Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på Med de åtte feltene på plass er importen fra IFC nær nok komplett til at beregningen står på
modellen alene. modellen alene.
Fire ting i selve modellen er verdt å gjøre riktig: Dette i selve modellen er verdt å gjøre riktig, omtrent i rekkefølge etter hva det betyr:
- **Sett prosjektstedet.** Da får `IfcSite` en posisjon, og klimadataene kan velges etter den. - **Ta med designlagene i eksporten.** I «Export IFC Project» må lagene være knyttet til
Uten det står Vectorworks' standardverdi, som ligger i Washington. etasjer under «Layer Mapping». Mangler de, blir filen bare prosjektstruktur uten vegger,
dekker, vinduer eller rom. enok stopper da med en feilmelding i stedet for å skrive en tom
bygning.
- **Georeferer tegningen, eller sett prosjektstedet.** Da får modellen koordinater, og
beregningen velger selv nærmeste av de 358 klimastedene. Uten det står Vectorworks'
standardverdi i Washington, og den blir oversett.
- **Merk yttervegger med `IsExternal`.** I modellen for Nordhusstykket 5 er bare tolv av - **Merk yttervegger med `IsExternal`.** I modellen for Nordhusstykket 5 er bare tolv av
hundre og fem vegger merket, og kjellermurene er ikke blant dem. hundre og fem vegger merket, og kjellermurene er ikke blant dem.
- **Fyll inn U-verdi for tak og vinduer.** Veggene har den, takflatene og vinduene ikke. - **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 - **Gi etasjene navn.** «Kjeller», «1. etg.» og «2. etg.» er nok til at enok finner den
uoppvarmede kjelleren og regner oppvarmet areal riktig. uoppvarmede kjelleren og regner oppvarmet areal riktig.
- **Sett referansehøyde og terrenghøyde på bygningen.** Da regner enok ut hvor mye av
grunnmuren som står over bakken. Uten dem regnes hele muren som nedgravd.
- **Legg byggets adresse på bygningen.** Adressen i forfatteropplysningene er firmaets, og
brukes ikke som byggets adresse.
Regnearkformatet er bevisst enkelt, slik at det kan settes opp som en rapport i BIM-verktøyet: Regnearkformatet er bevisst enkelt, slik at det kan settes opp som en rapport i BIM-verktøyet:
@@ -243,22 +257,18 @@ vindu,Stue sør,3.0,2.8,180,90,,0.76,2
## Rapporter fra byggeserveren ## Rapporter fra byggeserveren
Arbeidsflytene i `.gitea/workflows/` kjører på vertskjøreren `fish` og utløses når en Rapportene lages i hvert prosjektlager, ikke i enok. enoks egne arbeidsflyter kjører på
utgivelse publiseres, på samme måte som de andre prosjektene. vertskjøreren `fish` på samme måte som de andre prosjektene:
| Fil | Hva den gjør | | Fil | Hva den gjør |
| --- | --- | | --- | --- |
| `test.yml` | Formatering, clippy og tester på hver push | | `test.yml` | Formatering, clippy og tester på hver push |
| `release.yml` | Bygger enok og legger binærfilen ved utgivelsen | | `release.yml` | Bygger enok og legger binærfilen ved utgivelsen |
| `rapport.yml` | Lager energirapport for hver bygning i `bygninger/` og legger PDF, JSON og CSV ved utgivelsen |
IFC-, gbXML- og CSV-filer i `bygninger/` importeres automatisk til TOML dersom det ikke
allerede finnes en TOML-fil med samme navn. Se `bygninger/LES-MEG.md`.
### Energirapport fra et byggeprosjekt ### Energirapport fra et byggeprosjekt
Et prosjektlager med sin egen IFC-modell og `bygning.toml` trenger ikke Rust. Det henter Et prosjektlager med sin egen IFC-modell og `bygning.toml` trenger ikke Rust. Det henter
binærfilen fra enoks utgivelse: binærfilen fra enoks siste utgivelse:
```yaml ```yaml
name: Energirapport name: Energirapport
-18
View File
@@ -1,18 +0,0 @@
# Bygninger
Legg én fil per bygning her. Byggeserveren regner ut energirapporten og legger PDF, JSON og
CSV ved utgivelsen når du setter en tagg som begynner med `v` eller `rapport-`.
| Filtype | Kommer fra |
| -------- | --------------------------------------------------- |
| `.toml` | Bygningsbeskrivelse skrevet for hånd, eller importert |
| `.ifc` | Vectorworks: *Fil → Eksporter → Export IFC Project* |
| `.gbxml` | BIM-verktøy som eksporterer gbXML |
| `.csv` | Vectorworks: *Fil → Eksporter → Export Worksheet* |
IFC-, gbXML- og CSV-filer blir importert til TOML automatisk dersom det ikke allerede finnes
en TOML-fil med samme navn. Importen får med geometri, U-verdier og himmelretninger, men ikke
ventilasjon, lekkasjetall eller varmekilder. Åpne den importerte TOML-filen, fyll ut det som
mangler, og sjekk den inn — da brukes den i stedet for importen neste gang.
Eksempler på ferdige bygningsbeskrivelser ligger i `../eksempler/`.
File diff suppressed because one or more lines are too long
-457
View File
@@ -1,457 +0,0 @@
# Importert med enok. Fyll ut feltene som er merket nedenfor.
# Oppvarmet BRA er satt til 139.5 m², summen av rommene i etasjene over terreng. Rom [m²]: Stue 39.5, Kjøkken 22.6, Soverom 15.4, Gang/trapperom 11.2, Entré 10.6, Soverom 9.3, Soverom 9.2, Kott 7.3, Kott 4.6, Kott 4.1, Kott 3.6, Vindfang 1.2, Toalett 0.9.
# Disse rommene er regnet som oppvarmet, men navnet tyder på at de ikke er det: Kott 3.6 m², Kott 4.1 m², Kott 7.3 m², Kott 4.6 m². Trekk dem fra oppvarmet_bra om de står uoppvarmet.
# 30 bygningsdeler er utelatt fordi geometrien ikke ga noe areal.
# 17 bygningsdeler mangler U-verdi i IFC-filen. Legg inn U-verdier, eller la enok bruke typiske verdier for byggeåret.
# Arealene er regnet av geometrien: trekantene er gruppert etter retning, og den største gruppen er delens hovedflate. Himmelretningene følger flatenormalen. Kontroller begge mot tegningen.
# IFC beskriver ikke drift, lekkasjetall eller energiforsyning. Fyll inn [ventilasjon], [infiltrasjon], [oppvarming], [tappevann] og [klima] før beregning.
# Modellen har egenskaper for drift og forsyning, og disse er tatt i bruk: YearOfConstruction.
# Etasjen «Kjeller» ligger under terreng og er satt opp som en uoppvarmet sone med 75 m² gulv. Kjellerveggene er regnet 2.4 m under terreng uten noe over. Står en del av grunnmuren over bakken, sett `over_terreng` i meter på veggene; utslaget er lite, om lag én prosent per meter.
# Er kjelleren oppvarmet likevel, slett [[sone]] og legg flatene inn i klimaskjermen. Går det uisolerte varmerør der, sett `andel_distribusjonstap = 1.0` på sonen.
# Modellen oppgir posisjon 38.8894° nord, -77.0353° øst. Kontroller at den stemmer, og sett `kommune` under [klima] deretter.
[bygning]
navn = "nordhusstykket-5"
kategori = "smaahus"
byggeaar = 1950
oppvarmet_bra = 139.533
antall_boenheter = 1
beskrivelse = "Importert fra IFC"
[klima]
[[flate]]
navn = "Roof Slab"
type = "tak"
areal = 130.11978717725776
orientering = 0.0
helning = 14.999984009080658
masseklasse = "middels"
masseplassering = "fordelt"
mot = "ute"
kommentar = "importert fra IFC"
[[flate]]
navn = "Etasjeskille"
type = "gulv"
areal = 77.04260758445601
orientering = 0.0
helning = 0.0
u_verdi = 0.31
masseklasse = "lett"
masseplassering = "fordelt"
mot = "uoppvarmet"
sone = "Kjeller"
kommentar = "1. etg., importert fra IFC"
[[flate]]
navn = "Yttervegg panel ute og porøs huntonitt inne"
type = "vegg"
areal = 55.719888250487756
orientering = 180.0
helning = 90.0
u_verdi = 0.93
masseplassering = "fordelt"
mot = "ute"
kommentar = "2. etg., importert fra IFC"
[[flate]]
navn = "Yttervegg panel ute og porøs huntonitt inne"
type = "vegg"
areal = 59.50176490568849
orientering = 270.0
helning = 90.0
u_verdi = 0.93
masseklasse = "lett"
masseplassering = "fordelt"
mot = "ute"
kommentar = "1. etg., importert fra IFC"
[[flate]]
navn = "Yttervegg panel ute og porøs huntonitt inne"
type = "vegg"
areal = 41.833542336937
orientering = 0.0
helning = 90.0
u_verdi = 0.93
masseklasse = "lett"
masseplassering = "fordelt"
mot = "ute"
kommentar = "1. etg., importert fra IFC"
[[flate]]
navn = "Slab on Grade-100mm"
type = "gulv"
areal = 16.3663225
orientering = 0.0
helning = 0.0
u_verdi = 3.51
masseklasse = "tung"
masseplassering = "fordelt"
mot = "uoppvarmet"
sone = "Kjeller"
kommentar = "1. etg., importert fra IFC"
[[flate]]
navn = "Yttervegg panel ute og porøs huntonitt inne"
type = "vegg"
areal = 54.21918162153324
orientering = 90.0
helning = 90.0
u_verdi = 0.93
masseklasse = "lett"
masseplassering = "fordelt"
mot = "ute"
kommentar = "2. etg., importert fra IFC"
[[flate]]
navn = "Etasjeskille-loft"
type = "tak"
areal = 32.594422635
orientering = 0.0
helning = 0.0
u_verdi = 0.45
masseklasse = "lett"
masseplassering = "fordelt"
mot = "uoppvarmet"
kommentar = "2. etg., importert fra IFC"
[[sone]]
navn = "Kjeller"
volum = 176.1231
fast_effekt = 0.0
kommentar = "Etasje «Kjeller» ligger 2.35 m under referansenivået. Rom [m²]: Bod 10.4, Gang 11.1, Rom 21.1, Teknisk rom 2.2, Bod 9.3, Vaskrom 9.8, Vaskrom 11.1"
[[sone.flate]]
navn = "Kjellermur med treullsement-2"
type = "vegg"
areal = 1.4834999999999998
orientering = 90.0
helning = 90.0
u_verdi = 0.46
masseklasse = "lett"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 25 cm uisolert"
type = "vegg"
areal = 16.33814305
orientering = 90.0
helning = 90.0
u_verdi = 3.39
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert"
type = "vegg"
areal = 22.69398933457598
orientering = 180.0
helning = 90.0
u_verdi = 3.13
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert"
type = "vegg"
areal = 15.028989334576002
orientering = 270.0
helning = 90.0
u_verdi = 3.13
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 25 cm uisolert"
type = "vegg"
areal = 0.33587572106361
orientering = 135.0
helning = 90.0
u_verdi = 3.39
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 25 cm uisolert"
type = "vegg"
areal = 14.155249999999999
orientering = 0.0
helning = 90.0
u_verdi = 3.39
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 25 cm uisolert"
type = "vegg"
areal = 7.481249999999999
orientering = 180.0
helning = 90.0
u_verdi = 3.39
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 25 cm uisolert"
type = "vegg"
areal = 2.35212255
orientering = 270.0
helning = 90.0
u_verdi = 3.39
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert"
type = "vegg"
areal = 9.688000000000002
orientering = 90.0
helning = 90.0
u_verdi = 3.13
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellergulv"
type = "gulv"
areal = 78.88690000000003
orientering = 0.0
helning = 0.0
u_verdi = 3.85
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur 30 cm uisolert"
type = "vegg"
areal = 21.215968003727976
orientering = 0.0
helning = 90.0
u_verdi = 3.13
masseklasse = "tung"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[[sone.flate]]
navn = "Kjellermur med treullsement-2"
type = "vegg"
areal = 0.731
orientering = 0.0
helning = 90.0
u_verdi = 0.46
masseklasse = "lett"
masseplassering = "fordelt"
mot = "grunn"
dybde = 2.35
over_terreng = 0.0
kommentar = "Kjeller, importert fra IFC"
[grunn]
[[vindu]]
navn = "V-5 #3833"
areal = 0.8400000000000001
antall = 1.0
orientering = 180.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "W-01 #16334"
areal = 0.45
antall = 1.0
orientering = 0.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "W-01 #16303"
areal = 0.45
antall = 1.0
orientering = 0.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-3 #11303"
areal = 1.4300000000000002
antall = 1.0
orientering = 89.92370720902136
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-3 #11350"
areal = 1.4300000000000002
antall = 1.0
orientering = 89.92370720902136
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-2 #3207"
areal = 1.56
antall = 1.0
orientering = 0.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-2 #3882"
areal = 1.56
antall = 1.0
orientering = 180.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-4 #10945"
areal = 1.0450000000000002
antall = 1.0
orientering = 270.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-2 #3110"
areal = 1.56
antall = 1.0
orientering = 0.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-2 #3160"
areal = 1.56
antall = 1.0
orientering = 0.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "V-2 #3730"
areal = 1.56
antall = 1.0
orientering = 180.0
helning = 90.0
u_verdi = 2.4
dor = false
kommentar = "importert fra IFC"
[[vindu]]
navn = "VD-1 #3549"
areal = 2.9400000000000004
antall = 1.0
orientering = 89.92370720902136
helning = 90.0
u_verdi = 2.0
dor = true
kommentar = "importert fra IFC"
[[vindu]]
navn = "D-01 #3801"
areal = 1.9110000000000003
antall = 1.0
orientering = 180.0
helning = 90.0
u_verdi = 2.0
dor = true
kommentar = "importert fra IFC"
[[vindu]]
navn = "VD-2 #10977"
areal = 1.4625
antall = 1.0
orientering = 270.0
helning = 90.0
u_verdi = 2.0
dor = true
kommentar = "importert fra IFC"
[kuldebroer]
[infiltrasjon]
skjerming = "moderat"
[ventilasjon]
type = "naturlig"
[tappevann]
system = "aapent"
[oppvarming]
[distribusjon]
type = "punkt"
[kjoling]
ingen = true
[lufting]
ingen = false
[drift]
+351 -30
View File
@@ -11,6 +11,7 @@
use crate::inndata::ifcgeom; use crate::inndata::ifcgeom;
use crate::inndata::step::{self, Arg, Entitet, Fil}; use crate::inndata::step::{self, Arg, Entitet, Fil};
use crate::klima::geo;
use crate::modell::*; use crate::modell::*;
use crate::Result; use crate::Result;
use anyhow::Context; use anyhow::Context;
@@ -49,6 +50,35 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
let (vindu_vert, dor_vert) = samle_apninger(&fil); let (vindu_vert, dor_vert) = samle_apninger(&fil);
let (etasjer, etasje_for) = les_etasjer(&fil, skala); let (etasjer, etasje_for) = les_etasjer(&fil, skala);
// En eksport uten bygningsdeler gir ingen klimaskjerm å regne på. Det skjer typisk når
// designlagene ikke er knyttet til etasjer i eksportdialogen, og da er det bedre å stoppe
// enn å skrive en tom bygningsbeskrivelse.
let antall_deler: usize = [
"IFCWALL",
"IFCWALLSTANDARDCASE",
"IFCCURTAINWALL",
"IFCSLAB",
"IFCROOF",
"IFCWINDOW",
"IFCDOOR",
"IFCSPACE",
]
.iter()
.map(|t| fil.av_type(t).count())
.sum();
if antall_deler == 0 {
anyhow::bail!(
"IFC-filen har ingen bygningsdeler: ingen vegger, dekker, tak, vinduer, dører eller rom. \
Den inneholder bare prosjektstrukturen{}. I Vectorworks skjer dette gjerne når designlagene \
ikke er med under «Layer Mapping» i «Export IFC Project».",
if etasjer.is_empty() {
String::new()
} else {
format!(" med {} etasjer", etasjer.len())
}
);
}
// ── oppvarmet areal fra rommene ───────────────────────────────── // ── oppvarmet areal fra rommene ─────────────────────────────────
// Rom i en etasje under terreng hører til kjelleren og ikke til oppvarmet BRA. // Rom i en etasje under terreng hører til kjelleren og ikke til oppvarmet BRA.
let mut bra = 0.0; let mut bra = 0.0;
@@ -177,6 +207,14 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
.unwrap_or(0.0); .unwrap_or(0.0);
// Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger. // Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger.
let forste_over = etasjer.iter().position(|e| !e.under_terreng); 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))) {
(Some((referanse, terrengnivaa)), Some(forste)) => {
over_terreng_for_kjeller(referanse, terrengnivaa, forste.hoyde, kjellerdybde)
}
_ => 0.0,
};
// ── klimaskjerm ───────────────────────────────────────────────── // ── klimaskjerm ─────────────────────────────────────────────────
let mut kjellerflater: Vec<Flate> = Vec::new(); let mut kjellerflater: Vec<Flate> = Vec::new();
@@ -245,8 +283,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
b_faktor: None, b_faktor: None,
absorptans: None, absorptans: None,
omkrets: None, omkrets: None,
dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde), dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde - kjeller_over_terreng),
over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(0.0), over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(kjeller_over_terreng),
sone, sone,
kommentar: d kommentar: d
.etasje .etasje
@@ -407,13 +445,45 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
); );
} }
let ubrukelig = |s: &str| {
let s = s.trim();
s.is_empty() || s == "Default Building" || s == "Vectorworks Project"
};
// Bygningens navn, ellers prosjektets langnavn eller navn, ellers filnavnet.
let navn = fil let navn = fil
.av_type("IFCBUILDING") .av_type("IFCBUILDING")
.next() .next()
.and_then(|(_, e)| e.navn().map(|s| s.to_string())) .and_then(|(_, e)| e.navn().map(|s| s.to_string()))
.filter(|s| !s.is_empty() && s != "Default Building") .filter(|s| !ubrukelig(s))
.or_else(|| {
fil.av_type("IFCPROJECT").next().and_then(|(_, p)| {
[p.arg(5), p.arg(2)]
.into_iter()
.flatten()
.filter_map(|a| a.som_tekst())
.find(|s| !ubrukelig(s))
.map(|s| s.trim().to_string())
})
})
.unwrap_or_else(|| standardnavn.to_string()); .unwrap_or_else(|| standardnavn.to_string());
// Vernede bygninger er unntatt plikten til å ha energiattest.
let vernet = fil
.av_type("IFCBUILDING")
.next()
.and_then(|(r, _)| egenskaper.get(&r))
.and_then(|e| e.get("IsLandmarked"))
.map(|a| matches!(a.som_tekst(), Some("T") | Some("TRUE") | Some("true")))
.unwrap_or(false);
if vernet {
merknader.push(
"Bygningen er merket som vernet i modellen. Vernede bygninger etter kulturminneloven og \
plan- og bygningsloven er unntatt plikten til å ha energiattest, men beregningen kan \
fortsatt brukes til å vurdere tiltak."
.into(),
);
}
// Kjellersonen settes sammen av flatene i etasjen under terreng. // Kjellersonen settes sammen av flatene i etasjen under terreng.
let mut soner: Vec<Sone> = Vec::new(); let mut soner: Vec<Sone> = Vec::new();
if let Some(i) = kjelleretasje { if let Some(i) = kjelleretasje {
@@ -442,11 +512,22 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
rom.join(", ") rom.join(", ")
)), )),
}); });
let grunnmur = if terreng.is_some() {
format!(
"Etter terrenghøyden i modellen står grunnmuren {kjeller_over_terreng:.2} m over bakken og \
{:.2} m under.",
kjellerdybde - kjeller_over_terreng
)
} else {
format!(
"Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Står en del av \
grunnmuren over bakken, sett `over_terreng` i meter på veggene, eller sett terrenghøyden \
på bygningen i modellen. Utslaget er lite, om lag én prosent per meter."
)
};
merknader.push(format!( merknader.push(format!(
"Etasjen «{}» ligger under terreng og er satt opp som en uoppvarmet sone med {:.0} m² gulv. \ "Etasjen «{}» ligger under terreng og er satt opp som en uoppvarmet sone med {:.0} m² gulv. \
Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Står en del av \ {grunnmur}",
grunnmuren over bakken, sett `over_terreng` i meter på veggene; utslaget er lite, om lag \
én prosent per meter.",
etasjer[i].navn, kjellerareal etasjer[i].navn, kjellerareal
)); ));
merknader.push("Er kjelleren oppvarmet likevel, slett [[sone]] og legg flatene inn i klimaskjermen. \ merknader.push("Er kjelleren oppvarmet likevel, slett [[sone]] og legg flatene inn i klimaskjermen. \
@@ -454,18 +535,41 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
} }
} }
if let Some((lat, lon)) = posisjon(&fil) { let klimaposisjon = match posisjon(&fil) {
merknader.push(format!( Some(p) if p.er_standardplassering() => {
"Modellen oppgir posisjon {lat:.4}° nord, {lon:.4}° øst. Kontroller at den stemmer, og sett \
`kommune` under [klima] deretter."
));
} else {
merknader.push( merknader.push(
"Modellen oppgir ingen posisjon. Settes prosjektstedet i Vectorworks, kommer det med som \ "Modellen står på Vectorworks' standardplassering i Washington, så posisjonen er ikke \
IfcSite og kan brukes til å velge klimadata." brukt. Georefereres tegningen eller settes prosjektstedet, velger beregningen \
klimasted ut fra den."
.into(), .into(),
); );
None
} }
Some(p) if !p.i_norge() => {
merknader.push(format!(
"Modellen oppgir posisjon {:.4}° nord, {:.4}° øst, som ligger utenfor Norge. \
Posisjonen er ikke brukt til å velge klimasted.",
p.bredde, p.lengde
));
None
}
Some(p) => {
merknader.push(format!(
"Modellen oppgir posisjon {:.4}° nord, {:.4}° øst. Uten kommune velger beregningen \
klimastedet nærmest denne.",
p.bredde, p.lengde
));
Some([p.bredde, p.lengde])
}
None => {
merknader.push(
"Modellen oppgir ingen posisjon. Georefereres tegningen eller settes prosjektstedet, \
velger beregningen klimasted ut fra den."
.into(),
);
None
}
};
let bygning = Bygning { let bygning = Bygning {
bygning: BygningInfo { bygning: BygningInfo {
@@ -485,11 +589,12 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
adresse: adresse(&fil), adresse: adresse(&fil),
gnr_bnr: None, gnr_bnr: None,
beskrivelse: Some("Importert fra IFC".into()), beskrivelse: Some("Importert fra IFC".into()),
energiraadgiver: None, energiraadgiver: forfatter(&fil),
oppdragsgiver: None, oppdragsgiver: None,
}, },
klima: Klima { klima: Klima {
kommune: data.kommune.clone(), kommune: data.kommune.clone(),
posisjon: klimaposisjon,
..Klima::default() ..Klima::default()
}, },
flate: flater, flate: flater,
@@ -847,9 +952,24 @@ fn les_byggdata(fil: &Fil, egenskaper: &Egenskaper) -> (Byggdata, Vec<String>) {
(d, funnet) (d, funnet)
} }
/// Byggets posisjon fra `IfcSite`, som grader nord og øst. /// Byggets posisjon.
fn posisjon(fil: &Fil) -> Option<(f64, f64)> { ///
let (_, e) = fil.av_type("IFCSITE").next()?; /// Georeferering gjennom `IfcMapConversion` og `IfcProjectedCRS` brukes først, siden den er
/// mest presis. Ellers brukes breddegrad og lengdegrad på `IfcSite`. Vectorworks fyller begge
/// med en standardplassering i Washington når ingenting er satt, og den velges bare når ingen
/// annen posisjon finnes, slik at den kan kjennes igjen og oversees.
fn posisjon(fil: &Fil) -> Option<geo::Posisjon> {
let fra_kart = fil.av_type("IFCMAPCONVERSION").next().and_then(|(_, m)| {
let crs = m
.arg(1)
.and_then(|a| a.som_ref())
.and_then(|r| fil.hent(r))?;
let navn = crs.arg(0).and_then(|a| a.som_tekst())?;
let x = m.arg(2).and_then(|a| a.som_tall())?;
let y = m.arg(3).and_then(|a| a.som_tall())?;
geo::fra_koordinatsystem(navn, x, y)
});
let fra_tomt = fil.av_type("IFCSITE").next().and_then(|(_, e)| {
let grad = |a: Option<&Arg>| -> Option<f64> { let grad = |a: Option<&Arg>| -> Option<f64> {
let l = a?.som_liste(); let l = a?.som_liste();
if l.is_empty() { if l.is_empty() {
@@ -859,7 +979,39 @@ fn posisjon(fil: &Fil) -> Option<(f64, f64)> {
let verdi = g(0).abs() + g(1).abs() / 60.0 + g(2).abs() / 3600.0 + g(3).abs() / 3.6e9; let verdi = g(0).abs() + g(1).abs() / 60.0 + g(2).abs() / 3600.0 + g(3).abs() / 3.6e9;
Some(if g(0) < 0.0 { -verdi } else { verdi }) Some(if g(0) < 0.0 { -verdi } else { verdi })
}; };
Some((grad(e.arg(9))?, grad(e.arg(10))?)) Some(geo::Posisjon {
bredde: grad(e.arg(9))?,
lengde: grad(e.arg(10))?,
})
});
[fra_kart, fra_tomt]
.into_iter()
.flatten()
.find(|p| !p.er_standardplassering())
.or(fra_kart)
.or(fra_tomt)
}
/// Referansehøyden og terrenghøyden på bygningen, begge over havet, m.
fn terrenghoyder(fil: &Fil, skala: f64) -> Option<(f64, f64)> {
let (_, e) = fil.av_type("IFCBUILDING").next()?;
let referanse = e.arg(9).and_then(|a| a.som_tall())? * skala;
let terreng = e.arg(10).and_then(|a| a.som_tall())? * skala;
Some((referanse, terreng))
}
/// Hvor mye av grunnmuren som står over bakken, m.
///
/// Toppen av grunnmuren ligger ved gulvet i første etasje over kjelleren. Den står over
/// terrenget med differansen mellom etasjens høyde over havet og terrengets, begrenset til
/// kjellerens høyde.
fn over_terreng_for_kjeller(
referanse: f64,
terreng: f64,
forste_etasje: f64,
kjellerdybde: f64,
) -> f64 {
((referanse + forste_etasje) - terreng).clamp(0.0, kjellerdybde.max(0.0))
} }
/// En bygningsdel slik den er lest av IFC-filen. /// En bygningsdel slik den er lest av IFC-filen.
@@ -1097,23 +1249,93 @@ fn navn_av(e: &Entitet, r: u64, standard: &str) -> String {
} }
} }
/// Byggets adresse fra `IfcBuilding.BuildingAddress`, eller fra tomta.
///
/// Adresser knyttet til personer og organisasjoner er forfatterens, ikke byggets, og brukes
/// ikke. Plassholdere uten gate eller poststed, som Vectorworks setter inn, hoppes over.
fn adresse(fil: &Fil) -> Option<String> { fn adresse(fil: &Fil) -> Option<String> {
let (_, e) = fil.av_type("IFCPOSTALADDRESS").next()?; let bygning = fil
.av_type("IFCBUILDING")
.filter_map(|(_, e)| e.arg(11).and_then(|a| a.som_ref()));
let tomt = fil
.av_type("IFCSITE")
.filter_map(|(_, e)| e.arg(13).and_then(|a| a.som_ref()));
bygning
.chain(tomt)
.filter_map(|r| fil.hent(r))
.filter(|e| e.typ == "IFCPOSTALADDRESS")
.find_map(formater_adresse)
}
/// `IfcPostalAddress` har feltene Purpose, Description, UserDefinedPurpose, InternalLocation,
/// AddressLines, PostalBox, Town, Region, PostalCode og Country.
fn formater_adresse(e: &Entitet) -> Option<String> {
let linjer: Vec<String> = e let linjer: Vec<String> = e
.arg(4)? .arg(4)
.som_liste() .map(|a| a.som_liste())
.unwrap_or(&[])
.iter() .iter()
.filter_map(|a| a.som_tekst().map(|s| s.to_string())) .filter_map(|a| a.som_tekst())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(); .collect();
let by = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or(""); let postnummer = e.arg(8).and_then(|a| a.som_tekst()).unwrap_or("").trim();
let mut ut = linjer.join(", "); let sted = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or("").trim();
if !by.is_empty() { if linjer.is_empty() && sted.is_empty() {
if !ut.is_empty() { return None;
ut.push_str(", ");
} }
ut.push_str(by); let poststed: Vec<&str> = [postnummer, sted]
.into_iter()
.filter(|s| !s.is_empty())
.collect();
Some(
linjer
.into_iter()
.chain((!poststed.is_empty()).then(|| poststed.join(" ")))
.collect::<Vec<_>>()
.join(", "),
)
}
/// Forfatteren av modellen, som person og organisasjon.
///
/// I en modell laget for energirådgivning er det energirådgiveren. Vectorworks' egen
/// organisasjon, som står når ingenting er fylt inn, tas ikke med.
fn forfatter(fil: &Fil) -> Option<String> {
let (_, po) = fil.av_type("IFCPERSONANDORGANIZATION").next()?;
let person = po
.arg(0)
.and_then(|a| a.som_ref())
.and_then(|r| fil.hent(r));
let organisasjon = po
.arg(1)
.and_then(|a| a.som_ref())
.and_then(|r| fil.hent(r));
// IfcPerson: Identification, FamilyName, GivenName, …
let personnavn = person
.map(|p| {
[p.arg(2), p.arg(1)]
.into_iter()
.flatten()
.filter_map(|a| a.som_tekst())
.map(str::trim)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
})
.filter(|s| !s.is_empty());
// IfcOrganization: Identification, Name, …
let organisasjonsnavn = organisasjon
.and_then(|o| o.arg(1).and_then(|a| a.som_tekst()))
.map(str::trim)
.filter(|s| !s.is_empty() && !s.to_lowercase().contains("vectorworks"))
.map(str::to_string);
match (personnavn, organisasjonsnavn) {
(Some(p), Some(o)) => Some(format!("{p}, {o}")),
(Some(p), None) => Some(p),
(None, Some(o)) => Some(o),
(None, None) => None,
} }
(!ut.is_empty()).then_some(ut)
} }
/// Prosjektets dreining mot sant nord, i grader med klokka. /// Prosjektets dreining mot sant nord, i grader med klokka.
@@ -1500,6 +1722,105 @@ mod tester {
assert!(r.merknader.iter().any(|m| m.contains("tatt i bruk"))); assert!(r.merknader.iter().any(|m| m.contains("tatt i bruk")));
} }
#[test]
fn eksport_uten_bygningsdeler_gir_tydelig_feil() {
let tom = "ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n\
#1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n\
#2= IFCUNITASSIGNMENT((#1));\n\
#3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,$,#2);\n\
#10= IFCBUILDING('b',$,'Hus',$,$,$,$,$,.ELEMENT.,$,$,$);\n\
#11= IFCBUILDINGSTOREY('k',$,'Kjeller',$,$,$,$,$,.ELEMENT.,-2350.);\n\
ENDSEC;\nEND-ISO-10303-21;";
let feil = fra_tekst(tom, "tom")
.err()
.expect("en tom eksport skal gi feil");
let tekst = feil.to_string();
assert!(tekst.contains("ingen bygningsdeler"), "{tekst}");
assert!(tekst.contains("1 etasjer"), "{tekst}");
}
/// Adressen på firmaet som laget modellen skal ikke bli byggets adresse.
#[test]
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\
#504= IFCPERSONANDORGANIZATION(#503,#502,$);\n\
#500= IFCPOSTALADDRESS($,$,$,$,('Nordhusstykket 5'),$,'Tomter',$,'1825','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")
);
assert_eq!(
r.bygning.bygning.energiraadgiver.as_deref(),
Some("Fredrik Lynghaug, Klingenberg Bygg")
);
}
#[test]
fn plassholderadresse_og_standardnavn_brukes_ikke() {
let t = prove()
.replace(
"#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);",
"#500= IFCPOSTALADDRESS($,$,$,'Vectorworks Architect',$,$,$,$,$,$);\n\
#10= IFCBUILDING('b',$,'Default Building',$,$,#20,$,$,.ELEMENT.,$,$,#500);",
)
.replace(
"#3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);",
"#3= IFCPROJECT('p',$,'Nordhusstykket 5','Energianalyse',$,'Nordhusstykket 5','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");
}
#[test]
fn georeferering_i_utm_gir_posisjon_for_klimavalg() {
let t = prove().replace(
"#4= IFCDIRECTION((0.,1.));",
"#4= IFCDIRECTION((0.,1.));\n\
#600= IFCPROJECTEDCRS('EPSG:25832','ETRS89 / UTM zone 32N','ETRS89',$,$,$,$);\n\
#601= IFCMAPCONVERSION(#40,#600,613351.291,6611921.980,0.,1.,0.,$);",
);
let r = fra_tekst(&t, "prove").unwrap();
let [b, l] = r
.bygning
.klima
.posisjon
.expect("posisjon fra georefereringen");
assert!(
(b - 59.63).abs() < 1e-4 && (l - 11.01).abs() < 1e-4,
"{b}, {l}"
);
}
#[test]
fn vectorworks_standardplassering_brukes_ikke() {
let t = prove().replace(
"#4= IFCDIRECTION((0.,1.));",
"#4= IFCDIRECTION((0.,1.));\n\
#600= IFCPROJECTEDCRS('EPSG:3857',$,'WGS 84',$,$,$,$);\n\
#601= IFCMAPCONVERSION(#40,#600,-8575524.35228859,4705852.25746973,0.,1.,0.,$);",
);
let r = fra_tekst(&t, "prove").unwrap();
assert_eq!(r.bygning.klima.posisjon, None);
assert!(r.merknader.iter().any(|m| m.contains("standardplassering")));
}
#[test]
fn grunnmur_over_terreng_av_hoydene() {
// ±0 ligger 131,2 moh., terrenget 130,6 moh. og første etasje på ±0: 0,6 m over bakken.
assert!((over_terreng_for_kjeller(131.2, 130.6, 0.0, 2.35) - 0.6).abs() < 1e-9);
// Aldri mer enn kjellerhøyden, og aldri under null.
assert_eq!(over_terreng_for_kjeller(131.2, 125.0, 0.0, 2.35), 2.35);
assert_eq!(over_terreng_for_kjeller(130.0, 131.0, 0.0, 2.35), 0.0);
}
#[test] #[test]
fn vegg_uten_geometri_utelates() { fn vegg_uten_geometri_utelates() {
let uten = prove().replace("'Body','Tessellation'", "'Axis','Curve2D'"); let uten = prove().replace("'Body','Tessellation'", "'Axis','Curve2D'");
+22 -5
View File
@@ -112,11 +112,14 @@ pub fn hent_klima(b: &Bygning) -> Result<(Vaerdata, String, bool)> {
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA); == crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
return Ok((v, navn, er_referanse)); return Ok((v, navn, er_referanse));
} }
let sted = b // Uten kommune, men med posisjon, velges klimastedet nærmest posisjonen.
.klima let sted = match (&b.klima.kommune, b.klima.posisjon) {
.kommune (Some(k), _) => k.clone(),
.clone() (None, Some([bredde, lengde])) => {
.unwrap_or_else(|| REFERANSEKLIMA.to_string()); tmy::naermeste_kommune(&crate::klima::geo::Posisjon { bredde, lengde })?.0
}
(None, None) => REFERANSEKLIMA.to_string(),
};
let er_referanse = crate::data::normaliser_stedsnavn(&sted) let er_referanse = crate::data::normaliser_stedsnavn(&sted)
== crate::data::normaliser_stedsnavn(REFERANSEKLIMA); == crate::data::normaliser_stedsnavn(REFERANSEKLIMA);
let v = tmy::for_kommune(&sted)?; let v = tmy::for_kommune(&sted)?;
@@ -140,6 +143,20 @@ pub fn kjor_med_klima(
let varmetap = Varmetapsbudsjett::beregn(&lost); let varmetap = Varmetapsbudsjett::beregn(&lost);
let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?; let systemer_oppsett = systemer::sett_opp_systemer(b, &lost, kilde)?;
let mut merknader = systemer_oppsett.merknader.clone(); let mut merknader = systemer_oppsett.merknader.clone();
if b.klima.kommune.is_none() && b.klima.epw.is_none() {
if let Some([bredde, lengde]) = b.klima.posisjon {
let bygg = crate::klima::geo::Posisjon { bredde, lengde };
let stasjon = crate::klima::geo::Posisjon {
bredde: vaerdata.bredde,
lengde: vaerdata.lengde,
};
merknader.push(format!(
"Klimastedet {klimanavn} er valgt ut fra posisjonen {bredde:.4}° nord, {lengde:.4}° øst, \
{:.0} km unna. Sett `kommune` under [klima] for å velge et annet.",
bygg.avstand_km(&stasjon)
));
}
}
merknader.extend( merknader.extend(
crate::modell::kontroll::kontroller(b) crate::modell::kontroll::kontroller(b)
.into_iter() .into_iter()
+224
View File
@@ -0,0 +1,224 @@
//! Posisjon fra BIM-modellen, og valg av klimasted ut fra den.
//!
//! En IFC-modell kan oppgi hvor bygningen står på to måter: som breddegrad og lengdegrad på
//! `IfcSite`, eller som prosjiserte koordinater gjennom `IfcMapConversion` og
//! `IfcProjectedCRS`. I Norge er det siste normalt EUREF89 UTM sone 32, 33 eller 35. Denne
//! modulen gjør begge om til breddegrad og lengdegrad, slik at nærmeste klimasted kan velges
//! automatisk.
use std::f64::consts::FRAC_PI_2;
/// Store halvakse for GRS80, som EUREF89 bygger på, m.
const A: f64 = 6_378_137.0;
/// Flattrykning for GRS80.
const F: f64 = 1.0 / 298.257_222_101;
/// Målestokkfaktor langs sentralmeridianen i UTM.
const K0: f64 = 0.9996;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Posisjon {
/// Breddegrad, grader nord.
pub bredde: f64,
/// Lengdegrad, grader øst.
pub lengde: f64,
}
impl Posisjon {
/// Avstand langs jordoverflaten, km.
pub fn avstand_km(&self, annen: &Posisjon) -> f64 {
let (b1, b2) = (self.bredde.to_radians(), annen.bredde.to_radians());
let db = b2 - b1;
let dl = (annen.lengde - self.lengde).to_radians();
let h = (db / 2.0).sin().powi(2) + b1.cos() * b2.cos() * (dl / 2.0).sin().powi(2);
2.0 * 6371.0 * h.sqrt().asin()
}
/// Sant for plasseringen Vectorworks setter når prosjektstedet ikke er angitt, og for
/// nullpunktet. Begge betyder at modellen i praksis ikke oppgir noen posisjon.
pub fn er_standardplassering(&self) -> bool {
let washington =
(self.bredde - 38.8895).abs() < 0.05 && (self.lengde + 77.0353).abs() < 0.05;
let null = self.bredde.abs() < 1e-6 && self.lengde.abs() < 1e-6;
washington || null
}
/// Grovt: innenfor fastlands-Norge og Svalbard.
pub fn i_norge(&self) -> bool {
(57.5..=81.5).contains(&self.bredde) && (4.0..=32.0).contains(&self.lengde)
}
}
/// Fra Web Mercator (EPSG:3857), som Vectorworks bruker når ingen annen projeksjon er valgt.
pub fn fra_web_mercator(x: f64, y: f64) -> Posisjon {
Posisjon {
bredde: (2.0 * (y / A).exp().atan() - FRAC_PI_2).to_degrees(),
lengde: (x / A).to_degrees(),
}
}
/// Fra UTM på nordlige halvkule, med GRS80-ellipsoiden som EUREF89 bruker.
///
/// Inversformlene er de vanlige seriene etter Snyder, «Map Projections A Working Manual»,
/// som gir centimeternøyaktighet innenfor en sone.
pub fn fra_utm(sone: u32, x: f64, y: f64) -> Posisjon {
let e2 = F * (2.0 - F);
let ep2 = e2 / (1.0 - e2);
let e1 = (1.0 - (1.0 - e2).sqrt()) / (1.0 + (1.0 - e2).sqrt());
let m = y / K0;
let mu = m / (A * (1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2.powi(3) / 256.0));
let fi1 = mu
+ (3.0 * e1 / 2.0 - 27.0 * e1.powi(3) / 32.0) * (2.0 * mu).sin()
+ (21.0 * e1 * e1 / 16.0 - 55.0 * e1.powi(4) / 32.0) * (4.0 * mu).sin()
+ (151.0 * e1.powi(3) / 96.0) * (6.0 * mu).sin()
+ (1097.0 * e1.powi(4) / 512.0) * (8.0 * mu).sin();
let (s, c, t) = (fi1.sin(), fi1.cos(), fi1.tan());
let n1 = A / (1.0 - e2 * s * s).sqrt();
let t1 = t * t;
let c1 = ep2 * c * c;
let r1 = A * (1.0 - e2) / (1.0 - e2 * s * s).powf(1.5);
let d = (x - 500_000.0) / (n1 * K0);
let bredde = fi1
- (n1 * t / r1)
* (d * d / 2.0
- (5.0 + 3.0 * t1 + 10.0 * c1 - 4.0 * c1 * c1 - 9.0 * ep2) * d.powi(4) / 24.0
+ (61.0 + 90.0 * t1 + 298.0 * c1 + 45.0 * t1 * t1 - 252.0 * ep2 - 3.0 * c1 * c1)
* d.powi(6)
/ 720.0);
let sentralmeridian = (sone as f64 - 1.0) * 6.0 - 180.0 + 3.0;
let lengde = sentralmeridian
+ ((d - (1.0 + 2.0 * t1 + c1) * d.powi(3) / 6.0
+ (5.0 - 2.0 * c1 + 28.0 * t1 - 3.0 * c1 * c1 + 8.0 * ep2 + 24.0 * t1 * t1)
* d.powi(5)
/ 120.0)
/ c)
.to_degrees();
Posisjon {
bredde: bredde.to_degrees(),
lengde,
}
}
/// Gjør koordinater i et navngitt koordinatsystem om til posisjon.
///
/// `navn` er slik det står i `IfcProjectedCRS`, for eksempel «EPSG:25832». Koordinatsystemer
/// enok ikke kjenner gir `None`, og da brukes ikke posisjonen.
pub fn fra_koordinatsystem(navn: &str, x: f64, y: f64) -> Option<Posisjon> {
let stor = navn.to_uppercase();
let kode: Option<u32> = stor.find("EPSG").and_then(|i| {
let siffer: String = stor[i + 4..]
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
siffer.parse().ok()
});
match kode {
Some(3857) | Some(900_913) => Some(fra_web_mercator(x, y)),
// Geografiske koordinater: østverdien er lengdegrad og nordverdien breddegrad.
Some(4326) | Some(4258) => Some(Posisjon {
bredde: y,
lengde: x,
}),
// ETRS89 / EUREF89 UTM sone 2838.
Some(k @ 25828..=25838) => Some(fra_utm(k - 25800, x, y)),
// WGS 84 UTM nord, sone 2838.
Some(k @ 32628..=32638) => Some(fra_utm(k - 32600, x, y)),
// ETRS89 / UTM med NN2000-høyder, som Kartverket bruker: 5972 er sone 32, 5973 sone 33,
// 5975 sone 35.
Some(k @ 5972..=5976) => Some(fra_utm(k - 5940, x, y)),
_ => {
// Uten EPSG-kode: kjenn igjen «UTM32», «UTM 33N» og lignende.
let i = stor.find("UTM")?;
let sone: String = stor[i + 3..]
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
let sone: u32 = sone.parse().ok()?;
(1..=60).contains(&sone).then(|| fra_utm(sone, x, y))
}
}
}
#[cfg(test)]
mod tester {
use super::*;
#[test]
fn web_mercator_fra_vectorworks_gir_washington() {
// Verdiene i IfcMapConversion når prosjektstedet ikke er satt i Vectorworks.
let p = fra_web_mercator(-8_575_524.352_288_59, 4_705_852.257_469_73);
assert!((p.bredde - 38.8895).abs() < 1e-3, "bredde {}", p.bredde);
assert!((p.lengde + 77.0352).abs() < 1e-3, "lengde {}", p.lengde);
assert!(p.er_standardplassering());
assert!(!p.i_norge());
}
/// Referanseverdier fra pyproj (PROJ), transformert fra EPSG:4326.
#[test]
fn utm_og_web_mercator_stemmer_med_proj() {
// (sone, øst, nord, breddegrad, lengdegrad, toleranse i grader)
//
// Innenfor sonen og et stykke utenfor er avviket under 1e-5 grader, om lag én meter.
// Tromsø i sone 32 ligger nesten 10 grader utenfor sonen, der rekkeutviklingen gir
// om lag 1,3 m avvik. Det er langt innenfor hva valg av klimasted trenger, men viser
// hvor grensen går.
let utm = [
(32, 613_351.291, 6_611_921.980, 59.6300, 11.0100, 1e-5),
(32, 596_946.981, 6_642_824.393, 59.9115, 10.7336, 1e-5),
(33, 275_055.615, 6_616_968.071, 59.6300, 11.0100, 1e-5),
(33, 653_421.188, 7_731_721.083, 69.6492, 18.9553, 1e-5),
(35, 188_546.727, 7_747_295.774, 69.6492, 18.9553, 1e-5),
(32, 884_909.622, 7_758_204.184, 69.6492, 18.9553, 1e-4),
];
for (sone, x, y, b, l, toleranse) in utm {
let p = fra_utm(sone, x, y);
assert!(
(p.bredde - b).abs() < toleranse && (p.lengde - l).abs() < toleranse,
"sone {sone} ({x}, {y}) ga {p:?}, forventet {b}, {l}"
);
}
let p = fra_web_mercator(1_225_627.594, 8_317_818.189);
assert!(
(p.bredde - 59.63).abs() < 1e-6 && (p.lengde - 11.01).abs() < 1e-6,
"{p:?}"
);
}
#[test]
fn sentralmeridianen_gir_eksakt_lengdegrad() {
// Østverdi 500 000 ligger på sonens sentralmeridian, 9° øst for sone 32.
let p = fra_utm(32, 500_000.0, 6_600_000.0);
assert!((p.lengde - 9.0).abs() < 1e-9, "lengde {}", p.lengde);
let q = fra_utm(33, 500_000.0, 6_600_000.0);
assert!((q.lengde - 15.0).abs() < 1e-9, "lengde {}", q.lengde);
}
#[test]
fn ekvator_paa_sentralmeridianen() {
let p = fra_utm(32, 500_000.0, 0.0);
assert!(p.bredde.abs() < 1e-9 && (p.lengde - 9.0).abs() < 1e-9);
}
#[test]
fn ukjent_koordinatsystem_gir_ingen_posisjon() {
assert!(fra_koordinatsystem("EPSG:2154", 650_000.0, 6_860_000.0).is_none());
assert!(fra_koordinatsystem("lokalt", 1.0, 2.0).is_none());
}
#[test]
fn epsg_koder_tolkes() {
let a = fra_koordinatsystem("EPSG:25832", 500_000.0, 6_600_000.0).unwrap();
let b = fra_koordinatsystem("ETRS89 / UTM zone 32N", 500_000.0, 6_600_000.0).unwrap();
assert!((a.lengde - 9.0).abs() < 1e-9 && (b.lengde - 9.0).abs() < 1e-9);
let c = fra_koordinatsystem("EPSG:5973", 500_000.0, 6_600_000.0).unwrap();
assert!(
(c.lengde - 15.0).abs() < 1e-9,
"5973 er sone 33, fikk {}",
c.lengde
);
}
}
+1
View File
@@ -1,6 +1,7 @@
//! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering. //! Klimadata: TMY-filer (EPW) for norske kommuner, solberegning og klimakorrigering.
pub mod epw; pub mod epw;
pub mod geo;
pub mod korreksjon; pub mod korreksjon;
pub mod sol; pub mod sol;
pub mod tmy; pub mod tmy;
+30
View File
@@ -76,6 +76,36 @@ fn kommune_av_filnavn(navn: &str) -> Option<String> {
Some(base.to_string()) Some(base.to_string())
} }
/// Klimastedet nærmest en posisjon, og avstanden dit i km.
///
/// Hver TMY-fil oppgir i første linje koordinatene for punktet dataene gjelder. Bare starten
/// av hver fil pakkes ut, så oppslaget går raskt selv om arkivet er stort.
pub fn naermeste_kommune(p: &crate::klima::geo::Posisjon) -> Result<(String, f64)> {
let sti = sikre_arkiv()?;
let fil = std::fs::File::open(&sti)?;
let mut zip = zip::ZipArchive::new(fil)?;
let mut best: Option<(String, f64)> = None;
for i in 0..zip.len() {
let oppf = zip.by_index(i)?;
let Some(kommune) = kommune_av_filnavn(oppf.name()) else {
continue;
};
let mut hode = Vec::with_capacity(512);
oppf.take(512).read_to_end(&mut hode)?;
let linje = crate::klima::epw::avkod(&hode);
let felt: Vec<&str> = linje.lines().next().unwrap_or("").split(',').collect();
let tall = |i: usize| felt.get(i).and_then(|s| s.trim().parse::<f64>().ok());
let (Some(bredde), Some(lengde)) = (tall(6), tall(7)) else {
continue;
};
let d = p.avstand_km(&crate::klima::geo::Posisjon { bredde, lengde });
if best.as_ref().is_none_or(|(_, bd)| d < *bd) {
best = Some((kommune, d));
}
}
best.ok_or_else(|| anyhow!("TMY-arkivet inneholder ingen klimafiler med koordinater"))
}
/// Henter værdata for en kommune. Navnet sammenlignes normalisert, slik at både /// Henter værdata for en kommune. Navnet sammenlignes normalisert, slik at både
/// «Indre Østfold» og «Indre Ostfold» treffer samme fil. /// «Indre Østfold» og «Indre Ostfold» treffer samme fil.
pub fn for_kommune(kommune: &str) -> Result<Vaerdata> { pub fn for_kommune(kommune: &str) -> Result<Vaerdata> {
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn kontroller(b: &Bygning) -> Vec<Mangel> {
}); });
} }
if b.klima.kommune.is_none() && b.klima.epw.is_none() { if b.klima.kommune.is_none() && b.klima.epw.is_none() && b.klima.posisjon.is_none() {
ut.push(Mangel { ut.push(Mangel {
felt: "klima.kommune", felt: "klima.kommune",
melding: "Kommune er ikke oppgitt, så beregningen bruker referanseklimaet Oslo. \ melding: "Kommune er ikke oppgitt, så beregningen bruker referanseklimaet Oslo. \
+4
View File
@@ -117,6 +117,10 @@ fn en() -> u32 {
#[derive(Deserialize, Serialize, Clone, Debug, Default)] #[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct Klima { pub struct Klima {
/// Byggets posisjon som [breddegrad, lengdegrad]. Er kommune ikke oppgitt, velges
/// klimastedet nærmest denne posisjonen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posisjon: Option<[f64; 2]>,
/// Kommune for lokalt klima (TMY-fil fra Zenodo 14779899). Utelatt: bare Oslo. /// Kommune for lokalt klima (TMY-fil fra Zenodo 14779899). Utelatt: bare Oslo.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub kommune: Option<String>, pub kommune: Option<String>,
+30
View File
@@ -0,0 +1,30 @@
//! Kontroll av valg av klimasted. Testene bruker TMY-arkivet og laster det ned ved første kjøring.
use enok::data::normaliser_stedsnavn;
use enok::klima::geo::Posisjon;
use enok::klima::tmy::naermeste_kommune;
#[test]
fn naermeste_klimasted_for_tomter_er_indre_ostfold() {
let (kommune, km) = naermeste_kommune(&Posisjon {
bredde: 59.63,
lengde: 11.01,
})
.unwrap();
assert_eq!(
normaliser_stedsnavn(&kommune),
"indreostfold",
"fikk {kommune}"
);
assert!(km < 25.0, "{km} km");
}
#[test]
fn naermeste_klimasted_for_oslo_raadhus_er_oslo() {
let (kommune, _) = naermeste_kommune(&Posisjon {
bredde: 59.9115,
lengde: 10.7336,
})
.unwrap();
assert_eq!(normaliser_stedsnavn(&kommune), "oslo", "fikk {kommune}");
}