Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/roofmodel-building-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"ftw": minor
---

Pick your building on the map and read the panel angles off Lantmäteriet's laser
scan, instead of measuring your own roof.

**Settings → Weather** gains a Geotorget credential form and a building picker.
Press *Find buildings here* and the footprints near the marker are drawn on the
map and listed beside it; click yours, press *Read roof from LiDAR*, and the PV
arrays fill in with one entry per usable roof face. The form is filled but
nothing is saved: the operator sees the numbers, corrects what is wrong and
presses Save. FTW never rewrites a panel configuration on its own, because the
derivation is a guess from a scan that may be years old and only the operator
knows whether that face has panels on it at all.

Picking a building is not cosmetic. Without a footprint the module segments
whatever stands inside its search radius, and the plane fitting is global — a
fitted plane is infinite, so a roof at azimuth 180° is `z = f(y)` with no `x`
term and extends across the whole tile. A second building sharing that ridge
orientation lands inside its inlier band however far away it is, and the two lose
returns to each other. Measured on a synthetic pair: a detached garage recovered
93% of its true area and split into two fragments while coplanar with the house,
against 100% and one clean face once clipped to its own footprint. Clipping also
buffers the outline by a metre so the eaves, where the lowest roof returns are,
are not shaved off.

New `GET /api/roofmodel/buildings` lists footprints as GeoJSON, honouring an
explicit `lat`/`lon` so the picker can search where the marker is rather than
where the last save put it. `POST /api/roofmodel/derive` accepts a `building_id`.
`GET /api/roofmodel` reports `has_credentials` so the UI can stop asking.

The Geotorget token now masks and restores like every other secret: it never
appears in an API response, and saving an unrelated setting no longer wipes it.

Documented in [docs/roof-geometry.md](../docs/roof-geometry.md), including how to
order the two Geotorget products, what the derived kWp does and does not mean,
and what each failure message is telling you.
33 changes: 33 additions & 0 deletions .changeset/roofmodel-lantmateriet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"ftw": minor
---

New optional roof-geometry module derives PV array tilt, azimuth and kWp from
Lantmäteriet open geodata, so a Swedish site can stop typing panel angles in by
hand.

`roofmodel/` is a separate Python package alongside `optimizer/`, invoked at
arm's length: core spawns it, hands it coordinates and the operator's own
Geotorget credentials, and reads back one versioned `roof_model.json`. LiDAR
segmentation drags in a compiled point-cloud stack and runs for minutes, so
keeping it in a time-boxed subprocess means it cannot stall the control tick or
leak into the daemon — and it can be absent entirely, which is the normal case,
since the data only exists for Sweden.

The pipeline follows the SPAN method (Yavuzdoğan, *Renewable Energy* 2023):
iterative RANSAC plane fitting pulls one roof surface at a time out of the point
cloud, then DBSCAN splits faces that share a plane equation but not a location —
two wings of a building fit the same plane and are not the same roof. Method
only; no code is taken from SPAN's GPL QGIS plugin, and the module depends on
numpy, scikit-learn and requests, all BSD.

`GET /api/roofmodel` reports availability and coverage; `POST
/api/roofmodel/derive` runs a derive and returns the proposed arrays. Applying
them to `weather.pv_arrays` is deliberately left as a separate explicit act:
derivation is a best guess from a point cloud that may be years old, and
silently rewriting an operator's panel config is a change they should make
knowingly. Lantmäteriet also joins the `/api/data-sources` registry as a
Sweden-only, credential-gated source.

Off by default. Absent credentials, absent module or a non-Swedish site all
produce a clean explanation rather than a failure.
26 changes: 26 additions & 0 deletions .changeset/roofmodel-stac-formats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"ftw": minor
---

Read Lantmäteriet's data in the formats it is actually published in.

Both roof-geometry products are STAC APIs behind one Geotorget account, and they
differ only in what their items point at: *Byggnad Nedladdning, vektor* delivers
**GeoPackage**, *Laserdata Nedladdning, Skog* delivers **LAZ organised as COPC**
(Cloud Optimized Point Cloud). Assets are now chosen by their declared media
type instead of by guessing at asset names, so a catalogue that calls its asset
`punktmoln` rather than `data` still works, and a thumbnail is never mistaken
for a point cloud.

Building footprints are read straight out of the GeoPackage with the standard
library — a GeoPackage is a SQLite database holding geometry as WKB, both
published formats with fixed layouts, so this needs no GDAL and installs on a Pi
unchanged. Previously only inline STAC geometry was handled, which meant the
normal asset-backed case returned nothing at all.

