Skip to content

Commit 42424f7

Browse files
authored
Merge pull request #3 from cchdo/visualize
Add Map Maker
2 parents dd707e3 + 3860b8c commit 42424f7

File tree

9 files changed

+292
-1
lines changed

9 files changed

+292
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Right now the timeout is 5 minutes and fixed.
66
* fixed a bug where the wine retry decorator would retry forever
77
* Increase the cnv generation attempts from 3 to 5
8+
* Add HTML/leaflet based map output
89

910
## v2025.08.0 (2025-08-11)
1011
* Initial release.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"lxml>=5.4.0",
1616
"odf-sbe>=0.2.1",
1717
"rich>=14.0.0",
18+
"folium>=0.20.0",
1819
]
1920

2021
[project.scripts]

src/r2r_ctd/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from r2r_ctd.breakout import Breakout
2323
from r2r_ctd.docker_ctl import test_docker as _test_docker
24+
from r2r_ctd.maps import make_map
2425
from r2r_ctd.reporting import (
2526
ResultAggregator,
2627
write_xml_qa_report,
@@ -71,6 +72,7 @@ def qa(gen_cnvs: bool, paths: tuple[Path, ...]):
7172
station.r2r.write_con_report(breakout)
7273

7374
write_xml_qa_report(breakout, ra.certificate)
75+
make_map(ra)
7476

7577
# write the cnv files if asked
7678
if gen_cnvs:

src/r2r_ctd/accessors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ class R2RAccessor:
5959
def __init__(self, xarray_obj: xr.Dataset):
6060
self._obj = xarray_obj
6161

62+
@property
63+
def __geo_interface__(self):
64+
return {
65+
"type": "Point",
66+
"coordinates": (self.longitude, self.latitude),
67+
}
68+
69+
@property
70+
def name(self):
71+
"""Get the "name" of this station, basically the hex file name with the .hex removed"""
72+
return get_filename(self._obj.hex).removesuffix(".hex")
73+
6274
@cached_property
6375
def latitude(self) -> float | None:
6476
"""Simple wrapper around :py:func:`~r2r_ctd.derived.get_latitude`"""

src/r2r_ctd/breakout.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,46 @@ class BBox(NamedTuple):
3535
e: float
3636
n: float
3737

38+
@property
39+
def __geo_interface__(self):
40+
if self.w > self.e:
41+
return {
42+
"type": "MultiPolygon",
43+
"coordinates": (
44+
(
45+
(
46+
(self.w, self.s),
47+
(180, self.s),
48+
(180, self.n),
49+
(self.w, self.n),
50+
(self.w, self.s),
51+
),
52+
),
53+
(
54+
(
55+
(-180, self.s),
56+
(self.e, self.s),
57+
(self.e, self.n),
58+
(-180, self.n),
59+
(-180, self.s),
60+
),
61+
),
62+
),
63+
}
64+
65+
return {
66+
"type": "Polygon",
67+
"coordinates": (
68+
(
69+
(self.w, self.s),
70+
(self.e, self.s),
71+
(self.e, self.n),
72+
(self.w, self.n),
73+
(self.w, self.s),
74+
),
75+
),
76+
}
77+
3878
def contains(self, lon: float, lat: float) -> bool:
3979
"""given a lon/lat pair, determine if it is inside the bounding box represented by this instance"""
4080
if lat < self.s:

src/r2r_ctd/maps.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Functions for making maps to help QA work"""
2+
3+
from logging import getLogger
4+
5+
import folium
6+
7+
from r2r_ctd.reporting import ResultAggregator
8+
from r2r_ctd.state import get_map_path
9+
10+
logger = getLogger(__name__)
11+
12+
13+
def make_map(results: ResultAggregator):
14+
"""Write the results to a map using folium.
15+
16+
Useful for seeing what stations failed and what the tested bounding box was"""
17+
m = folium.Map()
18+
19+
breakout_fields = [
20+
"cruise_id",
21+
"fileset_id",
22+
"rating",
23+
"manifest_ok",
24+
"start_date",
25+
"end_date",
26+
"stations_not_on_map",
27+
]
28+
breakout_popup = folium.GeoJsonPopup(
29+
fields=breakout_fields,
30+
)
31+
breakout_tooltip = folium.GeoJsonTooltip(
32+
fields=breakout_fields,
33+
)
34+
35+
breakout_feature = results.geo_breakout_feature()
36+
37+
station_features = results.geo_station_feature()
38+
39+
if breakout_feature:
40+
folium.GeoJson(
41+
breakout_feature,
42+
popup=breakout_popup,
43+
tooltip=breakout_tooltip,
44+
style_function=lambda feature: {
45+
"fillColor": feature["properties"]["rating"],
46+
"weight": 0,
47+
},
48+
).add_to(m)
49+
50+
station_fields = [
51+
"name",
52+
"time",
53+
"all_three_files",
54+
"lon_lat_valid",
55+
"time_valid",
56+
"time_in",
57+
"lon_lat_in",
58+
"bottles_fired",
59+
]
60+
61+
if len(station_features["features"]) > 0:
62+
folium.GeoJson(
63+
station_features,
64+
marker=folium.Marker(icon=folium.Icon()),
65+
tooltip=folium.GeoJsonTooltip(fields=station_fields),
66+
popup=folium.GeoJsonPopup(fields=station_fields),
67+
style_function=lambda feature: {
68+
"markerColor": feature["properties"]["marker_color"]
69+
},
70+
).add_to(m)
71+
folium.FitOverlays().add_to(m)
72+
73+
map_path = get_map_path(results.breakout)
74+
m.save(map_path)
75+
logger.info(f"Wrote QA map to: {map_path}")

src/r2r_ctd/reporting.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,21 @@ def date_range(
141141
)
142142

143143

144+
def boolean_span_formatter(tf: bool) -> str:
145+
"""Format a boolean with html span element that colors green/red for true/false"""
146+
return f"<span style='color: {'green' if tf else 'red'}'>{tf}</span>"
147+
148+
149+
RATING_CSS_MAP = {
150+
"G": "green",
151+
"Y": "yellow",
152+
"R": "red",
153+
"X": "black",
154+
"N": "grey",
155+
}
156+
"""Mapping between the QA letter codes and css color name"""
157+
158+
144159
@dataclass
145160
class ResultAggregator:
146161
"""Dataclass which iterates though all the stations their tests and aggregates their results and generates the "info blocks".
@@ -151,6 +166,100 @@ class ResultAggregator:
151166

152167
breakout: Breakout
153168

169+
def geo_breakout_feature(self):
170+
"""If the breakout has a valid bounding box, generate the GeoJSON feature to plot on a map"""
171+
if self.breakout.bbox is None:
172+
return None
173+
174+
breakout_geometry = self.breakout.bbox.__geo_interface__
175+
date_start = (
176+
f"{self.breakout.temporal_bounds.dtstart:%Y-%m-%d}"
177+
if self.breakout.temporal_bounds is not None
178+
else ""
179+
)
180+
date_end = (
181+
f"{self.breakout.temporal_bounds.dtend:%Y-%m-%d}"
182+
if self.breakout.temporal_bounds is not None
183+
else ""
184+
)
185+
not_on_map = (
186+
f"<li>{station.r2r.name}</li>"
187+
for station in self.breakout
188+
if None in (station.r2r.longitude, station.r2r.latitude)
189+
)
190+
return {
191+
"type": "FeatureCollection",
192+
"features": [
193+
{
194+
"type": "Feature",
195+
"geometry": breakout_geometry,
196+
"properties": {
197+
"cruise_id": self.breakout.cruise_id,
198+
"fileset_id": self.breakout.fileset_id,
199+
"rating": RATING_CSS_MAP[self.rating],
200+
"manifest_ok": boolean_span_formatter(
201+
self.breakout.manifest_ok
202+
),
203+
"start_date": date_start,
204+
"end_date": date_end,
205+
"stations_not_on_map": f"<ul>{''.join(not_on_map) or '<li>All QAed stations on map</li>'}</ul>",
206+
},
207+
}
208+
],
209+
}
210+
211+
def geo_station_feature(self):
212+
"""Generate the GeoJSON feature collection with a feature for each station that has lon/lat coordinates to plot on a map"""
213+
features = []
214+
for station in self.breakout:
215+
if None in (station.r2r.longitude, station.r2r.latitude):
216+
continue
217+
station_geometry = station.r2r.__geo_interface__
218+
station_time = (
219+
f"{station.r2r.time:%Y-%m-%d %H:%M:%S}" if station.r2r.time else "None"
220+
)
221+
222+
marker_color = (
223+
"green"
224+
if (
225+
station.r2r.all_three_files
226+
and station.r2r.lon_lat_valid
227+
and station.r2r.time_valid
228+
and station.r2r.lon_lat_in(self.breakout.bbox)
229+
and station.r2r.time_in(self.breakout.temporal_bounds)
230+
)
231+
else "red"
232+
)
233+
234+
features.append(
235+
{
236+
"type": "Feature",
237+
"geometry": station_geometry,
238+
"properties": {
239+
"name": station.r2r.name,
240+
"time": station_time,
241+
"all_three_files": boolean_span_formatter(
242+
station.r2r.all_three_files
243+
),
244+
"lon_lat_valid": boolean_span_formatter(
245+
station.r2r.lon_lat_valid
246+
),
247+
"time_valid": boolean_span_formatter(station.r2r.time_valid),
248+
"lon_lat_in": boolean_span_formatter(
249+
station.r2r.lon_lat_in(self.breakout.bbox)
250+
),
251+
"time_in": boolean_span_formatter(
252+
station.r2r.time_in(self.breakout.temporal_bounds)
253+
),
254+
"bottles_fired": boolean_span_formatter(
255+
station.r2r.bottles_fired
256+
),
257+
"marker_color": marker_color,
258+
},
259+
}
260+
)
261+
return {"type": "FeatureCollection", "features": features}
262+
154263
@cached_property
155264
def presence_of_all_files(self) -> int:
156265
"""Iterate though the stations and count how many have :py:meth:`~r2r_ctd.accessors.R2RAccessor.all_three_files`"""

src/r2r_ctd/state.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ def get_product_path(breakout: "Breakout") -> Path:
120120
return product_dir
121121

122122

123+
def get_map_path(breakout: "Breakout") -> Path:
124+
"""Get the path to write the map file to, creating parent directories if necessary."""
125+
map_name = breakout.qa_template_path.name.replace(
126+
"_qa.2.0.xmlt",
127+
"_qa_map.html",
128+
)
129+
map_html = breakout.path / "proc" / map_name
130+
map_html.parent.mkdir(exist_ok=True, parents=True)
131+
132+
return map_html
133+
134+
123135
def get_filename(da: xr.DataArray) -> str:
124136
"""Gets the ``filename`` attribute of a DataArray object that represents a file.
125137

uv.lock

Lines changed: 40 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)