Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
56e587e
docs: add design spec for SJVAPCD daily forecast integration
dmpayton Jul 12, 2026
947b714
docs: add implementation plan for SJVAPCD daily forecast integration
dmpayton Jul 12, 2026
01583fc
feat(forecasts): add Forecast model and admin
dmpayton Jul 12, 2026
25f1186
feat(forecasts): add fetch_forecasts ingestion task
dmpayton Jul 12, 2026
e5c3fd4
fix(forecasts): restore server-clock issued_date semantics, mock time…
dmpayton Jul 12, 2026
68e6f49
feat(forecasts): add fetch_forecasts management command
dmpayton Jul 12, 2026
12fc7a5
feat(api): add /api/2.0/forecasts/ endpoints
dmpayton Jul 12, 2026
e0aa8db
fix(forecasts): isolate per-item feed parsing so one malformed zone d…
dmpayton Jul 12, 2026
add3a8a
feat(forecasts): make admin fully featured while staying read-only
dmpayton Jul 12, 2026
f01b71b
feat(forecasts): add SJVAPCD forecast feed to data-feeds health checks
dmpayton Jul 12, 2026
373dd32
fix(forecasts): isolate DB-level errors per zone, alert on ingestion …
dmpayton Jul 12, 2026
bd5bb3c
feat(forecasts): link region column in admin changelist to its detail…
dmpayton Jul 12, 2026
41f5164
refactor(entries): extract pollutant LevelSets into levels.py, add AQ…
dmpayton Jul 12, 2026
14fe097
refactor(entries): condense level-set names, import levels as a module
dmpayton Jul 12, 2026
51819e4
feat(regions): derive accurate SJVAPCD forecast zone geometry from ma…
dmpayton Jul 13, 2026
a6c518a
test(forecasts): cover custom-region boundary geometry in the API
dmpayton Jul 13, 2026
ac6ab00
fix(forecasts): restore blank=True on burn_status fields per original…
dmpayton Jul 13, 2026
dab5121
docs: update stale zone-geometry scope note, document forecast zone s…
dmpayton Jul 13, 2026
50b2e43
test(regions): add unit and integration tests for forecast_zones.py
dmpayton Jul 13, 2026
f8b37f4
chore: fix minor findings from full Sentry code review
dmpayton Jul 13, 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
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ Steps 2–4 are idempotent and safe to re-run. Re-running `import_pur` for a giv
deletes and reimports that year's records. Run `import_comptox --phase search` first whenever
new PUR years are added (new chemicals may appear); then re-run `equals` and `hazard`.

## SJVAPCD Forecast Setup

One-time setup, required per environment before the daily forecast task
(`fetch_forecasts`) will populate Kern, Tulare, and Sequoia forecasts. Must
run after county `Region`s are imported.

```bash
# 1. Import counties (if not already done)
docker compose run --rm web python manage.py import_counties

# 2. Derive and import the 3 SJVAPCD forecast zones that don't map 1:1 to a
# county (Kern SJV Air Basin portion, Tulare valley portion, Sequoia
# National Park and Forest), from the locally-saved forecast map SVG
# (datafiles/sjvapcd-forecast-areas.svg)
docker compose run --rm web python manage.py import_forecast_zones
```

Idempotent and safe to re-run (updates the existing `Region`/`Boundary` records
rather than duplicating them). Until step 2 has run, the daily
`fetch_forecasts` task still ingests the other 6 zones that map directly to
a county — Kern, Tulare, and Sequoia rows are silently skipped (region not
found) rather than failing.

## Architecture Overview

SJVAir is a Django/PostGIS air quality monitoring platform for the San Joaquin Valley. It ingests data from multiple sensor networks, processes it through a calibration pipeline, and exposes it via a versioned REST API.
Expand Down
Empty file.
42 changes: 42 additions & 0 deletions camp/api/v2/forecasts/endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.conf import settings
from django.utils import timezone

from resticus import generics

from camp.apps.forecasts.models import Forecast

from .filters import ForecastFilter
from .serializers import ForecastSerializer


class ForecastMixin:
model = Forecast
serializer_class = ForecastSerializer
paginate = True

def get_queryset(self):
return super().get_queryset().select_related('region', 'region__boundary')


class ForecastList(ForecastMixin, generics.ListEndpoint):
"""List SJVAPCD daily air quality forecasts. Defaults to current and future
forecasts (forecast_date >= today) unless forecast_date is explicitly filtered."""

filter_class = ForecastFilter

def get_queryset(self):
qs = super().get_queryset()
has_forecast_date_filter = any(
key == 'forecast_date' or key.startswith('forecast_date__')
for key in self.request.GET
)
if not has_forecast_date_filter:
today = timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date()
qs = qs.filter(forecast_date__gte=today)
return qs


class ForecastDetail(ForecastMixin, generics.DetailEndpoint):
"""Retrieve a single SJVAPCD forecast record."""
lookup_field = 'sqid'
lookup_url_kwarg = 'forecast_id'
15 changes: 15 additions & 0 deletions camp/api/v2/forecasts/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import django_filters
from resticus.filters import FilterSet

