Skip to content

Commit 625aad6

Browse files
authored
Merge pull request #56 from firelab/timestamps
add timestamps to frame retrieval API for #45; fix off-by-1 error #57; css for #53
2 parents 78e5f86 + 6be12c6 commit 625aad6

9 files changed

Lines changed: 134 additions & 39 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ And then there's a redis instance living at port 6379 where all the job status a
3838

3939
### Todo before MSU handoff
4040

41-
- [ ] Switching to a new station view pauses slide deck playthrough
4241
- [ ] Add a "clear" button to the map that clears all job assets from the map
4342
- [ ] Discuss pixel-width of gust fronts written to output file next team meeting
4443
- [ ] 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

backend/apis/retrieve_frames.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
"""
44

55
import os
6-
from flask import send_file, abort
6+
import json
7+
from flask import send_file, abort, make_response
78

89

910
def get_frame(job_id: str, index: int):
10-
"""Return a single GeoTIFF frame file for the given job and frame index."""
11+
"""Return a single GeoTIFF frame file for the given job and frame index.
12+
Includes an X-Frame-Timestamp header of the radar observation time.
13+
"""
1114
job_dir = "/processed_data/" + job_id
1215
if not os.path.exists(job_dir):
1316
abort(404, description="Job not found")
@@ -16,8 +19,33 @@ def get_frame(job_id: str, index: int):
1619
if not os.path.exists(frame_path):
1720
abort(404, description="Frame not found")
1821

19-
return send_file(
20-
frame_path,
21-
mimetype="image/tiff",
22-
as_attachment=False
22+
response = make_response(
23+
send_file(
24+
frame_path,
25+
mimetype="image/tiff",
26+
as_attachment=False,
27+
)
2328
)
29+
30+
# attach the radar observation timestamp if available
31+
timestamp = get_frame_timestamp(job_dir, index)
32+
if timestamp is not None:
33+
response.headers["X-Frame-Timestamp"] = timestamp
34+
35+
return response
36+
37+
38+
def get_frame_timestamp(job_dir: str, index: int) -> str | None:
39+
"""Read the per-frame observation timestamp from the job's manifest file.
40+
Returns an ISO 8601 UTC string (e.g. "2024-07-07T01:22:24Z") or
41+
None if the manifest is missing or the frame index has no entry.
42+
"""
43+
manifest_path = os.path.join(job_dir, "timestamps.json")
44+
if not os.path.exists(manifest_path):
45+
return None
46+
try:
47+
with open(manifest_path) as f:
48+
manifest = json.load(f)
49+
return manifest.get(str(index))
50+
except Exception:
51+
return None

frontend/assets/github.svg

Lines changed: 12 additions & 0 deletions
Loading

frontend/src/App.jsx

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,24 @@ export default function App() {
4949
const fetchRadarData = async () => {
5050
try {
5151
// ---- validate request ----
52+
//check if station is selected
5253
if (!selectedStation?.properties?.station_id) {
5354
setErrorMessage("Please select a radar station first.");
5455
return;
5556
}
57+
// check if endTime is in the past
58+
if (!currentMode && selectedDateTime.isAfter(dayjs().subtract(Number(selectedDuration), "minute"))) {
59+
setErrorMessage(
60+
`Please select a start time at least ${selectedDuration} minutes in the past`,
61+
);
62+
return;
63+
}
5664
setErrorMessage("");
5765
const durationMinutes = Number(selectedDuration);
5866
const requestBody = {
5967
stationId: selectedStation.properties.station_id,
6068
};
6169
if (!currentMode) {
62-
console.log("using historical data");
6370
requestBody.startUtc = selectedDateTime
6471
.utc()
6572
.format("YYYY-MM-DDTHH:mm:ss[Z]");
@@ -68,7 +75,6 @@ export default function App() {
6875
.utc()
6976
.format("YYYY-MM-DDTHH:mm:ss[Z]");
7077
} else {
71-
console.log("using current data");
7278
requestBody.startUtc = dayjs()
7379
.subtract(durationMinutes + 15, "minute")
7480
.utc()
@@ -119,23 +125,31 @@ export default function App() {
119125
};
120126

121127
// fetch frames once the job is completed and the jobId and numFrames are set
122-
useEffect(() => {
123-
async function fetchFrames() {
124-
if (jobStatus !== "COMPLETED" || !jobId || numFrames <= 0) return;
125-
console.log(`attempting to fetch ${numFrames} frames for job ${jobId}`);
126-
try {
127-
const promises = Array.from({ length: numFrames }, (_, i) =>
128-
fetch(`/apis/jobs/${jobId}/frames/${i}`)
129-
.then((res) => {
130-
if (!res.ok) throw new Error(`Failed frame ${i}`);
131-
return res.blob();
132-
})
133-
.then((blob) => URL.createObjectURL(blob)),
134-
);
135-
const urls = await Promise.all(promises);
136-
setFrames(urls);
137-
setIsPlaying(true);
138-
console.log("Frames fetched successfully: ", urls);
128+
useEffect(() => {
129+
async function fetchFrames() {
130+
if (jobStatus !== "COMPLETED" || !jobId || numFrames <= 0) return;
131+
console.log(`attempting to fetch ${numFrames} frames for job ${jobId}`);
132+
try {
133+
const promises = Array.from({ length: numFrames }, async (_, i) => {
134+
const res = await fetch(`/apis/jobs/${jobId}/frames/${i}`);
135+
if (res.status === 404) {
136+
console.warn(`Frame ${i} gave 404 - skipping`);
137+
return null;
138+
}
139+
if (!res.ok) throw new Error(`Failed frame ${i}`);
140+
const timestamp = res.headers.get("x-frame-timestamp");
141+
const blob = await res.blob();
142+
return {
143+
url: URL.createObjectURL(blob),
144+
timestamp,
145+
index: i,
146+
};
147+
});
148+
const frames = await Promise.all(promises);
149+
frames.filter(Boolean).sort((a, b) => a.index - b.index);
150+
setFrames(frames);
151+
setIsPlaying(frames.length > 0);
152+
console.log("Frames fetched successfully: ", frames);
139153
} catch (err) {
140154
console.error("Error fetching frames:", err);
141155
}
@@ -219,7 +233,10 @@ export default function App() {
219233
return (
220234
<div>
221235
<div className="flex flex-col md:flex-row w-full">
222-
<div className="md:mt-12 p-4 gap-4 md:w-92 w-full flex flex-col">
236+
<div className="md:mt-6 p-4 gap-4 md:w-92 w-full flex flex-col">
237+
<div className="mb-6 items-center gap-4">
238+
<h1 className="text-3xl font-light">Gust Front Web App</h1>
239+
</div>
223240
{/* Station Selector */}
224241
<RadarStationDropdown
225242
stations={stations}
@@ -360,11 +377,10 @@ export default function App() {
360377
<p className="min-w-fit px-3">{geotiffOpacity}%</p>
361378
</div>
362379
<div className="w-1/2">
363-
{/*TODO: Actual timestamp will go here: */}
364380
<p className="text-sm text-right">
365-
{selectedDateTime
366-
.tz(timezone)
367-
.format("YYYY-MM-DD HH:mm z")}
381+
{frames[currentFrameIndex]?.timestamp
382+
? `${dayjs(frames[currentFrameIndex].timestamp).tz(timezone).format("YYYY-MM-DD HH:mm z")}`
383+
: "No timestamp available"}
368384
</p>
369385
</div>
370386
</div>
@@ -378,8 +394,8 @@ export default function App() {
378394
<div
379395
className={
380396
jobStatus === "PROCESSING" ||
381-
jobStatus === "REQUESTED" ||
382-
jobStatus === "PENDING"
397+
jobStatus === "REQUESTED" ||
398+
jobStatus === "PENDING"
383399
? "opacity-50"
384400
: ""
385401
}
@@ -395,6 +411,12 @@ export default function App() {
395411
</div>
396412
</div>
397413
</div>
414+
<footer className=" m-4 absolute bottom-0 left-0 hidden md:block shadow-xl hover:shadow-sm transition-all">
415+
<a className="outline-1 hover:text-black opacity-50 hover:opacity-100 transition-all rounded-md p-2 flex gap-2 items-center" href="https://github.com/firelab/gust-front-detection-webapp" target="_blank" rel="noopener noreferrer">
416+
<p className="">Code</p>
417+
<img src="/assets/github.svg" alt="GitHub" className="w-6 h-6" />
418+
</a>
419+
</footer>
398420
</div>
399421
);
400422
}

frontend/src/components/GeoTiffAnimation.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ export default function GeotiffAnimation({ frames, currentIndex, opacity }) {
1010
useEffect(() => {
1111
async function processFrames() {
1212
const processed = await Promise.all(
13-
frames.map(async (url) => {
14-
const res = await fetch(url);
13+
frames.map(async (frame) => {
14+
const res = await fetch(frame.url);
1515
const buf = await res.arrayBuffer();
1616
const geo = await parseGeoraster(buf);
1717

frontend/src/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
@import 'tailwindcss';
2+
3+
@theme {
4+
--font-sans: "Roboto", "Helvetica", "Arial", sans-serif;
5+
}

nfgda_service/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def process_geotiff_output(job_id: str) -> None:
7878
redis_client.hset(f"job:{job_id}", mapping={"status": "FAILED", "error_message": result})
7979
else:
8080
logger.info("successfully generated GeoTIFF series for job %s", job_id)
81-
redis_client.hset(f"job:{job_id}", mapping={"status": "COMPLETED", "num_frames": len(os.listdir(f"/processed_data/{job_id}"))})
81+
redis_client.hset(f"job:{job_id}", mapping={"status": "COMPLETED", "num_frames": len(os.listdir(f"/processed_data/{job_id}")) - 1}) # subtract 1 for the timestamps.json file
8282

8383
async def run_and_release_job(job_id: str) -> None:
8484
"""Run a job and release the semaphore when finished."""

nfgda_service/process_output.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,21 @@ def generate_geotiff_output(job_id: str, redis_client: redis.Redis):
9494

9595
radar_lon, radar_lat = radar_coords
9696

97-
# process each file
97+
# process each file and collect per-frame observation timestamps
98+
timestamps = {}
9899
for i, file in enumerate(files):
99100
logger.info(f'processing file {i+1} of {len(files)} into GeoTIFF format')
100-
project_data(os.path.join(f"/nfgda_output/{job_id}/nfgda_detection", file), radar_lat, radar_lon, out_dir, i)
101+
npz_path = os.path.join(f"/nfgda_output/{job_id}/nfgda_detection", file)
102+
ts = extract_timestamp(npz_path)
103+
if ts is not None:
104+
timestamps[i] = ts
105+
project_data(npz_path, radar_lat, radar_lon, out_dir, i)
106+
107+
# write manifest so the API can serve observation timestamps per frame
108+
manifest_path = os.path.join(out_dir, "timestamps.json")
109+
with open(manifest_path, "w") as f:
110+
json.dump(timestamps, f)
111+
logger.info(f"Wrote timestamp manifest with {len(timestamps)} entries to {manifest_path}")
101112

102113

103114
def get_radar_coords(station_id: str, redis_client: redis.Redis) -> tuple[float, float]:
@@ -140,6 +151,24 @@ def _reflectivity_to_rgba(refl: np.ndarray, nfout: np.ndarray) -> np.ndarray:
140151
return rgba
141152

142153

154+
def extract_timestamp(npz_path: str) -> str | None:
155+
"""Return the radar observation time from a detection .npz as an ISO 8601 UTC string,
156+
or None if the key is absent or unparseable."""
157+
try:
158+
data = np.load(npz_path, allow_pickle=True)
159+
if "timestamp" not in data:
160+
return None
161+
ts = data["timestamp"]
162+
# numpy.datetime64 (0-d array) -> Python datetime -> ISO string
163+
# .item() is required to get the Python scalar; .astype(object) keeps it
164+
# as a 0-d ndarray which has no .strftime()
165+
ts_dt = ts.astype("datetime64[s]").item()
166+
return ts_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
167+
except Exception as e:
168+
logger.warning(f"Could not extract timestamp from {npz_path}: {e}")
169+
return None
170+
171+
143172
def project_data(npz_path: str, radar_lat: float, radar_lon: float, out_dir: str, index: int) -> None:
144173

145174
# ---------------------------

request_test_script.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ def main():
112112
print(f"\n GET {url}")
113113
resp = requests.get(url)
114114
if resp.status_code == 200:
115-
print(f" ✓ frame {index}: {resp.status_code} ({len(resp.content)} bytes)")
115+
ts = resp.headers.get("X-Frame-Timestamp", "no timestamp")
116+
print(f" ✓ frame {index}: {resp.status_code} ({len(resp.content)} bytes) timestamp={ts}")
116117
else:
117118
print(f" ✗ frame {index}: {resp.status_code}{resp.text[:200]}")
118119

0 commit comments

Comments
 (0)