Skip to main content
Glama
README.md
# IrradianceCrew Brief

An MCP server and Bun CLI for rooftop solar installers: give it tomorrow's booked jobs as a CSV and it returns a **ranked crew schedule** (Markdown + JSON). For every job it geocodes the address, reads the roof's pitch and azimuth from the Google Solar API, checks each shift hour of the Google Weather API hourly forecast against crew-safety thresholds, estimates irradiance on that roof plane, and says **KEEP**, **MOVE** (new start the same day) or **SLIP**, naming the rule that decided it.

> **Decision support only, not a safety authority.** The thresholds are examples written for this lab, the irradiance figures are a documented estimate (not a measurement or a forecast product), and forecasts can be wrong. The person in charge on site decides whether work goes ahead.

[![CI](https://github.com/SkilledDragonCorp/irradiancecrew-brief/actions/workflows/ci.yml/badge.svg)](https://github.com/SkilledDragonCorp/irradiancecrew-brief/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)

## The problem

An install dispatcher's evening question is "which of tomorrow's roofs can we work, from when, and which jobs slip?". The answer needs three things joined per address: the hourly forecast (gusts, rain, thunder, temperature), the roof itself (which way it faces and how steep it is, for commissioning work that wants strong sun on the array), and the crew's own limits. IrradianceCrew Brief does that join in one read-only tool call and prints the reason next to every decision: "J-102 MOVE to 11:00-17:00: wind_gust_kmh 52 km/h at 08:00 breaks gust".

## Quickstart

```bash
bun install --frozen-lockfile
bun run demo                 # keyless: 9 synthetic jobs -> ranked schedule (reports/example-crew-brief.md)
bun run irradiancecrew profiles
cp .env.example .env         # add GOOGLE_MAPS_API_KEY (Geocoding + Solar + Weather), optional GEMINI_API_KEY
bun run irradiancecrew brief --jobs my-jobs.csv --format both --out briefs/   # live, tomorrow by default
bun run irradiancecrew brief --jobs my-jobs.csv --source nws                  # keyless US forecast, lat/lng rows
```

`jobs.csv` (header names, any order; `job_id`, `date` and an address or `lat`+`lng` are required):

```csv
job_id,date,address,crew,task,start,duration_hours,panels,priority
J-101,2026-11-03,"100 Example Ave, Sampletown",crew-a,install,07:00,6,,2
J-104,2026-11-03,"55 Mockingbird Way, Sampletown",crew-b,commission,09:00,2,12,
```

`task: commission` (any task listed under the profile's `irradiance.tasks`) additionally needs the irradiance estimate on the roof plane to reach the threshold (example: 400 W/m2) every hour of the block. `panels` picks the first N panels of the Solar API layout to decide which roof segments the crew works on. Up to 25 jobs per brief; jobs booked on other dates are listed, not briefed.

### MCP server

```json
{
  "mcpServers": {
    "irradiancecrew": {
      "command": "bun",
      "args": ["/abs/path/irradiancecrew-brief/src/cli.ts", "serve"],
      "env": { "GOOGLE_MAPS_API_KEY": "" }
    }
  }
}
```

Add `"--fixtures"` to `args` for the keyless synthetic demo. Five read-only tools:

| Tool | Input | Upstream calls |
| --- | --- | --- |
| `crew_brief` | `jobs_csv`; optional `date`, `profile`, `rules[]`, `source` (`auto` / `google` / `nws`), `min_quality`, `format`, `include_hours`, `gemini` | per distinct site: 1 geocode (addresses only) + 1 Solar + 1 Weather call per 24 forecast hours; or 2 NWS calls on fallback |
| `job_windows` | one job: `address` or `lat`+`lng`, `task`, `start`, `duration_hours`, `panels`, `date` | same, for one site |
| `brief_method` | none | none: formulas, references, attribution, caching terms |
| `list_profiles` | none | none |
| `session_status` | none | none |

## How a job is decided

Each shift hour (default 07:00-17:00 local) is checked against the profile's rules. A rule states what must hold for GO: broken = **NO-GO**, within its margin = **MARGINAL**, value missing = its `on_missing` (MARGINAL by default). Bundled example profile `crew-example`:

| Rule | Example threshold |
| --- | --- |
| `gust` | `wind_gust_kmh < 40` (MARGINAL within 8) |
| `rain-chance` | `precip_probability_pct < 50` (MARGINAL within 20) |
| `rain` | `precip_mm <= 0.2` per hour |
| `thunder` | `thunderstorm_probability_pct <= 20` (MARGINAL within 10) |
| `frost` | `temperature_c > 1` (MARGINAL within 2) |
| `heat` | `feels_like_c < 35` (MARGINAL within 3) |
| irradiance (commission tasks) | estimate `>= 400 W/m2` every hour of the block |

Decision rules, first match wins:

| Rule | Action | When |
| --- | --- | --- |
| `no-location` | slip | the address does not geocode and the CSV has no lat/lng |
| `no-forecast` | slip | no hourly forecast for the job day (past, beyond 240 h, both sources failed, or no time zone) |
| `planned-window-ok` | keep | every hour of the planned block (start + duration) is GO or MARGINAL, and meets the irradiance threshold when the task needs it |
| `moved-within-day` | move | the planned block fails, but an equally long block inside the shift passes; the earliest one is proposed |
| `no-irradiance-window` | slip | safe blocks exist, but none meets the irradiance threshold (irradiance tasks only) |
| `no-safe-window` | slip | no block of the needed length without a NO-GO hour; the rule behind most NO-GO hours is named |

Ranking: keep, move, slip; then CSV `priority`; then fewer MARGINAL rules in the block; then the higher irradiance estimate; then job id. Same-crew overlaps after moves are flagged for the dispatcher, not resolved. Override thresholds with `--rule "wind_gust_kmh < 30 margin 5 id gust"` (same id replaces), or `--profile-file` (CLI only).

### Irradiance proxy (an estimate)

Neither API gives plane-of-array irradiance: the Weather API `ForecastHour` has `cloudCover` and `uvIndex` but no irradiance field, and the Solar API gives roof geometry and yearly sunshine, not hourly forecasts. The proxy per hour is:

1. Sun position from NOAA's "General Solar Position Calculations" (fractional-year equation of time and declination).
2. Clear-sky GHI = 1098 cos z exp(-0.059 / cos z) W/m2 (Haurwitz 1945; constants as in pvlib-python's `clearsky.haurwitz`).
3. Plane of array with an **assumed** clear-sky diffuse fraction of 0.15: POA = DNI max(0, cos AOI) + DHI (1 + cos tilt) / 2, with tilt = Solar API `pitchDegrees` and azimuth = `azimuthDegrees` (0 = N, 90 = E, 180 = S).
4. Cloud factor 1 - 0.75 (cloudCover/100)^3.4 (Kasten & Czeplak 1980).
5. Mean of four samples per hour; several roof segments weighted by their share of the panels.

Not modelled: shading, aerosols, altitude, the shift to diffuse light under cloud, ground reflection. Use it to rank hours and roofs against each other, not as an energy forecast.

## Measured

<!-- measured:start -->
Produced by `bun run report` ([`scripts/report.ts`](./scripts/report.ts)) on darwin-x64, Bun 1.4.3: each row is one call through the MCP server (in-memory client, real tools, service, HTTP client, rules and irradiance model) with every upstream answered by the **synthetic fixtures** (invented sites, roofs and weather; `now` = 2026-11-02T18:00:00-05:00, brief date 2026-11-03). No network, no keys. Source: [`reports/irradiancecrew-brief.md`](./reports/irradiancecrew-brief.md) and `.json`; full example brief: [`reports/example-crew-brief.md`](./reports/example-crew-brief.md).

| Scenario | Tool | Jobs | KEEP | MOVE | SLIP | Decided by | Weather code path (synthetic data) | Roof source | Upstream calls |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| demo CSV, source auto (Weather API path, NWS fallback) | crew_brief | 8 | 3 | 2 | 3 | planned-window-ok 3, moved-within-day 2, no-irradiance-window 1, no-location 1, no-safe-window 1 | google 6, none 1, nws (fallback) 1 | solar-panels 5, unavailable 2, solar-largest-segment 1 | 30 |
| demo CSV, source google only | crew_brief | 8 | 2 | 2 | 4 | moved-within-day 2, planned-window-ok 2, no-forecast 1, no-irradiance-window 1, no-location 1, no-safe-window 1 | google 6, none 2 | solar-panels 5, unavailable 2, solar-largest-segment 1 | 28 |
| demo CSV, inline rule gust < 25 margin 5 | crew_brief | 8 | 3 | 1 | 4 | planned-window-ok 3, no-safe-window 2, moved-within-day 1, no-irradiance-window 1, no-location 1 | google 6, none 1, nws (fallback) 1 | solar-panels 5, unavailable 2, solar-largest-segment 1 | 30 |
| demo CSV, --gemini with no key (gemini: skipped: no GEMINI_API_KEY) | crew_brief | 8 | 3 | 2 | 3 | planned-window-ok 3, moved-within-day 2, no-irradiance-window 1, no-location 1, no-safe-window 1 | google 6, none 1, nws (fallback) 1 | solar-panels 5, unavailable 2, solar-largest-segment 1 | 30 |
| demo CSV, upstream budget 12 | crew_brief | 8 | 1 | 1 | 6 | no-location 5, moved-within-day 1, no-safe-window 1, planned-window-ok 1 | none 5, google 3 | unavailable 5, solar-panels 3 | 12 |
| one commission job by lat/lng (west roof, cloudy morning) | job_windows | 1 | 0 | 1 | 0 | moved-within-day 1 | google 1 | solar-panels 1 | 3 |

Demo CSV, per job (synthetic data; irradiance figures are the documented ESTIMATE, not measurements):

| # | Job | Task | Action | Planned | Proposed | Decided by | Roof az/pitch (source) | Shift GO / MARGINAL / NO-GO h | Irradiance est. block Wh/m2 | Peak est. W/m2 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | J-101 | install | KEEP | 07:00-13:00 | 07:00-13:00 | `planned-window-ok` | 180/25 + 95/25 (solar-panels) | 10 / 0 / 0 | 3664 | 784 |
| 2 | J-108 | install | KEEP | 08:00-13:00 | 08:00-13:00 | `planned-window-ok` | 150/18 (solar-panels) | 10 / 0 / 0 | 3449 | 787 |
| 3 | J-107 | install | KEEP | 07:00-13:00 | 07:00-13:00 | `planned-window-ok` | 180/0 (unavailable) | 7 / 1 / 2 | 2694 | 624 |
| 4 | J-102 | install | MOVE | 08:00-14:00 | 11:00-17:00 | `moved-within-day` | 200/30 (solar-panels) | 6 / 0 / 4 | 4135 | 871 |
| 5 | J-104 | commission | MOVE | 09:00-11:00 | 12:00-14:00 | `moved-within-day` | 265/22 + 85/22 (solar-panels) | 10 / 0 / 0 | 1170 | 600 |
| 6 | J-103 | install | SLIP | 07:00-14:00 | - | `no-safe-window` | 178/20 (solar-panels) | 3 / 2 / 5 | - | 487 |
| 7 | J-105 | commission | SLIP | 10:00-12:00 | - | `no-irradiance-window` | 2/35 (solar-largest-segment) | 10 / 0 / 0 | - | 56 |
| 8 | J-106 | install | SLIP | - | - | `no-location` | 180/0 (unavailable) | 0 / 0 / 0 | - | - |
<!-- measured:end -->

### Live checks (`bun run test:live`, 2026-10-21, this machine, keys from `dev-test-461117`)

| Check | Result |
| --- | --- |
| Geocoding v4 `geocode/address` (a public landmark address) | HTTP 200, `granularity=ROOFTOP`, within 0.02 deg of the expected point; test passes |
| Solar API `buildingInsights:findClosest` | HTTP 403 `SERVICE_DISABLED` (Solar API not enabled in that project); prints a `skipped:` line |
| Weather API `forecast/hours:lookup` | HTTP 403 `SERVICE_DISABLED` (Weather API not enabled in that project); prints a `skipped:` line |
| End to end: one lat/lng job tomorrow, `source: auto` | Weather API 403 -> NWS fallback, roof `unavailable` (Solar 403), 10 shift hours evaluated, 4 upstream calls; test passes |
| Gemini `generateContent` (optional wording) | HTTP 402 (`RESOURCE_EXHAUSTED`); prints a `skipped:` line, template wording kept |

Without keys the Google tests print `skipped: ... no GOOGLE_MAPS_API_KEY`; with `IRRADIANCECREW_LIVE=0` (CI) every live test prints a `skipped:` line. Live responses are not stored.

## Sources (checked 2026-10-21)

| Source | Used for | Reference |
| --- | --- | --- |
| Geocoding API v4 `GET https://geocode.googleapis.com/v4/geocode/address/{address}` (`X-Goog-FieldMask: results.placeId,results.formattedAddress,results.location,results.granularity`) | job coordinates | [Geocoding v4 overview](https://developers.google.com/maps/documentation/geocoding/geocoding-v4-overview) |
| Solar API `GET https://solar.googleapis.com/v1/buildingInsights:findClosest?location.latitude&location.longitude&requiredQuality` | `roofSegmentStats[].pitchDegrees/azimuthDegrees/stats.areaMeters2`, `solarPanels[].segmentIndex`, `imageryQuality`, `imageryDate` | [findClosest](https://developers.google.com/maps/documentation/solar/reference/rest/v1/buildingInsights/findClosest) |
| Weather API `GET https://weather.googleapis.com/v1/forecast/hours:lookup?...&unitsSystem=METRIC&hours&pageSize=24&pageToken` | `interval`, `temperature`, `feelsLikeTemperature`, `wind.speed/gust`, `precipitation.probability/qpf/snowQpf`, `thunderstormProbability`, `cloudCover`, `uvIndex`, `isDaytime`, `weatherCondition.type`, `timeZone.id` | [forecast.hours.lookup](https://developers.google.com/maps/documentation/weather/reference/rest/v1/forecast.hours/lookup) |
| NWS `GET https://api.weather.gov/points/{lat},{lng}` then the raw `forecastGridData` grid (keyless, US only) | fallback hourly gust, wind, precipitation probability and amount, thunder probability, sky cover, temperature, time zone | [NWS API](https://www.weather.gov/documentation/services-web-api) |
| Gemini `POST .../v1beta/models/{model}:generateContent` (optional) | wording of the brief from ids, rule ids, times and rounded numbers only; rejected if it adds a number | [Gemini API](https://ai.google.dev/api/generate-content) |

**About "WeatherNext-powered".** The idea text calls the Weather API "WeatherNext-powered". Google's Weather API FAQ (last updated 2026-10-20) describes its forecasts as built "with advancements from Google DeepMind's MetNet and WeatherNext AI models (2025)", combining AI and traditional forecasting systems, and says the raw WeatherNext models (GraphCast, GenCast) are a separate offering through Earth Engine and BigQuery. So the Weather API draws on WeatherNext alongside other systems; it does not expose WeatherNext output, and this tool makes no claim about which model produced a given hour ([FAQ](https://developers.google.com/maps/documentation/weather/faq)).

## Attribution and caching

- Live output prints, as the [Weather](https://developers.google.com/maps/documentation/weather/policies), [Solar](https://developers.google.com/maps/documentation/solar/policies) and [Geocoding](https://developers.google.com/maps/documentation/geocoding/policies) policies require: `Source: Includes weather data from Google`, `Source: Includes solar data from Google`, `Includes data from Google Maps` (for the derived schedule and irradiance estimate) and `Google Maps` (geocoded addresses shown without a map), plus an NWS line when the fallback is used. Fixture runs print a SYNTHETIC note instead, because no Google data is involved.
- Caching: the Google Maps Platform [Service Specific Terms](https://cloud.google.com/maps-platform/terms/maps-service-terms) allow Weather API hourly forecast values to be cached for one hour, Solar API Building Insights for up to 30 days, and Geocoding lat/lng for up to 30 days. This tool caches nothing between runs (each site is fetched once per run, in memory) and commits no real responses. A written brief contains hourly forecast values: regenerate it rather than keeping it for reuse.
- The Solar API terms allow use to assess, design or install an energy system; this tool reads roof geometry for installs that are already booked. The Weather API terms forbid recreating a weather service; this tool only derives crew decisions.

## Limits

- **Not verified live in this build: Solar API and Weather API responses.** Both APIs answer 403 `SERVICE_DISABLED` in the only Cloud project available here and were not enabled for this build. Their clients run only against synthetic fixtures built from the published references (the fixtures are checked against the documented field lists). The live path that ran end to end is Geocoding + NWS fallback.
- **Not verified: the Gemini wording path.** The key answers HTTP 402, so `--gemini` was only exercised against mocked responses. The default model id `gemini-3.1-flash-lite` comes from SkilledDragonCorp/pollenbrief-mcp's record and was not confirmed from this build; override with `IRRADIANCECREW_GEMINI_MODEL`.
- The irradiance figure is an estimate with an assumed diffuse fraction and no shading; the 400 W/m2 commissioning threshold is an example, not taken from any standard or equipment manual.
- Formula sources: the NOAA sun-position equations and the Haurwitz constants (pvlib `clearsky.py`) were checked against the source documents during this build; the Kasten & Czeplak cloud factor is the widely cited form of the 1980 paper, which was not retrieved here.
- Forecast wind is not given at roof height; treat it as a lower bound for wind at the eave.
- Hours are whole local hours; blocks must start on the hour. The shift is one window per profile.
- Moves stay within the same day; slipped jobs are not re-planned onto later days, and crew overlaps are flagged, not resolved.
- NWS fallback: US only; no UV, no condition type; precipitation amounts arrive in multi-hour blocks and are spread evenly.
- One Solar request per site at `requiredQuality` (default MEDIUM); a 404 leaves the job with a flat-roof proxy rather than retrying lower qualities.
- The per-session call budget (default 60) is per process, not per key or per day.

## Development

`bun run check` is the local gate and the only CI step: frozen install, skills layout, strict YAML (workflow + profiles), Biome, `tsc --noEmit`, unit + keyless e2e tests, live tests (skip cleanly), the report, `bun audit`, actionlint when installed, and `gitleaks dir`. Fixtures are regenerated with `bun run fixtures`.

## Not affiliated

Not affiliated with, endorsed by, or sponsored by Google LLC or the US National Weather Service. Google Maps, Google Maps Platform, Solar API, Weather API and Gemini are trademarks of Google LLC.

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