Coverage for waveqc/views.py: 0%

248 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-05-15 08:47 +0000

1from itertools import groupby 

2from typing import Any 

3 

4import numpy as np 

5import numpy.typing as npt 

6import pendulum 

7import plotly.graph_objects as go 

8from pendulum.date import Date 

9from plotly.subplots import make_subplots 

10from pyramid.httpexceptions import HTTPNotFound 

11from pyramid.request import Request 

12from pyramid.response import Response 

13from pyramid.view import notfound_view_config, view_config, view_defaults 

14from sqlalchemy import Row, extract, func, or_, select 

15from sqlalchemy.exc import NoResultFound 

16from sqlalchemy.sql.expression import ColumnElement 

17 

18from .models import ( 

19 RESULT_CHANNEL_CLOSED, 

20 RESULT_DECONVOLUTION_FAILS, 

21 RESULT_DECONVOLUTION_PASS, 

22 RESULT_NO_DATA, 

23 RESULT_NOT_READABLE, 

24 RESULT_PONDERATION, 

25 Channel, 

26 Check, 

27 Network, 

28 Operator, 

29 Station, 

30) 

31from .utils import ( 

32 HEATMAP_LAYOUT_COMPLETENESS, 

33 HEATMAP_LAYOUT_NETWORK, 

34 HEATMAP_LAYOUT_QUALITY, 

35 HEATMAP_LAYOUT_STATION, 

36) 

37 

38RESULT_COLORS = { 

39 RESULT_NO_DATA: "danger", 

40 RESULT_NOT_READABLE: "danger", 

41 RESULT_DECONVOLUTION_FAILS: "warning", 

42 RESULT_DECONVOLUTION_PASS: "success", 

43 RESULT_CHANNEL_CLOSED: "dark", 

44} 

45 

46 

47@notfound_view_config(append_slash=True, renderer="waveqc:templates/404.html") 

48def notfound(request: Request) -> dict[Any, Any]: 

49 request.response.status = 404 

50 return {} 

51 

52 

53@view_config(route_name="healthcheck") 

54def healthcheck(_request: Request) -> Response: 

55 return Response("OK") 

56 

57 

58@view_config(route_name="homepage", renderer="templates/homepage.html") 

59def homepage(request: Request) -> dict[str, Any]: 

60 networks = request.dbsession.scalars(select(Network)).all() 

61 stations = request.dbsession.scalars(select(Station).order_by(Station.code)).all() 

62 operators = request.dbsession.scalars( 

63 select(Operator) 

64 .join(Operator.stations) 

65 .join(Station.channels) 

66 .group_by(Operator.id) 

67 .where(Channel.checks.any()) 

68 .order_by(Operator.agency) 

69 ).all() 

70 channels = sorted( 

71 { 

72 channel[:2] 

73 for channel in request.dbsession.scalars( 

74 select(Channel.code).where(Channel.checks.any()).group_by(Channel.code) 

75 ).all() 

76 } 

77 ) 

78 return { 

79 "networks": networks, 

80 "stations": stations, 

81 "operators": operators, 

82 "channels": channels, 

83 } 

84 

85 

86@view_config(route_name="network_list", renderer="templates/network_list.html") 

87def network_list(request: Request) -> dict[str, Any]: 

88 networks = request.dbsession.scalars(select(Network)).all() 

89 return {"networks": networks} 

90 

91 

92@view_config(route_name="operator_list", renderer="templates/operator_list.html") 

93def operator_list(request: Request) -> dict[str, Any]: 

94 operators = request.dbsession.scalars( 

95 select(Operator) 

96 .join(Operator.stations) 

97 .join(Station.channels) 

98 .group_by(Operator.id) 

99 .where(Channel.checks.any()) 

100 .order_by(Operator.agency) 

101 ).all() 

102 return {"operators": operators} 

103 

104 

105@view_config(route_name="network_summary", renderer="templates/network_summary.html") 

106def network_summary(request: Request) -> dict[str, Any]: 

