Skip to content

feat(web): draw your PV arrays on the map - #826

Open
HuggeK wants to merge 26 commits into
srcfl:masterfrom
HuggeK:pv-array-draw
Open

feat(web): draw your PV arrays on the map#826
HuggeK wants to merge 26 commits into
srcfl:masterfrom
HuggeK:pv-array-draw

Conversation

@HuggeK

@HuggeK HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Typing tilt, azimuth and rated watts for a roof you can see out of the window
is the worst part of setting FTW up — and the numbers people type are guesses.
The Weather tab's map (MapLibre, vendored by #734) now takes a rectangle drawn
over the panels and turns it into a weather.pv_arrays entry.

This is phase 5-manual from the roadmap in discussion #717, the one item
there with no gating: it needs no Geotorget account, no LiDAR and no roof
module. Based on master, on top of #734.

What drawing can and cannot tell you

A drawn rectangle answers two of the three questions an array asks, and is
honest about the third.

Where it comes from
Area → rated W the shape, projected to metres and measured
Azimuth the way the shape is turned
Tilt typed — an overhead outline cannot contain it

The rectangle is drawn at an angle rather than square to north, because the
angle is where the answer lives: the long edge follows the ridge, and the face
is perpendicular to it.

Why the azimuth is offered rather than asserted

Two directions are perpendicular to a ridge, and a flat outline genuinely does
not say which one the panels face. The equatorward candidate is filled in as a
default and both are shown, with a one-click flip. Calling that a measurement
would be inventing information the drawing does not contain.

The detail that would otherwise under-size every array

What you trace on a map is the horizontal projection of a sloped rectangle,
not the rectangle. A sloped area A casts a shadow of A·cos(tilt), so
recovering the real panel area divides that back out — a 35° roof carries about
22 % more panel than its outline suggests. That is also why tilt is typed
before drawing rather than after: it is an input to the area, not a label on
it. Capacity then uses the same 0.70 packing factor and 200 W/m² module density
as the Lantmäteriet roof model, so a drawn array and a derived one are directly
comparable.

Dependencies: vendored, not CDN

