Skip to content

Commit 1068694

Browse files
authored
Merge branch 'main' into licensing
2 parents ade6aac + 67c484e commit 1068694

107 files changed

Lines changed: 672 additions & 260 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Redis
2+
REDIS_HOST=redis
3+
REDIS_PORT=6379
4+
REDIS_DB=0
5+
6+
# NFGDA Service
7+
MAX_CONCURRENT_JOBS=2 # Max number of jobs to run at once
8+
MAX_NO_DATA_POLLS=10 # Polls radar S3 bucket this many times before giving up
9+
FILE_EXPIRATION_TIME=1440 # 24 hours (minutes)
10+
11+
# Backend
12+
MAX_JOB_DURATION=180 # Maximum total duration of job timebox (minutes, default is 3 hours)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Integration Tests
2+
3+
on:
4+
pull_request:
5+
6+
jobs:
7+
integration-tests:
8+
runs-on: ubuntu-latest
9+
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- name: Set up Python
14+
uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.11'
17+
18+
- name: Install test dependencies
19+
run: pip install pytest requests
20+
21+
- name: Start Docker Compose stack
22+
run: docker compose up -d --build
23+
24+
- name: Wait for backend to be ready
25+
run: |
26+
echo "Waiting for backend..."
27+
for i in $(seq 1 30); do
28+
if curl -sf http://localhost:8001/apis/stations > /dev/null; then
29+
echo "Backend is up"
30+
exit 0
31+
fi
32+
echo " attempt $i/30..."
33+
sleep 5
34+
done
35+
echo "Backend did not become ready in time"
36+
docker compose logs backend
37+
exit 1
38+
39+
- name: Run integration tests (non-slow)
40+
run: pytest test_endpoints.py -v -m "not slow"
41+
42+
- name: Dump logs on failure
43+
if: failure()
44+
run: docker compose logs
45+
46+
- name: Tear down stack
47+
if: always()
48+
run: docker compose down

.github/workflows/lint.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
name: Lint
22

3-
on: [push, pull_request]
3+
on: [pull_request]
44

55
jobs:
66
lint:
77
runs-on: ubuntu-latest
88
steps:
99
- uses: actions/checkout@v4
10-
- uses: actions/setup-python@v5
10+
- uses: actions/setup-python@v5
1111
with:
12-
python-version: '3.x'
12+
python-version: "3.x"
1313
- name: Install dependencies
1414
run: pip install ruff
1515
- name: Run linter
16-
run: ruff .
16+
run: ruff check .

README.md

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,58 @@
1-
## gust-front-detection-webapp
1+
# Gust Front Web App
22