Because COPC indexes its points into an octree, picking a building now also
makes the download small: FTW range-requests only the octree nodes covering that
footprint instead of pulling a 2.5 km tile that runs to hundreds of megabytes.
Plain `.laz` assets, servers that ignore `Range`, and builds of laspy without
COPC support all fall back to reading the tile whole — slower, same answer. The
derived model records which path ran as `source.fetch`.
28 changes: 28 additions & 0 deletions .changeset/roofmodel-vostok-shading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"ftw": minor
---

Roof faces can now carry a shading factor from neighbouring buildings and trees,
computed with vostok.

Roof segmentation gives each face a tilt and an azimuth, which predicts yield for
an unobstructed roof and says nothing about the spruce to the south. `vostok`
("Voxel Octree Solar Toolkit") computes per-point solar potential against
voxelised occlusion geometry, so pointing it at the same LiDAR tile the roof came
from supplies exactly the missing number. Each face is run twice — once against
the real geometry, once with shadowing off — and the ratio is a shading factor
that is independent of vostok's absolute units, sky model and chosen year, since
all of those cancel.

**vostok is GPL-3.0 and FTW is not.** It is therefore treated as an external
program at arm's length: a separate process communicating through files and a
command line, never linked, with no code copied in either direction. Two rules
keep that boundary intact — vostok is never bundled or redistributed with FTW,
and FTW never installs it. The operator installs it themselves and sets
`roofmodel.vostok_binary`.

Absent or unset, shading is simply not evaluated: faces report `shading_factor`
as *absent* rather than 1.0, because "we did not look" and "we looked and it is
unobstructed" are different claims and only one of them justifies trusting the
yield estimate. A missing or failing binary never fails a derive — the roof
geometry is still good without a shading number.
15 changes: 15 additions & 0 deletions .changeset/strang-poa-pv-forecast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"ftw": minor
---