from camp.apps.forecasts.models import Forecast


class ForecastFilter(FilterSet):
region_id = django_filters.CharFilter(field_name='region__sqid', lookup_expr='exact')

class Meta:
model = Forecast
fields = {
'forecast_date': ['exact', 'lt', 'lte', 'gt', 'gte'],
'issued_date': ['exact', 'lt', 'lte', 'gt', 'gte'],
}
14 changes: 14 additions & 0 deletions camp/api/v2/forecasts/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from resticus import serializers

from camp.api.v2.regions.serializers import RegionSerializer


class ForecastSerializer(serializers.Serializer):
fields = (
('id', lambda f: f.sqid),
('region', lambda f: RegionSerializer(f.region).serialize()),
'zone_name', 'forecast_date', 'issued_date', 'published_at',
'aqi_value', 'aqi_category', 'pollutant', 'color',
'burn_status', 'burn_status_text',
'air_alert', 'air_alert_start', 'air_alert_end',
)
111 changes: 111 additions & 0 deletions camp/api/v2/forecasts/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from datetime import date, datetime, timedelta, timezone as dt_timezone

from django.test import TestCase
from django.urls import reverse

from camp.apps.forecasts.models import Forecast
from camp.apps.regions.models import Region
from camp.utils.datetime import localtime


def create_forecast(region, forecast_date, issued_date=None, aqi_value=101,
pollutant=Forecast.Pollutant.OZONE, air_alert=False):
issued_date = issued_date or forecast_date
return Forecast.objects.create(
region=region,
zone_name=region.name.replace(' County', ''),
forecast_date=forecast_date,
issued_date=issued_date,
published_at=datetime(2026, 7, 11, 21, 31, 9, tzinfo=dt_timezone.utc),
aqi_value=aqi_value,
aqi_category='Unhealthy for Sensitive Groups',
pollutant=pollutant,
burn_status='Discouraged',
burn_status_text='Discouraged: Burning Discouraged',
air_alert=air_alert,
)


class ForecastListTests(TestCase):
fixtures = ['regions.yaml']

def setUp(self):
self.fresno = Region.objects.get(name='Fresno County')
self.kern = Region.objects.get(name='Kern County')
self.today = localtime().date()
self.yesterday = self.today - timedelta(days=1)
self.tomorrow = self.today + timedelta(days=1)
self.forecast_today = create_forecast(self.fresno, self.today)
self.forecast_tomorrow = create_forecast(self.fresno, self.tomorrow, issued_date=self.today)
self.forecast_yesterday = create_forecast(self.kern, self.yesterday, issued_date=self.yesterday)
self.url = reverse('api:v2:forecasts:forecast-list')

def test_defaults_to_today_and_future(self):
response = self.client.get(self.url)
assert response.status_code == 200
ids = [r['id'] for r in response.json()['data']]
assert self.forecast_today.sqid in ids
assert self.forecast_tomorrow.sqid in ids
assert self.forecast_yesterday.sqid not in ids

def test_forecast_date_filter_overrides_default(self):
response = self.client.get(self.url, {'forecast_date': self.yesterday.isoformat()})
assert response.status_code == 200
ids = [r['id'] for r in response.json()['data']]
assert self.forecast_yesterday.sqid in ids
assert self.forecast_today.sqid not in ids

def test_region_id_filter(self):
response = self.client.get(self.url, {
'region_id': self.kern.sqid,
'forecast_date__gte': self.yesterday.isoformat(),
})
assert response.status_code == 200
ids = [r['id'] for r in response.json()['data']]
assert self.forecast_yesterday.sqid in ids
assert self.forecast_today.sqid not in ids

def test_response_includes_region_boundary_geometry(self):
response = self.client.get(self.url)
data = response.json()['data'][0]
assert data['region']['boundary'] is not None
assert 'geometry' in data['region']['boundary']


class ForecastDetailTests(TestCase):
fixtures = ['regions.yaml']

def setUp(self):
self.fresno = Region.objects.get(name='Fresno County')
self.forecast = create_forecast(self.fresno, date(2026, 7, 11))

def test_detail(self):
url = reverse('api:v2:forecasts:forecast-detail', kwargs={'forecast_id': self.forecast.sqid})
response = self.client.get(url)
assert response.status_code == 200
data = response.json()['data']
assert data['id'] == self.forecast.sqid
assert data['aqi_value'] == 101
assert data['pollutant'] == 'O3'
assert data['color'] == self.forecast.color

def test_detail_not_found(self):
url = reverse('api:v2:forecasts:forecast-detail', kwargs={'forecast_id': 'doesnotexist'})
response = self.client.get(url)
assert response.status_code == 404

