Skip to content

Commit 0f00a10

Browse files
hc-sousacursoragent
andcommitted
fix(seismic): handle EMSC 204, widen Azores radius, bootstrap feeds on deploy
EMSC returns empty 204 for zero results — treat as no events instead of JSON error. Use archipelago-scale radius (400km min) and 30-day lookback. Queue news + seismic Celery syncs from runserver.sh after migrate. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 78515c1 commit 0f00a10

4 files changed

Lines changed: 64 additions & 3 deletions

File tree

src/runserver.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ set -euo pipefail
33

44
python manage.py collectstatic --no-input
55
python manage.py migrate --no-input
6+
python manage.py bootstrap_feed_syncs
67
python manage.py ensure_superuser
78
gunicorn src.wsgi --bind="0.0.0.0:${WEB_CONTAINER_PORT:-8000}" --workers="${GUNICORN_WORKERS:-3}" --timeout 120

src/seismic/services.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
logger = logging.getLogger(__name__)
1919

2020
FDSN_EVENT_URL = 'https://www.seismicportal.eu/fdsnws/event/1/query'
21-
DEFAULT_MIN_MAGNITUDE = 2.5
22-
DEFAULT_LOOKBACK_DAYS = 7
21+
DEFAULT_MIN_MAGNITUDE = 2.0
22+
DEFAULT_LOOKBACK_DAYS = 30
23+
# Island.radius_km is transit-local (~50); seismic covers the whole archipelago.
24+
SEISMIC_MIN_RADIUS_KM = 400
2325
KM_PER_DEGREE_LAT = 111.0
2426
REQUEST_TIMEOUT_SECONDS = 30
2527

@@ -85,7 +87,8 @@ def _parse_feature(feature: dict[str, Any]) -> dict[str, Any] | None:
8587

8688
def build_fdsn_params(island: Island) -> dict[str, str]:
8789
start = timezone.now() - timedelta(days=DEFAULT_LOOKBACK_DAYS)
88-
max_radius_deg = _km_to_degrees(float(island.radius_km))
90+
radius_km = max(float(island.radius_km), float(SEISMIC_MIN_RADIUS_KM))
91+
max_radius_deg = _km_to_degrees(radius_km)
8992
return {
9093
'format': 'json',
9194
'starttime': start.strftime('%Y-%m-%dT%H:%M:%S'),
@@ -103,6 +106,8 @@ def fetch_events_for_island(island: Island) -> list[dict[str, Any]]:
103106
params = build_fdsn_params(island)
104107
response = requests.get(FDSN_EVENT_URL, params=params, timeout=REQUEST_TIMEOUT_SECONDS)
105108
response.raise_for_status()
109+
if response.status_code == 204 or not response.content.strip():
110+
return []
106111
payload = response.json()
107112
features = payload.get('features') or []
108113
if not isinstance(features, list):

src/seismic/tests/test_services.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def test_parse_feature_maps_fields(self):
5959
@patch('seismic.services.requests.get')
6060
def test_sync_events_upserts_without_duplicates(self, mock_get):
6161
mock_response = MagicMock()
62+
mock_response.status_code = 200
63+
mock_response.content = b'{"features": []}'
6264
mock_response.raise_for_status = MagicMock()
6365
mock_response.json.return_value = SAMPLE_FEATURES
6466
mock_get.return_value = mock_response
@@ -76,10 +78,24 @@ def test_sync_events_upserts_without_duplicates(self, mock_get):
7678
@patch('seismic.services.requests.get')
7779
def test_sync_empty_features(self, mock_get):
7880
mock_response = MagicMock()
81+
mock_response.status_code = 200
82+
mock_response.content = b'{"features": []}'
7983
mock_response.raise_for_status = MagicMock()
8084
mock_response.json.return_value = {'features': []}
8185
mock_get.return_value = mock_response
8286

8387
counts = sync_events_for_island(self.island)
8488
self.assertEqual(counts['created'], 0)
8589
self.assertEqual(SeismicEvent.objects.count(), 0)
90+
91+
@patch('seismic.services.requests.get')
92+
def test_sync_emsc_nodata_204(self, mock_get):
93+
mock_response = MagicMock()
94+
mock_response.status_code = 204
95+
mock_response.content = b''
96+
mock_response.raise_for_status = MagicMock()
97+
mock_get.return_value = mock_response
98+
99+
counts = sync_events_for_island(self.island)
100+
self.assertEqual(counts['created'], 0)
101+
self.assertEqual(SeismicEvent.objects.count(), 0)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Queue one-shot feed sync Celery jobs after deploy."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from django.core.management.base import BaseCommand
8+
9+
logger = logging.getLogger(__name__)
10+
11+
FEED_TASKS: tuple[tuple[str, str], ...] = (
12+
('news', 'news.poll_sources'),
13+
('seismic', 'seismic.sync_events'),
14+
)
15+
16+
17+
class Command(BaseCommand):
18+
help = 'Queue initial news and seismic feed sync tasks (runs after migrate on deploy).'
19+
20+
def handle(self, *args: object, **options: object) -> None:
21+
queued: list[str] = []
22+
for label, task_name in FEED_TASKS:
23+
try:
24+
if label == 'news':
25+
from news.tasks import poll_sources_task
26+
27+
poll_sources_task.delay()
28+
elif label == 'seismic':
29+
from seismic.tasks import sync_events_task
30+
31+
sync_events_task.delay()
32+
queued.append(task_name)
33+
except Exception:
34+
logger.exception('bootstrap_feed_syncs failed to queue %s', task_name)
35+
36+
if queued:
37+
self.stdout.write(f'Queued feed sync tasks: {", ".join(queued)}')
38+
else:
39+
self.stdout.write('No feed sync tasks were queued (check Celery broker).')

0 commit comments

Comments
 (0)