Coverage for quality/views.py: 58%
133 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-26 15:42 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-26 15:42 +0000
1from itertools import groupby
2from typing import Any
4import numpy as np
5import numpy.typing as npt
6import plotly.graph_objects as go
7from django.core.exceptions import BadRequest
8from django.db.models import Max, Min
9from django.http import HttpRequest, HttpResponse
10from django.urls import reverse
11from django.views import View
12from django.views.generic import DetailView, ListView, TemplateView
13from django.views.generic.base import ContextMixin
14from pendulum.date import Date
15from plotly.subplots import make_subplots
17from quality.forms import FilterForm
19from .layouts import (
20 HEATMAP_LAYOUT_COMPLETENESS,
21 HEATMAP_LAYOUT_NETWORK,
22 HEATMAP_LAYOUT_QUALITY,
23 HEATMAP_LAYOUT_STATION,
24)
25from .models import (
26 RESULT_PONDERATION,
27 Channel,
28 Check,
29 Network,
30 Operator,
31 Station,
32)
33from .utils import Filter, get_rtserved_stations, get_station_last_rt_data
36class HealthCheck(View):
37 def get(self, _request: HttpRequest) -> HttpResponse:
38 return HttpResponse("OK")
41class CheckFormMixin(ContextMixin):
42 def get_context_data(self, **kwargs: object) -> dict[str, object]:
43 context = super().get_context_data(**kwargs)
44 extra = {
45 "networks": Network.objects.all(),
46 "stations": Station.objects.all().order_by("code"),
47 "operators": Operator.objects.checks_exist()
48 .only("agency")
49 .order_by("agency"),
50 "channels": Channel.objects.checks_exist().instruments(),
51 }
52 context.update(extra)
53 return context
56class HomePage(CheckFormMixin, TemplateView):
57 template_name = "quality/homepage.html"
60class OperatorList(ListView[Operator]):
61 model = Operator
62 ordering = ["agency"]
65class NetworkList(ListView[Network]):
66 model = Network
69class NetworkOverview(DetailView[Network]):
70 model = Network
71 template_name = "quality/network_overview.html"
73 def get_context_data(self, **kwargs: object) -> dict[str, object]:
74 context = super().get_context_data(**kwargs)
75 query = Station.objects.overview(network_id=self.object.pk)
76 context.update(
77 {
78 "stations": query.filter(triggered=False),
79 "triggered_stations": query.filter(triggered=True),
80 "rtserved": get_rtserved_stations(network=self.object.code),
81 }
82 )
83 return context
86class Heatmap(ContextMixin):
87 @staticmethod
88 def completeness_trace(mean: npt.NDArray[np.int8]) -> go.Heatmap:
89 return go.Heatmap(
90 y=[
91 (f"<span id='{index}'>{completeness}%</span>")
92 for index, completeness in enumerate(mean)
93 ],
94 z=np.expand_dims(mean, axis=1),
95 **HEATMAP_LAYOUT_COMPLETENESS,
96 )
98 def figure(
99 self,
100 results: npt.NDArray[np.int8],
101 items: list[str],
102 dates: list[Date],
103 ) -> go.Figure:
104 mean = np.round(np.mean(results[:, :, 0], axis=1), 2)
105 # Height = items * 15 px + padding
106 minimum_height = 400
107 layout_height = max(minimum_height, len(items) * 15 + 180)
109 figure = make_subplots(specs=[[{"secondary_y": True}]])
110 figure.add_trace(
111 go.Heatmap(
112 x=dates,
113 y=items,
114 z=results[:, :, 0] + results[:, :, 1],
115 customdata=results,
116 **HEATMAP_LAYOUT_NETWORK,
117 ),
118 secondary_y=False,
119 )
120 figure.add_trace(self.completeness_trace(mean), secondary_y=True)
121 figure.update_layout(
122 xaxis_side="top",
123 height=layout_height,
124 )
125 return figure.to_html(full_html=False, include_plotlyjs=False)
127 def get_summary(self, _filters: Filter) -> object:
128 msg = "Subclass must implement abstract method"
129 raise NotImplementedError(msg)
131 @staticmethod
132 def build_x_values(query: Any) -> list[Date]:
133 return sorted({key for key, _ in groupby(query, lambda x: x[2])})
135 def build_y_values(self, query: Any) -> list[str]:
136 return [
137 "<a href='{}?{}' target='_self'>{}.{}</a>".format(
138 reverse("quality:station-detail", kwargs={"pk": pk}),
139 self.request.GET.urlencode(), # type: ignore[attr-defined]
140 network,
141 station,
142 )
143 for (pk, station, network), _ in groupby(
144 query,
145 lambda x: (x[1], x[0], x[7]),
146 )
147 ]
149 @staticmethod
150 def build_z_values(query: Any) -> npt.NDArray[np.int8]:
151 # build z values (check results)
152 # Warning : Here, we make the assumption that there is a check
153 # for channel each day
154 time_to_percent_ratio = 100 / (86400 * 1000)
155 return np.array(
156 [
157 [
158 [
159 check[3] * time_to_percent_ratio,
160 RESULT_PONDERATION[check[4]],
161 check[5],
162 check[6],
163 ]
164 for check in checks
165 ]
166 for _, checks in groupby(query, lambda x: x[0])
167 ],
168 )
170 def years_available(self, _filters: Filter) -> list[int]:
171 msg = "Subclass must implement abstract method"
172 raise NotImplementedError(msg)
174 def get_context_data(self, **kwargs: object) -> dict[str, object]:
175 context = super().get_context_data(**kwargs)
176 form = FilterForm(self.request.GET) # type: ignore[attr-defined]
177 if not form.is_valid():
178 raise BadRequest(form.errors.as_data())
179 filters: Filter = form.cleaned_data # type: ignore[assignment]
180 query = self.get_summary(filters)
181 if query:
182 dates = self.build_x_values(query)
183 items = self.build_y_values(query)
184 results = self.build_z_values(query)
185 years_available = self.years_available(filters)
187 context.update(
188 {
189 "figure": self.figure(results=results, items=items, dates=dates),
190 "years_available": years_available,
191 },
192 )
193 return context
196class FullHeatmap(Heatmap):
197 @staticmethod
198 def build_y_values(query: Any) -> list[str]:
199 return [
200 key
201 for key, _ in groupby(
202 query,
203 lambda x: f"{x[9]}.{x[8]}.{x[7]}.{x[1]}",
204 )
205 ]
208class NetworkDetail(Heatmap, DetailView[Network]):
209 model = Network
211 def years_available(self, _filters: Filter) -> list[int]:
212 dates = Check.objects.filter(
213 channel__station__network_id=self.object.id,
214 ).aggregate(min=Min("date"), max=Max("date"))
215 return list(range(dates["min"].year, dates["max"].year + 1))
217 def get_summary(self, filters: Filter) -> object:
218 return (
219 Station.objects.filter(network_id=self.object.pk)
220 .channels(filters["channel"])
221 .year(filters["year"])
222 .triggered(filters["triggered"])
223 .summary()
224 )
227class NetworkFullDetail(FullHeatmap, NetworkDetail):
228 def get_summary(self, filters: Filter) -> object:
229 return (
230 Check.objects.filter(channel__station__network_id=self.object.pk)
231 .year(filters["year"])
232 .triggered(filters["triggered"])
233 .summary()
234 )
237class CheckOverview(Heatmap, CheckFormMixin, TemplateView):
238 template_name = "quality/check_overview.html"
240 def years_available(self, filters: Filter) -> list[int]:
241 dates = (
242 Check.objects.networks(filters["network"])
243 .stations(filters["station"])
244 .channels(filters["channel"])
245 .operators(filters["operator"])
246 .triggered(filters["triggered"])
247 .aggregate(min=Min("date"), max=Max("date"))
248 )
249 return list(range(dates["min"].year, dates["max"].year + 1))
251 def get_summary(self, filters: Filter) -> object:
252 return (
253 Station.objects.networks(filters["network"])
254 .stations(filters["station"])
255 .channels(filters["channel"])
256 .operators(filters["operator"])
257 .year(filters["year"])
258 .triggered(filters["triggered"])
259 .summary()
260 )
263class CheckFullOverview(FullHeatmap, CheckOverview):
264 def get_summary(self, filters: Filter) -> object:
265 return (
266 Check.objects.networks(filters["network"])
267 .stations(filters["station"])
268 .channels(filters["channel"])
269 .operators(filters["operator"])
270 .year(filters["year"])
271 .triggered(filters["triggered"])
272 .summary()
273 )
276class StationDetail(FullHeatmap, DetailView[Station]):
277 model = Station
278 queryset = Station.objects.all().select_related("network")
280 def figure(
281 self,
282 results: npt.NDArray[np.int8],
283 items: list[str],
284 dates: list[Date],
285 ) -> go.Figure:
286 minimum_height = 450
287 layout_height = max(minimum_height, len(items) * 60 + 200)
289 mean = np.round(np.mean(results[:, :, 0], axis=1), 2)
290 figure = make_subplots(
291 rows=2,
292 cols=1,
293 shared_xaxes=True,
294 specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
295 )
296 figure.add_trace(
297 go.Heatmap(
298 x=dates,
299 y=items,
300 z=results[:, :, 0] + results[:, :, 1],
301 customdata=results,
302 **HEATMAP_LAYOUT_QUALITY,
303 ),
304 row=1,
305 col=1,
306 secondary_y=False,
307 )
308 figure.add_trace(self.completeness_trace(mean), secondary_y=True)
309 # We need to know the maximum number of traces for all checks
310 zmax = max(np.max(results[:, :, 2]), 4) # type: ignore [call-overload]
311 HEATMAP_LAYOUT_STATION.update({"zmax": zmax})
312 figure.add_trace(
313 go.Heatmap(x=dates, y=items, z=results[:, :, 2], **HEATMAP_LAYOUT_STATION),
314 row=2,
315 col=1,
316 secondary_y=False,
317 )
318 figure.update_xaxes(side="top")
319 figure.update_layout(height=layout_height)
320 return figure.to_html(full_html=False, include_plotlyjs=False)
322 def years_available(self, _filters: Filter) -> list[int]:
323 dates = Check.objects.filter(
324 channel__station_id=self.object.id,
325 ).aggregate(min=Min("date"), max=Max("date"))
326 return list(range(dates["min"].year, dates["max"].year + 1))
328 def get_summary(self, filters: Filter) -> object:
329 return (
330 Check.objects.filter(channel__station_id=self.object.pk)
331 .year(filters["year"])
332 .summary()
333 )
335 def get_context_data(self, **kwargs: object) -> dict[str, object]:
336 context = super().get_context_data(**kwargs)
337 context["last_data"] = get_station_last_rt_data(
338 network=self.object.network.code, station=self.object.code
339 )
340 return context