def test_custom_region_includes_boundary_geometry(self):
# Kern/Tulare/Sequoia are Region(type=CUSTOM), not COUNTY -- confirm
# RegionSerializer includes their real derived boundary the same way
# it does for county regions (no type-based branching to regress on).
sequoia = Region.objects.get(type=Region.Type.CUSTOM, name='Sequoia National Park and Forest')
forecast = create_forecast(sequoia, date(2026, 7, 11))
url = reverse('api:v2:forecasts:forecast-detail', kwargs={'forecast_id': forecast.sqid})
response = self.client.get(url)
assert response.status_code == 200
region_data = response.json()['data']['region']
assert region_data['type'] == 'custom'
boundary = region_data['boundary']
assert boundary is not None
assert boundary['geometry']['type'] == 'MultiPolygon'
assert len(boundary['geometry']['coordinates']) > 0
10 changes: 10 additions & 0 deletions camp/api/v2/forecasts/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.urls import path

from . import endpoints

app_name = 'forecasts'

urlpatterns = [
path('', endpoints.ForecastList.as_view(), name='forecast-list'),
path('<forecast_id>/', endpoints.ForecastDetail.as_view(), name='forecast-detail'),
]
1 change: 1 addition & 0 deletions camp/api/v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
path('calenviroscreen/', include('camp.api.v2.ces.urls', namespace='ces')),
path('regions/', include('camp.api.v2.regions.urls', namespace='regions')),
path('ceidars/', include('camp.api.v2.ceidars.urls', namespace='ceidars')),
path('forecasts/', include('camp.api.v2.forecasts.urls', namespace='forecasts')),
path('hms/', include('camp.api.v2.hms.urls', namespace='hms')),
path('pesticides/', include('camp.api.v2.pesticides.urls', namespace='pesticides')),
path('task/<task_id>/', endpoints.TaskStatus.as_view(), name='task-status'),
Expand Down
75 changes: 75 additions & 0 deletions camp/apps/entries/levels.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,78 @@ def as_dict(self) -> Dict[str, Dict[str, any]]:
'guidance': level.guidance,
}
return result


# Pollutant-specific level sets, breakpoints in each pollutant's native unit.
# Import this module (not these names directly) to avoid colliding with the
# identically-named entry models in camp.apps.entries.models, e.g.:
# from . import levels
# Levels = levels.PM25

PM25 = LevelSet(
AQLevel.GOOD(0.0),
AQLevel.MODERATE(9.1),
AQLevel.UNHEALTHY_SENSITIVE(35.5),
AQLevel.UNHEALTHY(55.5),
AQLevel.VERY_UNHEALTHY(150.5),
AQLevel.HAZARDOUS(250.5),
)

PM100 = LevelSet(
AQLevel.GOOD(0),
AQLevel.MODERATE(55),
AQLevel.UNHEALTHY_SENSITIVE(155),
AQLevel.UNHEALTHY(255),
AQLevel.VERY_UNHEALTHY(355),
AQLevel.HAZARDOUS(425),
AQLevel.VERY_HAZARDOUS(605),
)

CO = LevelSet(
AQLevel.GOOD(0.0),
AQLevel.MODERATE(4.5),
AQLevel.UNHEALTHY_SENSITIVE(9.5),
AQLevel.UNHEALTHY(12.5),
AQLevel.VERY_UNHEALTHY(15.5),
AQLevel.HAZARDOUS(30.5),
AQLevel.VERY_HAZARDOUS(50.4),
)

NO2 = LevelSet(
AQLevel.GOOD(0.0),
AQLevel.MODERATE(54.0),
AQLevel.UNHEALTHY_SENSITIVE(101.0),
AQLevel.UNHEALTHY(361.0),
AQLevel.VERY_UNHEALTHY(650.0),
AQLevel.HAZARDOUS(1250.0),
AQLevel.VERY_HAZARDOUS(2050.0),
)

O3 = LevelSet(
AQLevel.GOOD(0.0),
AQLevel.UNHEALTHY_SENSITIVE(125),
AQLevel.UNHEALTHY(165),
AQLevel.VERY_UNHEALTHY(205),
AQLevel.HAZARDOUS(405),
AQLevel.VERY_HAZARDOUS(605),
)

SO2 = LevelSet(
AQLevel.GOOD(0),
AQLevel.MODERATE(36),
AQLevel.UNHEALTHY_SENSITIVE(76),
AQLevel.UNHEALTHY(186),
AQLevel.VERY_UNHEALTHY(305),
)

# The EPA Air Quality Index itself (0-500), as opposed to a pollutant's raw
# concentration. Breakpoints match camp.utils.aqi.aqi_label exactly (301+ is
# "Hazardous" all the way to 500 -- the EPA AQI has no tier past Hazardous).
AQI = LevelSet(
AQLevel.GOOD(0),
AQLevel.MODERATE(51),
AQLevel.UNHEALTHY_SENSITIVE(101),
AQLevel.UNHEALTHY(151),
AQLevel.VERY_UNHEALTHY(201),
AQLevel.HAZARDOUS(301),
)
Loading
Loading