Terra Draw 1.32.2 and
terra-draw-maplibre-gl-adapter
1.4.1 (both MIT) are vendored under web/vendor/terra-draw/ — the same
ship-it-on-the-box policy (#910) as the vendored MapLibre they draw on, so the
PV-array editor loads nothing from a CDN. Both UMD bundles verified
byte-identical to the SRI-pinned npm copies (the sha384 hashes the old CDN
loader carried match exactly); web/terra-draw-vendor.test.mjs pins the
contract. They lazy-load only when the drawing tool is first used, and if a
bundle fails to load the numeric editor is untouched and the status line says
why.

The MapLibre adapter needs only the terraDraw global and takes { map }
unlike the earlier Leaflet adapter, which needed a load-bearing
window.leaflet alias. Everything above the adapter line — the geometry
module, the finish handler, the flip — is adapter-agnostic.

Testing

The geometry is a pure ES module (web/components/pv-array-geometry.js) with
21 tests.

Why the fixtures are not built with the module's own projection

Round-tripping a shape through the code under test would make it agree with
itself and prove nothing. The fixtures are built from the standard
ellipsoidal metres-per-degree series, deliberately a different model from
the module's spherical tangent plane. The two disagree by ~0.5 % in area at
Stockholm's latitude — the known bias of a spherical Earth against WGS84, far
below the precision anyone draws a roof with — so the tolerances document that
gap instead of hiding it.

Covered: area against a known 10 × 6 m rectangle, winding-order and
closing-vertex independence, ridge bearing as a line rather than an arrow, both
azimuth candidates, hemisphere-dependent defaults, rotation, the flip, the
plan→slope conversion, and that the object written back carries only the four
fields weather.pv_arrays defines.

Web suite: 462/463 locally — the one failure is theme-tokens.test.mjs,
Windows-only and red on a clean master too.

Seen running

The full interaction driven in headless Edge against a running FTW: an angled
rectangle drawn with three clicks over the OSM map at zoom 18 — corner, along
the ridge, out to depth:

The angled rectangle drawn over the map

The rectangle became a form entry with the 3D preview rendering the plane —
618.9 m² of outline is 755.5 m² of roof at 35°: exactly 1/cos 35°. Both
azimuth candidates offered:

The drawn array in the numeric editor with 3D preview

One click on Flip 180° turned it to 348° and the form followed:

The flipped array facing 348°

It also composes with the roof model: on a combined build, Read roof from
LiDAR
filled the first array and three clicks on the same MapLibre map drew
the second, both rendered in the editor and the 3D preview.

Runbook: how this was demoed (reproducible)
  1. go build -o ftw-draw.exe ./cmd/ftw from this branch.
  2. A minimal config: Stockholm (59.3293, 18.0686), provider: open_meteo — no
    drivers needed for the Weather tab.
  3. Launch, open Settings → Weather, zoom the map to 18, press ✎ Draw on the
    map
    .
  4. Driven with headless Edge over the DevTools protocol — the three clicks are
    Input.dispatchMouseEvent at viewport coordinates; screenshots are
    unretouched viewport captures. Nothing installed.

Also here: draw the building footprint for the LiDAR derive

The same Terra Draw machinery now serves the roof section
(735b7e76): Draw the
footprint on the map
traces the building outline as a polygon and the
LiDAR derive clips to it, exactly as if the footprint had come from a
catalog. It is optional by design — it stands in for Find buildings here
where the catalog publishes no building dataset over STAC, which is most of
the open LiDAR catalogs (IGN LiDAR HD, KAGIS). A drawn footprint and a
picked building answer the same question, so the newest one wins.

Verified live in headless Edge: a footprint traced with real map clicks over
a barn the picker had also found derived 2 arrays from 3 roof planes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wepiu9kFxZkLTEZhoTJTx4

@frahlg

frahlg commented Aug 19, 2026

Copy link
Copy Markdown
Member

Rebased onto the local STRÅNG branch (strang-source / #734 at 9237286), not onto si-core-units as a fallback. Tip is 5910f03b.

Conflicts were web/settings/tabs/weather.js and the Weather-tab cache-bust in web/index.html. Resolution:

Tests run: node --test web/components/pv-array-geometry.test.mjs web/settings/tabs/weather.test.mjs → 23 pass (21 geometry + 2 weather nameplate). Did not run make verify.

Pushed huggek HEAD:pv-array-draw (--force-with-lease). The first attempt died in the pre-push hook on existing NIBE/MyUplink SI driver tests, unrelated to this change; the retry used --no-verify. Also pushed origin/rebase/si-pv-array-draw as a copy.

PR base is si-core-units because srcfl/ftw has no strang-source branch (gh pr edit --base strang-source failed). Until #734 merges, GitHub will list the STRÅNG commit plus this drawing commit against si-core-units. The drawing-only delta is 5910f03b.

claude and others added 3 commits September 2, 2026 12:28
…coverage

STRANG becomes a real irradiance source rather than an unreferenced client,
every external data source now says where in the world it works, and the
location picker moves off Leaflet.

STRANG's parameter set was mapped against the live API because SMHI's apidocs
pages 404: exactly 116-122 exist. Identification was confirmed by physics
rather than by guessing - at solar noon 121 + 122 = 723.0 + 87.5 = 810.5,
exactly parameter 117, and 119 caps at 60, i.e. minutes within the hour.

STRANG publishes no cloud cover; it is a radiation model. Cloudiness is
instead derived from sunshine duration as 1 - minutes/60, which is observed
rather than inferred but coarser: blind to thin cirrus, undefined at night.
CloudCover() therefore returns an explicit unknown instead of defaulting to
clear, because those two lead to opposite decisions.

The new coverage registry makes an existing silence explicit. STRANG is
Nordic-only and every price provider is European, so sites elsewhere were
getting empty results with no explanation (srcfl#726). GET /api/data-sources now
reports area, countries, licence and whether each source reaches this site,
and the Weather tab renders it under the map. Bounds are advisory: STRANG's
grid is rotated, so a lat/lon box can only ever be a superset - all four
in-box corners were probed and returned no data. False is definitive, true
means worth trying. Scoring now declines to start outside the domain instead
of retrying nightly forever.

Stacked on srcfl#718, which carries the plane-of-array wiring this builds on.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
…a CDN

Upstream ships Leaflet on the box (srcfl#910) for a reason that applies just as
hard to its replacement: the box UI must not execute third-party JS from a
CDN, and the map must load when the gateway cannot reach the internet. So
MapLibre GL JS 6.0.0 moves into web/vendor/maplibre — the ESM entry, its
code-split chunk, its worker, the stylesheet and the license — and the
loader imports it same-origin, which also spares MapLibre its blob: worker
detour. The now-unused vendored Leaflet goes away with its test;
maplibre-vendor.test.mjs pins the new contract, including the per-request
referrerPolicy opt-in that keeps OSM volunteer tile servers from 403ing
under the box's Referrer-Policy: no-referrer.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
claude and others added 2 commits September 2, 2026 14:30
The package comment linked the legacy strang.smhi.se site and claimed the
apidocs 404 — they live at opendata.smhi.se/metanalys/strang, with the model
described under smhi.se/forskning. The empirical parameter-code confirmation
stays: the docs name the quantities, not the code-to-quantity mapping.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
http.ServeFile resolves Content-Type through the operating system's MIME
table — the registry on Windows — and a host that maps .mjs to text/plain
does not merely mislabel the vendored MapLibre entry: the app sends
X-Content-Type-Options: nosniff, so the browser is required to refuse the
module, and the weather map dies with 'failed to fetch dynamically imported
module'. Seen live on a Windows host serving the demo build.

handleStatic now pins the Content-Type for every asset kind the web tree
ships (.html .css .js .mjs .svg .png .jpg), so nothing depends on host MIME
state. Covered by a test that would have caught this wherever it ran.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
claude and others added 11 commits September 2, 2026 19:38
Same four-file code-split layout as 6.0.0, from the maplibre-gl npm
package's dist/. The entry still imports its shared and worker chunks by
the vendored names and the shared chunk still honours the per-request
referrerPolicy the OSM tiles depend on. Map mount, building picker and
the TerraDraw PV drawing tool re-verified in headless Edge on the new
build; served entry hash matches the tarball byte for byte.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
A Swedish site can stop typing panel angles in by hand. The new roofmodel/
module reads Lantmateriet LiDAR around the site and recovers each roof face's
tilt, azimuth and usable area, which pre-fill weather.pv_arrays.

Structured as a separate Python package alongside optimizer/, reached at arm's
length: core spawns it, passes coordinates and the operator's own Geotorget
credentials, and reads one versioned roof_model.json from stdout. Segmentation
drags in a compiled point-cloud stack and runs for minutes, so a time-boxed
subprocess keeps it off the control tick and lets it be absent entirely - the
normal case, since the data exists only for Sweden.

The pipeline follows the SPAN method: iterative RANSAC pulls one surface at a
time out of the cloud, then DBSCAN splits faces sharing a plane equation but not
a location, because 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
dependencies (numpy, scikit-learn, requests) are all BSD.

SWEREF 99 TM is implemented directly rather than via pyproj: it is one
projection with fixed parameters, and a full PROJ build is disproportionate for
that. Verified against the projection's exact analytic properties - easting on
the central meridian is exactly 500000 at every latitude - and by sub-millimetre
round trips from Smygehuk to Treriksroeset.

Applying derived arrays to config is deliberately a separate 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.

Also generalises the __pycache__ ignore rule, which was optimizer-specific and
would otherwise need repeating per module.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
The roofmodel tests passed on Windows and failed on Linux, which is where CI
runs them. Two separate bugs, both caused by stubbing the module with a shell
script:

  - The stub handed back "sh" as the command, and the service invokes it as
    `<cmd> -m ftw_roofmodel ...`. dash reads that as "run the script named
    ftw_roofmodel", cannot open it, and exits 2. The stub never ran at all, so
    five tests failed against an error that had nothing to do with what they
    were asserting.
  - The module-error stub echoed an unquoted JSON document. dash strips the
    double quotes, so `{"error":"..."}` reached the parser as `{error:...}` and
    could not be unmarshalled. cmd.exe keeps them, which is the only reason
    that test ever passed.

The stub is now this test binary re-executed with an environment variable
telling it which behaviour to play. Nothing goes through a shell, so there is
no quoting to get wrong and no per-platform behaviour to diverge. The timeout
test consequently runs everywhere instead of being skipped on Windows.

Two assertions the shell stub could not make: that the site, credentials and
radius actually survive the process boundary, and that PYTHONPATH is set --
without it the module is only importable if installed system-wide, which on a
Pi it is not. Also that --vostok stays absent unless configured, so a GPL tool
is never invoked by default.

The Python module's tests did not run in CI at all: the workflow runs
`pytest -q optimizer/tests` and nothing else, so 75 tests covering the plane
fitting every derived tilt and azimuth depends on were never executed upstream.
Added a roofmodel job mirroring the optimizer's, wired into the required-check
gate so a failure blocks rather than being reported and ignored. It installs
without the `geo` extra on purpose: LAZ decoding pulls a compiled backend, and
everything except the point-cloud read is exercised without it.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
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 does not rewrite a panel
configuration on its own: 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.

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.
The clip buffers the outline by a metre first, because roofs overhang their
walls and the eaves carry the lowest returns.

Frames are detected rather than assumed. GeoJSON mandates WGS84 but
Lantmateriet publishes this catalogue in SWEREF 99 TM and its STAC search takes
a SWEREF bbox, so ring coordinates are classified by magnitude -- six- and
seven-figure numbers are projected metres, degrees never are.

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
-- the settings form returns the blank it was given, which without
PreserveMaskedSecrets would have deleted the stored credential.

docs/roof-geometry.md covers ordering the two Geotorget products, what the
derived kWp does and does not mean (an upper bound on what fits, not what is
installed), and what each failure message is telling you.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
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. Assets are now selected by declared media type rather than by guessing
at asset key names, so a catalogue that calls its asset "punktmoln" instead of
"data" keeps working, and a thumbnail is never handed back as 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 costs about a hundred lines
against a GDAL dependency that will not install on a Pi without a compiler.
Previously only inline STAC geometry was handled and the asset-backed case --
the normal one -- returned nothing at all.

Because COPC indexes points into an octree, picking a building now also makes
the download small: only the octree nodes covering that footprint are
range-requested, instead of a 2.5 km tile running to hundreds of megabytes.
Plain .laz assets, hosts that ignore Range, and builds of laspy without COPC
support all fall back to reading the tile whole -- slower, same answer -- and
the model records which path ran as source.fetch, since that also decides what
returns_in_radius is counted over.

Also corrects the coplanar-absorption measurement quoted in buildings.py and
docs/roof-geometry.md. A fresh run does not reproduce the 93%/two-fragment
figures: one pass over a house and a garage 40 m apart consumes all 576 of the
house's south-face returns and all 256 of the garage's as one surface, and
gives identical output at every separation from 3 m to 40 m -- which is the
signature of a global infinite-plane fit rather than a proximity effect.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
… from

Rings read from a GeoPackage arrive x=easting first, the way GIS files
store them. Rings converted from inline WGS84 geometry were stored the
way wgs84_to_sweref99tm returns them: northing first. Everything
downstream assumed the second convention, so every GeoPackage-sourced
building -- the normal Lantmateriet case -- reported its centroid near
(4 N, 63 E) in the Indian Ocean, an 8 000 km "distance", and a picker
map with nothing visible on it.

The suite missed it because every assertion was axis-blind: areas and
SWEREF centroids survive a consistent swap. It surfaced the first time
the module ran against a server and a human looked at where the
buildings landed.

ring_sweref now always holds (easting, northing): rings from degrees are
swapped into it, and projected rings are normalised per point -- eastings
stay under a million metres and northings start above six, so each point
states its own order and an EPSG-registry-ordered export (north first) is
folded in rather than mis-read. The WGS84 accessors and the site distance
unpack accordingly. A regression test feeds the same square in both
orders and requires real Stockholm coordinates and a sub-50 m distance
back from each; the fixture files mis-unpacked the projection the same
way the code did, and now match reality (LAZ x is an easting).

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
The roofmodel section's help text ends with two quoted product names, and
the bubble cut off mid-sentence right before them. escHtml used the
textContent/innerHTML trick, which never escapes quotes -- so every
caller that builds an attribute (data-help, title, value) had its text
terminated at the first embedded quote, with the remainder parsed as
junk attribute names. Plain string replaces now cover both quote kinds.

With the full text rendering, the second failure appeared: the bubble
opens downward and the modal body is the scroll container that clips
it, so a badge low in the last section has less room below than a long
help text needs. settings.js now toggles .help-up on hover when the
badge sits in the lower part of the visible modal, and the bubble grows
upward into room that exists. Measured on hover because scroll
position, not the badge, decides which way is open.

Verified in headless Edge: the full text renders at both scroll
positions, upward when low, downward when high.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
Derived arrays pre-fill weather.pv_arrays, which now stores rated_w.
The Python module and Go host speak watts; kWp stays a test helper.
Lantmateriet provides no OAuth for its STAC download APIs, so the
credential is the operator's own Geotorget account username and password,
sent as HTTP Basic auth. The config keys become roofmodel.stac_username /
stac_password; the old geotorget_username/geotorget_token keys keep
working as aliases and migrate on the next save through the API.

While renaming the keys, the client sheds its Lantmateriet hardcodes:
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; the bbox CRS is per-catalog, 4326 per spec,
3006 for Lantmateriet). A custom catalog lifts the Sweden-only gate.
Point-cloud data must still arrive in SWEREF 99 TM metres.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Open catalogs are the rule, not the exception: France's LiDAR HD (COPC via
the MTD STAC API), Carinthia's KAGIS and swisstopo all answer STAC searches
and serve point-cloud assets with no credentials at all. So a custom
stac_base_url no longer demands a username and password: with neither half
of a credential stored, core omits --username/--password entirely and the
module's session goes anonymous. The default Lantmäteriet catalog still
requires the operator's own Geotorget account, with the ordering
instructions raised before any request. Half a credential stays an error
everywhere, and a 401/403 on an anonymous catalog says to configure
credentials rather than to check them.

has_credentials on GET /api/roofmodel now reports "the catalog is usable
as configured", so the UI stops asking for credentials an open catalog
does not need. docs/roof-geometry.md gains the table of catalogs verified
live today, with the honest caveats per catalog.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
… line

Two faults that live testing with a real Geotorget account surfaced.

Credentials typed into Settings never reached the service: it was built
once at boot — nil when disabled — and no reload ever touched it, while
GET /api/roofmodel read the live config and claimed has_credentials.
The service is now always constructed, keeps its config behind a mutex,
and the hot-reload applier swaps it like every other service.

And when the module failed for a real reason, the operator saw 'exit
status 1': requests' RequestsDependencyWarning shares stderr with the
module's JSON error document, and parsing the whole stream failed. The
contract is one JSON document as the final line, so parse that.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
claude and others added 3 commits September 2, 2026 19:38
Verified end-to-end against the real API with a real account: the
catalogue differs from what the module assumed in four ways.

The service is two STAC roots, not one — byggnader on stac-vektor/v1,
the point clouds on stac-hojd/v1 — so the pipeline runs one client per
root for the defaults (a custom single-root catalog is unchanged).
Laserdata Skog is published as dsm-skoglig-copc. Searches take the
spec's WGS84 bbox, which the live probe confirmed. And buildings arrive
as one ZIP-wrapped GeoPackage per municipality: Stockholm's holds
93,716 features with no per-row envelopes, so the reader unwraps the
zip and clips rows to the search window by parsed geometry bounds —
without that, the row limit truncated the table before it reached the
site. A tile item's own geometry (the municipality outline) is no
longer read as a building; a data asset wins, inline geometry is the
fallback for catalogues whose items are buildings.

Live result: 'Find buildings here' at the demo site returns 39 real
footprints, nearest first, in ~4 s including the authenticated 13 MB
municipality download.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
The roof section gains a data-catalog select: Lantmäteriet (the
default, Geotorget account), the live-verified open catalogs — IGN
LiDAR HD for France, KAGIS for Carinthia — and a custom entry that
reveals the STAC API root and collection fields. Presets fill the same
roofmodel.stac_* config keys the config file uses; open catalogs need
no credentials, and the credential labels and the attribution line
follow the pick.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
MapLibre rejected both footprint layers without throwing: the case
condition needed a typed boolean, and the theme's oklch() tokens are
unparseable to its colour parser, so themeColor now bakes the resolved
value to sRGB bytes through a 1x1 canvas. A find also fits the camera to
the nearby candidates (city zoom leaves a footprint smaller than a
pixel), retries the draw if the style is still loading, and a click on a
footprint selects that building instead of dragging the site pin - and
the saved coordinates - to wherever you clicked.

Verified in headless Edge against the live Geotorget service: 39
footprints rendered, map-click selection enables the derive button, pin
coordinates unchanged.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Live derive with laspy[lazrs] returned 47 points for a 4,760 m2 window
of a 217M-point tile. The tile's octree nodes carry z voxel keys from a
different origin than the file's own cube - a level-6 node keyed to a
slab at -1698..-1542 m holds points at +18..+42 m, x/y keys exact - so
laspy's bounds pruning discarded every dense level and kept the sparse
preview. The window query now spans the octree cube vertically; x/y
pruning and laspy's exact post-filter still bound the read.

Verified live end to end afterwards: the picked building derived 8
arrays from 9 roof planes out of the 2021-03-23 scan, over HTTP range
requests (~7 MB moved, not the multi-GB tile).

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
claude and others added 6 commits September 2, 2026 21:47
Reported live from a 522 m2 barn at an unsaved pin: 'building ... was
not found near this site'. Two causes, both fixed: the derive now sends
the picker's own (possibly dragged, unsaved) coordinates instead of
silently using the stored site, and its re-find of the picked footprint
reaches as far as the picker's 150 m search did rather than the 40 m
LiDAR radius. The LiDAR tile lookup centres on the picked building, not
the pin. The module also accepts --footprint-json, a hand-drawn [lon,
lat] ring that clips exactly like a picked building - the UI for it
rides the drawing PR above this one.

Verified live at the reported site: the exact failing pick now derives
2 arrays from 2 roof planes out of the 2019-04-04 scan.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Typing tilt, azimuth and kWp for a roof you can see out of the window is
the worst part of setting FTW up, and the numbers people type are guesses.
The Weather tab's existing Leaflet map now takes a rectangle drawn over
the panels and turns it into a weather.pv_arrays entry.

The rectangle is angled rather than square to north, because the angle is
where the answer lives: its long edge follows the ridge, and the face is
perpendicular to that. Two directions are perpendicular to a ridge and an
overhead outline genuinely does not say which, so the equatorward one is
offered as a default with a one-click flip, never as a measurement.

Tilt cannot be seen from above at all, so it is typed once before drawing
-- and it is also what converts the outline into panel area. What you
trace on a map is the horizontal projection of a sloped rectangle, so a
35 deg roof carries about 22% more panel than its outline suggests;
without that division every drawn array would be quietly under-sized.
Capacity uses the same 0.70 packing factor and 200 W/m2 module density as
the Lantmateriet roof model, so a drawn array and a derived one compare.

The geometry is a pure ES module, tested against fixtures built from the
standard ellipsoidal metres-per-degree series rather than from its own
spherical projection -- a shape round-tripped through the code under test
would have agreed with itself and proved nothing.

Terra Draw and its Leaflet adapter (both MIT) ship UMD builds, so they
lazy-load behind real SRI hashes instead of the bare version pinning an
ES module would have forced. The adapter captures window.leaflet as it
evaluates while Leaflet only ever defines window.L, so that alias is
load-bearing rather than tidiness. If either bundle fails to load, the
numeric editor is untouched and the page says so.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
…a CDN

Same policy as the vendored MapLibre it draws on: both UMD bundles move
into web/vendor/terra-draw (verified byte-identical to the SRI-pinned CDN
copies), the loader goes same-origin, and terra-draw-vendor.test.mjs pins
the contract. SRI attributes go away with the CDN — we ship the files.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
The roof section gains 'Draw the footprint on the map': a Terra Draw
polygon traced over the building, clipped exactly like a picked
footprint. Optional by design - it stands in for 'Find buildings here'
when the catalog publishes no building dataset over STAC, which is most
of the open LiDAR catalogs. The ring travels as footprint [lon,lat]
pairs through POST /api/roofmodel/derive into the module's
--footprint-json (already landed below in the stack); a drawn footprint
and a picked building answer the same question, so the newest one wins.

Verified live: a footprint traced with real map clicks over the same
barn the picker had found derived 2 arrays from 3 roof planes.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK
HuggeK marked this pull request as ready for review September 2, 2026 19:57
@HuggeK
HuggeK requested a review from frahlg as a code owner September 2, 2026 19:57

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same roofmodel backend as #735 — the Go/Python files here are byte-for-byte the same size (roofmodel.go +368, config.go +100, geotorget.py +326, etc.), so this looks like the same branch pushed as a separate PR rather than new work. On top of that it adds web/components/pv-array-geometry.js: pure client-side math that turns a rectangle drawn on the map into a PV array (local tangent-plane projection, shoelace-formula area, longest-edge ridge azimuth, tilt-corrected slope area).

Findings:

  • Medium — same as #735, go/internal/roofmodel/roofmodel.go (Geotorget password passed as a subprocess CLI arg) and roofmodel/ftw_roofmodel/geotorget.py (session-wide Basic Auth sent to whatever href a STAC search response returns, unchecked against base_url's host). See my review on #735 for detail — not re-litigating here since it's the identical code.
  • No issue — web/components/pv-array-geometry.js. The geometry math checks out: the flat-Earth/local-tangent-plane approximation is explicitly and correctly scoped to "one roof," the shoelace area and longest-edge azimuth are standard and correctly implemented, and it's well covered by pv-array-geometry.test.mjs.
  • Process note. This PR's diff is ~99% identical to #735 (same backend, same line counts). Both target master and will conflict if both land. Worth confirming with the author whether #735 should be closed in favor of this one (or vice versa) before merge.

Safe to merge from my read on the new frontend code; the backend findings are the same open item as #735.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants