Skip to content

Commit 74a41b1

Browse files
authored
Merge pull request #798 from bcgov/bcwat-784-design-data-download-2
implemented endpoint
2 parents 2509ce6 + 01b79ac commit 74a41b1

File tree

8 files changed

+316
-0
lines changed

8 files changed

+316
-0
lines changed

backend/documentation/openapi.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7487,6 +7487,65 @@
74877487
}
74887488
}
74897489
}
7490+
},
7491+
"/watershed/{id}/report/csv": {
7492+
"get": {
7493+
"tags": [
7494+
"Watershed"
7495+
],
7496+
"summary": "Export Watershed Report data as CSV files in a zip archive",
7497+
"parameters": [
7498+
{
7499+
"name": "id",
7500+
"in": "path",
7501+
"required": true,
7502+
"schema": {
7503+
"type": "integer",
7504+
"example": 7553818
7505+
},
7506+
"description": "Watershed Feature ID (WFI)"
7507+
},
7508+
{
7509+
"name": "sections",
7510+
"in": "query",
7511+
"required": false,
7512+
"schema": {
7513+
"type": "string",
7514+
"example": "annualHydrology,monthlyHydrology,climate"
7515+
},
7516+
"description": "Comma-separated list of sections to include in the export. If omitted, all available sections are included. Valid values: annualHydrology, monthlyHydrology, allocationsByIndustry, allocations, hydrologicVariability, climate, topography"
7517+
}
7518+
],
7519+
"responses": {
7520+
"200": {
7521+
"description": "A zip archive containing one CSV file per requested section. Files included depend on the sections parameter and data availability for the watershed.",
7522+
"content": {
7523+
"application/zip": {
7524+
"schema": {
7525+
"type": "string",
7526+
"format": "binary",
7527+
"description": "Zip archive containing any of the following CSV files: annual_hydrology.csv, monthly_hydrology.csv, allocations_by_industry.csv, allocations.csv, hydrologic_variability.csv, hydrologic_variability_candidate_distance_values.csv, hydrologic_variability_candidate_climate_data.csv, climate.csv, topography.csv"
7528+
}
7529+
}
7530+
}
7531+
},
7532+
"404": {
7533+
"description": "Watershed not found",
7534+
"content": {
7535+
"application/json": {
7536+
"schema": {
7537+
"type": "object",
7538+
"properties": {
7539+
"error": {
7540+
"type": "string"
7541+
}
7542+
}
7543+
}
7544+
}
7545+
}
7546+
}
7547+
}
7548+
}
74907549
}
74917550
}
74927551
}

backend/routers/watershed.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import os
1515
import zipfile
1616
from io import BytesIO
17+
import io as io
18+
import csv as csv
1719

1820
watershed = Blueprint('watershed', __name__)
1921

@@ -350,6 +352,219 @@ def get_watershed_report_by_id(id):
350352

351353
return response, 200
352354

