From 56e587e12f138f1958321940c36a843c54f92958 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 00:41:36 -0700 Subject: [PATCH 01/20] docs: add design spec for SJVAPCD daily forecast integration --- .../2026-07-12-sjvapcd-forecast-design.md | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md diff --git a/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md b/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md new file mode 100644 index 00000000..7782cb8e --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md @@ -0,0 +1,299 @@ +# SJVAPCD Daily Air Quality Forecast Integration + +## Purpose + +Ingest the San Joaquin Valley Air Pollution Control District's (SJVAPCD) daily +air quality forecast and expose it through the SJVAir API, both as a +general-purpose data source and to drive a forecast map layer on the +front end (counties shaded by forecast AQI category). + +Source page: https://ww2.valleyair.org/air-quality-information/daily-air-quality-forecast/ + +## Data Source + +SJVAPCD publishes an XML feed intended for exactly this purpose: + +``` +https://ww2.valleyair.org/aqinfo/airstatus.xml +``` + +- Format: RSS-like XML with custom `burnStatus:` and `AQI:` namespaced elements. +- Updated daily by ~4:30pm Pacific (`ttl` of 1440 minutes / 24h — not meant to + be polled more often than daily). +- One `` per forecast zone, each containing: + - `` — zone name (see "Zone mapping" below) + - `` / `` — burn status, each with a + `date` and `status` attribute, plus descriptive text content + - `` / `` — AQI forecast, each with a `date` and + `status` (color) attribute, and text content like + `"101 Unhealthy for Sensitive Groups (O3)"` (value, category label, + dominant pollutant) + - `` + - `` — when this item was published + +Example item (abridged): + +```xml + + Fresno + Discouraged: Burning Discouraged + 101 Unhealthy for Sensitive Groups (O3) + Discouraged: Burning Discouraged + 100 Moderate (O3) + + 2026-07-11T14:31:09 -7:00 + +``` + +### Zone mapping + +The feed publishes 9 zones. 8 map 1:1 (after one alias) to the existing SJV +county `Region` records (`camp.apps.regions.managers.SJV_COUNTIES`); one does +not correspond to any `Region` and is dropped: + +| Feed `` value | Region | +|-------------------------------------|--------------------------| +| San Joaquin, Stanislaus, Merced, Madera, Fresno, Kings, Tulare | matches `Region` name directly | +| `Kern (SJV Air Basin portion)` | aliased to `Kern` | +| `Sequoia National Park and Forest` | **dropped** — no matching `Region`, not a county | + +This was confirmed against the SJVAPCD's own published forecast map, which +draws Kern's zone clipped to the air-basin portion (not the full county) and +draws Sequoia National Park and Forest as its own separate shape. For this +pass we reuse the existing county `Region` boundaries as an approximation +(Kern rendered as the full county) rather than sourcing/importing accurate +forecast-zone geometry. This is a known, accepted simplification — a future +pass could import real zone boundaries if pixel-accurate map shading becomes +a requirement. + +## Data Model + +New app: `camp/apps/forecasts/` + +```python +from django.contrib.gis.db import models # not needed unless geometry added later; use django.db.models +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_sqids import SqidsField, shuffle_alphabet +from model_utils.models import TimeStampedModel + + +class Forecast(TimeStampedModel): + class Pollutant(models.TextChoices): + OZONE = 'O3', _('Ozone') + PM25 = 'PM2.5', _('PM2.5') + + sqid = SqidsField(alphabet=shuffle_alphabet('forecasts.Forecast')) + + region = models.ForeignKey('regions.Region', on_delete=models.CASCADE, related_name='forecasts') + zone_name = models.CharField(_('zone name'), max_length=64) # raw label, for audit/debugging + + forecast_date = models.DateField(_('forecast date')) # date this forecast is FOR + issued_date = models.DateField(_('issued date')) # Pacific date this forecast was pulled + published_at = models.DateTimeField(_('published at')) # feed's own + + aqi_value = models.PositiveSmallIntegerField(_('AQI value')) + aqi_category = models.CharField(_('AQI category'), max_length=32) # via camp.utils.aqi.aqi_label + pollutant = models.CharField(_('pollutant'), max_length=16, choices=Pollutant.choices) + + burn_status = models.CharField(_('burn status'), max_length=32, blank=True) + burn_status_text = models.CharField(_('burn status text'), max_length=255, blank=True) + + air_alert = models.BooleanField(_('air alert'), default=False) + air_alert_start = models.DateField(_('air alert start'), null=True, blank=True) + air_alert_end = models.DateField(_('air alert end'), null=True, blank=True) + + class Meta: + ordering = ('-issued_date', 'region__name') + indexes = [ + models.Index(fields=['region', 'forecast_date']), + models.Index(fields=['issued_date']), + ] +``` + +Notes: +- `pollutant` uses `TextChoices` since the feed only ever reports `O3` or + `PM2.5` as the dominant pollutant for this forecast program. Ingestion does + **not** call `full_clean()`, so an unrecognized future value is still + stored as free text rather than breaking the daily task. +- `burn_status` / `burn_status_text` stay plain `CharField` — the full set of + possible status values (`Discouraged`, presumably `Allowed`/`Prohibited`) + hasn't been observed yet. +- **Full history is kept.** Every daily pull writes 2 new rows per zone (one + for `forecast_date == issued_date` ["today"], one for + `forecast_date == issued_date + 1` ["tomorrow"]) rather than upserting a + single latest-forecast row. This lets us later compare a "tomorrow" + prediction against the "today" actual pulled the next day for the same + calendar date. +- `forecast_date` is parsed from each element's own `date="..."` attribute + (authoritative from the feed), not assumed from `issued_date` arithmetic. + +## Ingestion Task + +`camp/apps/forecasts/tasks.py`, following the `camp.apps.hms` pattern +(external feed → periodic task → idempotent delete-and-recreate): + +```python +FEED_URL = 'https://ww2.valleyair.org/aqinfo/airstatus.xml' +ZONE_ALIASES = {'Kern (SJV Air Basin portion)': 'Kern'} +AQI_TEXT_RE = re.compile(r'^(\d+)\s+.+?\(([^)]+)\)$') # "101 ... (O3)" -> (101, 'O3') + +@db_periodic_task(crontab(minute='45', hour='23,0,1,2'), priority=50) +def fetch_forecasts(): + issued_date = timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date() + response = requests.get(FEED_URL, timeout=30) + root = ET.fromstring(response.content) + + with transaction.atomic(): + Forecast.objects.filter(issued_date=issued_date).delete() + for item in root.iter('item'): + zone_name = item.findtext('county').strip() + region = Region.objects.county.filter( + name=ZONE_ALIASES.get(zone_name, zone_name) + ).first() + if region is None: + continue # unmapped zone (Sequoia National Park and Forest) + + published_at = parse_pubdate(item.findtext('pubdate')) + for horizon in ('today', 'tomorrow'): + # parse AQI:, burnStatus:, airAlertStatus + # via the burnStatus:/AQI: namespace map + Forecast.objects.create( + region=region, + zone_name=zone_name, + forecast_date=..., # from element's date= attribute + issued_date=issued_date, + published_at=published_at, + aqi_value=value, + aqi_category=aqi_label(value), + pollutant=pollutant, + burn_status=..., + burn_status_text=..., + air_alert=..., + air_alert_start=..., + air_alert_end=..., + ) +``` + +- **Crontab window `minute=45, hour=23,0,1,2` (UTC)** covers roughly + 4:45pm–7:45pm Pacific regardless of DST, so the daily "by 4:30pm" update is + reliably caught without hardcoding a DST-specific offset. Mirrors `hms`'s + multi-run + idempotent-replace approach rather than `hms`'s separate + "final re-fetch" task, since this feed is much cheaper to fetch than HMS + shapefiles. +- **Idempotent**: deletes and recreates all rows for `issued_date` on every + run, so repeated runs within the window (or manual re-runs) never + duplicate data — same convention as `import_pur` and `hms.fetch_smoke`. +- Uses `defusedxml.ElementTree` (not stdlib `xml.etree.ElementTree`, which is + vulnerable to XXE/billion-laughs by default) with a namespace map for the + `burnStatus:`/`AQI:` prefixed elements. New dependency: `defusedxml`, + added to `requirements/base.txt`. `requests` is already a project + dependency. + +### Management command + +`camp/apps/forecasts/management/commands/fetch_forecasts.py`, following the +`hms.import_hms` pattern: + +```python +class Command(BaseCommand): + help = 'Fetch the SJVAPCD daily air quality forecast feed.' + + def handle(self, *args, **options): + self.stdout.write('Fetching SJVAPCD forecasts...') + fetch_forecasts.call_local() + self.stdout.write(self.style.SUCCESS('Done.')) +``` + +No arguments — unlike `hms`'s per-date backfill, this task always ingests +"now" (the feed has no historical archive to backfill from). + +## API + +New versioned endpoint group at `/api/2.0/forecasts/`, mounted in +`camp/api/v2/urls.py`: + +```python +path('forecasts/', include('camp.api.v2.forecasts.urls', namespace='forecasts')), +``` + +**Serializer** (`camp/api/v2/forecasts/serializers.py`) — embeds the full +`RegionSerializer` (including its boundary GeoJSON) so the frontend map layer +doesn't need a second request per zone: + +```python +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', + 'burn_status', 'burn_status_text', + 'air_alert', 'air_alert_start', 'air_alert_end', + ) +``` + +Accepted tradeoff: since both the "today" and "tomorrow" row for a zone +embed the same region, the boundary geometry is duplicated across the two +rows in a default list response. Simpler than restructuring the response +shape to dedupe; not worth optimizing unless it proves to be a real problem. + +**Endpoints** (`camp/api/v2/forecasts/endpoints.py`), following the +`hms.SmokeList`/`SmokeDetail` pattern: + +- `GET /api/2.0/forecasts/` — list. Defaults to `forecast_date >= today` + (current + future forecasts only) when no `forecast_date` filter is given; + explicit filters override the default. +- `GET /api/2.0/forecasts/{id}/` — single record detail. + +**Filters** (`camp/api/v2/forecasts/filters.py`), following +`hms.SmokeFilter`/`FireFilter`: + +- `region_id` — `Region.objects.get(sqid=...)` lookup, same as `hms`. +- `forecast_date` — `exact`/`lt`/`lte`/`gt`/`gte`. +- `issued_date` — `exact`/`lt`/`lte`/`gt`/`gte`. + +```python +class ForecastList(ForecastMixin, generics.ListEndpoint): + filter_class = ForecastFilter + + def get_queryset(self): + qs = super().get_queryset() + if 'forecast_date' not in self.request.GET: + today = timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date() + qs = qs.filter(forecast_date__gte=today) + return qs +``` + +## Admin + +`camp/apps/forecasts/admin.py` — simple `ModelAdmin`: +- `list_display`: `zone_name`, `forecast_date`, `issued_date`, `aqi_value`, + `aqi_category`, `burn_status`, `air_alert` +- `list_filter`: `aqi_category`, `burn_status`, `air_alert` +- `date_hierarchy`: `forecast_date` + +## Testing + +- `camp/apps/forecasts/tests.py` — task tests against a saved sample of the + real XML feed (fixture file, not a live fetch): + - parses correctly into `Forecast` rows + - aliases `Kern (SJV Air Basin portion)` → `Kern` region + - skips `Sequoia National Park and Forest` (no matching region) + - re-running the task for the same `issued_date` is idempotent (no + duplicate rows) + - `aqi_category` is derived correctly via `camp.utils.aqi.aqi_label` +- `camp/api/v2/forecasts/tests.py`: + - list defaults to `forecast_date >= today` + - `region_id`, `forecast_date`, `issued_date` filters work + - detail endpoint works + - region boundary GeoJSON is present in the nested `region` field + +## Out of Scope (this pass) + +- Accurate forecast-zone geometry (Kern air-basin clip, Sequoia zone) — using + existing county boundaries as an approximation instead. +- Notifications/alerts driven by forecast data (e.g. air alert push + notifications) — this pass is ingestion + API only. +- Historical backfill — the feed has no archive; history only accumulates + from when this integration starts running. From 947b71426d3fb98b45f68f7314601aa3754c9745 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 01:07:16 -0700 Subject: [PATCH 02/20] docs: add implementation plan for SJVAPCD daily forecast integration --- .../plans/2026-07-12-sjvapcd-forecast.md | 1038 +++++++++++++++++ 1 file changed, 1038 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-sjvapcd-forecast.md diff --git a/docs/superpowers/plans/2026-07-12-sjvapcd-forecast.md b/docs/superpowers/plans/2026-07-12-sjvapcd-forecast.md new file mode 100644 index 00000000..3e9e98b9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-sjvapcd-forecast.md @@ -0,0 +1,1038 @@ +# SJVAPCD Daily Air Quality Forecast Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ingest SJVAPCD's daily air quality forecast feed into a new `Forecast` model, keyed to SJV county `Region`s, and expose it through `/api/2.0/forecasts/` for a frontend map layer. + +**Architecture:** A new `camp/apps/forecasts` app follows the existing `camp.apps.hms` pattern (external feed → Huey periodic task → idempotent delete-and-recreate per pull) plus a manual management command wrapper. A new `camp/api/v2/forecasts` package follows the existing `camp.api.v2.hms` pattern (serializer/filter/endpoints/urls) for the read API. + +**Tech Stack:** Django 5.2, `django-huey`/`huey` (periodic tasks), `defusedxml` (new dependency, safe XML parsing), `requests` (already a dependency), `django-filter`/`resticus` (API layer), PostGIS via `regions.Region`. + +## Global Constraints + +- Design source of truth: `docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md`. +- Feed URL: `https://ww2.valleyair.org/aqinfo/airstatus.xml`. Updated daily by ~4:30pm Pacific. +- Only the 8 feed zones that map to an existing SJV county `Region` are stored. `Kern (SJV Air Basin portion)` maps to the `Kern County` region. `Sequoia National Park and Forest` has no matching region and is dropped. +- Full history is kept — every daily pull writes new rows, never overwrites past `issued_date` rows. Idempotent per `issued_date` (delete-and-recreate), matching `camp.apps.hms.tasks.fetch_smoke`. +- `pollutant` is a `TextChoices` (`O3`, `PM2.5`). `burn_status`/`burn_status_text` stay plain `CharField` (unconfirmed full value set). +- Use `defusedxml.ElementTree`, not stdlib `xml.etree.ElementTree` (XXE/billion-laughs risk on external XML). +- All commands run inside Docker: `docker compose run --rm test pytest ...`, `docker compose run --rm web python manage.py ...`. Tests use `assert`, not `self.assertX()`. Verbose names use `_()` as first positional arg. No `=` alignment in field definitions. +- Region county names in this codebase are stored with a `" County"` suffix (e.g. `"Fresno County"`, `"Kern County"`) — confirmed against `fixtures/regions.yaml` and `camp/apps/regions/managers.py:SJV_COUNTIES`. `Region.objects.counties()` (a manager **method**, not a property) filters to the 8 SJV county regions. +- **Namespace quirk confirmed by hand-parsing the real feed:** the feed declares both `xmlns:burnStatus="https://ww2.valleyair.org/"` and `xmlns:AQI="https://ww2.valleyair.org/"` — the *same* URI for both prefixes. This means `` and `` parse to the **identical** Clark-notation tag `{https://ww2.valleyair.org/}today` in `ElementTree`/`defusedxml`. You cannot distinguish them by tag lookup — `item.findall('{https://ww2.valleyair.org/}today')` returns *both* elements (burn status first, then AQI, in document order) under one call. They must be told apart by content shape (AQI text starts with a digit and ends with `(POLLUTANT)`; burn status text does not) — see `split_today_tomorrow()` in Task 2. Do not "fix" this to a per-namespace lookup; it will silently return the wrong element. + +--- + +## File Structure + +``` +camp/apps/forecasts/ + __init__.py + apps.py + models.py + admin.py + tasks.py + tests.py + management/__init__.py + management/commands/__init__.py + management/commands/fetch_forecasts.py + migrations/__init__.py + migrations/0001_initial.py # generated, not hand-written + +camp/api/v2/forecasts/ + __init__.py + serializers.py + filters.py + endpoints.py + urls.py + tests.py + +camp/settings/base.py # register app in INSTALLED_APPS +camp/api/v2/urls.py # mount forecasts app +requirements/base.txt # add defusedxml +``` + +--- + +### Task 1: `forecasts` app scaffold + `Forecast` model + admin + +**Files:** +- Create: `camp/apps/forecasts/__init__.py` +- Create: `camp/apps/forecasts/apps.py` +- Create: `camp/apps/forecasts/models.py` +- Create: `camp/apps/forecasts/admin.py` +- Create: `camp/apps/forecasts/migrations/__init__.py` +- Create: `camp/apps/forecasts/migrations/0001_initial.py` (generated via `makemigrations`, not hand-written) +- Create: `camp/apps/forecasts/tests.py` +- Modify: `camp/settings/base.py:107` (INSTALLED_APPS, insert after `'camp.apps.entries',` and before `'camp.apps.helpdesk',`) + +**Interfaces:** +- Produces: `camp.apps.forecasts.models.Forecast` — fields `sqid`, `region` (FK to `regions.Region`), `zone_name`, `forecast_date`, `issued_date`, `published_at`, `aqi_value`, `aqi_category`, `pollutant` (`Forecast.Pollutant.OZONE = 'O3'`, `Forecast.Pollutant.PM25 = 'PM2.5'`), `burn_status`, `burn_status_text`, `air_alert`, `air_alert_start`, `air_alert_end`. Task 2's `tasks.py` and Task 4's API both import this model directly. + +- [ ] **Step 1: Create the app package skeleton** + +```bash +mkdir -p camp/apps/forecasts/migrations +touch camp/apps/forecasts/__init__.py +touch camp/apps/forecasts/migrations/__init__.py +``` + +`camp/apps/forecasts/apps.py`: + +```python +from django.apps import AppConfig + + +class ForecastsConfig(AppConfig): + name = 'camp.apps.forecasts' +``` + +- [ ] **Step 2: Register the app in `INSTALLED_APPS`** + +In `camp/settings/base.py`, find this block (around line 106-108): + +```python + 'camp.apps.entries', + 'camp.apps.helpdesk', + 'camp.apps.hms', +``` + +Change it to: + +```python + 'camp.apps.entries', + 'camp.apps.forecasts', + 'camp.apps.helpdesk', + 'camp.apps.hms', +``` + +- [ ] **Step 3: Write the failing model test** + +`camp/apps/forecasts/tests.py`: + +```python +from datetime import date, datetime, timezone as dt_timezone + +from django.test import TestCase + +from camp.apps.regions.models import Region + +from .models import Forecast + + +class ForecastModelTests(TestCase): + fixtures = ['regions.yaml'] + + def test_create_forecast(self): + region = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.create( + region=region, + zone_name='Fresno', + forecast_date=date(2026, 7, 11), + issued_date=date(2026, 7, 11), + published_at=datetime(2026, 7, 11, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=101, + aqi_category='Unhealthy for Sensitive Groups', + pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', + burn_status_text='Discouraged: Burning Discouraged', + air_alert=False, + ) + assert forecast.sqid + assert forecast.pollutant == 'O3' + assert str(forecast) == 'Fresno forecast for 2026-07-11 (issued 2026-07-11)' + assert Forecast.objects.filter(region=region).count() == 1 + + def test_ordering_is_newest_issued_first(self): + region = Region.objects.get(name='Fresno County') + older = Forecast.objects.create( + region=region, zone_name='Fresno', + forecast_date=date(2026, 7, 10), issued_date=date(2026, 7, 10), + published_at=datetime(2026, 7, 10, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=90, aqi_category='Moderate', pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', + ) + newer = Forecast.objects.create( + region=region, zone_name='Fresno', + forecast_date=date(2026, 7, 11), issued_date=date(2026, 7, 11), + published_at=datetime(2026, 7, 11, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=101, aqi_category='Unhealthy for Sensitive Groups', pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', + ) + assert list(Forecast.objects.all()) == [newer, older] +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'camp.apps.forecasts.models'` (or similar import error, since `models.py` doesn't exist yet). + +- [ ] **Step 5: Write the model** + +`camp/apps/forecasts/models.py`: + +```python +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from django_sqids import SqidsField, shuffle_alphabet +from model_utils.models import TimeStampedModel + + +class Forecast(TimeStampedModel): + class Pollutant(models.TextChoices): + OZONE = 'O3', _('Ozone') + PM25 = 'PM2.5', _('PM2.5') + + sqid = SqidsField(alphabet=shuffle_alphabet('forecasts.Forecast')) + + region = models.ForeignKey( + 'regions.Region', + on_delete=models.CASCADE, + related_name='forecasts', + ) + zone_name = models.CharField(_('zone name'), max_length=64) + + forecast_date = models.DateField(_('forecast date')) + issued_date = models.DateField(_('issued date')) + published_at = models.DateTimeField(_('published at')) + + aqi_value = models.PositiveSmallIntegerField(_('AQI value')) + aqi_category = models.CharField(_('AQI category'), max_length=32) + pollutant = models.CharField(_('pollutant'), max_length=16, choices=Pollutant.choices) + + burn_status = models.CharField(_('burn status'), max_length=32) + burn_status_text = models.CharField(_('burn status text'), max_length=255) + + air_alert = models.BooleanField(_('air alert'), default=False) + air_alert_start = models.DateField(_('air alert start'), null=True, blank=True) + air_alert_end = models.DateField(_('air alert end'), null=True, blank=True) + + class Meta: + ordering = ('-issued_date', 'region__name') + indexes = [ + models.Index(fields=['region', 'forecast_date']), + models.Index(fields=['issued_date']), + ] + + def __str__(self): + return f'{self.zone_name} forecast for {self.forecast_date} (issued {self.issued_date})' +``` + +- [ ] **Step 6: Generate and run the migration** + +Run: `docker compose run --rm web python manage.py makemigrations forecasts` +Expected: `Migrations for 'forecasts': camp/apps/forecasts/migrations/0001_initial.py - Create model Forecast` + +Run: `docker compose run --rm web python manage.py migrate forecasts` +Expected: `Applying forecasts.0001_initial... OK` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py -v` +Expected: PASS (2 tests) + +- [ ] **Step 8: Add the admin** + +`camp/apps/forecasts/admin.py`: + +```python +from django.contrib import admin + +from .models import Forecast + + +@admin.register(Forecast) +class ForecastAdmin(admin.ModelAdmin): + date_hierarchy = 'forecast_date' + list_display = ['zone_name', 'forecast_date', 'issued_date', 'aqi_value', 'aqi_category', 'burn_status', 'air_alert'] + list_filter = ['aqi_category', 'burn_status', 'air_alert'] + readonly_fields = [ + 'sqid', 'region', 'zone_name', 'forecast_date', 'issued_date', 'published_at', + 'aqi_value', 'aqi_category', 'pollutant', 'burn_status', 'burn_status_text', + 'air_alert', 'air_alert_start', 'air_alert_end', + ] + ordering = ('-issued_date', 'zone_name') + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def save_model(self, request, obj, form, change): + pass +``` + +- [ ] **Step 9: Commit** + +```bash +git add camp/apps/forecasts/__init__.py camp/apps/forecasts/apps.py camp/apps/forecasts/models.py \ + camp/apps/forecasts/admin.py camp/apps/forecasts/tests.py camp/apps/forecasts/migrations/ \ + camp/settings/base.py +git commit -m "feat(forecasts): add Forecast model and admin" +``` + +--- + +### Task 2: Ingestion task (`fetch_forecasts`) + +**Files:** +- Modify: `requirements/base.txt` (add `defusedxml==0.7.1`, alphabetically between `delegator.py` and `dpath`) +- Create: `camp/apps/forecasts/tasks.py` +- Modify: `camp/apps/forecasts/tests.py` (append task tests) + +**Interfaces:** +- Consumes: `camp.apps.forecasts.models.Forecast` (Task 1), `camp.apps.regions.models.Region` + `Region.objects.counties()` (existing), `camp.utils.aqi.aqi_label` (existing). +- Produces: `camp.apps.forecasts.tasks.fetch_forecasts` — a `db_periodic_task` callable via `fetch_forecasts.call_local()` (no arguments). Task 3's management command calls this directly. + +- [ ] **Step 1: Add the `defusedxml` dependency** + +In `requirements/base.txt`, find: + +``` +delegator.py==0.1.1 +dpath==2.2.0 +``` + +Change to: + +``` +defusedxml==0.7.1 +delegator.py==0.1.1 +dpath==2.2.0 +``` + +Run: `docker compose build web test` (rebuilds images with the new dependency) + +- [ ] **Step 2: Write the failing ingestion tests** + +Append to `camp/apps/forecasts/tests.py`: + +```python +from unittest.mock import Mock, patch + +from .tasks import fetch_forecasts + + +SAMPLE_FEED_XML = b""" + +SJVAPCD mobile app Status +SJVAPCD Air Quality Information + + +SJVAPCD +/favicon.ico + +Air Quality Status +SJVAPCD Air Quality Status by County +https://ww2.valleyair.org/air-quality-information/real-time-air-advisory-network-raan/real-time-air-monitoring-stations/ +en-us +Copyright 2013 SJVAPCD +2026-07-11T21:31:09 -7:00 +SJVAPCD +webmaster@valleyair.org +1440 + +http://www.valleyair.org/aqinfo/San Joaquin +San Joaquin Air Quality Status +San Joaquin +Discouraged: Burning Discouraged +55 Moderate (PM2.5) +Discouraged: Burning Discouraged +51 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Stanislaus +Stanislaus Air Quality Status +Stanislaus +Discouraged: Burning Discouraged +77 Moderate (O3) +Discouraged: Burning Discouraged +58 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Merced +Merced Air Quality Status +Merced +Discouraged: Burning Discouraged +80 Moderate (O3) +Discouraged: Burning Discouraged +61 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Madera +Madera Air Quality Status +Madera +Discouraged: Burning Discouraged +100 Moderate (O3) +Discouraged: Burning Discouraged +80 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Fresno +Fresno Air Quality Status +Fresno +Discouraged: Burning Discouraged +101 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +100 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Kings +Kings Air Quality Status +Kings +Discouraged: Burning Discouraged +71 Moderate (O3) +Discouraged: Burning Discouraged +53 Moderate (PM2.5) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Tulare +Tulare Air Quality Status +Tulare +Discouraged: Burning Discouraged +105 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +84 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Kern (SJV Air Basin portion) +Kern (SJV Air Basin portion) Air Quality Status +Kern (SJV Air Basin portion) +Discouraged: Burning Discouraged +115 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +100 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Sequoia National Park and Forest +Sequoia National Park and Forest Air Quality Status +Sequoia National Park and Forest +Discouraged: Burning Discouraged +129 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +100 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + + +""" + + +def mock_response(content=SAMPLE_FEED_XML): + response = Mock() + response.content = content + response.raise_for_status = Mock() + return response + + +class FetchForecastsTests(TestCase): + fixtures = ['regions.yaml'] + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_creates_forecast_for_each_mapped_zone(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + # 8 mapped zones x 2 horizons (today/tomorrow) = 16 rows; Sequoia dropped. + assert Forecast.objects.count() == 16 + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_skips_unmapped_zone(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + assert not Forecast.objects.filter(zone_name='Sequoia National Park and Forest').exists() + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_kern_alias_maps_to_kern_county_region(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + kern = Region.objects.get(name='Kern County') + forecast = Forecast.objects.get( + region=kern, zone_name='Kern (SJV Air Basin portion)', forecast_date=date(2026, 7, 11), + ) + assert forecast.aqi_value == 115 + assert forecast.pollutant == 'O3' + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_sets_fields_correctly_for_fresno_today(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + fresno = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.get(region=fresno, forecast_date=date(2026, 7, 11)) + assert forecast.aqi_value == 101 + assert forecast.aqi_category == 'Unhealthy for Sensitive Groups' + assert forecast.pollutant == 'O3' + assert forecast.burn_status == 'Discouraged' + assert forecast.burn_status_text == 'Discouraged: Burning Discouraged' + assert forecast.air_alert is False + assert forecast.air_alert_start is None + assert forecast.air_alert_end is None + assert forecast.issued_date == date(2026, 7, 11) + assert forecast.published_at.year == 2026 + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_tomorrow_row_has_next_day_forecast_date(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + fresno = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.get(region=fresno, forecast_date=date(2026, 7, 12)) + assert forecast.aqi_value == 100 + assert forecast.aqi_category == 'Moderate' + assert forecast.issued_date == date(2026, 7, 11) + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_pm25_zone_parses_correctly(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + san_joaquin = Region.objects.get(name='San Joaquin County') + forecast = Forecast.objects.get(region=san_joaquin, forecast_date=date(2026, 7, 11)) + assert forecast.pollutant == 'PM2.5' + assert forecast.aqi_value == 55 + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_idempotent_rerun_does_not_duplicate(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + count = Forecast.objects.count() + assert count == 16 + fetch_forecasts.call_local() + assert Forecast.objects.count() == count +``` + +Add `from datetime import date` and `from camp.apps.regions.models import Region` to the existing imports at the top of `camp/apps/forecasts/tests.py` (added in Task 1, `Region` is already imported there — only `date` needs adding alongside the existing `datetime`/`dt_timezone` import). + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'camp.apps.forecasts.tasks'` + +- [ ] **Step 4: Write the ingestion task** + +`camp/apps/forecasts/tasks.py`: + +```python +import re +from datetime import datetime, timedelta, timezone as dt_timezone + +import requests +from defusedxml import ElementTree as ET + +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django_huey import db_periodic_task +from huey import crontab + +from camp.apps.regions.models import Region +from camp.utils.aqi import aqi_label + +from .models import Forecast + + +FEED_URL = 'https://ww2.valleyair.org/aqinfo/airstatus.xml' + +# Both the burnStatus: and AQI: prefixes resolve to this same namespace URI in +# the feed, so and share one Clark-notation tag. +# See split_today_tomorrow() below for how they're told apart. +NAMESPACE_URI = 'https://ww2.valleyair.org/' + +# Maps the feed's raw label to the matching county Region's name. +# "Sequoia National Park and Forest" has no matching Region and is skipped. +ZONE_TO_REGION_NAME = { + 'San Joaquin': 'San Joaquin County', + 'Stanislaus': 'Stanislaus County', + 'Merced': 'Merced County', + 'Madera': 'Madera County', + 'Fresno': 'Fresno County', + 'Kings': 'Kings County', + 'Tulare': 'Tulare County', + 'Kern (SJV Air Basin portion)': 'Kern County', +} + +# "101 Unhealthy for Sensitive Groups (O3)" -> value=101, pollutant='O3' +AQI_TEXT_RE = re.compile(r'^(\d+)\s+.+?\(([^)]+)\)$') + + +def parse_feed_datetime(value): + """Parses feed timestamps like '2026-07-11T14:31:09 -7:00' into aware datetimes.""" + dt_part, offset_part = value.rsplit(' ', 1) + sign = -1 if offset_part.startswith('-') else 1 + hours_str, minutes_str = offset_part.lstrip('+-').split(':') + offset = timedelta(hours=int(hours_str), minutes=int(minutes_str)) * sign + naive = datetime.strptime(dt_part, '%Y-%m-%dT%H:%M:%S') + return naive.replace(tzinfo=dt_timezone(offset)) + + +def parse_alert_date(value): + """Parses an air-alert start/end date attribute. Format hasn't been observed + live (no sample pull has had an active alert); falls back to None on any + unexpected format rather than breaking ingestion for every zone.""" + if not value: + return None + try: + return datetime.strptime(value, '%Y-%m-%d').date() + except ValueError: + return None + + +def parse_aqi_text(text): + match = AQI_TEXT_RE.match((text or '').strip()) + if not match: + raise ValueError(f'Unrecognized AQI text: {text!r}') + return int(match.group(1)), match.group(2) + + +def split_today_tomorrow(elements): + """Splits the two same-tag elements for a horizon into (burn_status_el, aqi_el). + They're told apart by content shape: AQI text matches AQI_TEXT_RE, burn status + text does not. See the namespace-collision note in tasks.py's module docstring.""" + aqi_el = next(el for el in elements if AQI_TEXT_RE.match((el.text or '').strip())) + burn_el = next(el for el in elements if el is not aqi_el) + return burn_el, aqi_el + + +@db_periodic_task(crontab(minute='45', hour='23,0,1,2'), priority=50) +def fetch_forecasts(): + issued_date = timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date() + response = requests.get(FEED_URL, timeout=30) + response.raise_for_status() + root = ET.fromstring(response.content) + + with transaction.atomic(): + Forecast.objects.filter(issued_date=issued_date).delete() + for item in root.iter('item'): + zone_name = (item.findtext('county') or '').strip() + region_name = ZONE_TO_REGION_NAME.get(zone_name) + if region_name is None: + continue # unmapped zone (e.g. Sequoia National Park and Forest) + + region = Region.objects.counties().filter(name=region_name).first() + if region is None: + continue # region not yet imported + + published_at = parse_feed_datetime(item.findtext('pubdate')) + + alert_el = item.find('airAlertStatus') + air_alert = alert_el.get('status') == 'YES' + air_alert_start = parse_alert_date(alert_el.get('startDate')) + air_alert_end = parse_alert_date(alert_el.get('endDate')) + + for horizon in ('today', 'tomorrow'): + elements = item.findall(f'{{{NAMESPACE_URI}}}{horizon}') + burn_el, aqi_el = split_today_tomorrow(elements) + + aqi_value, pollutant = parse_aqi_text(aqi_el.text) + forecast_date = parse_feed_datetime(aqi_el.get('date')).date() + + Forecast.objects.create( + region=region, + zone_name=zone_name, + forecast_date=forecast_date, + issued_date=issued_date, + published_at=published_at, + aqi_value=aqi_value, + aqi_category=aqi_label(aqi_value), + pollutant=pollutant, + burn_status=burn_el.get('status', ''), + burn_status_text=burn_el.text or '', + air_alert=air_alert, + air_alert_start=air_alert_start, + air_alert_end=air_alert_end, + ) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py -v` +Expected: PASS (9 tests total: 2 from Task 1 + 7 new) + +- [ ] **Step 6: Commit** + +```bash +git add requirements/base.txt camp/apps/forecasts/tasks.py camp/apps/forecasts/tests.py +git commit -m "feat(forecasts): add fetch_forecasts ingestion task" +``` + +--- + +### Task 3: Management command + +**Files:** +- Create: `camp/apps/forecasts/management/__init__.py` +- Create: `camp/apps/forecasts/management/commands/__init__.py` +- Create: `camp/apps/forecasts/management/commands/fetch_forecasts.py` +- Modify: `camp/apps/forecasts/tests.py` (append command test) + +**Interfaces:** +- Consumes: `camp.apps.forecasts.tasks.fetch_forecasts` (Task 2). +- Produces: `manage.py fetch_forecasts` — no arguments. + +- [ ] **Step 1: Create the management command package** + +```bash +mkdir -p camp/apps/forecasts/management/commands +touch camp/apps/forecasts/management/__init__.py +touch camp/apps/forecasts/management/commands/__init__.py +``` + +- [ ] **Step 2: Write the failing command test** + +Append to `camp/apps/forecasts/tests.py` (add `from django.core.management import call_command` and `from io import StringIO` to the imports): + +```python +from io import StringIO + +from django.core.management import call_command + + +class FetchForecastsCommandTests(TestCase): + fixtures = ['regions.yaml'] + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_command_ingests_forecasts(self, mock_get): + mock_get.return_value = mock_response() + out = StringIO() + call_command('fetch_forecasts', stdout=out) + assert Forecast.objects.count() == 16 + assert 'Done' in out.getvalue() +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py::FetchForecastsCommandTests -v` +Expected: FAIL — `django.core.management.base.CommandError: Unknown command: 'fetch_forecasts'` + +- [ ] **Step 4: Write the command** + +`camp/apps/forecasts/management/commands/fetch_forecasts.py`: + +```python +from django.core.management.base import BaseCommand + +from camp.apps.forecasts.tasks import fetch_forecasts + + +class Command(BaseCommand): + help = 'Fetch the SJVAPCD daily air quality forecast feed.' + + def handle(self, *args, **options): + self.stdout.write('Fetching SJVAPCD forecasts...') + fetch_forecasts.call_local() + self.stdout.write(self.style.SUCCESS('Done.')) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py::FetchForecastsCommandTests -v` +Expected: PASS + +- [ ] **Step 6: Run the full app test file** + +Run: `docker compose run --rm test pytest camp/apps/forecasts/tests.py -v` +Expected: PASS (10 tests) + +- [ ] **Step 7: Commit** + +```bash +git add camp/apps/forecasts/management camp/apps/forecasts/tests.py +git commit -m "feat(forecasts): add fetch_forecasts management command" +``` + +--- + +### Task 4: API — `/api/2.0/forecasts/` + +**Files:** +- Create: `camp/api/v2/forecasts/__init__.py` +- Create: `camp/api/v2/forecasts/serializers.py` +- Create: `camp/api/v2/forecasts/filters.py` +- Create: `camp/api/v2/forecasts/endpoints.py` +- Create: `camp/api/v2/forecasts/urls.py` +- Create: `camp/api/v2/forecasts/tests.py` +- Modify: `camp/api/v2/urls.py` + +**Interfaces:** +- Consumes: `camp.apps.forecasts.models.Forecast` (Task 1), `camp.api.v2.regions.serializers.RegionSerializer` (existing). +- Produces: named URLs `api:v2:forecasts:forecast-list` and `api:v2:forecasts:forecast-detail` (kwarg `forecast_id`). + +- [ ] **Step 1: Create the package and write the failing API tests** + +```bash +mkdir -p camp/api/v2/forecasts +touch camp/api/v2/forecasts/__init__.py +``` + +`camp/api/v2/forecasts/tests.py`: + +```python +from datetime import date, datetime, 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 + + +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 = date(2026, 7, 11) + self.yesterday = date(2026, 7, 10) + self.tomorrow = date(2026, 7, 12) + 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' + + 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 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `docker compose run --rm test pytest camp/api/v2/forecasts/tests.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'camp.apps.forecasts.models'` resolves fine (exists from Task 1), but `django.urls.exceptions.NoReverseMatch: Reverse for 'forecast-list' not found` since the URL isn't registered yet. + +- [ ] **Step 3: Write the serializer** + +`camp/api/v2/forecasts/serializers.py`: + +```python +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', + 'burn_status', 'burn_status_text', + 'air_alert', 'air_alert_start', 'air_alert_end', + ) +``` + +- [ ] **Step 4: Write the filter** + +`camp/api/v2/forecasts/filters.py`: + +```python +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'], + } +``` + +- [ ] **Step 5: Write the endpoints** + +`camp/api/v2/forecasts/endpoints.py`: + +```python +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() + if 'forecast_date' not in self.request.GET: + 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' +``` + +- [ ] **Step 6: Write the urls and mount them** + +`camp/api/v2/forecasts/urls.py`: + +```python +from django.urls import path + +from . import endpoints + +app_name = 'forecasts' + +urlpatterns = [ + path('', endpoints.ForecastList.as_view(), name='forecast-list'), + path('/', endpoints.ForecastDetail.as_view(), name='forecast-detail'), +] +``` + +In `camp/api/v2/urls.py`, find: + +```python + path('regions/', include('camp.api.v2.regions.urls', namespace='regions')), + path('ceidars/', include('camp.api.v2.ceidars.urls', namespace='ceidars')), +``` + +Change to: + +```python + 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')), +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `docker compose run --rm test pytest camp/api/v2/forecasts/tests.py -v` +Expected: PASS (6 tests) + +- [ ] **Step 8: Run the full test suite** + +Run: `docker compose run --rm test pytest -q` +Expected: PASS, no regressions (632 baseline + new tests from this plan) + +- [ ] **Step 9: Commit** + +```bash +git add camp/api/v2/forecasts camp/api/v2/urls.py +git commit -m "feat(api): add /api/2.0/forecasts/ endpoints" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Model (Task 1) ✓, ingestion task + zone mapping/skip + idempotency (Task 2) ✓, management command (Task 3) ✓, API list/detail/filters/default-date/nested-boundary (Task 4) ✓, admin (Task 1) ✓. Out-of-scope items (accurate zone geometry, notifications, historical backfill) are explicitly not tasked, per spec. +- **Namespace collision:** flagged as a Global Constraint and directly informed `split_today_tomorrow()` in Task 2 — this was discovered by hand-parsing the real feed during planning, not assumed. +- **County naming:** corrected from the design doc's shorthand (`"Fresno"`) to the actual stored form (`"Fresno County"`), verified against `fixtures/regions.yaml` and `SJV_COUNTIES` — `ZONE_TO_REGION_NAME` in Task 2 uses the corrected form throughout. +- **Type consistency:** `Forecast.Pollutant.OZONE`/`PM25` values (`'O3'`/`'PM2.5'`) match `AQI_TEXT_RE`'s captured group exactly, and match every observed value in the real feed sample used for tests. `fetch_forecasts.call_local()` signature (no args) is consistent between Task 2's tests and Task 3's command. From 01583fc7a83de9922b2d691eedaceed76f59c74b Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 01:12:03 -0700 Subject: [PATCH 03/20] feat(forecasts): add Forecast model and admin --- camp/apps/forecasts/__init__.py | 0 camp/apps/forecasts/admin.py | 25 ++++++++++ camp/apps/forecasts/apps.py | 5 ++ .../apps/forecasts/migrations/0001_initial.py | 43 ++++++++++++++++ camp/apps/forecasts/migrations/__init__.py | 0 camp/apps/forecasts/models.py | 45 +++++++++++++++++ camp/apps/forecasts/tests.py | 49 +++++++++++++++++++ camp/settings/base.py | 1 + 8 files changed, 168 insertions(+) create mode 100644 camp/apps/forecasts/__init__.py create mode 100644 camp/apps/forecasts/admin.py create mode 100644 camp/apps/forecasts/apps.py create mode 100644 camp/apps/forecasts/migrations/0001_initial.py create mode 100644 camp/apps/forecasts/migrations/__init__.py create mode 100644 camp/apps/forecasts/models.py create mode 100644 camp/apps/forecasts/tests.py diff --git a/camp/apps/forecasts/__init__.py b/camp/apps/forecasts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/camp/apps/forecasts/admin.py b/camp/apps/forecasts/admin.py new file mode 100644 index 00000000..1ac24525 --- /dev/null +++ b/camp/apps/forecasts/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin + +from .models import Forecast + + +@admin.register(Forecast) +class ForecastAdmin(admin.ModelAdmin): + date_hierarchy = 'forecast_date' + list_display = ['zone_name', 'forecast_date', 'issued_date', 'aqi_value', 'aqi_category', 'burn_status', 'air_alert'] + list_filter = ['aqi_category', 'burn_status', 'air_alert'] + readonly_fields = [ + 'sqid', 'region', 'zone_name', 'forecast_date', 'issued_date', 'published_at', + 'aqi_value', 'aqi_category', 'pollutant', 'burn_status', 'burn_status_text', + 'air_alert', 'air_alert_start', 'air_alert_end', + ] + ordering = ('-issued_date', 'zone_name') + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def save_model(self, request, obj, form, change): + pass diff --git a/camp/apps/forecasts/apps.py b/camp/apps/forecasts/apps.py new file mode 100644 index 00000000..c82f3340 --- /dev/null +++ b/camp/apps/forecasts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ForecastsConfig(AppConfig): + name = 'camp.apps.forecasts' diff --git a/camp/apps/forecasts/migrations/0001_initial.py b/camp/apps/forecasts/migrations/0001_initial.py new file mode 100644 index 00000000..be3ceee4 --- /dev/null +++ b/camp/apps/forecasts/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.15 on 2026-07-12 08:09 + +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('regions', '0005_region_metadata'), + ] + + operations = [ + migrations.CreateModel( + name='Forecast', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('zone_name', models.CharField(max_length=64, verbose_name='zone name')), + ('forecast_date', models.DateField(verbose_name='forecast date')), + ('issued_date', models.DateField(verbose_name='issued date')), + ('published_at', models.DateTimeField(verbose_name='published at')), + ('aqi_value', models.PositiveSmallIntegerField(verbose_name='AQI value')), + ('aqi_category', models.CharField(max_length=32, verbose_name='AQI category')), + ('pollutant', models.CharField(choices=[('O3', 'Ozone'), ('PM2.5', 'PM2.5')], max_length=16, verbose_name='pollutant')), + ('burn_status', models.CharField(max_length=32, verbose_name='burn status')), + ('burn_status_text', models.CharField(max_length=255, verbose_name='burn status text')), + ('air_alert', models.BooleanField(default=False, verbose_name='air alert')), + ('air_alert_start', models.DateField(blank=True, null=True, verbose_name='air alert start')), + ('air_alert_end', models.DateField(blank=True, null=True, verbose_name='air alert end')), + ('region', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forecasts', to='regions.region')), + ], + options={ + 'ordering': ('-issued_date', 'region__name'), + 'indexes': [models.Index(fields=['region', 'forecast_date'], name='forecasts_f_region__b2667f_idx'), models.Index(fields=['issued_date'], name='forecasts_f_issued__f350a8_idx')], + }, + ), + ] diff --git a/camp/apps/forecasts/migrations/__init__.py b/camp/apps/forecasts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/camp/apps/forecasts/models.py b/camp/apps/forecasts/models.py new file mode 100644 index 00000000..4d071d1f --- /dev/null +++ b/camp/apps/forecasts/models.py @@ -0,0 +1,45 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from django_sqids import SqidsField, shuffle_alphabet +from model_utils.models import TimeStampedModel + + +class Forecast(TimeStampedModel): + class Pollutant(models.TextChoices): + OZONE = 'O3', _('Ozone') + PM25 = 'PM2.5', _('PM2.5') + + sqid = SqidsField(alphabet=shuffle_alphabet('forecasts.Forecast')) + + region = models.ForeignKey( + 'regions.Region', + on_delete=models.CASCADE, + related_name='forecasts', + ) + zone_name = models.CharField(_('zone name'), max_length=64) + + forecast_date = models.DateField(_('forecast date')) + issued_date = models.DateField(_('issued date')) + published_at = models.DateTimeField(_('published at')) + + aqi_value = models.PositiveSmallIntegerField(_('AQI value')) + aqi_category = models.CharField(_('AQI category'), max_length=32) + pollutant = models.CharField(_('pollutant'), max_length=16, choices=Pollutant.choices) + + burn_status = models.CharField(_('burn status'), max_length=32) + burn_status_text = models.CharField(_('burn status text'), max_length=255) + + air_alert = models.BooleanField(_('air alert'), default=False) + air_alert_start = models.DateField(_('air alert start'), null=True, blank=True) + air_alert_end = models.DateField(_('air alert end'), null=True, blank=True) + + class Meta: + ordering = ('-issued_date', 'region__name') + indexes = [ + models.Index(fields=['region', 'forecast_date']), + models.Index(fields=['issued_date']), + ] + + def __str__(self): + return f'{self.zone_name} forecast for {self.forecast_date} (issued {self.issued_date})' diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py new file mode 100644 index 00000000..bb83bf4c --- /dev/null +++ b/camp/apps/forecasts/tests.py @@ -0,0 +1,49 @@ +from datetime import date, datetime, timezone as dt_timezone + +from django.test import TestCase + +from camp.apps.regions.models import Region + +from .models import Forecast + + +class ForecastModelTests(TestCase): + fixtures = ['regions.yaml'] + + def test_create_forecast(self): + region = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.create( + region=region, + zone_name='Fresno', + forecast_date=date(2026, 7, 11), + issued_date=date(2026, 7, 11), + published_at=datetime(2026, 7, 11, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=101, + aqi_category='Unhealthy for Sensitive Groups', + pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', + burn_status_text='Discouraged: Burning Discouraged', + air_alert=False, + ) + assert forecast.sqid + assert forecast.pollutant == 'O3' + assert str(forecast) == 'Fresno forecast for 2026-07-11 (issued 2026-07-11)' + assert Forecast.objects.filter(region=region).count() == 1 + + def test_ordering_is_newest_issued_first(self): + region = Region.objects.get(name='Fresno County') + older = Forecast.objects.create( + region=region, zone_name='Fresno', + forecast_date=date(2026, 7, 10), issued_date=date(2026, 7, 10), + published_at=datetime(2026, 7, 10, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=90, aqi_category='Moderate', pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', + ) + newer = Forecast.objects.create( + region=region, zone_name='Fresno', + forecast_date=date(2026, 7, 11), issued_date=date(2026, 7, 11), + published_at=datetime(2026, 7, 11, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=101, aqi_category='Unhealthy for Sensitive Groups', pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', + ) + assert list(Forecast.objects.all()) == [newer, older] diff --git a/camp/settings/base.py b/camp/settings/base.py index a5f095e6..553cc8aa 100644 --- a/camp/settings/base.py +++ b/camp/settings/base.py @@ -104,6 +104,7 @@ 'camp.apps.calibrations', 'camp.apps.contact', 'camp.apps.entries', + 'camp.apps.forecasts', 'camp.apps.helpdesk', 'camp.apps.hms', 'camp.apps.ces', From 25f11860502727083808d49a635a120a11c60895 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 01:22:06 -0700 Subject: [PATCH 04/20] feat(forecasts): add fetch_forecasts ingestion task --- camp/apps/forecasts/tasks.py | 131 +++++++++++++++++++++ camp/apps/forecasts/tests.py | 213 +++++++++++++++++++++++++++++++++++ requirements/base.txt | 1 + 3 files changed, 345 insertions(+) create mode 100644 camp/apps/forecasts/tasks.py diff --git a/camp/apps/forecasts/tasks.py b/camp/apps/forecasts/tasks.py new file mode 100644 index 00000000..de46ab0a --- /dev/null +++ b/camp/apps/forecasts/tasks.py @@ -0,0 +1,131 @@ +import re +from datetime import datetime, timedelta, timezone as dt_timezone + +import requests +from defusedxml import ElementTree as ET + +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django_huey import db_periodic_task +from huey import crontab + +from camp.apps.regions.models import Region +from camp.utils.aqi import aqi_label + +from .models import Forecast + + +FEED_URL = 'https://ww2.valleyair.org/aqinfo/airstatus.xml' + +# Both the burnStatus: and AQI: prefixes resolve to this same namespace URI in +# the feed, so and share one Clark-notation tag. +# See split_today_tomorrow() below for how they're told apart. +NAMESPACE_URI = 'https://ww2.valleyair.org/' + +# Maps the feed's raw label to the matching county Region's name. +# "Sequoia National Park and Forest" has no matching Region and is skipped. +ZONE_TO_REGION_NAME = { + 'San Joaquin': 'San Joaquin County', + 'Stanislaus': 'Stanislaus County', + 'Merced': 'Merced County', + 'Madera': 'Madera County', + 'Fresno': 'Fresno County', + 'Kings': 'Kings County', + 'Tulare': 'Tulare County', + 'Kern (SJV Air Basin portion)': 'Kern County', +} + +# "101 Unhealthy for Sensitive Groups (O3)" -> value=101, pollutant='O3' +AQI_TEXT_RE = re.compile(r'^(\d+)\s+.+?\(([^)]+)\)$') + + +def parse_feed_datetime(value): + """Parses feed timestamps like '2026-07-11T14:31:09 -7:00' into aware datetimes.""" + dt_part, offset_part = value.rsplit(' ', 1) + sign = -1 if offset_part.startswith('-') else 1 + hours_str, minutes_str = offset_part.lstrip('+-').split(':') + offset = timedelta(hours=int(hours_str), minutes=int(minutes_str)) * sign + naive = datetime.strptime(dt_part, '%Y-%m-%dT%H:%M:%S') + return naive.replace(tzinfo=dt_timezone(offset)) + + +def parse_alert_date(value): + """Parses an air-alert start/end date attribute. Format hasn't been observed + live (no sample pull has had an active alert); falls back to None on any + unexpected format rather than breaking ingestion for every zone.""" + if not value: + return None + try: + return datetime.strptime(value, '%Y-%m-%d').date() + except ValueError: + return None + + +def parse_aqi_text(text): + match = AQI_TEXT_RE.match((text or '').strip()) + if not match: + raise ValueError(f'Unrecognized AQI text: {text!r}') + return int(match.group(1)), match.group(2) + + +def split_today_tomorrow(elements): + """Splits the two same-tag elements for a horizon into (burn_status_el, aqi_el). + They're told apart by content shape: AQI text matches AQI_TEXT_RE, burn status + text does not. See the namespace-collision note in tasks.py's module docstring.""" + aqi_el = next(el for el in elements if AQI_TEXT_RE.match((el.text or '').strip())) + burn_el = next(el for el in elements if el is not aqi_el) + return burn_el, aqi_el + + +@db_periodic_task(crontab(minute='45', hour='23,0,1,2'), priority=50) +def fetch_forecasts(): + response = requests.get(FEED_URL, timeout=30) + response.raise_for_status() + root = ET.fromstring(response.content) + + # Extract issued_date from the feed's lastBuildDate + last_build_date_el = root.find('.//lastBuildDate') + issued_date = parse_feed_datetime(last_build_date_el.text).date() if last_build_date_el is not None else timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date() + + with transaction.atomic(): + Forecast.objects.filter(issued_date=issued_date).delete() + for item in root.iter('item'): + zone_name = (item.findtext('county') or '').strip() + region_name = ZONE_TO_REGION_NAME.get(zone_name) + if region_name is None: + continue # unmapped zone (e.g. Sequoia National Park and Forest) + + region = Region.objects.counties().filter(name=region_name).first() + if region is None: + continue # region not yet imported + + published_at = parse_feed_datetime(item.findtext('pubdate')) + + alert_el = item.find('airAlertStatus') + air_alert = alert_el.get('status') == 'YES' + air_alert_start = parse_alert_date(alert_el.get('startDate')) + air_alert_end = parse_alert_date(alert_el.get('endDate')) + + for horizon in ('today', 'tomorrow'): + elements = item.findall(f'{{{NAMESPACE_URI}}}{horizon}') + burn_el, aqi_el = split_today_tomorrow(elements) + + aqi_value, pollutant = parse_aqi_text(aqi_el.text) + forecast_date = parse_feed_datetime(aqi_el.get('date')).date() + + Forecast.objects.create( + region=region, + zone_name=zone_name, + forecast_date=forecast_date, + issued_date=issued_date, + published_at=published_at, + aqi_value=aqi_value, + aqi_category=aqi_label(aqi_value), + pollutant=pollutant, + burn_status=burn_el.get('status', ''), + burn_status_text=burn_el.text or '', + air_alert=air_alert, + air_alert_start=air_alert_start, + air_alert_end=air_alert_end, + ) diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index bb83bf4c..49f134dd 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -1,10 +1,12 @@ from datetime import date, datetime, timezone as dt_timezone +from unittest.mock import Mock, patch from django.test import TestCase from camp.apps.regions.models import Region from .models import Forecast +from .tasks import fetch_forecasts class ForecastModelTests(TestCase): @@ -47,3 +49,214 @@ def test_ordering_is_newest_issued_first(self): burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', ) assert list(Forecast.objects.all()) == [newer, older] + + +SAMPLE_FEED_XML = b""" + +SJVAPCD mobile app Status +SJVAPCD Air Quality Information + + +SJVAPCD +/favicon.ico + +Air Quality Status +SJVAPCD Air Quality Status by County +https://ww2.valleyair.org/air-quality-information/real-time-air-advisory-network-raan/real-time-air-monitoring-stations/ +en-us +Copyright 2013 SJVAPCD +2026-07-11T21:31:09 -7:00 +SJVAPCD +webmaster@valleyair.org +1440 + +http://www.valleyair.org/aqinfo/San Joaquin +San Joaquin Air Quality Status +San Joaquin +Discouraged: Burning Discouraged +55 Moderate (PM2.5) +Discouraged: Burning Discouraged +51 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Stanislaus +Stanislaus Air Quality Status +Stanislaus +Discouraged: Burning Discouraged +77 Moderate (O3) +Discouraged: Burning Discouraged +58 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Merced +Merced Air Quality Status +Merced +Discouraged: Burning Discouraged +80 Moderate (O3) +Discouraged: Burning Discouraged +61 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Madera +Madera Air Quality Status +Madera +Discouraged: Burning Discouraged +100 Moderate (O3) +Discouraged: Burning Discouraged +80 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Fresno +Fresno Air Quality Status +Fresno +Discouraged: Burning Discouraged +101 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +100 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Kings +Kings Air Quality Status +Kings +Discouraged: Burning Discouraged +71 Moderate (O3) +Discouraged: Burning Discouraged +53 Moderate (PM2.5) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Tulare +Tulare Air Quality Status +Tulare +Discouraged: Burning Discouraged +105 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +84 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Kern (SJV Air Basin portion) +Kern (SJV Air Basin portion) Air Quality Status +Kern (SJV Air Basin portion) +Discouraged: Burning Discouraged +115 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +100 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + +http://www.valleyair.org/aqinfo/Sequoia National Park and Forest +Sequoia National Park and Forest Air Quality Status +Sequoia National Park and Forest +Discouraged: Burning Discouraged +129 Unhealthy for Sensitive Groups (O3) +Discouraged: Burning Discouraged +100 Moderate (O3) + +https://www.valleyair.org/Programs/RAAN/raan_monitoring_system.htm +2026-07-11T14:31:09 -7:00 + + + +""" + + +def mock_response(content=SAMPLE_FEED_XML): + response = Mock() + response.content = content + response.raise_for_status = Mock() + return response + + +class FetchForecastsTests(TestCase): + fixtures = ['regions.yaml'] + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_creates_forecast_for_each_mapped_zone(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + # 8 mapped zones x 2 horizons (today/tomorrow) = 16 rows; Sequoia dropped. + assert Forecast.objects.count() == 16 + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_skips_unmapped_zone(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + assert not Forecast.objects.filter(zone_name='Sequoia National Park and Forest').exists() + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_kern_alias_maps_to_kern_county_region(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + kern = Region.objects.get(name='Kern County') + forecast = Forecast.objects.get( + region=kern, zone_name='Kern (SJV Air Basin portion)', forecast_date=date(2026, 7, 11), + ) + assert forecast.aqi_value == 115 + assert forecast.pollutant == 'O3' + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_sets_fields_correctly_for_fresno_today(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + fresno = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.get(region=fresno, forecast_date=date(2026, 7, 11)) + assert forecast.aqi_value == 101 + assert forecast.aqi_category == 'Unhealthy for Sensitive Groups' + assert forecast.pollutant == 'O3' + assert forecast.burn_status == 'Discouraged' + assert forecast.burn_status_text == 'Discouraged: Burning Discouraged' + assert forecast.air_alert is False + assert forecast.air_alert_start is None + assert forecast.air_alert_end is None + assert forecast.issued_date == date(2026, 7, 11) + assert forecast.published_at.year == 2026 + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_tomorrow_row_has_next_day_forecast_date(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + fresno = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.get(region=fresno, forecast_date=date(2026, 7, 12)) + assert forecast.aqi_value == 100 + assert forecast.aqi_category == 'Moderate' + assert forecast.issued_date == date(2026, 7, 11) + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_pm25_zone_parses_correctly(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + san_joaquin = Region.objects.get(name='San Joaquin County') + forecast = Forecast.objects.get(region=san_joaquin, forecast_date=date(2026, 7, 11)) + assert forecast.pollutant == 'PM2.5' + assert forecast.aqi_value == 55 + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_idempotent_rerun_does_not_duplicate(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + count = Forecast.objects.count() + assert count == 16 + fetch_forecasts.call_local() + assert Forecast.objects.count() == count diff --git a/requirements/base.txt b/requirements/base.txt index fb5abe2f..95f95961 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -31,6 +31,7 @@ argon2-cffi==25.1.0 boto3==1.43.24 ckanapi==4.11 contextily==1.7.0 +defusedxml==0.7.1 delegator.py==0.1.1 dpath==2.2.0 fiona==1.10.1 From e5c3fd49d96e9fc6acb325856823c23c8fbabced Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 01:28:06 -0700 Subject: [PATCH 05/20] fix(forecasts): restore server-clock issued_date semantics, mock timezone.now() in tests issued_date must reflect when THIS SERVER pulled the feed, not the feed's self-reported lastBuildDate, since the delete-and-recreate idempotency check is keyed on it. Revert fetch_forecasts() to compute issued_date from timezone.now() as the plan specifies, and fix the tests to mock timezone.now() instead of depending on the real wall-clock date. --- camp/apps/forecasts/tasks.py | 6 ++---- camp/apps/forecasts/tests.py | 8 ++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/camp/apps/forecasts/tasks.py b/camp/apps/forecasts/tasks.py index de46ab0a..c673a205 100644 --- a/camp/apps/forecasts/tasks.py +++ b/camp/apps/forecasts/tasks.py @@ -80,14 +80,12 @@ def split_today_tomorrow(elements): @db_periodic_task(crontab(minute='45', hour='23,0,1,2'), priority=50) def fetch_forecasts(): + issued_date = timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date() + response = requests.get(FEED_URL, timeout=30) response.raise_for_status() root = ET.fromstring(response.content) - # Extract issued_date from the feed's lastBuildDate - last_build_date_el = root.find('.//lastBuildDate') - issued_date = parse_feed_datetime(last_build_date_el.text).date() if last_build_date_el is not None else timezone.now().astimezone(settings.DEFAULT_TIMEZONE).date() - with transaction.atomic(): Forecast.objects.filter(issued_date=issued_date).delete() for item in root.iter('item'): diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index 49f134dd..667ef714 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -51,6 +51,9 @@ def test_ordering_is_newest_issued_first(self): assert list(Forecast.objects.all()) == [newer, older] +FIXED_NOW = datetime(2026, 7, 11, 19, 0, 0, tzinfo=dt_timezone.utc) + + SAMPLE_FEED_XML = b""" SJVAPCD mobile app Status @@ -192,6 +195,11 @@ def mock_response(content=SAMPLE_FEED_XML): class FetchForecastsTests(TestCase): fixtures = ['regions.yaml'] + def setUp(self): + patcher = patch('django.utils.timezone.now', return_value=FIXED_NOW) + patcher.start() + self.addCleanup(patcher.stop) + @patch('camp.apps.forecasts.tasks.requests.get') def test_creates_forecast_for_each_mapped_zone(self, mock_get): mock_get.return_value = mock_response() From 68e6f495ced455117e62892035102bc339e509a9 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 01:32:03 -0700 Subject: [PATCH 06/20] feat(forecasts): add fetch_forecasts management command --- camp/apps/forecasts/management/__init__.py | 0 .../apps/forecasts/management/commands/__init__.py | 0 .../management/commands/fetch_forecasts.py | 12 ++++++++++++ camp/apps/forecasts/tests.py | 14 ++++++++++++++ 4 files changed, 26 insertions(+) create mode 100644 camp/apps/forecasts/management/__init__.py create mode 100644 camp/apps/forecasts/management/commands/__init__.py create mode 100644 camp/apps/forecasts/management/commands/fetch_forecasts.py diff --git a/camp/apps/forecasts/management/__init__.py b/camp/apps/forecasts/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/camp/apps/forecasts/management/commands/__init__.py b/camp/apps/forecasts/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/camp/apps/forecasts/management/commands/fetch_forecasts.py b/camp/apps/forecasts/management/commands/fetch_forecasts.py new file mode 100644 index 00000000..c6747872 --- /dev/null +++ b/camp/apps/forecasts/management/commands/fetch_forecasts.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from camp.apps.forecasts.tasks import fetch_forecasts + + +class Command(BaseCommand): + help = 'Fetch the SJVAPCD daily air quality forecast feed.' + + def handle(self, *args, **options): + self.stdout.write('Fetching SJVAPCD forecasts...') + fetch_forecasts.call_local() + self.stdout.write(self.style.SUCCESS('Done.')) diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index 667ef714..76da17dc 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -1,6 +1,8 @@ from datetime import date, datetime, timezone as dt_timezone +from io import StringIO from unittest.mock import Mock, patch +from django.core.management import call_command from django.test import TestCase from camp.apps.regions.models import Region @@ -268,3 +270,15 @@ def test_idempotent_rerun_does_not_duplicate(self, mock_get): assert count == 16 fetch_forecasts.call_local() assert Forecast.objects.count() == count + + +class FetchForecastsCommandTests(TestCase): + fixtures = ['regions.yaml'] + + @patch('camp.apps.forecasts.tasks.requests.get') + def test_command_ingests_forecasts(self, mock_get): + mock_get.return_value = mock_response() + out = StringIO() + call_command('fetch_forecasts', stdout=out) + assert Forecast.objects.count() == 16 + assert 'Done' in out.getvalue() From 12fc7a5f7ccb6fc28e45d9938b6285cb80371edf Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 01:39:00 -0700 Subject: [PATCH 07/20] feat(api): add /api/2.0/forecasts/ endpoints --- camp/api/v2/forecasts/__init__.py | 0 camp/api/v2/forecasts/endpoints.py | 42 +++++++++++++ camp/api/v2/forecasts/filters.py | 15 +++++ camp/api/v2/forecasts/serializers.py | 14 +++++ camp/api/v2/forecasts/tests.py | 94 ++++++++++++++++++++++++++++ camp/api/v2/forecasts/urls.py | 10 +++ camp/api/v2/urls.py | 1 + 7 files changed, 176 insertions(+) create mode 100644 camp/api/v2/forecasts/__init__.py create mode 100644 camp/api/v2/forecasts/endpoints.py create mode 100644 camp/api/v2/forecasts/filters.py create mode 100644 camp/api/v2/forecasts/serializers.py create mode 100644 camp/api/v2/forecasts/tests.py create mode 100644 camp/api/v2/forecasts/urls.py diff --git a/camp/api/v2/forecasts/__init__.py b/camp/api/v2/forecasts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/camp/api/v2/forecasts/endpoints.py b/camp/api/v2/forecasts/endpoints.py new file mode 100644 index 00000000..e6a0dc2f --- /dev/null +++ b/camp/api/v2/forecasts/endpoints.py @@ -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' diff --git a/camp/api/v2/forecasts/filters.py b/camp/api/v2/forecasts/filters.py new file mode 100644 index 00000000..3285359c --- /dev/null +++ b/camp/api/v2/forecasts/filters.py @@ -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'], + } diff --git a/camp/api/v2/forecasts/serializers.py b/camp/api/v2/forecasts/serializers.py new file mode 100644 index 00000000..3e9da11c --- /dev/null +++ b/camp/api/v2/forecasts/serializers.py @@ -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', + 'burn_status', 'burn_status_text', + 'air_alert', 'air_alert_start', 'air_alert_end', + ) diff --git a/camp/api/v2/forecasts/tests.py b/camp/api/v2/forecasts/tests.py new file mode 100644 index 00000000..6ad9ac2d --- /dev/null +++ b/camp/api/v2/forecasts/tests.py @@ -0,0 +1,94 @@ +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' + + 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 diff --git a/camp/api/v2/forecasts/urls.py b/camp/api/v2/forecasts/urls.py new file mode 100644 index 00000000..0d2b08ee --- /dev/null +++ b/camp/api/v2/forecasts/urls.py @@ -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('/', endpoints.ForecastDetail.as_view(), name='forecast-detail'), +] diff --git a/camp/api/v2/urls.py b/camp/api/v2/urls.py index 948bc3df..f03790ad 100755 --- a/camp/api/v2/urls.py +++ b/camp/api/v2/urls.py @@ -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//', endpoints.TaskStatus.as_view(), name='task-status'), From e0aa8db7dccd5dc151107934bd1fa5db77b6f501 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 13:50:09 -0700 Subject: [PATCH 08/20] fix(forecasts): isolate per-item feed parsing so one malformed zone doesn't abort the whole run Wrap each item's parse+create body in fetch_forecasts() in a try/except (StopIteration, ValueError, AttributeError). Previously an unexpected feed shape for a single zone (e.g. "Unavailable" AQI text, a missing pubdate, or a missing airAlertStatus element) would propagate out of the enclosing transaction.atomic() block and roll back forecasts for all eight counties, not just the one affected zone. --- camp/apps/forecasts/tasks.py | 69 +++++++++++++++++++++--------------- camp/apps/forecasts/tests.py | 17 +++++++++ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/camp/apps/forecasts/tasks.py b/camp/apps/forecasts/tasks.py index c673a205..2dd18944 100644 --- a/camp/apps/forecasts/tasks.py +++ b/camp/apps/forecasts/tasks.py @@ -1,3 +1,4 @@ +import logging import re from datetime import datetime, timedelta, timezone as dt_timezone @@ -15,6 +16,8 @@ from .models import Forecast +logger = logging.getLogger(__name__) + FEED_URL = 'https://ww2.valleyair.org/aqinfo/airstatus.xml' @@ -98,32 +101,42 @@ def fetch_forecasts(): if region is None: continue # region not yet imported - published_at = parse_feed_datetime(item.findtext('pubdate')) - - alert_el = item.find('airAlertStatus') - air_alert = alert_el.get('status') == 'YES' - air_alert_start = parse_alert_date(alert_el.get('startDate')) - air_alert_end = parse_alert_date(alert_el.get('endDate')) - - for horizon in ('today', 'tomorrow'): - elements = item.findall(f'{{{NAMESPACE_URI}}}{horizon}') - burn_el, aqi_el = split_today_tomorrow(elements) - - aqi_value, pollutant = parse_aqi_text(aqi_el.text) - forecast_date = parse_feed_datetime(aqi_el.get('date')).date() - - Forecast.objects.create( - region=region, - zone_name=zone_name, - forecast_date=forecast_date, - issued_date=issued_date, - published_at=published_at, - aqi_value=aqi_value, - aqi_category=aqi_label(aqi_value), - pollutant=pollutant, - burn_status=burn_el.get('status', ''), - burn_status_text=burn_el.text or '', - air_alert=air_alert, - air_alert_start=air_alert_start, - air_alert_end=air_alert_end, + try: + published_at = parse_feed_datetime(item.findtext('pubdate')) + + alert_el = item.find('airAlertStatus') + air_alert = alert_el.get('status') == 'YES' + air_alert_start = parse_alert_date(alert_el.get('startDate')) + air_alert_end = parse_alert_date(alert_el.get('endDate')) + + for horizon in ('today', 'tomorrow'): + elements = item.findall(f'{{{NAMESPACE_URI}}}{horizon}') + burn_el, aqi_el = split_today_tomorrow(elements) + + aqi_value, pollutant = parse_aqi_text(aqi_el.text) + forecast_date = parse_feed_datetime(aqi_el.get('date')).date() + + Forecast.objects.create( + region=region, + zone_name=zone_name, + forecast_date=forecast_date, + issued_date=issued_date, + published_at=published_at, + aqi_value=aqi_value, + aqi_category=aqi_label(aqi_value), + pollutant=pollutant, + burn_status=burn_el.get('status', ''), + burn_status_text=burn_el.text or '', + air_alert=air_alert, + air_alert_start=air_alert_start, + air_alert_end=air_alert_end, + ) + except (StopIteration, ValueError, AttributeError) as exc: + # A single zone with an unexpected feed shape (e.g. "Unavailable" + # AQI text, or a missing pubdate/airAlertStatus element) shouldn't + # roll back the whole run. Skip it and keep processing the rest. + logger.warning( + 'Skipping malformed forecast feed item for zone %r: %s', + zone_name, exc, ) + continue diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index 76da17dc..ceab0114 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -271,6 +271,23 @@ def test_idempotent_rerun_does_not_duplicate(self, mock_get): fetch_forecasts.call_local() assert Forecast.objects.count() == count + @patch('camp.apps.forecasts.tasks.requests.get') + def test_malformed_zone_is_skipped_without_aborting_the_run(self, mock_get): + # Kings' text no longer matches AQI_TEXT_RE, simulating a + # plausible "Unavailable" feed state. This should only drop Kings' + # rows, not roll back the other 7 (already-good) zones. + broken_xml = SAMPLE_FEED_XML.replace( + b'71 Moderate (O3)', + b'Unavailable', + ) + assert broken_xml != SAMPLE_FEED_XML + mock_get.return_value = mock_response(broken_xml) + + fetch_forecasts.call_local() # must not raise + + assert Forecast.objects.count() == 14 + assert not Forecast.objects.filter(zone_name='Kings').exists() + class FetchForecastsCommandTests(TestCase): fixtures = ['regions.yaml'] From add3a8abe24d052a5b0cb27d995ee2b073eacbcf Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 14:40:34 -0700 Subject: [PATCH 09/20] feat(forecasts): make admin fully featured while staying read-only Reuse the existing ReadOnlyAdminMixin instead of hand-rolled permission overrides, and add search, filters, and an optimized queryset so the Forecasts section is fully browsable/searchable from the admin index. --- camp/apps/forecasts/admin.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/camp/apps/forecasts/admin.py b/camp/apps/forecasts/admin.py index 1ac24525..6e1da7fb 100644 --- a/camp/apps/forecasts/admin.py +++ b/camp/apps/forecasts/admin.py @@ -1,25 +1,27 @@ from django.contrib import admin +from camp.utils.admin import ReadOnlyAdminMixin + from .models import Forecast @admin.register(Forecast) -class ForecastAdmin(admin.ModelAdmin): +class ForecastAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): date_hierarchy = 'forecast_date' - list_display = ['zone_name', 'forecast_date', 'issued_date', 'aqi_value', 'aqi_category', 'burn_status', 'air_alert'] - list_filter = ['aqi_category', 'burn_status', 'air_alert'] - readonly_fields = [ - 'sqid', 'region', 'zone_name', 'forecast_date', 'issued_date', 'published_at', - 'aqi_value', 'aqi_category', 'pollutant', 'burn_status', 'burn_status_text', - 'air_alert', 'air_alert_start', 'air_alert_end', + list_display = [ + 'zone_name', 'region', 'forecast_date', 'issued_date', + 'aqi_value', 'aqi_category', 'pollutant', 'burn_status', 'air_alert', ] + list_filter = ['aqi_category', 'pollutant', 'burn_status', 'air_alert'] + search_fields = ['zone_name', 'region__name'] ordering = ('-issued_date', 'zone_name') + fields = [ + 'region', 'zone_name', + 'forecast_date', 'issued_date', 'published_at', + 'aqi_value', 'aqi_category', 'pollutant', + 'burn_status', 'burn_status_text', + 'air_alert', 'air_alert_start', 'air_alert_end', + ] - def has_add_permission(self, request): - return False - - def has_delete_permission(self, request, obj=None): - return False - - def save_model(self, request, obj, form, change): - pass + def get_queryset(self, request): + return super().get_queryset(request).select_related('region') From f01b71b6a6e386cebdc494ef7967770ad8c465be Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 15:14:23 -0700 Subject: [PATCH 10/20] feat(forecasts): add SJVAPCD forecast feed to data-feeds health checks Follows the existing MonitorHealthCheck pattern (camp/apps/monitors/health_checks.py): warns if no Forecast rows exist, or if the most recent one is older than 27 hours (the feed's ~24h cadence plus the fetch_forecasts cron window's slack). Registered on the existing /system-status/data-feeds/ page. --- camp/apps/forecasts/health_checks.py | 40 ++++++++++++++++++++++++++++ camp/urls.py | 2 ++ 2 files changed, 42 insertions(+) create mode 100644 camp/apps/forecasts/health_checks.py diff --git a/camp/apps/forecasts/health_checks.py b/camp/apps/forecasts/health_checks.py new file mode 100644 index 00000000..bb16f272 --- /dev/null +++ b/camp/apps/forecasts/health_checks.py @@ -0,0 +1,40 @@ +import dataclasses +from datetime import timedelta + +from django.utils import timezone +from django.utils.timesince import timesince + +from health_check.base import HealthCheck +from health_check.exceptions import ServiceWarning, ServiceReturnedUnexpectedResult + +from camp.apps.forecasts.models import Forecast + + +@dataclasses.dataclass +class ForecastsHealthCheck(HealthCheck): + # The feed updates once daily (~4:30pm Pacific); fetch_forecasts runs across + # a multi-hour window (23,0,1,2 UTC) to absorb DST, so the worst-case gap + # between two successful runs is the ~24 hour cadence plus that window. + limit: timedelta = dataclasses.field(default_factory=lambda: timedelta(hours=27), repr=False) + + def __repr__(self): + return 'SJVAPCD Forecast' + + @property + def labels(self): + return {'check': 'SJVAPCD Forecast'} + + def run(self): + try: + forecast = Forecast.objects.order_by('-created').first() + + if forecast is None: + raise ServiceWarning('No forecasts in database.') + + now = timezone.now() + if now - forecast.created > self.limit: + raise ServiceWarning(f'Last forecast was ingested {timesince(forecast.created)} ago.') + except ServiceWarning: + raise + except Exception as e: + raise ServiceReturnedUnexpectedResult(e.__class__.__name__) from e diff --git a/camp/urls.py b/camp/urls.py index 7031c670..39baf937 100644 --- a/camp/urls.py +++ b/camp/urls.py @@ -12,6 +12,7 @@ from health_check.contrib.redis import Redis from health_check.views import HealthCheckView +from camp.apps.forecasts.health_checks import ForecastsHealthCheck from camp.apps.monitors.health_checks import ( AirGradientHealthCheck, AirNowHealthCheck, @@ -64,6 +65,7 @@ AQviewHealthCheck, CCACBAMHealthCheck, PurpleAirHealthCheck, + ForecastsHealthCheck, ], extra_context={'title': 'Data Feeds'}, ), name='data-feeds'), From 373dd32ddedd755b61567b3b198e49b7b09457e0 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 16:06:56 -0700 Subject: [PATCH 11/20] fix(forecasts): isolate DB-level errors per zone, alert on ingestion failures - Wrap each zone's writes in a nested transaction.atomic() savepoint and catch django.db.Error alongside the existing parse-error types, so a DB-level failure (e.g. a value that violates a field constraint) for one zone no longer poisons the outer transaction and rolls back every other already-processed zone. - Log the skipped-zone case at ERROR instead of WARNING. Sentry's default logging integration only creates an issue at ERROR+ (WARNING is just a breadcrumb), so a persistent feed problem for one zone now actually alerts instead of silently degrading that zone's data forever. --- camp/apps/forecasts/tasks.py | 75 ++++++++++++++++++++---------------- camp/apps/forecasts/tests.py | 21 ++++++++++ 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/camp/apps/forecasts/tasks.py b/camp/apps/forecasts/tasks.py index 2dd18944..e79946d7 100644 --- a/camp/apps/forecasts/tasks.py +++ b/camp/apps/forecasts/tasks.py @@ -6,7 +6,7 @@ from defusedxml import ElementTree as ET from django.conf import settings -from django.db import transaction +from django.db import Error as DBError, transaction from django.utils import timezone from django_huey import db_periodic_task from huey import crontab @@ -102,40 +102,47 @@ def fetch_forecasts(): continue # region not yet imported try: - published_at = parse_feed_datetime(item.findtext('pubdate')) - - alert_el = item.find('airAlertStatus') - air_alert = alert_el.get('status') == 'YES' - air_alert_start = parse_alert_date(alert_el.get('startDate')) - air_alert_end = parse_alert_date(alert_el.get('endDate')) - - for horizon in ('today', 'tomorrow'): - elements = item.findall(f'{{{NAMESPACE_URI}}}{horizon}') - burn_el, aqi_el = split_today_tomorrow(elements) - - aqi_value, pollutant = parse_aqi_text(aqi_el.text) - forecast_date = parse_feed_datetime(aqi_el.get('date')).date() - - Forecast.objects.create( - region=region, - zone_name=zone_name, - forecast_date=forecast_date, - issued_date=issued_date, - published_at=published_at, - aqi_value=aqi_value, - aqi_category=aqi_label(aqi_value), - pollutant=pollutant, - burn_status=burn_el.get('status', ''), - burn_status_text=burn_el.text or '', - air_alert=air_alert, - air_alert_start=air_alert_start, - air_alert_end=air_alert_end, - ) - except (StopIteration, ValueError, AttributeError) as exc: + # A nested atomic() creates a savepoint for this zone alone, so a + # failure here (parse error or DB error) only rolls back this + # zone's writes, not the whole run's transaction. + with transaction.atomic(): + published_at = parse_feed_datetime(item.findtext('pubdate')) + + alert_el = item.find('airAlertStatus') + air_alert = alert_el.get('status') == 'YES' + air_alert_start = parse_alert_date(alert_el.get('startDate')) + air_alert_end = parse_alert_date(alert_el.get('endDate')) + + for horizon in ('today', 'tomorrow'): + elements = item.findall(f'{{{NAMESPACE_URI}}}{horizon}') + burn_el, aqi_el = split_today_tomorrow(elements) + + aqi_value, pollutant = parse_aqi_text(aqi_el.text) + forecast_date = parse_feed_datetime(aqi_el.get('date')).date() + + Forecast.objects.create( + region=region, + zone_name=zone_name, + forecast_date=forecast_date, + issued_date=issued_date, + published_at=published_at, + aqi_value=aqi_value, + aqi_category=aqi_label(aqi_value), + pollutant=pollutant, + burn_status=burn_el.get('status', ''), + burn_status_text=burn_el.text or '', + air_alert=air_alert, + air_alert_start=air_alert_start, + air_alert_end=air_alert_end, + ) + except (StopIteration, ValueError, AttributeError, DBError) as exc: # A single zone with an unexpected feed shape (e.g. "Unavailable" - # AQI text, or a missing pubdate/airAlertStatus element) shouldn't - # roll back the whole run. Skip it and keep processing the rest. - logger.warning( + # AQI text, a missing pubdate/airAlertStatus element, or a value + # that violates a field constraint) shouldn't roll back the whole + # run. Skip it and keep processing the rest. Logged at ERROR (not + # WARNING) so a persistent problem for one zone actually creates a + # Sentry issue instead of only a breadcrumb. + logger.error( 'Skipping malformed forecast feed item for zone %r: %s', zone_name, exc, ) diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index ceab0114..d4ce946c 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch from django.core.management import call_command +from django.db import IntegrityError from django.test import TestCase from camp.apps.regions.models import Region @@ -288,6 +289,26 @@ def test_malformed_zone_is_skipped_without_aborting_the_run(self, mock_get): assert Forecast.objects.count() == 14 assert not Forecast.objects.filter(zone_name='Kings').exists() + @patch('camp.apps.forecasts.tasks.requests.get') + def test_db_error_for_one_zone_does_not_abort_other_zones(self, mock_get): + # Simulates a DB-level failure (e.g. a value that violates a field + # constraint) for a single zone. The nested savepoint around each + # zone's writes should isolate this: Kings' rows are dropped, but the + # other 7 (already-committed-to-the-savepoint) zones must still land. + mock_get.return_value = mock_response() + original_create = Forecast.objects.create + + def flaky_create(**kwargs): + if kwargs.get('zone_name') == 'Kings': + raise IntegrityError('simulated db error') + return original_create(**kwargs) + + with patch.object(Forecast.objects, 'create', side_effect=flaky_create): + fetch_forecasts.call_local() # must not raise + + assert Forecast.objects.count() == 14 + assert not Forecast.objects.filter(zone_name='Kings').exists() + class FetchForecastsCommandTests(TestCase): fixtures = ['regions.yaml'] From bd5bb3c37caf2d86924f3f8e37e925c9825143d1 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 16:14:59 -0700 Subject: [PATCH 12/20] feat(forecasts): link region column in admin changelist to its detail page Follows the admin_change_link() convention already used in camp/apps/pesticides/admin.py. --- camp/apps/forecasts/admin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/camp/apps/forecasts/admin.py b/camp/apps/forecasts/admin.py index 6e1da7fb..c2fdfae4 100644 --- a/camp/apps/forecasts/admin.py +++ b/camp/apps/forecasts/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from camp.utils.admin import ReadOnlyAdminMixin +from camp.utils.admin import ReadOnlyAdminMixin, admin_change_link from .models import Forecast @@ -9,7 +9,7 @@ class ForecastAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): date_hierarchy = 'forecast_date' list_display = [ - 'zone_name', 'region', 'forecast_date', 'issued_date', + 'zone_name', 'get_region', 'forecast_date', 'issued_date', 'aqi_value', 'aqi_category', 'pollutant', 'burn_status', 'air_alert', ] list_filter = ['aqi_category', 'pollutant', 'burn_status', 'air_alert'] @@ -25,3 +25,8 @@ class ForecastAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): def get_queryset(self, request): return super().get_queryset(request).select_related('region') + + def get_region(self, instance): + return admin_change_link(instance.region, instance.region.name) + get_region.short_description = 'Region' + get_region.admin_order_field = 'region__name' From 41f5164c552a42147835ee3aba7236816bd53ff7 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 16:37:44 -0700 Subject: [PATCH 13/20] refactor(entries): extract pollutant LevelSets into levels.py, add AQI_LEVELS Moves the inline Levels = LevelSet(...) blocks out of PM25/PM100/CO/NO2/O3/SO2 (camp/apps/entries/models.py) into named constants in levels.py -- pure code motion, no behavior change (91 existing entries/alerts/regions/monitors tests still pass unchanged). Adds AQI_LEVELS: a new LevelSet for the EPA AQI index itself (0-500), as opposed to a pollutant's raw concentration -- something the existing per-pollutant LevelSets can't represent, since their breakpoints are in each pollutant's native unit. Breakpoints match camp.utils.aqi.aqi_label. Wires this into forecasts: Forecast.color derives a hex color from aqi_value via AQI_LEVELS.get_color(), matching the blended-color convention already used for monitor markers (camp/apps/regions/admin.py). Exposed on the API serializer so the frontend map layer can shade counties without reimplementing the AQI color scale. --- camp/api/v2/forecasts/serializers.py | 2 +- camp/api/v2/forecasts/tests.py | 1 + camp/apps/entries/levels.py | 71 ++++++++++++++++++++++++++++ camp/apps/entries/models.py | 58 +++-------------------- camp/apps/forecasts/models.py | 6 +++ camp/apps/forecasts/tests.py | 14 ++++++ 6 files changed, 100 insertions(+), 52 deletions(-) diff --git a/camp/api/v2/forecasts/serializers.py b/camp/api/v2/forecasts/serializers.py index 3e9da11c..6042a213 100644 --- a/camp/api/v2/forecasts/serializers.py +++ b/camp/api/v2/forecasts/serializers.py @@ -8,7 +8,7 @@ class ForecastSerializer(serializers.Serializer): ('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', + 'aqi_value', 'aqi_category', 'pollutant', 'color', 'burn_status', 'burn_status_text', 'air_alert', 'air_alert_start', 'air_alert_end', ) diff --git a/camp/api/v2/forecasts/tests.py b/camp/api/v2/forecasts/tests.py index 6ad9ac2d..6cd4c3e8 100644 --- a/camp/api/v2/forecasts/tests.py +++ b/camp/api/v2/forecasts/tests.py @@ -87,6 +87,7 @@ def test_detail(self): 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'}) diff --git a/camp/apps/entries/levels.py b/camp/apps/entries/levels.py index c7b5bf76..9f5fdee9 100644 --- a/camp/apps/entries/levels.py +++ b/camp/apps/entries/levels.py @@ -201,3 +201,74 @@ 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. + +PM25_LEVELS = 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_LEVELS = 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_LEVELS = 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_LEVELS = 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_LEVELS = LevelSet( + AQLevel.GOOD(0.0), + AQLevel.UNHEALTHY_SENSITIVE(125), + AQLevel.UNHEALTHY(165), + AQLevel.VERY_UNHEALTHY(205), + AQLevel.HAZARDOUS(405), + AQLevel.VERY_HAZARDOUS(605), +) + +SO2_LEVELS = 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_LEVELS = LevelSet( + AQLevel.GOOD(0), + AQLevel.MODERATE(51), + AQLevel.UNHEALTHY_SENSITIVE(101), + AQLevel.UNHEALTHY(151), + AQLevel.VERY_UNHEALTHY(201), + AQLevel.HAZARDOUS(301), +) diff --git a/camp/apps/entries/models.py b/camp/apps/entries/models.py index 4f0be2a6..b0907dff 100644 --- a/camp/apps/entries/models.py +++ b/camp/apps/entries/models.py @@ -13,7 +13,7 @@ from camp.utils import classproperty from . import stages -from .levels import LevelSet, AQLevel +from .levels import CO_LEVELS, NO2_LEVELS, O3_LEVELS, PM25_LEVELS, PM100_LEVELS, SO2_LEVELS from .managers import EntryQuerySet @@ -309,14 +309,7 @@ class PM25(BaseEntry): units = 'µg/m³' summarize = True - Levels = 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), - ) + Levels = PM25_LEVELS value = models.DecimalField( max_digits=7, decimal_places=2, @@ -350,15 +343,7 @@ class PM100(BaseEntry): epa_aqs_code = 81102 units = 'µg/m³' - Levels = 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), - ) + Levels = PM100_LEVELS value = models.DecimalField( max_digits=6, decimal_places=2, @@ -455,15 +440,7 @@ class CO(BaseEntry): units = 'ppm' summarize = True - Levels = 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), - ) + Levels = CO_LEVELS value = models.DecimalField( max_digits=6, decimal_places=2, @@ -488,15 +465,7 @@ class NO2(BaseEntry): units = 'ppb' summarize = True - Levels = 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), - ) + Levels = NO2_LEVELS value = models.DecimalField( max_digits=6, decimal_places=2, @@ -510,14 +479,7 @@ class O3(BaseEntry): units = 'ppb' summarize = True - Levels = LevelSet( - AQLevel.GOOD(0.0), - AQLevel.UNHEALTHY_SENSITIVE(125), - AQLevel.UNHEALTHY(165), - AQLevel.VERY_UNHEALTHY(205), - AQLevel.HAZARDOUS(405), - AQLevel.VERY_HAZARDOUS(605), - ) + Levels = O3_LEVELS value = models.DecimalField( max_digits=6, decimal_places=2, @@ -531,13 +493,7 @@ class SO2(BaseEntry): units = 'ppb' summarize = True - Levels = LevelSet( - AQLevel.GOOD(0), - AQLevel.MODERATE(36), - AQLevel.UNHEALTHY_SENSITIVE(76), - AQLevel.UNHEALTHY(186), - AQLevel.VERY_UNHEALTHY(305), - ) + Levels = SO2_LEVELS value = models.DecimalField( max_digits=6, decimal_places=2, diff --git a/camp/apps/forecasts/models.py b/camp/apps/forecasts/models.py index 4d071d1f..e6a33f5e 100644 --- a/camp/apps/forecasts/models.py +++ b/camp/apps/forecasts/models.py @@ -4,6 +4,8 @@ from django_sqids import SqidsField, shuffle_alphabet from model_utils.models import TimeStampedModel +from camp.apps.entries.levels import AQI_LEVELS + class Forecast(TimeStampedModel): class Pollutant(models.TextChoices): @@ -43,3 +45,7 @@ class Meta: def __str__(self): return f'{self.zone_name} forecast for {self.forecast_date} (issued {self.issued_date})' + + @property + def color(self): + return AQI_LEVELS.get_color(self.aqi_value) diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index d4ce946c..549e96e4 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -35,6 +35,20 @@ def test_create_forecast(self): assert str(forecast) == 'Fresno forecast for 2026-07-11 (issued 2026-07-11)' assert Forecast.objects.filter(region=region).count() == 1 + def test_color_matches_aqi_levels_for_value(self): + region = Region.objects.get(name='Fresno County') + forecast = Forecast.objects.create( + region=region, zone_name='Fresno', + forecast_date=date(2026, 7, 11), issued_date=date(2026, 7, 11), + published_at=datetime(2026, 7, 11, 21, 31, 9, tzinfo=dt_timezone.utc), + aqi_value=101, aqi_category='Unhealthy for Sensitive Groups', + pollutant=Forecast.Pollutant.OZONE, + burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', + ) + # 101 is exactly the AQI_LEVELS.UNHEALTHY_SENSITIVE breakpoint, so this + # should be that tier's color with no blending toward the next tier. + assert forecast.color == '#ff7e00' + def test_ordering_is_newest_issued_first(self): region = Region.objects.get(name='Fresno County') older = Forecast.objects.create( From 14fe0972bd54542b88ddaf8a2e8d37dd5881516a Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 16:57:39 -0700 Subject: [PATCH 14/20] refactor(entries): condense level-set names, import levels as a module Renames PM25_LEVELS -> levels.PM25, AQI_LEVELS -> levels.AQI, etc. Both camp.apps.entries.models and camp.apps.forecasts.models now do `from . import levels` / `from camp.apps.entries import levels` and reference sets as levels.PM25, levels.AQI, etc., instead of importing each name individually -- avoids colliding with the identically-named entry model classes (PM25, O3, ...) since the short names now only exist as attributes on the levels module, never imported into local scope. --- camp/apps/entries/levels.py | 18 +++++++++++------- camp/apps/entries/models.py | 15 +++++++-------- camp/apps/forecasts/models.py | 4 ++-- camp/apps/forecasts/tests.py | 2 +- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/camp/apps/entries/levels.py b/camp/apps/entries/levels.py index 9f5fdee9..bf1fd245 100644 --- a/camp/apps/entries/levels.py +++ b/camp/apps/entries/levels.py @@ -204,8 +204,12 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: # 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_LEVELS = LevelSet( +PM25 = LevelSet( AQLevel.GOOD(0.0), AQLevel.MODERATE(9.1), AQLevel.UNHEALTHY_SENSITIVE(35.5), @@ -214,7 +218,7 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: AQLevel.HAZARDOUS(250.5), ) -PM100_LEVELS = LevelSet( +PM100 = LevelSet( AQLevel.GOOD(0), AQLevel.MODERATE(55), AQLevel.UNHEALTHY_SENSITIVE(155), @@ -224,7 +228,7 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: AQLevel.VERY_HAZARDOUS(605), ) -CO_LEVELS = LevelSet( +CO = LevelSet( AQLevel.GOOD(0.0), AQLevel.MODERATE(4.5), AQLevel.UNHEALTHY_SENSITIVE(9.5), @@ -234,7 +238,7 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: AQLevel.VERY_HAZARDOUS(50.4), ) -NO2_LEVELS = LevelSet( +NO2 = LevelSet( AQLevel.GOOD(0.0), AQLevel.MODERATE(54.0), AQLevel.UNHEALTHY_SENSITIVE(101.0), @@ -244,7 +248,7 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: AQLevel.VERY_HAZARDOUS(2050.0), ) -O3_LEVELS = LevelSet( +O3 = LevelSet( AQLevel.GOOD(0.0), AQLevel.UNHEALTHY_SENSITIVE(125), AQLevel.UNHEALTHY(165), @@ -253,7 +257,7 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: AQLevel.VERY_HAZARDOUS(605), ) -SO2_LEVELS = LevelSet( +SO2 = LevelSet( AQLevel.GOOD(0), AQLevel.MODERATE(36), AQLevel.UNHEALTHY_SENSITIVE(76), @@ -264,7 +268,7 @@ def as_dict(self) -> Dict[str, Dict[str, any]]: # 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_LEVELS = LevelSet( +AQI = LevelSet( AQLevel.GOOD(0), AQLevel.MODERATE(51), AQLevel.UNHEALTHY_SENSITIVE(101), diff --git a/camp/apps/entries/models.py b/camp/apps/entries/models.py index b0907dff..375df8c8 100644 --- a/camp/apps/entries/models.py +++ b/camp/apps/entries/models.py @@ -12,8 +12,7 @@ from camp.apps.monitors.models import Monitor from camp.utils import classproperty -from . import stages -from .levels import CO_LEVELS, NO2_LEVELS, O3_LEVELS, PM25_LEVELS, PM100_LEVELS, SO2_LEVELS +from . import levels, stages from .managers import EntryQuerySet @@ -309,7 +308,7 @@ class PM25(BaseEntry): units = 'µg/m³' summarize = True - Levels = PM25_LEVELS + Levels = levels.PM25 value = models.DecimalField( max_digits=7, decimal_places=2, @@ -343,7 +342,7 @@ class PM100(BaseEntry): epa_aqs_code = 81102 units = 'µg/m³' - Levels = PM100_LEVELS + Levels = levels.PM100 value = models.DecimalField( max_digits=6, decimal_places=2, @@ -440,7 +439,7 @@ class CO(BaseEntry): units = 'ppm' summarize = True - Levels = CO_LEVELS + Levels = levels.CO value = models.DecimalField( max_digits=6, decimal_places=2, @@ -465,7 +464,7 @@ class NO2(BaseEntry): units = 'ppb' summarize = True - Levels = NO2_LEVELS + Levels = levels.NO2 value = models.DecimalField( max_digits=6, decimal_places=2, @@ -479,7 +478,7 @@ class O3(BaseEntry): units = 'ppb' summarize = True - Levels = O3_LEVELS + Levels = levels.O3 value = models.DecimalField( max_digits=6, decimal_places=2, @@ -493,7 +492,7 @@ class SO2(BaseEntry): units = 'ppb' summarize = True - Levels = SO2_LEVELS + Levels = levels.SO2 value = models.DecimalField( max_digits=6, decimal_places=2, diff --git a/camp/apps/forecasts/models.py b/camp/apps/forecasts/models.py index e6a33f5e..f5f5e9c3 100644 --- a/camp/apps/forecasts/models.py +++ b/camp/apps/forecasts/models.py @@ -4,7 +4,7 @@ from django_sqids import SqidsField, shuffle_alphabet from model_utils.models import TimeStampedModel -from camp.apps.entries.levels import AQI_LEVELS +from camp.apps.entries import levels class Forecast(TimeStampedModel): @@ -48,4 +48,4 @@ def __str__(self): @property def color(self): - return AQI_LEVELS.get_color(self.aqi_value) + return levels.AQI.get_color(self.aqi_value) diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index 549e96e4..cafb0dea 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -45,7 +45,7 @@ def test_color_matches_aqi_levels_for_value(self): pollutant=Forecast.Pollutant.OZONE, burn_status='Discouraged', burn_status_text='Discouraged: Burning Discouraged', ) - # 101 is exactly the AQI_LEVELS.UNHEALTHY_SENSITIVE breakpoint, so this + # 101 is exactly the levels.AQI.UNHEALTHY_SENSITIVE breakpoint, so this # should be that tier's color with no blending toward the next tier. assert forecast.color == '#ff7e00' From 51819e4ac1ded81299d582fa4f639e9d41c8b22a Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 19:45:47 -0700 Subject: [PATCH 15/20] feat(regions): derive accurate SJVAPCD forecast zone geometry from map SVG Adds camp.apps.regions.forecast_zones + the import_forecast_zones command, which fit an affine transform from the forecast map's SVG pixel space to lat/lon (using the 6 counties that map 1:1 to the feed's zones as ground-control points -- validated to IoU >= 0.99 against their real boundaries) and use it to derive real geometry for the 3 zones that don't: - Kern (SJV Air Basin portion): real Kern County intersected with the transformed SVG shape (SJVAPCD's zone excludes Kern's desert/mountain portion). - Tulare (SJV Valley portion): same approach against real Tulare County. - Sequoia National Park and Forest: real Tulare County minus the Tulare zone above. Verified this overlaps ~100% of the SVG-derived Sequoia shape and ~0% of Kern's excluded portion -- i.e. Sequoia is carved entirely from Tulare, not Kern -- so Tulare and Sequoia now tile the real county exactly, with no gap or overlap between them. All 3 are imported as Region(type=CUSTOM) records with metadata recording their derivation. The forecasts app's zone-to-region mapping now points Kern and Tulare at these instead of their political counties, and Sequoia (previously dropped entirely) is now included -- every zone in the feed maps to a region, so a daily pull now creates 18 Forecast rows (9 zones x 2 horizons) instead of 16. Source SVG saved at datafiles/sjvapcd-forecast-areas.svg (cleaned: just the 9 named region outlines, no fonts/colors/labels/tooltip markup). --- camp/apps/forecasts/tasks.py | 36 ++- camp/apps/forecasts/tests.py | 43 ++- camp/apps/regions/forecast_zones.py | 176 ++++++++++++ .../commands/import_forecast_zones.py | 78 ++++++ datafiles/sjvapcd-forecast-areas.svg | 11 + fixtures/regions.yaml | 263 ++++++++++++++++++ requirements/base.txt | 1 + 7 files changed, 580 insertions(+), 28 deletions(-) create mode 100644 camp/apps/regions/forecast_zones.py create mode 100644 camp/apps/regions/management/commands/import_forecast_zones.py create mode 100644 datafiles/sjvapcd-forecast-areas.svg diff --git a/camp/apps/forecasts/tasks.py b/camp/apps/forecasts/tasks.py index e79946d7..36078aaa 100644 --- a/camp/apps/forecasts/tasks.py +++ b/camp/apps/forecasts/tasks.py @@ -26,17 +26,22 @@ # See split_today_tomorrow() below for how they're told apart. NAMESPACE_URI = 'https://ww2.valleyair.org/' -# Maps the feed's raw label to the matching county Region's name. -# "Sequoia National Park and Forest" has no matching Region and is skipped. -ZONE_TO_REGION_NAME = { - 'San Joaquin': 'San Joaquin County', - 'Stanislaus': 'Stanislaus County', - 'Merced': 'Merced County', - 'Madera': 'Madera County', - 'Fresno': 'Fresno County', - 'Kings': 'Kings County', - 'Tulare': 'Tulare County', - 'Kern (SJV Air Basin portion)': 'Kern County', +# Maps the feed's raw label to the matching Region's (type, name). +# Six zones match a county Region 1:1. Kern and Tulare only match a *portion* +# of their county (SJVAPCD's forecast zone excludes the desert/mountain part +# of Kern, and Sequoia National Park and Forest is carved out of Tulare), so +# those three map to derived Region(type=CUSTOM) records instead -- see +# camp.apps.regions.forecast_zones and the import_forecast_zones command. +ZONE_TO_REGION = { + 'San Joaquin': (Region.Type.COUNTY, 'San Joaquin County'), + 'Stanislaus': (Region.Type.COUNTY, 'Stanislaus County'), + 'Merced': (Region.Type.COUNTY, 'Merced County'), + 'Madera': (Region.Type.COUNTY, 'Madera County'), + 'Fresno': (Region.Type.COUNTY, 'Fresno County'), + 'Kings': (Region.Type.COUNTY, 'Kings County'), + 'Tulare': (Region.Type.CUSTOM, 'Tulare (SJV Valley portion)'), + 'Kern (SJV Air Basin portion)': (Region.Type.CUSTOM, 'Kern (SJV Air Basin portion)'), + 'Sequoia National Park and Forest': (Region.Type.CUSTOM, 'Sequoia National Park and Forest'), } # "101 Unhealthy for Sensitive Groups (O3)" -> value=101, pollutant='O3' @@ -93,11 +98,12 @@ def fetch_forecasts(): Forecast.objects.filter(issued_date=issued_date).delete() for item in root.iter('item'): zone_name = (item.findtext('county') or '').strip() - region_name = ZONE_TO_REGION_NAME.get(zone_name) - if region_name is None: - continue # unmapped zone (e.g. Sequoia National Park and Forest) + region_type_name = ZONE_TO_REGION.get(zone_name) + if region_type_name is None: + continue # unrecognized zone (feed added something new) - region = Region.objects.counties().filter(name=region_name).first() + region_type, region_name = region_type_name + region = Region.objects.filter(type=region_type, name=region_name).first() if region is None: continue # region not yet imported diff --git a/camp/apps/forecasts/tests.py b/camp/apps/forecasts/tests.py index cafb0dea..a13787c0 100644 --- a/camp/apps/forecasts/tests.py +++ b/camp/apps/forecasts/tests.py @@ -221,26 +221,43 @@ def setUp(self): def test_creates_forecast_for_each_mapped_zone(self, mock_get): mock_get.return_value = mock_response() fetch_forecasts.call_local() - # 8 mapped zones x 2 horizons (today/tomorrow) = 16 rows; Sequoia dropped. - assert Forecast.objects.count() == 16 + # 9 mapped zones x 2 horizons (today/tomorrow) = 18 rows; every zone in + # the feed now maps to a region (6 counties + 3 derived custom zones). + assert Forecast.objects.count() == 18 @patch('camp.apps.forecasts.tasks.requests.get') - def test_skips_unmapped_zone(self, mock_get): + def test_sequoia_zone_maps_to_custom_region(self, mock_get): mock_get.return_value = mock_response() fetch_forecasts.call_local() - assert not Forecast.objects.filter(zone_name='Sequoia National Park and Forest').exists() + sequoia = Region.objects.get(type=Region.Type.CUSTOM, name='Sequoia National Park and Forest') + forecast = Forecast.objects.get( + region=sequoia, zone_name='Sequoia National Park and Forest', forecast_date=date(2026, 7, 11), + ) + assert forecast.aqi_value == 129 + assert forecast.pollutant == 'O3' @patch('camp.apps.forecasts.tasks.requests.get') - def test_kern_alias_maps_to_kern_county_region(self, mock_get): + def test_kern_zone_maps_to_custom_airbasin_region(self, mock_get): mock_get.return_value = mock_response() fetch_forecasts.call_local() - kern = Region.objects.get(name='Kern County') + kern_airbasin = Region.objects.get(type=Region.Type.CUSTOM, name='Kern (SJV Air Basin portion)') forecast = Forecast.objects.get( - region=kern, zone_name='Kern (SJV Air Basin portion)', forecast_date=date(2026, 7, 11), + region=kern_airbasin, zone_name='Kern (SJV Air Basin portion)', forecast_date=date(2026, 7, 11), ) assert forecast.aqi_value == 115 assert forecast.pollutant == 'O3' + @patch('camp.apps.forecasts.tasks.requests.get') + def test_tulare_zone_maps_to_custom_valley_region(self, mock_get): + mock_get.return_value = mock_response() + fetch_forecasts.call_local() + tulare_valley = Region.objects.get(type=Region.Type.CUSTOM, name='Tulare (SJV Valley portion)') + forecast = Forecast.objects.get( + region=tulare_valley, zone_name='Tulare', forecast_date=date(2026, 7, 11), + ) + assert forecast.aqi_value == 105 + assert forecast.pollutant == 'O3' + @patch('camp.apps.forecasts.tasks.requests.get') def test_sets_fields_correctly_for_fresno_today(self, mock_get): mock_get.return_value = mock_response() @@ -282,7 +299,7 @@ def test_idempotent_rerun_does_not_duplicate(self, mock_get): mock_get.return_value = mock_response() fetch_forecasts.call_local() count = Forecast.objects.count() - assert count == 16 + assert count == 18 fetch_forecasts.call_local() assert Forecast.objects.count() == count @@ -290,7 +307,7 @@ def test_idempotent_rerun_does_not_duplicate(self, mock_get): def test_malformed_zone_is_skipped_without_aborting_the_run(self, mock_get): # Kings' text no longer matches AQI_TEXT_RE, simulating a # plausible "Unavailable" feed state. This should only drop Kings' - # rows, not roll back the other 7 (already-good) zones. + # rows, not roll back the other 8 (already-good) zones. broken_xml = SAMPLE_FEED_XML.replace( b'71 Moderate (O3)', b'Unavailable', @@ -300,7 +317,7 @@ def test_malformed_zone_is_skipped_without_aborting_the_run(self, mock_get): fetch_forecasts.call_local() # must not raise - assert Forecast.objects.count() == 14 + assert Forecast.objects.count() == 16 assert not Forecast.objects.filter(zone_name='Kings').exists() @patch('camp.apps.forecasts.tasks.requests.get') @@ -308,7 +325,7 @@ def test_db_error_for_one_zone_does_not_abort_other_zones(self, mock_get): # Simulates a DB-level failure (e.g. a value that violates a field # constraint) for a single zone. The nested savepoint around each # zone's writes should isolate this: Kings' rows are dropped, but the - # other 7 (already-committed-to-the-savepoint) zones must still land. + # other 8 (already-committed-to-the-savepoint) zones must still land. mock_get.return_value = mock_response() original_create = Forecast.objects.create @@ -320,7 +337,7 @@ def flaky_create(**kwargs): with patch.object(Forecast.objects, 'create', side_effect=flaky_create): fetch_forecasts.call_local() # must not raise - assert Forecast.objects.count() == 14 + assert Forecast.objects.count() == 16 assert not Forecast.objects.filter(zone_name='Kings').exists() @@ -332,5 +349,5 @@ def test_command_ingests_forecasts(self, mock_get): mock_get.return_value = mock_response() out = StringIO() call_command('fetch_forecasts', stdout=out) - assert Forecast.objects.count() == 16 + assert Forecast.objects.count() == 18 assert 'Done' in out.getvalue() diff --git a/camp/apps/regions/forecast_zones.py b/camp/apps/regions/forecast_zones.py new file mode 100644 index 00000000..5f27942f --- /dev/null +++ b/camp/apps/regions/forecast_zones.py @@ -0,0 +1,176 @@ +""" +Derives lat/lon geometry for the SJVAPCD daily forecast zones that don't +map 1:1 to an existing county Region, from a locally-saved copy of the +forecast map's SVG (see datafiles/sjvapcd-forecast-areas.svg). + +The SVG's 9 named shapes are in an arbitrary rendered pixel space, not +lat/lon. Six of them (San Joaquin, Stanislaus, Merced, Madera, Fresno, +Kings) correspond 1:1 to real SJV counties already in the Region table -- +those six are used as ground-control points to fit an affine transform +from SVG pixel space to lat/lon (validated to IoU >= 0.99 against the real +county boundaries for all six, which is why a plain affine transform is +sufficient here -- the SJV's geographic extent is small enough that map +projection curvature is negligible). + +The other three shapes -- Kern (SJV Air Basin portion), Tulare, and +Sequoia National Park and Forest -- don't have a matching political +county, or (for Kern and Tulare) only cover part of one. For those, the +transform is used only to find the *dividing line* between the SJVAPCD +zone and the real county, and the real county boundary (already accurate, +already in the database) supplies the outer edge: + +- Kern (SJV Air Basin portion) = real Kern County boundary, intersected + with the transformed SVG shape (the SVG shape's own outline is not + trusted beyond providing this cut line). +- Tulare (SJV Valley portion) = same approach against real Tulare County. +- Sequoia National Park and Forest = real Tulare County minus the Tulare + zone above. Verified (see import_forecast_zones command) that Sequoia's + transformed SVG shape overlaps ~100% of this leftover piece and ~0% of + Kern's leftover piece -- i.e. Sequoia is carved entirely from Tulare, + not Kern -- so defining it this way makes Tulare and Sequoia tile the + real county exactly, with no gap or overlap between them. +""" +import json +import re + +import numpy as np +from shapely.geometry import Polygon, shape + + +# SVG shape id -> Region.name of the county it maps to 1:1, used as +# ground-control points for the affine fit. +GROUND_CONTROL_COUNTIES = { + 'san-joaquin': 'San Joaquin County', + 'stanislaus': 'Stanislaus County', + 'merced': 'Merced County', + 'madera': 'Madera County', + 'fresno': 'Fresno County', + 'kings': 'Kings County', +} + +MIN_ACCEPTABLE_IOU = 0.95 + + +def parse_svg_path(d): + """Parses a flat SVG path 'd' string (M/L commands, straight lines + only, no curves) into a list of (x, y) points.""" + tokens = d.split() + points = [] + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok in ('M', 'L', 'Z'): + i += 1 + continue + x, y = float(tok), float(tokens[i + 1]) + points.append((x, y)) + i += 2 + return points + + +def load_svg_shapes(svg_path): + """Returns {shape_id: shapely.Polygon} for every named in the + (already-flattened, see datafiles/sjvapcd-forecast-areas.svg) SVG.""" + with open(svg_path) as f: + content = f.read() + shapes = {} + for name, d in re.findall(r' + + + + + + + + + + diff --git a/fixtures/regions.yaml b/fixtures/regions.yaml index 8707c59d..cf01919a 100644 --- a/fixtures/regions.yaml +++ b/fixtures/regions.yaml @@ -1852,3 +1852,266 @@ 36.750153, -119.844285 36.757085))) metadata: {} version: latest +- model: regions.region + pk: 12 + fields: + name: Kern (SJV Air Basin portion) + slug: kern-sjv-air-basin-portion + external_id: sjvapcd-kern-airbasin + type: custom + metadata: + source: SJVAPCD daily forecast map SVG + derivation: Real Kern County boundary intersected with the SVG-derived forecast zone shape. + boundary: 12 +- model: regions.region + pk: 13 + fields: + name: Tulare (SJV Valley portion) + slug: tulare-sjv-valley-portion + external_id: sjvapcd-tulare-valley + type: custom + metadata: + source: SJVAPCD daily forecast map SVG + derivation: Real Tulare County boundary intersected with the SVG-derived forecast zone shape. + boundary: 13 +- model: regions.region + pk: 14 + fields: + name: Sequoia National Park and Forest + slug: sequoia-national-park-and-forest + external_id: sjvapcd-sequoia + type: custom + metadata: + source: SJVAPCD daily forecast map SVG + derivation: Real Tulare County boundary minus the Tulare (SJV Valley portion) zone -- verified to overlap ~100% of the SVG-derived Sequoia shape and ~0% of the portion excluded from Kern, i.e. carved entirely from Tulare, not Kern. + boundary: 14 +- model: regions.boundary + pk: 12 + fields: + region: 12 + version: 2026-07-12-svg + geometry: SRID=4326;MULTIPOLYGON (((-119.92328458446967 35.43930390096553, -119.92634758446698 35.4393159016767, -119.93876558683252 35.43931090070968, -119.93881958725918 35.43931090070968, -119.93918058693423 35.43931290021828, -119.94024558641594 35.439315900212904, -119.94392558797794 35.43932790019039, -119.94419058739349 35.439328899944485, -119.95133858900871 35.43935089965427, -119.9516655892469 35.43935189940809, -119.95185358866955 35.43935189940809, -119.96293359084356 35.43938489859519, -119.99633359540256 35.43948489655937, -119.99740459652868 35.43949989649947, -119.99740359670379 35.439576896391834, -119.99740359670379 35.44087089678553, -119.99740059722905 35.45020389704728, -119.99740059722905 35.450896897868304, -119.99739959740413 35.45290189815839, -119.99739559900281 35.464817899014534, -119.99739559900281 35.46524089919104, -119.9973945991779 35.46870689995912, -120.00098559925482 35.46904189873626, -120.01194891055614 35.46904724210164, -120.01545091798687 35.472087174664985, + -120.0148446026423 35.48046590008591, -120.01480960248222 35.4809558999822, -120.0146246034326 35.48351389981432, -120.01461460338686 35.48365690012346, -120.03052760556984 35.483791899250846, -120.03288760558536 35.4836738990128, -120.03332660687481 35.48365289892131, -120.03325360707989 35.498646900052364, -120.04887760955783 35.49862989921089, -120.05106261056949 35.498631898707806, -120.05074460965386 35.499971899286685, -120.05086361037787 35.50300589988896, -120.05124961106556 35.512699901055086, -120.05648861275199 35.51271690038071, -120.0630716135794 35.51278689971656, -120.06516461363125 35.51280989938057, -120.06891761408986 35.51278389880616, -120.06880161463724 35.517480899964895, -120.0687936151396 35.51780889994665, -120.06876761520036 35.51895090003299, -120.06876761520036 35.51896889972375, -120.0687636150024 35.519122900081335, -120.06876261427915 35.51914890006508, -120.06876061552765 35.51922090008861, -120.06875561550478 35.51946890024391, -120.06872761501738 35.52060190044939, + -120.06866961529107 35.526324900439946, -120.07363361661788 35.52638890099688, -120.07463361670061 35.52640190054401, -120.08668661829483 35.526558899463886, -120.08664061916238 35.52932889955918, -120.08661461922311 35.53281990016086, -120.08659761950469 35.53430490052138, -120.08656761936746 35.53705990073382, -120.08654461980124 35.53914690114753, -120.0865276191845 35.541552901231405, -120.0865266202579 35.54160890105197, -120.08651962058521 35.541691900766274, -120.08651361983908 35.54170190165959, -120.08651861986198 35.54184690131501, -120.086521620235 35.54259790193402, -120.08649662012066 35.54655390190829, -120.08642562177218 35.557854902468385, -120.08641762137626 35.5591319029307, -120.08641662155134 35.55933390354197, -120.08641462190153 35.559700903425814, -120.08629862334719 35.57898690489175, -120.086235623598 35.584932905380164, -120.08615262555398 35.593074905884016, -120.08602962642863 35.60512490719416, -120.08599462626852 35.60858290752461, -120.08593462689238 + 35.61452890803023, -120.08780962738433 35.614517908428375, -120.09775662940626 35.614463907970986, -120.09984462943521 35.614452907629904, -120.1247536330629 35.614318906075546, -120.12492863296507 35.614318906075546, -120.12497263334599 35.61435390595274, -120.12502763269929 35.614344906590894, -120.12523063308885 35.614317905578105, -120.1253466334398 35.61430390591485, -120.12591063350443 35.614232905602826, -120.12598963314714 35.61418190572035, -120.12614963387902 35.61418090595144, -120.13956763542895 35.61412890553599, -120.15034163728122 35.61408890451931, -120.15868863827248 35.61405790360803, -120.16879564012798 35.61402190310012, -120.16925764008532 35.61401990282803, -120.17166564067973 35.61401490324292, -120.17168164057327 35.614007902654805, -120.1731836407963 35.61400090279639, -120.17357964152974 35.613998902523726, -120.19393064371144 35.61436390215911, -120.19396564387155 35.61814190180771, -120.19396564387155 35.61820590208039, -120.19397064479273 35.62497290305288, + -120.19394964577465 35.62862890308396, -120.1939176459876 35.63462790393425, -120.19391264596473 35.63558690306909, -120.19391264596473 35.636200904064914, -120.19393864770065 35.65232690495394, -120.19393064820302 35.65387190552245, -120.19391064811154 35.65827190557078, -120.19390864846173 35.658735905369724, -120.19386764845386 35.667683906956654, -120.19385264928354 35.671121906654136, -120.19384265013612 35.67644590739746, -120.19384065048628 35.67759190789309, -120.19384065048628 35.678130907843, -120.19383865083648 35.67981290797267, -120.19383865083648 35.67992890843609, -120.19383865083648 35.68014090809238, -120.19383565046341 35.682571907746265, -120.19384865088223 35.69067490926991, -120.19382665203922 35.69706290934626, -120.19379465315049 35.70634491044818, -120.19379365242725 35.70666391098312, -120.19379265260233 35.70728091055411, -120.19379865334844 35.70877891034758, -120.19390465365365 35.72137791191262, -120.19390465455197 35.72651791246008, -120.19390365472705 + 35.726738912068875, -120.19387265476492 35.729408912564516, -120.193864654369 35.73063891225122, -120.19386965529017 35.73135491331761, -120.19389965632573 35.735097913415586, -120.19387465621136 35.74089691394713, -120.19391265674452 35.743057914330656, -120.1939306571862 35.747441914467004, -120.1939466570797 35.749397914594326, -120.19394965655444 35.74941091509891, -120.1940656569054 35.74996891484836, -120.19416465753791 35.75629191567172, -120.19412565807816 35.764766916101884, -120.19409465991264 35.77270891689719, -120.19406165940238 35.77869991776808, -120.19405666027782 35.77962891740236, -120.19414466014135 35.784810918421904, -120.19415866038507 35.78920891877206, -120.19368466073216 35.78920891877206, -120.18740565967836 35.78918291838064, -120.18551165981815 35.789174919547705, -120.18012065869407 35.78915291965604, -120.17501965799819 35.78913292001424, -120.16563765676409 35.78911692088011, -120.15999365502171 35.78910692032661, -120.15338765462806 35.78905092113871, + -120.14950065337683 35.78902392165935, -120.14299265289249 35.788992921651975, -120.13937265250328 35.78897092170997, -120.13431865166304 35.78894392293207, -120.12928465091436 35.78887792304701, -120.12808964993964 35.7888599228701, -120.1279656509894 35.78885792260731, -120.1266266500747 35.788837922891226, -120.125770649932 35.78882792303133, -120.1226236502691 35.78881392264243, -120.12157764925735 35.78880992284314, -120.1200746492094 35.7888009234762, -120.11961464890186 35.78879792344408, -120.11717764889345 35.78878492354653, -120.11696164898338 35.78878492354653, -120.11260564720378 35.788780923745776, -120.112021647946 35.78878492427523, -120.11164664712894 35.78878692381121, -120.11142764774416 35.78878892407579, -120.1111016473309 35.7887909236116, -120.10927164704482 35.78878392450725, -120.106033646247 35.78877092387871, -120.0998236456885 35.78885592453054, -120.09527964538454 35.78876892507104, -120.09466964528917 35.788797924901495, -120.09317164526412 35.78887092467899, + -120.08433464382806 35.788903925724284, -120.08156364301048 35.78881592582109, -120.07977664328041 35.78875992569946, -120.07624764222946 35.78873392588985, -120.07611364143682 35.78873292539253, -120.07218364142622 35.78870492676475, -120.06854264112062 35.78868792704662, -120.06738264030601 35.78868292674303, -120.06493764079998 35.78872092671054, -120.06367864025121 35.78876492745561, -120.06278063937744 35.78879692659104, -120.05942463947716 35.7888399275267, -120.05873863921381 35.78884992665644, -120.05541663875039 35.78888992680613, -120.05416963879551 35.78891692778843, -120.04846763732678 35.78889092730147, -120.0404876358583 35.78890092861086, -120.03667663567337 35.78879892831277, -120.0311536352031 35.78886792975039, -120.02812363391945 35.78882692982214, -120.02745363444795 35.78882792958958, -120.02485163368478 35.788834929418805, -120.0242546340082 35.78883692968217, -120.02269763353358 35.78888292989657, -120.02265363405098 35.78888692969218, -120.0225576337915 35.78888992899219, + -120.01924163317588 35.7889299298505, -120.01594663247667 35.78891993000212, -120.01480363227883 35.78891492971314, -120.01089763165936 35.78890293032997, -120.00486363082791 35.78885693085586, -120.00302963034386 35.78894993027208, -120.0009826303228 35.78906993050979, -119.99677862977745 35.78913793121984, -119.9946986283478 35.789171931188775, -119.99386162847172 35.78918493102297, -119.991691628427 35.78921993146295, -119.99164762894438 35.7892209312255, -119.98762362742579 35.78928693155436, -119.98337062737498 35.78938293193491, -119.98321562756429 35.78936093210082, -119.97824462656475 35.78943193257024, -119.97453062556589 35.789479932687435, -119.97438062577807 35.78948093244666, -119.96953962447489 35.78950293297623, -119.96905962497418 35.78950293297623, -119.96888962509485 35.78950293297623, -119.96461062420646 35.78951193372102, -119.96448062451013 35.78951193372102, -119.96432762434925 35.78951093396219, -119.95922062380559 35.789521933494854, -119.95414562304899 35.78953193326744, + -119.94811062149435 35.7895449345002, -119.9398866205267 35.789563935008786, -119.93340661873313 35.78957893502365, -119.92167261787945 35.78960493601422, -119.91734861678522 35.7894809360901, -119.9154396159581 35.789426936685615, -119.91465461685887 35.789404936863704, -119.91321061636212 35.789388937055676, -119.90379361496797 35.789287937145566, -119.88849961220285 35.78895493784575, -119.88848561195917 35.78895493784575, -119.88841361198914 35.788968938209855, -119.8881286115838 35.789044937853184, -119.88790361145288 35.78911593860491, -119.88779561149785 35.78914293805302, -119.88773461139849 35.78914893809112, -119.88752961225738 35.789156938384004, -119.88464661128683 35.78917393873058, -119.88390961096985 35.78918793832741, -119.88300661186982 35.78919193883632, -119.88285561046047 35.789197938870714, -119.88234361117271 35.78920793868406, -119.88173561072718 35.78920793868406, -119.8802666110144 35.78922793903567, -119.8789026099853 35.78924093813205, -119.876500610137 35.7892429383852, + -119.87314760971147 35.78922393925723, -119.8714536089695 35.78922093924106, -119.87139060922031 35.78922593951079, -119.87133360931888 35.7892409388607, -119.87075560901059 35.7892409388607, -119.8697156087449 35.78922993928915, -119.86907160831436 35.78922993928915, -119.86899760869451 35.78923293930496, -119.86813760835386 35.78926993924757, -119.86788560845882 35.78926193896606, -119.86699260850622 35.78925793991801, -119.86602760858361 35.78926293945671, -119.86469160804191 35.789280939542344, -119.86330660799474 35.78928794006032, -119.86240860801932 35.78928593980825, -119.86205660766679 35.78928593980825, -119.86065060770323 35.78930294012723, -119.85813160678072 35.7893209402038, -119.85680860665782 35.78932293972623, -119.85276760631913 35.78934894080056, -119.84985260645982 35.789392941197924, -119.84888660581402 35.7893949414473, -119.82614260130988 35.78935194226923, -119.8249816015687 35.789358942052296, -119.80837959874933 35.7895589435046, -119.80566259925673 35.789591943535555, + -119.79974459787796 35.78979094391742, -119.79684259753911 35.789794944396036, -119.79662159670788 35.789798944145744, -119.79613859683408 35.789808943883465, -119.79191559692055 35.78983594455354, -119.79054359639383 35.78983594455354, -119.79038959640803 35.789836945036974, -119.78555959497552 35.78986694496077, -119.78542959527915 35.78986394424019, -119.78540059586517 35.78986294521448, -119.78306759471553 35.78994294447399, -119.78285659482833 35.78994994493371, -119.78239959489382 35.78996494487567, -119.7791525947735 35.79007194535109, -119.77881559448961 35.79007194535109, -119.77508859307194 35.79013594619996, -119.77295759338529 35.79016794551206, -119.76868659289282 35.79020294624781, -119.76858459278556 35.79020294624781, -119.76846859333294 35.79019894578974, -119.7680035930025 35.79018194566248, -119.76792559318471 35.790179946161615, -119.76090259214125 35.79021694639207, -119.7608865922477 35.79020094601882, -119.76084859171456 35.79018694660045, -119.7608025916838 + 35.79018394662064, -119.75560559072848 35.790180946640724, -119.7554525905676 35.790163946509615, -119.7553955906662 35.790147946854404, -119.75534259096274 35.790123947001156, -119.75520459087048 35.790043946466405, -119.75511758993355 35.79001194710443, -119.75504259048877 35.789998947405465, -119.75499359098329 35.78999794692406, -119.75478459074591 35.79000794663674, -119.75371758981775 35.79004194696211, -119.75295358973659 35.79006294685574, -119.7509835897084 35.79022094684918, -119.7198005841515 35.790404949443385, -119.7179345847787 35.790412948881205, -119.71584758457465 35.79034694948743, -119.71582758448318 35.790338949314325, -119.7157805846275 35.790325949668855, -119.71571658415509 35.79031794949357, -119.70729058262279 35.790320950197014, -119.70036258201384 35.79033095059775, -119.69996258198078 35.79025895045339, -119.69955058225212 35.79026195043037, -119.69102858046034 35.79031895069964, -119.68951058034378 35.7903149509761, -119.68928257983984 35.79032795135207, + -119.68850757988803 35.790370950730704, -119.68521857993491 35.79037495118007, -119.66851057591201 35.79039695219084, -119.66404257667438 35.79040995255334, -119.66077657538916 35.79040895353449, -119.65232557374249 35.79041995367157, -119.65084957345873 35.790421953895034, -119.6466095729284 35.79042795456519, -119.64653157221225 35.79042895358386, -119.64385957292554 35.790307954192, -119.63131357051348 35.7897399549218, -119.62597056996829 35.789498955072226, -119.6187765692206 35.78982895501412, -119.61871856859594 35.78983595548382, -119.61849656883811 35.78984495546226, -119.6183275687837 35.78983895547676, -119.6182285681512 35.78982995549765, -119.61811756872142 35.78978995509239, -119.61803556870565 35.7897469553994, -119.61795856871275 35.78972795493451, -119.61787356832396 35.789720955912635, -119.61773856860474 35.789726955178395, -119.61561956861364 35.78972495639477, -119.61215756750983 35.78975595611655, -119.60843256664032 35.78978295680467, -119.60449956625664 35.7897429571044, + -119.59897956526113 35.78975295684923, -119.59658356526059 35.78977695681444, -119.5943875643783 35.78978695655499, -119.59282756442892 35.789779957538315, -119.5913195643581 35.789782957533376, -119.59003656351985 35.78976895731267, -119.58232856293625 35.78979695775156, -119.57951056226302 35.789805957734416, -119.57940456285615 35.78980695748953, -119.57930556222361 35.78980695748953, -119.57634556126015 35.78982495818065, -119.57442156126274 35.7898329584055, -119.57427356112474 35.789830958167194, -119.57402956072725 35.78982895792885, -119.5736945609915 35.7898269584191, -119.57346956086062 35.78977795875603, -119.5725685605121 35.78958095858628, -119.56555556041266 35.78953995902587, -119.55808955878013 35.78954395951712, -119.55573755826218 35.78955495904521, -119.55041855710812 35.78957895979899, -119.54530355616853 35.789635959829475, -119.53812855568742 35.789571960035296, -119.53737055545406 35.78986796074617, -119.53457155504738 35.789762960964815, -119.53083955450518 + 35.78962196104022, -119.52645555403481 35.789456960810796, -119.51826855287709 35.78962796249949, -119.50096454990084 35.78979096286322, -119.50020955004052 35.78990096356355, -119.50016254928656 35.78992896322728, -119.49803454907463 35.78988496312668, -119.49384154839996 35.78979896382023, -119.49031454789716 35.78972496441028, -119.49023254788138 35.78972396392543, -119.48898454720323 35.78974596438773, -119.48401754640166 35.789830963996664, -119.47921454563163 35.7899129642534, -119.47453354506034 35.79001796529391, -119.47224354536502 35.79003296522303, -119.47222154472541 35.790029965237466, -119.47206154489182 35.79000896460643, -119.47194554454086 35.78971896514443, -119.46469854319142 35.790122965467056, -119.46430254335631 35.78984096591645, -119.45418554235336 35.789872966075954, -119.45234954132117 35.789872966075954, -119.44684954041713 35.78989296626021, -119.43660753884244 35.790002966819344, -119.4278335380539 35.79009696828933, -119.4246245366701 35.79013096857661, + -119.42419053720015 35.79004396759823, -119.4188525357795 35.79011196893224, -119.41511653503932 35.78997496863901, -119.40106753257932 35.79012596982121, -119.40102953294448 35.79012596982121, -119.39305953152174 35.79014697042133, -119.39319753161402 35.79016497030674, -119.37608052913384 35.789744971189954, -119.35871052603537 35.790201972001505, -119.35562652522336 35.790282972126256, -119.35552852531406 35.790283972604065, -119.35545652534402 35.790282972126256, -119.35520452544895 35.790282972854946, -119.35518852555542 35.790282972854946, -119.3407425234354 35.790258973042484, -119.33677552271655 35.790251974067374, -119.32954152178587 35.7902399741574, -119.32936152096252 35.7902399741574, -119.3250455211625 35.790243974613354, -119.32120452084034 35.79022497499599, -119.32050151996025 35.79022397451744, -119.32043651966292 35.79022497499599, -119.30367751738304 35.7903499764222, -119.30314551800379 35.79034897594521, -119.29424551592024 35.79035297639573, -119.28625751495416 + 35.7903459774289, -119.28598751371909 35.7903459774289, -119.27655151295666 35.79039597721821, -119.26831851086988 35.79039397772273, -119.26812251105129 35.79039397772273, -119.26767851153559 35.79041097780462, -119.26328951014402 35.790417978223104, -119.25929050963815 35.790424978640914, -119.25880450939131 35.79042597911695, -119.25432850975776 35.79043597877576, -119.25365450918997 35.79043697925166, -119.25361350828375 35.79043797899884, -119.25309350860009 35.79043797899884, -119.25287450831699 35.79043797899884, -119.25263650956391 35.79043697925166, -119.25259850903076 35.79043797899884, -119.2517005090553 35.7904399792219, -119.25106950904355 35.79044197944488, -119.25096750893627 35.79044197944488, -119.25093050822805 35.790442979191994, -119.25090650883692 35.790442979191994, -119.25083450886689 35.790442979191994, -119.25081150840236 35.79044397966779, -119.2507775089655 35.79044397966779, -119.25066650863741 35.790446979637785, -119.25064450889609 35.790446979637785, + -119.2503555091911 35.790447979384844, -119.2494905088276 35.79045197910169, -119.24920250894752 35.790453979324404, -119.24561450834535 35.79045297957737, -119.24137050671872 35.79046097973905, -119.2412055068623 35.790461980214616, -119.24095450769043 35.790461980214616, -119.23981550679225 35.79046398043703, -119.23872350664804 35.790465980659455, -119.23761050658743 35.79046797942438, -119.23691550700157 35.790474979837846, -119.23679050642977 35.79047297961565, -119.23649150578072 35.7904699796467, -119.23537950554503 35.79047197986891, -119.23426050563663 35.79047398009108, -119.23335650581338 35.790474979837846, -119.23266650625041 35.790475980313246, -119.23247150625674 35.790475980313246, -119.2324485057922 35.790476980059935, -119.22584050485041 35.79048298072595, -119.22363050462272 35.79047298034435, -119.2235235035943 35.790490980884606, -119.22350650387585 35.79049198063111, -119.22325450398075 35.790478981010665, -119.2231645044674 35.79047398081977, -119.22310550401787 + 35.790475981041936, -119.22155750376407 35.790553980921565, -119.22004550349529 35.79055598114172, -119.2198505035016 35.79055598114172, -119.21885250306875 35.790558981107424, -119.21868350391266 35.79055598114172, -119.21847150330221 35.79055298117585, -119.2147245026914 35.790497981295985, -119.21459850229469 35.790497981295985, -119.2144025024761 35.79050098199257, -119.21404550210073 35.790493981581456, -119.21399150257238 35.79049298110629, -119.2123395025615 35.790496981549566, -119.21160550261759 35.790498981771094, -119.2072235008987 35.790501982467646, -119.2064255013807 35.790502982214065, -119.2055185011844 35.79049798202468, -119.20548050154956 35.79049798202468, -119.20536650084844 35.79049798202468, -119.20532950103852 35.79049798202468, -119.20532750049038 35.79050698192814, -119.19674349967264 35.79051998300134, -119.19668549994631 35.79052098274747, -119.19642049963245 35.790525982935414, -119.19197349851457 35.79053598258165, -119.18781949819795 35.790537983530896, + -119.18775249825079 35.790537983530896, -119.18750649820352 35.79054498393819, -119.18748349773898 35.79054798317562, -119.18336949760534 35.79055698380212, -119.17901049724931 35.79057598406879, -119.1788344966239 35.79057698381422, -119.17853549687315 35.79057998377919, -119.17851149748202 35.79058098425327, -119.17806849689293 35.79058298447268, -119.17760749676047 35.79058298447268, -119.16988249556012 35.79060198473315, -119.16972249482824 35.790602984478255, -119.16093749416908 35.790627984660304, -119.16067049330702 35.790627984660304, -119.15203849280874 35.79067098533398, -119.15191549188675 35.79067098533398, -119.14316249101462 35.79070298588797, -119.14308449119683 35.7907039856318, -119.13411048949341 35.790707986793144, -119.13408549027739 35.79070898653691, -119.12546548857632 35.79072698702367, -119.12528748830108 35.79079198708774, -119.12526148836182 35.790803986914426, -119.11501348693933 35.790740987075594, -119.11467648665544 35.79073098818384, -119.10328448431187 + 35.79075998875566, -119.10271848459743 35.79074998840891, -119.10265548394993 35.7907489886656, -119.10238248413846 35.7907539888392, -119.10220648441137 35.79076998837245, -119.09822048432426 35.790773988801725, -119.09794648378956 35.79075298909599, -119.09768948387159 35.79075298909599, -119.08926148179116 35.79077598974498, -119.08904848225411 35.790749989866235, -119.08878948178806 35.79074898939429, -119.08066248090479 35.79076498965719, -119.08027548039219 35.79076198969926, -119.08010948071083 35.790759990212976, -119.07791448055175 35.790754990768455, -119.07175947934651 35.79077899115981, -119.07122047939627 35.79077599120235, -119.07107847910605 35.790769990558466, -119.06960147899736 35.79081999008094, -119.06810847899514 35.790818990338565, -119.06288147790258 35.79070699069269, -119.06260347806824 35.79063399114432, -119.05729247641177 35.79063399187301, -119.05369647631198 35.79063399187301, -119.05344947554147 35.79075299201068, -119.05312147637673 35.79074799183704, + -119.05303247668829 35.79074999205225, -119.05275447595562 35.79076099141347, -119.04471847441064 35.79074499187847, -119.04448647460706 35.79076799252976, -119.02702847164504 35.79066799338791, -119.02701247175149 35.790671993093625, -119.02668447168844 35.79066799338791, -119.02594747226978 35.790665993170585, -119.02509347087862 35.79062899314924, -119.01070846885796 35.790646994382804, -119.00094446803249 35.79061799522713, -118.9973024670036 35.79066099590616, -118.99510746684456 35.79068699508537, -118.98194746413918 35.79073999680501, -118.98069646398628 35.790667996302616, -118.97885746347937 35.79066399586795, -118.94935445842071 35.790608998250946, -118.94715745861183 35.79059799886874, -118.93817045738793 35.790551998918346, -118.9355704571729 35.79054099952824, -118.93542645633453 35.79055799957869, -118.93476545618554 35.79054699873204, -118.93389045667465 35.79051699906501, -118.93287645634823 35.790515999318835, -118.9184804535586 35.790503000431, -118.9179734533954 + 35.7905010009382, -118.91664545324963 35.7905010009382, -118.90717245177896 35.79049300078061, -118.9060134525859 35.79049300078061, -118.90378545191653 35.79047900141367, -118.89636444959149 35.790509001095046, -118.88182844795809 35.79054200291752, -118.87810244726366 35.79055000307023, -118.87593944689164 35.790555003256344, -118.86692544500526 35.790540004154394, -118.86320044503404 35.790535003967335, -118.86304844469808 35.79053400349269, -118.86259544496157 35.79053400422137, -118.86244444445049 35.79053400422137, -118.86171844490251 35.790531003526034, -118.85958444394448 35.790524004574884, -118.85956344402807 35.790524004574884, -118.85847844355654 35.79055400423926, -118.85826644384444 35.790553003764856, -118.85580144334864 35.79054300484945, -118.84840844151098 35.79051600514824, -118.8459444417384 35.79050700451711, -118.84590844175338 35.79050600549949, -118.84580244144817 35.79050600549949, -118.84576744218637 35.79050600549949, -118.83720544021162 35.79047400559482, + -118.83685044038441 35.790473005119395, -118.82999543957041 35.79044700659892, -118.8290784393284 35.790444006628924, -118.82827343923937 35.79044000618299, -118.81539943676427 35.79039400686989, -118.80506243475499 35.79035700745057, -118.80496743521874 35.79035700745057, -118.80374143518023 35.790352007252, -118.80165143460313 35.79031500781316, -118.79670143352001 35.79028900851225, -118.79668543362648 35.79029600821279, -118.7964314340816 35.790295007735075, -118.7956814333458 35.79029200848809, -118.79267243287687 35.79028300856028, -118.79166943331943 35.79028100833325, -118.7882884324065 35.79027100938363, -118.77814643039089 35.79024100888372, -118.7747664302012 35.79023200967883, -118.77339842987244 35.790227009472396, -118.76929542871115 35.79021500955865, -118.76808242819311 35.79021201030859, -118.76792842820733 35.79020901032973, -118.76698642874926 35.79018901022502, -118.76613142843149 35.79018700999568, -118.76528842870763 35.79018500976624, -118.764392428382 35.7901830102655, + -118.75992142787298 35.79017401105404, -118.75956742787066 35.79017301057485, -118.75850542696536 35.790171011073745, -118.75815142786134 35.790171011073745, -118.75795142694649 35.79017001059451, -118.75735142779519 35.79016901084396, -118.75715242760354 35.79016901084396, -118.75319242655739 35.79016101065289, -118.75200742652675 35.79015901042285, -118.75093442664912 35.7901570109214, -118.75075242617592 35.790158010672165, -118.74462242508505 35.79017901126382, -118.74434442525069 35.79018001101424, -118.74422842579806 35.79018001101424, -118.74249942489598 35.79018301172283, -118.74240042426344 35.790184011473265, -118.7419444250522 35.790184011473265, -118.73887742395861 35.79016701207146, -118.73870242405644 35.79016601159217, -118.73378742313339 35.79013901176289, -118.71851942120591 35.79005701319677, -118.7134304202056 35.79003001333051, -118.7117134198974 35.7900200128919, -118.70656541844757 35.789993013741714, -118.70535741885068 35.7899870137674, -118.7048504186875 35.78998401378004, + -118.70400641824041 35.78997901428671, -118.68115441467953 35.789857015912645, -118.67785241340934 35.78984001571225, -118.67725141353479 35.789836015964596, -118.67545041356101 35.78982701525651, -118.67485041351138 35.78982401599181, -118.67012741220898 35.78979801653027, -118.65596041064653 35.78972301736332, -118.65123840916903 35.78969801762523, -118.63714940652605 35.78962201860642, -118.63388540668728 35.7896060188421, -118.6331034061645 35.78960201835398, -118.63069840594311 35.78958901931666, -118.62998740646371 35.78958501882774, -118.62728040522026 35.78983101864798, -118.62700940505863 35.78988501850661, -118.62668740574163 35.78992201814568, -118.62585740463994 35.79028001878564, -118.62593640518098 35.79048901855408, -118.62245840508196 35.789836019608025, -118.62059340373746 35.78983001889316, -118.61499840329718 35.78981501965444, -118.61313440357422 35.78981001942177, -118.6092414024752 35.789799019928914, -118.59756240007646 35.789767020468325, -118.59367039970067 + 35.789757020725276, -118.59277440027337 35.78975402072929, -118.59008639929647 35.789747020981025, -118.58996939912059 35.789747020981025, -118.58919039986914 35.78974802073693, -118.58109239750158 35.78975502194246, -118.57972839737079 35.78975702218267, -118.57960623023875 35.78975665732771, -118.57960605264894 35.78945882970209, -118.57961582910103 35.78926329327111, -118.58022824394311 35.77516554129514, -118.580360704529 35.744632284262856, -118.58038669463019 35.73033648879328, -118.58012138934778 35.71625015752304, -118.57934584455789 35.70402684655259, -118.57932866228151 35.70363384751085, -118.57898087511212 35.68915943102542, -118.57897904351812 35.68906439597768, -118.57876880112339 35.674583703753456, -118.57876774860355 35.67443344660688, -118.57879740365546 35.61610746697988, -118.57833452404164 35.58770789851521, -118.57810116654665 35.57338482529785, -118.57786578310865 35.5589332656427, -118.577399055434 35.53022412843235, -118.57739877410788 35.53020578880412, -118.57718199252298 + 35.51517499934028, -118.57718129943214 35.51511407001469, -118.57682193356003 35.471964403546295, -118.57670345684384 35.45771204853981, -118.57671165351772 35.45721567985931, -118.57731727202236 35.44257296120539, -118.57741010251864 35.441565931424066, -118.57760416889799 35.4405734271561, -118.57789748639216 35.43960559900549, -118.57828705516546 35.438672345207, -118.5787688909949 35.43778321039439, -118.57933806601783 35.43694728798484, -118.57998875913056 35.43617312717819, -118.58071431552233 35.43546864552202, -118.58150731473577 35.4348410479368, -118.58235964655795 35.43429675302921, -118.5832625939656 35.43384132744742, -118.58420692227645 35.433479428949454, -118.58518297359483 35.43321475876709, -118.58618076558538 35.43305002375237, -118.58719009356524 35.43298690869392, -118.58756753807806 35.43298243051712, -118.58823219326145 35.39897475961708, -118.58816750232445 35.38452149201981, -118.58787138154509 35.35571441787305, -118.58720930132213 35.341418830270676, -118.58608333508168 + 35.324747553453655, -118.58521631101642 35.31223064028917, -118.58507736406082 35.310602075915696, -118.58507029120885 35.3105146519345, -118.58402150386804 35.29680326043043, -118.58401877482754 35.29676670100084, -118.58198171749734 35.26878655492422, -118.58195533529091 35.26807763095267, -118.58188094040072 35.224802042262844, -118.5819275777192 35.223820037626226, -118.58207041495596 35.222847357992634, -118.58230807157749 35.22189340438995, -118.58263825061316 35.22096739685747, -118.58305776085571 35.22007828533341, -118.58356254770446 35.21923466315308, -118.58414773235341 35.21844468399358, -118.58480765894525 35.217715983067826, -118.58553594923573 35.21705560332963, -118.58632556423984 35.216469927402954, -118.58716887226427 35.215964615893334, -118.58805772266825 35.21554455267768, -118.58898352464024 35.215213797701175, -118.58993733022876 35.21497554773763, -118.5909099208251 35.214832105492334, -118.59189189626191 35.214784857346274, -118.60941448284989 35.21480408075261, + -118.62687482808131 35.21480836931637, -118.69487088207869 35.2146047831179, -118.72497183238288 35.191705819819404, -118.72563453763767 35.137718051566694, -118.72525792946843 35.109324033521844, -118.72492766586413 35.1052174640558, -118.7206939196356 35.10404186976439, -118.72068353826886 35.10403898112031, -118.70313933441568 35.099147071748085, -118.70222162212417 35.09884265663469, -118.70133759850624 35.09845103608598, -118.70049552798406 35.0979758712231, -118.69970328277526 35.09742160419325, -118.69896826929835 35.096793416641624, -118.69829735893276 35.09609718127009, -118.6976968237807 35.095339406935345, -118.69717227803162 35.094527177799975, -118.69672862547701 35.09366808710509, -118.69637001366674 35.09277016618386, -118.69609979513498 35.091841809379424, -118.69592049605863 35.090891695569155, -118.69583379264094 35.08992870702893, -118.69548481114836 35.081510251428554, -118.68650666424585 35.08188267370909, -118.68554376058317 35.08187621476296, -118.68458594229013 + 35.08177717285732, -118.6836420904991 35.08158646633368, -118.68272095684159 35.081305863470945, -118.68183108230082 35.08093796608951, -118.68098071801761 35.080486185426544, -118.68017774878375 35.07995471050612, -118.67942961993215 35.079348469297535, -118.66694678532662 35.068195759927235, -118.66233401794976 35.0656542932165, -118.63639223077624 35.05135429705523, -118.63558333867552 35.0508569281592, -118.63482526876523 35.050285078300874, -118.63412485642722 35.0496439037489, -118.63348841715518 35.048939185860405, -118.63292168960908 35.0481772789515, -118.63242978387059 35.04736505300154, -118.63201713536645 35.04650983170774, -118.63168746487497 35.04561932644869, -118.62681070376398 35.030239793817934, -118.62655243369154 35.02925328315971, -118.62639597587892 35.02824559885302, -118.6263429573414 35.027227219874554, -118.62622102772852 34.905869237372286, -118.62622347543834 34.90563771990498, -118.62630240887002 34.90207453123163, -118.62637382668485 34.90108276025014, + -118.62654338515222 34.90010298470945, -118.62680940783028 34.8991448917501, -118.62716926452573 34.89821795413476, -118.62761939729877 34.89733133659002, -118.62815535564084 34.896493805193856, -118.62877184047723 34.895713640704464, -118.62946275655956 34.89499855668761, -118.63022127273018 34.89435562325178, -118.63103988946247 34.89379119714534, -118.6319105130094 34.89331085890669, -118.63870923074644 34.88998953328073, -118.6388069707053 34.88994244014363, -118.65512809652067 34.88218739169701, -118.65648091138479 34.88154476731543, -118.65892651697453 34.8803831445174, -118.66028028687383 34.879856901263814, -118.6755472369562 34.87516668627498, -118.69257866371275 34.86993247592703, -118.7281883201302 34.858977247174515, -118.72859710452917 34.85886093500528, -118.74690906873728 34.85406880664836, -118.74868808337307 34.85360372828489, -118.78817102937559 34.82741040386771, -118.78800960186035 34.819144923219135, -118.78803673505936 34.818188110686584, -118.7880954970341 34.817717064267185, + -118.78816134325166 34.81771690735981, -118.79069834371751 34.81770990634493, -118.79071534343596 34.81770990634493, -118.79268034344126 34.8177129064639, -118.79365034338673 34.817713906503535, -118.794662343165 34.8177149065431, -118.79467734413193 34.81770490688391, -118.79487234412561 34.8177149065431, -118.79508234418788 34.8177149065431, -118.79571534384948 34.817715906582706, -118.79666434477686 34.81771690662231, -118.79824534464261 34.81771790666188, -118.79847234442335 34.81771890670146, -118.79869934420407 34.81771890670146, -118.79872934434131 34.81771890670146, -118.7991653452577 34.81771890670146, -118.799631345413 34.81771990674102, -118.8010463446991 34.817720906780565, -118.80246034595692 34.81772290685963, -118.80299734535903 34.81772290685963, -118.8030033452068 34.81764890611492, -118.80873634663767 34.817595905431084, -118.81149134666339 34.81757090513844, -118.81873234816508 34.81749990512143, -118.82082234784384 34.817483905181845, -118.82084934850637 34.81747990501208, + -118.82532734868806 34.81750690541664, -118.82784034886446 34.8174919047833, -118.83293735026069 34.81750190446835, -118.8342633507566 34.8175049045949, -118.83465835076682 34.817529904907566, -118.83570635053013 34.817507904721325, -118.83660135103084 34.81750890476345, -118.8376053504132 34.817539903850545, -118.84358435189134 34.81770490393395, -118.84359135156404 34.817765903384995, -118.85401935380989 34.817775903036775, -118.85420435375782 34.81777690307565, -118.85423235424523 34.81777690307565, -118.8542653538572 34.81777690307565, -118.85419735408514 34.8176669038904, -118.85419135423734 34.81765590271005, -118.85404535374919 34.81738890331025, -118.85402235418294 34.81734390281248, -118.8540273533075 34.81682990287225, -118.85403335315529 34.81595190265945, -118.8541263521434 34.803283901762846, -118.85442735244226 34.803283901762846, -118.85458235225298 34.8032819013333, -118.85859635282749 34.80325690112345, -118.8590213529749 34.80325490143089, -118.86112235432101 34.8032249008732, + -118.8627133542325 34.80320190107758, -118.86662435397655 34.80320790089661, -118.8671703544978 34.803204900987154, -118.87093835502505 34.803113900485265, -118.87595935625328 34.803194900304646, -118.87657535619643 34.80320490024952, -118.8767343562051 34.80320790015898, -118.87699835579572 34.80321190028396, -118.87713235658833 34.80321390071521, -118.87730135664275 34.80321690062434, -118.87773835558744 34.80357689999184, -118.88011735766617 34.80555789994334, -118.88024335716455 34.80566990091383, -118.88056935667946 34.80595890025854, -118.88094135712343 34.806292900722134, -118.88174535738757 34.80696790025463, -118.88254235708068 34.807666900352686, -118.88284935722736 34.807976899699334, -118.88312035828733 34.808284900522104, -118.8833933580988 34.80864190016952, -118.88366235771231 34.80901690014807, -118.88402335738732 34.80962990056892, -118.88413235806559 34.80984290086771, -118.88424235767043 34.81010990038157, -118.88433535755686 34.81037690050548, -118.88448135804504 + 34.810900899885574, -118.88457835812943 34.81133590031644, -118.88462335743696 34.81157790099473, -118.88465435829741 34.81179990021175, -118.88469435848039 34.81221790072882, -118.88470735800084 34.812417901101405, -118.88469635902852 34.812907901142644, -118.88468035913499 34.81314790070178, -118.88465435829741 34.81332790064997, -118.88459935894414 34.813599901120355, -118.88456335895911 34.81372990085988, -118.88453735901986 34.81381690109298, -118.8843723582651 34.81421090121053, -118.88421935810422 34.814590900572334, -118.88414035846151 34.81478690078464, -118.88403835835426 34.81501290123337, -118.88394735811768 34.81519990128612, -118.88385535895449 34.81537290070058, -118.88376235906806 34.81553290110612, -118.88346435824391 34.815971900927174, -118.88329735783934 34.81619190146462, -118.88303735844666 34.81650190119122, -118.88272135807914 34.81683690101192, -118.881993357983 34.817571900754814, -118.88174135808794 34.81780690128591, -118.8823013588529 34.81775890163678, + -118.8823313580918 34.81777890167834, -118.88279935879528 34.81790290123788, -118.8829563582558 34.81792990150381, -118.88318135838671 34.81793290161471, -118.88332435850182 34.81790990149885, -118.88379835905306 34.817772901445146, -118.88397535860507 34.81772090161814, -118.88414735903254 34.81768690174184, -118.88420235838578 34.81767590130166, -118.8844003587525 34.81765590123505, -118.88468735880768 34.817652901113995, -118.88935335931039 34.81766790171808, -118.8895403589081 34.81767390122147, -118.89015436009976 34.81771290056391, -118.89136536006967 34.81783790099838, -118.89265336003244 34.81793490095112, -118.89333036007497 34.817976901016884, -118.8944863606916 34.817976901016884, -118.89464635962688 34.817976901016884, -118.89306835923584 34.81472190103683, -118.88963835881741 34.8076529003023, -118.88922535836554 34.80680290005992, -118.88919735877643 34.8067449003868, -118.88907835895077 34.80649989974051, -118.88690135833502 34.80201489976862, -118.8868363580377 34.801880899799954, + -118.88472035662302 34.797524898507156, -118.88440135678078 34.79686889949922, -118.88381235660182 34.79565389872602, -118.88351135630296 34.79503389912484, -118.8828333564355 34.793635899317145, -118.88209835576836 34.7921228983243, -118.88166835559807 34.791235898700364, -118.88137635551999 34.79063389906824, -118.88190735597266 34.79062389833627, -118.88192435569108 34.79062089796891, -118.88209835576836 34.79062089796891, -118.89082835707428 34.79062089796891, -118.89090035794263 34.79062089796891, -118.89149135687309 34.79062189759954, -118.89211535721216 34.79062389759853, -118.89217735713643 34.79062089796891, -118.89235135721371 34.79062089796891, -118.89397935783346 34.79062089723116, -118.89438635843754 34.79062089723116, -118.89479535779313 34.79062089723116, -118.8970863582117 34.79061489797158, -118.89726935850977 34.790613897603095, -118.89756835915885 34.79062089723116, -118.8984173587305 34.79062089723116, -118.92135836287812 34.79062089575566, -118.92624936351172 34.79062089575566, + -118.92713336414178 34.79062089575566, -118.92882936363527 34.79062089575566, -118.93664736562036 34.790620895017966, -118.9367783651416 34.790620895017966, -118.94139436541558 34.79062689501486, -118.94156836549283 34.79062489501594, -118.94163036541713 34.79062489501594, -118.95447536847821 34.790624894278196, -118.95521936756958 34.790624894278196, -118.95992636897846 34.79062489354045, -118.96027236858485 34.79062489354045, -118.96285236960672 34.790633893904065, -118.96307836866421 34.79066489350961, -118.96449637011997 34.79066489350961, -118.96954837041206 34.79066489350961, -118.97673537148705 34.790664892771915, -118.97673537148705 34.792627892870684, -118.97673637310861 34.80645789456198, -118.97673637310861 34.80736489526151, -118.97673337363386 34.81163489567797, -118.97673337363386 34.81220389555125, -118.976800373581 34.812206895870716, -118.9851123753105 34.81221089482139, -118.98759337480153 34.81221189419025, -119.00006037757086 34.812217894090935, -119.00009337718284 + 34.81220989323991, -119.00013637684052 34.812201893863154, -119.00028237732872 34.812176893408804, -119.00032737663625 34.812167893921966, -119.0004653767285 34.81214989347018, -119.00069837725532 34.81211689361504, -119.00080937758338 34.812102893583145, -119.00094737767569 34.812087894178504, -119.00766137802434 34.8122128935591, -119.00932137843114 34.81226489391238, -119.01062037916289 34.812305893075624, -119.01078437919443 34.812310892864254, -119.01085937863917 34.812310892864254, -119.01321037933218 34.812317892862815, -119.02370038150232 34.812348892427984, -119.03095738289753 34.81236989167473, -119.03118838287621 34.81237089177921, -119.03524838348147 34.812382892294494, -119.0451873851075 34.8124468908338, -119.04565238453958 34.812449891144404, -119.04617638442122 34.81245289145492, -119.05218438531337 34.81249189105617, -119.06634938812432 34.81258289005401, -119.08186639047217 34.81263288923362, -119.08513739178025 34.81264388887208, -119.08615039228174 34.81264688843798, + -119.08639439178089 34.81265188894342, -119.09469739239124 34.81282988830227, -119.09684539359297 34.812866888265745, -119.09766239337758 34.812880888167896, -119.0982483931835 34.8128918877732, -119.09930539406591 34.81291088742328, -119.10025939411787 34.81292788834867, -119.10090639402316 34.812917888108466, -119.10103439406967 34.81291988756657, -119.10208239473127 34.81293588765494, -119.10321939418301 34.81294588789295, -119.10463039416949 34.81295988778168, -119.10585939458102 34.812949887545315, -119.10675639473159 34.8129428876004, -119.10919039526524 34.81289488733007, -119.11046339605775 34.81290988658785, -119.1121713952468 34.81293988730779, -119.11482639571341 34.812986887454905, -119.1259523988165 34.813182886320135, -119.1264043978298 34.81324088662496, -119.12708239859555 34.813266886112565, -119.12975139930587 34.81337088619344, -119.1324853994152 34.813353886097, -119.13613839941641 34.813331886270674, -119.13887840027182 34.813315886259204, -119.14471540145975 34.813319885156055, + -119.14582040112444 34.81332088451147, -119.15485340327739 34.813382884350496, -119.15509640295164 34.81338488453487, -119.17078540572695 34.813519883591376, -119.17194240527016 34.81352988375857, -119.17966840719372 34.813596883150886, -119.20045240991878 34.81377588203083, -119.201255410358 34.813782881904935, -119.20164340979721 34.8137868822542, -119.2075414119828 34.81384188187404, -119.21328141308638 34.8138958813712, -119.22200141344823 34.81397788100438, -119.2317684155451 34.81406987992, -119.24365741710778 34.81418287899262, -119.24365741800612 34.81829987970091, -119.24365741890442 34.82880488096596, -119.24365741890442 34.832177880695966, -119.24365741890442 34.83414688139186, -119.24365741890442 34.83540188129976, -119.24365741980276 34.839231882359684, -119.24365741980276 34.84326088238615, -119.24365741980276 34.843480882281085, -119.24365742070104 34.84661488262716, -119.24365742070104 34.84776188246908, -119.24365742070104 34.85053288331458, -119.2436574215994 34.85758088403044, + -119.25020042224381 34.857490883449096, -119.25095742265226 34.857480883460354, -119.2564484233355 34.85743988319884, -119.25684842336858 34.857437883347345, -119.26451242536785 34.85738188232654, -119.27514042673195 34.85730388213111, -119.27835842743826 34.857280881955404, -119.27823342686649 34.85948388215608, -119.27810742646977 34.861718882712005, -119.27809242729948 34.86197688215597, -119.27805542748955 34.862634882810525, -119.27779742774672 34.86721488284451, -119.27751342806457 34.87223388333325, -119.27735842825388 34.87497988398406, -119.27727042839034 34.876015883946884, -119.27724342862615 34.87633488376129, -119.27695842822082 34.87967988471338, -119.27713542867112 34.87967988471338, -119.27914342833414 34.87967988471338, -119.28171742950822 34.87967988397648, -119.28906943043965 34.87967988397648, -119.2943594312814 34.87967988323954, -119.29672743259115 34.879684882621305, -119.31027143363946 34.87971388256713, -119.32967943743651 34.87976088144105, -119.33376443725783 + 34.879769880908746, -119.33836143906187 34.879779881134986, -119.3452204400738 34.8797888798637, -119.34532244018104 34.8797888798637, -119.35073844052117 34.87979588002059, -119.35384344124958 34.879800879395304, -119.35643644179193 34.87980387946216, -119.3567144416263 34.87980387946216, -119.35852244217105 34.87980587950671, -119.36879844408097 34.87981987834309, -119.37596344541467 34.87982987856325, -119.38183944516402 34.87969387767574, -119.38207444534065 34.87968887755756, -119.38246344550309 34.87967987808095, -119.38234444657571 34.89188687926357, -119.38230344746613 34.89610787934837, -119.38230044799143 34.89638187925826, -119.38217144722168 34.90075788062406, -119.3821664480971 34.900940879823835, -119.38415244801882 34.90093788052868, -119.39071344910494 34.9009278791936, -119.39453644988373 34.900923879396025, -119.39701444990004 34.90092087862673, -119.39753645013185 34.90091987959819, -119.40050645024274 34.90110287917374, -119.40192145132546 34.90119087964172, -119.40671145167673 + 34.90119387819093, -119.42289045399846 34.901204877781936, -119.4286094551856 34.90120787780404, -119.42979345539133 34.90120887756584, -119.43344645629084 34.90112387714238, -119.4336414562845 34.90111887685471, -119.44236445791772 34.90127887633172, -119.44427945859265 34.90127187579476, -119.44802045845738 34.90125987645156, -119.44928345830581 34.901255875933394, -119.44930545894545 34.901255875933394, -119.44943945883973 34.901255875933394, -119.44960745817092 34.90125487543541, -119.44963545865834 34.90125487543541, -119.45670545955745 34.90123287552862, -119.46275846065545 34.90121787542129, -119.47276646277676 34.90117887439279, -119.4727554629061 34.90559487521003, -119.47275346235794 34.90639287510006, -119.4727404637358 34.911643875992404, -119.47273946301257 34.9118738756358, -119.4727344629897 34.91411687612869, -119.47272046364431 34.91949287644815, -119.47271646434467 34.92122787686756, -119.47271646434467 34.921265876220914, -119.4727134639716 34.9222058769528, -119.47268046525797 + 34.935413878437465, -119.47267346648357 34.937911878651114, -119.47266246571462 34.94213087857294, -119.47266146588973 34.94262787914732, -119.4726604660648 34.94273587839368, -119.4726604660648 34.94284487896159, -119.47265346639209 34.94580487926043, -119.4726524665672 34.945818879364666, -119.47264646671941 34.94590687975487, -119.47255746613264 34.94725687902266, -119.47254746698522 34.94741188019138, -119.47256546652856 34.947744879335815, -119.47256546652856 34.947878879589474, -119.47258846789143 34.9599018808082, -119.47260146831019 34.9663958816877, -119.47258146911703 34.974292882388475, -119.4725794685689 34.97470488226763, -119.4725794685689 34.975091882040616, -119.47257046924638 34.97842988269765, -119.47255646990098 34.98409388346854, -119.47252747048698 34.9954688843942, -119.47252747048698 34.99565688466043, -119.47269847108952 34.9999748847304, -119.47269847108952 35.001295884602534, -119.47269847198785 35.00661888588131, -119.47269847198785 35.01032388633975, -119.47269847198785 + 35.01050788598093, -119.47269847288615 35.015453885938484, -119.47269847288615 35.01599488687617, -119.47269847288615 35.01749188640034, -119.47269947271107 35.01916288632883, -119.47269947271107 35.02181188635419, -119.47269947271107 35.02200988695715, -119.47270047343427 35.024906887264656, -119.47270147415752 35.03362188829595, -119.47270147415752 35.03651788792314, -119.47270147505583 35.03818688892726, -119.47270247488075 35.04158288960969, -119.47270247577907 35.046961890006024, -119.47270347560399 35.04877489030299, -119.4727044763272 35.05374389029957, -119.47270547705041 35.06002289078458, -119.47270647687533 35.061456891194055, -119.47270847742347 35.06751989215664, -119.47270847742347 35.06770489164781, -119.47270947814667 35.07057589192202, -119.4727104779716 35.071760892115456, -119.47271147869483 35.073193892312105, -119.47273147878633 35.07688989220076, -119.4801644790103 35.07702189164738, -119.48066047930286 35.07703089225352, -119.4811074791916 35.07703889230131, + -119.48155147960561 35.07704689234837, -119.4817684793406 35.07705089237161, -119.48203647912919 35.077055892216535, -119.48752725657053 35.07715405703068, -119.49072526383289 35.07981740269231, -119.49070048121119 35.08336889268569, -119.49069048206377 35.084830892805556, -119.49065248242893 35.09053489249439, -119.49065148260404 35.09065489237657, -119.490644482033 35.09180989323873, -119.49728948368302 35.09140189237625, -119.49878048403542 35.09128789294859, -119.50096648397366 35.091120892106645, -119.50259148422036 35.09099489206373, -119.50360648437166 35.09091589239256, -119.50464748446224 35.090856892318385, -119.50788448543514 35.090673891704654, -119.51714048627252 35.09014989143553, -119.51999248728096 35.08998889065426, -119.52087148698985 35.089939890960565, -119.52393048768752 35.08976789123284, -119.52590048771569 35.08965789039627, -119.52889448901432 35.089489890222076, -119.52997848876261 35.08942889051077, -119.53188748869144 35.08932189043695, -119.53203048880658 + 35.089313890123535, -119.5344054888907 35.089149890875944, -119.5385934895425 35.08890989018138, -119.54048249027817 35.088801889748666, -119.54988049230407 35.08826388921947, -119.55444249304969 35.08804588859584, -119.55818549346257 35.087834888893916, -119.55867349335921 35.087807888443294, -119.56098749334396 35.08767788819021, -119.56080849324381 35.089967888899004, -119.56004449406099 35.0997768898556, -119.56003249436542 35.10056388980876, -119.55934649500037 35.10985189064966, -119.55889349526386 35.11599089150211, -119.55847049656288 35.121714892190404, -119.55838649599895 35.122832891804705, -119.558346495816 35.12337489266302, -119.55821149609677 35.124972892835544, -119.55811749638544 35.12552889215252, -119.55808149640043 35.12598089223607, -119.55797149679557 35.12736689253221, -119.55793249733584 35.12786589253392, -119.55749649731773 35.13337089358225, -119.55742049714975 35.134265893653534, -119.55711349790138 35.13786589315288, -119.55638549780528 35.147383894267044, + -119.55638449798036 35.147397895122516, -119.55627749874856 35.148713894998785, -119.5561674982454 35.150382895802565, -119.55616449787235 35.1504178955449, -119.55569049911773 35.15631289598087, -119.55551749886537 35.15813489581801, -119.55480649938598 35.166863897668975, -119.55480449973614 35.16688689687045, -119.55476549937808 35.16737089714088, -119.55468349936231 35.16838389744677, -119.55465850014629 35.16869589708687, -119.55423049962577 35.17348589797775, -119.55393950027097 35.176754897937165, -119.55388850021734 35.177339897730185, -119.55384850093267 35.17778389821955, -119.55371750051312 35.1792628986977, -119.5536735010305 35.179750898843785, -119.553653500939 35.17997989814006, -119.55437450046414 35.18006589899806, -119.55465650139476 35.18007589852844, -119.55699350184409 35.18015389834752, -119.55844150164043 35.18022589794459, -119.56020550180429 35.180185897686826, -119.56067350160943 35.180190897811926, -119.56588650335662 35.180243896916146, -119.57127150373462 + 35.180296897454255, -119.57651450472069 35.18000289730269, -119.57670650434132 35.17998989621254, -119.57682950436495 35.179981896726694, -119.57755650463619 35.17993389685829, -119.57790750426548 35.179910896207694, -119.57801550511883 35.17990789685658, -119.57869850500914 35.179887897019036, -119.57978850460519 35.17985689612298, -119.57982750496325 35.17985589609391, -119.58522450593514 35.17957889637023, -119.5892705062967 35.179371895984275, -119.59271150658577 35.17913589544387, -119.59464250715423 35.17900389546503, -119.59962650857254 35.17866189589108, -119.60686550862778 35.17816589468228, -119.61632151127833 35.177518894332955, -119.62284451183129 35.177102893429996, -119.62597051247612 35.176991893706536, -119.64311451472045 35.176120892474145, -119.64975751582233 35.17577189196397, -119.65802151697292 35.17531389104516, -119.66706851847125 35.174813891072034, -119.6670635193467 35.175346890905814, -119.6670345190344 35.17865289109098, -119.6669795196811 35.18485089166304, + -119.66697751913297 35.185107891752665, -119.66698151933095 35.18525789142655, -119.66698252005416 35.185316891403495, -119.667049519103 35.188141891824564, -119.66704152050372 35.194787892784994, -119.66703652048085 35.19838389284654, -119.66703552065593 35.198993893285916, -119.66703352100612 35.20076389331005, -119.66702952080817 35.2037598943012, -119.66697352162997 35.206758893961705, -119.66695752173645 35.20694989404159, -119.66691452118042 35.20746289409976, -119.66687552172068 35.21573989477228, -119.66684252300705 35.22267389581421, -119.66681452341794 35.22884589582601, -119.6668115239432 35.22979589605368, -119.66678852437697 35.234680897128676, -119.66677152465854 35.23841089772339, -119.66668652516803 35.24529989731605, -119.66658252541097 35.24996989844733, -119.66665052608137 35.25803189939123, -119.66665152680459 35.25816289947888, -119.6666525266295 35.25855489941139, -119.66666952634793 35.26159189957658, -119.66667552709403 35.26253189946352, -119.67467552775567 + 35.26258389935747, -119.67557052735808 35.26258889880954, -119.6801545287433 35.26261489947588, -119.68496853028232 35.26264589884892, -119.69336253112924 35.262660897926104, -119.6981175313204 35.26269389823331, -119.72249953559168 35.26287389567154, -119.73400953793602 35.26295789580615, -119.74461853903354 35.2630368949627, -119.74756953887785 35.26305889506689, -119.75097354025532 35.263158894465114, -119.75986154174498 35.263285893926366, -119.76871454307455 35.26333889307094, -119.78180354473649 35.26341689212254, -119.79117354717336 35.263473892312994, -119.79846254835559 35.26351889127687, -119.80226354849479 35.26354189098216, -119.80946154944043 35.26358889157142, -119.80946054961552 35.263798891358775, -119.80944955064321 35.27362889186189, -119.80944755099337 35.274838891881295, -119.80943855167087 35.281810892639, -119.80942555304871 35.29244489402242, -119.80942055302584 35.29639689385334, -119.80941755265276 35.29855389465303, -119.80941555300295 35.2996448946327, -119.80945455336101 + 35.30012889453366, -119.80945155298798 35.30187489407879, -119.80944055401564 35.30744189567734, -119.80941555479957 35.32008689711695, -119.80939755525624 35.32908389723998, -119.80939755525624 35.329129897504174, -119.80939155540844 35.332751897498284, -119.80937555641326 35.34110789932695, -119.80936755781397 35.34538989941135, -119.80936455744092 35.346987898899286, -119.8093585575931 35.35086989984953, -119.81903755982644 35.35093289924432, -119.82019056007002 35.35093789910117, -119.82680156048653 35.35096489861577, -119.833683561963 35.350992898238495, -119.84185456322719 35.35102689781969, -119.84208256283283 35.35102789793647, -119.84935556412154 35.35105689765376, -119.85074256471685 35.35106189677023, -119.8518505647546 35.35106589723551, -119.85594256514692 35.3510828970125, -119.86202356673233 35.351104896632435, -119.87597856858267 35.35113489570489, -119.87972156899555 35.35120289546612, -119.88018456877785 35.35121589475669, -119.87990756966671 35.36348689619185, -119.87993057013122 + 35.363850896358514, -119.88009257051294 35.364159896693245, -119.88009857036073 35.36417089692042, -119.88013557106898 35.37218889750998, -119.88012757157136 35.37497089718468, -119.88011257240105 35.387001898689135, -119.88010357307854 35.39543790027066, -119.88006657596357 35.42960990236334, -119.88006557613865 35.43117690313026, -119.88006457631373 35.431937903602574, -119.88005757753936 35.43913790424042, -119.88790257928862 35.439127903015866, -119.88921957866542 35.439125904234615, -119.8893525787348 35.439125904234615, -119.89247557900659 35.43917490327115, -119.89304557981728 35.43918390400208, -119.8932935795144 35.4391879030255, -119.90167058154123 35.43921990325628, -119.91231558262376 35.43926090273288, -119.9136715823586 35.439266901994074, -119.92328458446967 35.43930390096553))) + metadata: {} +- model: regions.boundary + pk: 13 + fields: + region: 13 + version: 2026-07-12-svg + geometry: SRID=4326;MULTIPOLYGON (((-118.80496743521874 35.79035700745057, -118.80506243475499 35.79035700745057, -118.81539943676427 35.79039400686989, -118.82827343923937 35.79044000618299, -118.8290784393284 35.790444006628924, -118.82999543957041 35.79044700659892, -118.83685044038441 35.790473005119395, -118.83720544021162 35.79047400559482, -118.84576744218637 35.79050600549949, -118.84580244144817 35.79050600549949, -118.84590844175338 35.79050600549949, -118.8459444417384 35.79050700451711, -118.84840844151098 35.79051600514824, -118.85580144334864 35.79054300484945, -118.85826644384444 35.790553003764856, -118.85847844355654 35.79055400423926, -118.85956344402807 35.790524004574884, -118.85958444394448 35.790524004574884, -118.86171844490251 35.790531003526034, -118.86244444445049 35.79053400422137, -118.86259544496157 35.79053400422137, -118.86304844469808 35.79053400349269, -118.86320044503404 35.790535003967335, -118.86692544500526 35.790540004154394, -118.87593944689164 + 35.790555003256344, -118.87810244726366 35.79055000307023, -118.88182844795809 35.79054200291752, -118.89636444959149 35.790509001095046, -118.90378545191653 35.79047900141367, -118.9060134525859 35.79049300078061, -118.90717245177896 35.79049300078061, -118.91664545324963 35.7905010009382, -118.9179734533954 35.7905010009382, -118.9184804535586 35.790503000431, -118.93287645634823 35.790515999318835, -118.93389045667465 35.79051699906501, -118.93476545618554 35.79054699873204, -118.93542645633453 35.79055799957869, -118.9355704571729 35.79054099952824, -118.93817045738793 35.790551998918346, -118.94715745861183 35.79059799886874, -118.94935445842071 35.790608998250946, -118.97885746347937 35.79066399586795, -118.98069646398628 35.790667996302616, -118.98194746413918 35.79073999680501, -118.99510746684456 35.79068699508537, -118.9973024670036 35.79066099590616, -119.00094446803249 35.79061799522713, -119.01070846885796 35.790646994382804, -119.02509347087862 35.79062899314924, -119.02594747226978 + 35.790665993170585, -119.02668447168844 35.79066799338791, -119.02701247175149 35.790671993093625, -119.02702847164504 35.79066799338791, -119.04448647460706 35.79076799252976, -119.04471847441064 35.79074499187847, -119.05275447595562 35.79076099141347, -119.05303247668829 35.79074999205225, -119.05312147637673 35.79074799183704, -119.05344947554147 35.79075299201068, -119.05369647631198 35.79063399187301, -119.05729247641177 35.79063399187301, -119.06260347806824 35.79063399114432, -119.06288147790258 35.79070699069269, -119.06810847899514 35.790818990338565, -119.06960147899736 35.79081999008094, -119.07107847910605 35.790769990558466, -119.07122047939627 35.79077599120235, -119.07175947934651 35.79077899115981, -119.07791448055175 35.790754990768455, -119.08010948071083 35.790759990212976, -119.08027548039219 35.79076198969926, -119.08066248090479 35.79076498965719, -119.08878948178806 35.79074898939429, -119.08904848225411 35.790749989866235, -119.08926148179116 35.79077598974498, + -119.09768948387159 35.79075298909599, -119.09794648378956 35.79075298909599, -119.09822048432426 35.790773988801725, -119.10220648441137 35.79076998837245, -119.10238248413846 35.7907539888392, -119.10265548394993 35.7907489886656, -119.10271848459743 35.79074998840891, -119.10328448431187 35.79075998875566, -119.11467648665544 35.79073098818384, -119.11501348693933 35.790740987075594, -119.12526148836182 35.790803986914426, -119.12528748830108 35.79079198708774, -119.12546548857632 35.79072698702367, -119.13408549027739 35.79070898653691, -119.13411048949341 35.790707986793144, -119.14308449119683 35.7907039856318, -119.14316249101462 35.79070298588797, -119.15191549188675 35.79067098533398, -119.15203849280874 35.79067098533398, -119.16067049330702 35.790627984660304, -119.16093749416908 35.790627984660304, -119.16972249482824 35.790602984478255, -119.16988249556012 35.79060198473315, -119.17760749676047 35.79058298447268, -119.17806849689293 35.79058298447268, -119.17851149748202 + 35.79058098425327, -119.17853549687315 35.79057998377919, -119.1788344966239 35.79057698381422, -119.17901049724931 35.79057598406879, -119.18336949760534 35.79055698380212, -119.18748349773898 35.79054798317562, -119.18750649820352 35.79054498393819, -119.18775249825079 35.790537983530896, -119.18781949819795 35.790537983530896, -119.19197349851457 35.79053598258165, -119.19642049963245 35.790525982935414, -119.19668549994631 35.79052098274747, -119.19674349967264 35.79051998300134, -119.20532750049038 35.79050698192814, -119.20532950103852 35.79049798202468, -119.20536650084844 35.79049798202468, -119.20548050154956 35.79049798202468, -119.2055185011844 35.79049798202468, -119.2064255013807 35.790502982214065, -119.2072235008987 35.790501982467646, -119.21160550261759 35.790498981771094, -119.2123395025615 35.790496981549566, -119.21399150257238 35.79049298110629, -119.21404550210073 35.790493981581456, -119.2144025024761 35.79050098199257, -119.21459850229469 35.790497981295985, + -119.2147245026914 35.790497981295985, -119.21847150330221 35.79055298117585, -119.21868350391266 35.79055598114172, -119.21885250306875 35.790558981107424, -119.2198505035016 35.79055598114172, -119.22004550349529 35.79055598114172, -119.22155750376407 35.790553980921565, -119.22310550401787 35.790475981041936, -119.2231645044674 35.79047398081977, -119.22325450398075 35.790478981010665, -119.22350650387585 35.79049198063111, -119.2235235035943 35.790490980884606, -119.22363050462272 35.79047298034435, -119.22584050485041 35.79048298072595, -119.2324485057922 35.790476980059935, -119.23247150625674 35.790475980313246, -119.23266650625041 35.790475980313246, -119.23335650581338 35.790474979837846, -119.23426050563663 35.79047398009108, -119.23537950554503 35.79047197986891, -119.23649150578072 35.7904699796467, -119.23679050642977 35.79047297961565, -119.23691550700157 35.790474979837846, -119.23761050658743 35.79046797942438, -119.23872350664804 35.790465980659455, -119.23981550679225 + 35.79046398043703, -119.24095450769043 35.790461980214616, -119.2412055068623 35.790461980214616, -119.24137050671872 35.79046097973905, -119.24561450834535 35.79045297957737, -119.24920250894752 35.790453979324404, -119.2494905088276 35.79045197910169, -119.2503555091911 35.790447979384844, -119.25064450889609 35.790446979637785, -119.25066650863741 35.790446979637785, -119.2507775089655 35.79044397966779, -119.25081150840236 35.79044397966779, -119.25083450886689 35.790442979191994, -119.25090650883692 35.790442979191994, -119.25093050822805 35.790442979191994, -119.25096750893627 35.79044197944488, -119.25106950904355 35.79044197944488, -119.2517005090553 35.7904399792219, -119.25259850903076 35.79043797899884, -119.25263650956391 35.79043697925166, -119.25287450831699 35.79043797899884, -119.25309350860009 35.79043797899884, -119.25361350828375 35.79043797899884, -119.25365450918997 35.79043697925166, -119.25432850975776 35.79043597877576, -119.25880450939131 35.79042597911695, + -119.25929050963815 35.790424978640914, -119.26328951014402 35.790417978223104, -119.26767851153559 35.79041097780462, -119.26812251105129 35.79039397772273, -119.26831851086988 35.79039397772273, -119.27655151295666 35.79039597721821, -119.28598751371909 35.7903459774289, -119.28625751495416 35.7903459774289, -119.29424551592024 35.79035297639573, -119.30314551800379 35.79034897594521, -119.30367751738304 35.7903499764222, -119.32043651966292 35.79022497499599, -119.32050151996025 35.79022397451744, -119.32120452084034 35.79022497499599, -119.3250455211625 35.790243974613354, -119.32936152096252 35.7902399741574, -119.32954152178587 35.7902399741574, -119.33677552271655 35.790251974067374, -119.3407425234354 35.790258973042484, -119.35518852555542 35.790282972854946, -119.35520452544895 35.790282972854946, -119.35545652534402 35.790282972126256, -119.35552852531406 35.790283972604065, -119.35562652522336 35.790282972126256, -119.35871052603537 35.790201972001505, -119.37608052913384 + 35.789744971189954, -119.39319753161402 35.79016497030674, -119.39305953152174 35.79014697042133, -119.40102953294448 35.79012596982121, -119.40106753257932 35.79012596982121, -119.41511653503932 35.78997496863901, -119.4188525357795 35.79011196893224, -119.42419053720015 35.79004396759823, -119.4246245366701 35.79013096857661, -119.4278335380539 35.79009696828933, -119.43660753884244 35.790002966819344, -119.44684954041713 35.78989296626021, -119.45234954132117 35.789872966075954, -119.45418554235336 35.789872966075954, -119.46430254335631 35.78984096591645, -119.46469854319142 35.790122965467056, -119.47194554454086 35.78971896514443, -119.47206154489182 35.79000896460643, -119.47222154472541 35.790029965237466, -119.47224354536502 35.79003296522303, -119.47453354506034 35.79001796529391, -119.47921454563163 35.7899129642534, -119.48401754640166 35.789830963996664, -119.48898454720323 35.78974596438773, -119.49023254788138 35.78972396392543, -119.49031454789716 35.78972496441028, + -119.49384154839996 35.78979896382023, -119.49803454907463 35.78988496312668, -119.50016254928656 35.78992896322728, -119.50020955004052 35.78990096356355, -119.50096454990084 35.78979096286322, -119.51826855287709 35.78962796249949, -119.52645555403481 35.789456960810796, -119.53083955450518 35.78962196104022, -119.53457155504738 35.789762960964815, -119.53737055545406 35.78986796074617, -119.53812855568742 35.789571960035296, -119.5381265551393 35.789893960913474, -119.53807255650923 35.79706096109762, -119.53802455593036 35.80362796250175, -119.53802055663071 35.803918962505385, -119.53800755621192 35.8044639620927, -119.5379265578177 35.81187896333507, -119.53787155756612 35.815585963216755, -119.53786555861664 35.81600296293899, -119.53783755812923 35.81816396313013, -119.53784455870024 35.81909396406772, -119.5378275589818 35.82408096420201, -119.53779255972003 35.838166965833494, -119.53781456035964 35.84064196580974, -119.53781456035964 35.84065596580152, -119.53782956042828 + 35.84459796661473, -119.53782756077844 35.846220966714085, -119.53781356143307 35.85139096654524, -119.53780656176035 35.85507096775276, -119.5378115608849 35.85516496719059, -119.53780256246073 35.85894396754071, -119.53780256246073 35.858957967213875, -119.53776156245283 35.86248696866671, -119.53780556283377 35.86464196801216, -119.53779356223988 35.866486967918725, -119.53779756333617 35.86955696858105, -119.53784456319183 35.871102968956535, -119.53789456342054 35.87494696967577, -119.5378835644482 35.87636296937216, -119.53788056407515 35.87690296949255, -119.53786656383143 35.87708796927362, -119.53787556405227 35.87717096977073, -119.53782856419662 35.88032897035855, -119.5378275643717 35.88054397001045, -119.5378275643717 35.88056996959364, -119.53782456489697 35.8812399699624, -119.5378415646154 35.887631970418894, -119.53786756545298 35.88785297047921, -119.5378745651257 35.887902969982456, -119.53789656486698 35.89147397044936, -119.5379025656131 35.89152597043608, -119.53789256466904 + 35.89178497065893, -119.53789656576531 35.89344097080393, -119.53787756549873 35.898687971484385, -119.53778456651061 35.89868897132337, -119.53778056631266 35.89882797137618, -119.53779556638128 35.90288697206876, -119.53775156600035 35.905813972462, -119.53774956724885 35.90591297228638, -119.53774756670072 35.906003972559596, -119.53773156680717 35.90631397196926, -119.53769156842084 35.92032497433809, -119.53769156842084 35.9204379739122, -119.53767256905257 35.927291974353345, -119.5376385687174 35.927514974646826, -119.5376385687174 35.92771697448728, -119.53769956881676 35.934009975515984, -119.53769456879387 35.93423097553859, -119.53770356901471 35.93454697484316, -119.53770656938777 35.93478897495265, -119.53772156945637 35.934889975100816, -119.53771056958574 35.93511397516335, -119.5377085699359 35.935292975026606, -119.53769456879387 35.935496975563005, -119.53769257004237 35.935516975401875, -119.53765057020958 35.935672975716926, -119.53764256981366 35.9387139759034, + -119.53757257039179 35.94221397549132, -119.53743957032238 35.94893297674466, -119.53740457106062 35.956486976909225, -119.53737257217188 35.96312197770795, -119.53739157243845 35.96335197766923, -119.53672257189353 35.96334297847681, -119.53644657170902 35.96333897802752, -119.53644857135883 35.96341897824957, -119.53625357136517 35.96341397805633, -119.53618757214123 35.96341997799738, -119.5361845717682 35.963817977674196, -119.5361915714409 35.965780978310654, -119.53617757299381 35.9727819794284, -119.53618457356481 35.975509979359636, -119.53614857357981 35.97807297995031, -119.53619257306242 35.97995797987178, -119.5362185739 35.98214698067497, -119.53620857475258 35.98409498031607, -119.53619757398363 35.985532980359324, -119.53620657420444 35.986100980557495, -119.53616557509488 35.989084981137495, -119.53617557424231 35.991658981140034, -119.53615957434876 35.992625980749445, -119.53615657487404 35.99277098126233, -119.53610057479754 35.99297098123161, -119.5360985742494 + 35.99363898073886, -119.5361035751706 35.99387698116583, -119.5361105757416 35.99417898125413, -119.53610857519347 35.994399980836334, -119.53609457494976 35.99595298202654, -119.53607857505625 35.99949898157038, -119.53608357507912 35.99996698185944, -119.53609857514773 36.00080298206055, -119.53610357606891 36.00294898194933, -119.53608257615252 36.005611982061744, -119.53612257633547 36.00678098178467, -119.53612057578734 36.00725498269866, -119.53611457773617 36.021749984336616, -119.53610057749249 36.022192983544215, -119.53610957861164 36.024387984515194, -119.53609157906831 36.02872998489325, -119.5360935787181 36.028896984571595, -119.53609657819285 36.02901898471316, -119.53609657819285 36.02907198466811, -119.53607057915188 36.03387198477024, -119.53607757882459 36.03544798505522, -119.53608057919764 36.03550098576956, -119.53610157911405 36.03576398533251, -119.5361075789618 36.03585398540936, -119.53606057910618 36.0385869851299, -119.53609557926626 36.04004498609732, -119.5360915799666 + 36.04141298606061, -119.53614658111653 36.05036498692416, -119.53617258015747 36.05076298660621, -119.53617357998239 36.05078698666502, -119.53620258029468 36.05149398756638, -119.53626358129237 36.054682987077896, -119.53625758054626 36.05505298708217, -119.53624958194698 36.05586998700944, -119.53624658157395 36.056061987163375, -119.5362595810944 36.05800698766823, -119.53625158159682 36.06215498775959, -119.53627558188624 36.06387898850989, -119.53627758153608 36.06414398825057, -119.53628458210709 36.06506198805187, -119.53628058190914 36.06525498819777, -119.53628058190914 36.06527598846725, -119.53629758162757 36.06550098799461, -119.53628558283032 36.06848598801621, -119.53628658265522 36.0689549886855, -119.53628858320334 36.069455989212855, -119.53629058195487 36.069518988092376, -119.53629158177976 36.069537988240164, -119.53629158177976 36.06956598852603, -119.53631058204635 36.069624988628966, -119.53647958299908 36.070174989009956, -119.5365245823066 36.07037798812348, + -119.53654258184994 36.07054498877913, -119.53659958264966 36.071103988502685, -119.53651658370731 36.07691098940207, -119.53650358239021 36.07767098927959, -119.53646758330352 36.07928099002608, -119.5364665834786 36.07931198966512, -119.53645258323489 36.07990898988522, -119.5362915835764 36.08700699063956, -119.53629058375151 36.08707299032607, -119.5362915835764 36.087209990587354, -119.53625458376648 36.08726799016505, -119.5362195845047 36.08736499045205, -119.53621358375861 36.08741799105222, -119.53627858495425 36.09338899066319, -119.53627958477917 36.094408991177374, -119.5362815853273 36.095441991157315, -119.53629258519796 36.09614699167314, -119.53629358502288 36.09621099191232, -119.53630458579183 36.09779199199136, -119.53631958496211 36.09793399136169, -119.53632258533517 36.09796199169799, -119.53635758549528 36.09842999127897, -119.53639758567824 36.10127799175654, -119.53645558630289 36.10796299277545, -119.53645358665307 36.10893399254562, -119.53645358665307 36.108959992432034, + -119.53644758590697 36.11122799344839, -119.53643358656157 36.112349992551614, -119.53642558616565 36.114099992581636, -119.53638858635576 36.11678699373464, -119.53638058775645 36.119936993817795, -119.53638058775645 36.119992993308145, -119.53636158748988 36.12034099370313, -119.53636258731477 36.12043699314027, -119.53637058681241 36.12050599357028, -119.53638358812948 36.12053599370678, -119.53640358822099 36.12056399396896, -119.53643058708685 36.120592993426584, -119.53644058803091 36.12060399411881, -119.53646158794731 36.12064099373141, -119.53647958749065 36.120752993844775, -119.53645758774934 36.122567993537956, -119.53646358849544 36.12276899396132, -119.53645658792442 36.12298499383173, -119.53640858734555 36.123232994186345, -119.53640958717047 36.12328999412496, -119.53642458813738 36.12331799413057, -119.536475588191 36.12335199422759, -119.53648558733842 36.123394994094454, -119.53650158813026 36.123713993451275, -119.5362645883038 36.12495599449174, -119.53638358902784 + 36.13323099447513, -119.5363825892029 36.137627995806156, -119.53639258924865 36.137897995041236, -119.53636558948446 36.137897995041236, -119.53618958975737 36.137897995041236, -119.53542258930318 36.13789899547735, -119.53525258942385 36.1378899951794, -119.53509158886705 36.13789399547316, -119.53492258971099 36.13789599562, -119.52915358739678 36.13791899621642, -119.52912758835583 36.14182699666547, -119.52911758831009 36.14698199630134, -119.52910658933774 36.149559996889906, -119.52911658938349 36.149992997547564, -119.52912158940637 36.15160399697097, -119.52913358910193 36.15238499697738, -119.52914059057125 36.162981998440344, -119.5291365903733 36.16325799883476, -119.5291215903047 36.16409499873188, -119.52913559054838 36.16676399920497, -119.52908559121798 36.16693799850262, -119.52908259084495 36.16714499877695, -119.52910459148457 36.16786399876169, -119.52909459054051 36.16864199911802, -119.52909459054051 36.17070099950354, -119.52909559126374 36.17268699932286, -119.52912359175114 + 36.1758359997015, -119.52913159214707 36.17750999963308, -119.52911159205556 36.17798499940337, -119.52911559135522 36.17809499974855, -119.52914059236788 36.178216000530014, -119.52921459198772 36.17836600025651, -119.5292465926731 36.18022300058063, -119.52925359234578 36.1817590000667, -119.52922259238365 36.18188899986445, -119.52915159223853 36.18206600000075, -119.52914259291602 36.18242700032544, -119.52914659311399 36.18321299997374, -119.52917059340344 36.184280000716846, -119.52915459261159 36.18541100072011, -119.52916259300751 36.18612600058046, -119.52916359283243 36.18674800049517, -119.52915659315975 36.18738000076938, -119.52915459261159 36.187518001263676, -119.52915559243651 36.18753100087739, -119.52916359283243 36.18905100118129, -119.52916959357852 36.18914100049367, -119.52919959281743 36.189293001336864, -119.52920559356352 36.189400000966515, -119.52921859308402 36.19073300102296, -119.52921859398234 36.193424001658485, -119.52920559356352 36.194678000984894, + -119.52921659343417 36.196143001266854, -119.52921759415742 36.19624300166977, -119.52922559455331 36.19647200104567, -119.52922659437823 36.19691500181834, -119.52921559360928 36.19816500238528, -119.52924059462197 36.20020500209152, -119.52922959475131 36.20840600336084, -119.52924559554313 36.21074800301832, -119.5292555955889 36.21110900303456, -119.52921959560386 36.2112030032614, -119.52920659608338 36.21127200356426, -119.52918959636496 36.21345800290055, -119.52918859564173 36.214468003603294, -119.52916259570246 36.215846003240166, -119.5291935956646 36.21851600392333, -119.52926959673088 36.22616200489535, -119.52925659721042 36.22635400470056, -119.52925459666228 36.22711200406084, -119.52928559752273 36.22910800517751, -119.52927859695173 36.22965900493709, -119.52928159732475 36.23014300486201, -119.5292895968224 36.23047300516835, -119.52928559752273 36.23073600573936, -119.52930659833744 36.23281700572351, -119.5293165974849 36.23333100467158, -119.5293165974849 36.23337200520198, + -119.52931859803303 36.2334590051782, -119.52932059768281 36.23418600590942, -119.5293325973784 36.234900005237684, -119.52932959790368 36.235285005586526, -119.52934459797227 36.23663200533195, -119.52934059777432 36.23726500558804, -119.52935659766784 36.23801600579169, -119.52935659856617 36.239229006116446, -119.52936459896208 36.23963100526238, -119.52936559878698 36.24078700592509, -119.52935259836819 36.240966005891195, -119.52935659856617 36.243779006458546, -119.52936459896208 36.246113006586214, -119.52937759938088 36.24781100702508, -119.52937559883274 36.24815900655056, -119.52937159863477 36.24834400697266, -119.52937059880988 36.24996200740025, -119.52937860010411 36.25463100729277, -119.52939760037066 36.25532400808725, -119.52938559977679 36.25604900826264, -119.52939059979967 36.25686900754172, -119.52939159962459 36.25941700773174, -119.52939860019559 36.25988600828385, -119.52939560072085 36.26000500800845, -119.52934760014195 36.260210007851555, -119.52934760014195 + 36.26069200776213, -119.52934760014195 36.261160007591506, -119.52936959988327 36.26142900816679, -119.52935860091092 36.26175000766784, -119.52934460066724 36.26308700838823, -119.52932360075081 36.26443500873681, -119.52934060136758 36.26562600844657, -119.52936660040854 36.26614700847766, -119.52934360084232 36.26675300875251, -119.52937160132971 36.26932500942649, -119.5293706006065 36.269855009027246, -119.52921960099376 36.26984900927879, -119.52855660119495 36.2698220089563, -119.52794360072653 36.26979800922506, -119.52674660100034 36.26974700913808, -119.52660660125825 36.269741008657, -119.52594460038601 36.269721008981485, -119.52513659992393 36.26970700906074, -119.52392460102745 36.26964800874629, -119.52289559973411 36.269598009488355, -119.52127359986049 36.26954401010899, -119.519902600057 36.26948401019144, -119.51931959972582 36.269459009427536, -119.51626959924897 36.2693230097382, -119.51532259886974 36.26929200913149, -119.51453759887218 36.26925600964973, -119.51346059879658 + 36.26921800973617, -119.51160659822105 36.2691430100621, -119.5105825987472 36.26911600949549, -119.51033859834972 36.26911901012223, -119.51032459810601 36.26911901012223, -119.5098655976234 36.2691370095346, -119.5093085981298 36.26913601005023, -119.50863259791218 36.269139009951935, -119.50609359779652 36.26913001024647, -119.50272959660204 36.26908701067376, -119.50131459641761 36.26907901044741, -119.50088459624732 36.26908101014195, -119.50030759666222 36.26906701083074, -119.49863659638478 36.26905601142185, -119.49829959699922 36.269060010812055, -119.49759959559384 36.26905701163153, -119.49689859526187 36.269060010812055, -119.49443459548927 36.26905001161195, -119.49321559512344 36.26903201145525, -119.49256059572056 36.26903001103518, -119.49241159485933 36.269033011665236, -119.49239659479073 36.269033011665236, -119.4918875949777 36.26904201138188, -119.4902695944037 36.26905401172672, -119.48704259437487 36.269048011916674, -119.48597259487026 36.269057012355816, -119.48495359452097 + 36.2690450112872, -119.4837935937064 36.26904701170682, -119.48242559337766 36.26903301238951, -119.48113359321694 36.269041011896284, -119.47969759311611 36.269035012085226, -119.47772759218964 36.26903301238951, -119.47640459206671 36.26903901220072, -119.47510459240836 36.269031012693745, -119.47484459211735 36.26903601301945, -119.47461959288478 36.26903001248374, -119.47461859216153 36.26918701270905, -119.47462559183425 36.26928401254753, -119.47462559183425 36.26956301324795, -119.4745925922223 36.27068201244153, -119.47456759210793 36.27199501256079, -119.47454559326495 36.273615013029854, -119.4745215929755 36.27443201287084, -119.47452559317345 36.275011012603265, -119.47451359257958 36.27625401284013, -119.47451059310484 36.276589013099006, -119.47447459311982 36.277990012834934, -119.47445159265529 36.2794180134071, -119.47444959300547 36.279884013118114, -119.47441759321842 36.28115001306237, -119.47439159327912 36.28341901382672, -119.47437059426107 36.2852720140947, + -119.47438859470273 36.287564014594885, -119.4743875939795 36.287734014817, -119.47436659496141 36.294043014710205, -119.47437059515939 36.29706301511692, -119.4743795953802 36.297894015612464, -119.47447159544171 36.299866015817884, -119.47456659497796 36.300602015849016, -119.47483459656318 36.305130016448906, -119.47505559649615 36.309770016802446, -119.47507659641252 36.31023901671128, -119.47524659719016 36.313378017042076, -119.47529859706869 36.317049017333964, -119.47531059676427 36.31846401715916, -119.47531659751036 36.32038301749339, -119.4753455978227 36.32304201747369, -119.47534559872099 36.32361601792362, -119.47537659868314 36.32760201770724, -119.47537859833297 36.32796201834043, -119.47542659891184 36.33405601806603, -119.47541959923915 36.33489301923042, -119.47542659891184 36.33595201841402, -119.4754515990262 36.337944018741545, -119.47546859964294 36.34038101906829, -119.47549359975731 36.34182501927208, -119.47529959958854 36.342204019945434, -119.47530160013666 + 36.34443601993951, -119.47521659974787 36.345031020381136, -119.47541460011459 36.348970020613365, -119.47541760048765 36.349091021041104, -119.47546659999313 36.351118020479234, -119.47546659999313 36.35118402055928, -119.4753656015074 36.35608302112459, -119.47506160263212 36.37080102183978, -119.47506560283007 36.37092802298017, -119.47519960272437 36.374946023092264, -119.47516860366058 36.378145023126045, -119.47509660369055 36.385447023498834, -119.47505160528134 36.38996102440133, -119.47504560453523 36.39048302399718, -119.47494760552425 36.40044402512657, -119.47492060576009 36.400765025751156, -119.47490460586653 36.40095802547493, -119.48251660708901 36.40105102492719, -119.4839396076693 36.40104302522749, -119.4848256070509 36.40103902465428, -119.4873376083007 36.401026024416765, -119.48865060747953 36.401012024214154, -119.49265560873154 36.40097202424122, -119.49304360817072 36.400968023664355, -119.50097060993575 36.401074024059305, -119.50129161032613 36.401078023907665, + -119.50138760968728 36.401078023907665, -119.5017476104357 36.401090023451566, -119.51074361098209 36.401206022561894, -119.51273461182498 36.40123202297452, -119.52424261362115 36.40138302186185, -119.5246736136164 36.40138102194552, -119.52604161394513 36.40137402223806, -119.52854861427373 36.401448022006, -119.52880461436678 36.40147002250968, -119.52878061497567 36.403306022414064, -119.52876361435891 36.40610302220808, -119.52874761446539 36.407335022685004, -119.52874361516575 36.40882102259614, -119.52874461499064 36.410668022707235, -119.52872961582035 36.41191602314275, -119.52873361601833 36.41290202269118, -119.52873061564524 36.413658023494776, -119.52870761518072 36.41455502340954, -119.52870461570599 36.41606402304217, -119.52869161618554 36.4176140235688, -119.52867661701522 36.423317023600575, -119.5286756171903 36.42332702397236, -119.52862961715955 36.43065302467139, -119.52862861733463 36.43066502542899, -119.52860561776842 36.434315024981906, -119.5285896178749 + 36.435701024711, -119.52857961872748 36.43947402560861, -119.52856961778342 36.44020502552377, -119.52857961872748 36.441479026008366, -119.52857961872748 36.44150302561933, -119.52856461955716 36.444799025980565, -119.52855661916124 36.445130026574105, -119.52856161918413 36.44529002642897, -119.52856361883393 36.445327026357475, -119.52856261900902 36.4453400264063, -119.52855561933634 36.44579702623764, -119.52855261896329 36.446706026009984, -119.52853361869671 36.44775602633923, -119.52850661983086 36.45032702622499, -119.5284896192141 36.451377027401726, -119.5284896192141 36.452472026247406, -119.52846362017316 36.45384102673137, -119.52845261940418 36.45503502742327, -119.52845461995231 36.455359027072646, -119.52845261940418 36.4556080274422, -119.5284466204547 36.45580802729068, -119.52845161957926 36.45744602690662, -119.52842862091136 36.45892402774737, -119.52843362003591 36.459448027620795, -119.5284256205383 36.45988002779873, -119.52842362088849 36.459939027104376, + -119.52837562030962 36.4650260281116, -119.52836662098709 36.466978028455905, -119.52837862158096 36.46999702891738, -119.52837862158096 36.470136028736796, -119.52837062208336 36.47191102906205, -119.52836962225845 36.47202102921187, -119.52834862234204 36.474375028951115, -119.52834162177103 36.475729029024635, -119.52832762242566 36.47637502940417, -119.52831362218195 36.48012702942443, -119.52831362218195 36.48155102933915, -119.52830062266146 36.48482902998303, -119.52829362298877 36.48564003015271, -119.52829062351404 36.48595603012364, -119.5282966224635 36.48668703034158, -119.52829862301166 36.48755003063215, -119.52829162333896 36.48769803047611, -119.52826162320171 36.48786103075784, -119.52822262284366 36.48798203092687, -119.52819162377985 36.488057030757666, -119.52810462284289 36.48821303046209, -119.52807462360397 36.48826003007992, -119.52801462332951 36.48833503036386, -119.52789562350382 36.48845903063538, -119.5278376228792 36.48851203034143, -119.52777762350307 + 36.48856103033724, -119.52760362342578 36.48867903074522, -119.52751262318918 36.488733030578146, -119.52720662286742 36.48889103091644, -119.52843962347697 36.4889280302634, -119.53314162396465 36.488881029606404, -119.53382862495123 36.48891902995538, -119.53451462431627 36.48886602980562, -119.53872662435919 36.48892702998851, -119.53920962513128 36.48893402974595, -119.53923162577088 36.48892302961093, -119.5393396248276 36.48891002964638, -119.53955162453973 36.488900029061014, -119.5459846255793 36.48893902895325, -119.54615862565657 36.48894002922799, -119.54801862607992 36.488967029419356, -119.54803062577547 36.488967029419356, -119.54812562710835 36.488967029419356, -119.55655462721707 36.48896302832161, -119.55704662820997 36.48895402873989, -119.55717962738106 36.48895202891295, -119.55718662795206 36.488932028474025, -119.55899862869482 36.488789028038006, -119.56028662865761 36.48876802800223, -119.56400362913122 36.48878602793008, -119.56604062910655 36.488799028637686, + -119.57305763030224 36.48883902813463, -119.57320663026515 36.488840027688425, -119.57306063067527 36.48895002764153, -119.57301362992129 36.48898902750859, -119.57176262976844 36.490014027252975, -119.5674446303186 36.49355302828337, -119.56648063022092 36.49434402834908, -119.56596163036215 36.49467302792115, -119.56550362970444 36.494949028663804, -119.56413163007605 36.49577802878719, -119.56367462924324 36.496055029385424, -119.56353462950113 36.496183028855306, -119.55691462976212 36.5020620304391, -119.55691762923686 36.50219602998834, -119.55690962884094 36.50243402971535, -119.55689062947268 36.50302803007183, -119.55687962960201 36.5031480302834, -119.55686063023377 36.50338602997229, -119.55657662965334 36.503384030518, -119.55632862905789 36.5033830304298, -119.55580963009743 36.503399030395414, -119.55572562953354 36.503402029937284, -119.55546262886949 36.50341402954778, -119.55544262877801 36.50341402954778, -119.55542462923465 36.50341402954778, -119.55537162953122 + 36.503415029635626, -119.55535462891446 36.5034160297234, -119.55510662921733 36.50341902998671, -119.55440262941065 36.50343102959457, -119.55436362905256 36.50343102959457, -119.55411662828205 36.503432029682195, -119.55401062887518 36.503434029857296, -119.5536966290558 36.503442030557345, -119.5535916285755 36.50344703099445, -119.55354362889493 36.50489502990015, -119.55092062911368 36.50705203102159, -119.55086262848904 36.50710003079976, -119.55067362924146 36.50725503037671, -119.55064562875405 36.50728203068121, -119.5505626289134 36.50736503088974, -119.55053562914921 36.50739303047026, -119.55051162885975 36.507415030546134, -119.55044262926278 36.50748403012005, -119.55042062862317 36.50750803023995, -119.55000762906961 36.50776903097322, -119.54997362873443 36.507955030842574, -119.5499136293583 36.50796903124946, -119.54973662890797 36.50801403037392, -119.54967762845841 36.508029031520365, -119.54940162827388 36.50820503122842, -119.54932462828098 36.50825403103898, + -119.54922262817374 36.50833503087893, -119.54907362910913 36.508455031528904, -119.54886562869666 36.50860303113564, -119.54884562860519 36.50861603068269, -119.54878962852868 36.508657030794915, -119.5487706282621 36.50867103107477, -119.54875662891673 36.50868803141125, -119.54870762851294 36.508745031068074, -119.54869262844431 36.5087640314257, -119.5486776283757 36.50878003100124, -119.54863562854291 36.50882803115201, -119.54862162829923 36.508845031453966, -119.54859262888522 36.50882203104454, -119.5485646283978 36.5088000313685, -119.54850562884656 36.50884703148927, -119.54831162867781 36.5090020311783, -119.54824862803031 36.50905503126623, -119.54811562885922 36.50916103133329, -119.54808362907218 36.509187030959765, -119.5480286288206 36.50923903163054, -119.54794062895705 36.50932103117329, -119.54791062881984 36.50935203152371, -119.54786862898703 36.50934703146802, -119.54782762897914 36.50934303070121, -119.54614362828292 36.50896803063951, -119.54607362886101 36.51055603119635, + -119.54595862833499 36.51066203209595, -119.54591462885237 36.5107020311308, -119.5456996287672 36.51090103105784, -119.54562862862208 36.510968031875976, -119.54512862813158 36.51139203162447, -119.54363062810651 36.512664032095486, -119.54319262754028 36.5130370323735, -119.54319262754028 36.51311703229969, -119.54284962830694 36.51338303196495, -119.54182362828494 36.51418003202447, -119.54167762779677 36.51429503181838, -119.54155462777311 36.514510031727994, -119.54136762817538 36.51449403189297, -119.54105262763278 36.514469031828284, -119.54107862847039 36.51471103257755, -119.54109962838676 36.514897031648545, -119.54089562817227 36.515070032533664, -119.54028262860217 36.515590032077114, -119.54007962821258 36.515764032063295, -119.54000162749647 36.515824032789354, -119.53977062841608 36.516007032297935, -119.53969362842321 36.516068032037914, -119.53954462846028 36.51618303191411, -119.53910062804627 36.51653103316027, -119.53907762848003 36.516550033050486, -119.53892962744374 + 36.51660603277575, -119.53883762828053 36.51663503254021, -119.53856462757075 36.51672503295983, -119.53847362733414 36.516755032595135, -119.53757162805906 36.51780703252235, -119.53746562775383 36.51793103252682, -119.5372236279045 36.51821403266022, -119.53701162729409 36.51838403294434, -119.52858362701026 36.52512603438361, -119.52111462680128 36.53082303542012, -119.51895262625415 36.532300035019425, -119.51889362580461 36.532384035537845, -119.51885262669504 36.53244203549884, -119.51877662562873 36.532752035324336, -119.51632262590188 36.534505036075544, -119.51597262609754 36.53475503585908, -119.51591362564797 36.53481703565722, -119.51573662609596 36.535004035876646, -119.51567862547135 36.535067035875734, -119.51541662642883 36.53534403590444, -119.51463362608112 36.53617703586213, -119.51437262596521 36.53645503590631, -119.51357262589904 36.537015035941444, -119.51117262570055 36.53869503625342, -119.51037262473606 36.53925503594475, -119.5103456258702 36.53927403602811, + -119.51026462567935 36.53933503618817, -119.51023862484176 36.53935603622035, -119.51018362548848 36.5393970366447, -119.51002562530472 36.53951503679419, -119.50997262560128 36.53955503679175, -119.509874625692 36.53964003698854, -119.5035316250641 36.54513003762573, -119.50327362442292 36.54535003761662, -119.50325962507752 36.545356037778184, -119.5031526249474 36.54543003804859, -119.5030616247108 36.54548303772083, -119.50097862470473 36.54683503813827, -119.49731962485575 36.550233037911006, -119.49730962391169 36.55024103882369, -119.49728062449769 36.55026803856028, -119.49727162427685 36.550277038229794, -119.4972106241775 36.55033203859306, -119.49719962430684 36.55034403886393, -119.49703262390226 36.55049803892733, -119.49697462417593 36.55055703849069, -119.49677762453244 36.55073403907563, -119.496688624844 36.550795038853046, -119.49577562479992 36.55143903896049, -119.4954726239529 36.55165503901394, -119.49545662405937 36.55166903872929, -119.49541162475187 36.55171303895219, + -119.49539662378493 36.551728038842015, -119.49504062503108 36.55207003857166, -119.49438762437971 36.55270003935324, -119.49397562375272 36.55309603950441, -119.4936216237504 36.55343903911606, -119.49353462461008 36.55352203959913, -119.49327462431908 36.55377103907016, -119.49318862500368 36.55385503935406, -119.49317062456203 36.553872039142725, -119.4931196245084 36.55392303920777, -119.49270562423159 36.55429603898593, -119.4914726245204 36.555426039129806, -119.4910636242665 36.55580403953062, -119.49067462320573 36.55615003977326, -119.48994362363487 36.55671103941859, -119.48992362444169 36.55672903942083, -119.48983562457815 36.55679503939307, -119.48635862430406 36.55946003964211, -119.48609962383799 36.55965704024635, -119.48530062359671 36.56027004068598, -119.48507862294053 36.56044204030982, -119.48485662408102 36.560614040272334, -119.48481062315194 36.5606410407141, -119.48468462365356 36.56073604077714, -119.48464762384364 36.56077404048102, -119.48453362314253 36.560861039846614, + -119.48445062420015 36.56092004015682, -119.47882662344756 36.565234040741586, -119.47567962288636 36.567645042086895, -119.47567162338872 36.567655041875916, -119.47524362286825 36.56794004109048, -119.47521062235798 36.56796304123698, -119.47488162247001 36.568182041911875, -119.47477162286516 36.56825504127738, -119.47474962222552 36.56827204150514, -119.47468562265144 36.56832604207656, -119.47466462273502 36.5683440422586, -119.4746546226893 36.568352041296045, -119.47302562314295 36.5697050419921, -119.46810862257011 36.57379204262064, -119.46647062280294 36.575155042781105, -119.46644262231553 36.57517204365427, -119.46636162212468 36.57522504312747, -119.46633462236052 36.57524304314444, -119.46603362206163 36.575240042781196, -119.46513162188819 36.575231043855, -119.46483162231253 36.57522904337206, -119.4646816216264 36.57523204373573, -119.46423262118952 36.57524504362701, -119.46408362212493 36.57524904387057, -119.46388762140802 36.57525404255157, -119.46314262159343 + 36.57526704316057, -119.46306762125039 36.57526604328024, -119.4622946218467 36.57525604303386, -119.461287621193 36.575244043746444, -119.4600216209715 36.57524604350757, -119.45910062142983 36.57524804399006, -119.45900662082019 36.57524904387057, -119.45895562076656 36.57524904387057, -119.45880362132891 36.57525004375107, -119.45875362020189 36.57525104363157, -119.45856662060413 36.575252044233466, -119.4582056200308 36.57525604303386, -119.4580076214607 36.57526104387864, -119.45782162078955 36.575266044001665, -119.45765662093312 36.57526704388195, -119.45755662047569 36.57526704388195, -119.45753962075727 36.57526704388195, -119.45709162104357 36.57527104412443, -119.45625162079443 36.57527604352538, -119.45402461995 36.57529204377058, -119.4520826195109 36.57528604449059, -119.45154361956067 36.57528604449059, -119.45069661874051 36.57528804425067, -119.4499746202888 36.575282044248944, -119.4497446192367 36.57528004376733, -119.44906461882115 36.575275043645206, -119.4489416187975 + 36.57527104484579, -119.44882861881958 36.57525604375529, -119.4464776190249 36.575228044934136, -119.44554161851629 36.57521804396137, -119.44429861875933 36.57520404490589, -119.43986961808311 36.575155044223905, -119.4356806167081 36.575107044836045, -119.43511061769402 36.57510104482029, -119.43312861617365 36.5750780446353, -119.43126261590254 36.57505704565232, -119.43122061606974 36.5750630449501, -119.43111261701303 36.575081045004836, -119.43107961560442 36.57508704574355, -119.43101661675354 36.575103045306555, -119.43082961625748 36.57515204602147, -119.43076761633321 36.575169045452554, -119.43072761615024 36.57517004605552, -119.43047561625514 36.575164045323184, -119.42954061557147 36.57515804531188, -119.42612061609708 36.57514204576034, -119.425861615631 36.57514104587843, -119.42471261558538 36.57514304564222, -119.42463561559249 36.5751440455241, -119.42439361484485 36.57514504612738, -119.42367061477158 36.575150046257924, -119.42342961564543 36.57515304662466, -119.42335161492936 + 36.57515304662466, -119.42312461514862 36.57515604554848, -119.42304861498064 36.57515804531188, -119.42281261497908 36.57516004579665, -119.42210761444916 36.575169046173926, -119.42199061427331 36.57517104665841, -119.42191361428041 36.57517104665841, -119.42187261517086 36.57515704543018, -119.41976161467736 36.57514604600923, -119.4181466144764 36.57515304662466, -119.41696661357034 36.57516504664764, -119.41551661322583 36.57516004651808, -119.41428461423784 36.57518604632263, -119.41423761348388 36.57518904668803, -119.41404561386322 36.57517704594734, -119.41388761367948 36.5751830466786, -119.4138736134358 36.5751830466786, -119.41383461397605 36.57518404655996, -119.41382161355726 36.575185046441305, -119.41372161309981 36.57518804680669, -119.4134246138972 36.57519904622165, -119.4133256132647 36.57520304646765, -119.41328461325683 36.575204046348745, -119.41316361288301 36.57520804659443, -119.41312361359834 36.57521104695891, -119.41308061304231 36.57521204683991, -119.41300461287436 + 36.57521704624468, -119.41297261308728 36.57521904672792, -119.41295561336884 36.57521704624468, -119.412943612775 36.57522504673455, -119.41292661305654 36.57522304697289, -119.41288761269848 36.57521704624468, -119.4128526134367 36.57521204683991, -119.4127906135124 36.575204046348745, -119.41228661282395 36.57515804603331, -119.411736613003 36.57512604692685, -119.41115561321999 36.575102046866945, -119.41086061276889 36.57509104671673, -119.40988761245038 36.57508604658237, -119.40768361207044 36.57506404699724, -119.40660561216993 36.57503704726837, -119.40584561228674 36.575040046918076, -119.40515961292171 36.575023046737215, -119.40481461224189 36.575014047785736, -119.40426361169777 36.57502104697033, -119.40412961180346 36.57501804731984, -119.40404161193992 36.575016047552815, -119.4038016117404 36.575007047157705, -119.40241161167036 36.57498804720185, -119.40091161109714 36.57497604787278, -119.39849961120306 36.57492604716143, -119.39614361048721 36.574870047826835, -119.39606661049432 + 36.57486904794141, -119.39504561049519 36.57497404810462, -119.39486460984693 36.5749520477662, -119.39384361074615 36.57494204747916, -119.39217161064379 36.57490604802207, -119.39146061026608 36.57489804822058, -119.390428609498 36.57488904781173, -119.38915460888062 36.57486304790712, -119.38590160891249 36.57483904848691, -119.38438360879593 36.574803048981806, -119.3830226090382 36.57479904871514, -119.38147560860935 36.574770048398726, -119.38070460885548 36.5747560485406, -119.37854960798106 36.574718049222064, -119.37721060796464 36.57470004908272, -119.37712160737784 36.574693049147655, -119.37709160724062 36.57469004876304, -119.37706460747646 36.57468804826609, -119.37703760771227 36.5746860492119, -119.37524260758627 36.57466404951287, -119.37491060732525 36.574660049239, -119.37288460722057 36.574608049988264, -119.37060660632251 36.57458704943466, -119.36985760630998 36.57457404943261, -119.36806360600887 36.57454604964275, -119.36679260576454 36.57451404956131, -119.36364960540129 + 36.57449105064433, -119.3589926042211 36.57446005043084, -119.35416260368689 36.57438805032909, -119.34086160141452 36.57419205183627, -119.34051860128287 36.57418605102799, -119.33973160163549 36.57416805148602, -119.33903060040521 36.57415305162406, -119.33737360037148 36.57414305123354, -119.336587600549 36.574139052375514, -119.33568260090085 36.574115052008786, -119.33449060029919 36.57408505153999, -119.3329695998096 36.57407605175775, -119.33206559998635 36.57407205217481, -119.3320495991945 36.57407205217481, -119.33200160041227 36.57407205217481, -119.33198660034364 36.57407205217481, -119.33181860011415 36.57407005166185, -119.33024059972314 36.57405105219688, -119.32981059955281 36.57404605199518, -119.32500459930804 36.57399005202241, -119.32325759796431 36.57397005264064, -119.32321359938003 36.573944053148054, -119.3231785983216 36.573943052529245, -119.32316159860315 36.573943052529245, -119.32307359873961 36.57394205263177, -119.32303959840443 36.57394205263177, -119.32299659874671 + 36.57394105273434, -119.31971259791817 36.57390605343, -119.31856859789539 36.57389005217616, -119.31845059789462 36.57388805237993, -119.31413259664821 36.57382605362061, -119.31405159645733 36.573842053444814, -119.31389459699682 36.57384505314099, -119.31383559654726 36.57384705365977, -119.31381459663086 36.57384805283699, -119.31208959682506 36.573823053202304, -119.30685759570964 36.57375105322808, -119.30511459546216 36.57373005388424, -119.3051025957666 36.57513305403689, -119.30506759650483 36.57947605433416, -119.3050575964591 36.5809230548594, -119.30504259639046 36.58227805417804, -119.30500159638257 36.58634605507334, -119.3049875970372 36.58770205485596, -119.30498459666414 36.5878000554098, -119.30497959753959 36.588098055181284, -119.3049785977147 36.588193055372436, -119.30497759699146 36.58820705495793, -119.3049615970979 36.58962805485573, -119.30491759761532 36.59392405586504, -119.30490259754669 36.595356055946446, -119.3049015986201 36.59546605590926, -119.30489759842213 + 36.59594705562239, -119.30488659765318 36.597722055517316, -119.30488059870369 36.59831505642804, -119.30483659832277 36.60269705657195, -119.30483659832277 36.60272105670632, -119.3048315982999 36.604143056387315, -119.30483959869582 36.605041057041745, -119.30484159924394 36.606407056969374, -119.30483559939618 36.60786205663448, -119.30484359889378 36.60978905763757, -119.30483359884805 36.60993505693385, -119.30483559939618 36.61138405774432, -119.30483959959413 36.61358405756616, -119.30482659917534 36.61573205840869, -119.30481860057606 36.61718205778241, -119.30481259982994 36.61725305828318, -119.30479759976134 36.617454057294815, -119.30479759976134 36.617469058114764, -119.30480459943402 36.6175630581396, -119.30480360050744 36.617628058695715, -119.30480459943402 36.617825057972055, -119.30480659998216 36.61789205840548, -119.30480659998216 36.618045057831715, -119.30482660007365 36.62008505841976, -119.30482060022587 36.62065405796654, -119.304822600774 36.62095405847465, + -119.304822600774 36.621016058309095, -119.30482459952553 36.62108005883017, -119.30482360059892 36.621143057849196, -119.30482460042384 36.621458057940124, -119.30481760075115 36.62296605898308, -119.30481360055319 36.624406058918446, -119.30481960129929 36.625909058797895, -119.30482060112418 36.62619005943509, -119.30482360149723 36.62703305880456, -119.30482560024873 36.62731505887534, -119.30482060112418 36.62759605871201, -119.30480560105558 36.62844305893315, -119.30480160175594 36.62872505961218, -119.30480160175594 36.62902005874263, -119.30480360140577 36.629908059131125, -119.30480460123066 36.630204059389875, -119.30480460123066 36.63161105956164, -119.30478660078901 36.63172905867898, -119.3047776014665 36.6317720595465, -119.30477460109344 36.63198805906494, -119.30476860124566 36.63263205960252, -119.30476760142074 36.63284805959388, -119.3047636021211 36.634028059737595, -119.30476160247129 36.63493005912189, -119.30476160247129 36.636454059504594, -119.30475560172518 + 36.63757105997259, -119.30475160242553 36.638406059843966, -119.30475260225045 36.638752059646485, -119.30475260225045 36.638826060515, -119.30474960187739 36.63918706007026, -119.30474560257775 36.6404950602878, -119.30474560257775 36.640865060593775, -119.30474460275283 36.64088906038086, -119.30471160224256 36.64275506057572, -119.30468660302651 36.64398106074643, -119.30466760365827 36.64607406082035, -119.30465060304151 36.64837806164905, -119.30465760361251 36.651736061671485, -119.30464560391695 36.65348206130409, -119.30465360341456 36.654830061732206, -119.30465160376473 36.65652706166787, -119.30464360426714 36.65728406220191, -119.30462360417565 36.659208062116086, -119.30462860509682 36.659779062193635, -119.30463760441933 36.66061106206858, -119.30456860482238 36.66062006256464, -119.30436360388465 36.66064706260511, -119.3042956050109 36.66065706187037, -119.30348860437374 36.66064906303475, -119.30107360410662 36.66062806284448, -119.30026860401759 36.66062106277978, + -119.30016860356015 36.66062206299497, -119.29935460325028 36.66065506216157, -119.296616602943 36.66077106248262, -119.29570460272383 36.6608110630746, -119.29569460357641 36.660658062805716, -119.29391060242276 36.660677063279515, -119.28856060130653 36.66074106328603, -119.28677860159934 36.66076406387212, -119.28661960159067 36.66076406387212, -119.28630060085013 36.66077406312219, -119.28487060149544 36.66082806308569, -119.28469760124305 36.660834063639754, -119.28451960186615 36.66085106364578, -119.28439660094419 36.660865063012075, -119.28392560076597 36.660874063478424, -119.28251960080243 36.66090406334374, -119.28219860131037 36.66091306380548, -119.28205460047202 36.660916063719, -119.28111260011563 36.66092506345871, -119.2782875997697 36.66095306432372, -119.27734659923823 36.66096306354924, -119.27707260050013 36.660965063970664, -119.27624959996945 36.660974064425325, -119.27597660015797 36.660977064336414, -119.27208159851082 36.66101506440127, -119.26885859867993 + 36.66104706463098, -119.26039659716263 36.66113206582957, -119.2574515971661 36.66116106538622, -119.2570165969729 36.661166065706226, -119.25650259623869 36.661170065817906, -119.25539359637605 36.66117906552792, -119.25206959626284 36.661208065787484, -119.25131259495608 36.661215066519425, -119.2509625951517 36.661219065907936, -119.24790159480419 36.66110706637696, -119.23792259389354 36.6608330670304, -119.2324015921748 36.660683067448446, -119.22742059202785 36.66047906740377, -119.22739959211145 36.66047906740377, -119.22650859091037 36.66040406766477, -119.22573459168179 36.66037106766995, -119.22489459143267 36.66032906785264, -119.22480159154624 36.66032806763364, -119.2208435910482 36.66014306725384, -119.21995259074544 36.66010106803278, -119.21696359036798 36.65996206804511, -119.21662358971103 36.65994606878733, -119.21617959019535 36.659925068405286, -119.21484858967654 36.659863068170466, -119.21440558998574 36.65984306871172, -119.21052758895533 36.6597270684335, -119.20017158757781 + 36.659419069306765, -119.19889558641228 36.65938106989529, -119.19633758603004 36.659305068854245, -119.19501858610509 36.659273069341666, -119.19360358681902 36.65923806983698, -119.18936058501731 36.65913506953466, -119.18794658555613 36.65910107020225, -119.18712458485037 36.65908106982486, -119.1846635845525 36.65902107010287, -119.18384458421976 36.65900307017715, -119.18274558350451 36.65897507004413, -119.17944958298041 36.65889707028903, -119.17835158388672 36.6588710705933, -119.17764158333394 36.65887307106909, -119.17551258239878 36.65888007057208, -119.17480358346754 36.65888307056478, -119.17471058268279 36.65888307056478, -119.17447658233107 36.65888507104032, -119.1744355832215 36.65888507104032, -119.17434358226167 36.658886071278005, -119.17220558290232 36.658893071500465, -119.16579458160402 36.65891707143483, -119.16365858099617 36.65892607140824, -119.16354958121624 36.658927071645444, -119.16328458090236 36.65893007163631, -119.16216658081888 36.65894507158894, + -119.16179358054998 36.65895007205289, -119.16152058073853 36.658951072289796, -119.1607035809539 36.65895507179593, -119.16043158096736 36.65895707154889, -119.14414857799021 36.65874107270357, -119.14334457772608 36.65872807319081, -119.14252257791861 36.65871907247355, -119.1397425777785 36.65864307294084, -119.13633957622598 36.65855207330683, -119.13266957560802 36.658535073513754, -119.13254557665778 36.658532073507494, -119.13244357655053 36.658532073507494, -119.12595757490917 36.658489073885264, -119.1228905747139 36.65841907414717, -119.12270357421785 36.65841507461319, -119.12053657364785 36.65832407470967, -119.11347757261936 36.65802907536781, -119.1117005720367 36.65795507565363, -119.11112457227652 36.65793107541938, -119.1079895723092 36.65780007569055, -119.10646457162164 36.6577340756162, -119.10361357133642 36.65769607537306, -119.1034925709626 36.65769507584051, -119.09602956970309 36.65760507606092, -119.09425356984367 36.65758307622691, -119.09206256898422 36.65755807634241, + -119.08842956907453 36.65751407664126, -119.08830756887579 36.657519076477556, -119.08260556740709 36.657771077029736, -119.08252256756639 36.65777507731777, -119.0824035677407 36.657780077137126, -119.08179956749312 36.657807076732425, -119.08159856765167 36.65781707708724, -119.08146556758228 36.65782307643476, -119.07881656696344 36.65794207672874, -119.07208456617313 36.65824507765212, -119.07166956607142 36.65826407800089, -119.0704735652718 36.65825007816159, -119.0676915654819 36.6582200779826, -119.0654175647818 36.65819407805822, -119.05859956377772 36.658120078502584, -119.05632856345063 36.65809607831982, -119.05448956294374 36.65807707865023, -119.04479656136503 36.657888079064676, -119.03731756021199 36.65774208051986, -119.03383455919179 36.657677080649236, -119.02879355877036 36.65758408080546, -119.02348355783714 36.657488081523006, -119.0226585576566 36.65743208086535, -119.02251055751859 36.65743108132937, -119.01983755750864 36.657421080924415, -119.01793955655215 + 36.657398080780766, -119.01760355699152 36.65739408119374, -119.01462955668265 36.65735608150344, -119.01443755616371 36.657358081297936, -119.01001555605849 36.657306081578845, -119.00990055553247 36.657305082041226, -119.0097695551129 36.65730308152469, -119.00230855440151 36.65721708304525, -119.0018715536602 36.65721208246868, -119.00095755379121 36.65720608235294, -119.0009015537147 36.657152082731386, -119.00029155361933 36.657152082731386, -118.99846255315815 36.657152082731386, -118.99785355288769 36.657152082731386, -118.99679855345171 36.657152082731386, -118.99363455317207 36.657152082731386, -118.9925805526627 36.65715208345199, -118.99242455302708 36.65715208345199, -118.99205355330632 36.65715208345199, -118.99195755304686 36.65715208345199, -118.99180255323614 36.65715208345199, -118.9914255527693 36.65715308299159, -118.99106855239393 36.65715508351208, -118.98520855163977 36.65715208345199, -118.98479155098994 36.65715208345199, -118.98482455150021 36.658107083929416, + -118.98454486678743 36.66797115649069, -118.98444326940069 36.667968620556245, -118.9684665638333 36.66677827504187, -118.96748927213969 36.66665683511923, -118.96652866455453 36.66643985637244, -118.96559405749889 36.666129443162745, -118.96469451522853 36.66572860602245, -118.96383876192455 36.66524123245735, -118.96303509708223 36.66467204924394, -118.96229131501858 36.664026576586885, -118.96161462927947 36.6633110745815, -118.96101160267928 36.66253248250045, -118.96048808365163 36.661698351493406, -118.9600491495286 36.660816771352486, -118.95969905729834 36.65989629205369, -118.95944120231881 36.65894584083519, -118.95927808538805 36.65797463561685, -118.95921128849025 36.656992095600536, -118.95879416635542 36.63459180579124, -118.93813602425695 36.63426611035236, -118.93715168682284 36.634201933338005, -118.93617846127317 36.63404108899455, -118.93522581747087 36.633785142399994, -118.93430302501044 36.633436584014184, -118.93341906302125 36.632998805445716, -118.93258253279707 + 36.63247606645024, -118.93180157410218 36.63187345348138, -118.93108378596827 36.631196830197595, -118.9304361527529 36.63045278040645, -118.92986497617903 36.629648544001654, -118.92937581401674 36.62879194651606, -118.92897342600403 36.62789132297618, -118.92866172753268 36.62695543679918, -118.92844375155 36.62599339452136, -118.92832161904701 36.62501455718805, -118.92829651842047 36.62402844926704, -118.92855545690956 36.613192928324096, -118.91800712154404 36.61329816496297, -118.9170729248054 36.6132637876386, -118.916146020244 36.6131423245536, -118.9152345081284 36.61293483718036, -118.91434635421172 36.61264313876214, -118.9134893201185 36.612269778467, -118.9126708955155 36.61181801911054, -118.91189823265913 36.61129180864191, -118.91117808389136 36.61069574564254, -118.9105167426308 36.61003503913885, -118.88689724293724 36.58412159845996, -118.88627978357562 36.583374437276056, -118.88573756057094 36.58257100706055, -118.88527566814034 36.581718856087704, -118.8848984457904 + 36.58082599036617, -118.88460943754737 36.57990079842206, -118.88441135866074 36.57895197248826, -118.88430607009333 36.57798842684038, -118.88429456103744 36.57701921404661, -118.88437693962132 36.576053439918304, -118.88455243189333 36.575100177960365, -118.88481938909322 36.574168384125116, -118.88517530314235 36.573266812670596, -118.88561683020723 36.57240393391378, -118.88613982211506 36.5715878546515, -118.88673936532601 36.57082624199654, -118.88740982709625 36.57012625134473, -118.8881449083979 36.56949445914952, -118.8889377030988 36.56893680113585, -118.8940043038425 36.56572825918132, -118.89594621111921 36.56160199340023, -118.89197810408919 36.55755345165675, -118.88190837664614 36.55107756172666, -118.87818479655398 36.54898554506203, -118.8773510859873 36.54846153082016, -118.87657295541979 36.547858059980264, -118.87585795010528 36.54718098418323, -118.8752130031946 36.54643686879091, -118.87464436850725 36.54563292922414, -118.87415755989042 36.54477696099735, -118.87375729775316 + 36.54387726412831, -118.87344746329427 36.54294256265577, -118.87323106086768 36.54198192004561, -118.87311018885032 36.54100465130564, -118.87308601929492 36.54002023266133, -118.87417516254838 36.495949505118375, -118.76804665679414 36.49562316216854, -118.76706599736507 36.49557193055351, -118.76609509113165 36.495424761670435, -118.76514330071353 36.49518307469476, -118.76421980439319 36.49484920025649, -118.7633335076081 36.4944263579656, -118.76249295707412 36.493918625364685, -118.76170625836791 36.49333089860855, -118.76098099776353 36.4926688452497, -118.76032416907658 36.491938849585175, -118.75974210622165 36.4911479510916, -118.75924042213329 36.49030377654229, -118.75882395463938 36.489414466460964, -118.75849671980929 36.488488596621195, -118.75826187322602 36.487535095348754, -118.75812167955651 36.486563157424165, -118.75807749071299 36.48558215541584, -118.75844752252308 36.39545003121162, -118.75848049197107 36.39467869257049, -118.75857286943065 36.39391219619818, + -118.7698259212695 36.32353420776719, -118.77002350648495 36.322591988262786, -118.77031077967905 36.3216731348339, -118.77068507835357 36.32078616357501, -118.7711429334428 36.319939295091075, -118.77168010146565 36.3191403783075, -118.77229160385477 36.318396817724945, -118.77297177309896 36.31771550479309, -118.77371430527053 36.31710275403952, -118.7745123184513 36.3165642445455, -118.77535841651536 36.31610496731134, -118.7762447576777 36.31572917899886, -118.77716312717318 36.315440362479954, -118.77810501339252 36.3152411945567, -118.7790616867693 36.31513352115228, -118.7800242806871 36.31511834020267, -118.80523368889412 36.31593502535654, -118.80496871539953 36.3116936234886, -118.80495556389813 36.310715030376116, -118.80503813374096 36.30973983826024, -118.80521563405966 36.30877738770117, -118.80548636472521 36.307836897218216, -118.80584773263209 36.306927374993265, -118.80629627653545 36.30605753258883, -118.80682770020354 36.30523570150717, -118.8074369135678 36.304469753389725, + -118.80811808147635 36.303767024621166, -118.80886467958416 36.30313424606021, -118.80966955684434 36.30257747857027, -118.81052500400209 36.302102054967484, -118.81142282743535 36.30171252894203, -118.81235442763466 36.30141263144214, -118.81331088157083 36.30120523493836, -118.81428302816124 36.30109232591058, -118.81526155601628 36.30107498582117, -118.84105004457845 36.3018807484891, -118.84051403482991 36.29899902423095, -118.82898574429345 36.29076416811826, -118.78192193646443 36.287648809456044, -118.78126274674598 36.288081947423905, -118.78035595488687 36.288553001900496, -118.7794058257026 36.28892905818464, -118.77842228009811 36.289206189632765, -118.77741558790052 36.289381502538625, -118.77639626062529 36.28945316634832, -118.76163891886227 36.289734698148436, -118.76063723427363 36.289703581541744, -118.75964369433446 36.28957236169719, -118.75866827756634 36.289342356508506, -118.75772078047258 36.28901587601039, -118.75681071914794 36.288596199177924, -118.75594723370465 + 36.28808754099449, -118.75513899647467 36.287495010119, -118.75439412490987 36.28682455757759, -118.75372010005525 36.28608291699503, -118.75312369141379 36.28527753696628, -118.75261088895755 36.28441650624718, -118.75218684296807 36.283508472515905, -118.75185581230994 36.282562555520755, -118.7516211216573 36.28158825548694, -118.75148512810287 36.28059535770192, -118.75144919748469 36.279593834237886, -118.75301316786975 36.169993565056764, -118.75307345005773 36.16903069605289, -118.7532262367601 36.16807811698302, -118.75347010591364 36.16714469398311, -118.75380278770457 36.16623911489392, -118.75422118569493 36.1653698083988, -118.75472140564267 36.1645448655737, -118.75529879174731 36.16377196457942, -118.75594796998386 36.16305829919706, -118.75666289812162 36.16241051187189, -118.75743692196218 36.16183463188872, -118.75826283727358 36.1613360192543, -118.75913295684357 36.16091931480901, -118.7600391820285 36.160588397032235, -118.76097307813149 36.160346345943374, -118.76192595290838 + 36.160195414434526, -118.76288893747089 36.160137007301714, -118.77882827471818 36.15994059995628, -118.78141660234608 36.1587421479709, -118.78168108224337 36.14857620343459, -118.7801363486321 36.14829733977375, -118.77913655150527 36.148063279924735, -118.77816602745337 36.14772792731565, -118.77723500947147 36.14729481783166, -118.77635331401122 36.14676851808521, -118.77553023747829 36.146154577266664, -118.7747744582131 36.14545946863484, -118.77409394498827 36.14469052126436, -118.77349587298762 36.1438558427695, -118.77298654815262 36.142964233819235, -118.77257134069382 36.14202509534495, -118.77006711784382 36.13546993405855, -118.76974422613002 36.1344700309017, -118.76952803612626 36.1334417667021, -118.76723769261534 36.118751888994574, -118.76712895478819 36.117672432112236, -118.76713779689449 36.11658754829678, -118.76825342474784 36.09873862678464, -118.76757579130222 36.086004703684964, -118.75954250160656 36.07705327170335, -118.75896235245108 36.076343955330486, + -118.75844956921605 36.075584510722045, -118.75800845775646 36.07478131495535, -118.7576427220974 36.073941112487425, -118.75735543333128 36.073070958521576, -118.75613393505655 36.06870927322305, -118.7559147398874 36.06774542664031, -118.75579177117677 36.066764648656545, -118.75576623038646 36.065776521931234, -118.7558383670619 36.06479070092451, -118.75600747639407 36.06381681756856, -118.75627190610574 36.06286438715912, -118.75662907259495 36.06194271538662, -118.75707548617812 36.06106080741523, -118.75760678518581 36.060227279898186, -118.75821777857828 36.059450276789065, -118.75890249666443 36.05873738977155, -118.75965424942838 36.05809558408512, -118.76046569189414 36.05753113047143, -118.76132889588939 36.05704954390621, -118.76223542750752 36.05665552971537, -118.79935007225751 36.042661578416926, -118.80016065838204 36.04239486114128, -118.80099103251831 36.04219822247717, -118.81113657864437 36.04024867313966, -118.81198039987973 35.987384505205846, -118.81089424797278 + 35.98724765764875, -118.81040894690051 35.987174373495655, -118.78649465966352 35.982960476755814, -118.78561011874916 35.9827629082156, -118.78474709881084 35.98248606431857, -118.78391268909094 35.98213221918773, -118.7831137438144 35.98170427946961, -118.77225535659885 35.9752385133338, -118.75700582365025 35.97496700157789, -118.74370396686218 35.976780014641555, -118.74334281222478 35.97682256590484, -118.70186131229953 35.98094671476177, -118.70086456524092 35.98099577168738, -118.69986789198558 35.98094523751225, -118.69888121851487 35.980795615511944, -118.69791437122119 35.98054839578884, -118.69697697904581 35.98020604043204, -118.69607837758316 35.97977195899717, -118.69522751610657 35.9792504745502, -118.69443286844145 35.97864678061357, -118.69370234857357 35.97796688944331, -118.69304323183269 35.977217572152256, -118.69246208243678 35.97640629127586, -118.69196468811823 35.97554112645187, -118.69155600248318 35.97463069395438, -118.69124009567797 35.973684060883336, + -118.6910201138541 35.97271065486427, -118.69089824783538 35.97172017015754, -118.69087571129923 35.970722471112076, -118.69121582983372 35.95828239229983, -118.68300678646973 35.95788256384085, -118.68201746376323 35.95778490461982, -118.68104272638496 35.95758949790428, -118.68009220763268 35.95729827489242, -118.67917530145225 35.95691411373179, -118.67830106959754 35.956440811074614, -118.6774781520735 35.95588304455562, -118.67671468174721 35.95524632656315, -118.67601820397103 35.95453694976037, -118.67539560201209 35.95376192489509, -118.67485302902527 35.95292891151282, -118.67439584724174 35.95204614225765, -118.67402857497414 35.951122341509375, -118.67375484196234 35.95016663916067, -118.67357735350076 35.94918848038671, -118.67349786370211 35.94819753229878, -118.67026898736569 35.8417279812743, -118.66349926302942 35.840879328863025, -118.66257029108327 35.84071807549157, -118.66166063546474 35.84047004689999, -118.66077838293523 35.840137448041425, -118.65993137664495 + 35.839723235691565, -118.65912714640807 35.83923109216309, -118.65837284176321 35.83866539257027, -118.65767516841478 35.83803116593456, -118.65704032861974 35.837334050477004, -118.65647396605004 35.83658024349494, -118.65598111562096 35.83577644626858, -118.65556615873105 35.83492980448715, -118.64681024613394 35.814704158636204, -118.64643420988902 35.81368771111573, -118.64617038025702 35.812636539034365, -118.64602185609856 35.8115629891316, -118.64552338298903 35.80558992954243, -118.64549944597876 35.804295707198456, -118.64617669049461 35.78967071365897, -118.65123840916903 35.78969801762523, -118.65596041064653 35.78972301736332, -118.67012741220898 35.78979801653027, -118.67485041351138 35.78982401599181, -118.67545041356101 35.78982701525651, -118.67725141353479 35.789836015964596, -118.67785241340934 35.78984001571225, -118.68115441467953 35.789857015912645, -118.70400641824041 35.78997901428671, -118.7048504186875 35.78998401378004, -118.70535741885068 35.7899870137674, + -118.70656541844757 35.789993013741714, -118.7117134198974 35.7900200128919, -118.7134304202056 35.79003001333051, -118.71851942120591 35.79005701319677, -118.73378742313339 35.79013901176289, -118.73870242405644 35.79016601159217, -118.73887742395861 35.79016701207146, -118.7419444250522 35.790184011473265, -118.74240042426344 35.790184011473265, -118.74249942489598 35.79018301172283, -118.74422842579806 35.79018001101424, -118.74434442525069 35.79018001101424, -118.74462242508505 35.79017901126382, -118.75075242617592 35.790158010672165, -118.75093442664912 35.7901570109214, -118.75200742652675 35.79015901042285, -118.75319242655739 35.79016101065289, -118.75715242760354 35.79016901084396, -118.75735142779519 35.79016901084396, -118.75795142694649 35.79017001059451, -118.75815142786134 35.790171011073745, -118.75850542696536 35.790171011073745, -118.75956742787066 35.79017301057485, -118.75992142787298 35.79017401105404, -118.764392428382 35.7901830102655, -118.76528842870763 35.79018500976624, + -118.76613142843149 35.79018700999568, -118.76698642874926 35.79018901022502, -118.76792842820733 35.79020901032973, -118.76808242819311 35.79021201030859, -118.76929542871115 35.79021500955865, -118.77339842987244 35.790227009472396, -118.7747664302012 35.79023200967883, -118.77814643039089 35.79024100888372, -118.7882884324065 35.79027100938363, -118.79166943331943 35.79028100833325, -118.79267243287687 35.79028300856028, -118.7956814333458 35.79029200848809, -118.7964314340816 35.790295007735075, -118.79668543362648 35.79029600821279, -118.79670143352001 35.79028900851225, -118.80165143460313 35.79031500781316, -118.80374143518023 35.790352007252, -118.80496743521874 35.79035700745057))) + metadata: {} +- model: regions.boundary + pk: 14 + fields: + region: 14 + version: 2026-07-12-svg + geometry: SRID=4326;MULTIPOLYGON (((-118.98447055329453 36.67059208517495, -118.98430455361317 36.676437085405105, -118.98424855353667 36.67838908593315, -118.98403655472286 36.685807086274565, -118.98367155484989 36.698692087723124, -118.98347355627982 36.705670088653015, -118.98344055576953 36.706810087814766, -118.98334355658345 36.71020708877626, -118.98331355734454 36.711340088555936, -118.98328855633187 36.71213708900287, -118.98322155728304 36.71453408895348, -118.98320055736664 36.7153360890726, -118.98314455818849 36.71725108925491, -118.98313755761745 36.717548089443994, -118.98311755752599 36.718224089455454, -118.98295355749444 36.723995090114975, -118.98285955868144 36.727309090288095, -118.9828505584606 36.72762509024164, -118.98271155944174 36.73253809062211, -118.98270555869564 36.732710090409476, -118.98270255832259 36.73282509149219, -118.98269055952535 36.733228091041006, -118.98268655932738 36.733401091430956, -118.98266355886284 36.73421609080813, -118.98265155916727 + 36.73460509135438, -118.98262655905292 36.735522091319645, -118.98260055911365 36.73643309071551, -118.98257055897642 36.73748409198944, -118.98254955906002 36.738217091574406, -118.98253655953954 36.7386980911451, -118.98251655944807 36.739422091464206, -118.98250355902928 36.73986709125269, -118.98246755994259 36.74113809170055, -118.98246555939444 36.74120509207636, -118.98245355969888 36.74165109220677, -118.98196455997734 36.74164609208087, -118.98103056001686 36.741639092191974, -118.980498558841 36.74163409206534, -118.98001055894439 36.74163009253962, -118.97896755920394 36.74162109216635, -118.97583855818611 36.74159409247981, -118.97479555844569 36.741586091984324, -118.96635955686763 36.74151509312926, -118.96549555722738 36.741507092625525, -118.96395955666914 36.7414940934249, -118.96282155649416 36.74148509303562, -118.9614995561962 36.741474093598256, -118.95340655474982 36.74140809334159, -118.95206155398729 36.74139609400868, -118.94821255416754 36.74136509470352, + -118.94691655380882 36.741355094414104, -118.94597055415278 36.74135009426891, -118.94009555243169 36.741300094958724, -118.93813755209906 36.741284095345364, -118.93801955209832 36.74128209485354, -118.9380045520297 36.74128209485354, -118.93766355244614 36.74127809530948, -118.93754655227029 36.7412770947036, -118.93715055243518 36.7412690956148, -118.93597255207723 36.74125109478246, -118.93558055154175 36.74124609535037, -118.93525155165378 36.74124409557743, -118.93423755132737 36.74123709493206, -118.93021255088217 36.741212094884276, -118.92887055139106 36.74120409578871, -118.92508854972178 36.74117209507887, -118.92011854944549 36.74112909630859, -118.91148054730276 36.74105509664411, -118.90990654800804 36.741041096757954, -118.90188354598354 36.740973097788704, -118.8974125454745 36.740935098359614, -118.89323254521864 36.74090009786477, -118.89203154529447 36.74090709782105, -118.88769254413162 36.74086609796527, -118.88482954325255 36.74083909873307, -118.88259954293336 + 36.74081809884377, -118.87667354205698 36.74076209983068, -118.87599654201446 36.74075609903417, -118.87219554187526 36.74072109989744, -118.85745953842869 36.74059710007147, -118.85299953779034 36.74055910045623, -118.84668653729963 36.74050510104643, -118.83542953557364 36.740410102219165, -118.83455953518728 36.74040310221756, -118.83057553474997 36.740370102715644, -118.82754753401446 36.740344102487285, -118.82729453339614 36.7403421026909, -118.8227865330772 36.740304102949416, -118.82222753303546 36.740298102117094, -118.81951653249237 36.74027610218066, -118.8143535318722 36.74023210372867, -118.80847953087438 36.74018210369014, -118.78471452726943 36.73998110546718, -118.78388152669301 36.73997410542645, -118.78227252633984 36.73997710585533, -118.7810745258904 36.73996310505267, -118.78088252626979 36.73996110596618, -118.77966852592685 36.739948105063945, -118.77672252520709 36.739916105990055, -118.7753365244367 36.73990210589616, -118.77488352559848 36.73990010608819, + -118.77423352532011 36.73989410594418, -118.77092552510045 36.73986710565014, -118.77064452399472 36.739865106561155, -118.77042252423689 36.73986310675224, -118.76982452383709 36.73985710660533, -118.76606852390373 36.73982510677367, -118.75480052140873 36.73972910647867, -118.7510455213003 36.739698107220136, -118.75092752129952 36.739448107842186, -118.74957052084144 36.7394511075718, -118.74467252053518 36.73946410783832, -118.74304151954237 36.73947110792551, -118.74296052024984 36.73946910810632, -118.74007351997963 36.739478108012115, -118.73202151854115 36.73950410853382, -118.73117351789608 36.73950610907199, -118.72820751708484 36.73951610888236, -118.72601451747389 36.739522109055976, -118.71943751559596 36.73954010957391, -118.7172455149116 36.73954710965417, -118.7170615156869 36.73954510983702, -118.71650951531785 36.73954010957391, -118.71632651501973 36.739539109665195, -118.71598151523824 36.73953610921919, -118.71495551521629 36.73952711004009, -118.71461551455933 + 36.73952611013126, -118.70447751364003 36.73944311045227, -118.67406450801205 36.73920211306146, -118.6639275060193 36.739123113408574, -118.65699850558549 36.73907511391694, -118.64153950206565 36.73897711501159, -118.63622850220581 36.73897411526352, -118.62931050074431 36.73897411526352, -118.628732500436 36.73897411526352, -118.62700150078243 36.73897811564753, -118.62642649994883 36.73898011619934, -118.62632949986445 36.73898011619934, -118.62604050015946 36.73898011619934, -118.62594449990002 36.73898111611534, -118.62275250003125 36.73898811552674, -118.61317649862842 36.73901011655218, -118.61057449786526 36.73901711668083, -118.60998549678796 36.73901811659628, -118.59992049566358 36.739040117613136, -118.56972749104196 36.7391081190148, -118.55966348974246 36.73913212055376, -118.55901348856581 36.73913412038167, -118.55873948892942 36.73913412038167, -118.55596748828694 36.739140120585155, -118.55504348837223 36.73914312104663, -118.55513448860881 36.74026412066755, -118.55540748842029 + 36.743629121787315, -118.5554994884818 36.74475112123455, -118.55213648890884 36.74475112123455, -118.5420474865967 36.744751121954394, -118.53868448612543 36.74475112267424, -118.53638148601132 36.74475112267424, -118.52947448442048 36.74475412291636, -118.52717248413126 36.74475612331769, -118.52378648319545 36.74475912355963, -118.51362848128635 36.74477012396588, -118.51024348107376 36.744774124047744, -118.51023648140107 36.74476512404316, -118.51021748113449 36.74473812474284, -118.5102114812867 36.7447301238553, -118.50987748137587 36.74473312409826, -118.50887548074502 36.74474512434907, -118.50854248065907 36.744749124432296, -118.50837848062754 36.744751124113854, -118.5070224808927 36.74475012427308, -118.5024624797969 36.744750124992926, -118.50094248003053 36.744750124992926, -118.50094248003053 36.74478212421091, -118.49916347979806 36.74477312492705, -118.49382647910063 36.74474812531128, -118.4921284781607 36.74474112498554, -118.49204847869306 36.74474112498554, -118.4762964761686 + 36.74467112673142, -118.47601347541305 36.744580126674215, -118.47588347571673 36.74453912662103, -118.47574047560158 36.74457212648999, -118.47535147543915 36.74466412639864, -118.47396847504181 36.74466112687277, -118.47314447468621 36.744658126626994, -118.46201247353196 36.744687127078386, -118.44236946955827 36.74473812906182, -118.4304824685437 36.74472312928652, -118.41126746419222 36.74464213131282, -118.4097724645402 36.74463613081967, -118.40819446414916 36.744630131045895, -118.40808046434633 36.74467613097923, -118.40794246425409 36.74482113093293, -118.39699646197437 36.74479913229301, -118.39429146217735 36.74479013229131, -118.39019946088669 36.744779132607775, -118.38995646121246 36.74478013172838, -118.38963146152246 36.744779132607775, -118.38924446100985 36.744778132767365, -118.38902446090181 36.74478013172838, -118.38904746046802 36.74474813250957, -118.38900846010996 36.74477713292689, -118.3889944607646 36.744778132767365, -118.38797546041529 36.74477513252617, + -118.38797546041529 36.74475513283468, -118.38591746052356 36.74476713236228, -118.38576346053779 36.74477013260379, -118.38379846053247 36.74476513268108, -118.37790645909294 36.74475013291101, -118.37594245801425 36.744746132827885, -118.37287345816915 36.74474313330527, -118.3717574577355 36.74474213346434, -118.36757245745675 36.74473813410065, -118.3658004577953 36.744736134418694, -118.36366645683728 36.74475313387302, -118.36059845591876 36.74477813492684, -118.36014145598426 36.74413913417075, -118.35994145596774 36.743939134644364, -118.35934145591811 36.74363913401768, -118.3584414553945 36.743239134717086, -118.35794145580233 36.742939134233424, -118.3578414553449 36.74283913405154, -118.35674145480478 36.74223913454326, -118.35574145472204 36.742139134169236, -118.35444145506371 36.742139134169236, -118.35344145408268 36.74153913494746, -118.35244145489827 36.74113913406025, -118.35134145435816 36.74103913441252, -118.35094145342674 36.74073913468773, -118.35034145337713 + 36.739739134335146, -118.3497414533275 36.739239135032314, -118.34934145419275 36.738539134711914, -118.34894145326133 36.73733913432614, -118.3482414536526 36.73663913467675, -118.34754145314555 36.73593913440352, -118.34664145262198 36.73483913434514, -118.34584145255579 36.73393913383107, -118.34474145201565 36.73283913463335, -118.34344145145901 36.731439134720986, -118.3424414513763 36.730439134170666, -118.34114145171792 36.72973913424381, -118.33964145114471 36.72903913441527, -118.33904145109508 36.727839133851084, -118.3391414506542 36.72623913392808, -118.33884145018023 36.7247391339198, -118.33864145016369 36.72303913343809, -118.33784145009753 36.72173913385545, -118.33744145006445 36.72103913382841, -118.33694144957393 36.719939133446466, -118.3367414495574 36.718739133261835, -118.33644144908342 36.71753913305289, -118.33604144905034 36.716339132820764, -118.3358404492089 36.71503913361958, -118.33574044875148 36.71393913301148, -118.33564044829401 36.71293913286701, + -118.33574044875148 36.7115391324027, -118.33594044876799 36.70983913216334, -118.33554044783659 36.709039132168805, -118.33494044778698 36.70823913176734, -118.33504044824443 36.707139132470196, -118.33494044778698 36.70653913197847, -118.33474044777046 36.70553913203201, -118.33474044777046 36.705339132065696, -118.33504044824443 36.70513913157892, -118.33554044783659 36.704439131538116, -118.3365404479193 36.703839131572984, -118.33734044798545 36.70333913114491, -118.33784044757768 36.70293913148541, -118.33874044810128 36.70243913168444, -118.33984044864141 36.702139131683076, -118.3413404483163 36.70213913096283, -118.34174044834938 36.701839130510756, -118.3424404479581 36.701139130664195, -118.34294044844863 36.70063913139809, -118.34344044893915 36.70003913129867, -118.34394044853131 36.69933913090537, -118.34484044815659 36.69873913002206, -118.3457404486802 36.697839130720176, -118.34624044917072 36.6970391311383, -118.34674044876292 36.69653913041333, -118.34824044933612 + 36.695939130004625, -118.34994044902757 36.69533912995524, -118.35174045007474 36.69483912969854, -118.35414044937492 36.694739129545134, -118.35574044950728 36.69503912961522, -118.35684045004739 36.695539129291525, -118.3588404502128 36.695639129124686, -118.36104045129306 36.695139129378425, -118.36244045140884 36.69463912926159, -118.36394045198206 36.69423912898781, -118.36454045113337 36.69383912951419, -118.36484045160734 36.69303912936604, -118.36574045213095 36.692240128621734, -118.36626545183749 36.691715128787266, -118.36634045218055 36.691640127797946, -118.36644045173969 36.691240128489476, -118.36624045172312 36.690840127820465, -118.36614045126572 36.690440128672385, -118.3656404516735 36.68984012893017, -118.36504045162387 36.68894012846258, -118.36424045155773 36.68794012791526, -118.36344045149156 36.68744012852809, -118.36214045003656 36.687040128262566, -118.36114044995388 36.686539128188215, -118.36104045039477 36.68603912833684, -118.3599404498546 36.68563912798816, + -118.35914044978844 36.68563912798816, -118.35784045013008 36.68553912811624, -118.35644044911598 36.68443912886773, -118.35564044904982 36.683939128330834, -118.35444044895057 36.68403912812193, -118.35334044841045 36.683939128330834, -118.35204044785377 36.68293912903233, -118.35144044780415 36.681639128229506, -118.35114044733018 36.680839127953654, -118.3502404477049 36.67963912886979, -118.34984044767182 36.679239128377255, -118.3492404476222 36.67853912809404, -118.34883944686591 36.67773912799871, -118.34823944681625 36.677239128577604, -118.34753944720752 36.676039128091915, -118.3475394463092 36.6745391280017, -118.34773944722409 36.673239127900395, -118.3475394463092 36.672339127710806, -118.34693944625958 36.672139127840225, -118.34633944620995 36.672339127710806, -118.34533944612726 36.67253912778206, -118.34360544610063 36.67265912843992, -118.3419344449249 36.672610127707586, -118.33993944568066 36.67273912877449, -118.3334394437956 36.67143912852246, -118.33213944413721 + 36.67043912884674, -118.33123944361364 36.66943912842464, -118.33123944361364 36.66813912790316, -118.33013944307352 36.66763912806287, -118.32933944300736 36.66673912794626, -118.32936544294661 36.666571128747535, -118.32953944302389 36.66543912865222, -118.32993944215866 36.66483912844517, -118.33083944268225 36.66393912801149, -118.33183944276496 36.663239128325, -118.33283944284766 36.66263912754156, -118.33373944337124 36.66213912727932, -118.33493944347049 36.66183912786891, -118.33563944307923 36.6616391273722, -118.3359394435532 36.66043912717119, -118.33613944267142 36.65973912736278, -118.33683944407679 36.65953912645432, -118.33753944368553 36.65893912709689, -118.33763944324464 36.65783912729932, -118.33773944280375 36.65683912675062, -118.3379394428203 36.65603912661636, -118.33823944329428 36.65543912664559, -118.33813944283683 36.6552391268174, -118.3379394428203 36.65483912632307, -118.33773944280375 36.65483912632307, -118.33759144266574 36.65480612682551, -118.33683944317848 + 36.65463912709831, -118.33683944317848 36.65403912630698, -118.33683944317848 36.653539126400034, -118.33643944224708 36.653039126849706, -118.33623944223054 36.652939126405904, -118.33603944311231 36.653139127163634, -118.33573944263834 36.652839126552884, -118.33493944257218 36.65263912645728, -118.33443944208167 36.6523391267812, -118.3335394424564 36.65183912664566, -118.33273944149191 36.65123912637748, -118.33143944093524 36.65043912643522, -118.3302384410111 36.6496391268305, -118.32913844047094 36.6488391268431, -118.32813844128658 36.648039126473236, -118.32703843984811 36.64753912652657, -118.3260384397654 36.64733912635977, -118.32523843969923 36.64713912639429, -118.32493844012359 36.64673912706745, -118.32483843966615 36.64633912638397, -118.32433844007396 36.64573912650878, -118.32403843959999 36.64563912667582, -118.32373843912602 36.64523912676623, -118.32333843999126 36.64443912648121, -118.32293843905985 36.64333912639693, -118.32263843948421 36.642239126461, -118.3222384385528 + 36.641439126732315, -118.32153843894407 36.640639126624215, -118.32103843845356 36.640039125596026, -118.32023843838739 36.63973912657313, -118.31943843832123 36.63943912638199, -118.31883843827161 36.6389391268311, -118.31893843872903 36.63843912619755, -118.31903843738984 36.638339126978906, -118.319838437456 36.637839126055454, -118.32033843884483 36.63723912628644, -118.32073843797959 36.63513912598001, -118.32043843840394 36.63393912585176, -118.32023843748908 36.63313912571619, -118.3203384379465 36.63243912572269, -118.32043843840394 36.63153912503799, -118.32053843706474 36.63093912522988, -118.32073843708127 36.63013912494722, -118.32073843708127 36.62913912517068, -118.32103843755525 36.62803912506402, -118.3213384371309 36.6274391246801, -118.3208384375387 36.62703912471289, -118.32013843703166 36.62683912467173, -118.31903843649152 36.62673912481696, -118.31773843683317 36.626439125195205, -118.31653843673392 36.625939124912925, -118.31523843617724 36.62573912562204, -118.31443843611105 + 36.62563912578197, -118.3132384360118 36.62553912509129, -118.31243843594567 36.62533912548343, -118.31103843582989 36.62493912543181, -118.31063843489848 36.62423912593402, -118.31063743507356 36.62313912510587, -118.31033743549791 36.62223912540238, -118.30933743451689 36.62143912493552, -118.30863743490815 36.62093912538457, -118.30753743436803 36.62043912475364, -118.30673743430187 36.62033912596869, -118.30573743332084 36.62033912596869, -118.30423743364594 36.61973912476696, -118.30283743353013 36.619039124913336, -118.30193743300656 36.61813912492952, -118.3021374330231 36.61673912514199, -118.3026374326153 36.61543912539242, -118.30243743349708 36.61463912502417, -118.30163743253259 36.61373912487345, -118.3012374324995 36.61283912503412, -118.30103743158466 36.61253912539731, -118.30033743197592 36.61223912459336, -118.29963743146887 36.61183912434989, -118.2988374314027 36.61163912525279, -118.29793743177743 36.61113912488023, -118.29783743221832 36.61003912534048, -118.29823743135309 + 36.60923912519457, -118.2977374317609 36.6087391244047, -118.29713743081295 36.60793912447484, -118.29703743125384 36.60733912412963, -118.29573743069716 36.607039124730576, -118.29453743059791 36.606439124595184, -118.29433743058138 36.605539123933696, -118.29393743054827 36.60513912427518, -118.29343743005776 36.60483912425176, -118.29233743041597 36.604439124568884, -118.29083742894444 36.60373912500047, -118.28903742969388 36.60333912466153, -118.28753742912068 36.60333912466153, -118.28593742898832 36.60333912466153, -118.28423742839855 36.60373912500047, -118.28273742872369 36.604439125290035, -118.28163742818353 36.6045391252249, -118.28073742765994 36.603839125842875, -118.28043742808428 36.602739125312134, -118.28023642734453 36.602139125622955, -118.27973642775231 36.601939125170254, -118.27923642726182 36.60153912559404, -118.2753364264901 36.597839124949274, -118.27463642598305 36.59733912526717, -118.27493642645705 36.596639124740236, -118.27543642604921 36.59563912461367, + -118.27603642609883 36.594839124412886, -118.27653642658935 36.59433912476357, -118.27683642616502 36.59383912403813, -118.27713642663899 36.59323912437256, -118.27803642626426 36.5923391240608, -118.27913642590609 36.591939124479865, -118.28033642690362 36.59163912361389, -118.28173642701941 36.591339124466685, -118.28313642713522 36.59083912377734, -118.28463642681011 36.59063912288306, -118.28623642694242 36.590539123684124, -118.28783642797309 36.59063912288306, -118.28903642807234 36.59073912339499, -118.28923642808884 36.590239123146006, -118.28913642852976 36.588939123206714, -118.28933642764798 36.58843912283685, -118.28923642808884 36.58803912323822, -118.28873642759837 36.587239122871004, -118.28853642758182 36.58623912283347, -118.28823642800617 36.585639123228376, -118.28783642707477 36.585039123287345, -118.2879364275322 36.58413912328719, -118.28803642709131 36.58373912232334, -118.28873642670004 36.58314012254031, -118.2894364272071 36.58234012261384, -118.29053642774723 + 36.58154012233187, -118.29133642781339 36.58084012204741, -118.29203642742213 36.580240122316745, -118.2928364274883 36.57994012214552, -118.29303642750483 36.57924012178135, -118.29253642701433 36.57904012185133, -118.29223642743867 36.57884012212456, -118.29243642745521 36.578540122284416, -118.29293642794572 36.578540122284416, -118.29343642753791 36.57814012116522, -118.29413642714665 36.57744012107351, -118.29413642714665 36.57634012150489, -118.29343642753791 36.57574012143116, -118.2923364278961 36.57544012181024, -118.29183642650727 36.57504012122398, -118.29193642696467 36.574640121451374, -118.29123642645764 36.57434012116393, -118.29053642684892 36.574440121148726, -118.29093642598367 36.57404012187525, -118.29063642730634 36.57364012125126, -118.28983642634186 36.573240122162446, -118.29023642637495 36.57244012137644, -118.29003642725672 36.57224012114754, -118.28983642634186 36.57214012119961, -118.29073642686544 36.57174012155578, -118.29133642691508 36.571340121283086, + -118.29173642604984 36.57094012182445, -118.29183642650727 36.5706401208709, -118.29185642659874 36.57048012089584, -118.29193642606639 36.56984012128847, -118.29203642652381 36.56954012039084, -118.29243642565858 36.56904012063412, -118.29243642565858 36.56874012023649, -118.292536426116 36.56834012102006, -118.29233642699778 36.56794012045359, -118.29263642657344 36.56744012043991, -118.29263642567511 36.5668401204801, -118.29233542627455 36.56654012019536, -118.29223542671544 36.56634012056074, -118.2923314260766 36.56610012046001, -118.29243542673197 36.565840120290915, -118.29233542627455 36.565540121172916, -118.2916354266658 36.565040120775336, -118.29133542619184 36.56474012071491, -118.29153542620838 36.56414011998494, -118.29193542534314 36.563340120180165, -118.292135426258 36.56274012012086, -118.29203542580058 36.56224012035989, -118.2916354257675 36.561540120453145, -118.2916354257675 36.56114012002953, -118.2916354257675 36.56054012020114, -118.2912354248361 36.55934011955357, + -118.29071842552548 36.55875012039806, -118.29053542522736 36.55854011983161, -118.28953542424634 36.55804011958731, -118.28823542458801 36.55764012052585, -118.2873354240644 36.557140119508645, -118.28623542352426 36.55664012030714, -118.28483542340847 36.556440120297886, -118.28323542327614 36.55614012075661, -118.28223542409175 36.555840120050576, -118.28093542263677 36.55564012013553, -118.27943542296187 36.555140120608755, -118.27853542243828 36.55414012050903, -118.27803542284609 36.55334012034887, -118.27703542186507 36.55284012037295, -118.27613542134146 36.55184012009753, -118.27593542222324 36.55084011987235, -118.27493542214054 36.55054012024045, -118.27393542115952 36.55104011994219, -118.27273542195859 36.55164011993303, -118.27063542043742 36.551740120440776, -118.26943542123651 36.551740120440776, -118.26873542072943 36.55184012081917, -118.26743542017279 36.55184012081917, -118.2665354205475 36.551640120654625, -118.26533542044825 36.55154012073914, -118.26522342029526 + 36.55145112132607, -118.26433541946722 36.55074012036495, -118.26373541941761 36.54974012106168, -118.26343541984194 36.549340120892914, -118.26283441906911 36.54894012081901, -118.26193441944383 36.548340120074286, -118.26123441893678 36.54744012104942, -118.26133441849589 36.54674012129863, -118.26163441896986 36.546040120260606, -118.26123441803846 36.545740120762, -118.2608344189037 36.54494012097781, -118.26023441885407 36.544140120853264, -118.25963441790613 36.543740119851556, -118.25843441780688 36.54334012038873, -118.25693441813196 36.543040120515975, -118.25473441795002 36.54274012092249, -118.25273441688628 36.54244012088664, -118.25203441637926 36.542140120408284, -118.25183441636273 36.541240121371146, -118.25193441682015 36.54014012091063, -118.25183441636273 36.539540121149365, -118.25153441678704 36.53914012016536, -118.25093441673745 36.53905412123525, -118.25083441628001 36.539040119956894, -118.25013441667126 36.53804012086367, -118.24993441665471 36.5377401206347, + -118.24993441575643 36.537140121015675, -118.25043441624693 36.53604011996782, -118.25013441667126 36.5359401200809, -118.24933441570678 36.53574012064088, -118.24953441572332 36.535040119610066, -118.24963441528246 36.53474012073439, -118.24963441528246 36.534540120356176, -118.24903441523281 36.53374012016722, -118.24863441609804 36.53324012044809, -118.24863441609804 36.53304011979966, -118.24863441609804 36.53294011964242, -118.24863441609804 36.53274011966181, -118.25013441577296 36.53094012038221, -118.25053441580604 36.530240119935854, -118.25063441536514 36.52994011976443, -118.25073441582256 36.529440120020915, -118.25093441583911 36.528840119527146, -118.25093441583911 36.528340119169606, -118.25093441583911 36.52564011913943, -118.25033441489117 36.52544011977302, -118.25003441531551 36.525340119174, -118.25003441531551 36.52524011916765, -118.24963341455921 36.524340119068306, -118.24943341544099 36.52404011983662, -118.2490334145096 36.52404011983662, -118.24883341449305 + 36.52404011983662, -118.24773341485124 36.52314011949298, -118.24733341391985 36.52224011950807, -118.24693341388677 36.52154011901941, -118.24673341387022 36.52144011915453, -118.24623341427802 36.52154011901941, -118.2461334138206 36.52164011947701, -118.24593341380405 36.52164011947701, -118.24403341319776 36.5220401192928, -118.24393341363864 36.5220401192928, -118.24263341308195 36.5220401192928, -118.24233341350632 36.52214011982603, -118.24173341255836 36.5220401192928, -118.24163341389759 36.5220401192928, -118.23903341278421 36.52364011953268, -118.23883341276768 36.52364011953268, -118.23873341231024 36.523440119327084, -118.23863341275116 36.52314012021489, -118.23913341324163 36.52134011988234, -118.23903341278421 36.5198401196211, -118.23903341278421 36.518340119157884, -118.23913341234335 36.51674011905323, -118.23913341234335 36.5156401188327, -118.2390334118859 36.51544011960681, -118.2389334123268 36.51434011895697, -118.23893341142846 36.51374011903545, -118.23893341142846 + 36.51344011841327, -118.2390334118859 36.51324011855571, -118.2390334118859 36.51304011890336, -118.23913341234335 36.51284011873418, -118.23863341095449 36.51254011823333, -118.2374334117536 36.51164011841761, -118.2374334117536 36.511540119476784, -118.23783341088836 36.51114011881132, -118.23883341097104 36.51064011886303, -118.23933341146154 36.51004011769405, -118.23943341102068 36.509240117564794, -118.23893341142846 36.50824011877258, -118.23873341141193 36.50804011847475, -118.2381334113623 36.506640118528345, -118.23813341046402 36.50614011767212, -118.23813341046402 36.50584011791881, -118.23833341137887 36.505540117725, -118.23913341144501 36.504040117369556, -118.24033341064596 36.50224011685085, -118.24093341069556 36.50114011779633, -118.24133341072867 36.5002401175808, -118.2414334111861 36.50004011683439, -118.24138341005906 36.49994011662851, -118.24123341116953 36.4998401170156, -118.24093341069556 36.49954011740195, -118.24043341020504 36.498840117394124, -118.23993341061288 + 36.49854011679466, -118.23953340968148 36.497940117163935, -118.23953340968148 36.49764011668843, -118.2392334101058 36.496940116836186, -118.23793240972424 36.49624011715579, -118.23673240962499 36.49564011703603, -118.23593240955883 36.49494011715942, -118.2353324095092 36.49424011745522, -118.23503240903523 36.493740116681536, -118.23583240910139 36.492940116384624, -118.23673240962499 36.49284011639839, -118.23793240882591 36.492240116658394, -118.23853240977382 36.491540116383284, -118.23913240982348 36.49074011647285, -118.23983240943221 36.49044011627202, -118.2405964104117 36.490364115603334, -118.24083241041323 36.49034011666907, -118.2416324104794 36.49024011621479, -118.24253240920636 36.48994011624403, -118.24333241017084 36.48964011583358, -118.24403241067789 36.48924011612705, -118.24483241074405 36.48904011586034, -118.24593241038588 36.48844011557293, -118.24673241045205 36.4881401151309, -118.24773241053474 36.48754011509471, -118.24843241104179 36.487040115848025, + -118.24893241063398 36.48654011481867, -118.24933241066707 36.48594011539053, -118.24953241158192 36.48554011535686, -118.24983241025926 36.48474011559402, -118.25003241117412 36.483540114544795, -118.2499324107167 36.482440114951295, -118.24913241065055 36.48164011495891, -118.2480324092121 36.480940114961314, -118.24703241002769 36.48044011428999, -118.24623240996152 36.480140115372215, -118.24513240942139 36.479840114570635, -118.24493240940485 36.479840114570635, -118.24403240977958 36.479640114594844, -118.2427324092229 36.479340115469014, -118.24163240868276 36.47814011457443, -118.24093240817571 36.47774011544377, -118.24003240855043 36.47734011497077, -118.23903240846774 36.477140114321195, -118.23873240799377 36.477140114321195, -118.23783240747018 36.47714011504348, -118.23703240740402 36.477040115608645, -118.23683240828579 36.47584011521477, -118.23713240696313 36.47534011486385, -118.23723240742055 36.4749401150054, -118.23703240740402 36.47394011535684, -118.23593240686388 + 36.472840114565884, -118.2350324072386 36.47154011484689, -118.23393240580015 36.47034011453614, -118.2330324070732 36.46934011405254, -118.23263240614179 36.468840114391085, -118.23303240617487 36.46754011477114, -118.23283240615834 36.46674011483898, -118.23263240614179 36.46634011394435, -118.23123240602598 36.465240114388216, -118.23083240509462 36.46494011442767, -118.23023240504499 36.46454011436019, -118.22763240482996 36.464540115082656, -118.22753240527085 36.464440114562734, -118.22683240476377 36.46434011463623, -118.22533240419055 36.46394011436303, -118.22503240461492 36.46394011436303, -118.22383240361734 36.46364011515184, -118.22323240356774 36.463340114779875, -118.22253240395898 36.46274011488835, -118.22193240390936 36.46164011410623, -118.2216324034354 36.461140114634524, -118.22133240385973 36.460140113966105, -118.22103240338576 36.45984011450201, -118.2202324033196 36.45944011413367, -118.21853240272985 36.45864011443203, -118.21763240220628 36.4580401142973, + -118.21683240214008 36.45744111453677, -118.21593240161653 36.4567411141841, -118.21563240204084 36.45634111444878, -118.21533240156687 36.45504111478904, -118.21517440138312 36.45385611351432, -118.21513240155033 36.45354111489461, -118.21503240199125 36.452341114179426, -118.21483240107636 36.4505411133419, -118.2145324006024 36.44914111327858, -118.21443240104331 36.44874111409076, -118.21423240102675 36.44824111310886, -118.21373240053623 36.447341113336954, -118.21333240050315 36.44684111344832, -118.21203239994648 36.44604111313846, -118.21133240033774 36.445641113864475, -118.21073240028812 36.44514111313654, -118.2102323997976 36.44484111418815, -118.20993240022196 36.44434111336306, -118.20973239930713 36.443041113481655, -118.20963239884966 36.44214111336762, -118.21013239934018 36.441441112967254, -118.21043239891583 36.44094111335758, -118.21053239937326 36.440341112897556, -118.21043239891583 36.439541112776205, -118.21043239891583 36.438941113054355, -118.21103239986377 + 36.43814111295418, -118.21153239945596 36.43764111303986, -118.21213239950562 36.43704111308219, -118.21283239911432 36.43684111254728, -118.21343240006226 36.43684111254728, -118.21433239968754 36.43674111244788, -118.2146324001615 36.43664111221968, -118.21493239973717 36.43604111248074, -118.21483240017805 36.435041112003475, -118.21443239924665 36.434341111880954, -118.21393239965445 36.43394111228038, -118.21333239960487 36.433741112429836, -118.2126323999961 36.43354111206391, -118.2122323990647 36.43304111214684, -118.21183239903165 36.43224111266319, -118.2113323985411 36.43124111237458, -118.20983139814297 36.43034111201405, -118.20943139900824 36.43044111240877, -118.20853139848464 36.43064111208905, -118.2075313975036 36.43064111281177, -118.2060313978287 36.43074111282003, -118.20583139781218 36.43094111172738, -118.20563139779561 36.43104111207195, -118.20533139732167 36.43124111237458, -118.20373139718932 36.43124111237458, -118.19953139594364 36.430141112283685, -118.19933139682541 + 36.430141112283685, -118.19923139636799 36.42994111276079, -118.1987313958775 36.42944111278337, -118.19853139675925 36.429141113130385, -118.19783139625223 36.42824111299778, -118.19743139621912 36.428041112916596, -118.1954313960537 36.42784111232024, -118.19423139505615 36.427241112499274, -118.19363139500653 36.42654111287085, -118.19293139539776 36.42674111320217, -118.19093139433407 36.42754111298938, -118.19013139426791 36.42774111363584, -118.18973139513314 36.42774111363584, -118.18803139454339 36.42744111319577, -118.18723139447722 36.42724111322208, -118.18563139344657 36.42674111320217, -118.1847313938213 36.42664111382369, -118.18463139336384 36.42664111382369, -118.18443139424565 36.42664111382369, -118.18293139367243 36.42724111322208, -118.18173139357319 36.42754111371219, -118.18123139308268 36.427441113918576, -118.18053139347394 36.42754111371219, -118.17943139293381 36.427641114099785, -118.17833139329197 36.427641114099785, -118.17686239268092 36.42731511364824, + -118.17653139224478 36.42724111394488, -118.17553139216207 36.42674111392498, -118.17493139211248 36.426541114316414, -118.17483139255334 36.42674111392498, -118.17403139248721 36.42724111394488, -118.17293139194705 36.42784111376584, -118.1712313922556 36.42884111448644, -118.17113139179818 36.429041114674924, -118.17093139178164 36.429041114674924, -118.16983139124153 36.42934111399156, -118.16833139066829 36.42954111433747, -118.16673139053596 36.42964111504002, -118.16561139080267 36.42968611484429, -118.16423139077835 36.42974111489091, -118.16343139071219 36.43044111529983, -118.16303139067911 36.43084111559057, -118.16273139020514 36.431141115178676, -118.1624313906295 36.43124111526559, -118.1605313900232 36.43184111525007, -118.15833138984124 36.432441116378726, -118.15703139018288 36.432641115965254, -118.15683138926804 36.432641115965254, -118.15653138969238 36.432441116378726, -118.15623139011672 36.43174111521332, -118.15593138874443 36.43034111562788, -118.15563138916879 + 36.429441114951736, -118.1548313891026 36.428841115209174, -118.15463138908608 36.4276411155454, -118.15423138815466 36.427141115210425, -118.15333138852938 36.42734111544172, -118.1525313875649 36.426741115370554, -118.1519313875153 36.425541115051, -118.15163138793963 36.4251411156416, -118.15053138739951 36.425241115506466, -118.14923138774114 36.4253411159653, -118.1486313867932 36.42524111622929, -118.14753138715137 36.42464111510841, -118.14687038700241 36.42398811544296, -118.14623138749305 36.423341115567375, -118.14543138742685 36.422341114843725, -118.14443138644583 36.42124111587102, -118.14423138642933 36.42124111587102, -118.1433313859057 36.42124111587102, -118.14123138618118 36.42094111561544, -118.1398313860654 36.41984111632829, -118.13883138508437 36.41894111609942, -118.13793138456079 36.41804111556072, -118.13783138500168 36.417541116296874, -118.13793138456079 36.41624111500148, -118.13744738486211 36.415370115782004, -118.13743138496861 36.415341114981835, -118.13749038451982 + 36.4149851156684, -118.1375313845277 36.414741115440165, -118.1385313846104 36.41284111523156, -118.13873138462695 36.411841115212695, -118.13903138510092 36.41084111461051, -118.1390313842026 36.40924111477775, -118.1390313842026 36.40854111483612, -118.13943138423568 36.40664111408334, -118.13983138426876 36.405041114511604, -118.14033138386095 36.40354111341316, -118.14153138485852 36.402341113954826, -118.14263138360202 36.4016411140278, -118.14313138499087 36.40114111404117, -118.14413138417525 36.40014111381619, -118.14473138422488 36.39964111285506, -118.14623138479807 36.3986411135741, -118.14753138445646 36.39844111318612, -118.14793138448951 36.39834111279913, -118.14883138501312 36.39784111327249, -118.150631385162 36.397441112202905, -118.15143138522816 36.397341111975344, -118.15213138483689 36.39674111224667, -118.15293138580138 36.39664111184157, -118.15393138588408 36.39684111252313, -118.15443138547627 36.39654111203088, -118.15613138606601 36.395941111911114, -118.15763138574093 + 36.395241112184216, -118.16003138683773 36.393841111895796, -118.16083138600558 36.392641110896676, -118.16103138692044 36.39174111154902, -118.16303138618753 36.38964111087663, -118.16293138662841 36.38944111047111, -118.16203138610483 36.38944111047111, -118.159931385482 36.389241111720565, -118.15813138533312 36.388941110822195, -118.15713138525042 36.38834111133776, -118.15613038534279 36.387341111308416, -118.1557303844114 36.386141111217874, -118.15533038437832 36.385141111097035, -118.15453038431215 36.383641111390595, -118.15413038427907 36.382541110476275, -118.15403038471996 36.381641110646505, -118.15353038333113 36.3802411106192, -118.15313038419636 36.37954111055657, -118.15213038321534 36.3792411112873, -118.15023038260905 36.37974111111405, -118.1494303834412 36.37934111020797, -118.14903038340812 36.3792411112873, -118.14783038330884 36.37944111044656, -118.14733038281837 36.379141111514805, -118.14723038236093 36.378741111139036, -118.14663038231132 36.37814111105759, + -118.14553038177118 36.377841110727594, -118.14413038165539 36.37704111045873, -118.143130382471 36.375941110938015, -118.14251538145446 36.37494111056466, -118.14153038144036 36.37334111085666, -118.14153038144036 36.37324111073148, -118.14133038142381 36.37284111111534, -118.14123038096639 36.37194111042612, -118.14103038094984 36.37164111005251, -118.13973038039317 36.37134111069187, -118.13823038071828 36.371641110775776, -118.13613038009544 36.37154111063525, -118.13533038002925 36.37114111095765, -118.13373037989695 36.370941110709275, -118.1311303787836 36.37074111067002, -118.13053037963226 36.37034111121908, -118.1307303787505 36.36904211056672, -118.1306303791914 36.3680421112004, -118.13043037917487 36.36714211069153, -118.12993037778604 36.36584211118885, -118.12923037817728 36.36464211067711, -118.12913037771985 36.36444211035999, -118.1279303776206 36.36354211089058, -118.12683037708047 36.3626421111396, -118.12623037703085 36.36164211054191, -118.12593037745519 36.361117110527864, + -118.12543037696472 36.36024211083284, -118.12463037689852 36.35874211058453, -118.12413037640802 36.357642109873915, -118.12333037634185 36.35654210953321, -118.12273037629225 36.35524211020059, -118.12253037627569 36.3547421106309, -118.1226303758348 36.35444210978155, -118.12323037588442 36.353342110521865, -118.12343037590097 36.3528421096005, -118.12423037596713 36.35194210911007, -118.12363037591753 36.35164210976959, -118.12253037537737 36.35094210994796, -118.12083037568593 36.350242110344006, -118.11923037465532 36.34994211024082, -118.11863037460566 36.349842109708334, -118.11743037450641 36.349642109705165, -118.11703037447333 36.34954210951089, -118.11623037440717 36.34934211018409, -118.11513037386703 36.34934211018409, -118.11363037419214 36.34924211032803, -118.112530373652 36.348842109619426, -118.1120303731615 36.348842109619426, -118.11183037404327 36.34894210998923, -118.11153037356931 36.349042110230606, -118.11073037350312 36.34894210998923, -118.11003037299609 + 36.348642110665054, -118.10843037286377 36.34804210999603, -118.10693037318883 36.34814210994629, -118.10543037261561 36.34824211049161, -118.10423037161809 36.347842111157355, -118.10242937254262 36.34744211049173, -118.10082937151196 36.34674211053239, -118.10032937102143 36.346142111140225, -118.10082937151196 36.345342110547364, -118.1010293706302 36.34484211034451, -118.1014293715616 36.34324210968367, -118.1014293715616 36.34214210992295, -118.10132937110416 36.34104210982054, -118.10182937069636 36.33934210969182, -118.10162937157813 36.33884211004126, -118.10142937066328 36.33794210981791, -118.10142937066328 36.33714210965303, -118.10132937020585 36.3366421096289, -118.10073037087943 36.335364109118764, -118.09982937053095 36.333442109025285, -118.09982937053095 36.333342109014104, -118.09952937005698 36.3329421098566, -118.09832936995774 36.33274210914587, -118.0982293695003 36.33254210936906, -118.09782936946722 36.332142109722454, -118.09762936945069 36.331142109581386, + -118.09770336907053 36.33110410941117, -118.0997563698377 36.33057610927994, -118.10000436953483 36.32963510844938, -118.10012837028171 36.328828109372516, -118.1007483695245 36.328425108233326, -118.10207036982248 36.32802410898351, -118.10322737026401 36.327051108890146, -118.10306236950926 36.325402108051854, -118.10446736964789 36.32567410785514, -118.10554137024879 36.32537310843459, -118.10678037070609 36.325779107884365, -118.10756537070364 36.325612107254436, -118.1085983703983 36.32467210821314, -118.10941737073104 36.32386910787447, -118.1099293709171 36.32374210739662, -118.11152937104946 36.322642108018975, -118.11202937064162 36.32234210727061, -118.11252937113214 36.32204210753875, -118.11282937070779 36.32064210654934, -118.11372937123139 36.32004210659539, -118.11402937080706 36.319542107568836, -118.11417637112015 36.31926610691757, -118.11343237023218 36.316978106885145, -118.11215237066527 36.31663910718843, -118.11165636947442 36.31556210653065, -118.11198737080883 + 36.314990106810534, -118.11256537021883 36.31334310702761, -118.11202836991839 36.31219810591582, -118.11042936871267 36.309842105779786, -118.11182936972679 36.308342106219314, -118.11356836977627 36.30751810575004, -118.11372936943478 36.30744210551768, -118.11458936977539 36.306989105358355, -118.11842237059993 36.307674104851976, -118.11852937073007 36.30764210528438, -118.11952937081278 36.307342104718046, -118.12042937133634 36.307342104718046, -118.12162937143559 36.30684210531155, -118.12122937140253 36.3059421046713, -118.12052936999714 36.30504210450285, -118.11986937057141 36.30364110466242, -118.11972937082932 36.303343104895355, -118.12012936996409 36.30224310506947, -118.12112937094511 36.30114310420841, -118.12202937057039 36.301343104926545, -118.12422937075233 36.30134310420262, -118.1259293713421 36.30106810407836, -118.12665237141535 36.300686104439826, -118.12702037166137 36.30022810435523, -118.12704037175288 36.29975710397039, -118.12682937096737 36.299248103863015, + -118.12653437141458 36.298960103676464, -118.1259293713421 36.29867510408906, -118.12570937123405 36.29780410379126, -118.12586937106764 36.29708410320638, -118.1259293713421 36.2962651032768, -118.12584137058022 36.29503110422804, -118.12551337051717 36.29434610374021, -118.12540737021195 36.29347710341062, -118.12545637061574 36.29287810355604, -118.12565637063229 36.29214210377653, -118.12578336995558 36.291665102836056, -118.12564236949025 36.29112010324839, -118.12537336987675 36.29050810352354, -118.1250593700574 36.28955610253589, -118.12519436977661 36.288543102532024, -118.12509037001955 36.28750410310827, -118.12503237029324 36.28674910292411, -118.12529636988386 36.28575510260749, -118.12552736896424 36.284970102711895, -118.12592937044377 36.2852821031796, -118.126566369405 36.28476310268112, -118.12683836939158 36.284460103146706, -118.12707236974327 36.28423810226073, -118.1272143700335 36.283936102879494, -118.12735536960051 36.283537102216776, -118.12736737019438 36.28275810266452, + -118.12749736989073 36.28233410223498, -118.12749636916749 36.281815101683904, -118.12760337019594 36.28125410196624, -118.12762036991437 36.28066210140002, -118.12761336934336 36.280353101486185, -118.12734336900664 36.279878102488325, -118.12715936978192 36.27966110197359, -118.12658536967156 36.27938510210585, -118.12613636923469 36.27933310240982, -118.12592936954546 36.27932010211835, -118.12491236884601 36.279054101861696, -118.1239633679186 36.27861510172902, -118.12198136819485 36.277704101662366, -118.12132036804586 36.276525102145904, -118.12037036819187 36.27470710181074, -118.11975136697735 36.273092102340904, -118.11863636726693 36.27171110151098, -118.11739736680963 36.27117010163691, -118.11723236695319 36.26945510131471, -118.11706736619844 36.26750310125533, -118.11723236695319 36.26572110129191, -118.11789336620384 36.264376101496474, -118.11760436560053 36.263030101429266, -118.11805836606031 36.26225710038592, -118.1186363663686 36.261148100197616, -118.11929636579437 + 36.259602100292476, -118.11937936563506 36.25835810046993, -118.11929636579437 36.25697810021501, -118.11937836581014 36.25556609956945, -118.11847036489063 36.25411810026687, -118.11801636443089 36.252805100223995, -118.11739736501299 36.25179509983213, -118.11690236454535 36.25095309988974, -118.11558036424736 36.24994310018972, -118.11562836482626 36.2497430995972, -118.1154283639114 36.249643099471214, -118.1153283643523 36.24954309921725, -118.11502836477663 36.24904309965014, -118.1144283638287 36.2478430992276, -118.11412836425305 36.24704309909282, -118.11402836379561 36.2460430995429, -118.11412836425305 36.2458430991117, -118.11422836381216 36.24504309951367, -118.11412836335472 36.24404409881128, -118.11352836330511 36.24284409915107, -118.11292836325548 36.24154409883421, -118.11222836274843 36.24024409863216, -118.11222836274843 36.2400440985771, -118.11192836317278 36.23984409873484, -118.11092836309007 36.239244098311445, -118.11042836170125 36.23864409907921, -118.10932836205943 + 36.237744098841766, -118.1086283624507 36.23734409919419, -118.10802836240107 36.23724409914366, -118.10782836238451 36.236844098386946, -118.10692836186094 36.235944098439795, -118.10642836137043 36.23514409824237, -118.10522836127119 36.233644098361744, -118.10512836081375 36.233144098527944, -118.10522836037285 36.23234409866478, -118.10552836084682 36.23124409825967, -118.10552836084682 36.23064409777456, -118.10512836081375 36.22984409769561, -118.10492835989888 36.22934409820028, -118.10522836037285 36.22864409817374, -118.10582836042249 36.22824409762278, -118.10702836142002 36.228344097589975, -118.1079283610453 36.22814409752766, -118.10862836155238 36.227644098032705, -118.10912836114457 36.22674409726306, -118.10922836070368 36.22554409800094, -118.10862836065407 36.223844097120065, -118.10852836109495 36.22344409665836, -118.10822836062098 36.22324409783453, -118.1079283610453 36.2230440970499, -118.1074283596565 36.22214409697401, -118.10762836057134 36.221344096909654, + -118.1078283605879 36.220844097425235, -118.10802836060444 36.220044097110545, -118.10762835967304 36.21894409690786, -118.10732835919907 36.217844097182166, -118.1069283600643 36.21614409709658, -118.1069283600643 36.21604409675123, -118.10632836001469 36.21464409661477, -118.10562835950763 36.2134440965427, -118.10502835855966 36.212544096188985, -118.104909358734 36.21236509711603, -118.10422835849353 36.21134409652672, -118.10362835844391 36.210444095938044, -118.10292835793686 36.20984409680156, -118.10192835785415 36.20884409623264, -118.10132835780453 36.20854409581718, -118.10112835778797 36.20844409663116, -118.10052835773837 36.207944095885175, -118.09982835723132 36.2077440958521, -118.09892835760604 36.20714409631037, -118.09812835664154 36.206544096517845, -118.09752835749023 36.20624409598396, -118.09642835605177 36.20594409647475, -118.09462835680122 36.20494409608787, -118.09462835680122 36.20484409592636, -118.0945283563438 36.20464409594495, -118.0945283563438 36.20444409617736, + -118.09402835585328 36.20374409659063, -118.09332735552134 36.20284509664365, -118.09262735591261 36.20184509580777, -118.09202735586298 36.201145096175345, -118.09172735538901 36.19994509570502, -118.09142735581337 36.19924509575566, -118.09132735535593 36.1986450955811, -118.09082735576374 36.19774509539636, -118.09072735530629 36.19734509634285, -118.08592735401099 36.19754509540012, -118.0858273544519 36.19744509666029, -118.08572735399447 36.19734509634285, -118.08522735440228 36.196945095970655, -118.08492735392831 36.19664509634396, -118.08422735342126 36.19614509561989, -118.08332735379598 36.19564509605246, -118.08312735288109 36.19554509633576, -118.0830273542203 36.195345095794316, -118.08272735374634 36.195345095794316, -118.0825273528315 36.195345095794316, -118.08202735323928 36.195345095794316, -118.0815273527488 36.19544509576645, -118.08142735318965 36.195345095794316, -118.08112735271568 36.19514509691682, -118.07992735261644 36.194245096371134, -118.07912735255029 + 36.19354509620556, -118.07852735250066 36.19294509584701, -118.07762735197707 36.192045096838676, -118.07712735238488 36.191545095739365, -118.07652735233528 36.19114509618624, -118.07512735221947 36.190345096025496, -118.07452735127156 36.19004509576134, -118.07372735120536 36.189445096135394, -118.07312735115573 36.18884509626262, -118.07242735064868 36.1879450965348, -118.07172735103995 36.186545096218545, -118.07132735010855 36.18524509547572, -118.07122735054944 36.18424509533659, -118.07082734961804 36.182445095832236, -118.07062734960151 36.1812450951779, -118.07022734956843 36.180145095650886, -118.07002734955188 36.17904509518039, -118.07002734955188 36.178845094556145, -118.07002734955188 36.178645095596615, -118.0696273495188 36.178545095200256, -118.06902734857086 36.17814509523899, -118.06852734897866 36.177545095093485, -118.06832734896213 36.17744509474336, -118.06712734886288 36.17714509582769, -118.06592734876364 36.176645095323934, -118.06502734913836 36.175745094642956, + -118.0642273490722 36.17524509535704, -118.06312734853206 36.174845094501215, -118.06202734709362 36.17424509519, -118.06162734795885 36.173945095624404, -118.0609273474518 36.173245095313774, -118.06032734740216 36.17234509546393, -118.06022734784305 36.17174509441425, -118.05982734691166 36.170845095466454, -118.05932734731947 36.170145094295854, -118.06012734738563 36.1694450948499, -118.06032734740216 36.16904509484572, -118.0605273465204 36.1684450953628, -118.0605273465204 36.167545094778006, -118.06002634710327 36.16674509436533, -118.05982634618843 36.166245094221516, -118.05952634661277 36.16564509433512, -118.05952634661277 36.16544509431935, -118.05992634664585 36.165345094482696, -118.06032634667893 36.165045094207315, -118.06102634718599 36.164145093744345, -118.06142634632076 36.16314509335914, -118.06262634731831 36.16224509428965, -118.06262634731831 36.16204509430195, -118.06232634684434 36.1615450931884, -118.06082634627113 36.160745093186634, -118.06002634620496 + 36.1598460942806, -118.05932634569791 36.15874609390107, -118.0592263461388 36.15834609442025, -118.05902634522394 36.15784609365007, -118.05882634520741 36.15694609322229, -118.05882634520741 36.15564609386922, -118.05882634520741 36.15374609298088, -118.05892634476652 36.1529460931881, -118.05942634525705 36.15174609342895, -118.05972634483268 36.15084609262109, -118.06042634444141 36.14974609292094, -118.06092634493193 36.14944609316066, -118.06352634514695 36.14804609264703, -118.06402634563747 36.147746092187255, -118.06462634568709 36.14754609221051, -118.06492634616106 36.1476460922626, -118.06522634573672 36.147746092187255, -118.06542634575328 36.147746092187255, -118.06632634627687 36.14704609221357, -118.06712634634303 36.146246092262665, -118.06712634634303 36.14614609187647, -118.06812634642574 36.14534609145129, -118.06872634647536 36.14514609115823, -118.07032634660766 36.14444609119487, -118.07112634667385 36.14374609151381, -118.07152634580862 36.14334609168965, -118.07192634674001 + 36.142546091726, -118.07216034619343 36.14226509124456, -118.0729263459244 36.14134609099301, -118.07352634687234 36.1403460914745, -118.07362634643142 36.13904609045618, -118.07352634687234 36.13844609064557, -118.07282634636529 36.13724609031838, -118.0721263449599 36.135846090440765, -118.0716263453677 36.135146090203854, -118.07158834573289 36.135051090364186, -118.07153134583149 36.13490908990059, -118.07142634535118 36.134646090979146, -118.07122634533462 36.13414609001965, -118.0710263453181 36.133746090585824, -118.07104934578261 36.13346809053656, -118.07112634577554 36.13254608948306, -118.07112634577554 36.13184608955609, -118.07142634445287 36.13124608983168, -118.0716263453677 36.130546089918205, -118.07162634446942 36.12974609004231, -118.07162634446942 36.12924609051373, -118.07092634486065 36.12864608977723, -118.07032634481104 36.12784608939988, -118.0697263447614 36.127146089636874, -118.06922634427093 36.126646089508405, -118.0683263437473 36.12554608933416, -118.06831534387665 + 36.12494608933842, -118.06822634328991 36.12024608914322, -118.06632634358192 36.11934608907481, -118.06632634358192 36.11924608851101, -118.06642634314103 36.11914608927119, -118.06652634359844 36.118046089227185, -118.06652634359844 36.1173470891304, -118.06652634270013 36.11614708840033, -118.06632634268358 36.114647088187496, -118.06562634217653 36.11284708871927, -118.0649263425678 36.111347088611346, -118.06462634209383 36.11044708887447, -118.0649263425678 36.11034708858963, -118.06602634220961 36.10954708825917, -118.06632634268358 36.1097470885614, -118.06712634274975 36.11034708858963, -118.0674263423254 36.1099470876286, -118.06802634237502 36.109847087432904, -118.0683263419507 36.108547087818074, -118.06752634188454 36.10744708808691, -118.06642634224272 36.106047087263576, -118.06622634132786 36.103947087873316, -118.06642534151949 36.101547087540034, -118.06662534153602 36.09984708688884, -118.06692534111168 36.0977470872646, -118.06712534112822 36.09604708641953, -118.06742534160219 + 36.094647086418306, -118.06732534114477 36.093447085854336, -118.06692534021336 36.09244708663152, -118.065625340555 36.091647086948164, -118.06442533955745 36.09024708551195, -118.06392533996525 36.0897470863704, -118.06312533989909 36.08924708622608, -118.06222533937547 36.08934708650939, -118.0613253388519 36.08994708626351, -118.06072533880226 36.090547086519024, -118.05982533917698 36.091247086593576, -118.05872533953519 36.091847086362094, -118.05802533902813 36.09194708624114, -118.05762533899505 36.091647086948164, -118.0574253380802 36.09094708753242, -118.05682533803055 36.08944708739141, -118.0564253388958 36.08874708653082, -118.05522533789825 36.08734708643483, -118.054025337799 36.08634708582699, -118.05282533769976 36.0856470863989, -118.05202533673526 36.08454708694207, -118.05162533760048 36.08354708628938, -118.05152533714306 36.08324708615421, -118.05162533760048 36.082547085984906, -118.05162533760048 36.082247086389884, -118.05152533714306 36.08134708654467, -118.05112533621168 + 36.08054708642302, -118.05082533663601 36.08024708573092, -118.05072533707691 36.080047086569635, -118.05062533661949 36.07954708571487, -118.05052533616207 36.07894708571964, -118.0504253357046 36.07824708574702, -118.04982533565502 36.076747085774045, -118.04992533611244 36.07514708616408, -118.0504253357046 36.07464708537327, -118.05072533617857 36.07434708540586, -118.05092533619512 36.0739480854042, -118.05122533577077 36.07344808570342, -118.05102533665254 36.072648085088694, -118.05132533532988 36.06974808526143, -118.05132533532988 36.069648085463555, -118.05122533577077 36.069548084812425, -118.05112533531337 36.06944808476029, -118.05102533575426 36.06924808500076, -118.05102533575426 36.06874808519245, -118.05112533531337 36.06854808437921, -118.05132533532988 36.06724808485187, -118.05162533580385 36.065748084954876, -118.05162533580385 36.06454808405096, -118.05172533536299 36.0627480843215, -118.05172533536299 36.06084808398682, -118.05172533536299 36.05954808430674, + -118.05142533488902 36.05854808384782, -118.05092533439849 36.05804808429951, -118.04972533429924 36.05724808393349, -118.04892433350986 36.056848083605615, -118.04822433390113 36.05624808402221, -118.04802433388458 36.05614808376796, -118.04792433432547 36.05594808360443, -118.04782433386805 36.05554808393135, -118.04772433341061 36.05494808387788, -118.04782433386805 36.054748083569784, -118.04802433298624 36.05444808324407, -118.04842433391767 36.0539480837913, -118.04862433393419 36.05364808332125, -118.04862433393419 36.053248083578616, -118.04852433347678 36.053248083578616, -118.04752433339408 36.053148083870006, -118.04702433380189 36.0529480833454, -118.04602433282086 36.05214808342759, -118.04452433314596 36.05064808327266, -118.04382433263892 36.049748083548984, -118.04369333221935 36.0496170833164, -118.0433243321484 36.04924808329119, -118.04292433211529 36.04874808276265, -118.04272433209876 36.04854808340513, -118.0428033317415 36.048151083545505, -118.04292433211529 + 36.047548083353554, -118.04312433213187 36.047348083126295, -118.04322433258926 36.047248082458935, -118.04372433218148 36.04644808271577, -118.043924332198 36.04554808363764, -118.04402433175714 36.04474808332535, -118.04402433175714 36.04464808298711, -118.04412433131625 36.04464808298711, -118.04432433223111 36.04464808298711, -118.04552433233036 36.04414808229611, -118.045824331906 36.04384808297219, -118.04592433236344 36.0436480827879, -118.04632433239652 36.04294808250016, -118.04632433239652 36.042248082526356, -118.04612433237997 36.041548082866704, -118.04562433188947 36.04074808258809, -118.04442433179022 36.03994808289833, -118.0434243308092 36.03944808214292, -118.0428243316579 36.03904808215833, -118.04232433116738 36.03864808232112, -118.04212433115082 36.03844808273017, -118.04212433115082 36.03824808263129, -118.0419243311343 36.03744808224131, -118.04172433021944 36.036548081985956, -118.04102433061071 36.03584808177938, -118.0405243301202 36.03534808259626, -118.040024330528 + 36.03464808189403, -118.0395243300375 36.034248082227855, -118.04021732997354 36.03239908182851, -118.04042432966277 36.03184808152596, -118.0405243301202 36.03174808151399, -118.0405243301202 36.03144808216933, -118.04072433013674 36.03124808172923, -118.04112433016982 36.03064808172108, -118.04152433020288 36.03004908113163, -118.04142433064379 36.02984908149842, -118.04132433018634 36.02984908149842, -118.04072433013674 36.02984908222486, -118.04032433010363 36.02984908222486, -118.0396243295966 36.02994808138395, -118.03902432954699 36.02984908222486, -118.03842432949736 36.029848081865815, -118.03792432900683 36.029848081865815, -118.03762432943117 36.02974808222074, -118.03722432939811 36.02924908100495, -118.03712432894066 36.02914908132598, -118.03702432848326 36.02844908147184, -118.03702432848326 36.02774908120959, -118.03702432848326 36.02694908097395, -118.03702432848326 36.02614908133275, -118.03662432845016 36.02504908182114, -118.03642432843364 36.02444908109634, -118.035824328384 + 36.023349081099326, -118.035824328384 36.02234908072152, -118.03592432884142 36.02204908118489, -118.03612432885795 36.021149080807966, -118.03622432751875 36.02024908105014, -118.03602432840056 36.01954908059044, -118.03572432792659 36.018149081361514, -118.03512432787694 36.01644908072837, -118.03492432786042 36.01574908066563, -118.03472432694554 36.01524908034369, -118.03462432828475 36.01434908005438, -118.03482432740297 36.01394908050241, -118.03542432745262 36.013049079901585, -118.03532432789348 36.01274907983976, -118.03472432694554 36.01154907980106, -118.03452432782734 36.01124907984361, -118.034123327071 36.01094908019771, -118.034123327071 36.01024907997506, -118.03402332661359 36.00964907919686, -118.03362332658051 36.00894907978396, -118.0330233265309 36.008649080105194, -118.03202332644818 36.007949079806394, -118.03182332643165 36.00744907942105, -118.03132332594113 36.00704907973639, -118.03082332545063 36.00704907973639, -118.02962332624969 36.006749079367744, -118.02832332569302 + 36.0063490804918, -118.0278233252025 36.00614908029281, -118.02702332513633 36.00584907940636, -118.02632332462929 36.00534908023704, -118.02532332454656 36.00484907935032, -118.02452332448043 36.004649080433296, -118.02422332490477 36.00474907995522, -118.02232332429848 36.00454908005782, -118.02062332370872 36.00404908063845, -118.01902332357636 36.00334907946752, -118.01842332352675 36.00264908007632, -118.01752332300315 36.002049080324014, -118.01642332336134 36.0010490800393, -118.01592332287083 36.00084908020482, -118.01452232203182 35.999949080486466, -118.01428232273062 35.99851107940105, -118.01208932222136 35.99831008036492, -118.01197632224346 35.99773507985225, -118.0116143218452 35.99715708009315, -118.01123432190361 35.99330807901025, -118.01068432118439 35.99174907883511, -118.01019532056453 35.99133807885798, -118.00891932119563 35.99088107964079, -118.00686632042843 35.98990607933396, -118.00624732101056 35.989762079510825, -118.00638931950415 35.98676407950223, -118.00595231966115 + 35.98581407961593, -118.00505031948772 35.985295079056414, -118.00439131988688 35.9850830790307, -118.00358931927258 35.98372307907441, -118.00531031977873 35.98051307790647, -118.00498631901532 35.98017107825388, -118.00450631861628 35.97928707815207, -118.00393431815577 35.978841078358855, -118.00403731898622 35.977831078250375, -118.00450631861628 35.97716307793251, -118.00525631845376 35.97697207842161, -118.00595631896081 35.97721907803261, -118.00690231951513 35.97760307816798, -118.00801631940067 35.97785607828313, -118.00962631957873 35.97818307783664, -118.00996331986262 35.97788407772881, -118.01010731980269 35.97762207795769, -118.01035031947691 35.97718207782814, -118.01069131995877 35.97668207794176, -118.01153731915737 35.975817077514, -118.01142831937742 35.97504107791247, -118.01144431927095 35.97423207698719, -118.01457931923828 35.97289607739039, -118.014712320206 35.97245907671488, -118.01402131991976 35.97174107718163, -118.01383432032202 35.970727077023085, -118.01309731910672 + 35.97027807640752, -118.01244531917857 35.96972907670398, -118.01245131902634 35.96966907625074, -118.0125243197196 35.96891407707077, -118.0125253195445 35.96889707672303, -118.01276231937096 35.96835207706328, -118.01268931867772 35.967845076477985, -118.01339331938274 35.96788907671664, -118.01380831948444 35.96782807662713, -118.01440431933611 35.96702707646672, -118.01455331929901 35.96578207690973, -118.01472931992443 35.965212076439244, -118.01502132000248 35.9648120770579, -118.01524031848896 35.96451107627645, -118.01759132008029 35.96302907564986, -118.01805931988542 35.96239507573425, -118.01825231933097 35.96098207541273, -118.0179803193444 35.96003407598221, -118.0175783196615 35.959388075523826, -118.01691831843908 35.9592430754771, -118.01667431893993 35.959004075496026, -118.01714731966625 35.9581340757182, -118.01718631912603 35.95617907495435, -118.01711431825765 35.95560507559041, -118.01680331791304 35.95455607454618, -118.01598131810557 35.95420607478346, -118.01543931778227 + 35.9544010750088, -118.01493731854025 35.95462907519881, -118.01356831748828 35.95474407563181, -118.0123293179293 35.95449007576085, -118.01158831831269 35.95427707530535, -118.01108431762421 35.95460607552841, -118.01027231696415 35.95375207540702, -118.00955431691375 35.952359075287355, -118.00953231717246 35.951347075463765, -118.0098193163293 35.94943007477304, -118.00946231685225 35.948616075019046, -118.00816131647068 35.94738307480021, -118.00689431552595 35.94648807452274, -118.00532531625407 35.94619507490421, -118.00433331566899 35.94607907514971, -118.00354931549634 35.94593207546064, -118.00250631485764 35.94632207554989, -118.00092231551713 35.94606107500951, -118.00038031429551 35.94653307571318, -117.99927031460795 35.94660707501316, -117.99860831463407 35.94627707569374, -117.99847631438955 35.945475075155635, -117.99814131465382 35.944941075110826, -117.99595131451761 35.94408607524669, -117.99496331323216 35.94405907544481, -117.99418431398068 35.944331075936475, + -117.99336131345002 35.944269075842655, -117.99195631331133 35.94377607551164, -117.99120531275068 35.94271007578002, -117.98982231235331 35.94041107557169, -117.98968331243614 35.93887307554502, -117.98889431224065 35.93824207541587, -117.98797931164844 35.93734407538373, -117.98643831106733 35.935547075160734, -117.98539431150199 35.934149074990394, -117.98567031168653 35.933536074332814, -117.98587731137576 35.93307607468784, -117.9864433110902 35.932169074323184, -117.98614731081418 35.931335074815586, -117.98329931000373 35.92656807432997, -117.98345930983727 35.9260660736321, -117.98418931048158 35.92509107379699, -117.98446730941761 35.92418707422144, -117.98507731041128 35.923447074064526, -117.98585031071326 35.922673073311884, -117.98616830983227 35.92163407362385, -117.98693531028647 35.92019107390056, -117.98753330978793 35.91841507313161, -117.98801831020987 35.91754207290368, -117.98924331042346 35.91669907321467, -117.99005831055825 35.91592407228008, -117.99032631034686 + 35.914050072405104, -117.9903093106284 35.91382107263417, -117.99023031008737 35.91274607217609, -117.98935231020342 35.911380071857394, -117.98888730987301 35.910178072124204, -117.98909030936426 35.90864807176978, -117.98915330911348 35.90817007210504, -117.98902730961507 35.907903072414435, -117.98699930896227 35.906712072272825, -117.98550930843479 35.90581807259679, -117.98538130928657 35.905317071656256, -117.98574830880939 35.9050800710996, -117.98660830915003 35.90464007147533, -117.98773830892905 35.9026270709565, -117.98788930854181 35.90132107131979, -117.98708630810259 35.8992520707687, -117.98566930826836 35.89745407033109, -117.98360130743258 35.896396071589415, -117.98248530699891 35.89580107079772, -117.98206230739959 35.894700071470616, -117.98221030663932 35.89309307069016, -117.98293930656034 35.891985070624095, -117.98375530652004 35.89141207009456, -117.98432330678263 35.890672070482466, -117.9845183067763 35.88960107049402, -117.9845273060988 35.8895110702388, + -117.98470430654913 35.8877260695152, -117.98519330627066 35.88732207032601, -117.98674930692039 35.88667706950639, -117.98740230667346 35.886272070064216, -117.98801230766713 35.88556606989037, -117.98902430744539 35.88412106956132, -117.98885530649267 35.883621069608665, -117.98846930670332 35.882118069590135, -117.98907730714885 35.88131106876268, -117.98881430738311 35.879774068851674, -117.98695130568842 35.87854806874768, -117.98571530560417 35.87828806880655, -117.98438330615876 35.8767900686148, -117.98429730504671 35.87669806885269, -117.9832603051541 35.875593068338596, -117.9830853052519 35.87529606902149, -117.98288330558555 35.87495206863644, -117.98317430494036 35.873817069027524, -117.98328830474318 35.8727810689384, -117.98253330488286 35.87114906782346, -117.98203130384422 35.870250068109755, -117.98214430472044 35.86904906790006, -117.98151930455644 35.86821706884249, -117.98077330401863 35.867520067804854, -117.98105530405094 35.86691706797815, -117.98307030428495 + 35.86677206796809, -117.9850023046783 35.86666106723889, -117.98693030487371 35.866049068080414, -117.98844930481516 35.865706067114594, -117.98956030522598 35.86566606707936, -117.99054930633635 35.86582806687903, -117.99121430578496 35.866591067643526, -117.99217530550962 35.8681550673223, -117.99366930713336 35.86961606738768, -117.99539630658899 35.869505067730344, -117.99671330686411 35.86949806773363, -117.99827030733873 35.86878706692552, -117.99899830653656 35.86741406710897, -117.99985230792771 35.86640706616586, -118.00026130728331 35.86617106731677, -118.00092130760738 35.865933067259846, -118.00173730756705 35.865163066332485, -118.00226330709854 35.863901066718675, -118.00550130789632 35.862704066101585, -118.00601530773226 35.86011006507536, -118.00609830757293 35.85990206541991, -118.0064703071186 35.85897606580473, -118.00743030791665 35.85818506565243, -118.00644530700424 35.85711106506845, -118.00641930796328 35.85609406487543, -118.00655230713436 35.854845065034766, + -118.0062013075051 35.85396206521755, -118.00511430738375 35.85317706484472, -118.0047963064681 35.85127006513748, -118.00527130684426 35.847837064942055, -118.00555330597825 35.845749064383476, -118.00544730657136 35.84327906333659, -118.00432330574176 35.84225206372408, -118.00300430581683 35.84205406353761, -118.0029253052758 35.841295064069755, -118.00452430558323 35.84034906429676, -118.00506030516043 35.83908706291962, -118.00491230502242 35.83763306374474, -118.00447130498146 35.83609606368006, -118.00524530510836 35.83377906317893, -118.00634630547339 35.831198062895844, -118.00620730465793 35.829042062068474, -118.00491730414697 35.82734406294126, -118.00266530318817 35.82539706198944, -118.00092030329088 35.824383061684344, -117.99889930231076 35.823767062286485, -117.99861730317677 35.823424062693405, -117.99861730317677 35.823034062931, -117.99887030289679 35.822920061954974, -118.0000773026687 35.82074406234832, -118.00047330340215 35.82028506221657, -118.00076330293206 + 35.82021106133911, -118.00092030329088 35.820171061662954, -118.00167730280104 35.82018606245439, -118.00169830361575 35.82019406180701, -118.0023213032316 35.81945506157323, -118.00353130337658 35.8184480619784, -118.00373130339312 35.8182820610336, -118.00670830407502 35.81715106068578, -118.00773930411988 35.817088060615816, -118.00774630379257 35.81600906147384, -118.00829030376569 35.8145280609257, -118.00840830376646 35.81405506096609, -118.00854330348568 35.81351806092939, -118.00888130359446 35.81220506037655, -118.00843030350778 35.81176405977599, -118.0075253029613 35.81128806092373, -118.00749130352442 35.81000706071484, -118.00802730310164 35.80895106054, -118.0081583035212 35.808695060634754, -118.00857830274745 35.8073820597443, -118.00878930263464 35.8066080604171, -118.00966430304389 35.8050270600982, -118.01049230269913 35.804391059695256, -118.0076593019573 35.800095059323056, -118.00754830252752 35.79992605901431, -118.00738930162055 35.79884605946319, -118.00880130233023 + 35.79716705873374, -118.00930330247056 35.79558205830707, -118.00938830285938 35.795316058531, -118.00813230178531 35.79077805845501, -118.00789330141072 35.78991405800737, -118.00805530089409 35.78916605794458, -118.01747130246338 35.789651057474586, -118.01985630349157 35.78977405737774, -118.02139630334943 35.78985405745542, -118.02336030352984 35.789955057374776, -118.05082330913842 35.79112905634086, -118.0541733091909 35.791210054792, -118.06638931189006 35.79151005489452, -118.06707831072984 35.791527054737664, -118.06773131138124 35.79154205438195, -118.06925431241898 35.791528054471186, -118.07036131173348 35.791518054949734, -118.07368631167162 35.791488054191966, -118.07479431260766 35.79147905439961, -118.07599431270691 35.791468053680454, -118.07959431390297 35.791436054163185, -118.08079531382711 35.791426053172884, -118.08591231531484 35.79138005370181, -118.09309731584169 35.79131605312623, -118.10126631655775 35.791309052058224, -118.10625331834909 35.79130505238452, + -118.10638531859358 35.79130505238452, -118.11740831996788 35.791290051238136, -118.12303832146658 35.79127205087893, -118.12592232136375 35.79126305106212, -118.13993032381585 35.79117505012781, -118.14556132513944 35.79114104955855, -118.1468243249879 35.7911320489982, -118.15061632580456 35.791109049909636, -118.1518593255615 35.79110204955203, -118.15188032547792 35.79110204955203, -118.15680932664465 35.79103404833652, -118.1666963283921 35.79089904765834, -118.17159732907145 35.790866048170145, -118.17652733006307 35.79083404768182, -118.17885533029155 35.79081804816152, -118.1858393318753 35.790773047352836, -118.18816833192865 35.7907580475634, -118.18962933214381 35.79074804648773, -118.19313633255514 35.79072504655928, -118.1940143324391 35.79071304672074, -118.19539333353681 35.79069504623077, -118.19547633247916 35.790694046486834, -118.19774933335438 35.79068104689944, -118.20457033473147 35.79064304641184, -118.20684433453327 35.79063104583222, -118.2071763347943 35.79062904561395, + -118.20817233467903 35.790625045177386, -118.20842233582263 35.7906240454325, -118.20850533476498 35.79062304568766, -118.2134553358481 35.79056504589088, -118.22830733874731 35.7903940440325, -118.23325833965534 35.79033704381709, -118.23380033908035 35.79033004412017, -118.2354303402482 35.790311044523385, -118.23597334039643 35.79030504384439, -118.23661733992866 35.79029704366707, -118.23855134087015 35.79027504335733, -118.2391963411256 35.790268043655004, -118.24246734153537 35.79023004369855, -118.25092534195643 35.79013204338187, -118.25228234241452 35.7901230427073, -118.25555434354752 35.790103042580895, -118.25797834313714 35.79008704218472, -118.26525134442585 35.790042041690796, -118.2676753449138 35.79002704176339, -118.26837334487271 35.790022041544056, -118.27001534573614 35.790013041585674, -118.27046834547268 35.790014042066886, -118.27116734525652 35.79001704205311, -118.27507834679717 35.7900270410347, -118.28681234854919 35.79005704088673, -118.29072434901647 35.79006804034381, + -118.29123534937762 35.79006904009564, -118.29258334961487 35.790082039783144, -118.29816035051181 35.79013903945281, -118.30001935111021 35.79015803909072, -118.30150135124175 35.7901730397221, -118.30594835235964 35.79021803941315, -118.30743135231612 35.790234039054255, -118.30873735272057 35.79024703944347, -118.31265535303562 35.79028603914088, -118.31396135344009 35.790300038541815, -118.3141823524747 35.79030203876831, -118.31484735282167 35.790308038718834, -118.31506935347782 35.790311039422654, -118.31513535360007 35.790311039422654, -118.31533635344152 35.79031403866896, -118.31540335338869 35.79031503841769, -118.31566335278137 35.790317038643806, -118.31644535330419 35.79032503881917, -118.3167063534201 35.790328038793604, -118.31679935330651 35.790329039270894, -118.3170653543436 35.79030503874366, -118.31863035341753 35.79031503841769, -118.32119735491891 35.79033103876796, -118.3288993556547 35.79037903761342, -118.33146735608268 35.79039503722217, -118.33211035579002 + 35.79039903767031, -118.33404135635845 35.79041103755633, -118.33468535678902 35.79041503727504, -118.34458535805695 35.79047703689684, -118.35812035978273 35.79056003623259, -118.36046336097814 35.790574035585244, -118.36103336089054 35.79057703627901, -118.36678236221489 35.79061303512219, -118.36693336182763 35.79061403559586, -118.36981136277535 35.790490035060266, -118.37592836344744 35.79022903520455, -118.37889936428154 35.79033603459576, -118.38193036449181 35.79044703501737, -118.38302536411075 35.79048703509191, -118.38456336431882 35.790538034538336, -118.39246336631966 35.79080603376348, -118.39469336663885 35.790882033836006, -118.39509736686988 35.79089603386063, -118.39626136698412 35.790935033968246, -118.39975536697663 35.79105403292645, -118.4009203678141 35.79109403269546, -118.40227136752608 35.79114003306073, -118.40632436935698 35.79127803326809, -118.406909369338 35.7912980330988, -118.40767536817063 35.79133303260814, -118.41686537068243 35.79175803218643, -118.42589137226436 + 35.79211203148028, -118.42616837227379 35.79211703229678, -118.42989637261807 35.792154031625294, -118.438008374331 35.79213903136785, -118.43818737443115 35.79214703063113, -118.43977937416759 35.79221103126618, -118.44389337519951 35.79230903047638, -118.44493537511501 35.79231703117983, -118.447799375819 35.79233703074898, -118.44792937551536 35.79233803120094, -118.44822837616441 35.79234002991884, -118.4485853765398 35.79234403026913, -118.44884537593248 35.79234903034236, -118.44892437557519 35.79235103051728, -118.44900337611622 35.79235303069215, -118.4490173754616 35.792354030415275, -118.44944737563192 35.79236203038552, -118.44973837588508 35.79236703045762, -118.44978037571784 35.79236703045762, -118.44983237649471 35.79236903063214, -118.44985937625887 35.79237003035501, -118.44992737603093 35.79237103080655, -118.4499393757265 35.7923720305294, -118.45003637581088 35.7923740307038, -118.45014637541573 35.792376030878074, -118.45378937716781 35.79244102959334, -118.46389537830008 + 35.79262602968145, -118.46480337832128 35.792642029566416, -118.46646837964924 35.792641029118286, -118.46943637921201 35.792639028950674, -118.47872038053679 35.791907029065854, -118.47931938165983 35.791856028312, -118.47999038095625 35.791805028254046, -118.48040938125588 35.79176902849342, -118.4838303814535 35.79149802821374, -118.48532038198096 35.79138102866236, -118.48535038121987 35.79138202839771, -118.48567138250856 35.791352027588545, -118.48568438202904 35.79135102785286, -118.48571138179322 35.79134902765273, -118.49493438391694 35.790606026705525, -118.49756038407126 35.79036102684634, -118.50093238386503 35.79004602685445, -118.50341638462746 35.789871026319425, -118.50401438502723 35.78983002618002, -118.50723638503321 35.78971602635653, -118.52104638801693 35.78962902638123, -118.52692438831441 35.78959302490622, -118.54596639151525 35.78964202395466, -118.55791439352748 35.78960002321065, -118.56725239617727 35.78971302271557, -118.57387539628937 35.7897370219642, + -118.57390239695184 35.7897370219642, -118.57404039614579 35.7897370219642, -118.57939339763503 35.789756021698224, -118.57972839737079 35.78975702218267, -118.58109239750158 35.78975502194246, -118.58919039986914 35.78974802073693, -118.58996939912059 35.789747020981025, -118.59008639929647 35.789747020981025, -118.59277440027337 35.78975402072929, -118.59367039970067 35.789757020725276, -118.59756240007646 35.789767020468325, -118.6092414024752 35.789799019928914, -118.61313440357422 35.78981001942177, -118.61499840329718 35.78981501965444, -118.62059340373746 35.78983001889316, -118.62245840508196 35.789836019608025, -118.62593640518098 35.79048901855408, -118.62585740463994 35.79028001878564, -118.62668740574163 35.78992201814568, -118.62700940505863 35.78988501850661, -118.62728040522026 35.78983101864798, -118.62998740646371 35.78958501882774, -118.63069840594311 35.78958901931666, -118.6331034061645 35.78960201835398, -118.63388540668728 35.7896060188421, -118.63714940652605 + 35.78962201860642, -118.64617669049461 35.78967071365897, -118.64549944597876 35.804295707198456, -118.64552338298903 35.80558992954243, -118.64602185609856 35.8115629891316, -118.64617038025702 35.812636539034365, -118.64643420988902 35.81368771111573, -118.64681024613394 35.814704158636204, -118.65556615873105 35.83492980448715, -118.65598111562096 35.83577644626858, -118.65647396605004 35.83658024349494, -118.65704032861974 35.837334050477004, -118.65767516841478 35.83803116593456, -118.65837284176321 35.83866539257027, -118.65912714640807 35.83923109216309, -118.65993137664495 35.839723235691565, -118.66077838293523 35.840137448041425, -118.66166063546474 35.84047004689999, -118.66257029108327 35.84071807549157, -118.66349926302942 35.840879328863025, -118.67026898736569 35.8417279812743, -118.67349786370211 35.94819753229878, -118.67357735350076 35.94918848038671, -118.67375484196234 35.95016663916067, -118.67402857497414 35.951122341509375, -118.67439584724174 35.95204614225765, + -118.67485302902527 35.95292891151282, -118.67539560201209 35.95376192489509, -118.67601820397103 35.95453694976037, -118.67671468174721 35.95524632656315, -118.6774781520735 35.95588304455562, -118.67830106959754 35.956440811074614, -118.67917530145225 35.95691411373179, -118.68009220763268 35.95729827489242, -118.68104272638496 35.95758949790428, -118.68201746376323 35.95778490461982, -118.68300678646973 35.95788256384085, -118.69121582983372 35.95828239229983, -118.69087571129923 35.970722471112076, -118.69089824783538 35.97172017015754, -118.6910201138541 35.97271065486427, -118.69124009567797 35.973684060883336, -118.69155600248318 35.97463069395438, -118.69196468811823 35.97554112645187, -118.69246208243678 35.97640629127586, -118.69304323183269 35.977217572152256, -118.69370234857357 35.97796688944331, -118.69443286844145 35.97864678061357, -118.69522751610657 35.9792504745502, -118.69607837758316 35.97977195899717, -118.69697697904581 35.98020604043204, -118.69791437122119 + 35.98054839578884, -118.69888121851487 35.980795615511944, -118.69986789198558 35.98094523751225, -118.70086456524092 35.98099577168738, -118.70186131229953 35.98094671476177, -118.74334281222478 35.97682256590484, -118.74370396686218 35.976780014641555, -118.75700582365025 35.97496700157789, -118.77225535659885 35.9752385133338, -118.7831137438144 35.98170427946961, -118.78391268909094 35.98213221918773, -118.78474709881084 35.98248606431857, -118.78561011874916 35.9827629082156, -118.78649465966352 35.982960476755814, -118.81040894690051 35.987174373495655, -118.81089424797278 35.98724765764875, -118.81198039987973 35.987384505205846, -118.81113657864437 36.04024867313966, -118.80099103251831 36.04219822247717, -118.80016065838204 36.04239486114128, -118.79935007225751 36.042661578416926, -118.76223542750752 36.05665552971537, -118.76132889588939 36.05704954390621, -118.76046569189414 36.05753113047143, -118.75965424942838 36.05809558408512, -118.75890249666443 36.05873738977155, + -118.75821777857828 36.059450276789065, -118.75760678518581 36.060227279898186, -118.75707548617812 36.06106080741523, -118.75662907259495 36.06194271538662, -118.75627190610574 36.06286438715912, -118.75600747639407 36.06381681756856, -118.7558383670619 36.06479070092451, -118.75576623038646 36.065776521931234, -118.75579177117677 36.066764648656545, -118.7559147398874 36.06774542664031, -118.75613393505655 36.06870927322305, -118.75735543333128 36.073070958521576, -118.7576427220974 36.073941112487425, -118.75800845775646 36.07478131495535, -118.75844956921605 36.075584510722045, -118.75896235245108 36.076343955330486, -118.75954250160656 36.07705327170335, -118.76757579130222 36.086004703684964, -118.76825342474784 36.09873862678464, -118.76713779689449 36.11658754829678, -118.76712895478819 36.117672432112236, -118.76723769261534 36.118751888994574, -118.76952803612626 36.1334417667021, -118.76974422613002 36.1344700309017, -118.77006711784382 36.13546993405855, -118.77257134069382 + 36.14202509534495, -118.77298654815262 36.142964233819235, -118.77349587298762 36.1438558427695, -118.77409394498827 36.14469052126436, -118.7747744582131 36.14545946863484, -118.77553023747829 36.146154577266664, -118.77635331401122 36.14676851808521, -118.77723500947147 36.14729481783166, -118.77816602745337 36.14772792731565, -118.77913655150527 36.148063279924735, -118.7801363486321 36.14829733977375, -118.78168108224337 36.14857620343459, -118.78141660234608 36.1587421479709, -118.77882827471818 36.15994059995628, -118.76288893747089 36.160137007301714, -118.76192595290838 36.160195414434526, -118.76097307813149 36.160346345943374, -118.7600391820285 36.160588397032235, -118.75913295684357 36.16091931480901, -118.75826283727358 36.1613360192543, -118.75743692196218 36.16183463188872, -118.75666289812162 36.16241051187189, -118.75594796998386 36.16305829919706, -118.75529879174731 36.16377196457942, -118.75472140564267 36.1645448655737, -118.75422118569493 36.1653698083988, -118.75380278770457 + 36.16623911489392, -118.75347010591364 36.16714469398311, -118.7532262367601 36.16807811698302, -118.75307345005773 36.16903069605289, -118.75301316786975 36.169993565056764, -118.75144919748469 36.279593834237886, -118.75148512810287 36.28059535770192, -118.7516211216573 36.28158825548694, -118.75185581230994 36.282562555520755, -118.75218684296807 36.283508472515905, -118.75261088895755 36.28441650624718, -118.75312369141379 36.28527753696628, -118.75372010005525 36.28608291699503, -118.75439412490987 36.28682455757759, -118.75513899647467 36.287495010119, -118.75594723370465 36.28808754099449, -118.75681071914794 36.288596199177924, -118.75772078047258 36.28901587601039, -118.75866827756634 36.289342356508506, -118.75964369433446 36.28957236169719, -118.76063723427363 36.289703581541744, -118.76163891886227 36.289734698148436, -118.77639626062529 36.28945316634832, -118.77741558790052 36.289381502538625, -118.77842228009811 36.289206189632765, -118.7794058257026 36.28892905818464, + -118.78035595488687 36.288553001900496, -118.78126274674598 36.288081947423905, -118.78192193646443 36.287648809456044, -118.82898574429345 36.29076416811826, -118.84051403482991 36.29899902423095, -118.84105004457845 36.3018807484891, -118.81526155601628 36.30107498582117, -118.81428302816124 36.30109232591058, -118.81331088157083 36.30120523493836, -118.81235442763466 36.30141263144214, -118.81142282743535 36.30171252894203, -118.81052500400209 36.302102054967484, -118.80966955684434 36.30257747857027, -118.80886467958416 36.30313424606021, -118.80811808147635 36.303767024621166, -118.8074369135678 36.304469753389725, -118.80682770020354 36.30523570150717, -118.80629627653545 36.30605753258883, -118.80584773263209 36.306927374993265, -118.80548636472521 36.307836897218216, -118.80521563405966 36.30877738770117, -118.80503813374096 36.30973983826024, -118.80495556389813 36.310715030376116, -118.80496871539953 36.3116936234886, -118.80523368889412 36.31593502535654, -118.7800242806871 + 36.31511834020267, -118.7790616867693 36.31513352115228, -118.77810501339252 36.3152411945567, -118.77716312717318 36.315440362479954, -118.7762447576777 36.31572917899886, -118.77535841651536 36.31610496731134, -118.7745123184513 36.3165642445455, -118.77371430527053 36.31710275403952, -118.77297177309896 36.31771550479309, -118.77229160385477 36.318396817724945, -118.77168010146565 36.3191403783075, -118.7711429334428 36.319939295091075, -118.77068507835357 36.32078616357501, -118.77031077967905 36.3216731348339, -118.77002350648495 36.322591988262786, -118.7698259212695 36.32353420776719, -118.75857286943065 36.39391219619818, -118.75848049197107 36.39467869257049, -118.75844752252308 36.39545003121162, -118.75807749071299 36.48558215541584, -118.75812167955651 36.486563157424165, -118.75826187322602 36.487535095348754, -118.75849671980929 36.488488596621195, -118.75882395463938 36.489414466460964, -118.75924042213329 36.49030377654229, -118.75974210622165 36.4911479510916, -118.76032416907658 + 36.491938849585175, -118.76098099776353 36.4926688452497, -118.76170625836791 36.49333089860855, -118.76249295707412 36.493918625364685, -118.7633335076081 36.4944263579656, -118.76421980439319 36.49484920025649, -118.76514330071353 36.49518307469476, -118.76609509113165 36.495424761670435, -118.76706599736507 36.49557193055351, -118.76804665679414 36.49562316216854, -118.87417516254838 36.495949505118375, -118.87308601929492 36.54002023266133, -118.87311018885032 36.54100465130564, -118.87323106086768 36.54198192004561, -118.87344746329427 36.54294256265577, -118.87375729775316 36.54387726412831, -118.87415755989042 36.54477696099735, -118.87464436850725 36.54563292922414, -118.8752130031946 36.54643686879091, -118.87585795010528 36.54718098418323, -118.87657295541979 36.547858059980264, -118.8773510859873 36.54846153082016, -118.87818479655398 36.54898554506203, -118.88190837664614 36.55107756172666, -118.89197810408919 36.55755345165675, -118.89594621111921 36.56160199340023, -118.8940043038425 + 36.56572825918132, -118.8889377030988 36.56893680113585, -118.8881449083979 36.56949445914952, -118.88740982709625 36.57012625134473, -118.88673936532601 36.57082624199654, -118.88613982211506 36.5715878546515, -118.88561683020723 36.57240393391378, -118.88517530314235 36.573266812670596, -118.88481938909322 36.574168384125116, -118.88455243189333 36.575100177960365, -118.88437693962132 36.576053439918304, -118.88429456103744 36.57701921404661, -118.88430607009333 36.57798842684038, -118.88441135866074 36.57895197248826, -118.88460943754737 36.57990079842206, -118.8848984457904 36.58082599036617, -118.88527566814034 36.581718856087704, -118.88573756057094 36.58257100706055, -118.88627978357562 36.583374437276056, -118.88689724293724 36.58412159845996, -118.9105167426308 36.61003503913885, -118.91117808389136 36.61069574564254, -118.91189823265913 36.61129180864191, -118.9126708955155 36.61181801911054, -118.9134893201185 36.612269778467, -118.91434635421172 36.61264313876214, -118.9152345081284 + 36.61293483718036, -118.916146020244 36.6131423245536, -118.9170729248054 36.6132637876386, -118.91800712154404 36.61329816496297, -118.92855545690956 36.613192928324096, -118.92829651842047 36.62402844926704, -118.92832161904701 36.62501455718805, -118.92844375155 36.62599339452136, -118.92866172753268 36.62695543679918, -118.92897342600403 36.62789132297618, -118.92937581401674 36.62879194651606, -118.92986497617903 36.629648544001654, -118.9304361527529 36.63045278040645, -118.93108378596827 36.631196830197595, -118.93180157410218 36.63187345348138, -118.93258253279707 36.63247606645024, -118.93341906302125 36.632998805445716, -118.93430302501044 36.633436584014184, -118.93522581747087 36.633785142399994, -118.93617846127317 36.63404108899455, -118.93715168682284 36.634201933338005, -118.93813602425695 36.63426611035236, -118.95879416635542 36.63459180579124, -118.95921128849025 36.656992095600536, -118.95927808538805 36.65797463561685, -118.95944120231881 36.65894584083519, -118.95969905729834 + 36.65989629205369, -118.9600491495286 36.660816771352486, -118.96048808365163 36.661698351493406, -118.96101160267928 36.66253248250045, -118.96161462927947 36.6633110745815, -118.96229131501858 36.664026576586885, -118.96303509708223 36.66467204924394, -118.96383876192455 36.66524123245735, -118.96469451522853 36.66572860602245, -118.96559405749889 36.666129443162745, -118.96652866455453 36.66643985637244, -118.96748927213969 36.66665683511923, -118.9684665638333 36.66677827504187, -118.98444326940069 36.667968620556245, -118.98454486678743 36.66797115649069, -118.98482455150021 36.658107083929416, -118.98447055329453 36.67059208517495))) + metadata: {} diff --git a/requirements/base.txt b/requirements/base.txt index 95f95961..4f447f4e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -68,6 +68,7 @@ requests==2.34.2 scikit-learn==1.9.0 scout-apm==3.5.3 sentry-sdk==2.62.0 +shapely==2.1.2 setuptools==80.10.2 simpleeval==1.0.7 tqdm==4.67.3 From a6c518ad41e194b7d3bd543851f5af8cbd3a753e Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 19:55:06 -0700 Subject: [PATCH 16/20] test(forecasts): cover custom-region boundary geometry in the API RegionSerializer has no type-based branching, so custom regions (Kern SJV Air Basin, Tulare valley, Sequoia) already got full boundary GeoJSON through /api/2.0/forecasts/ -- verified live against the real imported geometry. This locks that behavior in with an explicit regression test. --- camp/api/v2/forecasts/tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/camp/api/v2/forecasts/tests.py b/camp/api/v2/forecasts/tests.py index 6cd4c3e8..7ffdadf2 100644 --- a/camp/api/v2/forecasts/tests.py +++ b/camp/api/v2/forecasts/tests.py @@ -93,3 +93,19 @@ 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 From ac6ab001658d2b6139285d1f72d9b38b037bd999 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 20:55:35 -0700 Subject: [PATCH 17/20] fix(forecasts): restore blank=True on burn_status fields per original spec Both are legitimately optional -- burn_el.get('status', '') and burn_el.text or '' in the ingestion task can produce empty strings. --- ...002_alter_forecast_burn_status_and_more.py | 23 +++++++++++++++++++ camp/apps/forecasts/models.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 camp/apps/forecasts/migrations/0002_alter_forecast_burn_status_and_more.py diff --git a/camp/apps/forecasts/migrations/0002_alter_forecast_burn_status_and_more.py b/camp/apps/forecasts/migrations/0002_alter_forecast_burn_status_and_more.py new file mode 100644 index 00000000..87fa99b1 --- /dev/null +++ b/camp/apps/forecasts/migrations/0002_alter_forecast_burn_status_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-07-13 03:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('forecasts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='forecast', + name='burn_status', + field=models.CharField(blank=True, max_length=32, verbose_name='burn status'), + ), + migrations.AlterField( + model_name='forecast', + name='burn_status_text', + field=models.CharField(blank=True, max_length=255, verbose_name='burn status text'), + ), + ] diff --git a/camp/apps/forecasts/models.py b/camp/apps/forecasts/models.py index f5f5e9c3..c3455aca 100644 --- a/camp/apps/forecasts/models.py +++ b/camp/apps/forecasts/models.py @@ -29,8 +29,8 @@ class Pollutant(models.TextChoices): aqi_category = models.CharField(_('AQI category'), max_length=32) pollutant = models.CharField(_('pollutant'), max_length=16, choices=Pollutant.choices) - burn_status = models.CharField(_('burn status'), max_length=32) - burn_status_text = models.CharField(_('burn status text'), max_length=255) + burn_status = models.CharField(_('burn status'), max_length=32, blank=True) + burn_status_text = models.CharField(_('burn status text'), max_length=255, blank=True) air_alert = models.BooleanField(_('air alert'), default=False) air_alert_start = models.DateField(_('air alert start'), null=True, blank=True) From dab5121b5725ca48507630dbbbd9e9550d20ff89 Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 20:55:41 -0700 Subject: [PATCH 18/20] docs: update stale zone-geometry scope note, document forecast zone setup The original spec's "Out of Scope" note said accurate Kern/Sequoia zone geometry was deferred with county boundaries as an approximation -- that was superseded once the SVG-derivation approach shipped on this same branch. Added an addendum explaining what actually shipped, and a new CLAUDE.md section documenting the one-time import_forecast_zones setup step (parallel to the existing Pesticide Data Import section). --- CLAUDE.md | 23 ++++++++ .../2026-07-12-sjvapcd-forecast-design.md | 53 +++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b239c9b2..5f62f9e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md b/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md index 7782cb8e..bced1b1f 100644 --- a/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md +++ b/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md @@ -47,6 +47,11 @@ Example item (abridged): ### Zone mapping +> **Update (2026-07-13):** the section below describes the *original* plan. +> It's been superseded — see "Zone geometry update" after the Out of Scope +> section for what actually shipped. Kept here for history/context on why +> the original call was made. + The feed publishes 9 zones. 8 map 1:1 (after one alias) to the existing SJV county `Region` records (`camp.apps.regions.managers.SJV_COUNTIES`); one does not correspond to any `Region` and is dropped: @@ -291,9 +296,51 @@ class ForecastList(ForecastMixin, generics.ListEndpoint): ## Out of Scope (this pass) -- Accurate forecast-zone geometry (Kern air-basin clip, Sequoia zone) — using - existing county boundaries as an approximation instead. +- ~~Accurate forecast-zone geometry (Kern air-basin clip, Sequoia zone) — using + existing county boundaries as an approximation instead.~~ **Done — see + "Zone geometry update" below.** Implemented in a follow-up pass on this same + branch rather than deferred to a separate one. - Notifications/alerts driven by forecast data (e.g. air alert push - notifications) — this pass is ingestion + API only. + notifications) — considered and explicitly declined (not just deferred): + SJVAPCD already runs its own notification system for this, and the + decision was not to duplicate/compete with it. `air_alert` stays as + read-only data with no notification wiring. - Historical backfill — the feed has no archive; history only accumulates from when this integration starts running. + +## Zone geometry update (2026-07-13) + +The original plan (county boundaries as an approximation for Kern and a +dropped Sequoia zone) was superseded once real zone geometry became +available: the SJVAPCD forecast page renders its map as an inline SVG, whose +9 path shapes were saved locally (`datafiles/sjvapcd-forecast-areas.svg`, +cleaned of everything but the shape outlines) and used to derive accurate +lat/lon geometry for the 3 zones that don't map 1:1 to a county: + +- **Kern (SJV Air Basin portion)** and **Tulare (SJV Valley portion)**: the + real county `Region` boundary (already accurate) intersected with the + SVG shape transformed into lat/lon — the real boundary supplies the outer + edge, the SVG only supplies the internal dividing line. +- **Sequoia National Park and Forest**: verified (via exact-vertex-sequence + matching in the raw SVG path data, then confirmed geometrically) to be + carved entirely from Tulare's territory, not Kern's — defined as + `real_Tulare − Tulare_zone` so the two tile the real county exactly, with + no gap or overlap. + +The SVG pixel space is georeferenced via an affine transform fit against the +6 zones that *do* map 1:1 to a county (used as ground-control points), +validated to IoU ≥ 0.99 against their real boundaries — sufficient because +the SJV's geographic extent is small enough that map-projection curvature is +negligible. + +All 3 are stored as `Region(type=CUSTOM)` records (not a new `Region.Type` +— `CUSTOM` already exists as "catch-all for user-defined regions") with +`metadata` recording their derivation, imported via a new one-time command: +`camp.apps.regions.forecast_zones` (the parsing/fitting logic) + +`manage.py import_forecast_zones` (the command). This must be run once per +environment, after `import_counties`, before Kern/Tulare/Sequoia forecasts +will populate — see the updated CLAUDE.md import instructions. + +Every zone in the feed now maps to a region (`ZONE_TO_REGION` in +`camp/apps/forecasts/tasks.py`), so a daily pull creates 18 `Forecast` rows +(9 zones × 2 horizons) instead of the original 16. From 50b2e43c9665dd6bca7ecc8f02b95b662733770a Mon Sep 17 00:00:00 2001 From: dmpayton Date: Sun, 12 Jul 2026 20:55:51 -0700 Subject: [PATCH 19/20] test(regions): add unit and integration tests for forecast_zones.py Covers the SVG path parser, affine fit, transform, and IoU helpers with plain synthetic-data unit tests, plus integration tests against the real SVG + regions fixture: all 9 shapes load, the ground-control fit validates, the derived Kern/Tulare/Sequoia geometry is valid and closely reproduces what's already imported in fixtures/regions.yaml, Tulare and Sequoia tile the real county with no gap or overlap, and a deliberately scrambled ground-control mapping correctly trips the IoU validation gate. Also generalizes region_boundary_shape() to accept a region_type so tests can load the CUSTOM regions' boundaries the same way, not just COUNTY. --- camp/apps/regions/forecast_zones.py | 8 +- camp/apps/regions/tests.py | 137 ++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/camp/apps/regions/forecast_zones.py b/camp/apps/regions/forecast_zones.py index 5f27942f..9074dd55 100644 --- a/camp/apps/regions/forecast_zones.py +++ b/camp/apps/regions/forecast_zones.py @@ -104,12 +104,14 @@ def iou(a, b): return a.intersection(b).area / union if union else 0.0 -def region_boundary_shape(region_name): +def region_boundary_shape(region_name, region_type=None): from camp.apps.regions.models import Region - region = Region.objects.filter(type=Region.Type.COUNTY, name=region_name).select_related('boundary').first() + if region_type is None: + region_type = Region.Type.COUNTY + region = Region.objects.filter(type=region_type, name=region_name).select_related('boundary').first() if region is None or region.boundary is None: - raise RuntimeError(f'No boundary found for county Region {region_name!r}') + raise RuntimeError(f'No boundary found for {region_type} Region {region_name!r}') return shape(json.loads(region.boundary.geometry.geojson)) diff --git a/camp/apps/regions/tests.py b/camp/apps/regions/tests.py index ef0f9b6e..52583314 100644 --- a/camp/apps/regions/tests.py +++ b/camp/apps/regions/tests.py @@ -1,9 +1,24 @@ +from unittest.mock import patch + +import numpy as np +import pytest from django.contrib.gis.geos import Polygon, MultiPolygon from django.test import TestCase +from shapely.geometry import Polygon as ShapelyPolygon from camp.apps.monitors.purpleair.models import PurpleAir from camp.apps.regions.models import Region, Boundary from camp.apps.regions.management.commands.import_mtrs import build_mtrs +from camp.apps.regions.forecast_zones import ( + MIN_ACCEPTABLE_IOU, + derive_forecast_zones, + fit_affine, + iou, + load_svg_shapes, + parse_svg_path, + region_boundary_shape, + transform_polygon, +) class RegionTests(TestCase): @@ -87,3 +102,125 @@ def test_different_meridian(self): def test_mtrs_region_type_exists(self): assert Region.Type.MTRS == 'mtrs' + + +SVG_PATH = 'datafiles/sjvapcd-forecast-areas.svg' + + +class ParseSvgPathTests(TestCase): + def test_parses_move_and_line_commands(self): + points = parse_svg_path('M 0 0 L 10 0 10 10 0 10 Z') + assert points == [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + + def test_handles_floats(self): + points = parse_svg_path('M 1.5 2.25 L 3.75 4.125 Z') + assert points == [(1.5, 2.25), (3.75, 4.125)] + + +class FitAffineTests(TestCase): + def test_recovers_known_translation_and_scale(self): + # svg (x, y) -> real (lon, lat) via lon = 2x + 100, lat = -3y + 50 + gcp_svg = [(0, 0), (10, 0), (0, 10), (5, 5)] + gcp_real = [(2 * x + 100, -3 * y + 50) for x, y in gcp_svg] + + lon_coef, lat_coef = fit_affine(gcp_svg, gcp_real) + + assert lon_coef == pytest.approx([2, 0, 100]) + assert lat_coef == pytest.approx([0, -3, 50]) + + +class TransformPolygonTests(TestCase): + def test_applies_affine_transform_to_every_vertex(self): + square = ShapelyPolygon([(0, 0), (10, 0), (10, 10), (0, 10)]) + lon_coef = np.array([2, 0, 100]) + lat_coef = np.array([0, -3, 50]) + + transformed = transform_polygon(square, lon_coef, lat_coef) + + assert list(transformed.exterior.coords)[:-1] == pytest.approx( + [(100, 50), (120, 50), (120, 20), (100, 20)] + ) + + +class IouTests(TestCase): + def test_identical_polygons_have_iou_of_one(self): + square = ShapelyPolygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + assert iou(square, square) == pytest.approx(1.0) + + def test_non_overlapping_polygons_have_iou_of_zero(self): + a = ShapelyPolygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + b = ShapelyPolygon([(5, 5), (6, 5), (6, 6), (5, 6)]) + assert iou(a, b) == 0.0 + + def test_half_overlapping_squares(self): + a = ShapelyPolygon([(0, 0), (2, 0), (2, 2), (0, 2)]) # area 4 + b = ShapelyPolygon([(1, 0), (3, 0), (3, 2), (1, 2)]) # overlap area 2 (x in [1,2]) + # intersection area = 1*2 = 2, union = 4+4-2 = 6, iou = 2/6 = 1/3 + assert iou(a, b) == pytest.approx(1 / 3) + + +class LoadSvgShapesTests(TestCase): + def test_loads_all_nine_named_zones(self): + shapes = load_svg_shapes(SVG_PATH) + assert set(shapes.keys()) == { + 'san-joaquin', 'stanislaus', 'merced', 'madera', 'fresno', 'kings', + 'tulare', 'kern-(sjv air basin portion)', 'sequoia-national park and forest', + } + for name, polygon in shapes.items(): + assert polygon.is_valid, name + assert polygon.area > 0, name + + +class DeriveForecastZonesTests(TestCase): + fixtures = ['regions.yaml'] + + def test_ground_control_fit_validates_well(self): + result = derive_forecast_zones(SVG_PATH) + for shape_id, score in result['gcp_iou'].items(): + assert score >= MIN_ACCEPTABLE_IOU, f'{shape_id}: {score}' + + def test_derived_zones_are_valid_and_nonempty(self): + result = derive_forecast_zones(SVG_PATH) + for key in ('kern_airbasin', 'tulare_valley', 'sequoia'): + geom = result[key] + assert geom.is_valid, key + assert geom.area > 0, key + + def test_tulare_and_sequoia_tile_real_tulare_with_no_gap_or_overlap(self): + result = derive_forecast_zones(SVG_PATH) + real_tulare = region_boundary_shape('Tulare County') + combined = result['tulare_valley'].union(result['sequoia']) + assert iou(combined, real_tulare) > 0.9999 + + def test_derived_zones_match_imported_fixture_regions(self): + # fixtures/regions.yaml's custom regions were generated by this exact + # derivation against the same SVG -- re-deriving should reproduce + # (near-)identical geometry. Not a perfect 1.0: the fixture geometry + # went through a WKT/GeoJSON round-trip on its way into and out of + # the database, which loses a little coordinate precision. A future + # edit that breaks the derivation math would show up here as IoU + # dropping well below this threshold, not by a fraction of a percent. + result = derive_forecast_zones(SVG_PATH) + cases = [ + ('kern_airbasin', 'Kern (SJV Air Basin portion)'), + ('tulare_valley', 'Tulare (SJV Valley portion)'), + ('sequoia', 'Sequoia National Park and Forest'), + ] + for key, region_name in cases: + fixture_geom = region_boundary_shape(region_name, region_type=Region.Type.CUSTOM) + assert iou(result[key], fixture_geom) > 0.99, region_name + + def test_raises_when_ground_control_fit_is_untrustworthy(self): + # Deliberately mismatched svg-shape-to-county pairings should produce + # a garbage affine fit that fails the IoU validation gate. + scrambled = { + 'san-joaquin': 'Kern County', + 'stanislaus': 'Tulare County', + 'merced': 'Fresno County', + 'madera': 'Kings County', + 'fresno': 'Madera County', + 'kings': 'Merced County', + } + with patch('camp.apps.regions.forecast_zones.GROUND_CONTROL_COUNTIES', scrambled): + with pytest.raises(RuntimeError, match='IoU'): + derive_forecast_zones(SVG_PATH) From f8b37f485d9b01f545d78ab26115bc14ffc9363e Mon Sep 17 00:00:00 2001 From: dmpayton Date: Mon, 13 Jul 2026 01:15:17 -0700 Subject: [PATCH 20/20] chore: fix minor findings from full Sentry code review - requirements/base.txt: restore alphabetical order (shapely was inserted before setuptools instead of after) - forecast_zones.py / design spec: correct the ground-control IoU claim from ">= 0.99" to ">= 0.98" -- the actual measured minimum (san-joaquin, 0.9898) was technically just under the originally stated threshold --- camp/apps/regions/forecast_zones.py | 2 +- docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md | 2 +- requirements/base.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/camp/apps/regions/forecast_zones.py b/camp/apps/regions/forecast_zones.py index 9074dd55..9d75d5e0 100644 --- a/camp/apps/regions/forecast_zones.py +++ b/camp/apps/regions/forecast_zones.py @@ -7,7 +7,7 @@ lat/lon. Six of them (San Joaquin, Stanislaus, Merced, Madera, Fresno, Kings) correspond 1:1 to real SJV counties already in the Region table -- those six are used as ground-control points to fit an affine transform -from SVG pixel space to lat/lon (validated to IoU >= 0.99 against the real +from SVG pixel space to lat/lon (validated to IoU >= 0.98 against the real county boundaries for all six, which is why a plain affine transform is sufficient here -- the SJV's geographic extent is small enough that map projection curvature is negligible). diff --git a/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md b/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md index bced1b1f..cce0173f 100644 --- a/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md +++ b/docs/superpowers/specs/2026-07-12-sjvapcd-forecast-design.md @@ -329,7 +329,7 @@ lat/lon geometry for the 3 zones that don't map 1:1 to a county: The SVG pixel space is georeferenced via an affine transform fit against the 6 zones that *do* map 1:1 to a county (used as ground-control points), -validated to IoU ≥ 0.99 against their real boundaries — sufficient because +validated to IoU ≥ 0.98 against their real boundaries — sufficient because the SJV's geographic extent is small enough that map-projection curvature is negligible. diff --git a/requirements/base.txt b/requirements/base.txt index 4f447f4e..44c178a8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -68,8 +68,8 @@ requests==2.34.2 scikit-learn==1.9.0 scout-apm==3.5.3 sentry-sdk==2.62.0 -shapely==2.1.2 setuptools==80.10.2 +shapely==2.1.2 simpleeval==1.0.7 tqdm==4.67.3 tdigest==0.5.2.2