33
This is a prototype web interface to interact with the gust front detection algorithm found [here](https://github.com/firelab/NFGDA).
44

5-
# How To Run
5+
## How To Run
66

7-
First time running project?
8-
1. Navigate to project directory containing `docker-compose.yml'
9-
2. Run `docker compose up -d --build'
10-
3. Navigate to http://localhost:5173
11-
4. Play widdit
7+
First time running the project?
128

9+
1. Clone this repo and navigate to project directory containing `docker-compose.yml`
10+
2. Run `docker compose up -d --build`
11+
3. Open http://localhost:5173 in your browser
12+
13+
- See the browser console for additional logs.
1314
- To re-launch app, run `docker compose up -d`
1415
- To restart docker containers, run `docker compose restart -d`
1516

17+
## Frontend
1618

17-
# Frontend
19+
The frontend is a React app built using Vite. `frontend/src/` contains all the logic. The top-level component is `App.jsx`.
1820

21+
## Backend (/backend and /nfgda_service)
1922

20-
# Backend
23+
Backend directory structure:
2124

22-
Backend directory structure:
2325
- app.py contains the API endpoints
2426
- /apis contains the API endpoint definitions
2527
- API call logic defined in src/
26-
- src/ contains the backend logic (Not responsible for API endpoints that orchestrate or handle HTTP requests - Contains the business logic of the application only)
28+
- src/ contains the backend logic (Not responsible for API endpoints that orchestrate or handle HTTP requests. Contains the business logic of the application only)
29+
30+
NFGDA Service directory structure:
2731

32+
- /nfgda_service contains the NFGDA service logic
33+
- /nfgda_service/algorithm contains the original NFGDA code and script with some slight tweaks
34+
- responsible for all NFGDA execution, output processing, and file management
2835

29-
# Todo (before MSU handoff)
36+
And then there's a redis instance living at port 6379 where all the job status and asset information is stored.
3037

31-
- Guard against short jobs that run forever for some reason
32-
- Figure out zoom level / blank frame issue on frontend
33-
- Switching to a new station view pauses slide deck playthrough
34-
- Convert geotiff output to cloud-optimized-geotiffs
35-
- Remove "expired" job files and produced resources after set amount of time
36-
- Figure out what is a "reasonable" time to run a historical job and set a hard limit
37-
- Code cleanup / add comments where necessary
38+
### Todo before MSU handoff
3839

39-
# "Nice to have" features
40+
- [ ] Switching to a new station view pauses slide deck playthrough
41+
- [ ] Can we pretty up the landing page? Put a title on it somewhere before the research celebration?
42+
- [ ] Set opacity slider on frontend
43+
- [ ] Enhance resolution of output on frontend
44+
- [ ] Add a "clear" button to the map that clears all job assets from the map
45+
- [ ] Deliver frame time-stamps to the frontend
46+
- [ ] Switch to cloud-optimized geotiffs
47+
- [ ] Make some stuff environment variables instead of random variables everywhere
48+
- [ ] Discuss pixel-width of gust fronts written to output file next team meeting
49+
- [ ] Diff the NFGDA code used in nfgda_service with the original NFGDA code, see if there are any useful features we're missing out on or bugs we introduced
4050

51+
### "Nice to have" features
52+
53+
- There a should probably be a warning that shows up for small numbers of assets per job (2 frames produced or less). Maybe if not enough assets are produced, the job request could automatically re-run with a larger time window?
4154
- Average time to job completion estimator (small addition: new counter in redis, average out)
42-
- Serve tiles instead of individual GeoTIFFs (big refactor)
55+
- Serve tiles instead of individual GeoTIFFs (big refactor, honestly might not be worth at as Cloud-optimized-geotiffs are kinda the future anyway)
4356
- Hash job IDs to make them unguessable, so resources can't be directly accessed via URL (little development effort, likely med/large refactor effort)
4457

4558

@@ -50,3 +63,7 @@ Copyright (c) 2026 ROCKY MOUNTAIN RESEARCH STATION (RMRS)
5063
MISSOULA FIRE SCIENCES LABORATORY
5164

5265

66+
### Todo after MSU handoff (futures devs read this pls)
67+
68+
- Check that automatic asset deletion occurs within the timeframe specified (should be 24 hours)
69+
- Familiarize with the .env file and environment variables, and what they do

backend/apis/retrieve_frames.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
"""
2-
Frame Data API: returns the list of rendered frames for a completed job.
2+
Frame Data API: returns a single rendered GeoTIFF frame for a completed job.
33
"""
44

5-
def get_frames(job_id: str):
6-
7-
pass
5+
import os
6+
from flask import send_file, abort
7+
8+
9+
def get_frame(job_id: str, index: int):
10+
"""Return a single GeoTIFF frame file for the given job and frame index."""
11+
job_dir = "/processed_data/" + job_id
12+
if not os.path.exists(job_dir):
13+
abort(404, description="Job not found")
14+
15+
frame_path = job_dir + f"/frame_{index}.tif"
16+
if not os.path.exists(frame_path):
17+
abort(404, description="Frame not found")
18+
19+
return send_file(
20+
frame_path,
21+
mimetype="image/tiff",
22+
as_attachment=False
23+
)

backend/apis/run_request.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
import os
23
import uuid
34
from datetime import datetime, timedelta, timezone
45
from flask import jsonify
@@ -15,14 +16,14 @@ def send_job_to_redis_queue(redis_client, request_fields: dict):
1516
Response shape:
1617
{
1718
"job_id": "<jobId>",
18-
"status": 200
19+
"status": 202
1920
OR
2021
"error": "<error message>",
2122
"status": 400
2223
}
2324
"""
2425

25-
# Validate stationId
26+
# validate stationId
2627
station_id = request_fields.get("stationId")
2728
if not station_id:
2829
return jsonify({"error": "Missing stationId request field"}), 400
@@ -32,7 +33,8 @@ def send_job_to_redis_queue(redis_client, request_fields: dict):
3233
StationService(redis_client).get_station(station_id)
3334
except ValueError:
3435
return jsonify({"error": f"Invalid station ID: {station_id}"}), 400
35-
# Validate and/or set default timebox parameters
36+
37+
# validate and/or set default timebox parameters
3638
validation_error = validate_time_parameters(request_fields)
3739
if validation_error:
3840
return validation_error, 400
@@ -46,11 +48,14 @@ def send_job_to_redis_queue(redis_client, request_fields: dict):
4648

4749
# add job to redis
4850
job_key = f"job:{job_id}"
51+
expiry_minutes = int(os.getenv("FILE_EXPIRATION_TIME", "1440"))
52+
expiry_timestamp = (datetime.now(timezone.utc) + timedelta(minutes=expiry_minutes)).strftime("%Y-%m-%dT%H:%M:%SZ")
4953
redis_client.hset(job_key, mapping={
5054
"stationId": request_fields["stationId"],
5155
"startUtc": request_fields["startUtc"],
5256
"endUtc": request_fields["endUtc"],
53-
"status": "PENDING"
57+
"status": "PENDING",
58+
"asset_expiry_timestamp": expiry_timestamp
5459
})
5560

5661
# push job id to job queue
@@ -63,13 +68,14 @@ def send_job_to_redis_queue(redis_client, request_fields: dict):
6368
def validate_time_parameters(request_fields: dict):
6469
"""Validate the time parameters recieved via the request."""
6570

66-
# Default timebox when not provided: look back 15 minutes from now
67-
# so the algorithm captures 2-3 recent NEXRAD scans for detection + forecast
68-
# (2 scan minimum needed for forcasting)
71+
# Default timebox when not provided: look back over the last ~25 minutes, ending
72+
# 10 minutes ago. The 10-minute buffer ensures the algorithm's end time is always
73+
# fully in the past — if endUtc is too close to "now" the algorithm enters live
74+
# polling mode and runs indefinitely.
6975
now = datetime.now(timezone.utc)
7076
if not request_fields.get("startUtc") and not request_fields.get("endUtc"):
71-
request_fields["startUtc"] = (now - timedelta(minutes=15)).strftime("%Y-%m-%dT%H:%M:%SZ")
72-
request_fields["endUtc"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
77+
request_fields["startUtc"] = (now - timedelta(minutes=35)).strftime("%Y-%m-%dT%H:%M:%SZ")
78+
request_fields["endUtc"] = (now - timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
7379
elif not request_fields.get("startUtc") or not request_fields.get("endUtc"):
7480
return jsonify({"error": "Must provide both startUtc and endUtc, or neither"})
7581

@@ -88,15 +94,19 @@ def validate_time_parameters(request_fields: dict):
8894
if end_utc <= start_utc:
8995
return jsonify({"error": "endUtc must be after startUtc"})
9096

91-
# Duration must be between 5 minutes and 6 hours
97+
# duration must be between 15 minutes and MAX_JOB_DURATION (default is 180 minutes / 3 hours)
98+
max_duration = timedelta(minutes=int(os.getenv("MAX_JOB_DURATION", "180")))
99+
max_hours = max_duration.total_seconds() / 3600
92100
duration = end_utc - start_utc
93-
if duration < timedelta(minutes=5):
94-
return jsonify({"error": "Timebox duration must be at least 5 minutes"})
95-
if duration > timedelta(hours=6):
96-
return jsonify({"error": "Timebox duration must not exceed 6 hours"})
97-
98-
# endUtc must not be in the future
99-
if end_utc > now:
100-
return jsonify({"error": "endUtc must not be later than the current time"})
101+
if duration < timedelta(minutes=15):
102+
return jsonify({"error": "Timebox duration must be at least 15 minutes"})
103+
if duration > max_duration:
104+
return jsonify({"error": f"Timebox duration must not exceed {max_hours:.0f} hours"})
105+
106+
# endUtc must be at least 5 minutes in the past — the algorithm enters a live
107+
# polling loop if endUtc is too close to the current time, causing jobs to run
108+
# indefinitely instead of processing a closed historical window.
109+
if end_utc > now - timedelta(minutes=5):
110+
return jsonify({"error": "endUtc must be at least 5 minutes in the past"})
101111

102112
return None

backend/app.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
import os
21
import redis
3-
from flask import Flask, jsonify, request, send_file, abort
2+
from flask import Flask, jsonify, request
43
from apis.stations import list_stations_api
54
from apis.run_request import send_job_to_redis_queue
65
from apis.status import get_job_status
7-
from apis.retrieve_frames import get_frames
6+
from apis.retrieve_frames import get_frame
87

98
app = Flask(__name__)
109

1110
# Connect to the Redis container
1211
redis_client = redis.Redis(host='redis', port=6379, db=0, decode_responses=True)
1312

1413
# Station List API
15-
@app.route("/APIs/stations", methods=["GET"])
14+
@app.route("/apis/stations", methods=["GET"])
1615
def stations_endpoint():
1716
"""
1817
Returns:
@@ -27,7 +26,7 @@ def stations_endpoint():
2726

2827

2928
# Algorithm Runner API
30-
@app.route("/APIs/run", methods=["POST"])
29+
@app.route("/apis/run", methods=["POST"])
3130
def run_endpoint():
3231
"""Takes station and time frame args, kicks off an NFGDA processing job, and returns the new job ID and status code."""
3332
if not request.json:
@@ -37,27 +36,14 @@ def run_endpoint():
3736

3837

3938
# Frame Data API
40-
@app.route("/APIs/jobs/<job_id>/frames/<int:index>", methods=["GET"])
41-
def get_frame(job_id, index):
39+
@app.route("/apis/jobs/<job_id>/frames/<int:index>", methods=["GET"])
40+
def get_frame_endpoint(job_id, index):
4241
"""Takes job ID and frame index, returns a single GeoTIFF file."""
43-
44-
job_dir = "/processed_data/" + job_id
45-
if not os.path.exists(job_dir):
46-
abort(404, description="Job not found")
47-
48-
frame_path = job_dir + f"/frame_{index}.tif"
49-
if not os.path.exists(frame_path):
50-
abort(404, description="Frame not found")
51-
52-
return send_file(
53-
frame_path,
54-
mimetype="image/tiff",
55-
as_attachment=False
56-
)
42+
return get_frame(job_id, index)
5743

5844

5945
# Job Status API
60-
@app.route("/APIs/status", methods=["GET"])
46+
@app.route("/apis/status", methods=["GET"])
6147
def status_endpoint():
6248
"""Takes job ID, returns status."""
6349
job_id = request.args.get("job_id")

0 commit comments

Comments
 (0)