107 try: 

108 network = request.dbsession.scalars( 

109 select(Network).where(Network.code == request.matchdict["code"].upper()) 

110 ).one() 

111 except NoResultFound as exc: 

112 raise HTTPNotFound from exc 

113 

114 one_week_ago = pendulum.today("utc").subtract(days=7) 

115 query = request.dbsession.execute( 

116 select(Station, func.min(Check.result)) 

117 .join(Check.channel) 

118 .join(Channel.station) 

119 .join(Station.network) 

120 .where( 

121 Network.code == network.code, 

122 Check.date > one_week_ago, 

123 extract("year", Station.start_date) <= pendulum.today("utc").year, 

124 or_( 

125 Channel.end_date == None, # noqa: E711 

126 extract("year", Channel.end_date) == pendulum.today("utc").year, 

127 ), 

128 ) 

129 .group_by(Station.id) 

130 .order_by(Station.code) 

131 ).all() 

132 stations = [ 

133 { 

134 "code": station[0].code, 

135 "id": station[0].id, 

136 "result": RESULT_COLORS[station[1]], 

137 } 

138 for station in query 

139 if not station[0].triggered 

140 ] 

141 triggered_stations = [ 

142 { 

143 "code": station[0].code, 

144 "id": station[0].id, 

145 "result": RESULT_COLORS[station[1]], 

146 } 

147 for station in query 

148 if station[0].triggered 

149 ] 

150 return { 

151 "network": network, 

152 "stations": stations, 

153 "triggered_stations": triggered_stations, 

154 } 

155 

156 

157class HeatmapViews: 

158 _full_detail_fields = ( 

159 Channel.id, 

160 Channel.code, 

161 Check.date, 

162 Check.completeness, 

163 Check.result, 

164 Check.trace_count, 

165 Check.shortest_trace, 

166 Channel.location, 

167 Station.code, 

168 Network.code, 

169 ) 

170 _detail_fields = ( 

171 Station.code, 

172 Station.id, 

173 Check.date, 

174 func.min(Check.completeness), 

175 func.min(Check.result), 

176 func.max(Check.trace_count), 

177 func.min(Check.shortest_trace), 

178 Network.code, 

179 ) 

180 _full_detail_ordering = ( 

181 Station.code.desc(), 

182 Channel.location.desc(), 

183 Channel.code.desc(), 

184 Check.date, 

185 ) 

186 _detail_ordering = (Station.code.desc(), Check.date) 

187 

188 def __init__(self, request: Request) -> None: 

189 self.request = request 

190 self.view_name = "HeatmapViews" 

191 self.context: dict[str, Any] = {} 

192 

193 def period(self) -> Any: # noqa: ANN401 

194 if "year" in self.request.params: 

195 requested_year = int(self.request.params["year"]) 

196 period = extract("year", Check.date) == requested_year 

197 else: 

198 requested_year = pendulum.today("utc").year 

199 period = Check.date >= pendulum.today("utc").subtract(months=1) 

200 return ( 

201 period, 

202 extract("year", Station.start_date) <= requested_year, 

203 or_( 

204 Channel.end_date == None, # noqa: E711 

205 extract("year", Channel.end_date) == requested_year, 

206 ), 

207 ) 

208 

209 def triggered(self) -> tuple[ColumnElement[bool]] | tuple[()]: 

210 if "triggered" in self.request.params: 

211 triggered = bool(int(self.request.params["triggered"])) 

212 return (Station.triggered == triggered,) 

213 return () 

214 

215 def years_available(self, object_id: int) -> list[int]: 

216 start, end = self.request.dbsession.execute( 

217 select(func.min(Check.date), func.max(Check.date)) 

218 .join(Check.channel) 

219 .join(Channel.station) 

220 .join(Station.network) 

221 .where(Network.id == object_id) 

222 ).one() 

223 return list(range(start.year, end.year + 1)) 

224 

225 def build_x_values(self, query: list[Row[Any]]) -> list[Date]: 

