Skip to main content
Glama
README.md
# roofgeometry-prospect

A small MCP server and CLI that turns the **Google Solar API** roof data for one building into an
**installer-ready roof geometry packet**: per-segment pitch (degrees and rise/12), azimuth, facing, area, plane
height and sunshine quantiles, plus derived facts an installer or a sales agent asks first: how much **usable
south/west area** there is, whether the roof is **steep** (fall protection, extra labour), how **complex** it is,
and a **shading proxy**. Output is compact JSON, Markdown and a simple **SVG roof sketch**.

It can also ask for the Solar API's **pre-GA roof geometry** option (`experiments=ROOF_GEOMETRY_INSIGHTS` +
`additionalInsights=ROOF_GEOMETRY`), which adds facet polygons and rooftop obstacles. That option is opt-in and
labelled pre-GA everywhere; see [What we checked](#what-we-checked-in-googles-docs).

## The problem

`buildingInsights:findClosest` already returns roof segments with pitch, azimuth, area, height and sunshine, but as
raw numbers an agent has to interpret: which faces are worth panels, which are too small, which are steep, how
many planes a crew will deal with. Installers want that as a one-page answer before they roll a truck. This lab
does the packaging in one tool call and labels the source of every threshold.

## Quickstart

```bash
bun install --frozen-lockfile

# Keyless demo: synthetic buildings in the documented response shape (fictional addresses, open-ocean points)
bun run src/cli.ts roof --address "100 Example Ave, Sampletown" --fixtures
bun run src/cli.ts roof --address "100 Example Ave, Sampletown" --roof-geometry --svg roof.svg --fixtures
bun run src/cli.ts roof --address "9 Gable Row, Steepville" --format json --fixtures
bun run src/cli.ts roof --address "7 Sample Plaza, Calmtown" --min-quality MEDIUM --fixtures

# Live: put GOOGLE_MAPS_API_KEY in .env (see .env.example), then
bun run src/cli.ts roof --address "<address>" [--roof-geometry] [--steep-pitch 35] [--svg roof.svg]
bun run src/cli.ts roof --at=37.4,-122.1 --min-quality MEDIUM      # negative numbers need --at=...

# Methodology (formulas, thresholds, pre-GA status, references)
bun run src/cli.ts method

# MCP server (stdio)
bun run src/cli.ts serve            # live
bun run src/cli.ts serve --fixtures # keyless demo
```

MCP client config (Claude Code `.mcp.json`, Cursor `mcp.json`):

```json
{
  "mcpServers": {
    "roofgeometry": {
      "command": "bun",
      "args": ["run", "/path/to/roofgeometry-prospect/src/cli.ts", "serve"],
      "env": { "GOOGLE_MAPS_API_KEY": "${GOOGLE_MAPS_API_KEY}", "ROOFGEO_BUDGET": "10" }
    }
  }
}
```

The key's Cloud project must have the Solar API (`solar.googleapis.com`) enabled with billing on, plus the
Geocoding API for `address` lookups. Restrict the key to those APIs.

## Tools

| Tool | What it does | Google calls |
| --- | --- | --- |
| `roof_geometry` | `address` or `lat`+`lng`; `min_quality` (HIGH/MEDIUM/LOW/BASE, default HIGH), `exact_quality`, `roof_geometry` (pre-GA, default false), `fallback` (default true), thresholds `steep_pitch_deg`, `flat_pitch_deg`, `shade_threshold`, `min_panels`; `format` json / markdown / svg / all (default json + markdown) | 1 Geocoding v4 call for an address, plus 1 `findClosest` call (2 if a pre-GA request is rejected and retried) |
| `geometry_method` | Formulas, thresholds and their sources, what is verified about the pre-GA option, attribution, references | none |
| `session_status` | Budget used/remaining, refused calls | none |

CLI: `roofgeo roof | method | serve`. Exit codes: 0 ok, 2 error, 64 usage.

### What the packet contains

Per segment (copied from `roofSegmentStats[i]` unless marked derived):

| Field | Meaning |
| --- | --- |
| `pitchDeg`, `pitchRiseIn12` | Pitch, and derived `12 x tan(pitch)` (e.g. 6/12) |
| `azimuthDeg`, `facing` | Azimuth (dropped for flat segments: Google sets it to 0 there); derived 8-point facing or `flat` |
| `areaM2`, `groundAreaM2` | Roof area (tilt included) and footprint |
| `heightMslM`, `heightAboveLowestSegmentM` | Plane height at the segment center, meters above sea level; derived difference from the lowest segment |
| `sunshine` | min / p25 / median / p75 / max, interpolated from `sunshineQuantiles` (kWh/kW per year) |
| `medianPctOfPeak`, `shadedShare` | Derived: median vs `maxSunshineHoursPerYear`; share of area below 80% of that peak (shading proxy) |
| `steep`, `prime`, `usable` | Derived flags (rules below) |
| `panelsInMaxLayout` | Panels on this segment in the API's maximum layout (`solarPanels[].segmentIndex`) |
| `polygon` | Pre-GA only: rings, holes and ground footprint of `polygonGeojson` |

Summary: `primeAreaM2`, `usableAreaM2` + `usableSegments`, `flatAreaM2`, `areaByFacing`, `steepRoof` +
`steepSegments` + `steepAreaShare`, `complexity` (score, tier), `shading`, `obstacles` (pre-GA), `heightRangeM`,
`maxArrayPanelsCount`, panel size. Plus building id, imagery quality and date, distance from the query point,
`roofGeometry` (requested / returned / pre-GA / fallback), `thresholds` with sources, notes, attribution.

### Rules and their sources

| Rule | Default | Source |
| --- | --- | --- |
| steep = pitch > threshold | 18.43° (slope > 4 in 12) | [OSHA 29 CFR 1926.500(b)](https://www.osha.gov/laws-regs/regulations/standardnumber/1926/1926.500) "steep roof"; override with `steep_pitch_deg` |
| `steepRoof` | any usable segment is steep (any segment if none is usable) | lab rule |
| flat = pitch < threshold | 10° | lab default |
| prime = non-flat, facing within 90° of 225° (north of the equator: S, SW, W) or of 315° (south of it: N, NW, W) | | lab rule |
| usable = (prime or flat) and area >= N x panel height x panel width | N = 4 panels, panel size from the response | lab default |
| shading proxy = share of area below X x `maxSunshineHoursPerYear` | X = 0.8 | lab default |
| complexity score = segments + obstacles (obstacles only with pre-GA geometry) | tiers: simple <= 2, moderate <= 4, complex <= 8, else very-complex | lab default |
| far building note | center > 30 m from the query point | lab default |

The SVG sketch draws the building and segment bounding boxes (dashed) or, with pre-GA geometry, the facet polygons
(solid, holes cut out) and obstacles, on a local meter grid with north up, a scale bar, downslope arrows, hatching
for steep faces and the attribution line. It is a schematic, not a map.

![Sample sketch (synthetic building)](reports/sample-sketch.svg)

## Measured

`bun run report` (keyless): the real MCP server through an MCP client, against synthetic fixtures (invented
buildings, not real data). Byte counts are JSON/Markdown/SVG string lengths.

<!-- measured:start -->
| Scenario | Input | Calls | Imagery | Segments | Prime m² | Usable m² | Steep (threshold deg) | Max pitch | Complexity | Shaded share | Obstacles | Polygons | Fallback | Solar response bytes | Packet JSON bytes | Markdown bytes | SVG bytes | MCP = service |
| --- | --- | ---: | --- | ---: | ---: | ---: | --- | ---: | --- | ---: | ---: | --- | --- | ---: | ---: | ---: | ---: | --- |
| hip roof, GA fields | address | 2 | HIGH | 6 | 90.6 | 124.6 | yes (> 18.43, osha-4-in-12) | 26.8 | complex (6) | 55% | - | false | false | 8332 | 5763 | 2313 | 4409 | true |
| hip roof, pre-GA roof geometry | address, roof_geometry | 2 | HIGH | 6 | 90.6 | 124.6 | yes (> 18.43, osha-4-in-12) | 26.8 | complex (8) | 55% | 2 | true | false | 9926 | 5969 | 2475 | 4642 | true |
| hip roof, steep at 35 deg | address, steep_pitch_deg 35 | 2 | HIGH | 6 | 90.6 | 124.6 | no (> 35, user) | 26.8 | complex (6) | 55% | - | false | false | 8332 | 5746 | 2230 | 4013 | true |
| steep cut-up roof | address | 2 | HIGH | 9 | 105.8 | 95.4 | yes (> 18.43, osha-4-in-12) | 50.0 | very-complex (9) | 71% | - | false | false | 6498 | 7313 | 2628 | 6210 | true |
| southern hemisphere gable | address | 2 | HIGH | 3 | 76.8 | 76.8 | yes (> 18.43, osha-4-in-12) | 22.0 | moderate (3) | 59% | - | false | false | 4135 | 4155 | 1990 | 3107 | true |
| flat commercial, MEDIUM | address, min_quality MEDIUM | 2 | MEDIUM | 2 | 0.0 | 480.1 | no (> 18.43, osha-4-in-12) | 1.1 | simple (2) | 7% | - | false | false | 19220 | 3705 | 1893 | 2332 | true |
| pre-GA rejected, GA fallback | lat/lng, roof_geometry | 2 | HIGH | 2 | 46.2 | 46.2 | yes (> 18.43, osha-4-in-12) | 30.0 | simple (2) | 54% | - | false | true | 2923 | 3794 | 2254 | 2750 | true |

- Pre-GA polygons: ground footprint from `polygonGeojson` (shoelace) vs the segment's `groundAreaMeters2`: max difference **0%** on the synthetic hip roof (a round-trip check of our projection and shoelace code, since the fixture is drawn with the same projection; not evidence about real Google polygons).
- Flat-roof address at the default `min_quality` HIGH: **google_NOT_FOUND** (synthetic 404); with MEDIUM it answers (row above).
- 403 SERVICE_DISABLED (synthetic copy of the error shape): **google_PERMISSION_DENIED** / **SERVICE_DISABLED**; with `roof_geometry` the same error, no fallback retry (**SERVICE_DISABLED**).
- Pre-GA rejected with `fallback: false`: **google_INVALID_ARGUMENT**.
- Point with no building: **google_NOT_FOUND**.
- Budget 3, two address lookups (2 calls each): ok, budget_exceeded.
<!-- measured:end -->

### Live status (2026-10-21)

With a Google Maps Platform key from our test project (`bun test tests/live`, `bun run record`):

- Geocoding v4: answered (the address resolves to the expected area).
- Solar API `findClosest`, GA request and pre-GA roof geometry request: both **HTTP 403 PERMISSION_DENIED,
  reason SERVICE_DISABLED** (the Solar API is not enabled in that project, and we did not enable it). The live test
  prints a `skipped:` line for this case.

So **no real Solar response has been seen by this lab**, with or without roof geometry. The packet logic runs only
on synthetic fixtures shaped from the docs.

## What we checked in Google's docs

Checked on 2026-10-21.

| Item | What we found | Source |
| --- | --- | --- |
| HTML reference: `experiments[]` | Lists only `EXPANDED_COVERAGE`; requests using `experiments` are "classified as a pre-GA offering" under the Maps Service Specific Terms | [findClosest](https://developers.google.com/maps/documentation/solar/reference/rest/v1/buildingInsights/findClosest), [Experiment](https://developers.google.com/maps/documentation/solar/reference/rest/v1/Experiment) (both last updated 2026-05-12) |
| HTML reference: `additionalInsights[]` | Lists only `DETECTED_ARRAYS` | same |
| Discovery document | **`experiments` enum includes `ROOF_GEOMETRY_INSIGHTS`** ("Enables experimental roof geometry data via FindClosestBuildingInsights"; requires `ROOF_GEOMETRY` in `additional_insights`; error if passed to GetDataLayers). **`additionalInsights` enum includes `ROOF_GEOMETRY`**: adds `polygonGeojson` to each `RoofSegmentSizeAndSunshineStats` and `obstacles` to `SolarPotential`. New schema `Obstacle { polygonGeojson }`. Both GeoJSON fields: RFC 7946 Polygon, holes as extra loops | [discovery doc](https://solar.googleapis.com/$discovery/rest?version=v1), revision 20261019 |
| When it appeared | The Wayback Machine copy of the discovery doc captured 2026-03-12 (revision 20260310) has neither value. **The "Oct 2 2026" date in the idea could not be verified**; we only know it is between those revisions | [Wayback copy](https://web.archive.org/web/20260312130945/https://solar.googleapis.com/$discovery/rest?version=v1) |
| Release notes | Newest entry May 11, 2026 (detected arrays GA); nothing about roof geometry | [release notes](https://developers.google.com/maps/documentation/solar/release-notes) (last updated 2026-10-20) |
| Pre-GA terms | Experimental: no SLA or support obligation, not intended for production, may change in backward-incompatible ways. Preview: no SLA, test environments | [launch stages](https://developers.google.com/maps/launch-stages) (last updated 2026-10-20) |
| GA roof segment fields used | `pitchDegrees`, `azimuthDegrees` (0 for flat), `stats.areaMeters2` (tilt included), `stats.groundAreaMeters2`, `stats.sunshineQuantiles` ((N-1)-iles, kWh/kW), `center`, `boundingBox`, `planeHeightAtCenterMeters` (above sea level) | findClosest reference + discovery doc |
| Imagery quality | Discovery enum: `HIGH`, `MEDIUM`, `LOW`, `BASE` | discovery doc |
| Attribution | "Source: Includes solar data from Google" next to Solar data; "Includes data from Google Maps" for content created from it | [Solar API policies](https://developers.google.com/maps/documentation/solar/policies) (last updated 2026-10-20) |
| Caching | "Content pre-fetching, caching, or storage is generally restricted" (place IDs exempt) | same |

## Attribution and usage requirements

- **Attribution.** Every JSON packet carries `attribution` ("Source: Includes solar data from Google") and
  `derivedAttribution` ("Includes data from Google Maps"); every Markdown packet and SVG sketch ends with both. Keep
  them next to the numbers wherever you show them.
- **No caching.** This server keeps nothing between calls and the repo commits no Google responses. Store packets
  only as your agreement with Google allows.
- **Maps.** If you show Solar results on a map, the policies require a Google Map; the SVG is a schematic without
  any basemap.
- **Pre-GA.** `roof_geometry: true` uses an experimental option under Google's pre-GA terms. Treat it as test-only.

## Limits

- **Unverified live.** See Live status: every number in Measured comes from synthetic fixtures. Real responses may
  differ in fields, value formats or polygon semantics (for example, the third GeoJSON coordinate: the docs' example
  shows `0`, its meaning is not documented; we ignore it).
- **Pre-GA option unverified.** The roof geometry request and fields come only from the discovery document. We do
  not know whether `ROOF_GEOMETRY` works without the experiment flag, whether projects need allow-listing, or how
  it is billed. If Google rejects the request (400, or 403 other than API disabled/blocked), the tool retries once
  without it and says so.
- **Not a site survey or safety assessment.** Roof data comes from aerial/satellite imagery. The steep flag uses the
  OSHA steep-roof slope as a planning signal; it says nothing about the safety of a roof. Labour cost is not
  estimated.
- **Heights** are above sea level; there is no ground elevation in the response, so "height above ground" and
  storey count are not given. `heightAboveLowestSegmentM` is relative only.
- **Shading proxy** comes from the sunshine quantiles, not a shade study; it cannot tell tree shade from
  self-shading or orientation.
- **Complexity tiers and other lab defaults** are our choices, not an industry scale; they are labelled in every
  output and can be overridden where it matters (`steep_pitch_deg`, `flat_pitch_deg`, `shade_threshold`,
  `min_panels`).
- **Closest building.** The API returns the building nearest the point; a bad geocode still gives a confident answer.
- **Budget scope.** One upstream call budget per process / MCP connection (default 10), not per key or per day.
- **Keyless mode answers only the demo inputs** in `fixtures/synthetic/INDEX.json`; anything else returns
  `google_NO_FIXTURE`.
- **Not included:** the `dataLayers` endpoint (DSM / mask GeoTIFFs, which would give real 3D outlines), batch address
  lists, detected arrays (see `solarscope-detected-arrays`), financial analysis (see `solarscope-mcp`).

## Development

```bash
bun run check               # install, skills layout, strict YAML, biome, tsc, unit + e2e tests, live tests (skip
                            # cleanly), report, bun audit, actionlint (if installed), gitleaks
bun test tests/unit tests/e2e
bun test tests/live         # needs GOOGLE_MAPS_API_KEY; prints "skipped: ..." otherwise (ROOFGEO_SKIP_LIVE=1 forces skip)
bun run fixtures:synthetic  # regenerate fixtures/synthetic from src/synthetic.ts
bun run record              # live: save real responses to gitignored .recorded-local/ for the shape check
```

Credits: builds on the SkilledDragonCorp minilabs `solarscope-mcp` and `solarscope-detected-arrays` (see NOTICE).

## Not affiliated

Not affiliated with, endorsed by, or sponsored by Google LLC or OSHA. Google Maps Platform, Solar API and Geocoding
API are trademarks of Google LLC.

---

Built by [SkilledDragon LLC](https://skilleddragon.com). Questions: hello@skilleddragon.com. Apache-2.0 (see LICENSE
and NOTICE).