Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4617efa
feat(weather): add STRANG as an irradiance source and declare source …
claude Jul 31, 2026
338b575
feat(web): vendor MapLibre GL JS; the weather map loads nothing from …
claude Aug 29, 2026
ba47f6a
chore: changeset for the vendored MapLibre swap
claude Aug 29, 2026
a183781
docs(strang): point at SMHI's real STRANG documentation
claude Sep 2, 2026
a1a547e
fix(web): serve static assets with pinned Content-Types
claude Sep 2, 2026
e25ee24
chore(web): vendor MapLibre GL JS 6.7.0
claude Sep 2, 2026
00d61a1
feat(roofmodel): derive PV array geometry from Lantmateriet open geodata
claude Jul 31, 2026
e66a02b
test(roofmodel): run the subprocess tests on Linux, and the module in CI
claude Jul 31, 2026
0298321
feat(roofmodel): pick your building on the map, read the roof from LiDAR
claude Jul 31, 2026
2585d57
feat(roofmodel): read Lantmateriet in the formats it publishes
claude Aug 1, 2026
e853a15
fix(roofmodel): one axis order for building rings, wherever they came…
claude Aug 5, 2026
b94672b
fix(web): help bubbles survive quotes and the bottom of the modal
claude Aug 5, 2026
ad5f86e
fix(roofmodel): emit rated watts after si-core-units
frahlg Aug 19, 2026
7cc0df9
feat(roofmodel): Basic-auth credentials, catalog-agnostic STAC access
claude Aug 29, 2026
382298c
feat(roofmodel): anonymous access to open STAC catalogs
claude Sep 2, 2026
160eca2
fix(roofmodel): follow config hot-reload, and read the module's error…
claude Sep 2, 2026
6ba05b7
fix(roofmodel): match the live Geotorget STAC service
claude Sep 2, 2026
26fcd13
feat(web): pick the STAC catalog from a list, or bring your own
claude Sep 2, 2026
ac7ccf6
fix(web): found buildings draw on the map and a click picks one
claude Sep 2, 2026
249411b
fix(roofmodel): COPC window queries must not trust Lantmateriet z keys
claude Sep 2, 2026
43924e1
fix(roofmodel): the derive searches where the picker searched
claude Sep 2, 2026
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
11 changes: 11 additions & 0 deletions .changeset/copc-window-z-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"ftw": patch
---

The LiDAR derive now reads the full-density point cloud. Lantmäteriet's COPC
files key their octree nodes with a broken z origin (a node whose key implies
a slab 1.7 km underground holds the roof points), which made the windowed
query prune every dense level and return ~47 preview points — too few to fit
any plane. The query now spans the octree cube vertically, so pruning happens
on x/y only. Verified live: a picked Stockholm building went from "only 34
LiDAR returns" to 8 proposed arrays from 9 roof planes out of the 2021 scan.
14 changes: 14 additions & 0 deletions .changeset/derive-follows-the-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"ftw": patch
---

Deriving a picked building no longer fails with "not found near this site"
in the two ways it could. The derive now searches where the picker searched:
the same (possibly dragged, unsaved) map coordinates are sent with the
request instead of silently using the stored site. And the derive's re-find
of the picked footprint now reaches as far as the picker's own search did —
it used to re-search with the 40 m LiDAR radius, so anything the 150 m picker
had found beyond that reach vanished on derive. The LiDAR tile lookup also
centres on the picked building rather than the pin. Verified live on the
reported case: a 522 m² barn picked via an unsaved pin now derives 2 arrays
from 2 roof planes.
8 changes: 8 additions & 0 deletions .changeset/maplibre-670.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"ftw": patch
---

The vendored MapLibre GL JS is now 6.7.0 (was 6.0.0). Same four-file
code-split layout, and the per-request `referrerPolicy` the OSM tiles rely on
still works; the weather map, the building picker and the PV array drawing
tool were re-verified in a browser on the new build.
14 changes: 14 additions & 0 deletions .changeset/roof-buildings-on-the-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"ftw": patch
---

Found buildings now actually appear on the map, and clicking one selects it.
Three defects hid them: MapLibre silently rejected both footprint layers
because the styling used a value-typed `["get", "selected"]` where a typed
boolean is required, and — once typed — because the theme's `oklch()` colour
tokens reached MapLibre's parser unconverted, so the resolved colours are now
baked to sRGB through a canvas probe. Finally the camera never moved: the map
sat at city zoom, where a footprint is smaller than a pixel. A find now zooms
to the nearby candidates, and clicking a footprint selects that building
without also dragging the site pin (and its saved coordinates) to wherever
you clicked.
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.
23 changes: 23 additions & 0 deletions .changeset/roofmodel-live-geotorget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"ftw": patch
---

Roof derivation now works against the real Lantmäteriet service, verified
end-to-end with a live Geotorget account. The catalogue turned out to differ
from its paper description in four ways, each now handled: the STAC service
is two roots (`stac-vektor/v1` for buildings, `stac-hojd/v1` for the point
clouds) rather than one; the collections are `byggnader` and
`dsm-skoglig-copc`; searches take the STAC spec's WGS84 bbox; and buildings
arrive as one ZIP-wrapped GeoPackage per municipality — 90k+ features with no
per-row envelopes, so the reader now clips to the search window by parsed
geometry bounds instead of truncating at a row limit, and a tile item's own
geometry (the municipality outline) is no longer mistaken for a building.

Credentials typed into Settings also apply immediately: the roofmodel service
follows config hot-reload instead of keeping its boot-time snapshot, and a
module error on stderr survives third-party library warnings above it.

The roof section gains a data-catalog picker: Lantmäteriet (default), the
live-verified open catalogs (IGN LiDAR HD France, KAGIS Carinthia), or any
custom STAC endpoint with its own collections — open catalogs need no
credentials.
17 changes: 17 additions & 0 deletions .changeset/roofmodel-stac-basic-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"ftw": minor
---

Roof-geometry credentials are now the Geotorget account username and password,
sent as HTTP Basic auth — Lantmäteriet provides no OAuth for its STAC download
APIs, so there is no issued token to paste. Configs that stored the secret
under the old `geotorget_token` key keep working and migrate to
`roofmodel.stac_password` on their next save.

The STAC client is catalog-agnostic while it is at it: `stac_base_url`,
`stac_buildings_collection`, `stac_lidar_collection` and `stac_bbox_epsg`
point FTW at any STAC-conformant catalog (search is the spec's
`POST {base}/search`), with Lantmäteriet as the default. A custom catalog also
lifts the Sweden-only coordinate gate, and needs no credentials at all when it
is open — anonymous access is only refused for the default Lantmäteriet
catalog, which always requires the operator's own account.
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`.
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.
13 changes: 13 additions & 0 deletions .changeset/vendor-maplibre.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"ftw": patch
---

The weather map's MapLibre GL JS is vendored on the box instead of loaded
from unpkg, so the location picker works without internet access and the UI
executes no third-party CDN JavaScript — the same policy that vendored
Leaflet, whose now-unused copy is removed.

Static assets are also served with pinned Content-Types instead of whatever
the host OS's MIME table says: on a Windows host whose registry maps `.mjs`
to text/plain, the browser (correctly, under `nosniff`) refused the vendored
module and the map died with "failed to fetch dynamically imported module".
11 changes: 9 additions & 2 deletions .github/scripts/classify-test-changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ set -euo pipefail

core=false
optimizer=false
roofmodel=false
web=false
drivers=false
compose=false
Expand All @@ -28,6 +29,11 @@ while IFS= read -r file; do
optimizer/*|Dockerfile.optimizer|go/internal/mpc/*|go/cmd/ftw/main.go)
optimizer=true
;;
# Python roof-geometry module only. Its Go host lives under go/, which
# the core suite already covers.
roofmodel/*)
roofmodel=true
;;
web/*|package.json|package-lock.json)
web=true
;;
Expand All @@ -40,6 +46,7 @@ while IFS= read -r file; do
Makefile|.github/workflows/test.yml)
core=true
optimizer=true
roofmodel=true
web=true
drivers=true
compose=true
Expand All @@ -52,5 +59,5 @@ while IFS= read -r file; do
esac
done

printf 'core=%s\noptimizer=%s\nweb=%s\ndrivers=%s\ncompose=%s\n' \
"${core}" "${optimizer}" "${web}" "${drivers}" "${compose}"
printf 'core=%s\noptimizer=%s\nroofmodel=%s\nweb=%s\ndrivers=%s\ncompose=%s\n' \
"${core}" "${optimizer}" "${roofmodel}" "${web}" "${drivers}" "${compose}"
23 changes: 13 additions & 10 deletions .github/scripts/test-change-classifier.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,36 @@ assert_paths() {
# This was the release hole: before the special case came first, this path
# matched drivers/* and selected only core.
assert_paths 'drivers/BUNDLED_SOURCE.json' \
'core=true' 'optimizer=false' 'web=false' 'drivers=true' 'compose=false'
'core=true' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=true' 'compose=false'

assert_paths 'drivers/example.lua' \
'core=true' 'optimizer=false' 'web=false' 'drivers=true' 'compose=false'
'core=true' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=true' 'compose=false'

assert_paths 'config.example.yaml' \
'core=true' 'optimizer=false' 'web=false' 'drivers=false' 'compose=false'
'core=true' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=false'

assert_paths '.github/workflows/test.yml' \
'core=true' 'optimizer=true' 'web=true' 'drivers=true' 'compose=true'
'core=true' 'optimizer=true' 'roofmodel=true' 'web=true' 'drivers=true' 'compose=true'

assert_paths 'roofmodel/ftw_roofmodel/pipeline.py' \
'core=false' 'optimizer=false' 'roofmodel=true' 'web=false' 'drivers=false' 'compose=false'

assert_paths '.github/scripts/classify-test-changes.sh' \
'core=false' 'optimizer=false' 'web=false' 'drivers=false' 'compose=true'
'core=false' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=true'

assert_paths '.github/workflows/release-assets.yml' \
'core=false' 'optimizer=false' 'web=false' 'drivers=false' 'compose=true'
'core=false' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=true'

assert_paths 'scripts/promote-paired-latest.sh' \
'core=false' 'optimizer=false' 'web=false' 'drivers=false' 'compose=true'
'core=false' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=true'

assert_paths 'scripts/github-release-by-id.sh' \
'core=false' 'optimizer=false' 'web=false' 'drivers=false' 'compose=true'
'core=false' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=true'

assert_paths 'scripts/test-github-release-by-id.sh' \
'core=false' 'optimizer=false' 'web=false' 'drivers=false' 'compose=true'
'core=false' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=true'

assert_paths 'scripts/check-stable-release.py' \
'core=false' 'optimizer=false' 'web=false' 'drivers=false' 'compose=true'
'core=false' 'optimizer=false' 'roofmodel=false' 'web=false' 'drivers=false' 'compose=true'

echo "test workflow path classifier contract passed"
Loading