226 # build x values (dates) 

227 return sorted({date for date, _ in groupby(query, lambda x: x[2])}) 

228 

229 def build_y_values_nslc(self, query: list[Row[Any]]) -> list[str]: 

230 # build y values (channels) 

231 return [ 

232 nslc for nslc, _ in groupby(query, lambda x: f"{x[9]}.{x[8]}.{x[7]}.{x[1]}") 

233 ] 

234 

235 def build_y_values_stations(self, query: list[Row[Any]]) -> list[str]: 

236 year = self.request.GET.get("year") 

237 # include year get parameter if years available 

238 parameters = f"?year={ year }" if year else "" 

239 # build y values (stations) 

240 return [ 

241 ( 

242 f"<a href='{ self.request.route_path('station_detail', id=pk) }" 

243 f"{parameters}' target='_self'>{network} {station}</a>" 

244 ) 

245 for (pk, station, network), _ in groupby( 

246 query, lambda x: (x[1], x[0], x[7]) 

247 ) 

248 ] 

249 

250 def build_z_values(self, query: list[Row[Any]]) -> npt.NDArray[np.int8]: 

251 # build z values (check results) 

252 # Warning : Here, we make the assumption that there is a check 

253 # for each channel each day 

254 

255 # completeness is stored in milliseconds 

256 # lets process it in percents 

257 time_to_percent_ratio = 100 / (86400 * 1000) 

258 return np.array( 

259 [ 

260 [ 

261 [ 

262 check[3] * time_to_percent_ratio, 

263 RESULT_PONDERATION[check[4]], 

264 check[5], 

265 check[6], 

266 ] 

267 for check in checks 

268 ] 

269 for _, checks in groupby(query, lambda x: x[0]) 

270 ] 

271 ) 

272 

273 @staticmethod 

274 def completeness_trace(mean: npt.NDArray[np.int8]) -> go.Heatmap: 

275 return go.Heatmap( 

276 y=[ 

277 (f"<span id='{index}'>{completeness}%</span>") 

278 for index, completeness in enumerate(mean) 

279 ], 

280 z=np.expand_dims(mean, axis=1), 

281 **HEATMAP_LAYOUT_COMPLETENESS, 

282 ) 

283 

284 def figure( 

285 self, results: npt.NDArray[np.int8], items: list[str], dates: list[Date] 

286 ) -> go.Figure: 

287 mean = np.round(np.mean(results[:, :, 0], axis=1), 2) 

288 # Height = items * 15 px + padding 

289 minimum_height = 400 

290 layout_height = max(minimum_height, len(items) * 15 + 180) 

291 

292 figure = make_subplots(specs=[[{"secondary_y": True}]]) 

293 figure.add_trace( 

294 go.Heatmap( 

295 x=dates, 

296 y=items, 

297 z=results[:, :, 0] + results[:, :, 1], 

298 customdata=results, 

299 **HEATMAP_LAYOUT_NETWORK, 

300 ), 

301 secondary_y=False, 

302 ) 

303 figure.add_trace(self.completeness_trace(mean), secondary_y=True) 

304 figure.update_layout( 

305 xaxis_side="top", 

306 height=layout_height, 

307 ) 

308 return figure.to_html(full_html=False, include_plotlyjs=False) 

309 

310 def subplots( 

311 self, results: npt.NDArray[np.int8], items: list[str], dates: list[Date] 

312 ) -> go.Figure: 

313 minimum_height = 450 

314 layout_height = max(minimum_height, len(items) * 60 + 200) 

315 

316 mean = np.round(np.mean(results[:, :, 0], axis=1), 2) 

317 figure = make_subplots( 

318 rows=2, 

319 cols=1, 

320 shared_xaxes=True, 

321 specs=[[{"secondary_y": True}], [{"secondary_y": False}]], 

322 ) 

323 figure.add_trace( 

324 go.Heatmap( 

325 x=dates, 

326 y=items, 

327 z=results[:, :, 0] + results[:, :, 1], 

328 customdata=results, 

329 **HEATMAP_LAYOUT_QUALITY, 

330 ), 

331 row=1, 

332 col=1, 

333 secondary_y=False, 

334 ) 

335 figure.add_trace(self.completeness_trace(mean), secondary_y=True) 

336 # We need to know the maximum number of traces for all checks 

337 zmax = max(np.max(results[:, :, 2]), 4) 

338 HEATMAP_LAYOUT_STATION.update({"zmax": zmax}) 

339 figure.add_trace( 

340 go.Heatmap(x=dates, y=items, z=results[:, :, 2], **HEATMAP_LAYOUT_STATION), 

341 row=2, 

342 col=1, 

343 secondary_y=False, 

344 ) 

345 figure.update_xaxes(side="top") 

346 figure.update_layout(height=layout_height) 

347 return figure.to_html(full_html=False, include_plotlyjs=False) 

348 

349 def context_figure( 

350 self, 

351 query: list[Row[Any]], 

352 items: list[str], 

353 *, 

354 subplots: bool = False, 

355 ) -> None: 

356 if not query: 

357 # if query is empty, do not try to draw figure 

358 figure = None 

359 elif subplots: 

360 figure = self.subplots( 

361 results=self.build_z_values(query), 

362 items=items, 

363 dates=self.build_x_values(query), 

364 ) 

365 else: 

366 figure = self.figure( 

367 results=self.build_z_values(query), 

368 items=items, 

369 dates=self.build_x_values(query), 

370 ) 

371 self.context.update({"figure": figure}) 

372 

373 

374@view_defaults(renderer="waveqc:templates/station_detail.html") 

375class StationViews(HeatmapViews): 

376 def years_available(self, object_id: int) -> list[int]: 

377 start, end = self.request.dbsession.execute( 

378 select(func.min(Check.date), func.max(Check.date)) 

379 .join(Check.channel) 

380 .join(Channel.station) 

381 .where(Station.id == object_id) 

382 ).one() 

383 return list(range(start.year, end.year + 1)) 

384 

385 def get_station(self) -> Response | tuple[int, str]: 

386 try: 

387 station_id = int(self.request.matchdict["id"]) 

388 station_code = self.request.dbsession.scalars( 

389 select(Station.code).where(Station.id == station_id) 

390 ).one() 

391 except (NoResultFound, ValueError) as exc: 

392 raise HTTPNotFound from exc 

393 return station_id, station_code 

394 

395 @view_config(route_name="station_detail") 

396 def station_detail(self) -> dict[str, Any]: 

397 pk, label = self.get_station() 

398 try: 

399 query = self.request.dbsession.execute( 

400 select(*self._full_detail_fields) 

401 .join(Check.channel) 

402 .join(Channel.station) 

403 .join(Station.network) 

404 .where(Station.id == pk, *self.period(), *self.triggered()) 

405 .order_by(*self._full_detail_ordering) 

406 ).all() 

407 except NoResultFound as exc: 

408 raise HTTPNotFound from exc 

409 

410 items = self.build_y_values_nslc(query) 

411 self.context_figure(query, items, subplots=True) 

412 self.context.update({"label": label}) 

413 self.context.update({"years_available": self.years_available(pk)}) 

414 return self.context 

415 

416 

417@view_defaults(renderer="waveqc:templates/network_detail.html") 

418class NetworkViews(HeatmapViews): 

419 def get_network(self) -> Response | tuple[int, str]: 

420 network_code = self.request.matchdict["code"].upper() 

421 try: 

422 network_id = self.request.dbsession.scalars( 

423 select(Network.id).where(Network.code == network_code) 

424 ).one() 

425 except NoResultFound as exc: 

426 raise HTTPNotFound from exc 

427 return network_id, network_code 

428 

429 @view_config(route_name="network_detail") 

430 def network_detail(self) -> dict[str, Any]: 

431 pk, label = self.get_network() 