355+
@watershed.route('/<int:id>/report/csv', methods=['GET'])
356+
def get_watershed_report_zip_by_id(id):
357+
sections_param = request.args.get('sections', '')
358+
requested_sections = set(s.strip() for s in sections_param.split(',') if s.strip()) if sections_param else set()
359+
360+
region_id = app.db.get_watershed_region_by_id(watershed_feature_id=id)['region_id']
361+
362+
watershed_metadata = app.db.get_watershed_report_by_id(watershed_feature_id=id, region_id=region_id)
363+
364+
if not watershed_metadata or not watershed_metadata.get("watershed_metadata"):
365+
return {"error": "Watershed not found"}, 404
366+
367+
zip_stream = BytesIO()
368+
months = ["January", "February", "March", "April", "May", "June",
369+
"July", "August", "September", "October", "November", "December"]
370+
371+
with zipfile.ZipFile(zip_stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
372+
373+
if not requested_sections or 'annualHydrology' in requested_sections:
374+
annual_hydrology = app.db.get_watershed_annual_hydrology_by_id(watershed_feature_id=id)
375+
if annual_hydrology["results"]:
376+
readability_map = {
377+
'area_km2': 'Area (km²)',
378+
'mad_m3s': 'Mean Annual Discharge (MAD, m³/s)',
379+
'allocs_m3s': 'Allocations (average, m³/s)',
380+
'allocs_pct': 'Allocations (average, % of MAD)',
381+
'rr': 'Reserves and Restrictions',
382+
'runoff_m3yr': 'Volume Runoff (m³/yr)',
383+
'allocs_m3yr': 'Volume Allocations (m³/yr)',
384+
'seasonal_sens': 'Seasonal Flow Sensitivity',
385+
}
386+
flattened_data = [
387+
{
388+
"Metric": readability_map[key],
389+
"Query Watershed Value": values.get("query"),
390+
"Downstream Watershed Value": values.get("downstream")
391+
}
392+
for key, values in annual_hydrology["results"].items()
393+
if isinstance(values, dict)
394+
]
395+
if flattened_data:
396+
zip_file.writestr("annual_hydrology.csv", pl.DataFrame(flattened_data).write_csv())
397+
398+
if not requested_sections or 'monthlyHydrology' in requested_sections:
399+
query_monthly = app.db.get_watershed_monthly_hydrology_by_id(
400+
watershed_feature_id=id, in_basin='query', region_id=region_id)
401+
downstream_monthly = app.db.get_watershed_monthly_hydrology_by_id(
402+
watershed_feature_id=id, in_basin='downstream', region_id=region_id)
403+
if query_monthly["results"] or downstream_monthly["results"]:
404+
query_results = query_monthly["results"]
405+
downstream_results = downstream_monthly["results"]
406+
flattened_data = [
407+
{
408+
"Month": month,
409+
"Metric": metric_name,
410+
"Query Watershed Value": query_results.get(qk, [])[i] if i < len(query_results.get(qk, [])) else None,
411+
"Downstream Watershed Value": downstream_results.get(dk, [])[i] if i < len(downstream_results.get(dk, [])) else None,
412+
}
413+
for i, month in enumerate(months)
414+
for metric_name, qk, dk in [
415+
("Existing Allocations (m³/s)", "ea_all", "ea_all"),
416+
("Monthly Discharge (m³/s)", "mad_m3s", "mad_m3s"),
417+
]
418+
]
419+
zip_file.writestr("monthly_hydrology.csv", pl.DataFrame(flattened_data).write_csv())
420+
421+
if not requested_sections or 'allocationsByIndustry' in requested_sections:
422+
industry_allocs = app.db.get_watershed_industry_allocations_by_id(watershed_feature_id=id)
423+
if industry_allocs["results"]:
424+
flattened_data = [
425+
{
426+
"Industry Type": industry,
427+
"Surface Water Licence (m³)": allocations.get("sw_long"),
428+
"Surface Water STUA (m³)": allocations.get("sw_short"),
429+
"Ground Water Licence (m³)": allocations.get("gw_long"),
430+
"Ground Water STUA (m³)": allocations.get("gw_short")
431+
}
432+
for industry, allocations in industry_allocs["results"].items()
433+
]
434+
zip_file.writestr("allocations_by_industry.csv", pl.DataFrame(flattened_data).write_csv())
435+
436+
if not requested_sections or 'allocations' in requested_sections:
437+
allocations = app.db.get_watershed_allocations_by_id(watershed_feature_id=id, in_basin='query')
438+
if allocations:
439+
zip_file.writestr("allocations.csv", pl.DataFrame(allocations, infer_schema_length=10000).write_csv())
440+
441+
if not requested_sections or 'hydrologicVariability' in requested_sections:
442+
if region_id in (5, 6):
443+
hydrologic_var = app.db.get_watershed_hydrologic_variability_by_id(watershed_feature_id=id)
444+
if hydrologic_var:
445+
rows = [
446+
{
447+
"Month": months[row["month"] - 1],
448+
"Candidate": f"Candidate {cand_num}",
449+
"Station": mv.get(f"c{cand_num}"),
450+
"10th Percentile (m³/s)": dict(zip(mv.get("percs", []), mv.get(f"q_m3s_c{cand_num}", []))).get(10),
451+
"25th Percentile (m³/s)": dict(zip(mv.get("percs", []), mv.get(f"q_m3s_c{cand_num}", []))).get(25),
452+
"50th Percentile (m³/s)": dict(zip(mv.get("percs", []), mv.get(f"q_m3s_c{cand_num}", []))).get(50),
453+
"75th Percentile (m³/s)": dict(zip(mv.get("percs", []), mv.get(f"q_m3s_c{cand_num}", []))).get(75),
454+
"90th Percentile (m³/s)": dict(zip(mv.get("percs", []), mv.get(f"q_m3s_c{cand_num}", []))).get(90),
455+
}
456+
for row in hydrologic_var
457+
for cand_num in range(1, 4)
458+
for mv in [row["month_value"]]
459+
if mv.get(f"c{cand_num}")
460+
]
461+
if rows:
462+
zip_file.writestr("hydrologic_variability.csv", pl.DataFrame(rows).write_csv())
463+
candidate_metadata = app.db.get_watershed_candidates_by_id(watershed_feature_id=id)
464+
if candidate_metadata:
465+
466+
distance_rows = [
467+
{
468+
"Candidate ID": row["candidate_id"],
469+
"Candidate Name": row["candidate_name"],
470+
"Area (km²)": row["candidate_area_km2"],
471+
"Month": months[month_idx],
472+
"Monthly Flow Ratio": row["candidate_month_value"].get(f"month{month_idx+1:02d}"),
473+
}
474+
for row in candidate_metadata
475+
for month_idx in range(12)
476+
]
477+
if distance_rows:
478+
zip_file.writestr(
479+
"hydrologic_variability_candidate_distance_values.csv",
480+
pl.DataFrame(distance_rows).write_csv()
481+
)
482+
483+
climate_rows = [
484+
{
485+
"Candidate ID": row["candidate_id"],
486+
"Candidate Name": row["candidate_name"],
487+
"Latitude": row["candidate_climate_data"].get("lat"),
488+
"Longitude": row["candidate_climate_data"].get("lon"),
489+
"Upstream Area (km²)": row["candidate_climate_data"].get("upstream_area_km2"),
490+
"Min Elevation (m)": row["candidate_climate_data"].get("min_elev"),
491+
"Avg Elevation (m)": row["candidate_climate_data"].get("avg_elev"),
492+
"Max Elevation (m)": row["candidate_climate_data"].get("max_elev"),
493+
"Month": months[month_idx],
494+
"Precipitation (mm)": row["candidate_climate_data"].get("ppt", [])[month_idx] if month_idx < len(row["candidate_climate_data"].get("ppt", [])) else None,
495+
"Mean Temperature (°C)": row["candidate_climate_data"].get("tave", [])[month_idx] if month_idx < len(row["candidate_climate_data"].get("tave", [])) else None,
496+
"Snow (mm)": row["candidate_climate_data"].get("pas", [])[month_idx] if month_idx < len(row["candidate_climate_data"].get("pas", [])) else None,
497+
}
498+
for row in candidate_metadata
499+
for month_idx in range(12)
500+
]
501+
if climate_rows:
502+
zip_file.writestr(
503+
"hydrologic_variability_candidate_climate_data.csv",
504+
pl.DataFrame(climate_rows).write_csv()
505+
)
506+
elif region_id == 4:
507+
hydrologic_var = app.db.get_kwt_hydrologic_variability_by_id(watershed_feature_id = id)
508+
if hydrologic_var:
509+
hv = hydrologic_var['hydrological_variability']
510+
return_periods = [6, 20, 50, 80]
511+
512+
rows = [
513+
{
514+
"Month": months[month_idx],
515+
"Return Period (years)": rp,
516+
"10th Percentile (m³/s)": hv.get(f"nc_p10_m{month_idx+1:02d}_{rp:02d}"),
517+
"25th Percentile (m³/s)": hv.get(f"nc_p25_m{month_idx+1:02d}_{rp:02d}"),
518+
"50th Percentile (m³/s)": hv.get(f"nc_p50_m{month_idx+1:02d}_{rp:02d}"),
519+
"75th Percentile (m³/s)": hv.get(f"nc_p75_m{month_idx+1:02d}_{rp:02d}"),
520+
"90th Percentile (m³/s)": hv.get(f"nc_p90_m{month_idx+1:02d}_{rp:02d}"),
521+
}
522+
for month_idx in range(12)
523+
for rp in return_periods
524+
]
525+
526+
if rows:
527+
zip_file.writestr("future_hydrologic_variability.csv", pl.DataFrame(rows).write_csv())
528+
529+
530+
if not requested_sections or 'climate' in requested_sections:
531+
climate_data = watershed_metadata.get("watershed_metadata", {})
532+
if climate_data:
533+
flattened_data = [
534+
{
535+
"Month": month,
536+
"Precipitation (mm) historical": climate_data.get("ppt_monthly_hist", [])[i] if i < len(climate_data.get("ppt_monthly_hist", [])) else None,
537+
"Precipitation (mm) future high": climate_data.get("ppt_monthly_future_max", [])[i] if i < len(climate_data.get("ppt_monthly_future_max", [])) else None,
538+
"Precipitation (mm) future low": climate_data.get("ppt_monthly_future_min", [])[i] if i < len(climate_data.get("ppt_monthly_future_min", [])) else None,
539+
"Temperature (°C) historical": climate_data.get("tave_monthly_hist", [])[i] if i < len(climate_data.get("tave_monthly_hist", [])) else None,
540+
"Temperature (°C) future high": climate_data.get("tave_monthly_future_max", [])[i] if i < len(climate_data.get("tave_monthly_future_max", [])) else None,
541+
"Temperature (°C) future low": climate_data.get("tave_monthly_future_min", [])[i] if i < len(climate_data.get("tave_monthly_future_min", [])) else None,
542+
"Snow (mm) historical": climate_data.get("pas_monthly_hist", [])[i] if i < len(climate_data.get("pas_monthly_hist", [])) else None,
543+
"Snow (mm) future high": climate_data.get("pas_monthly_future_max", [])[i] if i < len(climate_data.get("pas_monthly_future_max", [])) else None,
544+
"Snow (mm) future low": climate_data.get("pas_monthly_future_min", [])[i] if i < len(climate_data.get("pas_monthly_future_min", [])) else None,
545+
}
546+
for i, month in enumerate(months)
547+
]
548+
zip_file.writestr("climate.csv", pl.DataFrame(flattened_data).write_csv())
549+
550+
if not requested_sections or 'topography' in requested_sections:
551+
if region_id in (5, 6):
552+
df = pl.DataFrame({
553+
"Cumulative %": list(range(1, len(watershed_metadata.get("elevation_steep", [])) + 1)),
554+
"Elevation Steep (M)": watershed_metadata.get("elevation_steep"),
555+
"Elevation Flat (M)": watershed_metadata.get("elevation_flat"),
556+
})
557+
else:
558+
df = pl.DataFrame({
559+
"Cumulative %": list(range(1, len(watershed_metadata.get("elevs", [])) + 1)),
560+
"Elevation (M)": watershed_metadata.get("elevs"),
561+
})
562+
zip_file.writestr("topography.csv", df.write_csv())
563+
564+
zip_stream.seek(0)
565+
return send_file(zip_stream, mimetype="application/zip", as_attachment=True,
566+
download_name=f"watershed_{id}_report.zip")
567+
353568

354569
@watershed.route('/<int:id>/report/download_watershed/<string:format>', methods=['GET'])
355570
def get_watershed_polygon_as_file(id, format):
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

backend/tests/unit/routers/test_watershed_router.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import json
3+
from test_utils import assert_zips_equal
34

45
def test_get_watershed_by_lat_lng(client):
56
"""
@@ -116,6 +117,34 @@ def test_get_watershed_by_id(client):
116117
assert data['name'] == "unit_test"
117118

118119

120+
def test_get_watershed_report_zip_by_id(client):
121+
"""
122+
Unit Test of Watershed zip report_by_id Endpoint
123+
Going to do a test for each of the regions used (Kootenay, Cariboo, Omineca and Northwest)
124+
"""
125+
test_cases = [
126+
('KWT', 9253853),
127+
('Cariboo', 9191927),
128+
('Omineca', 10255303),
129+
('Northwest', 9196070),
130+
]
131+
132+
for region, wfi in test_cases:
133+
response = client.get(f'/watershed/{wfi}/report/csv')
134+
assert response.status_code == 200, f"[{region}] Expected 200, got {response.status_code}"
135+
136+
fixture_path = os.path.join(
137+
os.path.dirname(__file__),
138+
'../fixtures/watershed',
139+
f'watershed{wfi}ReportData.zip'
140+
)
141+
142+
with open(fixture_path, 'rb') as f:
143+
expected_data = f.read()
144+
145+
assert_zips_equal(response.data, expected_data)
146+
147+
119148
def test_get_watershed_station_report_by_id(client):
120149
"""
121150
Unit Test of Watershed report_by_id Endpoint

backend/tests/unit/test_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from pathlib import Path
22
import json
3+
import io
4+
import zipfile
35

46
def load_fixture(*subpath_parts: str) -> dict:
57
"""
@@ -11,3 +13,14 @@ def load_fixture(*subpath_parts: str) -> dict:
1113
fixture_path = base.joinpath(*subpath_parts).resolve()
1214
with fixture_path.open() as f:
1315
return json.load(f)
16+
17+
def assert_zips_equal(data1, data2):
18+
with zipfile.ZipFile(io.BytesIO(data1)) as z1, \
19+
zipfile.ZipFile(io.BytesIO(data2)) as z2:
20+
assert z1.namelist() == z2.namelist()
21+
for name in z1.namelist():
22+
info1 = z1.getinfo(name)
23+
info2 = z2.getinfo(name)
24+
assert info1.file_size == info2.file_size, f"File size mismatch in {name}"
25+
assert info1.compress_type == info2.compress_type, f"Compression type mismatch in {name}"
26+
assert z1.read(name) == z2.read(name), f"Content mismatch in {name}"

0 commit comments

Comments
 (0)