PV forecasts are now orientation-aware. When per-plane geometry is configured
(the Weather tab's PV arrays: tilt/azimuth/kWp), a radiation-bearing forecast
provider's global horizontal irradiance is projected onto each panel plane via
the physics `sunpos` model and summed, instead of the previous flat
`rated × (W/m² / 1000)` estimate that ignored panel orientation. Sites with no
arrays configured keep the existing behaviour, and providers that already return
site-calibrated watts (Forecast.Solar) are left untouched.

Providers that publish only global horizontal irradiance get an Erbs correlation
to split it into direct and diffuse components before projection, so a
south-facing 35° roof and a flat one no longer receive the same forecast.
46 changes: 46 additions & 0 deletions .changeset/strang-source-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"ftw": minor
---

SMHI STRÅNG becomes a first-class irradiance source, every external data source
now declares where in the world it works, and the location picker moves to
MapLibre GL JS.

- **STRÅNG as an irradiance source.** The client now covers the model's full
parameter set and knows its own domain. A nightly backfill scores measured
production against the DC energy the configured arrays should have produced
under that irradiance, exposed at `GET /api/pv/performance` and drawn as a
dashed "expected (STRÅNG)" overlay on the Produced tile. The resulting
performance ratio feeds back as a calibration factor on the forward forecast,
refused outright when it lands outside a plausible band — a site reading at
10% or 160% of nameplate is a configuration fault, and silently rescaling the
forecast would hide it.

- **Cloud cover, derived.** STRÅNG publishes no cloud-cover parameter; it is a
radiation model. It does publish sunshine duration (minutes per hour above the
WMO beam threshold), so cloudiness is recovered as `1 − minutes/60`. That is an
observed quantity rather than an inferred cloud field, but coarser: blind to
thin cirrus, and undefined at night. The API returns an explicit *unknown*
rather than defaulting to *clear*, because those lead to opposite decisions.

- **Coverage metadata.** New `GET /api/data-sources` reports every forecast,
irradiance and price source with its coverage area, country list, licence and
whether it reaches this specific site; the Weather tab renders it under the
map and flags sources that do not. This makes an existing silence explicit:
STRÅNG is Nordic-only and every price provider is European, so sites elsewhere
were getting empty results with no explanation. Bounds are advisory — a
rotated model grid means a lat/lon box can only be a superset — so `false` is
definitive and `true` means "worth trying". PV performance scoring now declines
to start outside the STRÅNG domain instead of retrying nightly forever.

- **MapLibre GL JS location picker.** The Weather tab's map is now MapLibre GL
JS 6 (BSD-3) instead of Leaflet, still lazy-loaded only when the tab opens. v6
is ESM-only and code-split, so it loads via a pinned dynamic import; the
stylesheet keeps its integrity hash, while the JS relies on version pinning
(an integrity hash on the entry point would not cover the shared chunk it
imports anyway). The style is built inline from the same OpenStreetMap raster
tiles as before, so neither the tile source nor the attribution changed. The
numeric latitude/longitude fields remain authoritative, so a CDN or WebGL
failure costs the picker and nothing else.

Nothing here touches the control tick, dispatch or the optimizer contract.
37 changes: 35 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
outputs:
core: ${{ steps.paths.outputs.core }}
optimizer: ${{ steps.paths.outputs.optimizer }}
roofmodel: ${{ steps.paths.outputs.roofmodel }}
web: ${{ steps.paths.outputs.web }}
drivers: ${{ steps.paths.outputs.drivers }}
compose: ${{ steps.paths.outputs.compose }}
Expand All @@ -43,6 +44,7 @@ jobs:
set -euo pipefail
core=false
optimizer=false
roofmodel=false
web=false
drivers=false
compose=false
Expand All @@ -63,6 +65,12 @@ jobs:
optimizer/*|Dockerfile.optimizer|go/internal/mpc/*|go/cmd/ftw/main.go)
optimizer=true
;;
# Only the Python module. Its Go counterpart lives under go/,
# which the core suite already covers -- and a `go/...` pattern
# here would never fire anyway, since the `go/*` arm above wins.
roofmodel/*)
roofmodel=true
;;
web/*|package.json|package-lock.json)
web=true
;;
Expand All @@ -75,6 +83,7 @@ jobs:
Makefile|.github/workflows/test.yml)
core=true
optimizer=true
roofmodel=true
web=true
drivers=true
compose=true
Expand All @@ -96,6 +105,7 @@ jobs:
{
echo "core=${core}"
echo "optimizer=${optimizer}"
echo "roofmodel=${roofmodel}"
echo "web=${web}"
echo "drivers=${drivers}"
echo "compose=${compose}"
Expand Down Expand Up @@ -157,6 +167,28 @@ jobs:
go test -count=1 ./internal/mpc
-run 'TestExternalOptimizer(EndToEnd|PlansMultipleLoadpoints|PlansAndValidatesMultipleStorages)$'

# The roof-geometry module is optional at runtime but not optional to verify:
# it decides the tilt and azimuth every PV forecast is then built on, and a
# silently wrong plane fit looks exactly like a working one. Installed without
# the `geo` extra on purpose -- LAZ decoding pulls a compiled backend, and
# everything except the point-cloud read is exercised without it.
roofmodel:
name: roofmodel (Python)
needs: changes
if: needs.changes.outputs.roofmodel == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: roofmodel/pyproject.toml
- name: Install
run: pip install -e 'roofmodel[test]'
- name: Python tests
run: pytest -q roofmodel/tests

web:
name: web
needs: changes
Expand Down Expand Up @@ -412,12 +444,13 @@ jobs:
test:
name: go test + vet
if: always()
needs: [changes, core, optimizer, web, drivers, device-support-contract, compose, e2e]
needs: [changes, core, optimizer, roofmodel, web, drivers, device-support-contract, compose, e2e]
runs-on: ubuntu-latest
env:
RESULTS: >-
${{ needs.changes.result }} ${{ needs.core.result }}
${{ needs.optimizer.result }} ${{ needs.web.result }}
${{ needs.optimizer.result }} ${{ needs.roofmodel.result }}
${{ needs.web.result }}
${{ needs.drivers.result }} ${{ needs.device-support-contract.result }}
${{ needs.compose.result }}
${{ needs.e2e.result }}
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ node_modules/
release-notes.md
optimizer/.venv/
optimizer/.pytest_cache/
optimizer/**/__pycache__/
# Python build artefacts from any module, not just the optimizer — roofmodel/
# is a second one, and a third would otherwise repeat this again.
**/__pycache__/
**/.pytest_cache/
*.pyc

# TLS material — never commit.
*.pem
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ metadata are the detailed reference.
- [Product roadmap](docs/roadmap.md)
- [Power sign convention](docs/site-convention.md)
- [Safety invariants](docs/safety.md)
- [Geographic coverage of external data](docs/data-coverage.md)
- [Operations and recovery](docs/operations.md)
- [Full backup and safe restore](docs/backup-and-restore.md)
- [Writing a driver](docs/writing-a-driver.md)
Expand Down
Loading