432 query = self.request.dbsession.execute( 

433 select(*self._detail_fields) 

434 .join(Check.channel) 

435 .join(Channel.station) 

436 .join(Station.network) 

437 .where(Network.id == pk, *self.period(), *self.triggered()) 

438 .group_by(Check.date, Network.id, Station.id) 

439 .order_by(*self._detail_ordering) 

440 ).all() 

441 

442 items = self.build_y_values_stations(query) 

443 self.context_figure(query, items) 

444 self.context.update({"label": label}) 

445 self.context.update({"years_available": self.years_available(pk)}) 

446 return self.context 

447 

448 @view_config(route_name="network_full_detail") 

449 def network_full_detail(self) -> dict[str, Any]: 

450 pk, label = self.get_network() 

451 query = self.request.dbsession.execute( 

452 select(*self._full_detail_fields) 

453 .join(Check.channel) 

454 .join(Channel.station) 

455 .join(Station.network) 

456 .where(Network.id == pk, *self.period(), *self.triggered()) 

457 .order_by(*self._full_detail_ordering) 

458 ).all() 

459 

460 items = self.build_y_values_nslc(query) 

461 self.context_figure(query, items) 

462 self.context.update({"label": label}) 

463 self.context.update({"years_available": self.years_available(pk)}) 

464 return self.context 

465 

466 

467@view_defaults(renderer="waveqc:templates/operator_detail.html") 

468class OperatorViews(HeatmapViews): 

469 def get_operator(self) -> Response | tuple[int, str]: 

470 try: 

471 operator_id = int(self.request.matchdict["id"]) 

472 operator_agency = self.request.dbsession.scalars( 

473 select(Operator.agency).where(Operator.id == operator_id) 

474 ).one() 

475 except (NoResultFound, ValueError) as exc: 

476 raise HTTPNotFound from exc 

477 return operator_id, operator_agency 

478 

479 def years_available(self, object_id: int) -> list[int]: 

480 start, end = self.request.dbsession.execute( 

481 select(func.min(Check.date), func.max(Check.date)) 

482 .join(Check.channel) 

483 .join(Channel.station) 

484 .join(Station.operators) 

485 .where(Operator.id == object_id) 

486 ).one() 

487 return list(range(start.year, end.year + 1)) 

488 

489 @view_config(route_name="operator_detail") 

490 def operator_detail(self) -> dict[str, Any]: 

491 pk, label = self.get_operator() 

492 query = self.request.dbsession.execute( 

493 select(*self._detail_fields) 

494 .join(Check.channel) 

495 .join(Channel.station) 

496 .join(Station.network) 

497 .join(Station.operators) 

498 .where(Operator.id == pk, *self.period(), *self.triggered()) 

499 .group_by(Check.date, Network.id, Station.id) 

500 .order_by(Network.code.desc(), *self._detail_ordering) 

501 ).all() 

502 

503 items = self.build_y_values_stations(query) 

504 self.context_figure(query, items) 

505 self.context.update({"label": label}) 

506 self.context.update({"years_available": self.years_available(pk)}) 

507 return self.context 

508 

509 @view_config(route_name="operator_full_detail") 

510 def operator_full_detail(self) -> dict[str, Any]: 

511 pk, label = self.get_operator() 

512 query = self.request.dbsession.execute( 

513 select(*self._full_detail_fields) 

514 .join(Check.channel) 

515 .join(Channel.station) 

516 .join(Station.network) 

517 .join(Station.operators) 

518 .where(Operator.id == pk, *self.period(), *self.triggered()) 

519 .order_by(Network.code.desc(), *self._full_detail_ordering) 

520 ).all() 

521 

522 items = self.build_y_values_nslc(query) 

523 self.context_figure(query, items) 

524 self.context.update({"label": label}) 

525 self.context.update({"years_available": self.years_available(pk)}) 

526 return self.context 

527 

528 

529@view_defaults(renderer="waveqc:templates/channel_detail.html") 

530class ChannelViews(HeatmapViews): 

531 def __init__(self, request: Request) -> None: 

532 super().__init__(request) 

533 networks = request.dbsession.scalars(select(Network)).all() 

534 stations = request.dbsession.scalars( 

535 select(Station).order_by(Station.code) 

536 ).all() 

537 channels = sorted( 

538 { 

539 channel[:2] 

540 for channel in request.dbsession.scalars( 

541 select(Channel.code) 

542 .where(Channel.checks.any()) 

543 .group_by(Channel.code) 

544 ).all() 

545 } 

546 ) 

547 operators = request.dbsession.scalars( 

548 select(Operator) 

549 .join(Operator.stations) 

550 .join(Station.channels) 

551 .group_by(Operator.id) 

552 .where(Channel.checks.any()) 

553 .order_by(Operator.agency) 

554 ).all() 

555 self.context.update( 

556 { 

557 "networks": networks, 

558 "stations": stations, 

559 "channels": channels, 

560 "operators": operators, 

561 } 

562 ) 

563 

564 def years_available(self, *_: int) -> list[int]: 

565 filters = ( 

566 or_( 

567 *( 

568 Channel.code.istartswith(value) 

569 for value in self.request.GET.getall("channel") 

570 ) 

571 ), 

572 ) 

573 start, end = self.request.dbsession.execute( 

574 select(func.min(Check.date), func.max(Check.date)) 

575 .join(Check.channel) 

576 .where(*filters) 

577 ).one() 

578 return list(range(start.year, end.year + 1)) 

579 

580 def filters(self) -> Any: # noqa: ANN401 

581 filters = self.period() 

582 for key in set(self.request.GET.keys()): 

583 values = self.request.GET.getall(key) 

584 if values: 

585 match key: 

586 case "network": 

587 filters += (Network.code.in_(values),) 

588 case "station": 

589 filters += (Station.code.in_(values),) 

590 case "channel": 

591 filters += ( 

592 or_(*(Channel.code.istartswith(value) for value in values)), 

593 ) 

594 case "operator": 

595 filters += ( 

596 or_( 

597 *(Operator.agency.icontains(value) for value in values) 

598 ), 

599 ) 

600 return filters 

601 

602 def label(self) -> str: 

603 return " - ".join( 

604 [ 

605 f"{key.title()}: {value.upper()}" 

606 for key, value in self.request.GET.items() 

607 ] 

608 ) 

609 

610 @view_config(route_name="channel_detail") 

611 def channel_detail(self) -> dict[str, Any]: 

612 query = self.request.dbsession.execute( 

613 select(*self._detail_fields) 

614 .join(Check.channel) 

615 .join(Channel.station) 

616 .join(Station.network) 

617 .join(Station.operators) 

618 .where(*self.triggered(), *self.filters()) 

619 .group_by(Check.date, Network.id, Station.id) 

620 .order_by(Network.code.desc(), *self._detail_ordering) 

621 ).all() 

622 

623 items = self.build_y_values_stations(query) 

624 self.context_figure(query, items) 

625 self.context.update({"label": "Channels"}) 

626 self.context.update({"years_available": self.years_available()}) 

627 return self.context 

628 

629 @view_config(route_name="channel_full_detail") 

630 def channel_full_detail(self) -> dict[str, Any]: 

631 stmt = ( 

632 select(*self._full_detail_fields) 

633 .join(Check.channel) 

634 .join(Channel.station) 

635 .join(Station.network) 

636 ) 

637 if self.request.GET.get("operator"): 

638 stmt = stmt.join(Station.operators) 

639 query = self.request.dbsession.execute( 

640 stmt.where(*self.triggered(), *self.filters()).order_by( 

641 Network.code.desc(), *self._full_detail_ordering 

642 ) 

643 ).all() 

644 items = self.build_y_values_nslc(query) 

645 self.context_figure(query, items) 

646 self.context.update({"label": "Channels"}) 

647 self.context.update({"years_available": self.years_available()}) 

648 return self.context